canopycms-cdk 0.0.67-int.90 → 0.0.67

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.
Files changed (48) hide show
  1. package/dist/constructs/asset-support.d.ts +137 -269
  2. package/dist/constructs/asset-support.js +275 -455
  3. package/dist/constructs/cms-distribution.d.ts +23 -40
  4. package/dist/constructs/cms-distribution.js +61 -88
  5. package/dist/constructs/cms-service.d.ts +38 -60
  6. package/dist/constructs/cms-service.js +246 -390
  7. package/dist/constructs/lambda-execution-role.d.ts +23 -39
  8. package/dist/constructs/lambda-execution-role.js +37 -60
  9. package/dist/index.js +0 -2
  10. package/dist/worker.d.ts +0 -2
  11. package/dist/worker.js +0 -2
  12. package/lambda/asset-transform/dist/handler.js +33 -49
  13. package/lambda/asset-transform/dist/node_modules/.package-lock.json +38 -38
  14. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/README.md +1 -1
  15. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/lib/glib-2.0/include/glibconfig.h +2 -2
  16. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/lib/{libvips-cpp.so.8.18.6 → libvips-cpp.so.8.18.7} +0 -0
  17. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/package.json +2 -2
  18. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/versions.json +11 -11
  19. package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/index.cjs +1 -1
  20. package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/lib/{sharp-linux-arm64-0.35.4.node → sharp-linux-arm64-0.35.5.node} +0 -0
  21. package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/package.json +2 -2
  22. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/index.cjs +1 -1
  23. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.5.node.js +1 -0
  24. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/{sharp-wasm32-0.35.4.node.wasm → sharp-wasm32-0.35.5.node.wasm} +0 -0
  25. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/package.json +1 -1
  26. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/versions.json +7 -7
  27. package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.cts +2 -2
  28. package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.mts +3 -3
  29. package/lambda/asset-transform/dist/node_modules/sharp/dist/is.cjs +8 -2
  30. package/lambda/asset-transform/dist/node_modules/sharp/dist/is.mjs +8 -2
  31. package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.cjs +2 -2
  32. package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.mjs +2 -2
  33. package/lambda/asset-transform/dist/node_modules/sharp/dist/output.cjs +1 -0
  34. package/lambda/asset-transform/dist/node_modules/sharp/dist/output.mjs +1 -0
  35. package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.cjs +14 -14
  36. package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.mjs +14 -14
  37. package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.cjs +3 -3
  38. package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.mjs +3 -3
  39. package/lambda/asset-transform/dist/node_modules/sharp/lib/index.d.ts +2 -2
  40. package/lambda/asset-transform/dist/node_modules/sharp/package.json +35 -35
  41. package/lambda/asset-transform/dist/node_modules/sharp/src/binding.gyp +1 -1
  42. package/lambda/asset-transform/dist/node_modules/sharp/src/common.cc +4 -4
  43. package/lambda/asset-transform/dist/node_modules/sharp/src/common.h +2 -2
  44. package/lambda/asset-transform/dist/node_modules/sharp/src/pipeline.cc +31 -6
  45. package/lambda/asset-transform/dist/node_modules/sharp/src/utilities.cc +1 -1
  46. package/package.json +4 -4
  47. package/worker/dist/index.js +251 -364
  48. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.4.node.js +0 -1
@@ -3,35 +3,21 @@ import { fileURLToPath } from 'node:url';
3
3
  import { Construct } from 'constructs';
4
4
  import { Duration, RemovalPolicy, Stack, Token, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_s3_assets as s3assets, aws_logs as logs, } from 'aws-cdk-lib';
5
5
  import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
6
- // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
7
- // output is real ESM - `__dirname` is not a global there. Found while
8
- // fixing this construct's B1 deploy blocker below: the worker asset path a
9
- // few lines down threw `__dirname is not defined` under a real ESM runtime
10
- // (e.g. `tsx`) - masked in this file's own tests only because Vitest's
11
- // SSR/CJS-interop transform shims `__dirname` automatically. Same fix as
12
- // ../../lambda/asset-transform/build.mjs and ./asset-support.ts.
6
+ // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled output
7
+ // is real ESM and `__dirname` is not a global there - the worker asset path
8
+ // below throws `__dirname is not defined` under a real ESM runtime (e.g. `tsx`)
9
+ // without this. Vitest's SSR/CJS-interop transform shims `__dirname`
10
+ // automatically, so this file's own tests would not catch its absence. Same fix
11
+ // as ../../lambda/asset-transform/build.mjs and ./asset-support.ts.
13
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
13
  /**
15
14
  * Synth-time mirror of `resolveDeploymentName`'s rule in the `canopycms`
16
15
  * package (packages/canopycms/src/operating-mode/deployment-name.ts).
17
- * Duplicated rather than imported, but NOT for the reason this comment used
18
- * to give ("canopycms-cdk publishes with no runtime dependency on canopycms").
19
- * That was false, and measurably so: `pnpm --filter canopycms-cdk run build`
20
- * emits `dist/index.js` -> `export { CmsWorker } from './worker.js'` and
21
- * `dist/worker.js` -> `export { CmsWorker } from 'canopycms/worker/cms-worker'`
22
- * -- a bare, unresolved specifier in tsc output, reached from this package's
23
- * MAIN entry point. (The esbuild bundle is a different artifact,
24
- * `worker/dist/index.js`, built for the EC2 instance.) `canopycms` is
25
- * correspondingly a non-optional `peerDependency` in package.json, so
26
- * importing `canopycms-cdk` already requires `canopycms` to resolve.
27
16
  *
28
- * The honest reason is narrower: importing the predicate here would make a
29
- * CONSTRUCT-only consumer pay for the core package's module graph, and the
30
- * drift it risks is already covered by a test (below). Whether that still
31
- * justifies duplicating is an open question, tracked with the same question
32
- * about the S3 prefix constants in
33
- * .claude/future-tasks/cdk-prefixes-duplication.md, which reached this
34
- * conclusion first.
17
+ * Duplicated rather than imported so a CONSTRUCT-only consumer does not pay for
18
+ * the core package's module graph. Whether that still justifies duplicating is
19
+ * an open question, tracked alongside the same question about the S3 prefix
20
+ * constants in .claude/future-tasks/cdk-prefixes-duplication.md.
35
21
  *
36
22
  * Drift between the two copies is caught by a test, not by this comment:
37
23
  * cms-deploy.test.ts drives both this construct and the runtime predicate over
@@ -66,12 +52,8 @@ const ENV_HEREDOC_DELIMITER = 'ENVEOF';
66
52
  * deploy clean and fail at boot, or worse, boot with a subtly wrong
67
53
  * environment.
68
54
  *
69
- * Applied to every entry in `envEntries` below by construction rather than by
70
- * remembering to call it — the previous version validated `deploymentName`
71
- * only, while giving a rationale that applied verbatim to the other three
72
- * interpolated values.
73
- *
74
- * Returns the value so it can be used inline.
55
+ * Applied to EVERY entry in `envEntries` below by construction, not by
56
+ * remembering to call it at each site.
75
57
  */
