| 1 | import * as fis from "@distilled.cloud/aws/fis"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import * as crypto from "node:crypto"; |
| 7 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 8 | import { isResolved } from "../../Diff.ts"; |
| 9 | import type { Input } from "../../Input.ts"; |
| 10 | import * as Provider from "../../Provider.ts"; |
| 11 | import { Resource } from "../../Resource.ts"; |
| 12 | import { |
| 13 | createInternalTags, |
| 14 | diffTags, |
| 15 | hasAlchemyTags, |
| 16 | tagRecord, |
| 17 | } from "../../Tags.ts"; |
| 18 | import { toSeconds } from "../../Util/Duration.ts"; |
| 19 | import 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 | */ |
| 25 | export 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 | */ |
| 40 | export 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 | */ |
| 75 | export 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 | */ |
| 106 | export 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 | */ |
| 121 | export 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 | */ |
| 155 | export 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 | */ |
| 173 | export 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 | |
| 218 | export 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 | |
| 266 | export 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 | */ |
| 381 | export 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 | */ |
| 392 | const 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 | */ |
| 405 | const 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 | |
| 420 | const stableStringify = (value: unknown): string => |
| 421 | JSON.stringify(normalize(value) ?? null); |
| 422 | |
| 423 | export 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 | ); |