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

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.110",
3
+ "version": "0.0.68-int.112",
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.110"
34
+ "canopycms": "0.0.68-int.112"
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.110",
61
- "canopycms-auth-clerk": "0.0.68-int.110"
60
+ "canopycms": "0.0.68-int.112",
61
+ "canopycms-auth-clerk": "0.0.68-int.112"
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.
@@ -79169,6 +79169,13 @@ async function repackBareRemoteIfNeeded(gitDir, git = simpleGit().env(gitChildEn
79169
79169
  const after = await countObjects(git, gitDir);
79170
79170
  return { repacked: true, before, after, ms: Date.now() - startedAt };
79171
79171
  }
79172
+ function failOnSignalExit(error3, result) {
79173
+ if (error3) return error3;
79174
+ if (result.exitCode === null) {
79175
+ return new Error("git was killed by a signal before it finished");
79176
+ }
79177
+ return void 0;
79178
+ }
79172
79179
  var GITHUB_TRACKING_REF_PREFIX = "refs/remotes/github/";
79173
79180
  async function ensureGitExcludePattern(repoPath, pattern) {
79174
79181
  const excludePath = path7.join(repoPath, ".git", "info", "exclude");
@@ -79209,7 +79216,7 @@ init_error();
79209
79216
  init_worker_status();
79210
79217
 
79211
79218
  // ../canopycms/src/version.ts
79212
- var CANOPYCMS_VERSION = "0.0.68-int.110";
79219
+ var CANOPYCMS_VERSION = "0.0.68-int.112";
79213
79220
 
79214
79221
  // ../canopycms/src/worker/log.ts
79215
79222
  init_logger();
@@ -79842,6 +79849,27 @@ async function assertOwnDirectory(dir) {
79842
79849
  );
79843
79850
  }
79844
79851
  }
79852
+ var RefusedPushError = class extends Error {
79853
+ constructor(branch, reason) {
79854
+ super(
79855
+ reason === "invalid" ? `Refusing to push ${JSON.stringify(branch)} to GitHub: it is not a valid branch name.` : `Refusing to push "${branch}" to GitHub: it is the base branch or GitHub's default branch, which the CanopyCMS worker never pushes. Nothing in CanopyCMS queues such a push; find out what wrote this task.`
79856
+ );
79857
+ this.branch = branch;
79858
+ this.name = "RefusedPushError";
79859
+ }
79860
+ };
79861
+ async function assertPlainBranchName(branch) {
79862
+ let normalized;
79863
+ try {
79864
+ normalized = (await simpleGit().raw(["check-ref-format", "--branch", branch])).trim();
79865
+ } catch (err) {
79866
+ if (/is not a valid branch name/.test(getErrorMessage(err))) {
79867
+ throw new RefusedPushError(branch, "invalid");
79868
+ }
79869
+ throw err;
79870
+ }
79871
+ if (normalized !== branch) throw new RefusedPushError(branch, "invalid");
79872
+ }
79845
79873
  var MirrorSession = class {
79846
79874
  constructor(gitDir, remoteGitPath, timeoutMs) {
79847
79875
  this.gitDir = gitDir;
@@ -79907,6 +79935,21 @@ var MirrorSession = class {
79907
79935
  ...refspecs
79908
79936
  ]);
79909
79937
  }
79938
+ /**
79939
+ * The branch GitHub's HEAD names, or null for a repository with none (empty, or HEAD unborn). A
79940
+ * failure to reach GitHub throws, so a push that cannot learn it does not happen.
79941
+ */
79942
+ async githubDefaultBranch(githubUrl, signal) {
79943
+ const out = await this.git(signal).raw([
79944
+ "ls-remote",
79945
+ "--symref",
79946
+ "--end-of-options",
79947
+ githubUrl,
79948
+ "HEAD"
79949
+ ]);
79950
+ const match2 = /^ref: refs\/heads\/(.+)\tHEAD$/m.exec(out);
79951
+ return match2 ? match2[1] : null;
79952
+ }
79910
79953
  /** The mirror's tip for `branch`, or null when GitHub had no such branch at the last fetch. */
