canopycms-cdk 0.0.63 → 0.0.65

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.
@@ -1,5 +1,5 @@
1
1
  import { Construct } from 'constructs';
2
- import { RemovalPolicy, aws_cloudfront as cloudfront, aws_iam as iam, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3 } from 'aws-cdk-lib';
2
+ import { Duration, RemovalPolicy, aws_cloudfront as cloudfront, aws_iam as iam, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3 } from 'aws-cdk-lib';
3
3
  export interface AssetSupportProps {
4
4
  /**
5
5
  * Use an existing bucket (BYO mode - e.g. a site's existing content
@@ -76,6 +76,26 @@ export interface AssetSupportProps {
76
76
  * three months / 90 days).
77
77
  */
78
78
  readonly transformLogRetention?: logs.RetentionDays;
79
+ /**
80
+ * Concurrency cap on the transform Lambda (default: 10).
81
+ *
82
+ * This is a reservation — a CAP carved from the account's concurrency pool,
83
+ * not pre-warmed capacity, so it costs nothing when idle. It bounds the
84
+ * blast radius of the anonymous `/assets/t/*` path; see the default
85
+ * constant's doc comment. Raise it for an unusually image-heavy site;
86
+ * setting it to 0 would disable transforms entirely.
87
+ */
88
+ readonly transformReservedConcurrency?: number;
89
+ /**
90
+ * How long generated derivatives under `assets/t/` are kept (default: 180
91
+ * days). Only applies to a bucket this construct creates — in BYO-bucket
92
+ * mode the caller owns lifecycle rules.
93
+ *
94
+ * These objects are regenerable; expiry is self-healing (the next request
95
+ * regenerates and re-stores). Source assets under `asset-originals/` are
96
+ * never affected.
97
+ */
98
+ readonly transformOutputRetention?: Duration;
79
99
  /**
80
100
  * Name for the transform Lambda's CloudWatch log group (default:
81
101
  * `/canopycms/<stackName>/transform`). Deliberately NOT
@@ -179,4 +199,3 @@ export declare class AssetSupport extends Construct {
179
199
  */
180
200
  grantUploadAccess(grantee: iam.IGrantable): void;
181
201
  }
182
- //# sourceMappingURL=asset-support.d.ts.map
@@ -56,6 +56,43 @@ const CORS_MAX_AGE_SECONDS = 3000;
56
56
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
57
57
  const TRANSFORM_LAMBDA_MEMORY_MB = 1536;
58
58
  const TRANSFORM_LAMBDA_TIMEOUT = Duration.seconds(30);