76
58
  function assertEnvSafe(name, value) {
77
59
  if (/[\r\n]/.test(value)) {
@@ -125,27 +107,22 @@ function assertEnvSafe(name, value) {
125
107
  * synth rather than at worker boot.
126
108
  *
127
109
  * Both values are interpolated into a git ref AND into a line of the worker's
128
- * `.env` that user-data writes with a shell heredoc. `assertEnvSafe` covers
129
- * the `.env` half (newlines, a leading quote, the ENVEOF delimiter); this covers the
130
- * ref half, because a value git refuses does not fail at `cdk deploy` -- it
131
- * fails inside the worker, where `verifyBaseBranchExists` throws,
132
- * `worker/index.ts` exits 1, and systemd's `Restart=always` turns it into a
133
- * crash loop with no signal at the deploy that caused it.
134
- *
135
- * DELIBERATELY NOT `isValidDeploymentName`. That rule governs a single ref
136
- * COMPONENT (`deploymentName` is interpolated into
137
- * `canopycms-settings-<name>`), so it forbids `/`. These two props are whole
138
- * branch names, where `/` is not merely legal but conventional --
139
- * `release/2026`, `epic/foo`. Reusing the component rule here would refuse
140
- * `cdk synth` for an adopter whose default branch is `release/v2`, and the
141
- * worker handles such names fine: it keeps the raw name for git refs and runs
142
- * it through `sanitizeBranchName` only for workspace DIRECTORY names.
143
- *
144
- * So this implements git's `check-ref-format` rules for a branch name
145
- * instead. There is no runtime counterpart to drift from (nothing in
146
- * `canopycms` validates a branch name -- the worker uses the string as
147
- * given), which is why this has its own tests rather than a shared fixture
148
- * list like `deploymentName`'s.
110
+ * `.env` that user-data writes with a shell heredoc. `assertEnvSafe` covers the
111
+ * `.env` half; this covers the ref half, because a value git refuses does not
112
+ * fail at `cdk deploy` -- it fails inside the worker, where
113
+ * `verifyBaseBranchExists` throws, `worker/index.ts` exits 1, and systemd's
114
+ * `Restart=always` turns it into a crash loop with no signal at the deploy that
115
+ * caused it.
116
+ *
117
+ * DELIBERATELY NOT `isValidDeploymentName`, which governs a single ref
118
+ * COMPONENT and so forbids `/`. These two props are whole branch names, where
119
+ * `/` is conventional (`release/2026`, `epic/foo`); reusing the component rule
120
+ * would refuse `cdk synth` for an adopter whose default branch is `release/v2`,
121
+ * and the worker handles such names fine - it keeps the raw name for git refs
122
+ * and runs it through `sanitizeBranchName` only for workspace DIRECTORY names.
123
+ * This implements git's `check-ref-format` rules for a branch name instead.
124
+ * Nothing in `canopycms` validates a branch name, so there is no runtime
125
+ * counterpart to drift from and no shared fixture list like `deploymentName`'s.
149
126
  */
150
127
  function assertValidGitBranchName(propName, value) {
151
128
  const reject = (why) => {
@@ -205,32 +182,26 @@ function assertValidGitBranchName(propName, value) {
205
182
  * name -- plus the six random characters AWS appends, when the ARN is a
206
183
  * complete one rather than the partial form this guard deliberately accepts --
207
184
  * and a further colon can only begin the `:json-key:version-stage:version-id`
208
- * tail.
209
- *
210
- * An earlier version of this anchored on the six-character suffix itself
211
- * (`-[A-Za-z0-9]{6}:`) and therefore missed the suffix form built on a
212
- * name-only ARN -- `arn:…:secret:gh:MY_KEY::`, which is the shape ECS's own
213
- * documentation shows. That ARN was accepted, stamped, and written into the IAM
214
- * policy, producing exactly the AccessDenied restart-loop this guard exists to
215
- * prevent.
185
+ * tail. Anchoring on the six-character suffix instead would MISS the name-only
186
+ * form `arn:…:secret:name:MY_KEY::`, which is the shape ECS's own documentation
187
+ * shows and so the one adopters copy.
216
188
  */
217
189
  const SECRET_ARN_WITH_FIELD_SUFFIX = /:secret:[^:]*:/;
218
190
  /**
219
191
  * The last two characters of the `…:json-key::` spelling -- the ECS suffix with
220
- * `version-stage` and `version-id` left empty, which is how the convention is
221
- * almost always written and how AWS's own examples show it.
192
+ * `version-stage` and `version-id` left empty, which is how AWS's own examples
193
+ * show it.
222
194
  *
223
195
  * Checked in ADDITION to the regex above, for one case the regex cannot see: an
224
196
  * unresolved CDK token, `${Token[TOKEN.42]}:MY_KEY::`, carries no `:secret:` to
225
197
  * anchor on because the ARN has not been rendered yet. No well-formed secret
226
198
  * ARN, token or literal, ends in two colons, so this is safe to refuse.
227
199
  *
228
- * KNOWN LIMIT, stated rather than fixed: a token ARN with NON-empty version
229
- * parts (`${Token[…]}:MY_KEY:AWSCURRENT:v1`) is caught by neither check and is
230
- * stamped verbatim. Recognising it needs `Token.isUnresolved` plus a guess at
231
- * where the token ends; the spelling adopters actually copy has the empty
232
- * parts, and every LITERAL ARN is caught by the regex whatever its version
233
- * parts say.
200
+ * KNOWN LIMIT: a token ARN with NON-empty version parts
201
+ * (`${Token[…]}:MY_KEY:AWSCURRENT:v1`) is caught by neither check and is stamped
202
+ * verbatim. Recognising it needs `Token.isUnresolved` plus a guess at where the
203
+ * token ends; the spelling adopters actually copy has the empty parts, and every
204
+ * LITERAL ARN is caught by the regex whatever its version parts say.
234
205
  */
235
206
  const SECRET_ARN_WITH_EMPTY_VERSION_TAIL = '::';
236
207
  /**
@@ -242,22 +213,19 @@ const SECRET_ARN_WITH_EMPTY_VERSION_TAIL = '::';
242
213
  * anything `cdk deploy` reports.
243
214
  *
244
215
  * 1. A JSON-field prop with no ARN prop. The field env var is stamped, the ARN
245
- * is not, and the credential is then read from nowhere: for Clerk that
246
- * leaves `refreshAuthCache` undefined, which disables auth-cache refresh
247
- * with NO log line at all; for GitHub the worker reports "CANOPYCMS_GITHUB_TOKEN
248
- * or CANOPYCMS_GITHUB_TOKEN_SECRET_ARN is required" while the adopter is
249
- * looking at a stack that plainly configures a GitHub secret.
250
- *
251
- * 2. An ARN carrying the ECS `:KEY::` suffix. That form is a
252
- * CloudFormation-dynamic-reference and ECS `secrets.valueFrom` convention;
253
- * the `GetSecretValue` API this worker calls does not parse it. Through the
254
- * scaffolded stack the adopter gets CDK's own cryptic complaint
255
- * ("does not appear to be complete; missing 6-character suffix" --
256
- * `Secret.fromSecretCompleteArn` gates on `/-[a-z0-9]{6}$/i`); hand-rolling
257
- * the stack, the string lands verbatim in the worker's IAM `Resource`, where
258
- * it can never match the real secret, and the worker gets AccessDenied.
259
- * Naming the JSON-field prop in the message is the whole point of the check:
260
- * the adopter's intent is supported, just spelled differently here.
216
+ * is not, and the credential is read from nowhere: for Clerk that leaves
217
+ * `refreshAuthCache` undefined, disabling auth-cache refresh with NO log line
218
+ * at all; for GitHub the worker reports "CANOPYCMS_GITHUB_TOKEN or
219
+ * CANOPYCMS_GITHUB_TOKEN_SECRET_ARN is required" while the adopter looks at a
220
+ * stack that plainly configures a GitHub secret.
221
+ * 2. An ARN carrying the ECS `:KEY::` suffix, a CloudFormation-dynamic-reference
222
+ * and ECS `secrets.valueFrom` convention the `GetSecretValue` API does not
223
+ * parse. Through the scaffolded stack the adopter gets CDK's own cryptic
224
+ * complaint ("does not appear to be complete; missing 6-character suffix");
225
+ * hand-rolling the stack, the string lands verbatim in the worker's IAM
226
+ * `Resource`, where it can never match the real secret, and the worker gets
227
+ * AccessDenied. The message names the JSON-field prop, because the adopter's
228
+ * intent is supported - just spelled differently here.
261
229
  *
262
230
  * An empty JSON field is rejected for the same reason `settingsBranch: ''` is:
263
231
  * the worker reads a blank env var as "not configured" (`|| undefined` at both
@@ -268,8 +236,7 @@ function assertSecretPropPair(arnPropName, arn, jsonFieldPropName, jsonField) {
268
236
  if (jsonField !== undefined) {
269
237
  // `.trim()`, not `=== ''`: systemd's EnvironmentFile parser strips leading
270
238
  // and trailing whitespace from a value, so `" "` reaches the worker as `""`
271
- // and takes the same silently-ignored path an empty string would. The
272
- // untrimmed check let exactly the case it was written for through.
239
+ // and takes the same silently-ignored path an empty string would.
273
240
  if (jsonField.trim() === '') {
274
241
  throw new Error(`CanopyCmsService: ${jsonFieldPropName} must name a key, but it is ` +
275
242
  `${JSON.stringify(jsonField)}. The worker reads a blank value as "no field configured" ` +
@@ -300,12 +267,10 @@ function assertSecretArnHasNoFieldSuffix(propName, arn, jsonFieldPropName) {
300
267
  // `secretsArns: [process.env.EXTRA_SECRET_ARN!]` is the idiom a CDK app that
301
268
  // reads its config from the environment reaches for -- the scaffolded
302
269
  // `bin/app.ts` does exactly that everywhere else -- and `!` turns an unset
303
- // variable into `undefined` with the compiler none the wiser.
304
- //
305
- // Throwing is a deliberate change from what that used to do. The entry was
306
- // silently dropped by the `typeof arn === 'string'` filter on the IAM union
307
- // below, so the worker was told to read a secret it had no grant for and got
308
- // AccessDenied at boot -- the failure that filter's own comment is about.
270
+ // variable into `undefined` with the compiler none the wiser. Throwing, not
271
+ // skipping: the `typeof arn === 'string'` filter on the IAM union below drops
272
+ // such an entry silently, leaving a worker told to read a secret it has no
273
+ // grant for, which is AccessDenied at boot.
309
274
  if (typeof arn !== 'string' || arn.length === 0) {
310
275
  throw new Error(`CanopyCmsService: ${propName} must be a non-empty secret ARN string, but it is ` +
311
276
  `${JSON.stringify(arn) ?? String(arn)}. An unset environment variable asserted with '!' ` +
@@ -345,30 +310,26 @@ const GITHUB_TOKEN_PROP_NAMES = ['githubTokenSecretArn', 'githubTokenSecretJsonF
345
310
  * would be written into the worker's `.env`, which systemd reads as
346
311
  * `EnvironmentFile=` where a newline begins a new variable. So the realistic
347
312
  * mistake is to paste the key into `githubAppPrivateKeySecretArn` -- the prop
348
- * whose name contains "PrivateKey" -- instead of the ARN of a secret holding
349
- * it.
350
- *
313
+ * whose name contains "PrivateKey" -- instead of the ARN of a secret holding it.
351
314
  * Without this, that lands on `assertEnvSafe`'s generic rule and reports "must
352
- * not contain a newline" about an ARN, which explains the mechanism and not the
353
- * mistake. Checked on each of `GITHUB_APP_PROP_NAMES` because the same
354
- * misunderstanding puts the key in any of them, and each would otherwise
355
- * produce a differently confusing message.
356
- * `githubAppPrivateKeySecretJsonField` is deliberately not checked: a JSON key
357
- * NAME is not somewhere anyone mistakes a PEM for, and it keeps this aligned
358
- * with the one list that defines what "the App props" are.
315
+ * not contain a newline" about an ARN, explaining the mechanism and not the
316
+ * mistake.
317
+ *
318
+ * Checked on each of `GITHUB_APP_PROP_NAMES`, since the same misunderstanding
319
+ * puts the key in any of them. `githubAppPrivateKeySecretJsonField` is
320
+ * deliberately not checked: a JSON key NAME is not somewhere anyone mistakes a
321
+ * PEM for, and this stays aligned with the one list defining "the App props".
359
322
  *
360
- * **Reached only by a hand-written stack.** The generated
323
+ * **Reached only by a hand-written stack**, since the generated
361
324
  * `infrastructure/lib/cms-stack.ts` resolves the ARN with
362
- * `Secret.fromSecretCompleteArn` BEFORE it constructs `CanopyCmsService`, so a
363
- * scaffolded adopter gets CDK's own complaint ("does not appear to be complete;
364
- * missing 6-character suffix") first. That asymmetry is the same one
365
- * `assertSecretPropPair` documents for the token ARN, and is why this guard is
366
- * worth having rather than redundant: the hand-written path has nothing else.
325
+ * `Secret.fromSecretCompleteArn` BEFORE constructing `CanopyCmsService`, so a
326
+ * scaffolded adopter gets CDK's own "does not appear to be complete" complaint
327
+ * first. The hand-written path has nothing else.
367
328
  *
368
- * A one-line key (a base64-wrapped PEM, say) is NOT caught here, and cannot be:
329
+ * A one-line key (a base64-wrapped PEM, say) is NOT caught here and cannot be -
369
330
  * it is indistinguishable from a malformed ARN at synth. It fails at boot in
370
- * `normalizeGitHubAppPrivateKey`, or -- for the ARN prop -- as a Secrets
371
- * Manager error naming the string it tried to fetch.
331
+ * `normalizeGitHubAppPrivateKey`, or for the ARN prop as a Secrets Manager error
332
+ * naming the string it tried to fetch.
372
333
  */
373
334
  function assertNotInlinePrivateKey(propName, value) {
374
335
  if (value === undefined || !value.includes('-----BEGIN'))
@@ -387,33 +348,26 @@ function assertNotInlinePrivateKey(propName, value) {
387
348
  * are the App's *slug* and its `Iv1.…` OAuth client id — both are on the same
388
349
  * settings page as the number, and neither works.
389
350
  *
390
- * The two fail differently at boot, and both are worse than failing here.
391
- * `createAppAuth` refuses a non-numeric `appId` at construction (measured
392
- * against `@octokit/auth-app@6.1.4`: `Number.isFinite(+options.appId)`), so the
393
- * app id at least produces a named error. The installation id is checked only
394
- * for falsiness there, so a non-numeric one is interpolated into
351
+ * Both fail worse at boot. `createAppAuth` refuses a non-numeric `appId` at
352
+ * construction (`@octokit/auth-app@6.1.4`: `Number.isFinite(+options.appId)`),
353
+ * so the app id at least produces a named error; the installation id is checked
354
+ * only for falsiness there, so a non-numeric one is interpolated into
395
355
  * `/app/installations/NaN/access_tokens` and comes back as a 404 that reads as
396
- * "the app is not installed" — sending the operator to re-install a perfectly
397
- * good App.
398
- *
356
+ * "the app is not installed", sending the operator to re-install a good App.
399
357
  * Stricter than `createAppAuth`'s own `+value` coercion, deliberately: that
400
- * accepts `' 12 '`, `12.5` and `0x1f`. None of them is an id.
358
+ * accepts `' 12 '`, `12.5` and `0x1f`, and none of them is an id.
401
359
  *
402
- * Two values pass through untouched, and both would otherwise be reported as the
403
- * wrong problem:
360
+ * Two values pass through untouched, both of which would otherwise be reported
361
+ * as the wrong problem:
404
362
  *
405
363
  * - **Empty**, which is what `process.env.GITHUB_APP_ID ?? ''` and an Actions
406
- * `vars.` reference to a variable nobody created both produce. That is not a
407
- * malformed id, it is an ABSENT one, and `assertGitHubAuthProps` says so by
408
- * name. Reporting `must be the numeric id (got "")` instead would send the
409
- * adopter to correct a value they never set.
410
- * - **An unresolved CDK token**, e.g.
411
- * `ssm.StringParameter.valueForStringParameter(…)` or `Fn.importValue(…)`,
412
- * whose value does not exist until deploy. Refusing it would make a legitimate
413
- * configuration unrepresentable; it is unverifiable here either way, so it
414
- * goes through and fails at the worker if it is wrong. `githubAppPrivateKeySecretArn`
415
- * already accepts a token (one trips neither the `:secret:X:` regex nor
416
- * `assertEnvSafe`), so this keeps the App props consistent with each other.
364
+ * `vars.` reference to a variable nobody created both produce. That is an
365
+ * ABSENT id, not a malformed one, and `assertGitHubAuthProps` says so by name.
366
+ * - **An unresolved CDK token**, e.g. `Fn.importValue(…)`, whose value does not
367
+ * exist until deploy. Refusing it would make a legitimate configuration
368
+ * unrepresentable, and it is unverifiable here either way.
369
+ * `githubAppPrivateKeySecretArn` already accepts a token, so this keeps the
370
+ * App props consistent with each other.
417
371
  */
418
372
  function assertNumericId(propName, value) {
419
373
  if (value === undefined || value === '' || /^\d+$/.test(value))
@@ -429,26 +383,21 @@ function assertNumericId(propName, value) {
429
383
  /**
430
384
  * Guards the GitHub credential props at synth: exactly one shape, fully given.
431
385
  *
432
- * Only the SECOND rule restates `resolveWorkerGitHubAuth`
433
- * (packages/canopycms/src/worker/github-auth.ts:237-248), which refuses both
434
- * credentials and refuses neither. The first has no counterpart there and could
435
- * not: core takes one already-built `githubAppAuth` object, so a partial set of
436
- * three props is not representable by the time it sees anything. Restating the
437
- * second rather than leaving it to core is the point: a worker that throws at
438
- * boot is restarted by systemd every 5 seconds indefinitely while `cdk deploy`
439
- * reports success, so a rule that only exists at boot is a rule the adopter
440
- * discovers from CloudWatch. The core check stays because core is reachable
441
- * without this construct.
442
- *
443
- * 1. **All three App props or none.** Two of the three is not a partial
444
- * configuration that could still work: `createAppAuth` needs the App ID, the
445
- * installation ID and the key, and the message names the missing ones rather
446
- * than saying the set is incomplete.
386
+ * 1. **All three App props or none.** Two of the three cannot work at all:
387
+ * `createAppAuth` needs the App ID, the installation ID and the key, and the
388
+ * message names the missing ones.
447
389
  * 2. **Not both an App and a token.** Rejected rather than resolved by
448
390
  * precedence, because it would otherwise be undefined which identity a push
449
391
  * or a pull request acts as -- and a PR opened by the wrong identity is not
450
392
  * something an adopter notices quickly.
451
393
  *
394
+ * Rule 2 also lives in `resolveWorkerGitHubAuth`
395
+ * (packages/canopycms/src/worker/github-auth.ts), which core keeps because core
396
+ * is reachable without this construct; rule 1 has no counterpart there, since
397
+ * core takes one already-built `githubAppAuth` object. Checking at synth matters
398
+ * because a worker that throws at boot is restarted by systemd every 5 seconds
399
+ * indefinitely while `cdk deploy` reports success.
400
+ *
452
401
  * Configuring NEITHER is deliberately not an error here. The worker also reads
453
402
  * `CANOPYCMS_GITHUB_TOKEN` directly from its environment, which an adopter can
454
403
  * supply outside this construct, and core refuses the genuinely empty case at
@@ -535,71 +484,48 @@ export const MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT = Duration.seconds(60);
535
484
  export class CanopyCmsService extends Construct {
536
485
  constructor(scope, id, props) {
537
486
  super(scope, id);
538
- // ------------------------------------------------------------------
539
- // Deployment name: ONE effective value, validated once, used by both halves
540
- // ------------------------------------------------------------------
541
- //
542
- // `props.environment` is spread into the Lambda's environment, so an
543
- // adopter can set CANOPYCMS_DEPLOYMENT_NAME there directly. That escape
544
- // hatch used to bypass both things that make this prop safe: the synth
545
- // guard below (an invalid value deployed clean and crash-looped the Lambda
546
- // at boot) and the worker, which kept reading `props.deploymentName` and so
547
- // resolved a DIFFERENT settings branch than the Lambda — exactly the split
548
- // that `pushSettingsBranches`'s [SYNC-M3] warning was added to detect.
549
- //
550
- // Kept rather than dropped (dropping is a breaking change for anyone
551
- // already setting it), but resolved to a single value here: whatever wins
552
- // is validated, and the same string is stamped on the Lambda AND written
553
- // into the worker's `.env` below. The two halves cannot disagree.
487
+ // Deployment name: ONE effective value, validated once, used by both halves.
488
+ // `props.environment` is spread into the Lambda's environment, so an adopter
489
+ // can set CANOPYCMS_DEPLOYMENT_NAME there directly; that escape hatch is
490
+ // resolved HERE so it cannot bypass the synth guard below, and so the same
491
+ // validated string is stamped on the Lambda AND written into the worker's
492
+ // `.env`. Two halves resolving different settings branches is what
493
+ // `pushSettingsBranches`'s "foreign settings branch" warning detects.
554
494
  const envDeploymentNameOverride = props.environment?.['CANOPYCMS_DEPLOYMENT_NAME'];
555
495
  const deploymentName = envDeploymentNameOverride ?? props.deploymentName ?? 'prod';
556
496
  const deploymentNameSource = envDeploymentNameOverride !== undefined
557
497
  ? 'environment.CANOPYCMS_DEPLOYMENT_NAME'
558
498
  : 'deploymentName';
559
- // Fail at synth, not at boot. deploymentName is interpolated BOTH into a
560
- // git ref (`canopycms-settings-{deploymentName}`) and into a line of the
561
- // worker's `.env`, which user-data writes with a shell heredoc — a value
562
- // carrying a newline or quote would corrupt the worker's environment file
563
- // before any runtime validation could run. Mirrors the package-side rule
564
- // in operating-mode/deployment-name.ts (resolveDeploymentName); the
565
- // fixture-driven drift test named above keeps the two in step.
499
+ // Fail at synth, not at boot. deploymentName is interpolated BOTH into a git
500
+ // ref (`canopycms-settings-{deploymentName}`) and into a line of the worker's
501
+ // `.env`, which user-data writes with a shell heredoc — a value carrying a
502
+ // newline or quote would corrupt the worker's environment file before any
503
+ // runtime validation could run.
566
504
  if (!isValidDeploymentName(deploymentName)) {
567
505
  throw new Error(`CanopyCmsService: invalid deploymentName ${JSON.stringify(deploymentName)} ` +
568
506
  `(from ${deploymentNameSource}). ` +
569
507
  `It must start with a letter or digit, contain only letters, digits, '.', '_' or '-', ` +
570
508
  `and must not contain '..' or end with '.' or '.lock'.`);
571
509
  }
572
- // ------------------------------------------------------------------
573
- // Base branch / settings branch: validated the same way as deploymentName
574
- // ------------------------------------------------------------------
575
- //
576
510
  // Both are interpolated into a git ref and the worker's `.env` heredoc, so
577
- // both are guarded at synth rather than left to fail (or silently diverge
578
- // from the shared `canopycms.config.ts`) at boot. See the doc comments on
579
- // `baseBranch`/`settingsBranch` above for the specific failure each guards
580
- // against. `settingsBranch` stays `undefined` (not stamped at all) unless
581
- // the adopter explicitly set it — an absent env var and an empty one are
582
- // NOT the same to the worker, which falls through to a computed name only
583
- // when `CANOPYCMS_SETTINGS_BRANCH` is unset entirely.
511
+ // both are guarded at synth - see their doc comments for the failure each
512
+ // prevents. `settingsBranch` stays `undefined` (not stamped at all) unless
513
+ // the adopter explicitly set it: an absent env var and an empty one are NOT
514
+ // the same to the worker, which falls through to a computed name only when
515
+ // `CANOPYCMS_SETTINGS_BRANCH` is unset entirely.
584
516
  const baseBranch = assertValidGitBranchName('baseBranch', props.baseBranch ?? 'main');
585
517
  const settingsBranch = props.settingsBranch !== undefined
586
518
  ? assertValidGitBranchName('settingsBranch', props.settingsBranch)
587
519
  : undefined;
588
- // ------------------------------------------------------------------
589
- // Secret ARNs and their JSON fields
590
- // ------------------------------------------------------------------
591
- //
592
- // Checked here, at the top, so a misconfigured pair fails `cdk synth`
593
- // rather than `cdk deploy`-then-restart-loop. See `assertSecretPropPair`
594
- // for what each of the two checks costs when it is absent.
520
+ // Checked at the top so a misconfigured pair fails `cdk synth` rather than
521
+ // `cdk deploy`-then-restart-loop.
595
522
  //
596
- // WHICH CREDENTIAL first, then whether each is well formed. The order is
597
- // load-bearing and the reverse produces self-contradictory advice: an
598
- // adopter following docs/adopter-migration.md removes `githubTokenSecretArn`
599
- // and overlooks `githubTokenSecretJsonField`, and the pair check answers
600
- // "Set githubTokenSecretArn, or drop githubTokenSecretJsonField" -- pointing
601
- // them straight back at the credential they were just told to delete, and at
602
- // a configuration the exclusivity rule below would then refuse anyway.
523
+ // WHICH CREDENTIAL first, then whether each is well formed: the order is
524
+ // load-bearing. Reversed, an adopter following docs/adopter-migration.md who
525
+ // removes `githubTokenSecretArn` and overlooks `githubTokenSecretJsonField`
526
+ // is told "Set githubTokenSecretArn, or drop githubTokenSecretJsonField" --
527
+ // pointing them back at the credential they were just told to delete, and at
528
+ // a configuration the exclusivity rule would refuse anyway.
603
529
  // `assertGitHubAuthProps` is silent when no App prop is set, so this costs
604
530
  // the token-only path nothing.
605
531
  assertGitHubAuthProps(props);
@@ -613,29 +539,23 @@ export class CanopyCmsService extends Construct {
613
539
  for (const [index, arn] of (props.secretsArns ?? []).entries()) {
614
540
  assertSecretArnHasNoFieldSuffix(`secretsArns[${index}]`, arn);
615
541
  }
616
- // ------------------------------------------------------------------
617
- // Operating mode
618
- // ------------------------------------------------------------------
619
- //
620
542
  // The adopter's `canopycms.config.ts` is shared by local dev, the image
621
- // build and this deployment, and it says `dev`. `next dev` needs that, and
622
- // the image build should stay in dev mode too: build-time reads come from
623
- // the working tree in either mode (`readsFromCheckout` in canopycms's
624
- // build-mode.ts), so nothing in a build needs prod, while prod would hold
543
+ // build and this deployment, and it says `dev`. That is right for both of
544
+ // the others: build-time reads come from the working tree in either mode
545
+ // (`readsFromCheckout` in canopycms's build-mode.ts), while prod would hold
625
546
  // the image builder to checks it has no reason to meet (gitBotAuthorName/
626
547
  // gitBotAuthorEmail, a credential-verifying auth plugin). So the deployed
627
- // mode is supplied here, at run time: `resolveOperatingMode`
628
- // (packages/canopycms/src/operating-mode/mode-env.ts) reads CANOPY_MODE
629
- // and it wins over the config literal. Without it the Lambda runs dev
630
- // mode, resolves its workspace to `<cwd>/.canopy-dev`, and fails EROFS on
631
- // Lambda's read-only filesystem.
548
+ // mode is supplied at run time instead: `resolveOperatingMode`
549
+ // (packages/canopycms/src/operating-mode/mode-env.ts) reads CANOPY_MODE and
550
+ // it wins over the config literal. Without it the Lambda runs dev mode,
551
+ // resolves its workspace to `<cwd>/.canopy-dev`, and fails EROFS on Lambda's
552
+ // read-only filesystem.
632
553
  //
633
554
  // Only 'prod' is accepted from `props.environment`: this construct deploys
634
- // the prod topology (EFS workspace, no internet, read-only container), and
635
- // 'dev' there cannot work — better a synth error than an EROFS crash-loop.
636
- // The browser half of `mode` cannot come from here at all; it is inlined
637
- // at image-build time from the NEXT_PUBLIC_CANOPY_MODE build arg (see
638
- // Dockerfile.cms.template and the generated cms-stack.ts).
555
+ // the prod topology (EFS workspace, no internet, read-only container), so a
556
+ // synth error beats an EROFS crash-loop. The browser half of `mode` cannot
557
+ // come from here at all; it is inlined at image-build time from the
558
+ // NEXT_PUBLIC_CANOPY_MODE build arg (Dockerfile.cms.template).
639
559
  const envModeOverride = props.environment?.['CANOPY_MODE'];
640
560
  if (envModeOverride !== undefined && envModeOverride !== 'prod') {
641
561
  throw new Error(`CanopyCmsService: invalid environment.CANOPY_MODE ${JSON.stringify(envModeOverride)}. ` +
@@ -643,9 +563,6 @@ export class CanopyCmsService extends Construct {
643
563
  `(dev mode resolves its workspace to <cwd>/.canopy-dev, which is read-only on Lambda). ` +
644
564
  `Omit it to get the default.`);
645
565
  }
646
- // ========================================================================
647
- // VPC — 2 AZs, public + private subnets, NO NAT
648
- // ========================================================================
649
566
  this.vpc =
650
567
  props.vpc ??
651
568
  new ec2.Vpc(this, 'Vpc', {
@@ -665,18 +582,15 @@ export class CanopyCmsService extends Construct {
665
582
  ],
666
583
  });
667
584
  // Gateway VPC endpoint for S3 (free - no hourly/data charge, unlike an
668
- // interface endpoint). Without this the PRIVATE_ISOLATED subnet has NO
669
- // route to S3 at all (no NAT, no IGW) - the CMS Lambda's S3AssetStore
670
- // calls (presigned POST generation, finalize's originals/meta writes)
671
- // would hang/fail outright (adversarial finding B1). `addGatewayEndpoint`
672
- // is on `IVpc` itself, so this works whether `this.vpc` was created here
673
- // or supplied via `props.vpc`.
585
+ // interface endpoint). Without this the PRIVATE_ISOLATED subnet has NO route
586
+ // to S3 at all (no NAT, no IGW) and the CMS Lambda's S3AssetStore calls
587
+ // (presigned POST generation, finalize's originals/meta writes) hang or fail
588
+ // outright. `addGatewayEndpoint` is on `IVpc` itself, so this works whether
589
+ // `this.vpc` was created here or supplied via `props.vpc`.
674
590
  this.vpc.addGatewayEndpoint('S3Endpoint', {
675
591
  service: ec2.GatewayVpcEndpointAwsService.S3,
676
592
  });
677
- // ========================================================================
678
593
  // EFS — persistent filesystem for content, git repos, cache
679
- // ========================================================================
680
594
  const efsSg = new ec2.SecurityGroup(this, 'EfsSg', {
681
595
  vpc: this.vpc,
682
596
  description: 'CanopyCMS EFS',
@@ -702,47 +616,39 @@ export class CanopyCmsService extends Construct {
702
616
  uid: '1000',
703
617
  },
704
618
  });
705
- // ========================================================================
706
- // Lambda — CMS app, private subnet, no internet, EFS mount
707
- // ========================================================================
708
619
  const lambdaSg = new ec2.SecurityGroup(this, 'LambdaSg', {
709
620
  vpc: this.vpc,
710
621
  description: 'CanopyCMS Lambda',
711
622
  allowAllOutbound: false, // No internet access
712
623
  });
713
- // Lambda ↔ EFS (ingress on EFS SG + egress on Lambda SG).
714
- // The Lambda SG is allowAllOutbound: false, so without the explicit egress
715
- // rule the NFS mount is blocked and every Lambda request fails to reach
716
- // /mnt/efs (DEP-C1). Mirrors the worker's ingress+egress pair below.
624
+ // The Lambda SG is allowAllOutbound: false, so without the explicit EGRESS
625
+ // rule as well as the EFS ingress rule the NFS mount is blocked and every
626
+ // Lambda request fails to reach /mnt/efs. Same pair for the worker below.
717
627
  efsSg.addIngressRule(lambdaSg, ec2.Port.tcp(2049), 'Lambda NFS access');
718
628
  lambdaSg.addEgressRule(efsSg, ec2.Port.tcp(2049), 'NFS to EFS');
719
- // Lambda -> S3 (via the gateway endpoint above), HTTPS only. The tight
720
- // option - `ec2.Peer.prefixList(<S3 managed prefix list id>)` - needs a
721
- // region-specific literal id (there is no CFN attribute exposing it off
722
- // `GatewayVpcEndpoint`, and `PrefixList.fromLookup` does a real AWS
723
- // context-provider lookup at synth time, which would make this
724
- // construct's synth require live AWS credentials - unacceptable for a
725
- // construct whose own unit tests synth with a fake account/region).
726
- // `anyIpv4()` on 443 is safe here specifically because the route table
727
- // for this PRIVATE_ISOLATED subnet has no route to 0.0.0.0/0 at all (no
728
- // NAT, no IGW) - only to the VPC CIDR and to configured endpoints'
729
- // prefix-list routes - so this rule cannot actually reach the general
730
- // internet; the route table, not the security group, is the real
731
- // boundary here. Narrow this to `Peer.prefixList(...)` if/when a
732
- // region-agnostic way to reference the S3 managed prefix list lands in
733
- // CDK, or if this VPC ever gains a NAT/IGW route.
629
+ // Lambda -> S3 via the gateway endpoint above, HTTPS only. The tight option,
630
+ // `ec2.Peer.prefixList(<S3 managed prefix list id>)`, needs a
631
+ // region-specific literal id: no CFN attribute exposes it off
632
+ // `GatewayVpcEndpoint`, and `PrefixList.fromLookup` is a real AWS
633
+ // context-provider lookup, which would make this construct's synth require
634
+ // live AWS credentials.
635
+ //
636
+ // `anyIpv4()` on 443 is safe HERE SPECIFICALLY because this
637
+ // PRIVATE_ISOLATED subnet's route table has no route to 0.0.0.0/0 at all
638
+ // (no NAT, no IGW) - only the VPC CIDR and configured endpoints' prefix-list
639
+ // routes - so the rule cannot reach the general internet. The route table,
640
+ // not the security group, is the real boundary. Narrow this to
641
+ // `Peer.prefixList(...)` if a region-agnostic reference to the S3 managed
642
+ // prefix list lands in CDK, or if this VPC ever gains a NAT/IGW route.
734
643
  lambdaSg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'HTTPS to S3 (via gateway endpoint)');
735
- // Dedicated CloudWatch log group for the CMS Lambda's stdout/stderr.
736
- // Custom name (NOT the CloudFormation-implicit `/aws/lambda/<function
737
- // name>`), for two reasons: (1) CDK does not manage that implicit group
738
- // at all - infinite retention, and `cdk destroy` leaves it behind (the
739
- // deploy-test teardown had to sweep it manually); (2) Lambda auto-creates
740
- // `/aws/lambda/<function name>` on first invoke, OUTSIDE CloudFormation -
741
- // once that has happened (e.g. this stack was already deployed before
742
- // this log group existed), a CDK `LogGroup` construct using that exact
743
- // name would fail its `CreateLogGroup` call with "already exists" and
744
- // block every future `cdk deploy`. Mirrors `workerLogGroup` below, which
745
- // predates this and already follows the same convention.
644
+ // Dedicated CloudWatch log group for the CMS Lambda's stdout/stderr,
645
+ // custom-named and NOT the CloudFormation-implicit `/aws/lambda/<function
646
+ // name>`, for two reasons: CDK does not manage that implicit group at all
647
+ // (infinite retention, and `cdk destroy` leaves it behind), and Lambda
648
+ // auto-creates it on first invoke OUTSIDE CloudFormation, after which a CDK
649
+ // `LogGroup` using that exact name fails `CreateLogGroup` with "already
650
+ // exists" and blocks every future `cdk deploy`. Same convention as
651
+ // `workerLogGroup` below.
746
652
  this.cmsLogGroup = new logs.LogGroup(this, 'CmsFunctionLogs', {
747
653
  logGroupName: props.cmsLogGroupName ?? `/canopycms/${Stack.of(this).stackName}/cms`,
748
654
  retention: props.cmsLogRetention ?? logs.RetentionDays.THREE_MONTHS,
@@ -783,61 +689,52 @@ export class CanopyCmsService extends Construct {
783
689
  // LogGroup construct above instead.
784
690
  logGroup: this.cmsLogGroup,
785
691
  environment: {
786
- // INVARIANT (B1): the Lambda mounts EFS through the WorkspaceAP access
787
- // point above, which is already rooted at EFS:/workspace - so /mnt/efs
788
- // here IS EFS:/workspace. The EC2 worker instead mounts the filesystem
789
- // ROOT at /mnt/efs (see UserData below) and reaches the same directory
790
- // via /mnt/efs/workspace. Both paths must resolve to EFS:/workspace,
791
- // or the Lambda and worker silently operate on different directories.
692
+ // INVARIANT: the Lambda mounts EFS through the WorkspaceAP access point
693
+ // above, which is already rooted at EFS:/workspace - so /mnt/efs here IS
694
+ // EFS:/workspace. The EC2 worker instead mounts the filesystem ROOT at
695
+ // /mnt/efs (see UserData below) and reaches the same directory via
696
+ // /mnt/efs/workspace. Both paths must resolve to EFS:/workspace, or the
697
+ // Lambda and worker silently operate on different directories.
792
698
  CANOPYCMS_WORKSPACE_ROOT: '/mnt/efs',
793
699
  CANOPY_AUTH_CACHE_PATH: '/mnt/efs/.cache',
794
- // B7 note: git >= 2.35.2 refuses repos owned by another uid (the
795
- // access point forces uid 1000; Lambda containers run as a different
796
- // user). Env-based GIT_CONFIG_* CANNOT fix this - simple-git
797
- // hard-blocks env config (deploy-proven 2026-07-24). The fix lives in
798
- // the image: Dockerfile.cms.template runs
700
+ // git >= 2.35.2 refuses repos owned by another uid (the access point
701
+ // forces uid 1000; Lambda containers run as a different user).
702
+ // Env-based GIT_CONFIG_* CANNOT fix this - simple-git hard-blocks env
703
+ // config. The fix lives in the image: Dockerfile.cms.template runs
799
704
  // `git config --system safe.directory '*'`.
800
705
  ...props.environment,
801
- // AFTER the spread, deliberately. Both values are already the
802
- // adopter's own choice (an `environment` override is folded into
803
- // `deploymentName` above and validated; CANOPY_MODE is restricted to
804
- // 'prod'), so nothing is being taken away here - what the placement
805
- // buys is that the Lambda and the worker's `.env` cannot end up
806
- // holding different strings, which is the failure mode that made this
807
- // an escape hatch worth fixing rather than a harmless one.
706
+ // AFTER the spread, deliberately. Both values are already the adopter's
707
+ // own choice (an `environment` override is folded into `deploymentName`
708
+ // above and validated; CANOPY_MODE is restricted to 'prod'), so nothing
709
+ // is taken away - the placement is what stops the Lambda and the
710
+ // worker's `.env` holding different strings.
808
711
  CANOPYCMS_DEPLOYMENT_NAME: deploymentName,
809
712
  CANOPY_MODE: 'prod',
810
713
  },
811
714
  });
812
715
  // Explicit, scoped grant - NOT a reliance on the auto-created execution
813
- // role's AWSLambdaBasicExecutionRole managed policy (attached by CDK's
814
- // lambda.Function/DockerImageFunction regardless of `logGroup`, and
815
- // never adjusted for it - passing `logGroup` only points the function's
816
- // LoggingConfig at this group, it grants no IAM permissions). That
817
- // managed policy's logs:CreateLogStream/logs:PutLogEvents statement is
818
- // scoped to `arn:aws:logs:*:*:log-group:/aws/lambda/*:*` only (its
819
- // logs:CreateLogGroup statement is the sole one that's unrestricted) -
820
- // it grants nothing for a custom-named group like this one. Without this
821
- // grantWrite, the function would still create log streams to the void:
822
- // CloudWatch Logs delivery failures are never surfaced to the function's
823
- // own invocation, so logs would simply vanish with no error anywhere.
716
+ // role's AWSLambdaBasicExecutionRole managed policy, which CDK attaches
717
+ // regardless of `logGroup` and never adjusts for it (passing `logGroup` only
718
+ // points the function's LoggingConfig at this group; it grants no IAM). That
719
+ // policy's logs:CreateLogStream/logs:PutLogEvents statement is scoped to
720
+ // `arn:aws:logs:*:*:log-group:/aws/lambda/*:*`, so it grants nothing for a
721
+ // custom-named group. Without this grantWrite the function creates log
722
+ // streams into the void: CloudWatch Logs delivery failures never reach the
723
+ // invocation, so logs simply vanish with no error anywhere.
824
724
  this.cmsLogGroup.grantWrite(this.lambdaFunction);
825
- // Function URL for CloudFront origin.
826
- // AWS_IAM (not NONE): the URL must only be reachable through CloudFront,
827
- // which signs origin requests via Origin Access Control (see
725
+ // AWS_IAM (not NONE): the Function URL must only be reachable through
726
+ // CloudFront, which signs origin requests via Origin Access Control (see
828
727
  // CanopyCmsDistribution). With NONE, anyone who learns the URL hits the CMS
829
- // directly, bypassing CloudFront (DEP-H2). Adopters wiring their own
830
- // CloudFront must configure an OAC and grant it lambda:InvokeFunctionUrl.
728
+ // directly, bypassing CloudFront. Adopters wiring their own CloudFront must
729
+ // configure an OAC and grant it lambda:InvokeFunctionUrl.
831
730
  this.functionUrl = this.lambdaFunction.addFunctionUrl({
832
731
  authType: lambda.FunctionUrlAuthType.AWS_IAM,
833
732
  });
834
- // Asset bucket access (optional) - grants the CMS Lambda's role the same
835
- // prefix-scoped put/get/delete permissions `AssetSupport.grantUploadAccess()`
836
- // grants, without this construct depending on `AssetSupport` directly
837
- // (kept decoupled - a consumer wires both constructs together in their
838
- // own stack). Duplicated rather than shared because the two constructs
839
- // must stay independently usable (`AssetSupport` has no CMS-service
840
- // dependency either).
733
+ // The same prefix-scoped put/get/delete grants as
734
+ // `AssetSupport.grantUploadAccess()`, duplicated rather than shared so the
735
+ // two constructs stay independently usable - a consumer wires them together
736
+ // in their own stack, and `AssetSupport` has no CMS-service dependency
737
+ // either.
841
738
  if (props.assetBucket) {
842
739
  const prefixes = {
843
740
  staging: 'asset-staging',
@@ -856,57 +753,38 @@ export class CanopyCmsService extends Construct {
856
753
  props.assetBucket.grantDelete(this.lambdaFunction, `${prefixes.staging}/*`);
857
754
  props.assetBucket.grantDelete(this.lambdaFunction, `${prefixes.meta}/*`);
858
755
  }
859
- // ========================================================================
860
- // EC2 Worker — t4g.nano spot, public subnet, internet, EFS mount
861
- // ========================================================================
862
756
  const workerSg = new ec2.SecurityGroup(this, 'WorkerSg', {
863
757
  vpc: this.vpc,
864
758
  description: 'CanopyCMS EC2 Worker',
865
759
  allowAllOutbound: false,
866
760
  });
867
- // Worker ↔ EFS (ingress on EFS SG + egress on Worker SG)
868
761
  efsSg.addIngressRule(workerSg, ec2.Port.tcp(2049), 'Worker NFS access');
869
762
  workerSg.addEgressRule(efsSg, ec2.Port.tcp(2049), 'NFS to EFS');
870
- // Worker → internet (HTTPS only)
871
763
  workerSg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'HTTPS outbound');
872
764
  // Worker → DNS (needed for EFS DNS-based mount targets)
873
765
  workerSg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(53), 'DNS TCP');
874
766
  workerSg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.udp(53), 'DNS UDP');
875
- // Worker IAM role
876
767
  const workerRole = new iam.Role(this, 'WorkerRole', {
877
768
  assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
878
769
  description: 'CanopyCMS EC2 Worker role',
879
770
  });
880
- // Worker needs to read secrets.
771
+ // The worker's secret grant MUST be the UNION of `secretsArns` and every
772
+ // individual ARN prop. Anything the construct stamps into the worker's
773
+ // `.env` and omits here is a worker that knows WHICH secret to read and has
774
+ // no permission to read it: `cdk deploy` succeeds, the worker boots, gets
775
+ // AccessDenied from GetSecretValue, exits, and systemd restart-loops it
776
+ // every 5s forever, with nothing flagged at synth.
881
777
  //
882
- // The grant is the UNION of `secretsArns` and the individual ARN props,
883
- // because the construct previously carried two disconnected
884
- // representations of "the secrets the worker reads": `secretsArns` fed
885
- // this policy, while `githubTokenSecretArn`/`clerkSecretKeySecretArn` fed
886
- // only the worker's `.env`. An adopter hand-writing their stack (both are
887
- // individually documented, and `secretsArns`'s doc comment did not say it
888
- // was the sole source of IAM) could set the individual props and omit
889
- // `secretsArns` -- producing a worker that knows WHICH secret to read and
890
- // has no permission to read it. `cdk deploy` succeeded; the worker booted,
891
- // got AccessDenied from GetSecretValue, exited, and systemd restart-looped
892
- // it every 5s forever. Nothing flagged it at synth.
893
- //
894
- // Deduped for the benefit of anyone reading this list, NOT of the emitted
895
- // template: measured against aws-cdk-lib 2.265, `PolicyStatement` already
896
- // collapses a repeated `resources` entry, so passing the same ARN twice
897
- // renders one `Resource` either way. Removing this `new Set` changes no
898
- // synthesized output and breaks no test -- which is worth saying out loud,
899
- // because the comment here previously claimed the opposite and a reader
900
- // could reasonably have trusted it while refactoring.
778
+ // Deduped for the reader, NOT for the emitted template: `PolicyStatement`
779
+ // already collapses a repeated `resources` entry (aws-cdk-lib 2.265), so
780
+ // removing this `new Set` changes no synthesized output.
901
781
  const secretsArns = [
902
782
  ...new Set([
903
783
  ...(props.secretsArns ?? []),
904
784
  props.githubTokenSecretArn,
905
- // The App private key is read by the same `getSecret` call path as
906
- // the token it replaces, from the same instance profile, so omitting
907
- // it here would reproduce that AccessDenied restart-loop exactly --
908
- // on the credential path an adopter reaches for precisely because
909
- // the token path was not good enough.
785
+ // Read by the same `getSecret` call path as the token it replaces,
786
+ // from the same instance profile, so omitting it here reproduces that
787
+ // AccessDenied restart-loop exactly.
910
788
  props.githubAppPrivateKeySecretArn,
911
789
  props.clerkSecretKeySecretArn,
912
790
  ].filter((arn) => typeof arn === 'string' && arn.length > 0)),
@@ -933,14 +811,11 @@ export class CanopyCmsService extends Construct {
933
811
  // CreateLogStream + PutLogEvents scoped to this group only (least privilege;
934
812
  // the group is pre-created by CFN so the agent never needs CreateLogGroup).
935
813
  this.workerLogGroup.grantWrite(workerRole);
936
- // Worker S3 Asset — upload bundled worker code to CDK assets bucket
937
- // The worker is bundled with esbuild into a single JS file (npm run build:worker)
814
+ // The worker is bundled with esbuild into a single JS file (pnpm run build:worker)
938
815
  const workerAsset = new s3assets.Asset(this, 'WorkerCode', {
939
816
  path: path.join(__dirname, '../../worker/dist'),
940
817
  });
941
818
  workerAsset.grantRead(workerRole);
942
- // Build worker .env file content from CDK props.
943
- //
944
819
  // Name/value PAIRS rather than pre-formatted lines: every value then flows
945
820
  // through `assertEnvSafe` in the single `map` below, so a value added here
946
821
  // later is guarded whether or not whoever adds it remembers to.
@@ -952,11 +827,11 @@ export class CanopyCmsService extends Construct {
952
827
  // The SAME string the Lambda's environment gets above, including an
953
828
  // `environment.CANOPYCMS_DEPLOYMENT_NAME` override - the two halves
954
829
  // resolve one settings branch (`canopycms-settings-<name>`) between them,
955
- // and disagreeing here is what [SYNC-M3] warns about at runtime.
830
+ // and disagreeing here is what the runtime warning detects.
956
831
  ['CANOPYCMS_DEPLOYMENT_NAME', deploymentName],
957
- // B8: the AWS SDK JS v3 cannot resolve a region from IMDS on its own -
958
- // without this the worker's bare `SecretsManagerClient({})` crash-loops
959
- // with "Region is missing".
832
+ // The AWS SDK JS v3 cannot resolve a region from IMDS on its own - without
833
+ // this the worker's bare `SecretsManagerClient({})` crash-loops with
834
+ // "Region is missing".
960
835
  ['AWS_REGION', Stack.of(this).region],
961
836
  ];
962
837
  if (props.githubTokenSecretArn) {
@@ -964,23 +839,18 @@ export class CanopyCmsService extends Construct {
964
839
  }
965
840
  // The JSON-field vars need NO IAM change, and that is not an oversight: a
966
841
  // field is a key inside a secret's value, not a separately grantable
967
- // resource. `secretsmanager:GetSecretValue` on the secret -- already
968
- // granted above from the same ARN prop -- returns the whole document, and
969
- // the worker picks the field out of it in `getSecret`. Said explicitly
970
- // because the deduped union above exists precisely because someone
971
- // previously assumed the two prop families were connected when they were
972
- // not, and shipped a worker that knew which secret to read and had no
973
- // permission to read it.
842
+ // resource. `secretsmanager:GetSecretValue` on the secret -- already granted
843
+ // above from the same ARN prop -- returns the whole document, and the worker
844
+ // picks the field out of it in `getSecret` (worker/secrets.ts).
974
845
  if (props.githubTokenSecretJsonField) {
975
846
  envEntries.push([
976
847
  'CANOPYCMS_GITHUB_TOKEN_SECRET_JSON_FIELD',
977
848
  props.githubTokenSecretJsonField,
978
849
  ]);
979
850
  }
980
- // GitHub App credentials. Stamped individually rather than as a group even
981
- // though `assertGitHubAuthProps` has already established they are all set
982
- // or all unset: each `if` is then the same shape as every other entry here,
983
- // and a future prop added to the App set cannot be silently dropped by a
851
+ // GitHub App credentials, stamped individually even though
852
+ // `assertGitHubAuthProps` has established they are all set or all unset: a
853
+ // future prop added to the App set then cannot be silently dropped by a
984
854
  // condition that names only its siblings.
985
855
  if (props.githubAppId) {
986
856
  envEntries.push(['CANOPYCMS_GITHUB_APP_ID', props.githubAppId]);
@@ -1015,7 +885,6 @@ export class CanopyCmsService extends Construct {
1015
885
  const envFileContent = envEntries
1016
886
  .map(([name, value]) => `${name}=${assertEnvSafe(name, value)}`)
1017
887
  .join('\n');
1018
- // UserData script
1019
888
  const userData = ec2.UserData.forLinux();
1020
889
  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', 'mkdir -p /mnt/efs', `mount -t efs ${this.fileSystem.fileSystemId}:/ /mnt/efs`, '# 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}:/ /mnt/efs efs _netdev 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=/mnt/efs', '', '[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', '# Non-recursive: EFS access point enforces UID 1000 for Lambda.', '# Only set ownership on mount point and workspace dir to avoid', '# slow recursive chown on large filesystems during ASG replacements.', 'chown ec2-user:ec2-user /mnt/efs', 'mkdir -p /mnt/efs/workspace', 'chown ec2-user:ec2-user /mnt/efs/workspace', '', '# 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}",',
1021
890
  // The worker prefixes every line with an ISO-8601 timestamp
@@ -1052,7 +921,6 @@ export class CanopyCmsService extends Construct {
1052
921
  maxPrice: parseFloat(props.spotMaxPrice ?? '0.0042'), // On-demand rate for t4g.nano
1053
922
  },
1054
923
  });
1055
- // Auto Scaling Group
1056
924
  this.workerAsg = new autoscaling.AutoScalingGroup(this, 'WorkerAsg', {
1057
925
  vpc: this.vpc,
1058
926
  vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
@@ -1078,52 +946,40 @@ export class CanopyCmsService extends Construct {
1078
946
  // launch template, so a worker code change actually reaches it.
1079
947
  //
1080
948
  // minInstancesInService: 0 is REQUIRED, not just accepted, because
1081
- // minCapacity/maxCapacity are both 1: there is no way to keep an
1082
- // instance "in service" out of a max of 1 while its replacement is
1083
- // being created. The update is therefore terminate-then-relaunch, with
1084
- // a short worker outage while the replacement boots (yum install
1085
- // git/unzip/nodejs/efs-utils, mount EFS - realistically 2-4 minutes).
1086
- // That outage is acceptable: the task queue and branch workspaces live
1087
- // on EFS, not on the instance, so the new instance picks up exactly
1088
- // where the old one left off; the Lambda's Save/Publish paths only
1089
- // enqueue task files onto EFS and never talk to the worker directly,
1090
- // so they are unaffected by the worker being briefly down. A task that
1091
- // was actually mid-flight when the old instance was terminated is
1092
- // handled by orphan recovery now running on every task-queue cycle,
1093
- // not only at worker boot (see recoverOrphanedTasks's call site in
949
+ // minCapacity/maxCapacity are both 1: nothing can stay "in service" out of
950
+ // a max of 1 while its replacement is created. The update is therefore
951
+ // terminate-then-relaunch, with a short worker outage while the
952
+ // replacement boots (2-4 minutes of package installs and the EFS mount).
953
+ // That outage is acceptable: the task queue and branch workspaces live on
954
+ // EFS, not on the instance, so the new instance picks up where the old one
955
+ // left off, and the Lambda's Save/Publish paths only enqueue task files
956
+ // onto EFS and never talk to the worker directly. A task mid-flight at
957
+ // termination is handled by orphan recovery on every task-queue cycle, not
958
+ // only at worker boot (recoverOrphanedTasks in
1094
959
  // CmsWorker.processTaskQueue(), packages/canopycms/src/worker/cms-worker.ts).
1095
960
  //
1096
- // Deliberately NOT paired with a `signals`/cfn-signal setup (and
1097
- // `waitOnResourceSignals` therefore defaults to false here, so this
1098
- // rolling update does not wait on one): see the comment below.
961
+ // `waitOnResourceSignals` therefore defaults to false here - see the
962
+ // no-cfn-signal note below.
1099
963
  updatePolicy: autoscaling.UpdatePolicy.rollingUpdate({ minInstancesInService: 0 }),
1100
964
  });
1101
- // Why this PR does NOT add cfn-signal, despite the rolling update above
1102
- // now making instance replacement routine instead of rare:
965
+ // Deliberately NO cfn-signal, for two reasons:
1103
966
  //
1104
- // 1. User-data runs under `set -euo pipefail`, and the CloudWatch-agent
1105
- // block is placed at the very end ON PURPOSE (see that block's own
1106
- // comment) so an agent failure there cannot kill the boot. A
1107
- // cfn-signal placed after it would then never run when that block
1108
- // fails, and CloudFormation would wait out its own timeout and
1109
- // fail/roll back the ENTIRE deploy - the opposite of the intent
1110
- // (agent shipping is best-effort; the worker itself must not be
1111
- // blocked by it).
1112
- // 2. Even placed earlier (right after `systemctl start canopy-worker`),
1113
- // a signal there would prove almost nothing: the systemd unit is
1114
- // `Type=simple` with `Restart=always`, so `systemctl start` returns 0
1115
- // the instant the process execs, regardless of what happens next. A
1116
- // worker that immediately crash-loops (bad env, bad bundle) would
1117
- // still signal SUCCESS. A real readiness gate would have to poll
1118
- // `worker-status.json` or `systemctl is-active` in a loop before
1119
- // signaling - a bigger change than this PR should carry.
967
+ // 1. User-data runs under `set -euo pipefail` and the CloudWatch-agent block
968
+ // sits at the very end ON PURPOSE, so an agent failure cannot kill the
969
+ // boot. A cfn-signal after it would never run when that block fails, and
970
+ // CloudFormation would wait out its timeout and roll back the ENTIRE
971
+ // deploy - the opposite of "agent shipping is best-effort".
972
+ // 2. Placed earlier (right after `systemctl start canopy-worker`) a signal
973
+ // proves almost nothing: the unit is `Type=simple` with `Restart=always`,
974
+ // so `systemctl start` returns 0 the instant the process execs and a
975
+ // worker that immediately crash-loops still signals SUCCESS. A real
976
+ // readiness gate would have to poll `worker-status.json` or
977
+ // `systemctl is-active` in a loop first.
1120
978
  //
1121
- // recoverOrphanedTasks()'s per-cycle recovery (see above) is the
1122
- // intentionally simpler fix for the actual problem (a stranded task
1123
- // surviving an instance replacement) - it works regardless of WHY the
1124
- // instance was replaced (rolling update, spot interruption, manual
1125
- // terminate) and does not depend on the new instance ever proving
1126
- // "ready" in the first place.
979
+ // recoverOrphanedTasks()'s per-cycle recovery (see above) covers the actual
980
+ // problem - a stranded task surviving an instance replacement - regardless
981
+ // of WHY the instance was replaced, and without depending on the new one
982
+ // ever proving "ready".
1127
983
  // Boot ordering: the ASG can launch before EFS mount targets are
1128
984
  // available; user-data runs with `set -euo pipefail`, so an early
1129
985
  // `mount -t efs` failure kills the whole bootstrap and the