canopycms-cdk 0.0.68-int.111 → 0.0.68-int.113

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.
@@ -362,6 +362,9 @@ export interface CanopyCmsServiceProps {
362
362
  * minutes, which covers a crash loop, a boot loop, a worker that never
363
363
  * started and a worker whose loops have stopped. A deploy or a spot
364
364
  * replacement does not trip it. See `workerDownAlarm`.
365
+ *
366
+ * The topic is also notified when a worker boot could not upgrade its
367
+ * packages and started on the AMI's. See `workerUnpatchedAlarm`.
365
368
  */
366
369
  alarmTopic?: sns.ITopic;
367
370
  /**
@@ -482,6 +485,8 @@ export declare class CanopyCmsService extends Construct {
482
485
  readonly workerLogGroup: logs.LogGroup;
483
486
  /** Alarms when the worker logs no git-sync cycle for 30 minutes. Set only with `alarmTopic`. */
484
487
  readonly workerDownAlarm?: cloudwatch.Alarm;
488
+ /** Alarms when a worker boot started without its package upgrade. Set only with `alarmTopic`. */
489
+ readonly workerUnpatchedAlarm?: cloudwatch.Alarm;
485
490
  /**
486
491
  * With `workerCode: { source: 'parameter' }`: the bucket worker bundles are
487
492
  * uploaded to, as `canopy-worker/<sha256>.js`.
@@ -3,7 +3,7 @@ import { Annotations, ArnFormat, Duration, Names, RemovalPolicy, Stack, Token, a
3
3
  import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
4
4
  import { WORKER_BUNDLE_DOWNLOAD_PATH, workerBundleSource } from './worker-bundle.js';
5
5
  import { attachEditorBehaviors } from './editor-routing.js';
6
- import { EXIT_DRAINED_FOR_TERMINATION, WORKER_CAPACITY_ENV, WORKER_DRAIN_HOOK_NAME, WORKER_SYNC_LOG_PHRASE, } from './worker-lifecycle.js';
6
+ import { EXIT_DRAINED_FOR_TERMINATION, WORKER_CAPACITY_ENV, WORKER_CONTRACT_ENV, WORKER_CONTRACT_VERSION, WORKER_DRAIN_HOOK_NAME, WORKER_SYNC_LOG_PHRASE, } from './worker-lifecycle.js';
7
7
  /**
8
8
  * Synth-time mirror of `resolveDeploymentName`'s rule in the `canopycms`
9
9
  * package (packages/canopycms/src/operating-mode/deployment-name.ts).
@@ -37,6 +37,12 @@ const isValidDeploymentName = (name) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name
37
37
  * by one process must resolve for the other.
38
38
  */
39
39
  const EFS_MOUNT_PATH = '/mnt/efs';
40
+ /**
41
+ * The text of the worker.log line user data writes when the boot's package
42
+ * upgrade failed and the worker runs on the AMI's packages. The unpatched
43
+ * alarm's metric filter counts it.
44
+ */
45
+ const WORKER_UNPATCHED_LOG_PHRASE = 'running unpatched on the AMI packages until the next boot';
40
46
  /**
41
47
  * The worker unit's sandbox, also in worker/canopy-worker.service. Node and git
42
48
  * need none of what these take away; `MemoryDenyWriteExecute` is left out
@@ -989,6 +995,28 @@ export class CanopyCmsService extends Construct {
989
995
  const topicAction = new cloudwatchActions.SnsAction(props.alarmTopic);
990
996
  this.workerDownAlarm.addAlarmAction(topicAction);
991
997
  this.workerDownAlarm.addOkAction(topicAction);
998
+ const unpatchedPrefix = 'WorkerUnpatchedBoots-';
999
+ const unpatchedBoots = new logs.MetricFilter(this, 'WorkerUnpatchedBoots', {
1000
+ logGroup: this.workerLogGroup,
1001
+ filterPattern: logs.FilterPattern.literal(`"${WORKER_UNPATCHED_LOG_PHRASE}"`),
1002
+ metricNamespace: 'CanopyCMS',
1003
+ metricName: `${unpatchedPrefix}${Names.uniqueResourceName(this, { maxLength: 255 - unpatchedPrefix.length })}`,
1004
+ metricValue: '1',
1005
+ });
1006
+ // One line per unpatched boot, so the alarm returns to OK one period
1007
+ // later on its own; an OK notification would only repeat the alarm.
1008
+ this.workerUnpatchedAlarm = new cloudwatch.Alarm(this, 'WorkerUnpatchedAlarm', {
1009
+ metric: unpatchedBoots.metric({ statistic: 'Sum', period: Duration.minutes(10) }),
1010
+ threshold: 1,
1011
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
1012
+ evaluationPeriods: 1,
1013
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
1014
+ alarmDescription: `A CMS worker boot could not upgrade its packages and started on the AMI's. It is ` +
1015
+ `working, and the next boot retries the upgrade. The boot's console output ` +
1016
+ `(aws ec2 get-console-output) and the worker log group ` +
1017
+ `${this.workerLogGroup.logGroupName} name the failure.`,
1018
+ });
1019
+ this.workerUnpatchedAlarm.addAlarmAction(topicAction);
992
1020
  }
993
1021
  const workerBundle = workerBundleSource(this, props.workerCode ?? { source: 'asset' }, workerRole);
994
1022
  this.workerBundleBucket = workerBundle.bundleBucket;
@@ -1069,7 +1097,7 @@ export class CanopyCmsService extends Construct {
1069
1097
  .join('\n');
1070
1098
  const efsMountOptions = `tls,iam,accesspoint=${accessPoint.accessPointId}`;
1071
1099
  const userData = ec2.UserData.forLinux();
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}",',
1100
+ userData.addCommands('#!/bin/bash', 'set -euo pipefail', '', '# FAIL-FAST. Every step below is required for the worker to exist at', '# all, except the swap and the package upgrade, which are written as', '# conditions so that they cannot trip it. Until this trap existed, a', '# failure in any required step 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. The first argument names the step in the message', '# an exhausted step leaves on the console.', 'retry() {', ' local step=$1 n=0', ' shift', ' until "$@"; do', ' n=$((n + 1))', ' if [ "$n" -ge 5 ]; then', ` echo "canopy-worker boot: '$step' failed after $n attempts" >&2`, ' return 1', ' fi', ' sleep $((n * 5))', ' done', '}', '', '# Swap, before the first dnf: loading the repository metadata takes more', '# memory than a t4g.nano has free after boot, and the kernel OOM-kills', '# dnf. It stays on afterwards at low swappiness, as headroom for git and', '# the CloudWatch agent rather than working memory. It is on the encrypted', '# root volume, so memory paged out of the worker is encrypted at rest.', '# fallocate is safe here: XFS, the root file system, supports', '# preallocated swap files. A failure only warns, because dnf may still fit', '# and a boot that stopped here would put the group into a replacement', '# loop. Each command carries `|| return 1` because errexit does not apply', '# inside a function called as a condition.', 'setup_swap() {', ' swapon --show=NAME --noheadings | grep -qx /swapfile && return 0', ' if [ "$(stat -c %s /swapfile 2>/dev/null)" != 1073741824 ]; then', ' # The file, plus 2 GiB for packages, logs and the GitHub mirror.', ' local avail_mib', ` avail_mib=$(df --output=avail -m / | tail -n 1 | tr -d ' ')`, ' case "$avail_mib" in ""|*[!0-9]*) return 1 ;; esac', ' if [ "$avail_mib" -lt 3072 ]; then', ' echo "canopy-worker boot: under 3 GiB free on /, so no swap file" >&2', ' return 1', ' fi', ' rm -f /swapfile || return 1', ' fallocate -l 1G /swapfile || return 1', ' fi', ' chmod 600 /swapfile || return 1', ' mkswap /swapfile || return 1', ' # swapon last, so that swap is never on when this returns 1.', " grep -qs '^/swapfile ' /etc/fstab || echo '/swapfile none swap defaults 0 0' >> /etc/fstab || return 1", ' mkdir -p /etc/sysctl.d || return 1', " echo 'vm.swappiness = 10' > /etc/sysctl.d/90-canopy-worker-swap.conf || return 1", ' sysctl -q -w vm.swappiness=10 || return 1', ' swapon /swapfile', '}', 'if ! setup_swap; then', ` echo "canopy-worker boot: 'swap' setup failed; continuing without swap" >&2`, 'fi', '', '# 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.', '#', "# A failed upgrade does not stop the boot: the worker starts on the AMI's", '# packages and the next boot (by default the weekly recycle at the', '# latest) tries again. Failing here would put the group into a', '# replacement loop with no worker at all. The installs below then', '# resolve from the AMI release: 2023.7.20250428 carries all of them,', '# 2023.7.20250331 lacks nodejs22. The failure is written to worker.log', '# below, for the unpatched alarm.', 'WORKER_UNPATCHED=0', "if ! retry 'dnf upgrade' dnf upgrade --releasever=latest --exclude='kernel*' -y; then", ' WORKER_UNPATCHED=1', ` echo "canopy-worker boot: ${WORKER_UNPATCHED_LOG_PHRASE}" >&2`, 'fi', '', "retry 'dnf install git' 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 nodejs22' 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 amazon-efs-utils' dnf install -y amazon-efs-utils", `mkdir -p ${EFS_MOUNT_PATH}`, '# Retried: an `iam` mount also fetches instance-role credentials.', `retry 'EFS mount' 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 'worker bundle download' 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).', '# systemd opens append: targets as root, following symlinks, before it', '# applies the sandbox or drops to User=. So the worker must own neither', '# the file nor its directory: one that could swap worker.log for a', '# symlink would have its output appended to any file on the host as root.', '# Hence no LogsDirectory=, which chowns the directory to User=; user-data', '# creates both, root-owned, before the first start. The worker writes', '# through the descriptor systemd hands it.', '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', '# What this unit provides; a bundle needing more refuses to start.', `Environment=${WORKER_CONTRACT_ENV}=${WORKER_CONTRACT_VERSION}`, '# Sandbox. Writable: the workspace, 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', '', '# The worker log, root-owned (see the unit), and created BEFORE the first', '# systemctl start: without the directory the append: open fails with', '# 209/STDOUT and Restart=always crash-loops forever. 0644, so it stays', '# readable without sudo.', 'install -d -o root -g root -m 0755 /var/log/canopy-worker', 'install -o root -g root -m 0644 /dev/null /var/log/canopy-worker/worker.log', '', '# In the worker log format (canopycms worker/log.ts), so the CloudWatch', '# agent ships it as its own event: the agent reads a file it has no saved', '# position for from the start (its `from_beginning` defaults to true).', 'if [ "$WORKER_UNPATCHED" = 1 ]; then', ` echo "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) ERROR canopy-worker boot: 'dnf upgrade' failed; ${WORKER_UNPATCHED_LOG_PHRASE}" >> /var/log/canopy-worker/worker.log`, 'fi', '', '# 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 amazon-cloudwatch-agent logrotate' 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}",',
1073
1101
  // The worker prefixes every line with an ISO-8601 timestamp
1074
1102
  // (packages/canopycms/src/worker/log.ts). Parsing it here is what makes
1075
1103
  // CloudWatch show the time the WORKER emitted a line rather than the
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
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
+ import { WORKER_CONTRACT_VERSION } from './worker-lifecycle.js';
6
7
  // This package is `"type": "module"`, so `__dirname` is not a global in its
7
8
  // compiled output. Vitest shims it, so this file's tests would not notice.
8
9
  // Same fix as ./asset-support.ts.
@@ -73,6 +74,13 @@ export function workerBundleSource(scope, workerCode, workerRole) {
73
74
  description: 'The template parameter that selects the worker bundle',
74
75
  value: sha256Parameter.logicalId,
75
76
  });
77
+ // Read by a change-set gate before a parameter-only roll: the bundle's
78
+ // `index.js.contract` must not exceed it, because such a change set runs the
79
+ // new bundle under this template's unit.
80
+ new CfnOutput(scope, 'WorkerContract', {
81
+ description: 'The worker contract version this template provides',
82
+ value: String(WORKER_CONTRACT_VERSION),
83
+ });
76
84
  new CfnOutput(scope, 'WorkerBundleBucketName', {
77
85
  description: `The bucket holding worker bundles, as ${WORKER_BUNDLE_KEY_PREFIX}<sha256>.js`,
78
86
  value: bundleBucket.bucketName,
@@ -24,6 +24,26 @@ export declare const EXIT_WORKER_SELF_STOPPED = 69;
24
24
  * arms the watch for a spot interruption notice.
25
25
  */
26
26
  export declare const WORKER_CAPACITY_ENV = "CANOPYCMS_WORKER_CAPACITY";
27
+ /**
28
+ * The worker contract version: stamped into the unit by the construct (and,
29
+ * in parameter mode, into the `WorkerContract` stack output), and published beside the
30
+ * bundle as `worker/dist/index.js.contract`, so a change-set gate can compare
31
+ * the two before rolling a bundle. See {@link WORKER_CONTRACT_REQUIREMENTS}.
32
+ */
33
+ export declare const WORKER_CONTRACT_VERSION: 1;
34
+ /** The unit's `Environment=` variable carrying its worker contract version. */
35
+ export declare const WORKER_CONTRACT_ENV = "CANOPYCMS_WORKER_CONTRACT";
36
+ /**
37
+ * The unit's worker contract version, read from the worker's environment as
38
+ * {@link WORKER_CONTRACT_REQUIREMENTS} describes, or `NaN` for a stamp that is
39
+ * not a whole number.
40
+ */
41
+ export declare function unitWorkerContract(env: Readonly<Record<string, string | undefined>>): number;
42
+ /**
43
+ * The fatal line for a unit older than this bundle, or `undefined` when the
44
+ * unit's contract is new enough.
45
+ */
46
+ export declare function workerContractShortfall(env: Readonly<Record<string, string | undefined>>): string | undefined;
27
47
  /**
28
48
  * The text of the line `syncGit()` (canopycms `worker/git-sync.ts`) logs at the
29
49
  * start of every git-sync cycle. The worker-down alarm's metric filter counts
@@ -24,6 +24,62 @@ export const EXIT_WORKER_SELF_STOPPED = 69;
24
24
  * arms the watch for a spot interruption notice.
25
25
  */
26
26
  export const WORKER_CAPACITY_ENV = 'CANOPYCMS_WORKER_CAPACITY';
27
+ /**
28
+ * What each worker contract version requires of the unit and template, in
29
+ * order: entry `i` is version `i + 1`. The construct stamps the latest version
30
+ * into every unit it writes, and a bundle refuses to start under a unit whose
31
+ * version is lower.
32
+ *
33
+ * A unit with no stamp predates the stamp, so its version is read from what
34
+ * it observably provides: 1 when `STATE_DIRECTORY` is set (systemd sets it
35
+ * from `StateDirectory=`), else 0. Inference never reads past 1, so from
36
+ * version 2 on only an explicit stamp satisfies a bundle.
37
+ *
38
+ * Append an entry ONLY when the bundle starts to need something new from the
39
+ * unit or template. Anything a bundle merely tolerates the absence of does
40
+ * not count. The bundle's "template too old" line names every entry above the
41
+ * unit's version, so write it as the setting an operator would add.
42
+ */
43
+ const WORKER_CONTRACT_REQUIREMENTS = ['StateDirectory=canopy-worker'];
44
+ /**
45
+ * The worker contract version: stamped into the unit by the construct (and,
46
+ * in parameter mode, into the `WorkerContract` stack output), and published beside the
47
+ * bundle as `worker/dist/index.js.contract`, so a change-set gate can compare
48
+ * the two before rolling a bundle. See {@link WORKER_CONTRACT_REQUIREMENTS}.
49
+ */
50
+ export const WORKER_CONTRACT_VERSION = WORKER_CONTRACT_REQUIREMENTS.length;
51
+ /** The unit's `Environment=` variable carrying its worker contract version. */
52
+ export const WORKER_CONTRACT_ENV = 'CANOPYCMS_WORKER_CONTRACT';
53
+ /**
54
+ * The unit's worker contract version, read from the worker's environment as
55
+ * {@link WORKER_CONTRACT_REQUIREMENTS} describes, or `NaN` for a stamp that is
56
+ * not a whole number.
57
+ */
58
+ export function unitWorkerContract(env) {
59
+ const stamp = env[WORKER_CONTRACT_ENV];
60
+ if (stamp === undefined)
61
+ return env.STATE_DIRECTORY ? 1 : 0;
62
+ return /^\d+$/.test(stamp) ? Number(stamp) : NaN;
63
+ }
64
+ /**
65
+ * The fatal line for a unit older than this bundle, or `undefined` when the
66
+ * unit's contract is new enough.
67
+ */
68
+ export function workerContractShortfall(env) {
69
+ const unit = unitWorkerContract(env);
70
+ if (Number.isNaN(unit)) {
71
+ return `canopy-worker: ${WORKER_CONTRACT_ENV}=${JSON.stringify(env[WORKER_CONTRACT_ENV])} on the worker unit is not a whole number`;
72
+ }
73
+ if (unit >= WORKER_CONTRACT_VERSION)
74
+ return undefined;
75
+ return (`canopy-worker: template too old for this bundle: needs worker contract ` +
76
+ `${WORKER_CONTRACT_VERSION}, unit has ${unit} (` +
77
+ WORKER_CONTRACT_REQUIREMENTS.slice(unit)
78
+ .map((setting, i) => `contract ${unit + i + 1} adds ${setting}`)
79
+ .join('; ') +
80
+ `). Deploy the stack template ` +
81
+ `before rolling this bundle.`);
82
+ }
27
83
  /**
28
84
  * The text of the line `syncGit()` (canopycms `worker/git-sync.ts`) logs at the
29
85
  * start of every git-sync cycle. The worker-down alarm's metric filter counts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-cdk",
3
- "version": "0.0.68-int.111",
3
+ "version": "0.0.68-int.113",
4
4
  "description": "AWS CDK constructs and EC2 worker for CanopyCMS deployment",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "peerDependencies": {
32
32
  "aws-cdk-lib": "^2.260.0",
33
33
  "constructs": "^10.0.0",
34
- "canopycms": "0.0.68-int.111"
34
+ "canopycms": "0.0.68-int.113"
35
35
  },
36
36
  "peerDependenciesMeta": {
37
37
  "aws-cdk-lib": {
@@ -57,15 +57,15 @@
57
57
  "tsx": "^4.19.0",
58
58
  "typescript": "^5.6.3",
59
59
  "vitest": "^4.1.0",
60
- "canopycms": "0.0.68-int.111",
61
- "canopycms-auth-clerk": "0.0.68-int.111"
60
+ "canopycms": "0.0.68-int.113",
61
+ "canopycms-auth-clerk": "0.0.68-int.113"
62
62
  },
63
63
  "scripts": {
64
64
  "build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",
65
65
  "build:worker": "esbuild worker/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=worker/dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
66
- "build:worker-checksum": "node worker/write-bundle-checksum.mjs",
66
+ "build:worker-metadata": "node --import tsx worker/write-bundle-metadata.ts",
67
67
  "build:lambda": "node lambda/asset-transform/build.mjs",
68
- "build:test-fixtures": "pnpm run build:worker && pnpm run build:worker-checksum && node lambda/asset-transform/build.mjs --skip-native",
68
+ "build:test-fixtures": "pnpm run build:worker && pnpm run build:worker-metadata && node lambda/asset-transform/build.mjs --skip-native",
69
69
  "lint": "eslint src/ 'lambda/**/*.ts' 'canary/**/*.ts' 'worker/**/*.ts' 'test-support/**/*.ts'",
70
70
  "test": "pnpm run build:test-fixtures && vitest run",
71
71
  "typecheck": "tsc --noEmit && tsc --noEmit -p lambda/tsconfig.json && tsc --noEmit -p canary/tsconfig.json && tsc --noEmit -p worker/tsconfig.json && tsc --noEmit -p test-support/tsconfig.json"
@@ -26,13 +26,15 @@ SuccessExitStatus=75
26
26
  # File output, not journal: the CloudWatch agent cannot read journald,
27
27
  # so it tails this file instead (agent config lives in the cms-service.ts
28
28
  # user-data, which writes the deployed copy of this unit).
29
- # CAUTION: /var/log/canopy-worker must exist BEFORE first start. systemd
30
- # opens append: targets before it creates LogsDirectory= dirs
31
- # (systemd#27591), so without the pre-created dir the exec fails with
32
- # 209/STDOUT and Restart=always crash-loops forever. The deployed user-data
33
- # runs mkdir first; do the same if you install this unit by hand.
34
- # LogsDirectory= is kept for ownership management on subsequent starts.
35
- LogsDirectory=canopy-worker
29
+ # systemd opens append: targets as root, following symlinks, before it
30
+ # applies the sandbox or drops to User=. So the worker must own neither the
31
+ # file nor its directory: one that could swap worker.log for a symlink would
32
+ # have its output appended to any file on the host as root. Hence no
33
+ # LogsDirectory=, which chowns the directory to User=. Create both, root-owned,
34
+ # BEFORE the first start (without the directory the exec fails with 209/STDOUT
35
+ # and Restart=always crash-loops):
36
+ # install -d -o root -g root -m 0755 /var/log/canopy-worker
37
+ # install -o root -g root -m 0644 /dev/null /var/log/canopy-worker/worker.log
36
38
  StandardOutput=append:/var/log/canopy-worker/worker.log
37
39
  StandardError=append:/var/log/canopy-worker/worker.log
38
40
 
@@ -43,9 +45,12 @@ StateDirectory=canopy-worker
43
45
 
44
46
  # Environment
45
47
  EnvironmentFile=/opt/canopy-worker/.env
48
+ # The worker contract this unit provides (WORKER_CONTRACT_VERSION in
49
+ # src/constructs/worker-lifecycle.ts); a bundle needing more refuses to start.
50
+ Environment=CANOPYCMS_WORKER_CONTRACT=1
46
51
 
47
- # Sandbox. Writable: the workspace, the log dir (LogsDirectory=), the state dir
48
- # (StateDirectory=) and a private /tmp.
52
+ # Sandbox. Writable: the workspace, the state dir (StateDirectory=) and a
53
+ # private /tmp.
49
54
  # ProtectHome=tmpfs, not yes: every git call reads per-user files under $HOME
50
55
  # (~/.config/git/attributes, ignore), and an inaccessible /home makes each read
51
56
  # warn "Permission denied"; an empty one is a silent ENOENT.