59
+ /**
60
+ * Concurrency cap on the transform Lambda.
61
+ *
62
+ * `/assets/t/*` is reachable by any anonymous viewer through CloudFront, and
63
+ * `hash32` is not secret — it appears in every published page's `<img src>`.
64
+ * Width and quality are allowlisted precisely to bound how many cache keys one
65
+ * asset can have, but `crop` is a 4-decimal float rect (~10^16 values), so a
66
+ * scripted loop of unique crops is an unbounded stream of guaranteed
67
+ * CloudFront+S3 misses, each a sharp transform on a 1536MB Lambda plus a
68
+ * permanently stored S3 object.
69
+ *
70
+ * A reservation is the cheap half of bounding that: it is a CAP carved from the
71
+ * account's concurrency pool, not pre-warmed capacity, so it costs nothing when
72
+ * idle (that is `provisionedConcurrentExecutions`, which this is not).
73
+ *
74
+ * 10 mirrors the CMS Lambda's own reservation. Genuine demand is first-render
75
+ * misses only — every already-generated derivative is served by the S3 primary
76
+ * origin without invoking this function at all — so 10 concurrent transforms
77
+ * covers a cold page of images comfortably while capping a flood.
78
+ */
79
+ const TRANSFORM_LAMBDA_RESERVED_CONCURRENCY = 10;
80
+ /**
81
+ * Retention for generated derivatives under `assets/t/`.
82
+ *
83
+ * Everything under that prefix is REGENERABLE — source assets live under
84
+ * `asset-originals/` and are never touched by this rule. Without it the bucket
85
+ * keeps every derivative forever, including every object minted by the crop
86
+ * amplifier above.
87
+ *
88
+ * S3 lifecycle is prefix+age based; there is no "expire if not recently read"
89
+ * mode. Expiry is self-healing anyway: the next request for an expired
90
+ * derivative misses S3, fails over to the transform Lambda, regenerates and
91
+ * re-stores it — one invocation, then it is warm in both CloudFront and S3
92
+ * again. 180 days is set so ordinary traffic never notices and only genuinely
93
+ * cold or abusive objects age out.
94
+ */
95
+ const TRANSFORM_OUTPUT_RETENTION = Duration.days(180);
59
96
  /**
60
97
  * `/assets/t/*`-specific cache TTLs. The managed `CachePolicy.CACHING_OPTIMIZED`
61
98
  * (used for the plain `/assets/*` static behavior) has a 1-second MIN TTL,
@@ -125,9 +162,17 @@ export class AssetSupport extends Construct {
125
162
  versioned: props.versioned ?? false,
126
163
  removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN,
127
164
  autoDeleteObjects: props.autoDeleteObjects ?? false,
128
- // Only `asset-staging/` expires - originals/meta/public/transform
129
- // outputs are all kept forever by design (content-addressed,
165
+ // `asset-staging/` expires after a day, and generated derivatives
166
+ // under `assets/t/` after `transformOutputRetention`. Originals, meta
167
+ // and the public prefix are kept forever by design (content-addressed,
130
168
  // immutable - see the design record's "Storage" section).
169
+ //
170
+ // Derivatives were originally in that keep-forever set, on the same
171
+ // "immutable" reasoning. That holds for their CONTENT but not for
172
+ // their COUNT: `assets/t/` is the one prefix an anonymous caller can
173
+ // mint unbounded distinct keys in (see
174
+ // TRANSFORM_LAMBDA_RESERVED_CONCURRENCY), and unlike an original, a
175
+ // derivative that is deleted can simply be recomputed.
131
176
  lifecycleRules: [
132
177
  {
133
178
  id: 'expire-asset-staging',
@@ -135,6 +180,12 @@ export class AssetSupport extends Construct {
135
180
  prefix: `${PREFIXES.staging}/`,
136
181
  expiration: Duration.days(1),
137
182
  },
183
+ {
184
+ id: 'expire-transform-outputs',
185
+ enabled: true,
186
+ prefix: `${PREFIXES.transform}/`,
187
+ expiration: props.transformOutputRetention ?? TRANSFORM_OUTPUT_RETENTION,
188
+ },
138
189
  ],
139
190
  cors: [
140
191
  {
@@ -179,6 +230,9 @@ export class AssetSupport extends Construct {
179
230
  architecture: lambda.Architecture.ARM_64,
180
231
  memorySize: TRANSFORM_LAMBDA_MEMORY_MB,
181
232
  timeout: TRANSFORM_LAMBDA_TIMEOUT,
233
+ // See the constant's doc comment: this is an anonymous, uncapped compute
234
+ // and storage amplifier without it.
235
+ reservedConcurrentExecutions: props.transformReservedConcurrency ?? TRANSFORM_LAMBDA_RESERVED_CONCURRENCY,
182
236
  // Pass the pre-created group via `logGroup`, NOT `logRetention` (CDK
183
237
  // throws LogRetentionLogGroupConflict/ConflictingLogPolicyOptions if
184
238
  // both are set on the same function) - the removal policy lives on the
@@ -220,7 +274,12 @@ export class AssetSupport extends Construct {
220
274
  }
221
275
  buildBehaviors() {
222
276
  const s3Origin = origins.S3BucketOrigin.withOriginAccessControl(this.bucket);
223
- const transformLambdaOrigin = origins.FunctionUrlOrigin.withOriginAccessControl(this.transformFunctionUrl);
277
+ // readTimeout passed EXPLICITLY, matching the transform Lambda's own
278
+ // timeout. Unset, CloudFront applies its 30s service default, which
279
+ // happens to equal TRANSFORM_LAMBDA_TIMEOUT today -- an accidental match,
280
+ // not an asserted one: raising the Lambda's timeout alone would silently
281
+ // start 504ing the slow transforms the raise was meant to allow.
282
+ const transformLambdaOrigin = origins.FunctionUrlOrigin.withOriginAccessControl(this.transformFunctionUrl, { readTimeout: TRANSFORM_LAMBDA_TIMEOUT });
224
283
  const assets = {
225
284
  origin: s3Origin,
226
285
  viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
@@ -285,4 +344,3 @@ export class AssetSupport extends Construct {
285
344
  this.bucket.grantDelete(grantee, `${PREFIXES.meta}/*`);
286
345
  }
287
346
  }
288
- //# sourceMappingURL=asset-support.js.map
@@ -1,5 +1,5 @@
1
1
  import { Construct } from 'constructs';
2
- import { aws_cloudfront as cloudfront, aws_certificatemanager as acm, aws_route53 as route53, aws_lambda as lambda } from 'aws-cdk-lib';
2
+ import { Duration, aws_cloudfront as cloudfront, aws_certificatemanager as acm, aws_route53 as route53, aws_lambda as lambda } from 'aws-cdk-lib';
3
3
  export interface CanopyCmsDistributionProps {
4
4
  /** Lambda Function URL from CanopyCmsService */
