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.
- package/dist/constructs/asset-support.d.ts +137 -269
- package/dist/constructs/asset-support.js +275 -455
- package/dist/constructs/cms-distribution.d.ts +23 -40
- package/dist/constructs/cms-distribution.js +61 -88
- package/dist/constructs/cms-service.d.ts +38 -60
- package/dist/constructs/cms-service.js +246 -390
- package/dist/constructs/lambda-execution-role.d.ts +23 -39
- package/dist/constructs/lambda-execution-role.js +37 -60
- package/dist/index.js +0 -2
- package/dist/worker.d.ts +0 -2
- package/dist/worker.js +0 -2
- package/lambda/asset-transform/dist/handler.js +33 -49
- package/lambda/asset-transform/dist/node_modules/.package-lock.json +38 -38
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/README.md +1 -1
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/lib/glib-2.0/include/glibconfig.h +2 -2
- 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
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/package.json +2 -2
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/versions.json +11 -11
- package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/index.cjs +1 -1
- 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
- package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/package.json +2 -2
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/index.cjs +1 -1
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.5.node.js +1 -0
- 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
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/package.json +1 -1
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/versions.json +7 -7
- package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.cts +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.mts +3 -3
- package/lambda/asset-transform/dist/node_modules/sharp/dist/is.cjs +8 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/is.mjs +8 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.cjs +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.mjs +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/output.cjs +1 -0
- package/lambda/asset-transform/dist/node_modules/sharp/dist/output.mjs +1 -0
- package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.cjs +14 -14
- package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.mjs +14 -14
- package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.cjs +3 -3
- package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.mjs +3 -3
- package/lambda/asset-transform/dist/node_modules/sharp/lib/index.d.ts +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/package.json +35 -35
- package/lambda/asset-transform/dist/node_modules/sharp/src/binding.gyp +1 -1
- package/lambda/asset-transform/dist/node_modules/sharp/src/common.cc +4 -4
- package/lambda/asset-transform/dist/node_modules/sharp/src/common.h +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/src/pipeline.cc +31 -6
- package/lambda/asset-transform/dist/node_modules/sharp/src/utilities.cc +1 -1
- package/package.json +4 -4
- package/worker/dist/index.js +251 -364
- 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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
70
|
-
* remembering to call it
|
|
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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* `
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* DELIBERATELY NOT `isValidDeploymentName
|
|
136
|
-
* COMPONENT
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
*
|
|
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
|
|
221
|
-
*
|
|
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
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
306
|
-
//
|
|
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,
|
|
353
|
-
* mistake.
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* NAME is not somewhere anyone mistakes a
|
|
358
|
-
* with the one list
|
|
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
|
|
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
|
|
363
|
-
* scaffolded adopter gets CDK's own
|
|
364
|
-
*
|
|
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
|
|
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
|
|
371
|
-
*
|
|
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
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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"
|
|
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
|
|
358
|
+
* accepts `' 12 '`, `12.5` and `0x1f`, and none of them is an id.
|
|
401
359
|
*
|
|
402
|
-
* Two values pass through untouched,
|
|
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
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
* `
|
|
412
|
-
*
|
|
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
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
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
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
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
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
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
|
-
//
|
|
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
|
|
597
|
-
// load-bearing
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
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`.
|
|
622
|
-
// the
|
|
623
|
-
//
|
|
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
|
|
628
|
-
// (packages/canopycms/src/operating-mode/mode-env.ts) reads CANOPY_MODE
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
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),
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
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
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
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
|
|
714
|
-
//
|
|
715
|
-
//
|
|
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
|
|
720
|
-
//
|
|
721
|
-
// region-specific literal id
|
|
722
|
-
// `GatewayVpcEndpoint`, and `PrefixList.fromLookup`
|
|
723
|
-
// context-provider lookup
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
// `anyIpv4()` on 443 is safe
|
|
727
|
-
//
|
|
728
|
-
// NAT, no IGW) - only
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
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
|
-
//
|
|
737
|
-
// name
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
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
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
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
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
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
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
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
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
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
|
|
826
|
-
//
|
|
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
|
|
830
|
-
//
|
|
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
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
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
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
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
|
-
//
|
|
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
|
|
830
|
+
// and disagreeing here is what the runtime warning detects.
|
|
956
831
|
['CANOPYCMS_DEPLOYMENT_NAME', deploymentName],
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
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
|
-
//
|
|
969
|
-
//
|
|
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
|
|
981
|
-
//
|
|
982
|
-
//
|
|
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:
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
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
|
-
//
|
|
1097
|
-
//
|
|
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
|
-
//
|
|
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
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
// `
|
|
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)
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
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
|