1import * as Effect from "effect/Effect";
2import * as Binding from "../../Binding.ts";
3import * as Output from "../../Output.ts";
4import { isBindingHost } from "../Lambda/Function.ts";
5import type { ExperimentTemplate } from "./ExperimentTemplate.ts";
6
7/**
8 * Shared scaffolding for the AWS Fault Injection Service (FIS) HTTP bindings.
9 *
10 * NOT exported from `index.ts` — every thin `{Op}Http.ts` in this service is
11 * a `Layer.effect(Cap, make…HttpBinding({ … }))` over one of the two builders
12 * below. Everything except the operation, the IAM action list, and the
13 * injected identifier is boilerplate.
14 */
15
16/**
17 * Build the impl Effect for a FIS operation scoped to a bound
18 * {@link ExperimentTemplate}: the deploy-time half grants `actions` on the
19 * template's ARN (plus, when `grantExperimentWildcard` is set, the account's
20 * `experiment/*` ARNs — `fis:StartExperiment` authorizes against both the
21 * template being started and the experiment being created), and the runtime
22 * half injects the template's id into every request as `requestKey`.
23 *
24 * `clientToken` (an idempotency token) is auto-generated by distilled when
25 * absent, so it is omitted from the runtime request shape.
26 */
27export const makeFisTemplateHttpBinding = <I, A, E, R>(options: {
28 /** Fully-qualified binding tag, e.g. `AWS.FIS.StartExperiment`. */
29 tag: string;
30 /** The distilled operation. */
31 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
32 /** IAM actions granted on the experiment template ARN. */
33 actions: readonly string[];
34 /** The request field the bound template's id is injected as. */
35 requestKey: "id" | "experimentTemplateId";
36 /**
37 * Additionally grant `actions` on the account's `experiment/*` ARN —
38 * `fis:StartExperiment` is authorized against both the template and the
39 * experiment it creates.
40 */
41 grantExperimentWildcard?: boolean;
42 /**
43 * Additionally grant `iam:CreateServiceLinkedRole` scoped to FIS — the
44 * first `fis:StartExperiment` in an account creates the
45 * `AWSServiceRoleForFIS` service-linked role on the caller's behalf and is
46 * denied without it.
47 */
48 grantServiceLinkedRole?: boolean;
49}) =>
50 Effect.gen(function* () {
51 const op = yield* options.operation;
52
53 return Effect.fn(function* (template: ExperimentTemplate) {
54 const TemplateId = yield* template.id;
55 if (!globalThis.__ALCHEMY_RUNTIME__) {
56 const host = yield* Binding.Host;
57 if (isBindingHost(host)) {
58 yield* host.bind`Allow(${host}, ${options.tag}(${template}))`({
59 policyStatements: [
60 {
61 Effect: "Allow",
62 Action: [...options.actions],
63 Resource: [
64 Output.interpolate`${template.arn}`,
65 ...(options.grantExperimentWildcard
66 ? [
67 // arn:…:experiment-template/EXT… → arn:…:experiment/*
68 template.arn.pipe(
69 Output.map(
70 (arn) =>
71 `${arn.slice(0, arn.lastIndexOf(":"))}:experiment/*`,
72 ),
73 ),
74 ]
75 : []),
76 ],
77 },
78 ...(options.grantServiceLinkedRole
79 ? [
80 {
81 Effect: "Allow" as const,
82 Action: ["iam:CreateServiceLinkedRole"],
83 Resource: [
84 "arn:aws:iam::*:role/aws-service-role/fis.amazonaws.com/AWSServiceRoleForFIS",
85 ],
86 Condition: {
87 StringEquals: {
88 "iam:AWSServiceName": "fis.amazonaws.com",
89 },
90 },
91 },
92 ]
93 : []),
94 ],
95 });
96 }
97 }
98 return Effect.fn(`${options.tag}(${template.LogicalId})`)(function* (
99 request?: Omit<I, "id" | "experimentTemplateId" | "clientToken">,
100 ) {
101 return yield* op({
102 ...request,
103 [options.requestKey]: yield* TemplateId,
104 } as I);
105 });
106 });
107 });
108
109/**
110 * Build the impl Effect for an account-level FIS operation (experiment
111 * control and reads, the action/target-resource-type catalogs, the safety
112 * lever). The deploy-time half grants `actions` on `*` — experiments are
113 * created dynamically at runtime and the catalog/list actions are not scoped
114 * to a single resource.
115 */
116export const makeFisAccountHttpBinding = <I, A, E, R>(options: {
117 /** Fully-qualified binding tag, e.g. `AWS.FIS.GetExperiment`. */
118 tag: string;
119 /** The distilled operation, invoked with the caller's request as-is. */
120 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
121 /** IAM actions granted on `*`. */
122 actions: readonly string[];
123}) =>
124 Effect.gen(function* () {
125 const op = yield* options.operation;
126
127 return Effect.fn(function* () {
128 if (!globalThis.__ALCHEMY_RUNTIME__) {
129 const host = yield* Binding.Host;
130 if (isBindingHost(host)) {
131 yield* host.bind`Allow(${host}, ${options.tag}())`({
132 policyStatements: [
133 {
134 Effect: "Allow",
135 Action: [...options.actions],
136 Resource: ["*"],
137 },
138 ],
139 });
140 }
141 }
142 return Effect.fn(options.tag)(function* (request?: I) {
143 return yield* op((request ?? {}) as I);
144 });
145 });
146 });