canopycms-cdk 0.0.68-int.108 → 0.0.68-int.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,10 +9,11 @@ AWS CDK constructs and EC2 worker for deploying CanopyCMS.
9
9
  **`CanopyCmsService`** — Core infrastructure (required):
10
10
 
11
11
  - VPC (2 AZs, public + private subnets, no NAT Gateway)
12
- - EFS filesystem with `/workspace` access point
12
+ - EFS filesystem with `/workspace` access point, TLS and IAM required, backed up daily (`efsBackup`)
13
13
  - Lambda function (Docker image, EFS mount, private subnet, no internet)
14
14
  - Lambda Function URL (for CloudFront origin)
15
- - EC2 Worker (on-demand t4g.nano in ASG, spot opt-in, public subnet, EFS mount, drained before replacement)
15
+ - EC2 Worker (on-demand t4g.nano in ASG, spot opt-in, public subnet, EFS mount, drained before replacement, replaced weekly, [hardened](../../docs/deploying-to-aws.md#the-worker-instance))
16
+ - `workerCode: { source: 'parameter' }`: lets CI roll the worker with a parameter-only change set; see [Rolling the worker from CI](../../docs/deploying-to-aws.md#rolling-the-worker-from-ci)
16
17
  - Security groups and IAM roles (least-privilege)
17
18
  - `attachTo(distribution)`: adds the editor's `/edit`, `/edit/*` and `/api/canopycms/*` behaviors (and, optionally, the CMS build's `assetPrefix` and the preview route) to a CloudFront distribution you already own
18
19
 
@@ -79,6 +80,7 @@ See [ARCHITECTURE.md](../../ARCHITECTURE.md#deployment-architecture) for details
79
80
  | EC2 t4g.nano on-demand (ASG 1/1/1) | ~$3 |
80
81
  | Lambda (editors only) | ~$1-5 |
81
82
  | EFS (small repo) | ~$1 |
83
+ | EFS backup (~$0.05/GB-month) | cents |
82
84
  | CloudFront (low traffic) | ~$1 |
83
85
  | **Total** | **~$6-10/month** |
84
86
 
@@ -1,6 +1,8 @@
1
1
  import { Construct } from 'constructs';
2
- import { Duration, RemovalPolicy, aws_ec2 as ec2, aws_efs as efs, aws_cloudfront as cloudfront, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_logs as logs } from 'aws-cdk-lib';
2
+ import { CfnParameter, Duration, RemovalPolicy, aws_cloudwatch as cloudwatch, aws_ec2 as ec2, aws_efs as efs, aws_cloudfront as cloudfront, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_s3 as s3, aws_logs as logs } from 'aws-cdk-lib';
3
3
  import type { IBucket } from 'aws-cdk-lib/aws-s3';
4
+ import type * as sns from 'aws-cdk-lib/aws-sns';
5
+ import type { WorkerCode } from './worker-bundle.js';
4
6
  import type { CanopyCmsAttachOptions } from './editor-routing.js';
5
7
  /**
6
8
  * Default CMS Lambda timeout.
@@ -93,6 +95,23 @@ export interface CanopyCmsServiceProps {
93
95
  * this bounds only a worker that hangs.
94
96
  */
95
97
  workerTerminationHeartbeat?: Duration;
98
+ /**
99
+ * How long a worker instance may run before Auto Scaling replaces it
100
+ * (default 7 days; between 1 and 365 days; `null` never replaces it on
101
+ * age). Each replacement boots on the latest Amazon Linux packages, the
102
+ * kernel excepted, which is how a worker no deploy has touched still gets
103
+ * patched.
104
+ *
105
+ * A replacement pauses only the worker's half: saves keep working, while
106
+ * publishing, pull requests and sync wait for the new instance to boot.
107
+ */
108
+ workerMaxInstanceLifetime?: Duration | null;
109
+ /**
110
+ * Where the worker's bundle comes from (default `{ source: 'asset' }`).
111
+ * `{ source: 'parameter' }` lets a parameter-only change set roll the
112
+ * worker; see "Rolling the worker from CI" in docs/deploying-to-aws.md.
113
+ */
114
+ workerCode?: WorkerCode;
96
115
  /**
97
116
  * ADDITIONAL Secrets Manager ARNs the worker may read.
98
117
  *
@@ -115,6 +134,24 @@ export interface CanopyCmsServiceProps {
115
134
  environment?: Record<string, string>;
116
135
  /** EFS removal policy (default: RETAIN) */
117
136
  efsRemovalPolicy?: RemovalPolicy;
137
+ /**
138
+ * Daily AWS Backup of the EFS file system, kept 35 days (default true).
139
+ * Branches nobody has submitted yet exist only on EFS. Backup storage is
140
+ * billed per GB-month on top of EFS itself, and recovery points outlive the
141
+ * stack: their vault refuses deletes until its access policy is changed.
142
+ */
143
+ efsBackup?: boolean;
144
+ /**
145
+ * Give the file system a policy that refuses clients without TLS or without
146
+ * IAM authorization (default true). The worker and the Lambda mount with
147
+ * both, through the access point their roles are granted.
148
+ *
149
+ * A stack deployed before the worker mounted with IAM upgrades in two
150
+ * deploys, `false` first: CloudFormation updates the file system before it
151
+ * replaces the worker, so in one deploy the policy lands while the outgoing
152
+ * worker still holds an anonymous mount. See docs/adopter-migration.md.
153
+ */
154
+ efsEnforceIamAndTls?: boolean;
118
155
  /** GitHub owner for worker git operations (e.g., 'safeinsights') */
119
156
  githubOwner: string;
120
157
  /** GitHub repo name for worker git operations (e.g., 'docs-site') */
@@ -224,24 +261,26 @@ export interface CanopyCmsServiceProps {
224
261
  */
225
262
  clerkSecretKeySecretJsonField?: string;
226
263
  /**
227
- * The GitHub repository's default branch name (default: 'main').
264
+ * The branch editing branches fork from (default: unset, so the worker
265
+ * detects it: the branch remote.git's HEAD names, or before remote.git exists
266
+ * GitHub's default branch).
228
267
  *
229
268
  * Interpolated straight into a git ref (`refs/heads/{baseBranch}`) and into
230
269
  * the worker's `.env` heredoc, so an invalid value is rejected at synth (see
231
270
  * `assertValidGitBranchName` above). A branch that git accepts but the repo
232
271
  * does not have fails permanently at boot instead: `verifyBaseBranchExists`
233
272
  * (packages/canopycms/src/worker/cms-worker.ts) throws, `worker/index.ts`
234
- * exits 1, systemd's `Restart=always` repeats that forever, and
235
- * `rebaseActiveBranches` rebases against the wrong lineage in the meantime.
273
+ * exits 1, and systemd's `Restart=always` repeats that forever.
236
274
  *
237
275
  * MUST match the shared repo's `canopycms.config.ts`'s `defaultBaseBranch`
238
- * (both default to 'main' when unset) — the two are resolved by different
239
- * processes (this stamps the worker's `.env`; the Lambda reads
240
- * `config.defaultBaseBranch` at request time) with no automatic reconciliation
241
- * between them. `infrastructure/lib/cms-stack.ts`, as scaffolded by
276
+ * whenever that is set. Leaving `defaultBaseBranch` unset is safe whether or
277
+ * not this is set: the worker points remote.git's HEAD at the branch it uses,
278
+ * and the Lambda reads that HEAD. Setting `defaultBaseBranch` alone is not:
279
+ * the two are resolved by different processes, and a Lambda configured with
280
+ * one name against a worker that detected another forks and rebases against
281
+ * different lineages. `infrastructure/lib/cms-stack.ts`, as scaffolded by
242
282
  * `canopycms init-deploy aws`, derives this prop FROM that config file so the
243
- * two cannot drift; a hand-rolled stack must set it explicitly whenever
244
- * `defaultBaseBranch` is anything other than 'main'.
283
+ * two cannot drift.
245
284
  */
246
285
  baseBranch?: string;
247
286
  /**
@@ -313,6 +352,18 @@ export interface CanopyCmsServiceProps {
313
352
  * default name would collide).
314
353
  */
315
354
  workerLogGroupName?: string;
355
+ /**
356
+ * SNS topic notified when the worker has stopped syncing git (and again when
357
+ * it recovers). Creating the topic and subscribing to it (email, Slack,
358
+ * PagerDuty) is the adopter's job. Default: none, and then no metric filter,
359
+ * alarm or alarm action is created.
360
+ *
361
+ * The alarm fires when the worker's log shows no git-sync cycle for 30
362
+ * minutes, which covers a crash loop, a boot loop, a worker that never
363
+ * started and a worker whose loops have stopped. A deploy or a spot
364
+ * replacement does not trip it. See `workerDownAlarm`.
365
+ */
366
+ alarmTopic?: sns.ITopic;
316
367
  /**
317
368
  * Execution role for the CMS Lambda (default: CDK creates one).
318
369
  *
@@ -429,6 +480,18 @@ export declare class CanopyCmsService extends Construct {
429
480
  readonly workerAsg: autoscaling.AutoScalingGroup;
430
481
  /** The EC2 worker's CloudWatch log group (worker stdout/stderr) */
431
482
  readonly workerLogGroup: logs.LogGroup;
483
+ /** Alarms when the worker logs no git-sync cycle for 30 minutes. Set only with `alarmTopic`. */
484
+ readonly workerDownAlarm?: cloudwatch.Alarm;
485
+ /**
486
+ * With `workerCode: { source: 'parameter' }`: the bucket worker bundles are
487
+ * uploaded to, as `canopy-worker/<sha256>.js`.
488
+ */
489
+ readonly workerBundleBucket?: s3.Bucket;
490
+ /**
491
+ * With `workerCode: { source: 'parameter' }`: the template parameter naming
492
+ * the bundle to run by its sha256; its logical id is also a stack output.
493
+ */
494
+ readonly workerBundleSha256Parameter?: CfnParameter;
432
495
  constructor(scope: Construct, id: string, props: CanopyCmsServiceProps);
433
496
  /**
434
497
  * Serve the editor from a CloudFront distribution you already own, such as
@@ -1,17 +1,9 @@
1
- import * as path from 'node:path';
2
- import { fileURLToPath } from 'node:url';
3
1
  import { Construct } from 'constructs';
4
- import { Annotations, ArnFormat, Duration, RemovalPolicy, Stack, Token, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_s3_assets as s3assets, aws_logs as logs, } from 'aws-cdk-lib';
2
+ import { Annotations, ArnFormat, Duration, Names, RemovalPolicy, Stack, Token, aws_cloudwatch as cloudwatch, aws_cloudwatch_actions as cloudwatchActions, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_logs as logs, } from 'aws-cdk-lib';
5
3
  import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
4
+ import { WORKER_BUNDLE_DOWNLOAD_PATH, workerBundleSource } from './worker-bundle.js';
6
5
  import { attachEditorBehaviors } from './editor-routing.js';
7
- import { EXIT_DRAINED_FOR_TERMINATION, WORKER_CAPACITY_ENV, WORKER_DRAIN_HOOK_NAME, } from './worker-lifecycle.js';
8
- // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled output
9
- // is real ESM and `__dirname` is not a global there - the worker asset path
10
- // below throws `__dirname is not defined` under a real ESM runtime (e.g. `tsx`)
11
- // without this. Vitest's SSR/CJS-interop transform shims `__dirname`
12
- // automatically, so this file's own tests would not catch its absence. Same fix
13
- // as ../../lambda/asset-transform/build.mjs and ./asset-support.ts.
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ import { EXIT_DRAINED_FOR_TERMINATION, WORKER_CAPACITY_ENV, WORKER_DRAIN_HOOK_NAME, WORKER_SYNC_LOG_PHRASE, } from './worker-lifecycle.js';
15
7
  /**
16
8
  * Synth-time mirror of `resolveDeploymentName`'s rule in the `canopycms`
17
9
  * package (packages/canopycms/src/operating-mode/deployment-name.ts).
@@ -45,6 +37,31 @@ const isValidDeploymentName = (name) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name
45
37
  * by one process must resolve for the other.
46
38
  */
47
39
  const EFS_MOUNT_PATH = '/mnt/efs';
40
+ /**
41
+ * The worker unit's sandbox, also in worker/canopy-worker.service. Node and git
42
+ * need none of what these take away; `MemoryDenyWriteExecute` is left out
43
+ * because V8's JIT writes executable memory.
44
+ */
45
+ const WORKER_SANDBOX_DIRECTIVES = [
46
+ 'NoNewPrivileges=yes',
47
+ 'ProtectSystem=strict',
48
+ 'ProtectHome=tmpfs',
49
+ 'PrivateTmp=yes',
50
+ 'PrivateDevices=yes',
51
+ 'ProtectProc=invisible',
52
+ 'ProtectKernelTunables=yes',
53
+ 'ProtectKernelModules=yes',
54
+ 'ProtectKernelLogs=yes',
55
+ 'ProtectControlGroups=yes',
56
+ 'ProtectClock=yes',
57
+ 'ProtectHostname=yes',
58
+ 'RestrictNamespaces=yes',
59
+ 'RestrictSUIDSGID=yes',
60
+ 'RestrictRealtime=yes',
61
+ 'LockPersonality=yes',
62
+ 'SystemCallArchitectures=native',
63
+ 'CapabilityBoundingSet=',
64
+ ];
48
65
  /**
49
66
  * The heredoc delimiter user-data uses to write the worker's `.env` (see the
50
67
  * `cat > … << 'ENVEOF'` block below).
@@ -553,11 +570,14 @@ export class CanopyCmsService extends Construct {
553
570
  }
554
571
  // Both are interpolated into a git ref and the worker's `.env` heredoc, so
555
572
  // both are guarded at synth - see their doc comments for the failure each
556
- // prevents. `settingsBranch` stays `undefined` (not stamped at all) unless
557
- // the adopter explicitly set it: an absent env var and an empty one are NOT
558
- // the same to the worker, which falls through to a computed name only when
573
+ // prevents. Each stays `undefined` (not stamped at all) unless the adopter
574
+ // explicitly set it, so the worker detects the base branch and computes the
575
+ // settings branch name. For the settings branch an absent env var and an
576
+ // empty one are NOT the same: the worker computes the name only when
559
577
  // `CANOPYCMS_SETTINGS_BRANCH` is unset entirely.
560
- const baseBranch = assertValidGitBranchName('baseBranch', props.baseBranch ?? 'main');
578
+ const baseBranch = props.baseBranch !== undefined
579
+ ? assertValidGitBranchName('baseBranch', props.baseBranch)
580
+ : undefined;
561
581
  const settingsBranch = props.settingsBranch !== undefined
562
582
  ? assertValidGitBranchName('settingsBranch', props.settingsBranch)
563
583
  : undefined;
@@ -668,6 +688,19 @@ export class CanopyCmsService extends Construct {
668
688
  description: 'CanopyCMS EFS',
669
689
  allowAllOutbound: false,
670
690
  });
691
+ // With any policy in effect, EFS evaluates a client that mounts without
692
+ // `iam` against the Principal "*" statements alone, so a policy granting
693
+ // "*" nothing is what requires IAM: the worker and the Lambda get their
694
+ // access from their roles' grants (below, and CDK's for the Lambda's access
695
+ // point). Lambda uses TLS for every file-system connection
696
+ // (docs.aws.amazon.com/lambda/latest/dg/security-dataprotection.html). `allowAnonymousAccess: true` keeps CDK from adding its own
697
+ // "*" ClientWrite/ClientRootAccess statement, which it does under the
698
+ // `@aws-cdk/aws-efs:denyAnonymousAccess` flag or after a `grant*` call.
699
+ const efsClientActions = [
700
+ 'elasticfilesystem:ClientMount',
701
+ 'elasticfilesystem:ClientWrite',
702
+ 'elasticfilesystem:ClientRootAccess',
703
+ ];
671
704
  this.fileSystem = new efs.FileSystem(this, 'FileSystem', {
672
705
  vpc: this.vpc,
673
706
  encrypted: true,
@@ -675,6 +708,20 @@ export class CanopyCmsService extends Construct {
675
708
  removalPolicy: props.efsRemovalPolicy ?? RemovalPolicy.RETAIN,
676
709
  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
677
710
  securityGroup: efsSg,
711
+ enableAutomaticBackups: props.efsBackup ?? true,
712
+ allowAnonymousAccess: true,
713
+ fileSystemPolicy: (props.efsEnforceIamAndTls ?? true)
714
+ ? new iam.PolicyDocument({
715
+ statements: [
716
+ new iam.PolicyStatement({
717
+ effect: iam.Effect.DENY,
718
+ principals: [new iam.AnyPrincipal()],
719
+ actions: efsClientActions,
720
+ conditions: { Bool: { 'aws:SecureTransport': 'false' } },
721
+ }),
722
+ ],
723
+ })
724
+ : undefined,
678
725
  });
679
726
  const accessPoint = this.fileSystem.addAccessPoint('WorkspaceAP', {
680
727
  path: '/workspace',
@@ -870,8 +917,26 @@ export class CanopyCmsService extends Construct {
870
917
  resources: secretsArns,
871
918
  }));
872
919
  }
873
- // Worker needs EFS access (handled via security group, but mount needs ec2:DescribeAvailabilityZones)
874
- workerRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonElasticFileSystemClientReadWriteAccess'));
920
+ // The worker's `iam` mount, through the shared access point only. The
921
+ // describe calls serve amazon-efs-utils' fallback to a mount target's IP
922
+ // address when the file system's DNS name does not resolve (it needs
923
+ // botocore too).
924
+ workerRole.addToPolicy(new iam.PolicyStatement({
925
+ actions: ['elasticfilesystem:ClientMount', 'elasticfilesystem:ClientWrite'],
926
+ resources: [this.fileSystem.fileSystemArn],
927
+ conditions: {
928
+ StringEquals: { 'elasticfilesystem:AccessPointArn': accessPoint.accessPointArn },
929
+ },
930
+ }));
931
+ workerRole.addToPolicy(new iam.PolicyStatement({
932
+ actions: ['elasticfilesystem:DescribeMountTargets'],
933
+ resources: [this.fileSystem.fileSystemArn],
934
+ }));
935
+ workerRole.addToPolicy(new iam.PolicyStatement({
936
+ // Supports no resource-level permissions.
937
+ actions: ['ec2:DescribeAvailabilityZones'],
938
+ resources: ['*'],
939
+ }));
875
940
  // Observation channel for a NAT-less deploy (SSM Session Manager / send-command);
876
941
  // worker SG already allows 443 egress.
877
942
  workerRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'));
@@ -886,11 +951,48 @@ export class CanopyCmsService extends Construct {
886
951
  // CreateLogStream + PutLogEvents scoped to this group only (least privilege;
887
952
  // the group is pre-created by CFN so the agent never needs CreateLogGroup).
888
953
  this.workerLogGroup.grantWrite(workerRole);
889
- // The worker is bundled with esbuild into a single JS file (pnpm run build:worker)
890
- const workerAsset = new s3assets.Asset(this, 'WorkerCode', {
891
- path: path.join(__dirname, '../../worker/dist'),
892
- });
893
- workerAsset.grantRead(workerRole);
954
+ if (props.alarmTopic) {
955
+ // Metrics are keyed by namespace and name alone, so the name carries this
956
+ // construct's unique id: two services in one account and region must not
957
+ // share one count. Capped at CloudWatch's 255 characters.
958
+ const metricPrefix = 'WorkerGitSyncCycles-';
959
+ const syncCycles = new logs.MetricFilter(this, 'WorkerSyncCycles', {
960
+ logGroup: this.workerLogGroup,
961
+ filterPattern: logs.FilterPattern.literal(`"${WORKER_SYNC_LOG_PHRASE}"`),
962
+ metricNamespace: 'CanopyCMS',
963
+ metricName: `${metricPrefix}${Names.uniqueResourceName(this, { maxLength: 255 - metricPrefix.length })}`,
964
+ metricValue: '1',
965
+ });
966
+ // Fires when 3 consecutive 10-minute periods hold no git-sync cycle line.
967
+ // The worker logs one every cycle (default 5 minutes), even while the
968
+ // schema gate holds the base branch. 30 minutes leaves room for a normal
969
+ // replacement: the terminating hook's drain
970
+ // (`workerTerminationHeartbeat`, 5 minutes by default), a boot of a few
971
+ // minutes plus its package upgrade, and the first sync. So a deploy or a
972
+ // spot replacement does not page, while a crash loop, a boot loop, a worker that never started and a
973
+ // worker whose loops have stopped all go silent and do. Missing data
974
+ // counts as a breach because a boot loop dies before the CloudWatch agent
975
+ // is installed and so writes no events at all. The construct does not
976
+ // expose CANOPYCMS_GIT_SYNC_INTERVAL; an adopter who sets it above about
977
+ // 10 minutes on the instance needs a wider window.
978
+ this.workerDownAlarm = new cloudwatch.Alarm(this, 'WorkerDownAlarm', {
979
+ metric: syncCycles.metric({ statistic: 'Sum', period: Duration.minutes(10) }),
980
+ threshold: 1,
981
+ comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
982
+ evaluationPeriods: 3,
983
+ datapointsToAlarm: 3,
984
+ treatMissingData: cloudwatch.TreatMissingData.BREACHING,
985
+ alarmDescription: `The CMS worker has not run a git sync for 30 minutes, so publishing and branch syncing ` +
986
+ `are stalled. Check System health in the editor's admin panel and the worker log group ` +
987
+ `${this.workerLogGroup.logGroupName}.`,
988
+ });
989
+ const topicAction = new cloudwatchActions.SnsAction(props.alarmTopic);
990
+ this.workerDownAlarm.addAlarmAction(topicAction);
991
+ this.workerDownAlarm.addOkAction(topicAction);
992
+ }
993
+ const workerBundle = workerBundleSource(this, props.workerCode ?? { source: 'asset' }, workerRole);
994
+ this.workerBundleBucket = workerBundle.bundleBucket;
995
+ this.workerBundleSha256Parameter = workerBundle.sha256Parameter;
894
996
  // Name/value PAIRS rather than pre-formatted lines: every value then flows
895
997
  // through `assertEnvSafe` in the single `map` below, so a value added here
896
998
  // later is guarded whether or not whoever adds it remembers to.
@@ -898,7 +1000,6 @@ export class CanopyCmsService extends Construct {
898
1000
  ['CANOPYCMS_WORKSPACE_ROOT', EFS_MOUNT_PATH],
899
1001
  ['CANOPYCMS_GITHUB_OWNER', props.githubOwner],
900
1002
  ['CANOPYCMS_GITHUB_REPO', props.githubRepo],
901
- ['CANOPYCMS_BASE_BRANCH', baseBranch],
902
1003
  // The SAME string the Lambda's environment gets above, including an
903
1004
  // `environment.CANOPYCMS_DEPLOYMENT_NAME` override - the two halves
904
1005
  // resolve one settings branch (`canopycms-settings-<name>`) between them,
@@ -909,6 +1010,9 @@ export class CanopyCmsService extends Construct {
909
1010
  // "Region is missing".
910
1011
  ['AWS_REGION', Stack.of(this).region],
911
1012
  ];
1013
+ if (baseBranch !== undefined) {
1014
+ envEntries.push(['CANOPYCMS_BASE_BRANCH', baseBranch]);
1015
+ }
912
1016
  if (props.githubTokenSecretArn) {
913
1017
  envEntries.push(['CANOPYCMS_GITHUB_TOKEN_SECRET_ARN', props.githubTokenSecretArn]);
914
1018
  }
@@ -963,9 +1067,9 @@ export class CanopyCmsService extends Construct {
963
1067
  const envFileContent = envEntries
964
1068
  .map(([name, value]) => `${name}=${assertEnvSafe(name, value)}`)
965
1069
  .join('\n');
966
- const efsMountOptions = `tls,accesspoint=${accessPoint.accessPointId}`;
1070
+ const efsMountOptions = `tls,iam,accesspoint=${accessPoint.accessPointId}`;
967
1071
  const userData = ec2.UserData.forLinux();
968
- userData.addCommands('#!/bin/bash', 'set -euo pipefail', '', '# FAIL-FAST. Every step below is required for the worker to exist at', '# all, and until this trap existed a failure in any of them aborted', '# user-data BEFORE the systemd unit was written -- leaving an instance', "# that runs, passes the ASG's EC2-only health check indefinitely, and", '# does nothing. cfn-signal is deliberately not used here (it would', '# prove nothing about READINESS, which is the argument recorded below),', '# but that argument never covered a boot-SCRIPT failure: `cdk deploy`', '# reported success while publishes queued on EFS, the auth cache went', '# stale and PRs stopped being created, until a human noticed the admin', '# panel showing the worker absent.', '#', '# Shutting down makes the instance fail its EC2 health check, so the ASG', '# replaces it -- which is the only automatic recovery available in this', '# topology. The echo lands in the console log, readable via', '# `aws ec2 get-console-output`, because the CloudWatch agent is itself', '# configured further down and cannot be relied on to exist yet.', 'trap \'echo "canopy-worker user-data FAILED at line $LINENO (exit $?)" >&2; shutdown -h now\' ERR', '', '# Bounded retry for the network-dependent steps. Package mirrors and S3', '# have transient failures; a single flake should cost seconds, not an', '# instance replacement.', 'retry() {', ' local n=0', ' until "$@"; do', ' n=$((n + 1))', ' if [ "$n" -ge 5 ]; then', ' echo "canopy-worker: command failed after $n attempts: $*" >&2', ' return 1', ' fi', ' sleep $((n * 5))', ' done', '}', '', '# Install dependencies (unzip is not guaranteed in the AL2023 AMI)', 'retry dnf install -y git unzip', "# Node comes from AL2023's own repos, NOT a piped third-party installer.", '# The previous boot curled the NodeSource RPM setup script straight into', '# bash, so every instance replacement -- which the ASG performs on every', '# `cdk deploy`, plus every spot interruption -- depended on a third party', '# being reachable. cms-deploy.test.ts asserts that URL never comes back,', '# which is why it is not spelled out here.', '#', '# nodejs22, not 20: Node 20 reached upstream EOL on 2026-04-30, and this', '# repo declares `engines.node: ">=22"` with .nvmrc `v22`, so the worker', '# was running an EOL runtime BELOW the floor its own code is tested at.', "# 22 (EOL 2027-04-30) matches .nvmrc, CI, and the transform Lambda's", '# NODEJS_22_X, so one runtime is tested everywhere.', '#', '# ExecStart uses the NAMESPACED /usr/bin/node-22 (see the systemd unit', '# below), not the bare `node`: AL2023 installs versioned binaries and', '# points `/usr/bin/node` at one of them through `alternatives`, whose', '# selection AWS documents as able to change at any time.', 'retry dnf install -y nodejs22', '', '# Mount EFS through the same access point, at the same path, as the', '# Lambda (see EFS_MOUNT_PATH). amazon-efs-utils refuses an access-point', '# mount without tls (efs_utils_common/mount_options.py).', 'retry dnf install -y amazon-efs-utils', `mkdir -p ${EFS_MOUNT_PATH}`, `mount -t efs -o ${efsMountOptions} ${this.fileSystem.fileSystemId}:/ ${EFS_MOUNT_PATH}`, '# Persist the mount across instance reboots: user-data runs once per', '# instance, so without an fstab entry a plain reboot leaves /mnt/efs an', '# empty local dir and the worker would clone a divergent remote.git', '# onto the instance disk, invisible to the Lambda.', `echo '${this.fileSystem.fileSystemId}:/ ${EFS_MOUNT_PATH} efs _netdev,${efsMountOptions} 0 0' >> /etc/fstab`, '', '# Download worker from CDK S3 Asset', `retry aws s3 cp s3://${workerAsset.s3BucketName}/${workerAsset.s3ObjectKey} /tmp/canopy-worker.zip`, 'mkdir -p /opt/canopy-worker', 'cd /opt/canopy-worker', 'unzip -o /tmp/canopy-worker.zip', '# The worker bundle is ESM (esbuild --format=esm). Without this marker a', '# .js file is CommonJS by default and the import statement fails.', '# Node >=22.7 auto-detects module syntax and would mask its absence, and', '# this instance now runs node-22 -- so the marker is kept BECAUSE that', '# auto-detection is a fallback we should not depend on, not because the', '# runtime here still needs it.', `echo '{"type":"module"}' > /opt/canopy-worker/package.json`, '', '# Write environment file for systemd service', `cat > /opt/canopy-worker/.env << 'ENVEOF'`, envFileContent, 'ENVEOF', '', '# Create systemd service', `cat > /etc/systemd/system/canopy-worker.service << 'SVCEOF'`, '[Unit]', 'Description=CanopyCMS Worker Daemon', 'After=network.target', '# Never run against an unmounted /mnt/efs (see the fstab note above).', `RequiresMountsFor=${EFS_MOUNT_PATH}`, '', '[Service]', 'Type=simple', 'User=ec2-user', 'WorkingDirectory=/opt/canopy-worker', '# Namespaced binary, not bare `node`: AL2023 points /usr/bin/node at', '# an installed version through `alternatives`, and AWS documents that', '# selection as able to change at any time. node-22 always means 22.', 'ExecStart=/usr/bin/node-22 index.js', 'Restart=always', 'RestartSec=5', 'TimeoutStartSec=300', '# Stopping drains: SIGTERM reaches node ONLY (mixed), so the git', '# children it waits for keep running. Whatever is left when node', '# exits, or at TimeoutStopSec, is SIGKILLed; 120s outlasts the 90s', '# drain and its abort grace.', 'KillMode=mixed', 'TimeoutStopSec=120', '# A worker that drained for an instance termination exits with this;', '# restarting it would start work on an instance about to disappear.', `RestartPreventExitStatus=${EXIT_DRAINED_FOR_TERMINATION}`, `SuccessExitStatus=${EXIT_DRAINED_FOR_TERMINATION}`, '# File output, not journal: the CloudWatch agent cannot read journald,', '# so it tails this file instead (see the agent config below).', '# CAUTION: /var/log/canopy-worker must exist BEFORE first start. systemd', '# opens append: targets before it creates LogsDirectory= dirs', '# (systemd#27591), so without the pre-created dir the exec fails with', '# 209/STDOUT and Restart=always crash-loops forever. User-data runs', '# mkdir before systemctl start; LogsDirectory= is kept for ownership.', 'LogsDirectory=canopy-worker', 'StandardOutput=append:/var/log/canopy-worker/worker.log', 'StandardError=append:/var/log/canopy-worker/worker.log', 'EnvironmentFile=/opt/canopy-worker/.env', '', '[Install]', 'WantedBy=multi-user.target', 'SVCEOF', '', '# Set ownership for ec2-user', 'chown -R ec2-user:ec2-user /opt/canopy-worker', '', '# Pre-create the worker log dir (crash-loop guard, MUST precede the', '# first systemctl start): systemd opens StandardOutput=append: files', '# BEFORE it creates LogsDirectory= dirs (systemd#27591), so on a fresh', '# instance the unit would fail exec with 209/STDOUT and crash-loop', '# forever without this. The unit keeps LogsDirectory= for ownership', '# management on subsequent starts.', 'mkdir -p /var/log/canopy-worker', 'chown ec2-user:ec2-user /var/log/canopy-worker', '', '# Start worker', 'systemctl daemon-reload', 'systemctl enable canopy-worker', 'systemctl start canopy-worker', '', '# ---- CloudWatch log shipping ----', '# Placed AFTER worker start: with set -euo pipefail, a package/agent', '# failure here must not prevent the worker from running (shipping is', '# best-effort).', '#', '# DISARM THE FAIL-FAST TRAP AND errexit FIRST. Everything above this line', '# is required for the worker to exist at all, so a failure there should', '# replace the instance. Nothing below it is: the worker is already', '# running and healthy by this point. Leaving the trap armed would let a', '# package-mirror outage during the agent install shut down a perfectly', '# good worker -- and the ASG would relaunch straight into the same', '# outage, turning degraded log shipping into a replacement loop with the', '# worker down for its duration. That is strictly worse than the', '# best-effort behaviour this section has always documented.', 'trap - ERR', 'set +e', 'retry dnf install -y amazon-cloudwatch-agent logrotate', '', '# Bound on-disk growth; copytruncate keeps the fd the CW agent tails valid', '# (tiny copy->truncate loss window is acceptable for diagnostic logs).', `cat > /etc/logrotate.d/canopy-worker << 'ROTEOF'`, '/var/log/canopy-worker/worker.log {', ' size 10M', ' rotate 5', ' compress', ' copytruncate', ' missingok', ' notifempty', '}', 'ROTEOF', '# The config above only fires when logrotate actually runs; AL2023', '# presets may leave logrotate.timer disabled, and without it the size', '# cap never triggers and worker.log grows until the nano disk fills.', '# --now is idempotent if the timer is already enabled/running.', 'systemctl enable --now logrotate.timer', '', '# No retention_in_days here: CDK owns retention on the pre-created group.', `cat > /opt/aws/amazon-cloudwatch-agent/etc/canopy-worker-logs.json << 'CWEOF'`, '{', ' "logs": {', ' "logs_collected": {', ' "files": {', ' "collect_list": [', ' {', ' "file_path": "/var/log/canopy-worker/worker.log",', ` "log_group_name": "${this.workerLogGroup.logGroupName}",`, ' "log_stream_name": "{instance_id}",',
1072
+ userData.addCommands('#!/bin/bash', 'set -euo pipefail', '', '# FAIL-FAST. Every step below is required for the worker to exist at', '# all, and until this trap existed a failure in any of them aborted', '# user-data BEFORE the systemd unit was written -- leaving an instance', "# that runs, passes the ASG's EC2-only health check indefinitely, and", '# does nothing. cfn-signal is deliberately not used here (it would', '# prove nothing about READINESS, which is the argument recorded below),', '# but that argument never covered a boot-SCRIPT failure: `cdk deploy`', '# reported success while publishes queued on EFS, the auth cache went', '# stale and PRs stopped being created, until a human noticed the admin', '# panel showing the worker absent.', '#', '# Shutting down makes the instance fail its EC2 health check, so the ASG', '# replaces it -- which is the only automatic recovery available in this', '# topology. The echo lands in the console log, readable via', '# `aws ec2 get-console-output`, because the CloudWatch agent is itself', '# configured further down and cannot be relied on to exist yet.', 'trap \'echo "canopy-worker user-data FAILED at line $LINENO (exit $?)" >&2; shutdown -h now\' ERR', '', '# Bounded retry for the network-dependent steps. Package mirrors and S3', '# have transient failures; a single flake should cost seconds, not an', '# instance replacement.', 'retry() {', ' local n=0', ' until "$@"; do', ' n=$((n + 1))', ' if [ "$n" -ge 5 ]; then', ' echo "canopy-worker: command failed after $n attempts: $*" >&2', ' return 1', ' fi', ' sleep $((n * 5))', ' done', '}', '', '# Patch first. AL2023 locks dnf to the repository release its AMI was', '# built from, so a relaunch from the AMI resolved at the last deploy', '# would otherwise install nothing newer than that AMI. Moving to the', '# latest release also makes it the one every install below resolves', '# from. The kernel is left out: a new one applies only at a reboot,', '# which nothing here performs, so it would cost boot time and change', '# nothing. Kernel fixes arrive with the AMI a deploy resolves.', "retry dnf upgrade --releasever=latest --exclude='kernel*' -y", '', 'retry dnf install -y git', "# Node comes from AL2023's own repos, NOT a piped third-party installer.", '# The previous boot curled the NodeSource RPM setup script straight into', '# bash, so every instance replacement -- which the ASG performs on every', '# `cdk deploy`, plus every spot interruption -- depended on a third party', '# being reachable. cms-deploy.test.ts asserts that URL never comes back,', '# which is why it is not spelled out here.', '#', '# nodejs22, not 20: Node 20 reached upstream EOL on 2026-04-30, and this', '# repo declares `engines.node: ">=22"` with .nvmrc `v22`, so the worker', '# was running an EOL runtime BELOW the floor its own code is tested at.', "# 22 (EOL 2027-04-30) matches .nvmrc, CI, and the transform Lambda's", '# NODEJS_22_X, so one runtime is tested everywhere.', '#', '# ExecStart uses the NAMESPACED /usr/bin/node-22 (see the systemd unit', '# below), not the bare `node`: AL2023 installs versioned binaries and', '# points `/usr/bin/node` at one of them through `alternatives`, whose', '# selection AWS documents as able to change at any time.', 'retry dnf install -y nodejs22', '', '# Mount EFS through the same access point, at the same path, as the', '# Lambda (see EFS_MOUNT_PATH). amazon-efs-utils refuses an access-point', '# mount without tls (efs_utils_common/mount_options.py).', 'retry dnf install -y amazon-efs-utils', `mkdir -p ${EFS_MOUNT_PATH}`, '# Retried: an `iam` mount also fetches instance-role credentials.', `retry mount -t efs -o ${efsMountOptions} ${this.fileSystem.fileSystemId}:/ ${EFS_MOUNT_PATH}`, '# Persist the mount across instance reboots: user-data runs once per', '# instance, so without an fstab entry a plain reboot leaves /mnt/efs an', '# empty local dir and the worker would clone a divergent remote.git', '# onto the instance disk, invisible to the Lambda.', `echo '${this.fileSystem.fileSystemId}:/ ${EFS_MOUNT_PATH} efs _netdev,${efsMountOptions} 0 0' >> /etc/fstab`, '', '# The bundle runs only if its sha256 is the one given here (synthesized,', '# or the workerCode parameter); a mismatch fails the boot under the trap', '# above.', `retry aws s3 cp s3://${workerBundle.bucketName}/${workerBundle.objectKey} ${WORKER_BUNDLE_DOWNLOAD_PATH}`, `echo '${workerBundle.sha256} ${WORKER_BUNDLE_DOWNLOAD_PATH}' | sha256sum -c -`, '# Root-owned, and read-only to the service (ProtectSystem=strict below).', `install -D -m 0644 ${WORKER_BUNDLE_DOWNLOAD_PATH} /opt/canopy-worker/index.js`, '# The worker bundle is ESM (esbuild --format=esm). Without this marker a', '# .js file is CommonJS by default and the import statement fails.', '# Node >=22.7 auto-detects module syntax and would mask its absence, and', '# this instance now runs node-22 -- so the marker is kept BECAUSE that', '# auto-detection is a fallback we should not depend on, not because the', '# runtime here still needs it.', `echo '{"type":"module"}' > /opt/canopy-worker/package.json`, '', '# Write environment file for systemd service', `cat > /opt/canopy-worker/.env << 'ENVEOF'`, envFileContent, 'ENVEOF', '', '# Create systemd service', `cat > /etc/systemd/system/canopy-worker.service << 'SVCEOF'`, '[Unit]', 'Description=CanopyCMS Worker Daemon', 'After=network.target', '# Never run against an unmounted /mnt/efs (see the fstab note above).', `RequiresMountsFor=${EFS_MOUNT_PATH}`, '', '[Service]', 'Type=simple', 'User=ec2-user', 'WorkingDirectory=/opt/canopy-worker', '# Namespaced binary, not bare `node`: AL2023 points /usr/bin/node at', '# an installed version through `alternatives`, and AWS documents that', '# selection as able to change at any time. node-22 always means 22.', 'ExecStart=/usr/bin/node-22 index.js', 'Restart=always', 'RestartSec=5', 'TimeoutStartSec=300', '# Stopping drains: SIGTERM reaches node ONLY (mixed), so the git', '# children it waits for keep running. Whatever is left when node', '# exits, or at TimeoutStopSec, is SIGKILLed; 120s outlasts the 90s', '# drain and its abort grace.', 'KillMode=mixed', 'TimeoutStopSec=120', '# A worker that drained for an instance termination exits with this;', '# restarting it would start work on an instance about to disappear.', `RestartPreventExitStatus=${EXIT_DRAINED_FOR_TERMINATION}`, `SuccessExitStatus=${EXIT_DRAINED_FOR_TERMINATION}`, '# File output, not journal: the CloudWatch agent cannot read journald,', '# so it tails this file instead (see the agent config below).', '# CAUTION: /var/log/canopy-worker must exist BEFORE first start. systemd', '# opens append: targets before it creates LogsDirectory= dirs', '# (systemd#27591), so without the pre-created dir the exec fails with', '# 209/STDOUT and Restart=always crash-loops forever. User-data runs', '# mkdir before systemctl start; LogsDirectory= is kept for ownership.', 'LogsDirectory=canopy-worker', 'StandardOutput=append:/var/log/canopy-worker/worker.log', 'StandardError=append:/var/log/canopy-worker/worker.log', '# The private GitHub mirror, /var/lib/canopy-worker on the root volume:', '# the only git repository the GitHub credential is used in. The worker', '# does not start without it.', 'StateDirectory=canopy-worker', 'EnvironmentFile=/opt/canopy-worker/.env', '# Sandbox. Writable: the workspace, the log dir (LogsDirectory=), the', '# state dir (StateDirectory=) and a private /tmp.', '# ProtectHome=tmpfs, not yes: every git call reads per-user files under', '# $HOME (~/.config/git/attributes, ignore), and an inaccessible /home', '# makes each read warn "Permission denied"; an empty one is a silent', '# ENOENT.', ...WORKER_SANDBOX_DIRECTIVES, `ReadWritePaths=${EFS_MOUNT_PATH}`, '', '[Install]', 'WantedBy=multi-user.target', 'SVCEOF', '', '# Pre-create the worker log dir (crash-loop guard, MUST precede the', '# first systemctl start): systemd opens StandardOutput=append: files', '# BEFORE it creates LogsDirectory= dirs (systemd#27591), so on a fresh', '# instance the unit would fail exec with 209/STDOUT and crash-loop', '# forever without this. The unit keeps LogsDirectory= for ownership', '# management on subsequent starts.', 'mkdir -p /var/log/canopy-worker', 'chown ec2-user:ec2-user /var/log/canopy-worker', '', '# Start worker', 'systemctl daemon-reload', 'systemctl enable canopy-worker', 'systemctl start canopy-worker', '', '# ---- CloudWatch log shipping ----', '# Placed AFTER worker start: with set -euo pipefail, a package/agent', '# failure here must not prevent the worker from running (shipping is', '# best-effort).', '#', '# DISARM THE FAIL-FAST TRAP AND errexit FIRST. Everything above this line', '# is required for the worker to exist at all, so a failure there should', '# replace the instance. Nothing below it is: the worker is already', '# running and healthy by this point. Leaving the trap armed would let a', '# package-mirror outage during the agent install shut down a perfectly', '# good worker -- and the ASG would relaunch straight into the same', '# outage, turning degraded log shipping into a replacement loop with the', '# worker down for its duration. That is strictly worse than the', '# best-effort behaviour this section has always documented.', 'trap - ERR', 'set +e', 'retry dnf install -y amazon-cloudwatch-agent logrotate', '', '# Bound on-disk growth; copytruncate keeps the fd the CW agent tails valid', '# (tiny copy->truncate loss window is acceptable for diagnostic logs).', `cat > /etc/logrotate.d/canopy-worker << 'ROTEOF'`, '/var/log/canopy-worker/worker.log {', ' size 10M', ' rotate 5', ' compress', ' copytruncate', ' missingok', ' notifempty', '}', 'ROTEOF', '# The config above only fires when logrotate actually runs; AL2023', '# presets may leave logrotate.timer disabled, and without it the size', '# cap never triggers and worker.log grows until the nano disk fills.', '# --now is idempotent if the timer is already enabled/running.', 'systemctl enable --now logrotate.timer', '', '# No retention_in_days here: CDK owns retention on the pre-created group.', `cat > /opt/aws/amazon-cloudwatch-agent/etc/canopy-worker-logs.json << 'CWEOF'`, '{', ' "logs": {', ' "logs_collected": {', ' "files": {', ' "collect_list": [', ' {', ' "file_path": "/var/log/canopy-worker/worker.log",', ` "log_group_name": "${this.workerLogGroup.logGroupName}",`, ' "log_stream_name": "{instance_id}",',
969
1073
  // The worker prefixes every line with an ISO-8601 timestamp
970
1074
  // (packages/canopycms/src/worker/log.ts). Parsing it here is what makes
971
1075
  // CloudWatch show the time the WORKER emitted a line rather than the
@@ -995,9 +1099,28 @@ export class CanopyCmsService extends Construct {
995
1099
  role: workerRole,
996
1100
  securityGroup: workerSg,
997
1101
  userData,
1102
+ requireImdsv2: true,
1103
+ // 1, not AL2023's 2: everything that reads metadata runs on the host,
1104
+ // and a second hop only serves containers.
1105
+ httpPutResponseHopLimit: 1,
1106
+ blockDevices: [
1107
+ {
1108
+ // AL2023's root device. Its size is removed below.
1109
+ deviceName: '/dev/xvda',
1110
+ volume: ec2.BlockDeviceVolume.ebs(8, {
1111
+ encrypted: true,
1112
+ volumeType: ec2.EbsDeviceVolumeType.GP3,
1113
+ deleteOnTermination: true,
1114
+ }),
1115
+ },
1116
+ ],
998
1117
  });
1118
+ launchTemplate.node.defaultChild.addPropertyDeletionOverride('LaunchTemplateData.BlockDeviceMappings.0.Ebs.VolumeSize');
999
1119
  this.workerAsg = new autoscaling.AutoScalingGroup(this, 'WorkerAsg', {
1000
1120
  vpc: this.vpc,
1121
+ // Public, deliberately: it reaches GitHub and AWS APIs through the
1122
+ // internet gateway instead of a NAT gateway that would cost more than
1123
+ // the rest of the stack. Its security group admits no inbound traffic.
1001
1124
  vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
1002
1125
  // Spot goes through a mixed-instances policy rather than spot options on
1003
1126
  // the launch template, so the group can choose among several pools.
@@ -1020,6 +1143,9 @@ export class CanopyCmsService extends Construct {
1020
1143
  : { launchTemplate }),
1021
1144
  minCapacity: 1,
1022
1145
  maxCapacity: 1,
1146
+ maxInstanceLifetime: props.workerMaxInstanceLifetime === null
1147
+ ? undefined
1148
+ : (props.workerMaxInstanceLifetime ?? Duration.days(7)),
1023
1149
  // `healthChecks`, not the deprecated `healthCheck`/`HealthCheck.ec2({ grace })`:
1024
1150
  // both synthesize the same HealthCheckType/HealthCheckGracePeriod, but the
1025
1151
  // deprecated form prints a jsii warning on every synth -- in adopters'
@@ -1030,9 +1156,9 @@ export class CanopyCmsService extends Construct {
1030
1156
  // Without an updatePolicy, CloudFormation's default behavior for an ASG
1031
1157
  // behind a changed launch template is to update the template resource
1032
1158
  // and do NOTHING else - the running instance keeps its old user-data
1033
- // (and therefore the old worker code: the worker bundle is a CDK S3
1034
- // asset whose hash is interpolated into user-data's `aws s3 cp
1035
- // s3://...`) until an interruption or a manual terminate happens to
1159
+ // (and therefore the old worker code: the bundle's key and sha256,
1160
+ // from the CDK asset or the workerCode parameter, are interpolated into
1161
+ // user data) until an interruption or a manual terminate happens to
1036
1162
  // replace it. `cdk deploy` would then silently deploy everything
1037
1163
  // EXCEPT the worker. `rollingUpdate` makes CloudFormation actually
1038
1164
  // terminate-and-relaunch the instance on every deploy that changes the
@@ -0,0 +1,36 @@
1
+ import type { Construct } from 'constructs';
2
+ import { CfnParameter, aws_iam as iam, aws_s3 as s3 } from 'aws-cdk-lib';
3
+ /** Where user data downloads the bundle to and checks it, before installing it. */
4
+ export declare const WORKER_BUNDLE_DOWNLOAD_PATH = "/tmp/canopy-worker.js";
5
+ /**
6
+ * Where the worker's bundle comes from.
7
+ *
8
+ * - `'asset'` (default): a CDK asset, so a canopycms upgrade changes the
9
+ * template and only a template deploy moves the worker.
10
+ * - `'parameter'`: a bucket of bundles keyed by sha256, chosen by a template
11
+ * parameter, so a parameter-only change set (`--use-previous-template`)
12
+ * can roll the worker. While the parameter is empty the worker runs the
13
+ * template's own asset, as in `'asset'` mode. Once set, the parameter
14
+ * outlives template deploys: `cdk deploy` keeps a parameter's previous
15
+ * value.
16
+ */
17
+ export type WorkerCode = {
18
+ source: 'asset';
19
+ } | {
20
+ source: 'parameter';
21
+ };
22
+ interface WorkerBundleSource {
23
+ /** S3 location and expected sha256, as tokens for user data. */
24
+ readonly bucketName: string;
25
+ readonly objectKey: string;
26
+ readonly sha256: string;
27
+ /** Parameter mode only. */
28
+ readonly bundleBucket?: s3.Bucket;
29
+ readonly sha256Parameter?: CfnParameter;
30
+ }
31
+ /**
32
+ * The worker bundle's source, with the worker granted read on exactly what it
33
+ * may download.
34
+ */
35
+ export declare function workerBundleSource(scope: Construct, workerCode: WorkerCode, workerRole: iam.IRole): WorkerBundleSource;
36
+ export {};
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CfnCondition, CfnOutput, CfnParameter, Duration, Fn, RemovalPolicy, Token, aws_iam as iam, aws_s3 as s3, aws_s3_assets as s3assets, } from 'aws-cdk-lib';
6
+ // This package is `"type": "module"`, so `__dirname` is not a global in its
7
+ // compiled output. Vitest shims it, so this file's tests would not notice.
8
+ // Same fix as ./asset-support.ts.
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ /**
11
+ * The worker bundle: the single file esbuild writes (`pnpm run build:worker`),
12
+ * shipped to the instance as-is so its sha256 is known at synth. A directory
13
+ * asset would be zipped by cdk-assets at publish time, after synth.
14
+ */
15
+ const WORKER_BUNDLE_PATH = path.join(__dirname, '../../worker/dist/index.js');
16
+ /** Where user data downloads the bundle to and checks it, before installing it. */
17
+ export const WORKER_BUNDLE_DOWNLOAD_PATH = '/tmp/canopy-worker.js';
18
+ /** Where `workerCode: { source: 'parameter' }` expects bundles in its bucket: `<prefix><sha256>.js`. */
19
+ const WORKER_BUNDLE_KEY_PREFIX = 'canopy-worker/';
20
+ function sha256OfFile(file) {
21
+ return createHash('sha256').update(readFileSync(file)).digest('hex');
22
+ }
23
+ /**
24
+ * The worker bundle's source, with the worker granted read on exactly what it
25
+ * may download.
26
+ */
27
+ export function workerBundleSource(scope, workerCode, workerRole) {
28
+ const asset = new s3assets.Asset(scope, 'WorkerCode', { path: WORKER_BUNDLE_PATH });
29
+ const assetSha256 = sha256OfFile(WORKER_BUNDLE_PATH);
30
+ // This one object, not `asset.grantRead`'s whole bootstrap bucket. No KMS
31
+ // grant: the bootstrap bucket's key (aws/s3, or the key bootstrap creates)
32
+ // lets any principal in the account decrypt through S3.
33
+ const readable = [asset.bucket.arnForObjects(asset.s3ObjectKey)];
34
+ if (workerCode.source === 'asset') {
35
+ workerRole.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['s3:GetObject'], resources: readable }));
36
+ return { bucketName: asset.s3BucketName, objectKey: asset.s3ObjectKey, sha256: assetSha256 };
37
+ }
38
+ const bundleBucket = new s3.Bucket(scope, 'WorkerBundleBucket', {
39
+ versioned: true,
40
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
41
+ encryption: s3.BucketEncryption.S3_MANAGED,
42
+ enforceSSL: true,
43
+ removalPolicy: RemovalPolicy.RETAIN,
44
+ // Re-uploading a bundle under its own hash leaves a noncurrent version of
45
+ // the same bytes; a launch template only ever names current ones.
46
+ lifecycleRules: [{ noncurrentVersionExpiration: Duration.days(30) }],
47
+ });
48
+ // A bundle a running or rolled-back launch template names must stay
49
+ // downloadable. Not PutBucketPolicy too: that would lock CloudFormation out
50
+ // of this policy. Emptying the bucket starts by removing this statement.
51
+ bundleBucket.addToResourcePolicy(new iam.PolicyStatement({
52
+ effect: iam.Effect.DENY,
53
+ principals: [new iam.AnyPrincipal()],
54
+ actions: ['s3:DeleteObject', 's3:DeleteObjectVersion'],
55
+ resources: [bundleBucket.arnForObjects('*')],
56
+ }));
57
+ readable.push(bundleBucket.arnForObjects(`${WORKER_BUNDLE_KEY_PREFIX}*`));
58
+ workerRole.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['s3:GetObject'], resources: readable }));
59
+ // One value names the object AND is what it is checked against, so a key
60
+ // and a hash cannot disagree.
61
+ const sha256Parameter = new CfnParameter(scope, 'WorkerBundleSha256', {
62
+ type: 'String',
63
+ default: '',
64
+ allowedPattern: '^([0-9a-f]{64})?$',
65
+ description: `sha256 of the CanopyCMS worker bundle to run, uploaded to the worker bundle bucket as ` +
66
+ `${WORKER_BUNDLE_KEY_PREFIX}<sha256>.js. Empty runs the bundle this template was synthesized with.`,
67
+ });
68
+ const fromParameter = new CfnCondition(scope, 'WorkerBundleFromParameter', {
69
+ expression: Fn.conditionNot(Fn.conditionEquals(sha256Parameter.valueAsString, '')),
70
+ });
71
+ const pick = (whenParameter, whenAsset) => Token.asString(Fn.conditionIf(fromParameter.logicalId, whenParameter, whenAsset));
72
+ new CfnOutput(scope, 'WorkerBundleSha256ParameterName', {
73
+ description: 'The template parameter that selects the worker bundle',
74
+ value: sha256Parameter.logicalId,
75
+ });
76
+ new CfnOutput(scope, 'WorkerBundleBucketName', {
77
+ description: `The bucket holding worker bundles, as ${WORKER_BUNDLE_KEY_PREFIX}<sha256>.js`,
78
+ value: bundleBucket.bucketName,
79
+ });
80
+ return {
81
+ bucketName: pick(bundleBucket.bucketName, asset.s3BucketName),
82
+ objectKey: pick(Fn.join('', [WORKER_BUNDLE_KEY_PREFIX, sha256Parameter.valueAsString, '.js']), asset.s3ObjectKey),
83
+ sha256: pick(sha256Parameter.valueAsString, assetSha256),
84
+ bundleBucket,
85
+ sha256Parameter,
86
+ };
87
+ }
@@ -11,8 +11,22 @@ export declare const WORKER_DRAIN_HOOK_NAME = "canopycms-worker-drain";
11
11
  * does not start a fresh worker on an instance about to disappear.