5
5
  functionUrl: lambda.FunctionUrl;
@@ -11,6 +11,46 @@ export interface CanopyCmsDistributionProps {
11
11
  hostedZone?: route53.IHostedZone;
12
12
  /** Optional: provide an existing ACM certificate instead of creating one */
13
13
  certificate?: acm.ICertificate;
14
+ /**
15
+ * How long CloudFront waits for the origin to respond.
16
+ *
17
+ * Defaults to {@link DEFAULT_CMS_LAMBDA_TIMEOUT}, matching the CMS Lambda's
18
+ * own default. Pass `cmsService.timeout` when you override the Lambda's
19
+ * timeout, so the two cannot drift.
20
+ *
21
+ * Left unset on the origin, CloudFront applies its service default of **30
22
+ * seconds** — which silently caps a 60s Lambda at half its budget. Every
23
+ * request landing in the 30-60s band is answered 504 at the edge while the
24
+ * invocation continues to completion behind it: server-side success,
25
+ * viewer-facing failure, and nothing in either log explaining the other
26
+ * half. First-touch branch provisioning does a full `git clone` onto EFS
27
+ * inside the request, so this is a real path.
28
+ *
29
+ * Capped at 60s: CloudFront rejects more without a service-quota increase.
30
+ */
31
+ originReadTimeout?: Duration;
32
+ /**
33
+ * Extra CloudFront behaviors, merged with this construct's own.
34
+ *
35
+ * The reason this exists: `AssetSupport.assetBehaviors()` returns the two
36
+ * behaviors a media-enabled deployment needs (`/assets/*` and
37
+ * `/assets/t/*`), and without a way to pass them in there was no route to
38
+ * attach them to the distribution the scaffold generates — making the
39
+ * scaffold's own "uncomment to enable media" path a dead end.
40
+ *
41
+ * ORDER MATTERS. CloudFront matches path patterns in the order given, so a
42
+ * more specific pattern must be listed before a more general one that also
43
+ * matches: `/assets/t/*` before `/assets/*`, or every transform request is
44
+ * served by the static S3-only behavior and never fails over to the
45
+ * transform Lambda.
46
+ *
47
+ * Keys here override this construct's own behaviors on collision, which is
48
+ * deliberate — the caller is more specific than the default — and an
49
+ * overridden key takes YOUR position in this object, not the position the
50
+ * default held. (A plain object spread would do the opposite; see
51
+ * `mergeBehaviors`.)
52
+ */
53
+ additionalBehaviors?: Record<string, cloudfront.BehaviorOptions>;
14
54
  }
