1import * as fis from "@distilled.cloud/aws/fis";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import * as crypto from "node:crypto";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import type { Input } from "../../Input.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import {
13 createInternalTags,
14 diffTags,
15 hasAlchemyTags,
16 tagRecord,
17} from "../../Tags.ts";
18import { toSeconds } from "../../Util/Duration.ts";
19import type { Providers } from "../Providers.ts";
20
21/**
22 * A filter that narrows the resources selected by an experiment template
23 * target using resource attribute paths.
24 */
25export interface ExperimentTemplateTargetFilter {
26 /**
27 * The attribute path used to filter, e.g. `State.Name` for EC2 instances.
28 */
29 path: string;
30 /**
31 * The attribute values that a resource must match to be included.
32 */
33 values: string[];
34}
35
36/**
37 * A target definition for an experiment template — the set of resources an
38 * action is applied to.
39 */
40export interface ExperimentTemplateTarget {
41 /**
42 * The resource type of the target, e.g. `aws:ec2:instance`.
43 */
44 resourceType: string;
45 /**
46 * ARNs of specific resources to target. Mutually exclusive with
47 * `resourceTags`.
48 */
49 resourceArns?: string[];
50 /**
51 * Tags that resources must have to be targeted. Mutually exclusive with
52 * `resourceArns`.
53 */
54 resourceTags?: Record<string, string>;
55 /**
56 * Filters that further narrow the targeted resources by attribute path,
57 * e.g. only running instances.
58 */
59 filters?: ExperimentTemplateTargetFilter[];
60 /**
61 * How targets are selected from the matched resources, e.g. `ALL`,
62 * `COUNT(1)`, or `PERCENT(25)`.
63 */
64 selectionMode: string;
65 /**
66 * Resource-type-specific parameters for the target.
67 */
68 parameters?: Record<string, string>;
69}
70
71/**
72 * An action carried out on targets during an experiment, e.g.
73 * `aws:ec2:stop-instances`.
74 */
75export interface ExperimentTemplateAction {
76 /**
77 * The FIS action ID, e.g. `aws:ec2:stop-instances` or
78 * `aws:fis:wait`.
79 */
80 actionId: string;
81 /**
82 * A description of the action.
83 */
84 description?: string;
85 /**
86 * Action-specific parameters, e.g.
87 * `{ startInstancesAfterDuration: "PT2M" }`.
88 */
89 parameters?: Record<string, string>;
90 /**
91 * The named targets (keys of the template's `targets` map) the action
92 * applies to, keyed by the action's target name, e.g.
93 * `{ Instances: "MyTarget" }`.
94 */
95 targets?: Record<string, string>;
96 /**
97 * Names of other actions that must complete before this action starts.
98 */
99 startAfter?: string[];
100}
101
102/**
103 * A stop condition for an experiment. When triggered while an experiment is
104 * running, the experiment is stopped.
105 */
106export interface ExperimentTemplateStopCondition {
107 /**
108 * The source of the stop condition: `aws:cloudwatch:alarm` or `none`.
109 */
110 source: "aws:cloudwatch:alarm" | "none" | (string & {});
111 /**
112 * The ARN of the CloudWatch alarm. Required when `source` is
113 * `aws:cloudwatch:alarm`.
114 */
115 value?: string;
116}
117
118/**
119 * Experiment log delivery configuration.
120 */
121export interface ExperimentTemplateLogConfiguration {
122 /**
123 * Deliver experiment logs to a CloudWatch Logs log group.
124 */
125 cloudWatchLogsConfiguration?: {
126 /**
127 * The ARN of the destination log group.
128 */
129 logGroupArn: string;
130 };
131 /**
132 * Deliver experiment logs to an S3 bucket.
133 */
134 s3Configuration?: {
135 /**
136 * The name of the destination bucket.
137 */
138 bucketName: string;
139 /**
140 * The bucket prefix for delivered log objects.
141 */
142 prefix?: string;
143 };
144 /**
145 * The schema version of delivered log records.
146 * @default 2
147 */
148 logSchemaVersion?: number;
149}
150
151/**
152 * Experiment options controlling account targeting and empty-target
153 * behavior.
154 */
155export interface ExperimentTemplateExperimentOptions {
156 /**
157 * Whether the experiment targets resources in the current account only or
158 * across multiple accounts. Changing this replaces the template.
159 * @default "single-account"
160 */
161 accountTargeting?: "single-account" | "multi-account";
162 /**
163 * What happens when a target resolves to zero resources: `fail` the
164 * experiment or `skip` the action.
165 * @default "fail"
166 */
167 emptyTargetResolutionMode?: "fail" | "skip";
168}
169
170/**
171 * Experiment report configuration.
172 */
173export interface ExperimentTemplateReportConfiguration {
174 /**
175 * Where the experiment report is delivered.
176 */
177 outputs?: {
178 /**
179 * Deliver the report to an S3 bucket.
180 */
181 s3Configuration?: {
182 /**
183 * The name of the destination bucket.
184 */
185 bucketName?: string;
186 /**
187 * The bucket prefix for the delivered report.
188 */
189 prefix?: string;
190 };
191 };
192 /**
193 * Data sources included in the report.
194 */
195 dataSources?: {
196 /**
197 * CloudWatch dashboards to capture in the report.
198 */
199 cloudWatchDashboards?: {
200 /**
201 * The ARN of the CloudWatch dashboard.
202 */
203 dashboardIdentifier?: string;
204 }[];
205 };
206 /**
207 * How long before the experiment to capture data, e.g. `"10 minutes"` or
208 * `Duration.minutes(10)`. Sent to FIS as an ISO-8601 duration (`PT10M`).
209 */
210 preExperimentDuration?: Duration.Input;
211 /**
212 * How long after the experiment to capture data, e.g. `"10 minutes"` or
213 * `Duration.minutes(10)`. Sent to FIS as an ISO-8601 duration (`PT10M`).
214 */
215 postExperimentDuration?: Duration.Input;
216}
217
218export interface ExperimentTemplateProps {
219 /**
220 * A description of the experiment template.
221 * @default the logical ID of the resource
222 */
223 description?: string;
224 /**
225 * The ARN of the IAM role that grants FIS permission to perform the
226 * template's actions on your behalf. The role must trust
227 * `fis.amazonaws.com`.
228 */
229 roleArn: string;
230 /**
231 * The actions carried out during an experiment, keyed by a name of your
232 * choosing.
233 */
234 actions: Record<string, ExperimentTemplateAction>;
235 /**
236 * The targets the actions apply to, keyed by a name of your choosing and
237 * referenced from each action's `targets` map.
238 */
239 targets?: Record<string, ExperimentTemplateTarget>;
240 /**
241 * Stop conditions that halt a running experiment when triggered.
242 * @default [{ source: "none" }]
243 */
244 stopConditions?: ExperimentTemplateStopCondition[];
245 /**
246 * Experiment log delivery configuration. Once configured, log delivery
247 * cannot be fully removed via the API — only changed.
248 */
249 logConfiguration?: ExperimentTemplateLogConfiguration;
250 /**
251 * Experiment options. `accountTargeting` is create-only — changing it
252 * replaces the template.
253 */
254 experimentOptions?: ExperimentTemplateExperimentOptions;
255 /**
256 * Experiment report configuration.
257 */
258 experimentReportConfiguration?: ExperimentTemplateReportConfiguration;
259 /**
260 * Tags to apply to the experiment template. Merged with internal Alchemy
261 * tags.
262 */
263 tags?: Record<string, string>;
264}
265
266export interface ExperimentTemplate extends Resource<
267 "AWS.FIS.ExperimentTemplate",
268 ExperimentTemplateProps,
269 {
270 /**
271 * The generated ID of the experiment template, e.g. `EXT1a2b3c4d`.
272 */
273 id: string;
274 /**
275 * The ARN of the experiment template.
276 */
277 arn: string;
278 /**
279 * The ARN of the IAM role the experiment runs as.
280 */
281 roleArn: string;
282 },
283 never,
284 Providers
285> {}
286
287/**
288 * An AWS Fault Injection Service (FIS) experiment template — a reusable
289 * definition of a chaos-engineering experiment: the targets to disrupt, the
290 * fault actions to run against them, and the stop conditions that abort a
291 * runaway experiment.
292 *
293 * Creating a template is free and does not disrupt any resources — faults
294 * are only injected when an experiment is explicitly started from the
295 * template.
296 * ### Creating Experiment Templates
297 * **Example:** Stop EC2 instances selected by tag
298 * ```typescript
299 * import * as AWS from "alchemy/AWS";
300 *
301 * const role = yield* AWS.IAM.Role("FisRole", {
302 * assumeRolePolicyDocument: {
303 * Version: "2012-10-17",
304 * Statement: [{
305 * Effect: "Allow",
306 * Principal: { Service: "fis.amazonaws.com" },
307 * Action: ["sts:AssumeRole"],
308 * }],
309 * },
310 * managedPolicyArns: [
311 * "arn:aws:iam::aws:policy/service-role/AWSFaultInjectionSimulatorEC2Access",
312 * ],
313 * });
314 *
315 * const template = yield* AWS.FIS.ExperimentTemplate("StopInstances", {
316 * description: "Stop one tagged instance for two minutes",
317 * roleArn: role.roleArn,
318 * targets: {
319 * Instances: {
320 * resourceType: "aws:ec2:instance",
321 * resourceTags: { ChaosReady: "true" },
322 * selectionMode: "COUNT(1)",
323 * },
324 * },
325 * actions: {
326 * StopInstances: {
327 * actionId: "aws:ec2:stop-instances",
328 * parameters: { startInstancesAfterDuration: "PT2M" },
329 * targets: { Instances: "Instances" },
330 * },
331 * },
332 * });
333 * ```
334 *
335 * **Example:** Stop condition backed by a CloudWatch alarm
336 * ```typescript
337 * const template = yield* AWS.FIS.ExperimentTemplate("GuardedExperiment", {
338 * roleArn: role.roleArn,
339 * targets: {
340 * Instances: {
341 * resourceType: "aws:ec2:instance",
342 * resourceTags: { ChaosReady: "true" },
343 * selectionMode: "ALL",
344 * },
345 * },
346 * actions: {
347 * StopInstances: {
348 * actionId: "aws:ec2:stop-instances",
349 * targets: { Instances: "Instances" },
350 * },
351 * },
352 * stopConditions: [
353 * {
354 * source: "aws:cloudwatch:alarm",
355 * value: alarmArn,
356 * },
357 * ],
358 * });
359 * ```
360 *
361 * **Example:** Wait action sequenced after a fault
362 * ```typescript
363 * const template = yield* AWS.FIS.ExperimentTemplate("SequencedExperiment", {
364 * roleArn: role.roleArn,
365 * actions: {
366 * Wait: {
367 * actionId: "aws:fis:wait",
368 * parameters: { duration: "PT1M" },
369 * },
370 * WaitAgain: {
371 * actionId: "aws:fis:wait",
372 * parameters: { duration: "PT1M" },
373 * startAfter: ["Wait"],
374 * },
375 * },
376 * });
377 * ```
378 *
379 * @resource
380 */
381export const ExperimentTemplate = Resource<ExperimentTemplate>(
382 "AWS.FIS.ExperimentTemplate",
383);
384
385/**
386 * A freshly created IAM role referenced by `roleArn` can take a few seconds
387 * to become visible to FIS, which rejects the create/update with a
388 * `ValidationException` until the role propagates. Bounded retry through the
389 * propagation window. Explicitly typed so the conditional `Retry.Return`
390 * type never leaks into declaration emit.
391 */
392const retryRolePropagation = <A, E extends { _tag: string }, R>(
393 self: Effect.Effect<A, E, R>,
394): Effect.Effect<A, E, R> =>
395 Effect.retry(self, {
396 while: (e) => e._tag === "ValidationException",
397 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
398 });
399
400/**
401 * Deterministic stringification for observed-vs-desired comparison: sorts
402 * object keys and drops `undefined` members, empty arrays, and empty objects
403 * so wire-level "absent" and "empty" compare equal.
404 */
405const normalize = (value: unknown): unknown => {
406 if (Array.isArray(value)) {
407 const items = value.map(normalize).filter((v) => v !== undefined);
408 return items.length === 0 ? undefined : items;
409 }
410 if (typeof value === "object" && value !== null) {
411 const entries = Object.entries(value)
412 .map(([k, v]) => [k, normalize(v)] as const)
413 .filter(([, v]) => v !== undefined)
414 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
415 return entries.length === 0 ? undefined : Object.fromEntries(entries);
416 }
417 return value;
418};
419
420const stableStringify = (value: unknown): string =>
421 JSON.stringify(normalize(value) ?? null);
422
423export const ExperimentTemplateProvider = () =>
424 Provider.effect(
425 ExperimentTemplate,
426 Effect.gen(function* () {
427 const getTemplate = (templateId: string) =>
428 fis.getExperimentTemplate({ id: templateId }).pipe(
429 Effect.map((r) => r.experimentTemplate),
430 Effect.catchTag("ResourceNotFoundException", () =>
431 Effect.succeed(undefined),
432 ),
433 );
434
435 // FIS template IDs are server-generated, so when the cached output is
436 // lost (state persistence failure) we recover our template by the
437 // internal Alchemy tags carried on the list summaries. The match is
438 // scoped to the INSTANCE id (not just stack/stage/logical id): during a
439 // replacement the new instance must not "recover" the old physical
440 // template — it has to create a fresh one.
441 const findByTags = Effect.fn(function* (id: string, instanceId: string) {
442 const summaries = yield* fis.listExperimentTemplates.items({}).pipe(
443 Stream.runCollect,
444 Effect.map((chunk) => Array.from(chunk)),
445 );
446 for (const summary of summaries) {
447 if (
448 summary.id !== undefined &&
449 tagRecord(summary.tags)["alchemy::instance"] === instanceId &&
450 (yield* hasAlchemyTags(id, summary.tags))
451 ) {
452 return yield* getTemplate(summary.id);
453 }
454 }
455 return undefined;
456 });
457
458 const toAttrs = (template: fis.ExperimentTemplate) => ({
459 id: template.id!,
460 arn: template.arn!,
461 roleArn: template.roleArn!,
462 });
463
464 // Report-capture durations are `Duration.Input`s; the FIS wire format
465 // is an ISO-8601 duration string (e.g. `PT10M`). Normalize through the
466 // central Duration util (handles state-persisted Duration JSON), then
467 // emit hours when the duration is whole hours, else minutes, else
468 // seconds, matching the canonical forms FIS reports back.
469 const toIsoDuration = (
470 input: Duration.Input | undefined,
471 ): string | undefined => {
472 if (input === undefined) return undefined;
473 const seconds = toSeconds(input)!;
474 if (seconds > 0 && seconds % 3600 === 0) return `PT${seconds / 3600}H`;
475 if (seconds % 60 === 0) return `PT${seconds / 60}M`;
476 return `PT${seconds}S`;
477 };
478
479 // Project the report configuration into the wire shape (ISO-8601
480 // duration strings).
481 const projectReportConfiguration = (
482 config: ExperimentTemplateReportConfiguration | undefined,
483 ) =>
484 config
485 ? {
486 ...config,
487 preExperimentDuration: toIsoDuration(
488 config.preExperimentDuration,
489 ),
490 postExperimentDuration: toIsoDuration(
491 config.postExperimentDuration,
492 ),
493 }
494 : undefined;
495
496 // The desired mutable state, projected into the wire shape that
497 // `getExperimentTemplate` reports so observed-vs-desired comparison is
498 // structural.
499 const projectDesired = (id: string, props: ExperimentTemplateProps) => ({
500 description: props.description ?? id,
501 roleArn: props.roleArn,
502 stopConditions: props.stopConditions ?? [{ source: "none" }],
503 targets: props.targets ?? {},
504 actions: props.actions,
505 // Settable-but-not-removable aspects only participate in the
506 // comparison while the user declares them, so dropping the prop
507 // doesn't cause a perpetual (and futile) update call.
508 logConfiguration: props.logConfiguration
509 ? {
510 ...props.logConfiguration,
511 logSchemaVersion: props.logConfiguration.logSchemaVersion ?? 2,
512 }
513 : undefined,
514 emptyTargetResolutionMode:
515 props.experimentOptions?.emptyTargetResolutionMode,
516 experimentReportConfiguration: projectReportConfiguration(
517 props.experimentReportConfiguration,
518 ),
519 });
520
521 const projectObserved = (
522 template: fis.ExperimentTemplate,
523 props: ExperimentTemplateProps,
524 ) => ({
525 description: template.description,
526 roleArn: template.roleArn,
527 stopConditions: template.stopConditions ?? [],
528 targets: template.targets ?? {},
529 actions: template.actions ?? {},
530 logConfiguration: props.logConfiguration
531 ? template.logConfiguration
532 : undefined,
533 emptyTargetResolutionMode: props.experimentOptions
534 ?.emptyTargetResolutionMode
535 ? template.experimentOptions?.emptyTargetResolutionMode
536 : undefined,
537 experimentReportConfiguration: props.experimentReportConfiguration
538 ? template.experimentReportConfiguration
539 : undefined,
540 });
541
542 return ExperimentTemplate.Provider.of({
543 stables: ["id", "arn"],
544
545 // Enumerate every experiment template in the ambient account/region,
546 // hydrating each summary into the full attribute shape. A template
547 // can vanish between enumeration and hydration; drop it.
548 list: () =>
549 Effect.gen(function* () {
550 const summaries = yield* fis.listExperimentTemplates.items({}).pipe(
551 Stream.runCollect,
552 Effect.map((chunk) => Array.from(chunk)),
553 );
554 const items = yield* Effect.forEach(
555 summaries,
556 (summary) =>
557 summary.id === undefined
558 ? Effect.succeed(undefined)
559 : getTemplate(summary.id).pipe(
560 Effect.map((t) => (t ? toAttrs(t) : undefined)),
561 ),
562 { concurrency: 10 },
563 );
564 return items.filter(
565 (item): item is ExperimentTemplate["Attributes"] =>
566 item !== undefined,
567 );
568 }),
569
570 read: Effect.fn(function* ({ id, instanceId, output }) {
571 const template = output?.id
572 ? yield* getTemplate(output.id)
573 : yield* findByTags(id, instanceId);
574 if (template === undefined) return undefined;
575 const attrs = toAttrs(template);
576 return (yield* hasAlchemyTags(id, template.tags))
577 ? attrs
578 : Unowned(attrs);
579 }),
580
581 diff: Effect.fn(function* ({ news, olds }) {
582 // accountTargeting is create-only — changing it replaces. Other
583 // props (commonly roleArn) are unresolved Outputs during plan, so
584 // narrow only the field replacement detection needs rather than
585 // requiring the whole props object to be resolved.
586 const newOptions = (
587 news as {
588 experimentOptions?: Input<ExperimentTemplateExperimentOptions>;
589 }
590 ).experimentOptions;
591 if (newOptions !== undefined && !isResolved(newOptions)) {
592 return undefined;
593 }
594 const oldTargeting =
595 olds.experimentOptions?.accountTargeting ?? "single-account";
596 const newTargeting = newOptions?.accountTargeting ?? "single-account";
597 if (oldTargeting !== newTargeting) {
598 return { action: "replace" } as const;
599 }
600 // fall through: undefined → default update
601 }),
602
603 reconcile: Effect.fn(function* ({
604 id,
605 instanceId,
606 news,
607 output,
608 session,
609 }) {
610 const internalTags = yield* createInternalTags(id);
611 const desiredTags = {
612 ...news.tags,
613 ...internalTags,
614 "alchemy::instance": instanceId,
615 };
616 const desired = projectDesired(id, news);
617
618 // 1. Observe — cloud state is authoritative; output is only an id
619 // cache. Fall back to instance-scoped tag recovery when the id is
620 // unknown (state persistence failure).
621 let observed = output?.id ? yield* getTemplate(output.id) : undefined;
622 if (observed === undefined) {
623 observed = yield* findByTags(id, instanceId);
624 }
625
626 // 2. Ensure — create if missing. The clientToken is a per-request
627 // idempotency token, not a physical name.
628 if (observed === undefined) {
629 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
630 observed = yield* retryRolePropagation(
631 fis.createExperimentTemplate({
632 clientToken,
633 description: desired.description,
634 stopConditions: desired.stopConditions,
635 targets: desired.targets,
636 actions: desired.actions,
637 roleArn: news.roleArn,
638 logConfiguration: desired.logConfiguration,
639 experimentOptions: news.experimentOptions,
640 experimentReportConfiguration:
641 desired.experimentReportConfiguration,
642 tags: desiredTags,
643 }),
644 ).pipe(Effect.map((r) => r.experimentTemplate!));
645 } else if (
646 // 3a. Sync definition — compare the observed template against the
647 // desired projection and apply a single update on any delta
648 // (UpdateExperimentTemplate replaces the provided aspects
649 // wholesale). Skip the API entirely on no-op.
650 stableStringify(projectObserved(observed, news)) !==
651 stableStringify(desired)
652 ) {
653 observed = yield* retryRolePropagation(
654 fis.updateExperimentTemplate({
655 id: observed.id!,
656 description: desired.description,
657 stopConditions: desired.stopConditions,
658 targets: desired.targets,
659 actions: desired.actions,
660 roleArn: news.roleArn,
661 logConfiguration: desired.logConfiguration,
662 experimentOptions: news.experimentOptions
663 ? {
664 emptyTargetResolutionMode:
665 news.experimentOptions.emptyTargetResolutionMode,
666 }
667 : undefined,
668 experimentReportConfiguration:
669 desired.experimentReportConfiguration,
670 }),
671 ).pipe(Effect.map((r) => r.experimentTemplate!));
672 }
673
674 // 3b. Sync tags — diff against the OBSERVED cloud tags (the create
675 // path already landed them, so this is a no-op there; adoption and
676 // out-of-band drift converge here).
677 const arn = observed.arn!;
678 const currentTags = tagRecord(observed.tags);
679 const { upsert, removed } = diffTags(currentTags, desiredTags);
680 if (upsert.length > 0) {
681 yield* fis.tagResource({
682 resourceArn: arn,
683 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
684 });
685 }
686 if (removed.length > 0) {
687 yield* fis.untagResource({
688 resourceArn: arn,
689 tagKeys: removed,
690 });
691 }
692
693 yield* session.note(observed.id!);
694 return toAttrs(observed);
695 }),
696
697 delete: Effect.fn(function* ({ output }) {
698 yield* fis
699 .deleteExperimentTemplate({ id: output.id })
700 .pipe(
701 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
702 );
703 }),
704 });
705 }),
706 );