79911
79954
  async branchTip(branch) {
79912
79955
  try {
@@ -79916,6 +79959,26 @@ var MirrorSession = class {
79916
79959
  return null;
79917
79960
  }
79918
79961
  }
79962
+ /**
79963
+ * Whether a branch GitHub had at the last fetch contains the commit `id`, or the commit an
79964
+ * annotated tag `id` points at. False for an object the mirror does not have, or one git cannot
79965
+ * read as a commit.
79966
+ */
79967
+ async isOnGitHub(id) {
79968
+ if (!isObjectId(id)) return false;
79969
+ try {
79970
+ const found = await this.git().raw([
79971
+ "for-each-ref",
79972
+ "--count=1",
79973
+ "--format=%(refname)",
79974
+ `--contains=${id}`,
79975
+ "refs/heads/"
79976
+ ]);
79977
+ return found.trim() !== "";
79978
+ } catch {
79979
+ return false;
79980
+ }
79981
+ }
79919
79982
  /**
79920
79983
  * Copy GitHub's branches into `remote.git`'s tracking namespace, pruning the ones GitHub no
79921
79984
  * longer has. `reconcileTrackedBranches` (worker/git-sync.ts) moves `refs/heads/*` from there.
@@ -79950,10 +80013,18 @@ var MirrorSession = class {
79950
80013
  * commit from `remote.git` first is the step that brings the Lambda's objects across, through
79951
80014
  * the pinned `upload-pack`.
79952
80015
  */
79953
- async pushToGitHub(githubUrl, branch, sha, options = {}) {
80016
+ async pushToGitHub(githubUrl, branch, sha, options) {
79954
80017
  for (const id of [sha, ...options.lease === void 0 ? [] : [options.lease]]) {
79955
80018
  if (!isObjectId(id)) throw new Error(`Not a commit ID: ${JSON.stringify(id)}`);
79956
80019
  }
80020
+ await assertPlainBranchName(branch);
80021
+ const protectedNames = [...options.protectedBranches];
80022
+ const githubDefault = await this.githubDefaultBranch(githubUrl, options.signal);
80023
+ if (githubDefault !== null) protectedNames.push(githubDefault);
80024
+ const target = sanitizeBranchName(branch);
80025
+ if (protectedNames.some((name) => name === branch || sanitizeBranchName(name) === target)) {
80026
+ throw new RefusedPushError(branch, "protected");
80027
+ }
79957
80028
  const staging = `${STAGING_PREFIX}${branch}`;
79958
80029
  const git = this.git(options.signal);
79959
80030
  try {
@@ -81196,6 +81267,12 @@ function throwIfWorkflowRefusal(branch, message) {
81196
81267
  }
81197
81268
  async function pushBranchToGitHub(ctx, branch, signal) {
81198
81269
  await assertSharedRepoConfig(ctx.remoteGitPath, "bare");
81270
+ try {
81271
+ await assertPlainBranchName(branch);
81272
+ } catch (err) {
81273
+ if (err instanceof RefusedPushError) throw new PermanentTaskError(err.message);
81274
+ throw err;
81275
+ }
81199
81276
  const githubUrl = await ctx.buildGitHubUrl();
81200
81277
  const branchPath = ctx.branchWorkspacePath(branch);
81201
81278
  const metaFile = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
@@ -81205,11 +81282,16 @@ async function pushBranchToGitHub(ctx, branch, signal) {
81205
81282
  throw new Error(`Branch "${branch}" is not in remote.git, so there is nothing to push`);
81206
81283
  }
81207
81284
  const outcome = await ctx.githubMirror().exclusive(async (mirror) => {
81208
- const push = (lease) => mirror.pushToGitHub(githubUrl, branch, outgoingSha, { lease, signal });
81285
+ const push = (lease) => mirror.pushToGitHub(githubUrl, branch, outgoingSha, {
81286
+ lease,
81287
+ signal,
81288
+ protectedBranches: [ctx.baseBranch]
81289
+ });
81209
81290
  try {
81210
81291
  await push(marker);
81211
81292
  return "pushed";
81212
81293
  } catch (err) {
81294
+ if (err instanceof RefusedPushError) throw new PermanentTaskError(err.message);
81213
81295
  const message = getErrorMessage(err);
81214
81296
  throwIfWorkflowRefusal(branch, message);
81215
81297
  if (marker && isStaleLeaseRejection(message)) {
@@ -82193,7 +82275,10 @@ async function pushSettingsBranches(ctx, git, trackedNames) {
82193
82275
  const sha = (await git.revparse(["--verify", `refs/heads/${settingsBranch}`])).trim();
82194
82276
  const githubUrl = await ctx.buildGitHubUrl();
82195
82277
  await ctx.githubMirror().exclusive(
82196
- (mirror) => mirror.pushToGitHub(githubUrl, settingsBranch, sha, { signal: ctx.shutdownSignal() })
82278
+ (mirror) => mirror.pushToGitHub(githubUrl, settingsBranch, sha, {
82279
+ signal: ctx.shutdownSignal(),
82280
+ protectedBranches: [ctx.baseBranch]
82281
+ })
82197
82282
  );
82198
82283
  workerLog(`Pushed settings branch ${settingsBranch} to GitHub`);
82199
82284
  } catch (err) {
@@ -82700,6 +82785,29 @@ function defaultStateDirectory(workspacePath) {
82700
82785
  const key = createHash2("sha256").update(path25.resolve(workspacePath)).digest("hex").slice(0, 16);
82701
82786
  return path25.join(os.tmpdir(), `canopycms-worker-${key}`);
82702
82787
  }
82788
+ var MAX_REFS_LISTED = 20;
82789
+ var RemoteGitKeptError = class extends Error {
82790
+ };
82791
+ async function listRemoteGitRefs(gitDir) {
82792
+ let stderr = "";
82793
+ const output = await sharedRepoGit(gitDir, "bare", { errors: failOnSignalExit }).outputHandler((_command, _stdout, err) => {
82794
+ err.on("data", (chunk) => {
82795
+ stderr += String(chunk);
82796
+ });
82797
+ }).raw(["for-each-ref", "--format=%(objectname) %(refname)"]);
82798
+ if (stderr.trim() !== "") throw new Error(stderr.trim());
82799
+ const refs = /* @__PURE__ */ new Map();
82800
+ for (const line of output.split("\n")) {
82801
+ const space = line.indexOf(" ");
82802
+ if (space === -1) continue;
82803
+ const ref = line.slice(space + 1);
82804
+ if (!ref.startsWith(GITHUB_TRACKING_REF_PREFIX)) refs.set(ref, line.slice(0, space));
82805
+ }
82806
+ return refs;
82807
+ }
82808
+ function sameRefs(a9, b10) {
82809
+ return a9.size === b10.size && [...a9].every(([ref, id]) => b10.get(ref) === id);
82810
+ }
82703
82811
  var DEFAULT_TASK_TIMEOUT = 6e4;
82704
82812
  var DEFAULT_MAX_RETRIES2 = 3;
82705
82813
  var DEFAULT_LOCK_STALE_MS = 6e4;
@@ -83234,7 +83342,9 @@ var CmsWorker = class {
83234
83342
  /**
83235
83343
  * An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
83236
83344
  * reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
83237
- * branch, which the clone then records as that HEAD. Never assumes 'main'.
83345
+ * branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
83346
+ * outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
83347
+ * replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
83238
83348
  */
83239
83349
  async resolveBaseBranch() {
83240
83350
  if (this.resolvedBaseBranch) return;
@@ -83253,10 +83363,11 @@ var CmsWorker = class {
83253
83363
  } catch (err) {
83254
83364
  throw undetermined(err);
83255
83365
  }
83256
- if (remoteGitExists) await assertSharedRepoConfig(this.remoteGitPath, "bare");
83366
+ if (remoteGitExists) await this.assertRemoteGitConfig();
83257
83367
  let name;
83258
83368
  try {
83259
- name = remoteGitExists ? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, "bare")) : (await this.octokitClient().repos.get({
83369
+ const fromRemoteGit = remoteGitExists && await this.hasContentBranch(this.remoteGitPath);
83370
+ name = fromRemoteGit ? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, "bare")) : (await this.octokitClient().repos.get({
83260
83371
  owner: this.config.githubOwner,
83261
83372
  repo: this.config.githubRepo
83262
83373
  })).data.default_branch;
@@ -83266,6 +83377,30 @@ var CmsWorker = class {
83266
83377
  this.setBaseBranch(name);
83267
83378
  workerLog(`Base branch: '${name}' (detected; CANOPYCMS_BASE_BRANCH is not set)`);
83268
83379
  }
83380
+ /**
83381
+ * Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
83382
+ * something wrote there, and listing the refs a replacement would lose means running git under
83383
+ * that config. The message says so, so System health does not read it as a routine failure.
83384
+ */
83385
+ async assertRemoteGitConfig() {
83386
+ try {
83387
+ await assertSharedRepoConfig(this.remoteGitPath, "bare");
83388
+ } catch (err) {
83389
+ if (!(err instanceof SharedRepoRefusalError)) throw err;
83390
+ throw new SharedRepoRefusalError(
83391
+ `${err.message.replace(/\.$/, "")}. This needs an operator: the worker never repairs or replaces a remote.git whose config was tampered with or cannot be read.`
83392
+ );
83393
+ }
83394
+ }
83395
+ /** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
83396
+ async hasContentBranch(gitDir) {
83397
+ const branches = await sharedRepoGit(gitDir, "bare", { errors: failOnSignalExit }).raw([
83398
+ "for-each-ref",
83399
+ "--format=%(refname:strip=2)",
83400
+ "refs/heads/"
83401
+ ]);
83402
+ return branches.split("\n").some((branch) => branch !== "" && !isSettingsBranchName(branch, this.ensureSettingsBranch()));
83403
+ }
83269
83404
  /**
83270
83405
  * Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
83271
83406
  * (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
@@ -83372,7 +83507,7 @@ var CmsWorker = class {
83372
83507
  * pushed) succeeds and leaves a refs-less repo that `fs.stat` cannot tell from a healthy one, so
83373
83508
  * left unchecked it silently poisons remote.git and the stat short-circuit means it never heals.
83374
83509
  * The base branch is therefore verified right after seeding AND on the already-exists fast
83375
- * path, since a previous run can have left a poisoned remote.git behind.
83510
+ * path, where a poisoned remote.git is replaced (`replacePoisonedRemoteGit`).
83376
83511
  */
83377
83512
  async ensureRemoteGit() {
83378
83513
  let exists2;
@@ -83383,26 +83518,125 @@ var CmsWorker = class {
83383
83518
  exists2 = false;
83384
83519
  }
83385
83520
  if (exists2) {
83386
- await assertSharedRepoConfig(this.remoteGitPath, "bare");
83521
+ await this.assertRemoteGitConfig();
83387
83522
  await this.scrubPersistedRemote(this.remoteGitPath);
83388
83523
  try {
83389
83524
  await this.verifyBaseBranchExists(this.remoteGitPath);
83390
83525
  } catch (err) {
83391
83526
  workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
83392
- throw new Error(
83393
- `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`
83394
- );
83527
+ await this.replacePoisonedRemoteGit();
83528
+ return;
83395
83529
  }
83396
83530
  await this.applyRemoteGitConfig(this.remoteGitPath);
83397
83531
  return;
83398
83532
  }
83399
83533
  workerLog("Initializing remote.git from GitHub...");
83534
+ const stagingPath = await this.seedRemoteGitStaging();
83535
+ await fs23.rename(stagingPath, this.remoteGitPath);
83536
+ workerLog("remote.git initialized");
83537
+ }
83538
+ /**
83539
+ * Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
83540
+ * when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
83541
+ * a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
83542
+ * naming the refs at stake: unpushed work such as a settings branch.
83543
+ *
83544
+ * The refs are compared in the same mirror session that fetches GitHub and seeds the
83545
+ * replacement, and listed again just before the swap, since the Lambda can push into remote.git
83546
+ * while the seed runs. The swap is two renames, so the name remote.git resolves to the old repo,
83547
+ * the new one, or for an instant nothing, never a half-built or half-deleted one.
83548
+ */
83549
+ async replacePoisonedRemoteGit() {
83550
+ const poisoned = `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (cloned while the GitHub repository was empty, or the base branch is set to one it never had)`;
83551
+ const notReplaced = (reason) => new Error(`${poisoned}, and it was not replaced: ${reason}`);
83552
+ let refs;
83553
+ try {
83554
+ refs = await listRemoteGitRefs(this.remoteGitPath);
83555
+ } catch (err) {
83556
+ throw notReplaced(
83557
+ `its refs could not all be read (${redactCredentials(getErrorMessage(err)).trim()}), so the worker cannot tell whether replacing it would lose work. Deleting ${this.remoteGitPath} and restarting the worker re-clones it and discards every ref in it.`
83558
+ );
83559
+ }
83560
+ if (refs.has(`refs/heads/${this.baseBranch}`)) {
83561
+ throw new Error(
83562
+ `remote.git at ${this.remoteGitPath} has branch '${this.baseBranch}', but git could not verify it, so the worker will not replace remote.git. Restarting the worker checks again.`
83563
+ );
83564
+ }
83565
+ workerLog(
83566
+ `remote.git has no branch '${this.baseBranch}': replacing it if GitHub has every ref in it`
83567
+ );
83568
+ const stagingPath = await this.seedRemoteGitStaging(async (mirror) => {
83569
+ const unpushed = [];
83570
+ for (const [ref, id] of refs) {
83571
+ if (!await mirror.isOnGitHub(id)) unpushed.push(ref);
83572
+ }
83573
+ if (unpushed.length === 0) return;
83574
+ const shown = unpushed.slice(0, MAX_REFS_LISTED);
83575
+ const more = unpushed.length - shown.length;
83576
+ throw new RemoteGitKeptError(
83577
+ `${poisoned}, and it holds ${unpushed.length === 1 ? "a ref" : `${unpushed.length} refs`} GitHub does not have, so the worker will not replace it: ${shown.join(", ")}${more > 0 ? ` and ${more} more` : ""}. Deleting ${this.remoteGitPath} and restarting the worker re-clones it and discards ${unpushed.length === 1 ? "that ref" : "those refs"}.`
83578
+ );
83579
+ }).catch((err) => {
83580
+ if (err instanceof RemoteGitKeptError) throw err;
83581
+ throw notReplaced(redactCredentials(getErrorMessage(err)));
83582
+ });
83583
+ const discardStaging = () => fs23.rm(stagingPath, { recursive: true, force: true });
83584
+ let current;
83585
+ try {
83586
+ current = await listRemoteGitRefs(this.remoteGitPath);
83587
+ } catch (err) {
83588
+ await discardStaging();
83589
+ throw notReplaced(
83590
+ `its refs could not all be read again (${getErrorMessage(err).trim()}). Restarting the worker checks again.`
83591
+ );
83592
+ }
83593
+ if (!sameRefs(refs, current)) {
83594
+ await discardStaging();
83595
+ throw notReplaced(
83596
+ "its refs changed while the worker seeded its replacement. Restarting the worker checks again."
83597
+ );
83598
+ }
83599
+ const replaced = `${this.remoteGitPath}.replaced-${Date.now()}`;
83600
+ try {
83601
+ await fs23.rename(this.remoteGitPath, replaced);
83602
+ } catch (err) {
83603
+ await discardStaging();
83604
+ throw err;
83605
+ }
83606
+ try {
83607
+ await fs23.rename(stagingPath, this.remoteGitPath);
83608
+ } catch (err) {
83609
+ await fs23.rename(replaced, this.remoteGitPath);
83610
+ await discardStaging();
83611
+ throw err;
83612
+ }
83613
+ const after = await listRemoteGitRefs(replaced).catch(() => null);
83614
+ if (after !== null && sameRefs(refs, after)) {
83615
+ await fs23.rm(replaced, { recursive: true, force: true, maxRetries: 3 }).catch((err) => {
83616
+ workerLogWarn(
83617
+ `Could not remove the replaced remote.git at ${replaced}: ${getErrorMessage(err)}`
83618
+ );
83619
+ });
83620
+ } else {
83621
+ workerLogError(
83622
+ `Kept the replaced remote.git at ${replaced}: its refs changed as it was swapped out, so it may hold work GitHub does not have.`
83623
+ );
83624
+ }
83625
+ workerLog("remote.git replaced");
83626
+ }
83627
+ /**
83628
+ * Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
83629
+ * return its path for the caller to rename into place. `check` runs in the mirror session right
83630
+ * after the GitHub fetch, and its throw stops the seed. Throws with the staging directory gone.
83631
+ */
83632
+ async seedRemoteGitStaging(check) {
83400
83633
  const stagingPath = `${this.remoteGitPath}.cloning`;
83401
83634
  await fs23.rm(stagingPath, { recursive: true, force: true });
83402
83635
  try {
83403
83636
  const githubUrl = await this.buildGitHubUrl();
83404
83637
  await this.githubMirror.exclusive(async (mirror) => {
83405
83638
  await mirror.fetchFromGitHub(githubUrl);
83639
+ await check?.(mirror);
83406
83640
  if (await mirror.branchTip(this.baseBranch) === null) {
83407
83641
  throw new Error(`GitHub has no branch '${this.baseBranch}'`);
83408
83642
  }
@@ -83416,8 +83650,11 @@ var CmsWorker = class {
83416
83650
  await this.recordBaseBranchInRemoteHead(stagingPath);
83417
83651
  await this.applyRemoteGitConfig(stagingPath);
83418
83652
  } catch (err) {
83419
- workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
83653
+ if (!(err instanceof RemoteGitKeptError)) {
83654
+ workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
83655
+ }
83420
83656
  await fs23.rm(stagingPath, { recursive: true, force: true });
83657
+ if (err instanceof RemoteGitKeptError) throw err;
83421
83658
  if (err instanceof SharedRepoRefusalError) {
83422
83659
  throw new SharedRepoRefusalError(
83423
83660
  `Refusing to run git in ${stagingPath}: something wrote to it while the worker seeded remote.git from it` + (err instanceof UntrustedRepoConfigError ? ` (${err.keys.map((k10) => k10.key).join(", ")})` : "") + `, so it was removed. Find out what wrote there; restarting the worker seeds it again.`
@@ -83427,8 +83664,7 @@ var CmsWorker = class {
83427
83664
  `remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} failed or has no branch '${this.baseBranch}' - the GitHub repository may be empty, or the base branch may not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`
83428
83665
  );
83429
83666
  }
83430
- await fs23.rename(stagingPath, this.remoteGitPath);
83431
- workerLog("remote.git initialized");
83667
+ return stagingPath;
83432
83668
  }
83433
83669
  /**
83434
83670
  * Refuse a state directory on the shared filesystem: the mirror there would be as writable by
@@ -83863,6 +84099,22 @@ var WORKER_DRAIN_HOOK_NAME = "canopycms-worker-drain";
83863
84099
  var EXIT_DRAINED_FOR_TERMINATION = 75;
83864
84100
  var EXIT_WORKER_SELF_STOPPED = 69;
83865
84101
  var WORKER_CAPACITY_ENV = "CANOPYCMS_WORKER_CAPACITY";
84102
+ var WORKER_CONTRACT_REQUIREMENTS = ["StateDirectory=canopy-worker"];
84103
+ var WORKER_CONTRACT_VERSION = WORKER_CONTRACT_REQUIREMENTS.length;
84104
+ var WORKER_CONTRACT_ENV = "CANOPYCMS_WORKER_CONTRACT";
84105
+ function unitWorkerContract(env2) {
84106
+ const stamp = env2[WORKER_CONTRACT_ENV];
84107
+ if (stamp === void 0) return env2.STATE_DIRECTORY ? 1 : 0;
84108
+ return /^\d+$/.test(stamp) ? Number(stamp) : NaN;
84109
+ }
84110
+ function workerContractShortfall(env2) {
84111
+ const unit = unitWorkerContract(env2);
84112
+ if (Number.isNaN(unit)) {
84113
+ return `canopy-worker: ${WORKER_CONTRACT_ENV}=${JSON.stringify(env2[WORKER_CONTRACT_ENV])} on the worker unit is not a whole number`;
84114
+ }
84115
+ if (unit >= WORKER_CONTRACT_VERSION) return void 0;
84116
+ return `canopy-worker: template too old for this bundle: needs worker contract ${WORKER_CONTRACT_VERSION}, unit has ${unit} (` + WORKER_CONTRACT_REQUIREMENTS.slice(unit).map((setting, i10) => `contract ${unit + i10 + 1} adds ${setting}`).join("; ") + `). Deploy the stack template before rolling this bundle.`;
84117
+ }
83866
84118
 
83867
84119
  // worker/termination-watch.ts
83868
84120
  var DEFAULT_IMDS_ENDPOINT = "http://169.254.169.254";
@@ -93472,6 +93724,8 @@ async function runWorker(deps) {
93472
93724
  workerLog("CMS Worker starting...");
93473
93725
  workspacePath = env2.CANOPYCMS_WORKSPACE_ROOT;
93474
93726
  if (!workspacePath) throw new Error("CANOPYCMS_WORKSPACE_ROOT is required");
93727
+ const contractShortfall = workerContractShortfall(env2);
93728
+ if (contractShortfall) throw new Error(contractShortfall);
93475
93729
  const githubOwner = env2.CANOPYCMS_GITHUB_OWNER;
93476
93730
  if (!githubOwner) throw new Error("CANOPYCMS_GITHUB_OWNER is required");
93477
93731
  const githubRepo = env2.CANOPYCMS_GITHUB_REPO;
@@ -0,0 +1 @@
1
+ 1
@@ -1 +1 @@
1
- e039d186388e604a02d5271caa938f20b07676d232bbd96665f4e4bd5dd8eaa7 index.js
1
+ 7d6f470af88ec0126d5c10e5056cccc4a2ef52e3a464a4fee6a613b4f1583b7f index.js