1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as iam from "@distilled.cloud/aws/iam";
3import * as lambda from "@distilled.cloud/aws/lambda";
4import * as synthetics from "@distilled.cloud/aws/synthetics";
5import * as Data from "effect/Data";
6import type * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10import { Unowned } from "../../AdoptPolicy.ts";
11import { isResolved } from "../../Diff.ts";
12import { createPhysicalName } from "../../PhysicalName.ts";
13import * as Provider from "../../Provider.ts";
14import { Resource } from "../../Resource.ts";
15import {
16 createInternalTags,
17 createTagsList,
18 diffTags,
19 hasAlchemyTags,
20} from "../../Tags.ts";
21import { toWireDays, toWireSeconds } from "../../Util/Duration.ts";
22import { zipFiles } from "../../Util/zip.ts";
23import { sha256Object } from "../../Util/sha256.ts";
24import { AWSEnvironment } from "../Environment.ts";
25import type { Providers } from "../Providers.ts";
26
27export interface CanaryProps {
28 /**
29 * Name of the canary. Must be lowercase, up to 255 characters, and match
30 * `^[0-9a-z_-]+$`. Keep it to 21 characters or fewer so the derived Lambda
31 * function name (`cwsyn-<name>-...`) stays within limits.
32 * @default ${app}-${id}-${stage}-${suffix} (lowercased, truncated to 21 chars)
33 */
34 canaryName?: string;
35 /**
36 * Inline canary script source. For Node.js runtimes
37 * (`syn-nodejs-puppeteer-*`, `syn-nodejs-playwright-*`) the script is
38 * packaged as `nodejs/node_modules/<file>.js`; for Python runtimes
39 * (`syn-python-selenium-*`) as `python/<file>.py`, where `<file>` is the
40 * first segment of `handler`.
41 */
42 script: string;
43 /**
44 * The entry point of the canary script, `<fileName>.<functionName>`.
45 * @default "index.handler"
46 */
47 handler?: string;
48 /**
49 * The Synthetics runtime version to use.
50 * @default "syn-nodejs-puppeteer-16.1"
51 */
52 runtimeVersion?: string;
53 /**
54 * S3 location where the canary stores run artifacts (screenshots, HAR
55 * files, logs), e.g. `s3://my-bucket/canary-artifacts`. The `s3://` prefix
56 * is added automatically if missing.
57 */
58 artifactS3Location: string;
59 /**
60 * ARN of the IAM role the canary's Lambda assumes. When omitted, a role is
61 * created automatically with write access to the artifact bucket,
62 * CloudWatch Logs, and the `CloudWatchSynthetics` metric namespace.
63 */
64 executionRoleArn?: string;
65 /**
66 * How often the canary runs.
67 */
68 schedule?: {
69 /**
70 * A `rate(...)` expression (`rate(1 minute)` to `rate(1 hour)`), a
71 * `cron(...)` expression, or `rate(0 minute)` to run only once when
72 * started.
73 * @default "rate(5 minutes)"
74 */
75 expression?: string;
76 /**
77 * How long the canary keeps running on this schedule after it starts
78 * (up to 1 year), e.g. `"12 hours"` or `Duration.hours(12)` (a bare
79 * number is milliseconds). Rounded to whole seconds on the wire.
80 * `"0 seconds"` (or omitted) runs the canary continuously.
81 */
82 duration?: Duration.Input;
83 };
84 /**
85 * Whether the canary is started (scheduled to run) after deployment.
86 * When `false`, the canary is created in the `READY` state and never runs
87 * until started.
88 * @default false
89 */
90 start?: boolean;
91 /**
92 * Per-run configuration of the canary's Lambda.
93 */
94 runConfig?: {
95 /**
96 * Run timeout (max 840 seconds), e.g. `"60 seconds"` or
97 * `Duration.minutes(1)` (a bare number is milliseconds). Rounded to
98 * whole seconds on the wire. Defaults to the schedule frequency
99 * capped at 14 minutes.
100 */
101 timeout?: Duration.Input;
102 /**
103 * Memory in MB (multiple of 64, between 960 and 3008).
104 */
105 memoryInMB?: number;
106 /**
107 * Enable X-Ray active tracing for canary runs.
108 * @default false
109 */
110 activeTracing?: boolean;
111 /**
112 * Environment variables exposed to the canary script. Not observable
113 * from the API after creation.
114 */
115 environmentVariables?: Record<string, string>;
116 };
117 /**
118 * How long to retain data on successful runs (1 - 455 days), e.g.
119 * `"7 days"` or `Duration.days(7)` (a bare number is milliseconds).
120 * Rounded to whole days on the wire.
121 * @default 31 days
122 */
123 successRetentionPeriod?: Duration.Input;
124 /**
125 * How long to retain data on failed runs (1 - 455 days), e.g.
126 * `"31 days"` or `Duration.days(31)` (a bare number is milliseconds).
127 * Rounded to whole days on the wire.
128 * @default 31 days
129 */
130 failureRetentionPeriod?: Duration.Input;
131 /**
132 * Run the canary inside a VPC. Both fields are required together.
133 */
134 vpcConfig?: {
135 /** Subnet IDs the canary's ENIs are placed in. */
136 subnetIds: string[];
137 /** Security group IDs applied to the canary's ENIs. */
138 securityGroupIds: string[];
139 };
140 /**
141 * Whether the Lambda function and layers backing the canary are deleted
142 * automatically when the canary is deleted.
143 * @default "AUTOMATIC"
144 */
145 provisionedResourceCleanup?: "AUTOMATIC" | "OFF";
146 /**
147 * Tags to apply to the canary. Merged with internal Alchemy tags.
148 */
149 tags?: Record<string, string>;
150}
151
152export interface Canary extends Resource<
153 "AWS.Synthetics.Canary",
154 CanaryProps,
155 {
156 /**
157 * Physical name of the canary.
158 */
159 canaryName: string;
160 /**
161 * ARN of the canary.
162 */
163 canaryArn: string;
164 /**
165 * Service-assigned unique ID of the canary.
166 */
167 canaryId: string;
168 /**
169 * ARN of the IAM role the canary runs as.
170 */
171 executionRoleArn: string;
172 /**
173 * S3 location where run artifacts (screenshots, HAR files, logs) land.
174 */
175 artifactS3Location: string;
176 /**
177 * Synthetics runtime version the canary executes on.
178 */
179 runtimeVersion: string;
180 /** Name of the auto-created execution role; `undefined` when the user supplied `executionRoleArn`. */
181 roleName: string | undefined;
182 /** Hash of the last-applied desired configuration; used to skip no-op updates. */
183 configHash: string | undefined;
184 /** Exact ARN of the service-created Lambda function backing the canary. */
185 engineArn: string | undefined;
186 },
187 {},
188 Providers
189> {}
190
191/**
192 * A CloudWatch Synthetics canary — a scripted probe that monitors your
193 * endpoints and APIs on a schedule from the outside in.
194 *
195 * The canary script is provided inline and packaged automatically for the
196 * chosen runtime. Unless you pass `executionRoleArn`, an IAM execution role
197 * is created with least-privilege access to the artifact bucket, CloudWatch
198 * Logs, and Synthetics metrics.
199 * ### Creating Canaries
200 * **Example:** Heartbeat Canary (created stopped)
201 * ```typescript
202 * import * as Synthetics from "alchemy/AWS/Synthetics";
203 *
204 * const canary = yield* Synthetics.Canary("Heartbeat", {
205 * script: `
206 * const synthetics = require("Synthetics");
207 * exports.handler = async () => {
208 * return await synthetics.executeStep("heartbeat", async () => {});
209 * };
210 * `,
211 * artifactS3Location: Output.interpolate`s3://${bucket.bucketName}/canary`,
212 * });
213 * ```
214 *
215 * **Example:** Started Canary on a Schedule
216 * ```typescript
217 * const canary = yield* Synthetics.Canary("ApiMonitor", {
218 * script: myCanaryScript,
219 * artifactS3Location: "s3://my-artifacts/api-monitor",
220 * schedule: { expression: "rate(5 minutes)" },
221 * start: true,
222 * });
223 * ```
224 *
225 * ### Configuration
226 * **Example:** Custom Runtime, Timeout and Environment
227 * ```typescript
228 * const canary = yield* Synthetics.Canary("Checkout", {
229 * script: checkoutScript,
230 * runtimeVersion: "syn-nodejs-puppeteer-16.1",
231 * artifactS3Location: "s3://my-artifacts/checkout",
232 * runConfig: {
233 * timeout: "60 seconds",
234 * environmentVariables: { TARGET_URL: "https://example.com" },
235 * },
236 * successRetentionPeriod: "7 days",
237 * failureRetentionPeriod: "31 days",
238 * });
239 * ```
240 *
241 * **Example:** Bring Your Own Execution Role
242 * ```typescript
243 * const canary = yield* Synthetics.Canary("Probe", {
244 * script: probeScript,
245 * artifactS3Location: "s3://my-artifacts/probe",
246 * executionRoleArn: role.roleArn,
247 * });
248 * ```
249 *
250 * @resource
251 */
252export const Canary = Resource<Canary>("AWS.Synthetics.Canary");
253
254const DEFAULT_RUNTIME_VERSION = "syn-nodejs-puppeteer-16.1";
255const DEFAULT_SCHEDULE_EXPRESSION = "rate(5 minutes)";
256const ROLE_POLICY_NAME = "AlchemyCanaryPolicy";
257const VPC_ACCESS_POLICY_ARN =
258 "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole";
259
260/**
261 * Canary create/update/start/stop are asynchronous; the canary reports a
262 * transitional state while converging. Raised internally to drive the
263 * bounded settle-polling retry loop.
264 */
265export class CanaryNotSettled extends Data.TaggedError("CanaryNotSettled")<{
266 canaryName: string;
267 state: string | undefined;
268}> {}
269
270/**
271 * Raised when a canary lands in the `ERROR` state after a create or update
272 * instead of converging to `READY`/`STOPPED`.
273 */
274export class CanaryFailed extends Data.TaggedError("CanaryFailed")<{
275 canaryName: string;
276 stateReasonCode: string | undefined;
277 message: string;
278}> {}
279
280class CanaryBackingResourceStillVisible extends Data.TaggedError(
281 "CanaryBackingResourceStillVisible",
282)<{
283 readonly canaryName: string;
284 readonly resource: "LambdaFunction" | "LogGroup";
285 readonly identifier: string;
286}> {}
287
288class UnexpectedCanaryEngineArn extends Data.TaggedError(
289 "UnexpectedCanaryEngineArn",
290)<{
291 readonly canaryName: string;
292 readonly engineArn: string;
293}> {}
294
295const TRANSITIONAL_STATES = [
296 "CREATING",
297 "UPDATING",
298 "STARTING",
299 "STOPPING",
300 "DELETING",
301];
302
303/**
304 * Retry an operation while the canary is briefly locked by a concurrent
305 * state transition (`ConflictException`), on a bounded schedule (~60s).
306 * Module-scope with an explicit return type so the conditional `Retry.Return`
307 * type never leaks into declaration emit (which would widen the provider
308 * layer to `unknown`).
309 */
310const retryWhileConflict = <A, E extends { _tag: string }, R>(
311 self: Effect.Effect<A, E, R>,
312): Effect.Effect<A, E, R> =>
313 Effect.retry(self, {
314 while: (e) => e._tag === "ConflictException",
315 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
316 });
317
318const parseArtifactLocation = (location: string) => {
319 const stripped = location.startsWith("s3://") ? location.slice(5) : location;
320 return {
321 artifactS3Location: `s3://${stripped}`,
322 artifactBucket: stripped.split("/")[0]!,
323 };
324};
325
326const scriptFilePath = (runtimeVersion: string, handler: string) => {
327 const fileName = handler.split(".")[0]!;
328 return runtimeVersion.startsWith("syn-python")
329 ? `python/${fileName}.py`
330 : `nodejs/node_modules/${fileName}.js`;
331};
332
333/** Package the inline script as the zip layout the runtime expects. */
334const buildCode = Effect.fn(function* (
335 script: string,
336 handler: string,
337 runtimeVersion: string,
338) {
339 const buffer = yield* zipFiles([
340 { path: scriptFilePath(runtimeVersion, handler), content: script },
341 ]);
342 return {
343 ZipFile: new Uint8Array(buffer),
344 Handler: handler,
345 } satisfies synthetics.CanaryCodeInput;
346});
347
348const filterTags = (
349 tags: { [key: string]: string | undefined } | undefined,
350): Record<string, string> =>
351 Object.fromEntries(
352 Object.entries(tags ?? {}).filter(
353 (entry): entry is [string, string] => entry[1] !== undefined,
354 ),
355 );
356
357export const CanaryProvider = () =>
358 Provider.effect(
359 Canary,
360 Effect.gen(function* () {
361 const createCanaryName = Effect.fn(function* (
362 id: string,
363 props: { canaryName?: string | undefined },
364 ) {
365 if (props.canaryName) return props.canaryName;
366 // Canary names must be lowercase [0-9a-z_-]; the console caps them at
367 // 21 chars because the backing Lambda is named `cwsyn-<name>-<uuid>`.
368 return yield* createPhysicalName({
369 id,
370 maxLength: 21,
371 suffixLength: 8,
372 lowercase: true,
373 });
374 });
375
376 const createRoleName = (id: string) =>
377 createPhysicalName({ id, maxLength: 64 });
378
379 const canaryArnOf = Effect.fn(function* (canaryName: string) {
380 const { accountId, region } = yield* AWSEnvironment.current;
381 return `arn:aws:synthetics:${region}:${accountId}:canary:${canaryName}`;
382 });
383
384 const getCanaryOrUndefined = Effect.fn(function* (canaryName: string) {
385 return yield* synthetics.getCanary({ Name: canaryName }).pipe(
386 Effect.map((r) => r.Canary),
387 Effect.catchTag("ResourceNotFoundException", () =>
388 Effect.succeed(undefined),
389 ),
390 );
391 });
392
393 // Poll until the canary leaves its transitional state. Create typically
394 // settles in 30-90s; budget 3 minutes (60 * 3s).
395 const waitForSettled = Effect.fn(function* (canaryName: string) {
396 return yield* getCanaryOrUndefined(canaryName).pipe(
397 Effect.flatMap((canary) =>
398 canary !== undefined &&
399 TRANSITIONAL_STATES.includes(canary.Status?.State ?? "")
400 ? Effect.fail(
401 new CanaryNotSettled({
402 canaryName,
403 state: canary.Status?.State,
404 }),
405 )
406 : Effect.succeed(canary),
407 ),
408 Effect.retry({
409 while: (e) => e instanceof CanaryNotSettled,
410 schedule: Schedule.max([
411 Schedule.fixed("3 seconds"),
412 Schedule.recurs(60),
413 ]),
414 }),
415 );
416 });
417
418 const failIfErrored = (
419 canaryName: string,
420 canary: synthetics.Canary | undefined,
421 ) =>
422 canary?.Status?.State === "ERROR"
423 ? Effect.fail(
424 new CanaryFailed({
425 canaryName,
426 stateReasonCode: canary.Status?.StateReasonCode,
427 message:
428 canary.Status?.StateReason ??
429 `canary ${canaryName} is in ERROR state`,
430 }),
431 )
432 : Effect.void;
433
434 // Deletion is asynchronous (DELETING); wait until gone so a
435 // destroy-then-deploy of the same logical resource (same physical
436 // name) does not race the pending delete.
437 const waitUntilGone = Effect.fn(function* (canaryName: string) {
438 yield* getCanaryOrUndefined(canaryName).pipe(
439 Effect.flatMap((canary) =>
440 canary === undefined
441 ? Effect.void
442 : Effect.fail(
443 new CanaryNotSettled({
444 canaryName,
445 state: canary.Status?.State,
446 }),
447 ),
448 ),
449 Effect.retry({
450 while: (e) => e instanceof CanaryNotSettled,
451 schedule: Schedule.max([
452 Schedule.fixed("3 seconds"),
453 Schedule.recurs(40),
454 ]),
455 }),
456 );
457 });
458
459 // Stop (if running) + delete + wait-until-gone. Also used by reconcile
460 // to self-heal a canary stuck in CREATE_FAILED.
461 const deleteCanaryAndWait = Effect.fn(function* (canaryName: string) {
462 const observed = yield* getCanaryOrUndefined(canaryName);
463 if (observed === undefined) return;
464 const state = observed.Status?.State;
465 if (state === "RUNNING" || state === "STARTING") {
466 yield* synthetics
467 .stopCanary({ Name: canaryName })
468 .pipe(
469 Effect.catchTag(
470 ["ResourceNotFoundException", "ConflictException"],
471 () => Effect.void,
472 ),
473 );
474 }
475 // wait out any transitional state (STOPPING, UPDATING, ...) — a
476 // canary can only be deleted from READY/STOPPED/ERROR.
477 yield* waitForSettled(canaryName);
478 yield* synthetics
479 .deleteCanary({ Name: canaryName, DeleteLambda: true })
480 .pipe(
481 // e.g. a run is still finishing; bounded retry
482 retryWhileConflict,
483 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
484 );
485 yield* waitUntilGone(canaryName);
486 });
487
488 const backingFunctionName = (canaryName: string, engineArn: string) => {
489 const marker = ":function:";
490 const markerIndex = engineArn.indexOf(marker);
491 const functionName =
492 markerIndex === -1
493 ? undefined
494 : engineArn.slice(markerIndex + marker.length).split(":")[0];
495 // EngineArn is observed directly from this owned canary. Retain an
496 // explicit name check so a malformed or unexpectedly-shaped response
497 // can never make deletion target another Lambda.
498 return functionName?.startsWith(`cwsyn-${canaryName}-`)
499 ? Effect.succeed(functionName)
500 : Effect.fail(
501 new UnexpectedCanaryEngineArn({ canaryName, engineArn }),
502 );
503 };
504
505 const discoverBackingFunctionNames = Effect.fn(function* (
506 canaryName: string,
507 engineArn: string | undefined,
508 ) {
509 if (engineArn) {
510 return [yield* backingFunctionName(canaryName, engineArn)];
511 }
512
513 // EngineArn is optional and is absent on some Synthetics runtimes.
514 // The backing Lambda's only observable relationship is its service
515 // name. Require the complete canonical UUID suffix so a canary named
516 // `foo` can never capture another named `foo-bar`.
517 const escapedCanaryName = canaryName.replace(
518 /[.*+?^${}()|[\]\\]/g,
519 "\\$&",
520 );
521 const exactBackingName = new RegExp(
522 `^cwsyn-${escapedCanaryName}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
523 "i",
524 );
525 const pages = yield* lambda.listFunctions
526 .pages({})
527 .pipe(Stream.runCollect);
528 return Array.from(pages)
529 .flatMap((page) => page.Functions ?? [])
530 .flatMap((fn) =>
531 fn.FunctionName && exactBackingName.test(fn.FunctionName)
532 ? [fn.FunctionName]
533 : [],
534 );
535 });
536
537 const backingFunctionExists = (functionName: string) =>
538 lambda.getFunction({ FunctionName: functionName }).pipe(
539 Effect.as(true),
540 Effect.catchTag("ResourceNotFoundException", () =>
541 Effect.succeed(false),
542 ),
543 );
544
545 const exactLogGroupExists = (logGroupName: string) =>
546 logs
547 .describeLogGroups({
548 logGroupNamePrefix: logGroupName,
549 limit: 50,
550 })
551 .pipe(
552 Effect.map((response) =>
553 (response.logGroups ?? []).some(
554 (group) => group.logGroupName === logGroupName,
555 ),
556 ),
557 );
558
559 const waitForBackingResourceGone = <
560 E extends { readonly _tag: string },
561 R,
562 >(
563 canaryName: string,
564 resource: "LambdaFunction" | "LogGroup",
565 identifier: string,
566 exists: Effect.Effect<boolean, E, R>,
567 ) =>
568 exists.pipe(
569 Effect.flatMap((visible) =>
570 visible
571 ? Effect.fail(
572 new CanaryBackingResourceStillVisible({
573 canaryName,
574 resource,
575 identifier,
576 }),
577 )
578 : Effect.void,
579 ),
580 Effect.retry({
581 while: (error) =>
582 error._tag === "CanaryBackingResourceStillVisible",
583 schedule: Schedule.max([
584 Schedule.fixed("2 seconds"),
585 Schedule.recurs(20),
586 ]),
587 }),
588 );
589
590 const deleteBackingResources = Effect.fn(function* (
591 canaryName: string,
592 functionName: string,
593 ) {
594 const logGroupName = `/aws/lambda/${functionName}`;
595
596 yield* lambda.deleteFunction({ FunctionName: functionName }).pipe(
597 Effect.retry({
598 while: (error) => error._tag === "ResourceConflictException",
599 schedule: Schedule.max([
600 Schedule.fixed("2 seconds"),
601 Schedule.recurs(20),
602 ]),
603 }),
604 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
605 );
606 yield* waitForBackingResourceGone(
607 canaryName,
608 "LambdaFunction",
609 functionName,
610 backingFunctionExists(functionName),
611 );
612
613 // Lambda never owns its log group lifecycle. A just-finished
614 // invocation can flush its final stream after Lambda deletion starts,
615 // so reap this exact group across a short quiescence window.
616 for (let attempt = 0; attempt < 5; attempt++) {
617 if (yield* exactLogGroupExists(logGroupName)) {
618 yield* logs.deleteLogGroup({ logGroupName }).pipe(
619 Effect.retry({
620 while: (error) => error._tag === "OperationAbortedException",
621 schedule: Schedule.max([
622 Schedule.fixed("2 seconds"),
623 Schedule.recurs(15),
624 ]),
625 }),
626 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
627 );
628 }
629 yield* Effect.sleep("2 seconds");
630 }
631 yield* waitForBackingResourceGone(
632 canaryName,
633 "LogGroup",
634 logGroupName,
635 exactLogGroupExists(logGroupName),
636 );
637 });
638
639 /**
640 * Ensure the auto-created execution role exists and its inline policy
641 * matches the artifact bucket. Every step is idempotent: create
642 * tolerates AlreadyExists, putRolePolicy is an upsert.
643 */
644 const ensureExecutionRole = Effect.fn(function* ({
645 id,
646 roleName,
647 artifactBucket,
648 vpc,
649 }: {
650 id: string;
651 roleName: string;
652 artifactBucket: string;
653 vpc: boolean;
654 }) {
655 const { accountId, region } = yield* AWSEnvironment.current;
656 const internalTags = yield* createInternalTags(id);
657 // Track whether we actually created the role on this pass — a fresh
658 // role needs a propagation grace period before Synthetics can build
659 // the backing Lambda with it.
660 let created = true;
661 yield* iam
662 .createRole({
663 RoleName: roleName,
664 AssumeRolePolicyDocument: JSON.stringify({
665 Version: "2012-10-17",
666 Statement: [
667 {
668 Effect: "Allow",
669 Principal: { Service: "lambda.amazonaws.com" },
670 Action: "sts:AssumeRole",
671 },
672 ],
673 }),
674 Tags: createTagsList(internalTags),
675 })
676 .pipe(
677 Effect.catchTag("EntityAlreadyExistsException", () =>
678 Effect.sync(() => {
679 created = false;
680 }),
681 ),
682 );
683 yield* iam.putRolePolicy({
684 RoleName: roleName,
685 PolicyName: ROLE_POLICY_NAME,
686 PolicyDocument: JSON.stringify({
687 Version: "2012-10-17",
688 Statement: [
689 {
690 Effect: "Allow",
691 Action: ["s3:PutObject", "s3:GetObject"],
692 Resource: [`arn:aws:s3:::${artifactBucket}/*`],
693 },
694 {
695 Effect: "Allow",
696 Action: ["s3:GetBucketLocation"],
697 Resource: [`arn:aws:s3:::${artifactBucket}`],
698 },
699 {
700 Effect: "Allow",
701 Action: ["s3:ListAllMyBuckets"],
702 Resource: ["*"],
703 },
704 {
705 Effect: "Allow",
706 Action: ["cloudwatch:PutMetricData"],
707 Resource: ["*"],
708 Condition: {
709 StringEquals: {
710 "cloudwatch:namespace": "CloudWatchSynthetics",
711 },
712 },
713 },
714 {
715 Effect: "Allow",
716 Action: [
717 "logs:CreateLogGroup",
718 "logs:CreateLogStream",
719 "logs:PutLogEvents",
720 ],
721 Resource: [
722 `arn:aws:logs:${region}:${accountId}:log-group:/aws/lambda/cwsyn-*`,
723 ],
724 },
725 {
726 Effect: "Allow",
727 Action: ["xray:PutTraceSegments"],
728 Resource: ["*"],
729 },
730 ],
731 }),
732 });
733 if (vpc) {
734 yield* iam.attachRolePolicy({
735 RoleName: roleName,
736 PolicyArn: VPC_ACCESS_POLICY_ARN,
737 });
738 }
739 if (created) {
740 // IAM is eventually consistent — a canary created immediately after
741 // the role lands in CREATE_FAILED with "The role defined for the
742 // function cannot be assumed by Lambda". Give the role time to
743 // propagate; the CREATE_FAILED recreate loop below covers stragglers.
744 yield* Effect.sleep("10 seconds");
745 }
746 return `arn:aws:iam::${accountId}:role/${roleName}`;
747 });
748
749 // Tear down the auto-created execution role. Idempotent: every step
750 // tolerates the entity being gone.
751 const deleteExecutionRole = Effect.fn(function* (roleName: string) {
752 yield* iam
753 .deleteRolePolicy({
754 RoleName: roleName,
755 PolicyName: ROLE_POLICY_NAME,
756 })
757 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
758 yield* iam
759 .detachRolePolicy({
760 RoleName: roleName,
761 PolicyArn: VPC_ACCESS_POLICY_ARN,
762 })
763 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
764 yield* iam.deleteRole({ RoleName: roleName }).pipe(
765 Effect.retry({
766 while: (error) =>
767 error._tag === "ConcurrentModificationException" ||
768 error._tag === "DeleteConflictException" ||
769 error._tag === "LimitExceededException" ||
770 error._tag === "ServiceFailureException",
771 schedule: Schedule.max([
772 Schedule.fixed("1 second"),
773 Schedule.recurs(20),
774 ]),
775 }),
776 Effect.catchTag("NoSuchEntityException", () => Effect.void),
777 );
778
779 // IAM deletion is eventually consistent. Keep the canary's state
780 // until its owned execution role is observably absent so a following
781 // nuke cannot race the successful DeleteRole request.
782 for (let attempt = 0; attempt < 30; attempt++) {
783 const remaining = yield* iam
784 .getRole({ RoleName: roleName })
785 .pipe(
786 Effect.catchTag("NoSuchEntityException", () =>
787 Effect.succeed(undefined),
788 ),
789 );
790 if (remaining === undefined) return;
791 yield* Effect.sleep("1 second");
792 }
793 return yield* Effect.die(
794 new Error(
795 `Synthetics execution role ${roleName} remained observable 30 seconds after delete`,
796 ),
797 );
798 });
799
800 const desiredConfigHash = (
801 props: CanaryProps,
802 executionRoleArn: string,
803 artifactS3Location: string,
804 runtimeVersion: string,
805 ) =>
806 sha256Object({
807 script: props.script,
808 handler: props.handler ?? "index.handler",
809 runtimeVersion,
810 artifactS3Location,
811 executionRoleArn,
812 schedule: {
813 expression:
814 props.schedule?.expression ?? DEFAULT_SCHEDULE_EXPRESSION,
815 durationInSeconds: toWireSeconds(props.schedule?.duration) ?? 0,
816 },
817 runConfig:
818 props.runConfig === undefined
819 ? {}
820 : {
821 ...props.runConfig,
822 timeout: undefined,
823 timeoutInSeconds: toWireSeconds(props.runConfig.timeout),
824 },
825 successRetentionPeriodInDays: toWireDays(
826 props.successRetentionPeriod,
827 ),
828 failureRetentionPeriodInDays: toWireDays(
829 props.failureRetentionPeriod,
830 ),
831 vpcConfig: props.vpcConfig,
832 provisionedResourceCleanup:
833 props.provisionedResourceCleanup ?? "AUTOMATIC",
834 });
835
836 const toRunConfigInput = (
837 props: CanaryProps,
838 ): synthetics.CanaryRunConfigInput | undefined =>
839 props.runConfig === undefined
840 ? undefined
841 : {
842 TimeoutInSeconds: toWireSeconds(props.runConfig.timeout),
843 MemoryInMB: props.runConfig.memoryInMB,
844 ActiveTracing: props.runConfig.activeTracing,
845 EnvironmentVariables: props.runConfig.environmentVariables,
846 };
847
848 const toScheduleInput = (
849 props: CanaryProps,
850 ): synthetics.CanaryScheduleInput => ({
851 Expression: props.schedule?.expression ?? DEFAULT_SCHEDULE_EXPRESSION,
852 DurationInSeconds: toWireSeconds(props.schedule?.duration),
853 });
854
855 const toVpcConfigInput = (
856 props: CanaryProps,
857 ): synthetics.VpcConfigInput | undefined =>
858 props.vpcConfig === undefined
859 ? undefined
860 : {
861 SubnetIds: props.vpcConfig.subnetIds,
862 SecurityGroupIds: props.vpcConfig.securityGroupIds,
863 };
864
865 return Canary.Provider.of({
866 stables: [
867 "canaryName",
868 "canaryArn",
869 "canaryId",
870 "roleName",
871 "engineArn",
872 ],
873
874 // Enumerate every canary in the ambient account/region.
875 list: () =>
876 Effect.gen(function* () {
877 const pages = yield* synthetics.describeCanaries
878 .pages({})
879 .pipe(Stream.runCollect);
880 const canaries = Array.from(pages).flatMap(
881 (page) => page.Canaries ?? [],
882 );
883 const items: Canary["Attributes"][] = [];
884 for (const canary of canaries) {
885 if (!canary.Name) continue;
886 items.push({
887 canaryName: canary.Name,
888 canaryArn: yield* canaryArnOf(canary.Name),
889 canaryId: canary.Id ?? "",
890 executionRoleArn: canary.ExecutionRoleArn ?? "",
891 artifactS3Location: canary.ArtifactS3Location
892 ? parseArtifactLocation(canary.ArtifactS3Location)
893 .artifactS3Location
894 : "",
895 runtimeVersion: canary.RuntimeVersion ?? "",
896 roleName: undefined,
897 configHash: undefined,
898 engineArn: canary.EngineArn,
899 });
900 }
901 return items;
902 }),
903
904 read: Effect.fn(function* ({ id, olds, output }) {
905 const canaryName =
906 output?.canaryName ?? (yield* createCanaryName(id, olds ?? {}));
907 const canary = yield* getCanaryOrUndefined(canaryName);
908 if (canary === undefined) return undefined;
909 const attrs: Canary["Attributes"] = {
910 canaryName,
911 canaryArn: yield* canaryArnOf(canaryName),
912 canaryId: canary.Id ?? "",
913 executionRoleArn: canary.ExecutionRoleArn ?? "",
914 artifactS3Location: canary.ArtifactS3Location
915 ? parseArtifactLocation(canary.ArtifactS3Location)
916 .artifactS3Location
917 : "",
918 runtimeVersion: canary.RuntimeVersion ?? "",
919 roleName: output?.roleName,
920 configHash: output?.configHash,
921 engineArn: canary.EngineArn ?? output?.engineArn,
922 };
923 const tags = filterTags(canary.Tags);
924 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
925 }),
926
927 diff: Effect.fn(function* ({ id, news, olds }) {
928 if (!isResolved(news)) return undefined;
929 const oldName = yield* createCanaryName(id, olds);
930 const newName = yield* createCanaryName(id, news);
931 if (oldName !== newName) {
932 return { action: "replace" } as const;
933 }
934 // everything else converges through updateCanary
935 }),
936
937 reconcile: Effect.fn(function* ({ id, news, output, session }) {
938 const canaryName =
939 output?.canaryName ?? (yield* createCanaryName(id, news));
940 const canaryArn = yield* canaryArnOf(canaryName);
941 const internalTags = yield* createInternalTags(id);
942 const desiredTags = { ...news.tags, ...internalTags };
943 const { artifactS3Location, artifactBucket } = parseArtifactLocation(
944 news.artifactS3Location,
945 );
946 const runtimeVersion = news.runtimeVersion ?? DEFAULT_RUNTIME_VERSION;
947 const handler = news.handler ?? "index.handler";
948
949 // Ensure the execution role (user-supplied or auto-created, with
950 // its inline policy converged to the current artifact bucket).
951 let roleName: string | undefined;
952 let executionRoleArn: string;
953 if (news.executionRoleArn) {
954 executionRoleArn = news.executionRoleArn;
955 } else {
956 roleName = output?.roleName ?? (yield* createRoleName(id));
957 executionRoleArn = yield* ensureExecutionRole({
958 id,
959 roleName,
960 artifactBucket,
961 vpc: news.vpcConfig !== undefined,
962 });
963 }
964
965 const configHash = yield* desiredConfigHash(
966 news,
967 executionRoleArn,
968 artifactS3Location,
969 runtimeVersion,
970 );
971
972 // 1. OBSERVE — cloud state is authoritative.
973 let observed = yield* getCanaryOrUndefined(canaryName);
974
975 // Self-heal a canary stuck in CREATE_FAILED (e.g. a crashed prior
976 // deploy): it cannot be updated, only deleted and recreated.
977 if (
978 observed?.Status?.State === "ERROR" &&
979 observed.Status?.StateReasonCode === "CREATE_FAILED"
980 ) {
981 yield* session.note(
982 `canary ${canaryName} is in CREATE_FAILED (${observed.Status?.StateReason}); recreating`,
983 );
984 yield* deleteCanaryAndWait(canaryName);
985 observed = undefined;
986 }
987
988 // 2. ENSURE — create if missing; wait for the async create to
989 // settle (CREATING → READY, typically 30-90s).
990 if (observed === undefined) {
991 const code = yield* buildCode(news.script, handler, runtimeVersion);
992 // Canary creation is asynchronous and validated in the backend:
993 // an execution role that hasn't finished propagating surfaces as
994 // CREATE_FAILED ("role ... cannot be assumed by Lambda") only
995 // AFTER the create settles. A CREATE_FAILED canary can't be
996 // updated — delete it and recreate, bounded to 3 attempts.
997 for (let attempt = 0; ; attempt++) {
998 yield* synthetics.createCanary({
999 Name: canaryName,
1000 Code: code,
1001 ArtifactS3Location: artifactS3Location,
1002 ExecutionRoleArn: executionRoleArn,
1003 Schedule: toScheduleInput(news),
1004 RunConfig: toRunConfigInput(news),
1005 RuntimeVersion: runtimeVersion,
1006 SuccessRetentionPeriodInDays: toWireDays(
1007 news.successRetentionPeriod,
1008 ),
1009 FailureRetentionPeriodInDays: toWireDays(
1010 news.failureRetentionPeriod,
1011 ),
1012 VpcConfig: toVpcConfigInput(news),
1013 ProvisionedResourceCleanup:
1014 news.provisionedResourceCleanup ?? "AUTOMATIC",
1015 Tags: desiredTags,
1016 });
1017 yield* session.note(
1018 `created canary ${canaryName}, waiting for it to become ready`,
1019 );
1020 observed = yield* waitForSettled(canaryName);
1021 if (
1022 observed?.Status?.State === "ERROR" &&
1023 observed.Status?.StateReasonCode === "CREATE_FAILED"
1024 ) {
1025 // A failed create is not persisted by the engine, so leaving
1026 // the CREATE_FAILED canary behind would leak it — delete it
1027 // whether we retry or give up.
1028 yield* session.note(
1029 `canary create failed (${observed.Status?.StateReason}); deleting`,
1030 );
1031 yield* deleteCanaryAndWait(canaryName);
1032 if (attempt < 2) {
1033 yield* Effect.sleep("10 seconds");
1034 continue;
1035 }
1036 // Giving up — the engine does not persist a failed create, so
1037 // also remove the execution role we auto-created this pass.
1038 if (roleName !== undefined) {
1039 yield* deleteExecutionRole(roleName);
1040 }
1041 }
1042 yield* failIfErrored(canaryName, observed);
1043 break;
1044 }
1045 } else {
1046 // 3. SYNC config — the script content is not observable from the
1047 // API (only a source-location ARN), so we use the persisted
1048 // configHash as a skip-hint; on adoption (no hash) or any prop
1049 // change we push the full desired configuration, which is
1050 // idempotent.
1051 observed = yield* waitForSettled(canaryName);
1052 yield* failIfErrored(canaryName, observed);
1053 if (output?.configHash !== configHash) {
1054 const code = yield* buildCode(
1055 news.script,
1056 handler,
1057 runtimeVersion,
1058 );
1059 yield* synthetics
1060 .updateCanary({
1061 Name: canaryName,
1062 Code: code,
1063 ExecutionRoleArn: executionRoleArn,
1064 RuntimeVersion: runtimeVersion,
1065 Schedule: toScheduleInput(news),
1066 RunConfig: toRunConfigInput(news),
1067 SuccessRetentionPeriodInDays: toWireDays(
1068 news.successRetentionPeriod,
1069 ),
1070 FailureRetentionPeriodInDays: toWireDays(
1071 news.failureRetentionPeriod,
1072 ),
1073 VpcConfig: toVpcConfigInput(news),
1074 ArtifactS3Location: artifactS3Location,
1075 ProvisionedResourceCleanup:
1076 news.provisionedResourceCleanup ?? "AUTOMATIC",
1077 })
1078 .pipe(retryWhileConflict);
1079 yield* session.note(`updated canary ${canaryName}`);
1080 observed = yield* waitForSettled(canaryName);
1081 yield* failIfErrored(canaryName, observed);
1082 }
1083 }
1084
1085 // 4. SYNC run state — start/stop to match the `start` prop.
1086 const desiredRunning = news.start ?? false;
1087 const state = observed?.Status?.State;
1088 if (desiredRunning && state !== "RUNNING") {
1089 yield* synthetics
1090 .startCanary({ Name: canaryName })
1091 .pipe(retryWhileConflict);
1092 yield* session.note(`started canary ${canaryName}`);
1093 observed = yield* waitForSettled(canaryName);
1094 } else if (!desiredRunning && state === "RUNNING") {
1095 yield* synthetics
1096 .stopCanary({ Name: canaryName })
1097 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
1098 yield* session.note(`stopped canary ${canaryName}`);
1099 observed = yield* waitForSettled(canaryName);
1100 }
1101
1102 // 5. SYNC tags — diff against OBSERVED cloud tags so adoption
1103 // converges (create already applied them; update cannot).
1104 const currentTags = filterTags(observed?.Tags);
1105 const { upsert, removed } = diffTags(currentTags, desiredTags);
1106 if (upsert.length > 0) {
1107 yield* synthetics.tagResource({
1108 ResourceArn: canaryArn,
1109 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
1110 });
1111 }
1112 if (removed.length > 0) {
1113 yield* synthetics.untagResource({
1114 ResourceArn: canaryArn,
1115 TagKeys: removed,
1116 });
1117 }
1118
1119 yield* session.note(canaryName);
1120 return {
1121 canaryName,
1122 canaryArn,
1123 canaryId: observed?.Id ?? "",
1124 executionRoleArn,
1125 artifactS3Location,
1126 runtimeVersion,
1127 roleName,
1128 configHash,
1129 engineArn: observed?.EngineArn,
1130 };
1131 }),
1132
1133 delete: Effect.fn(function* ({ output, session }) {
1134 const observed = yield* getCanaryOrUndefined(output.canaryName);
1135 const engineArn = observed?.EngineArn ?? output.engineArn;
1136 const backingFunctions = yield* discoverBackingFunctionNames(
1137 output.canaryName,
1138 engineArn,
1139 );
1140 yield* deleteCanaryAndWait(output.canaryName);
1141 for (const functionName of backingFunctions) {
1142 yield* session.note(
1143 `deleting exact backing Lambda ${functionName} and log group /aws/lambda/${functionName}`,
1144 );
1145 yield* deleteBackingResources(output.canaryName, functionName);
1146 }
1147 // Clean up the auto-created execution role (never a user-supplied
1148 // one).
1149 if (output.roleName) {
1150 yield* deleteExecutionRole(output.roleName);
1151 }
1152 }),
1153 });
1154 }),
1155 );