@fjall/components-infrastructure 3.11.0 → 4.1.0

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.
@@ -38,6 +38,18 @@ export interface IDevSubstrateProps {
38
38
  * account where the repo was provisioned out-of-band.
39
39
  */
40
40
  readonly adoptSlotEcr?: boolean;
41
+ /**
42
+ * When `true`, adopt an already-existing slot build-cache ECR repository
43
+ * (`fjall-dev-<appKebab>-slots-cache`) by reference instead of creating one.
44
+ * Independent of {@link adoptSlotEcr} on purpose: an app whose slots repo
45
+ * predates the build-cache feature runs the substrate in slot-adopt mode, yet
46
+ * its cache repo does NOT exist and MUST be created on the next redeploy —
47
+ * coupling the two would import a non-existent cache repo and the slot builds
48
+ * would stay permanently uncached. The producer probes each repo's existence
49
+ * separately (webapp `shouldAdoptSlotEcr` / `shouldAdoptCacheEcr`). Defaults to
50
+ * `false`: create the cache repo with the untagged-image lifecycle rule.
51
+ */
52
+ readonly adoptCacheEcr?: boolean;
41
53
  /**
42
54
  * Deploy phase for the R2 two-step cert-hang guard. `"zone"` synthesises the
43
55
  * `devDomain` zone + cross-account NS delegation only; `"full"` additionally
@@ -1,13 +1,14 @@
1
1
  import { CfnOutput, Duration, Stack, Tags } from "aws-cdk-lib";
2
2
  import { Port } from "aws-cdk-lib/aws-ec2";
3
3
  import { Cluster } from "aws-cdk-lib/aws-ecs";
4
- import { Repository, TagMutability } from "aws-cdk-lib/aws-ecr";
4
+ import { Repository, TagMutability, TagStatus } from "aws-cdk-lib/aws-ecr";
5
5
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
7
7
  import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
8
8
  import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
9
9
  import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
10
10
  import { Construct } from "constructs";
11
+ import { buildCacheRepositoryName, CACHE_REPO_UNTAGGED_RETENTION_DAYS } from "@fjall/util/docker";
11
12
  import { readFileSync } from "node:fs";
12
13
  import path from "node:path";
13
14
  import { fileURLToPath } from "node:url";
@@ -249,6 +250,31 @@ export class DevSubstrate extends Construct {
249
250
  // first same-branch recreate (BUG-22).
250
251
  tagMutability: TagMutability.MUTABLE
251
252
  });
253
+ // Buildx registry cache for slot builds, named by the same helper as the app
254
+ // path's `<repo>-cache` so `fjall dev up` derives this URI from the slot repo's
255
+ // without a second wire field. Adopt is gated on its OWN `adoptCacheEcr` flag,
256
+ // NOT `adoptSlotEcr`: an app whose slots repo predates the cache feature
257
+ // redeploys in slot-adopt mode with no cache repo yet — coupling the two would
258
+ // import a non-existent repo and slot builds would stay permanently uncached.
259
+ const slotCacheRepositoryName = buildCacheRepositoryName(slotEcrRepositoryName);
260
+ if (props.adoptCacheEcr === true) {
261
+ Repository.fromRepositoryName(this, `${slotEcrId}Cache`, slotCacheRepositoryName);
262
+ }
263
+ else {
264
+ const slotCacheRepository = new Ecr(this, `${slotEcrId}Cache`, {
265
+ repositoryName: slotCacheRepositoryName,
266
+ // buildx overwrites the per-repo cache manifest tag on every export.
267
+ tagMutability: TagMutability.MUTABLE
268
+ });
269
+ // Each `mode=max` export orphans the previous manifest's blobs. The
270
+ // dev-deploy role holds no BatchDeleteImage or PutLifecyclePolicy, so this
271
+ // rule is the ONLY reclaim path — without it the cache grows without bound
272
+ // in a customer's dev account. An adopted repo keeps its existing policy.
273
+ slotCacheRepository.addLifecycleRule({
274
+ tagStatus: TagStatus.UNTAGGED,
275
+ maxImageAge: Duration.days(CACHE_REPO_UNTAGGED_RETENTION_DAYS)
276
+ });
277
+ }
252
278
  return {
253
279
  vpc,
254
280
  slotSecurityGroup,
@@ -4,9 +4,17 @@
4
4
  *
5
5
  * Re-invocation safety: ECS hook responses do NOT carry state between
6
6
  * IN_PROGRESS invocations. On every invocation we reconstruct the
7
- * deterministic startedBy tag from the event's targetServiceRevisionArn and
8
- * use ListTasks to find an existing running migration. RunTask fires only on
9
- * the first invocation.
7
+ * deterministic startedBy tag from the event's
8
+ * executionDetails.targetServiceRevisionArn and use ListTasks to find an
9
+ * existing running migration. RunTask fires only on the first invocation.
10
+ *
11
+ * Event contract: the target revision ARN is nested under `executionDetails`
12
+ * and the `executionId` is top-level — see AWS "Lambda hooks for Amazon ECS
13
+ * service deployments" (developerguide/lambda-lifecycle-hooks.html). Reading
14
+ * the revision ARN from the wrong level collapses the startedBy tag to a
15
+ * shared constant across every deploy, so ListTasks can adopt another
16
+ * deployment's task (cross-deploy race). A missing revision ARN therefore
17
+ * FAILS the hook loudly rather than proceeding with a shared tag.
10
18
  *
11
19
  * CommonJS rather than ESM because Code.fromInline lands the source as
12
20
  * `index.js`, which Lambda treats as CommonJS by default.
@@ -37,6 +45,26 @@ function startedByTag(targetServiceRevisionArn) {
37
45
  return `fjall-migrate-${suffix}`.slice(0, 36);
38
46
  }
39
47
 
48
+ // The revision ARN is nested under executionDetails in the ECS lifecycle-hook
49
+ // event; the top-level fallback tolerates a future/legacy shape without
50
+ // reopening the shared-tag race (nested is read first).
51
+ function extractTargetServiceRevisionArn(event) {
52
+ const nested =
53
+ event &&
54
+ event.executionDetails &&
55
+ event.executionDetails.targetServiceRevisionArn;
56
+ const topLevel = event && event.targetServiceRevisionArn;
57
+ return nested || topLevel || undefined;
58
+ }
59
+
60
+ // executionId is top-level in the AWS contract; nested fallback is defensive.
61
+ function extractExecutionId(event) {
62
+ const topLevel = event && event.executionId;
63
+ const nested =
64
+ event && event.executionDetails && event.executionDetails.executionId;
65
+ return topLevel || nested || undefined;
66
+ }
67
+
40
68
  function buildContainerOverrides(
41
69
  name,
42
70
  command,
@@ -97,12 +125,20 @@ async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
97
125
  }
98
126
 
99
127
  async function runHandler(event, deps) {
128
+ const targetServiceRevisionArn = extractTargetServiceRevisionArn(event);
129
+ if (!targetServiceRevisionArn) {
130
+ return {
131
+ hookStatus: "FAILED",
132
+ reason:
133
+ "Lifecycle event missing executionDetails.targetServiceRevisionArn"
134
+ };
135
+ }
100
136
  const client = (deps && deps.client) || getDefaultClient();
101
137
  const sleep = deps && deps.sleep;
102
138
  const config = JSON.parse(
103
139
  (deps && deps.migrateConfig) || process.env.MIGRATE_CONFIG
104
140
  );
105
- const startedBy = startedByTag(event && event.targetServiceRevisionArn);
141
+ const startedBy = startedByTag(targetServiceRevisionArn);
106
142
 
107
143
  const existingTaskArn = await findExistingTaskArn(
108
144
  client,
@@ -158,12 +194,8 @@ exports.handler = async (event) => {
158
194
  JSON.stringify({
159
195
  msg: "lifecycle-hook-invoked",
160
196
  targetServiceRevisionArn:
161
- (event && event.targetServiceRevisionArn) || "(missing)",
162
- executionId:
163
- (event &&
164
- event.executionDetails &&
165
- event.executionDetails.executionId) ||
166
- "(missing)"
197
+ extractTargetServiceRevisionArn(event) || "(missing)",
198
+ executionId: extractExecutionId(event) || "(missing)"
167
199
  })
168
200
  );
169
201
  try {
@@ -182,6 +214,8 @@ exports.handler = async (event) => {
182
214
 
183
215
  exports._internals = {
184
216
  startedByTag,
217
+ extractTargetServiceRevisionArn,
218
+ extractExecutionId,
185
219
  buildContainerOverrides,
186
220
  findExistingTaskArn,
187
221
  pollTaskUntilStopped,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "3.11.0",
3
+ "version": "4.1.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@aws-sdk/client-organizations": "^3.1038.0",
70
- "@fjall/generator": "^3.11.0",
71
- "@fjall/util": "^3.11.0",
70
+ "@fjall/generator": "^4.1.0",
71
+ "@fjall/util": "^4.1.0",
72
72
  "constructs": "^10.6.0"
73
73
  },
74
74
  "overrides": {
@@ -82,5 +82,5 @@
82
82
  "engines": {
83
83
  "node": ">=18.0.0"
84
84
  },
85
- "gitHead": "a15125ea026d52428ff5433d098699899695ca3f"
85
+ "gitHead": "09dee795753839b82aedbb7d0cef1c5ef283df55"
86
86
  }