canopycms-cdk 0.0.68-int.95 → 0.0.68-int.96
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.
|
@@ -61,11 +61,12 @@ export interface CanopyCmsServiceProps {
|
|
|
61
61
|
/**
|
|
62
62
|
* Environment variables for the Lambda function.
|
|
63
63
|
*
|
|
64
|
-
*
|
|
64
|
+
* Three keys are not free-form here, because the construct configures the
|
|
65
65
|
* worker from the same values and the two halves must agree:
|
|
66
66
|
* `CANOPYCMS_DEPLOYMENT_NAME` is folded into `deploymentName` (validated,
|
|
67
|
-
* and mirrored into the worker's `.env`),
|
|
68
|
-
* `'prod'
|
|
67
|
+
* and mirrored into the worker's `.env`), `CANOPY_MODE` accepts only
|
|
68
|
+
* `'prod'`, and `CANOPYCMS_WORKSPACE_ROOT` accepts only the shared EFS mount
|
|
69
|
+
* path, `/mnt/efs`. Everything else is passed through untouched.
|
|
69
70
|
*/
|
|
70
71
|
environment?: Record<string, string>;
|
|
71
72
|
/** EFS removal policy (default: RETAIN) */
|
|
@@ -35,6 +35,15 @@ const isValidDeploymentName = (name) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name
|
|
|
35
35
|
!name.includes('..') &&
|
|
36
36
|
!name.endsWith('.') &&
|
|
37
37
|
!name.endsWith('.lock');
|
|
38
|
+
/**
|
|
39
|
+
* Where the Lambda AND the worker mount the `WorkspaceAP` access point (rooted
|
|
40
|
+
* at EFS:/workspace), and so the workspace root of both.
|
|
41
|
+
*
|
|
42
|
+
* One path string, not merely one directory: git records absolute paths on
|
|
43
|
+
* EFS (a workspace clone's `origin` is `<root>/remote.git`), and a path written
|
|
44
|
+
* by one process must resolve for the other.
|
|
45
|
+
*/
|
|
46
|
+
const EFS_MOUNT_PATH = '/mnt/efs';
|
|
38
47
|
/**
|
|
39
48
|
* The heredoc delimiter user-data uses to write the worker's `.env` (see the
|
|
40
49
|
* `cat > … << 'ENVEOF'` block below).
|
|
@@ -562,6 +571,14 @@ export class CanopyCmsService extends Construct {
|
|
|
562
571
|
`(dev mode resolves its workspace to <cwd>/.canopy-dev, which is read-only on Lambda). ` +
|
|
563
572
|
`Omit it to get the default.`);
|
|
564
573
|
}
|
|
574
|
+
const envWorkspaceRootOverride = props.environment?.['CANOPYCMS_WORKSPACE_ROOT'];
|
|
575
|
+
if (envWorkspaceRootOverride !== undefined && envWorkspaceRootOverride !== EFS_MOUNT_PATH) {
|
|
576
|
+
throw new Error(`CanopyCmsService: invalid environment.CANOPYCMS_WORKSPACE_ROOT ` +
|
|
577
|
+
`${JSON.stringify(envWorkspaceRootOverride)}. The Lambda and the worker both mount the ` +
|
|
578
|
+
`workspace at ${EFS_MOUNT_PATH}, and git paths one writes on EFS must resolve for the ` +
|
|
579
|
+
`other, so the only supported value is ${JSON.stringify(EFS_MOUNT_PATH)}. ` +
|
|
580
|
+
`Omit it to get the default.`);
|
|
581
|
+
}
|
|
565
582
|
this.vpc =
|
|
566
583
|
props.vpc ??
|
|
567
584
|
new ec2.Vpc(this, 'Vpc', {
|
|
@@ -681,21 +698,16 @@ export class CanopyCmsService extends Construct {
|
|
|
681
698
|
vpc: this.vpc,
|
|
682
699
|
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
|
|
683
700
|
securityGroups: [lambdaSg],
|
|
684
|
-
filesystem: lambda.FileSystem.fromEfsAccessPoint(accessPoint,
|
|
701
|
+
filesystem: lambda.FileSystem.fromEfsAccessPoint(accessPoint, EFS_MOUNT_PATH),
|
|
685
702
|
// Pass the pre-created group via `logGroup`, NOT `logRetention` (CDK
|
|
686
703
|
// throws LogRetentionLogGroupConflict/ConflictingLogPolicyOptions if
|
|
687
704
|
// both are set on the same function) - the removal policy lives on the
|
|
688
705
|
// LogGroup construct above instead.
|
|
689
706
|
logGroup: this.cmsLogGroup,
|
|
690
707
|
environment: {
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
// /mnt/efs (see UserData below) and reaches the same directory via
|
|
695
|
-
// /mnt/efs/workspace. Both paths must resolve to EFS:/workspace, or the
|
|
696
|
-
// Lambda and worker silently operate on different directories.
|
|
697
|
-
CANOPYCMS_WORKSPACE_ROOT: '/mnt/efs',
|
|
698
|
-
CANOPY_AUTH_CACHE_PATH: '/mnt/efs/.cache',
|
|
708
|
+
// The worker's `.env` gets the same root; see EFS_MOUNT_PATH.
|
|
709
|
+
CANOPYCMS_WORKSPACE_ROOT: EFS_MOUNT_PATH,
|
|
710
|
+
CANOPY_AUTH_CACHE_PATH: `${EFS_MOUNT_PATH}/.cache`,
|
|
699
711
|
// git >= 2.35.2 refuses repos owned by another uid (the access point
|
|
700
712
|
// forces uid 1000; Lambda containers run as a different user).
|
|
701
713
|
// Env-based GIT_CONFIG_* CANNOT fix this - simple-git hard-blocks env
|
|
@@ -819,7 +831,7 @@ export class CanopyCmsService extends Construct {
|
|
|
819
831
|
// through `assertEnvSafe` in the single `map` below, so a value added here
|
|
820
832
|
// later is guarded whether or not whoever adds it remembers to.
|
|
821
833
|
const envEntries = [
|
|
822
|
-
['CANOPYCMS_WORKSPACE_ROOT',
|
|
834
|
+
['CANOPYCMS_WORKSPACE_ROOT', EFS_MOUNT_PATH],
|
|
823
835
|
['CANOPYCMS_GITHUB_OWNER', props.githubOwner],
|
|
824
836
|
['CANOPYCMS_GITHUB_REPO', props.githubRepo],
|
|
825
837
|
['CANOPYCMS_BASE_BRANCH', baseBranch],
|
|
@@ -884,8 +896,9 @@ export class CanopyCmsService extends Construct {
|
|
|
884
896
|
const envFileContent = envEntries
|
|
885
897
|
.map(([name, value]) => `${name}=${assertEnvSafe(name, value)}`)
|
|
886
898
|
.join('\n');
|
|
899
|
+
const efsMountOptions = `tls,accesspoint=${accessPoint.accessPointId}`;
|
|
887
900
|
const userData = ec2.UserData.forLinux();
|
|
888
|
-
userData.addCommands('#!/bin/bash', 'set -euo pipefail', '', '# FAIL-FAST. Every step below is required for the worker to exist at', '# all, and until this trap existed a failure in any of them aborted', '# user-data BEFORE the systemd unit was written -- leaving an instance', "# that runs, passes the ASG's EC2-only health check indefinitely, and", '# does nothing. cfn-signal is deliberately not used here (it would', '# prove nothing about READINESS, which is the argument recorded below),', '# but that argument never covered a boot-SCRIPT failure: `cdk deploy`', '# reported success while publishes queued on EFS, the auth cache went', '# stale and PRs stopped being created, until a human noticed the admin', '# panel showing the worker absent.', '#', '# Shutting down makes the instance fail its EC2 health check, so the ASG', '# replaces it -- which is the only automatic recovery available in this', '# topology. The echo lands in the console log, readable via', '# `aws ec2 get-console-output`, because the CloudWatch agent is itself', '# configured further down and cannot be relied on to exist yet.', 'trap \'echo "canopy-worker user-data FAILED at line $LINENO (exit $?)" >&2; shutdown -h now\' ERR', '', '# Bounded retry for the network-dependent steps. Package mirrors and S3', '# have transient failures; a single flake should cost seconds, not an', '# instance replacement.', 'retry() {', ' local n=0', ' until "$@"; do', ' n=$((n + 1))', ' if [ "$n" -ge 5 ]; then', ' echo "canopy-worker: command failed after $n attempts: $*" >&2', ' return 1', ' fi', ' sleep $((n * 5))', ' done', '}', '', '# Install dependencies (unzip is not guaranteed in the AL2023 AMI)', 'retry dnf install -y git unzip', "# Node comes from AL2023's own repos, NOT a piped third-party installer.", '# The previous boot curled the NodeSource RPM setup script straight into', '# bash, so every instance replacement -- which the ASG performs on every', '# `cdk deploy`, plus every spot interruption -- depended on a third party', '# being reachable. cms-deploy.test.ts asserts that URL never comes back,', '# which is why it is not spelled out here.', '#', '# nodejs22, not 20: Node 20 reached upstream EOL on 2026-04-30, and this', '# repo declares `engines.node: ">=22"` with .nvmrc `v22`, so the worker', '# was running an EOL runtime BELOW the floor its own code is tested at.', "# 22 (EOL 2027-04-30) matches .nvmrc, CI, and the transform Lambda's", '# NODEJS_22_X, so one runtime is tested everywhere.', '#', '# ExecStart uses the NAMESPACED /usr/bin/node-22 (see the systemd unit', '# below), not the bare `node`: AL2023 installs versioned binaries and', '# points `/usr/bin/node` at one of them through `alternatives`, whose', '# selection AWS documents as able to change at any time.', 'retry dnf install -y nodejs22', '', '# Mount EFS', 'retry dnf install -y amazon-efs-utils',
|
|
901
|
+
userData.addCommands('#!/bin/bash', 'set -euo pipefail', '', '# FAIL-FAST. Every step below is required for the worker to exist at', '# all, and until this trap existed a failure in any of them aborted', '# user-data BEFORE the systemd unit was written -- leaving an instance', "# that runs, passes the ASG's EC2-only health check indefinitely, and", '# does nothing. cfn-signal is deliberately not used here (it would', '# prove nothing about READINESS, which is the argument recorded below),', '# but that argument never covered a boot-SCRIPT failure: `cdk deploy`', '# reported success while publishes queued on EFS, the auth cache went', '# stale and PRs stopped being created, until a human noticed the admin', '# panel showing the worker absent.', '#', '# Shutting down makes the instance fail its EC2 health check, so the ASG', '# replaces it -- which is the only automatic recovery available in this', '# topology. The echo lands in the console log, readable via', '# `aws ec2 get-console-output`, because the CloudWatch agent is itself', '# configured further down and cannot be relied on to exist yet.', 'trap \'echo "canopy-worker user-data FAILED at line $LINENO (exit $?)" >&2; shutdown -h now\' ERR', '', '# Bounded retry for the network-dependent steps. Package mirrors and S3', '# have transient failures; a single flake should cost seconds, not an', '# instance replacement.', 'retry() {', ' local n=0', ' until "$@"; do', ' n=$((n + 1))', ' if [ "$n" -ge 5 ]; then', ' echo "canopy-worker: command failed after $n attempts: $*" >&2', ' return 1', ' fi', ' sleep $((n * 5))', ' done', '}', '', '# Install dependencies (unzip is not guaranteed in the AL2023 AMI)', 'retry dnf install -y git unzip', "# Node comes from AL2023's own repos, NOT a piped third-party installer.", '# The previous boot curled the NodeSource RPM setup script straight into', '# bash, so every instance replacement -- which the ASG performs on every', '# `cdk deploy`, plus every spot interruption -- depended on a third party', '# being reachable. cms-deploy.test.ts asserts that URL never comes back,', '# which is why it is not spelled out here.', '#', '# nodejs22, not 20: Node 20 reached upstream EOL on 2026-04-30, and this', '# repo declares `engines.node: ">=22"` with .nvmrc `v22`, so the worker', '# was running an EOL runtime BELOW the floor its own code is tested at.', "# 22 (EOL 2027-04-30) matches .nvmrc, CI, and the transform Lambda's", '# NODEJS_22_X, so one runtime is tested everywhere.', '#', '# ExecStart uses the NAMESPACED /usr/bin/node-22 (see the systemd unit', '# below), not the bare `node`: AL2023 installs versioned binaries and', '# points `/usr/bin/node` at one of them through `alternatives`, whose', '# selection AWS documents as able to change at any time.', 'retry dnf install -y nodejs22', '', '# Mount EFS through the same access point, at the same path, as the', '# Lambda (see EFS_MOUNT_PATH). amazon-efs-utils refuses an access-point', '# mount without tls (efs_utils_common/mount_options.py).', 'retry dnf install -y amazon-efs-utils', `mkdir -p ${EFS_MOUNT_PATH}`, `mount -t efs -o ${efsMountOptions} ${this.fileSystem.fileSystemId}:/ ${EFS_MOUNT_PATH}`, '# Persist the mount across instance reboots: user-data runs once per', '# instance, so without an fstab entry a plain reboot leaves /mnt/efs an', '# empty local dir and the worker would clone a divergent remote.git', '# onto the instance disk, invisible to the Lambda.', `echo '${this.fileSystem.fileSystemId}:/ ${EFS_MOUNT_PATH} efs _netdev,${efsMountOptions} 0 0' >> /etc/fstab`, '', '# Download worker from CDK S3 Asset', `retry aws s3 cp s3://${workerAsset.s3BucketName}/${workerAsset.s3ObjectKey} /tmp/canopy-worker.zip`, 'mkdir -p /opt/canopy-worker', 'cd /opt/canopy-worker', 'unzip -o /tmp/canopy-worker.zip', '# The worker bundle is ESM (esbuild --format=esm). Without this marker a', '# .js file is CommonJS by default and the import statement fails.', '# Node >=22.7 auto-detects module syntax and would mask its absence, and', '# this instance now runs node-22 -- so the marker is kept BECAUSE that', '# auto-detection is a fallback we should not depend on, not because the', '# runtime here still needs it.', `echo '{"type":"module"}' > /opt/canopy-worker/package.json`, '', '# Write environment file for systemd service', `cat > /opt/canopy-worker/.env << 'ENVEOF'`, envFileContent, 'ENVEOF', '', '# Create systemd service', `cat > /etc/systemd/system/canopy-worker.service << 'SVCEOF'`, '[Unit]', 'Description=CanopyCMS Worker Daemon', 'After=network.target', '# Never run against an unmounted /mnt/efs (see the fstab note above).', `RequiresMountsFor=${EFS_MOUNT_PATH}`, '', '[Service]', 'Type=simple', 'User=ec2-user', 'WorkingDirectory=/opt/canopy-worker', '# Namespaced binary, not bare `node`: AL2023 points /usr/bin/node at', '# an installed version through `alternatives`, and AWS documents that', '# selection as able to change at any time. node-22 always means 22.', 'ExecStart=/usr/bin/node-22 index.js', 'Restart=always', 'RestartSec=5', 'TimeoutStartSec=300', '# File output, not journal: the CloudWatch agent cannot read journald,', '# so it tails this file instead (see the agent config below).', '# CAUTION: /var/log/canopy-worker must exist BEFORE first start. systemd', '# opens append: targets before it creates LogsDirectory= dirs', '# (systemd#27591), so without the pre-created dir the exec fails with', '# 209/STDOUT and Restart=always crash-loops forever. User-data runs', '# mkdir before systemctl start; LogsDirectory= is kept for ownership.', 'LogsDirectory=canopy-worker', 'StandardOutput=append:/var/log/canopy-worker/worker.log', 'StandardError=append:/var/log/canopy-worker/worker.log', 'EnvironmentFile=/opt/canopy-worker/.env', '', '[Install]', 'WantedBy=multi-user.target', 'SVCEOF', '', '# Set ownership for ec2-user', 'chown -R ec2-user:ec2-user /opt/canopy-worker', '', '# Pre-create the worker log dir (crash-loop guard, MUST precede the', '# first systemctl start): systemd opens StandardOutput=append: files', '# BEFORE it creates LogsDirectory= dirs (systemd#27591), so on a fresh', '# instance the unit would fail exec with 209/STDOUT and crash-loop', '# forever without this. The unit keeps LogsDirectory= for ownership', '# management on subsequent starts.', 'mkdir -p /var/log/canopy-worker', 'chown ec2-user:ec2-user /var/log/canopy-worker', '', '# Start worker', 'systemctl daemon-reload', 'systemctl enable canopy-worker', 'systemctl start canopy-worker', '', '# ---- CloudWatch log shipping ----', '# Placed AFTER worker start: with set -euo pipefail, a package/agent', '# failure here must not prevent the worker from running (shipping is', '# best-effort).', '#', '# DISARM THE FAIL-FAST TRAP AND errexit FIRST. Everything above this line', '# is required for the worker to exist at all, so a failure there should', '# replace the instance. Nothing below it is: the worker is already', '# running and healthy by this point. Leaving the trap armed would let a', '# package-mirror outage during the agent install shut down a perfectly', '# good worker -- and the ASG would relaunch straight into the same', '# outage, turning degraded log shipping into a replacement loop with the', '# worker down for its duration. That is strictly worse than the', '# best-effort behaviour this section has always documented.', 'trap - ERR', 'set +e', 'retry dnf install -y amazon-cloudwatch-agent logrotate', '', '# Bound on-disk growth; copytruncate keeps the fd the CW agent tails valid', '# (tiny copy->truncate loss window is acceptable for diagnostic logs).', `cat > /etc/logrotate.d/canopy-worker << 'ROTEOF'`, '/var/log/canopy-worker/worker.log {', ' size 10M', ' rotate 5', ' compress', ' copytruncate', ' missingok', ' notifempty', '}', 'ROTEOF', '# The config above only fires when logrotate actually runs; AL2023', '# presets may leave logrotate.timer disabled, and without it the size', '# cap never triggers and worker.log grows until the nano disk fills.', '# --now is idempotent if the timer is already enabled/running.', 'systemctl enable --now logrotate.timer', '', '# No retention_in_days here: CDK owns retention on the pre-created group.', `cat > /opt/aws/amazon-cloudwatch-agent/etc/canopy-worker-logs.json << 'CWEOF'`, '{', ' "logs": {', ' "logs_collected": {', ' "files": {', ' "collect_list": [', ' {', ' "file_path": "/var/log/canopy-worker/worker.log",', ` "log_group_name": "${this.workerLogGroup.logGroupName}",`, ' "log_stream_name": "{instance_id}",',
|
|
889
902
|
// The worker prefixes every line with an ISO-8601 timestamp
|
|
890
903
|
// (packages/canopycms/src/worker/log.ts). Parsing it here is what makes
|
|
891
904
|
// CloudWatch show the time the WORKER emitted a line rather than the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopycms-cdk",
|
|
3
|
-
"version": "0.0.68-int.
|
|
3
|
+
"version": "0.0.68-int.96",
|
|
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.
|
|
34
|
+
"canopycms": "0.0.68-int.96"
|
|
35
35
|
},
|
|
36
36
|
"peerDependenciesMeta": {
|
|
37
37
|
"aws-cdk-lib": {
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"tsx": "^4.19.0",
|
|
57
57
|
"typescript": "^5.6.3",
|
|
58
58
|
"vitest": "^4.1.0",
|
|
59
|
-
"canopycms
|
|
60
|
-
"canopycms": "0.0.68-int.
|
|
59
|
+
"canopycms": "0.0.68-int.96",
|
|
60
|
+
"canopycms-auth-clerk": "0.0.68-int.96"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",
|
package/worker/dist/index.js
CHANGED
|
@@ -48958,7 +48958,7 @@ async function writeWorkerStatus(taskDir, report) {
|
|
|
48958
48958
|
}
|
|
48959
48959
|
|
|
48960
48960
|
// ../canopycms/src/version.ts
|
|
48961
|
-
var CANOPYCMS_VERSION = "0.0.68-int.
|
|
48961
|
+
var CANOPYCMS_VERSION = "0.0.68-int.96";
|
|
48962
48962
|
|
|
48963
48963
|
// ../canopycms/src/worker/log.ts
|
|
48964
48964
|
init_logger();
|
|
@@ -49871,7 +49871,7 @@ async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expectedSha
|
|
|
49871
49871
|
// Real flags must precede --end-of-options; everything after it is
|
|
49872
49872
|
// positional (see GitManager.push() for the same guard).
|
|
49873
49873
|
"--end-of-options",
|
|
49874
|
-
|
|
49874
|
+
ctx.remoteGitPath,
|
|
49875
49875
|
`${branchRef}:${branchRef}`
|
|
49876
49876
|
]);
|
|
49877
49877
|
workerLog(` Published rebased ${branchRef} into remote.git`);
|
|
@@ -50686,7 +50686,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
50686
50686
|
}
|
|
50687
50687
|
const trackedState = trackedCanopyStateChanges(dirtyCheck);
|
|
50688
50688
|
if (trackedState.length > 0) {
|
|
50689
|
-
await branchGit.fetch(
|
|
50689
|
+
await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
|
|
50690
50690
|
const baseTip = (await branchGit.revparse(["FETCH_HEAD"])).trim();
|
|
50691
50691
|
const { stillTracked } = await splitByUpstreamTracking(branchGit, trackedState, baseTip);
|
|
50692
50692
|
const reason = stillTracked.length > 0 ? `git cannot rebase over modified canopycms state the repo tracks (${trackedState.join(", ")}). To fix, ${TRACKED_CANOPY_STATE_FIX}.` : `git cannot rebase over modified canopycms state this clone still tracks (${trackedState.join(", ")}), though the base branch no longer does; the clone needs manual repair`;
|
|
@@ -50705,7 +50705,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
50705
50705
|
marker: metaFile.branch.historyRewrittenFrom
|
|
50706
50706
|
});
|
|
50707
50707
|
}
|
|
50708
|
-
await branchGit.fetch(
|
|
50708
|
+
await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
|
|
50709
50709
|
const fetchedBaseTip = (await branchGit.revparse(["FETCH_HEAD"])).trim();
|
|
50710
50710
|
const behindCount = parseInt(
|
|
50711
50711
|
(await branchGit.raw(["rev-list", "--count", `HEAD..${fetchedBaseTip}`])).trim(),
|
|
@@ -51149,7 +51149,7 @@ async function refreshBaseBranchWorkspace(ctx) {
|
|
|
51149
51149
|
trackedCanopyMeta
|
|
51150
51150
|
};
|
|
51151
51151
|
}
|
|
51152
|
-
await baseGit.fetch(
|
|
51152
|
+
await baseGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
|
|
51153
51153
|
const fetchedTip = (await baseGit.revparse(["FETCH_HEAD"])).trim();
|
|
51154
51154
|
const behindCount = parseInt(
|
|
51155
51155
|
(await baseGit.raw(["rev-list", "--count", `HEAD..${fetchedTip}`])).trim(),
|