12
12
  */
13
13
  export declare const EXIT_DRAINED_FOR_TERMINATION = 75;
14
+ /**
15
+ * The worker's exit status after it stopped itself (its EFS lock was
16
+ * compromised). Non-zero, and deliberately not {@link EXIT_DRAINED_FOR_TERMINATION}
17
+ * (75), which the unit lists in `RestartPreventExitStatus=`: this one must let
18
+ * `Restart=always` start a fresh worker. Not in `SuccessExitStatus=` either.
19
+ * 69 is sysexits' EX_UNAVAILABLE.
20
+ */
21
+ export declare const EXIT_WORKER_SELF_STOPPED = 69;
14
22
  /**
15
23
  * Set to `spot` in the worker's environment when it runs on spot capacity, which
16
24
  * arms the watch for a spot interruption notice.
17
25
  */
18
26
  export declare const WORKER_CAPACITY_ENV = "CANOPYCMS_WORKER_CAPACITY";
27
+ /**
28
+ * The text of the line `syncGit()` (canopycms `worker/git-sync.ts`) logs at the
29
+ * start of every git-sync cycle. The worker-down alarm's metric filter counts
30
+ * it, so the alarm misfires if the worker's text drifts from this one.
31
+ */
32
+ export declare const WORKER_SYNC_LOG_PHRASE = "Syncing git...";