1import * as fis from "@distilled.cloud/aws/fis";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface TargetAccountConfigurationProps {
10 /**
11 * The ID of the experiment template the target account belongs to. The
12 * template must use `accountTargeting: "multi-account"`. Changing it
13 * replaces the configuration.
14 */
15 experimentTemplateId: string;
16 /**
17 * The AWS account ID of the target account. Changing it replaces the
18 * configuration.
19 */
20 accountId: string;
21 /**
22 * The ARN of an IAM role in the target account that grants FIS permission
23 * to perform the experiment's actions there. The role must trust
24 * `fis.amazonaws.com`.
25 */
26 roleArn: string;
27 /**
28 * A description of the target account. Once set, it cannot be fully
29 * removed via the API — only changed.
30 */
31 description?: string;
32}
33
34export interface TargetAccountConfiguration extends Resource<
35 "AWS.FIS.TargetAccountConfiguration",
36 TargetAccountConfigurationProps,
37 {
38 /**
39 * The ID of the experiment template the target account belongs to.
40 */
41 experimentTemplateId: string;
42 /**
43 * The AWS account ID of the target account.
44 */
45 accountId: string;
46 /**
47 * The ARN of the IAM role FIS assumes in the target account.
48 */
49 roleArn: string;
50 },
51 never,
52 Providers
53> {}
54
55/**
56 * A target account configuration for a multi-account AWS Fault Injection
57 * Service (FIS) experiment template — registers an AWS account (and the IAM
58 * role FIS assumes there) as a target of the experiment, so a single
59 * experiment can inject faults into resources across accounts.
60 *
61 * The parent {@link ExperimentTemplate} must declare
62 * `experimentOptions: { accountTargeting: "multi-account" }`.
63 * ### Registering Target Accounts
64 * **Example:** Add a Target Account to a Multi-Account Template
65 * ```typescript
66 * import * as AWS from "alchemy/AWS";
67 *
68 * const template = yield* AWS.FIS.ExperimentTemplate("CrossAccount", {
69 * roleArn: orchestratorRole.roleArn,
70 * experimentOptions: { accountTargeting: "multi-account" },
71 * actions: {
72 * Wait: { actionId: "aws:fis:wait", parameters: { duration: "PT1M" } },
73 * },
74 * });
75 *
76 * const target = yield* AWS.FIS.TargetAccountConfiguration("WorkloadAccount", {
77 * experimentTemplateId: template.id,
78 * accountId: "111122223333",
79 * roleArn: "arn:aws:iam::111122223333:role/FisTargetRole",
80 * description: "the workload account faults are injected into",
81 * });
82 * ```
83 *
84 * @resource
85 */
86export const TargetAccountConfiguration = Resource<TargetAccountConfiguration>(
87 "AWS.FIS.TargetAccountConfiguration",
88);
89
90export const TargetAccountConfigurationProvider = () =>
91 Provider.effect(
92 TargetAccountConfiguration,
93 Effect.gen(function* () {
94 // A missing template and a missing configuration both surface as
95 // ResourceNotFoundException — either way the configuration does not
96 // exist.
97 const observe = (experimentTemplateId: string, accountId: string) =>
98 fis
99 .getTargetAccountConfiguration({ experimentTemplateId, accountId })
100 .pipe(
101 Effect.map((r) => r.targetAccountConfiguration),
102 Effect.catchTag("ResourceNotFoundException", () =>
103 Effect.succeed(undefined),
104 ),
105 );
106
107 const toAttrs = (
108 experimentTemplateId: string,
109 config: fis.TargetAccountConfiguration,
110 ) => ({
111 experimentTemplateId,
112 accountId: config.accountId!,
113 roleArn: config.roleArn!,
114 });
115
116 return TargetAccountConfiguration.Provider.of({
117 stables: ["experimentTemplateId", "accountId"],
118
119 // The (template, account) pair IS the configuration's identity —
120 // changing either side replaces it. roleArn/description sync in
121 // place.
122 diff: Effect.fn(function* ({ news, olds }) {
123 if (!isResolved(news)) return undefined;
124 if (
125 olds !== undefined &&
126 (olds.experimentTemplateId !== news.experimentTemplateId ||
127 olds.accountId !== news.accountId)
128 ) {
129 return { action: "replace" } as const;
130 }
131 }),
132
133 read: Effect.fn(function* ({ olds, output }) {
134 const experimentTemplateId =
135 output?.experimentTemplateId ?? olds?.experimentTemplateId;
136 const accountId = output?.accountId ?? olds?.accountId;
137 if (experimentTemplateId === undefined || accountId === undefined) {
138 return undefined;
139 }
140 const config = yield* observe(experimentTemplateId, accountId);
141 if (config === undefined) return undefined;
142 // Target account configurations cannot carry tags; existence under
143 // the parent template is the ownership signal.
144 return toAttrs(experimentTemplateId, config);
145 }),
146
147 reconcile: Effect.fn(function* ({ news, session }) {
148 // 1. Observe — the (template, account) pair is the deterministic
149 // identity; cloud state is authoritative.
150 let observed = yield* observe(
151 news.experimentTemplateId,
152 news.accountId,
153 );
154
155 if (observed === undefined) {
156 // 2. Ensure — create when missing; a concurrent create race
157 // surfaces as ConflictException and falls through to observe.
158 observed = yield* fis
159 .createTargetAccountConfiguration({
160 experimentTemplateId: news.experimentTemplateId,
161 accountId: news.accountId,
162 roleArn: news.roleArn,
163 description: news.description,
164 })
165 .pipe(
166 Effect.map((r) => r.targetAccountConfiguration),
167 Effect.catchTag("ConflictException", () =>
168 observe(news.experimentTemplateId, news.accountId),
169 ),
170 );
171 } else if (
172 // 3. Sync — diff observed against desired and apply the single
173 // update on any delta. `description` is settable-but-not-removable,
174 // so it only participates while declared.
175 observed.roleArn !== news.roleArn ||
176 (news.description !== undefined &&
177 observed.description !== news.description)
178 ) {
179 observed = yield* fis
180 .updateTargetAccountConfiguration({
181 experimentTemplateId: news.experimentTemplateId,
182 accountId: news.accountId,
183 roleArn: news.roleArn,
184 description: news.description,
185 })
186 .pipe(Effect.map((r) => r.targetAccountConfiguration));
187 }
188
189 yield* session.note(`${news.experimentTemplateId}/${news.accountId}`);
190 return toAttrs(news.experimentTemplateId, observed!);
191 }),
192
193 delete: Effect.fn(function* ({ output }) {
194 yield* fis
195 .deleteTargetAccountConfiguration({
196 experimentTemplateId: output.experimentTemplateId,
197 accountId: output.accountId,
198 })
199 .pipe(
200 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
201 );
202 }),
203
204 // Enumerate every template's target account configurations.
205 // Single-account templates simply have none.
206 list: () =>
207 Effect.gen(function* () {
208 const templates = yield* fis.listExperimentTemplates.items({}).pipe(
209 Stream.runCollect,
210 Effect.map((chunk) => Array.from(chunk)),
211 );
212 const results: TargetAccountConfiguration["Attributes"][] = [];
213 for (const template of templates) {
214 if (template.id === undefined) continue;
215 const configs = yield* fis.listTargetAccountConfigurations
216 .items({ experimentTemplateId: template.id })
217 .pipe(
218 Stream.runCollect,
219 Effect.map((chunk) => Array.from(chunk)),
220 // A template can vanish between enumeration and listing.
221 Effect.catchTag("ResourceNotFoundException", () =>
222 Effect.succeed([]),
223 ),
224 );
225 for (const config of configs) {
226 if (
227 config.accountId === undefined ||
228 config.roleArn === undefined
229 ) {
230 continue;
231 }
232 results.push({
233 experimentTemplateId: template.id,
234 accountId: config.accountId,
235 roleArn: config.roleArn,
236 });
237 }
238 }
239 return results;
240 }),
241 });
242 }),
243 );