15
55
  /**
16
56
  * Optional CDK construct for CanopyCMS CloudFront distribution.
@@ -32,4 +72,3 @@ export declare class CanopyCmsDistribution extends Construct {
32
72
  readonly distribution: cloudfront.Distribution;
33
73
  constructor(scope: Construct, id: string, props: CanopyCmsDistributionProps);
34
74
  }
35
- //# sourceMappingURL=cms-distribution.d.ts.map
@@ -1,5 +1,27 @@
1
1
  import { Construct } from 'constructs';
2
- import { Duration, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_certificatemanager as acm, aws_route53 as route53, aws_route53_targets as targets, } from 'aws-cdk-lib';
2
+ import { Duration, Stack, Token, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_certificatemanager as acm, aws_route53 as route53, aws_route53_targets as targets, } from 'aws-cdk-lib';
3
+ import { DEFAULT_CMS_LAMBDA_TIMEOUT, MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT } from './cms-service.js';
4
+ /**
5
+ * Merge caller behaviors over this construct's own, preserving THE CALLER'S
6
+ * ordering for every key they supply.
7
+ *
8
+ * A plain `{ ...defaults, ...caller }` gets the values right and the ORDER
9
+ * wrong: JavaScript keeps an overridden key at its first-insertion index, so a
10
+ * caller who overrides `/_next/static/*` finds it pinned ahead of every other
11
+ * pattern they passed, regardless of the order they wrote. Since CloudFront
12
+ * matches path patterns in order, that silently makes a more specific pattern
13
+ * listed after it unreachable -- exactly the failure the prop's doc comment
14
+ * warns callers to avoid, introduced by the merge itself.
15
+ *
16
+ * Dropping collided defaults FIRST means the caller's own object contributes
17
+ * its keys in its own order.
18
+ */
19
+ function mergeBehaviors(defaults, caller) {
20
+ if (!caller)
21
+ return defaults;
22
+ const uncollided = Object.fromEntries(Object.entries(defaults).filter(([pattern]) => !(pattern in caller)));
23
+ return { ...uncollided, ...caller };
24
+ }
3
25
  /**
4
26
  * Optional CDK construct for CanopyCMS CloudFront distribution.
5
27
  *
@@ -28,6 +50,25 @@ export class CanopyCmsDistribution extends Construct {
28
50
  // ========================================================================
29
51
  // ACM Certificate
30
52
  // ========================================================================
53
+ // CloudFront requires its ACM certificate to live in us-east-1, and this
54
+ // construct creates one in the STACK's region. Nothing in the construct,
55
+ // the scaffold, or docs/deploying-to-aws.md said so -- the scaffold merely
56
+ // asks for an AWS_REGION variable with us-east-1 as an example -- so an
57
+ // adopter elsewhere got an opaque region error and had to research both
58
+ // workarounds themselves. Fail with the answer instead.
59
+ //
60
+ // Only when the region is actually known at synth: a region-agnostic stack
61
+ // resolves to a token, and rejecting that would break `cdk synth` for
62
+ // everyone (the token is not us-east-1 as a string). Those deploys are
63
+ // rejected by CloudFront at deploy time as before.
64
+ const stackRegion = Stack.of(this).region;
65
+ if (!props.certificate && !Token.isUnresolved(stackRegion) && stackRegion !== 'us-east-1') {
66
+ throw new Error(`CanopyCmsDistribution: CloudFront requires its ACM certificate in us-east-1, but this ` +
67
+ `stack is in ${stackRegion}, so the certificate this construct would create cannot be ` +
68
+ `used. Either (a) create the certificate in a us-east-1 stack and pass it as the ` +
69
+ `\`certificate\` prop, or (b) deploy this stack in us-east-1. See ` +
70
+ `docs/deploying-to-aws.md.`);
71
+ }
31
72
  const certificate = props.certificate ??
32
73
  new acm.Certificate(this, 'Cert', {
33
74
  domainName: props.domainName,
@@ -41,7 +82,22 @@ export class CanopyCmsDistribution extends Construct {
41
82
  // Function URL only accepts traffic from this distribution — direct hits to
42
83
  // the Function URL are rejected (DEP-H2). withOriginAccessControl creates
43
84
  // the OAC and grants CloudFront lambda:InvokeFunctionUrl automatically.
44
- const origin = origins.FunctionUrlOrigin.withOriginAccessControl(props.functionUrl);
85
+ //
86
+ // readTimeout is passed EXPLICITLY. aws-cdk-lib emits it as
87
+ // `originReadTimeout: this.props.readTimeout?.toSeconds()` — omitted
88
+ // entirely when unset, so CloudFront's 30s service default applies and
89
+ // silently halves the CMS Lambda's 60s budget. See the prop's doc comment.
90
+ const readTimeout = props.originReadTimeout ?? DEFAULT_CMS_LAMBDA_TIMEOUT;
91
+ if (readTimeout.toSeconds() > MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT.toSeconds()) {
92
+ throw new Error(`CanopyCmsDistribution: originReadTimeout is ${readTimeout.toSeconds()}s, but CloudFront ` +
93
+ `allows at most ${MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT.toSeconds()}s without a service-quota ` +
94
+ `increase. Either lower the CMS Lambda's timeout to match, or request a quota increase for ` +
95
+ `"Origin response timeout" and pass the higher value explicitly. Deploying with a shorter ` +
96
+ `origin timeout than the Lambda's would 504 at the edge on requests that actually succeed.`);
97
+ }
98
+ const origin = origins.FunctionUrlOrigin.withOriginAccessControl(props.functionUrl, {
99
+ readTimeout,
100
+ });
45
101
  // Cache policy for API/editor routes: AWS's managed CACHING_DISABLED
46
102
  // policy. Deploy-proven (deploy-test epic, 2026-07-23): CloudFront
47
103
  // rejects ANY non-none cache-key setting on a caching-disabled policy -
@@ -98,13 +154,13 @@ export class CanopyCmsDistribution extends Construct {
98
154
  },
99
155
  ],
100
156
  },
101
- additionalBehaviors: {
157
+ additionalBehaviors: mergeBehaviors({
102
158
  '/_next/static/*': {
103
159
  origin,
104
160
  cachePolicy: staticCachePolicy,
105
161
  viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
106
162
  },
107
- },
163
+ }, props.additionalBehaviors),
108
164
  });
109
165
  // ========================================================================
110
166
  // DNS Records
@@ -121,4 +177,3 @@ export class CanopyCmsDistribution extends Construct {
121
177
  });
122
178
  }
123
179
  }
124
- //# sourceMappingURL=cms-distribution.js.map
@@ -1,6 +1,27 @@
1
1
  import { Construct } from 'constructs';
2
2
  import { Duration, RemovalPolicy, aws_ec2 as ec2, aws_efs as efs, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_logs as logs } from 'aws-cdk-lib';
3
3
  import type { IBucket } from 'aws-cdk-lib/aws-s3';
4
+ /**
5
+ * Default CMS Lambda timeout.
6
+ *
7
+ * Shared with `CanopyCmsDistribution`, which uses it as its default origin
8
+ * read timeout: CloudFront's own default is **30 seconds**, so leaving the
9
+ * origin unset silently caps this Lambda at half its budget. Requests in the
10
+ * gap are answered 504 at the edge while the invocation runs to completion
11
+ * behind them — first-touch branch provisioning does a full `git clone` onto
12
+ * EFS inside the request, so this is a real path, not a hypothetical one.
13
+ *
14
+ * One constant rather than two matching literals, so the pair cannot drift;
15
+ * `cms-deploy.test.ts` asserts the emitted template keeps them equal.
16
+ *
17
+ * NOTE: CloudFront accepts an origin read timeout up to 60s without a quota
18
+ * increase. A longer Lambda timeout needs an AWS quota increase before the
19
+ * distribution can match it — `CanopyCmsDistribution` fails at synth rather
20
+ * than deploying a configuration that would 504.
21
+ */
22
+ export declare const DEFAULT_CMS_LAMBDA_TIMEOUT: Duration;
23
+ /** CloudFront's maximum origin read timeout without a service-quota increase. */
24
+ export declare const MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT: Duration;
4
25
  export interface CanopyCmsServiceProps {
5
26
  /** Docker image for the CMS Lambda function */
6
27
  cmsDockerImage: lambda.DockerImageCode;
@@ -22,7 +43,14 @@ export interface CanopyCmsServiceProps {
22
43
  architecture?: lambda.Architecture;
23
44
  /** EC2 spot max price (default: on-demand rate for t4g.nano) */
24
45
  spotMaxPrice?: string;
25
- /** Secrets Manager ARNs the worker needs to read (GitHub token, Clerk key) */
46
+ /**
47
+ * ADDITIONAL Secrets Manager ARNs the worker may read.
48
+ *
49
+ * You do NOT need to repeat `githubTokenSecretArn` or
50
+ * `clerkSecretKeySecretArn` here — those are unioned into the worker's IAM
51
+ * policy automatically. Use this only for secrets the construct does not
52
+ * know about.
53
+ */
26
54
  secretsArns?: string[];
27
55
  /**
28
56
  * Environment variables for the Lambda function.
@@ -136,6 +164,17 @@ export interface CanopyCmsServiceProps {
136
164
  export declare class CanopyCmsService extends Construct {
137
165
  /** Lambda Function URL — use as CloudFront origin */
138
166
  readonly functionUrl: lambda.FunctionUrl;
167
+ /**
168
+ * The CMS Lambda's resolved timeout — `props.timeout` or
169
+ * {@link DEFAULT_CMS_LAMBDA_TIMEOUT}.
170
+ *
171
+ * Exposed so a distribution in front of this service can set its origin read
172
+ * timeout to the SAME value. CloudFront's own default is 30s, which silently
173
+ * caps a longer Lambda: every request landing in the gap is answered 504 at
174
+ * the edge while the invocation runs to completion behind it (see
175
+ * `CanopyCmsDistribution`'s `originReadTimeout`).
176
+ */
177
+ readonly timeout: Duration;
139
178
  /** The EFS filesystem */
140
179
  readonly fileSystem: efs.FileSystem;
141
180
  /** The VPC */
@@ -150,4 +189,3 @@ export declare class CanopyCmsService extends Construct {
150
189
  readonly workerLogGroup: logs.LogGroup;
151
190
  constructor(scope: Construct, id: string, props: CanopyCmsServiceProps);
152
191
  }
153
- //# sourceMappingURL=cms-service.d.ts.map
@@ -65,6 +65,27 @@ function assertEnvSafe(name, value) {
65
65
  }
66
66
  return value;
67
67
  }
68
+ /**
69
+ * Default CMS Lambda timeout.
70
+ *
71
+ * Shared with `CanopyCmsDistribution`, which uses it as its default origin
72
+ * read timeout: CloudFront's own default is **30 seconds**, so leaving the
73
+ * origin unset silently caps this Lambda at half its budget. Requests in the
74
+ * gap are answered 504 at the edge while the invocation runs to completion
75
+ * behind them — first-touch branch provisioning does a full `git clone` onto
76
+ * EFS inside the request, so this is a real path, not a hypothetical one.
77
+ *
78
+ * One constant rather than two matching literals, so the pair cannot drift;
79
+ * `cms-deploy.test.ts` asserts the emitted template keeps them equal.
80
+ *
81
+ * NOTE: CloudFront accepts an origin read timeout up to 60s without a quota
82
+ * increase. A longer Lambda timeout needs an AWS quota increase before the
83
+ * distribution can match it — `CanopyCmsDistribution` fails at synth rather
84
+ * than deploying a configuration that would 504.
85
+ */
86
+ export const DEFAULT_CMS_LAMBDA_TIMEOUT = Duration.seconds(60);
87
+ /** CloudFront's maximum origin read timeout without a service-quota increase. */
88
+ export const MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT = Duration.seconds(60);
68
89
  /**
69
90
  * Core CDK construct for CanopyCMS deployment.
70
91
  *
@@ -258,10 +279,11 @@ export class CanopyCmsService extends Construct {
258
279
  retention: props.cmsLogRetention ?? logs.RetentionDays.THREE_MONTHS,
259
280
  removalPolicy: RemovalPolicy.DESTROY,
260
281
  });
282
+ this.timeout = props.timeout ?? DEFAULT_CMS_LAMBDA_TIMEOUT;
261
283
  this.lambdaFunction = new lambda.DockerImageFunction(this, 'CmsFunction', {
262
284
  code: props.cmsDockerImage,
263
285
  memorySize: props.memorySize ?? 2048,
264
- timeout: props.timeout ?? Duration.seconds(60),
286
+ timeout: this.timeout,
265
287
  reservedConcurrentExecutions: props.reservedConcurrency ?? 10,
266
288
  architecture: props.architecture,
267
289
  vpc: this.vpc,
@@ -368,11 +390,33 @@ export class CanopyCmsService extends Construct {
368
390
  assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
369
391
  description: 'CanopyCMS EC2 Worker role',
370
392
  });
371
- // Worker needs to read secrets
372
- if (props.secretsArns && props.secretsArns.length > 0) {
393
+ // Worker needs to read secrets.
394
+ //
395
+ // The grant is the UNION of `secretsArns` and the individual ARN props,
396
+ // because the construct previously carried two disconnected
397
+ // representations of "the secrets the worker reads": `secretsArns` fed
398
+ // this policy, while `githubTokenSecretArn`/`clerkSecretKeySecretArn` fed
399
+ // only the worker's `.env`. An adopter hand-writing their stack (both are
400
+ // individually documented, and `secretsArns`'s doc comment did not say it
401
+ // was the sole source of IAM) could set the individual props and omit
402
+ // `secretsArns` -- producing a worker that knows WHICH secret to read and
403
+ // has no permission to read it. `cdk deploy` succeeded; the worker booted,
404
+ // got AccessDenied from GetSecretValue, exited, and systemd restart-looped
405
+ // it every 5s forever. Nothing flagged it at synth.
406
+ //
407
+ // Deduped so the emitted policy does not list the same ARN twice when an
408
+ // adopter correctly passes both.
409
+ const secretsArns = [
410
+ ...new Set([
411
+ ...(props.secretsArns ?? []),
412
+ props.githubTokenSecretArn,
413
+ props.clerkSecretKeySecretArn,
414
+ ].filter((arn) => typeof arn === 'string' && arn.length > 0)),
415
+ ];
416
+ if (secretsArns.length > 0) {
373
417
  workerRole.addToPolicy(new iam.PolicyStatement({
374
418
  actions: ['secretsmanager:GetSecretValue'],
375
- resources: props.secretsArns,
419
+ resources: secretsArns,
376
420
  }));
377
421
  }
378
422
  // Worker needs EFS access (handled via security group, but mount needs ec2:DescribeAvailabilityZones)
@@ -428,7 +472,7 @@ export class CanopyCmsService extends Construct {
428
472
  .join('\n');
429
473
  // UserData script
430
474
  const userData = ec2.UserData.forLinux();
431
- userData.addCommands('#!/bin/bash', 'set -euo pipefail', '', '# Install dependencies (unzip is not guaranteed in the AL2023 AMI)', 'yum install -y git unzip', 'curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -', 'yum install -y nodejs', '', '# Mount EFS', 'yum 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', `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). Node 20 treats .js as', '# CommonJS without this marker and crash-loops on the import statement', '# (Node >=22.7 auto-detects and masks the bug locally).', `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', 'ExecStart=/usr/bin/node 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 yum/agent failure', '# here must not prevent the worker from running (shipping is best-effort).', 'yum 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}",',
475
+ 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}",',
432
476
  // The worker prefixes every line with an ISO-8601 timestamp
433
477
  // (packages/canopycms/src/worker/log.ts). Parsing it here is what makes
434
478
  // CloudWatch show the time the WORKER emitted a line rather than the
@@ -538,4 +582,3 @@ export class CanopyCmsService extends Construct {
538
582
  this.workerAsg.node.addDependency(this.fileSystem.mountTargetsAvailable);
539
583
  }
540
584
  }
541
- //# sourceMappingURL=cms-service.js.map
package/dist/index.d.ts CHANGED
@@ -1,9 +1,8 @@
1
- export { CmsWorker } from './worker';
2
- export type { CmsWorkerConfig } from './worker';
3
- export { CanopyCmsService } from './constructs/cms-service';
4
- export type { CanopyCmsServiceProps } from './constructs/cms-service';
5
- export { CanopyCmsDistribution } from './constructs/cms-distribution';
6
- export type { CanopyCmsDistributionProps } from './constructs/cms-distribution';
7
- export { AssetSupport } from './constructs/asset-support';
8
- export type { AssetSupportProps, AssetCloudFrontBehaviors } from './constructs/asset-support';
9
- //# sourceMappingURL=index.d.ts.map
1
+ export { CmsWorker } from './worker.js';
2
+ export type { CmsWorkerConfig } from './worker.js';
3
+ export { CanopyCmsService } from './constructs/cms-service.js';
4
+ export type { CanopyCmsServiceProps } from './constructs/cms-service.js';
5
+ export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
6
+ export type { CanopyCmsDistributionProps } from './constructs/cms-distribution.js';
7
+ export { AssetSupport } from './constructs/asset-support.js';
8
+ export type { AssetSupportProps, AssetCloudFrontBehaviors } from './constructs/asset-support.js';
package/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Worker daemon
2
- export { CmsWorker } from './worker';
2
+ export { CmsWorker } from './worker.js';
3
3
  // CDK Constructs
4
- export { CanopyCmsService } from './constructs/cms-service';
5
- export { CanopyCmsDistribution } from './constructs/cms-distribution';
6
- export { AssetSupport } from './constructs/asset-support';
7
- //# sourceMappingURL=index.js.map
4
+ export { CanopyCmsService } from './constructs/cms-service.js';
5
+ export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
6
+ export { AssetSupport } from './constructs/asset-support.js';
package/dist/worker.d.ts CHANGED
@@ -5,4 +5,3 @@
5
5
  */
6
6
  export { CmsWorker } from 'canopycms/worker/cms-worker';
7
7
  export type { CmsWorkerConfig, AuthCacheRefresher } from 'canopycms/worker/cms-worker';
8
- //# sourceMappingURL=worker.d.ts.map
package/dist/worker.js CHANGED
@@ -4,4 +4,3 @@
4
4
  * This file re-exports it for convenience from the CDK package.
5
5
  */
6
6
  export { CmsWorker } from 'canopycms/worker/cms-worker';
7
- //# sourceMappingURL=worker.js.map