@deployfoundation/foundation-deploy 0.2.6 → 0.2.8

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/README.md CHANGED
@@ -47,7 +47,7 @@ bun run packages/infra/bin/foundation-deploy.ts <command> --instance <path>
47
47
 
48
48
  | Command | What it does |
49
49
  | --- | --- |
50
- | `deploy` | Build and push the agent image, `cdk deploy --all`, refresh the runtime secret, sync config. |
50
+ | `deploy` | Build and push the agent image, `cdk deploy --all`, refresh the runtime secret (and `cdk deploy` once more when that changed it, so no container boots on the old one), sync config. |
51
51
  | `post-deploy` | Smoke DEFAULT, probe the persistent mount, promote `live`, smoke `live`. |
52
52
  | `config:sync` | Upload the runtime config and Foundation's product skills to S3. |
53
53
  | `setup` | First-deploy orchestration, idempotent. |
package/dist/bin/app.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  FoundationNetwork,
8
8
  FoundationPipeline,
9
9
  NewsletterStack
10
- } from "../chunk-my0awr1g.js";
10
+ } from "../chunk-revzxtba.js";
11
11
  import {
12
12
  adminsFor,
13
13
  instanceNames,
@@ -87,6 +87,7 @@ async function extractSkillsTarball(tarball, dir, opts = {}) {
87
87
  }
88
88
 
89
89
  // src/deploy/deploy.ts
90
+ import { createHash } from "node:crypto";
90
91
  import { existsSync as existsSync2 } from "node:fs";
91
92
  import { createRequire } from "node:module";
92
93
  import { join as join3 } from "node:path";
@@ -1030,7 +1031,8 @@ async function cdkDeploy(ctx, opts) {
1030
1031
  ...opts.alarmEmail === "" ? [] : ["-c", `alarmEmail=${opts.alarmEmail}`],
1031
1032
  ...opts.phase1 === true ? ["-c", "deployRuntime=false"] : [],
1032
1033
  ...opts.tag === undefined || opts.release !== undefined ? [] : ["-c", `agentImageTag=${opts.tag}`],
1033
- ...opts.release === undefined ? [] : releaseContext(opts.release)
1034
+ ...opts.release === undefined ? [] : releaseContext(opts.release),
1035
+ ...opts.runtimeSecretDigest === undefined ? [] : ["-c", `runtimeSecretDigest=${opts.runtimeSecretDigest}`]
1034
1036
  ];
1035
1037
  await run([
1036
1038
  ...cdkCommand(),
@@ -1130,6 +1132,28 @@ async function syncRuntimeSecret(ctx) {
1130
1132
  await putSecretJson(ctx, secretRuntime, value);
1131
1133
  return Object.keys(value);
1132
1134
  }
1135
+ function runtimeSecretDigest(value) {
1136
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
1137
+ }
1138
+ async function currentRuntimeSecretDigest(ctx) {
1139
+ if (ctx.dryRun === true)
1140
+ return;
1141
+ try {
1142
+ return runtimeSecretDigest(await readSecretString(ctx, ctx.names.secretRuntime));
1143
+ } catch {
1144
+ return;
1145
+ }
1146
+ }
1147
+ async function refreshRuntimeSecret(before, refresh) {
1148
+ const log = refresh.log ?? ((line) => console.log(line));
1149
+ log(` keys: ${(await refresh.sync()).join(", ")}`);
1150
+ const after = await refresh.digest();
1151
+ if (after === undefined || after === before)
1152
+ return "unchanged";
1153
+ log("▶ the runtime secret changed: cdk deploy once more, so no container boots on the old one");
1154
+ await refresh.redeploy(after);
1155
+ return "rolled";
1156
+ }
1133
1157
  async function promoteLive(ctx) {
1134
1158
  const dry = ctx.dryRun === true;
1135
1159
  const runner = cliRunner(ctx);
@@ -1184,19 +1208,26 @@ async function deploy(ctx, opts) {
1184
1208
  console.log(` ${await buildAndPushImage(ctx, { tag })}`);
1185
1209
  }
1186
1210
  console.log(`▶ cdk deploy --all${phase1 ? " (phase 1: no runtime)" : release !== undefined ? ` (release ${release.version})` : ` (image ${tag})`}`);
1187
- await cdkDeploy(ctx, {
1211
+ const deployed = await currentRuntimeSecretDigest(ctx);
1212
+ const deployStacks = (runtimeSecretDigest2) => cdkDeploy(ctx, {
1188
1213
  admins,
1189
1214
  alarmEmail,
1190
1215
  tag,
1191
1216
  phase1,
1192
- ...release === undefined ? {} : { release }
1217
+ ...release === undefined ? {} : { release },
1218
+ ...runtimeSecretDigest2 === undefined ? {} : { runtimeSecretDigest: runtimeSecretDigest2 }
1193
1219
  });
1220
+ await deployStacks(deployed);
1194
1221
  if (ctx.instance.integrations.knock) {
1195
1222
  console.log(`▶ Knock OAuth public-client registration (${ctx.names.secretKnockOauthClient})`);
1196
1223
  console.log(` ${await syncKnockOAuthClient(ctx)}`);
1197
1224
  }
1198
1225
  console.log(`▶ runtime secret refresh (${ctx.names.secretRuntime})`);
1199
- console.log(` keys: ${(await syncRuntimeSecret(ctx)).join(", ")}`);
1226
+ await refreshRuntimeSecret(phase1 ? undefined : deployed, {
1227
+ sync: () => syncRuntimeSecret(ctx),
1228
+ digest: async () => phase1 ? undefined : currentRuntimeSecretDigest(ctx),
1229
+ redeploy: (digest) => deployStacks(digest)
1230
+ });
1200
1231
  console.log("▶ config sync");
1201
1232
  const bucket = await configSync(ctx, {
1202
1233
  runtimeConfigPath: customization.runtimeConfigPath,
@@ -1819,9 +1850,20 @@ async function setup(ctx, opts = {}) {
1819
1850
  console.log(` ${repositoryUri}:${tag}`);
1820
1851
  await buildAndPushImage(ctx, { tag, repositoryUri });
1821
1852
  step(8, "phase 2: deploy the AgentCore runtime");
1822
- await cdkDeploy(ctx, { admins, alarmEmail, tag });
1853
+ const phase2 = (runtimeSecretDigest2) => cdkDeploy(ctx, {
1854
+ admins,
1855
+ alarmEmail,
1856
+ tag,
1857
+ ...runtimeSecretDigest2 === undefined ? {} : { runtimeSecretDigest: runtimeSecretDigest2 }
1858
+ });
1859
+ const written = await currentRuntimeSecretDigest(ctx);
1860
+ await phase2(written);
1823
1861
  step(9, `refresh ${ctx.names.secretRuntime} with the runtime arn`);
1824
- await writeRuntimeSecret(ctx);
1862
+ await refreshRuntimeSecret(written, {
1863
+ sync: () => syncRuntimeSecret(ctx),
1864
+ digest: () => currentRuntimeSecretDigest(ctx),
1865
+ redeploy: phase2
1866
+ });
1825
1867
  step(10, "create the pinned `live` endpoint");
1826
1868
  await ensureLiveEndpoint(ctx);
1827
1869
  step(11, `seed ${ctx.names.secretCodex}`);
@@ -41,6 +41,7 @@ class FoundationAgent extends cdk.Stack {
41
41
  const display = props.instance.displayName;
42
42
  const deployRuntime = String(this.node.tryGetContext("deployRuntime") ?? "true") !== "false";
43
43
  const imageTag = this.node.tryGetContext("agentImageTag");
44
+ const secretDigest = runtimeSecretDigestContext(this.node.tryGetContext("runtimeSecretDigest"));
44
45
  this.repository = new ecr.Repository(this, "AgentRepository", {
45
46
  repositoryName: names.ecrRepo,
46
47
  imageScanOnPush: true,
@@ -124,7 +125,8 @@ class FoundationAgent extends cdk.Stack {
124
125
  EnvironmentVariables: {
125
126
  RUNTIME_SECRET_ID: props.secrets.runtime.secretName,
126
127
  AWS_REGION: this.region,
127
- FOUNDATION_WORKSPACE_ROOT: "/workspace"
128
+ FOUNDATION_WORKSPACE_ROOT: "/workspace",
129
+ ...secretDigest === undefined ? {} : { FOUNDATION_RUNTIME_SECRET_DIGEST: secretDigest }
128
130
  },
129
131
  Tags: { project: props.instance.naming.secretPrefix }
130
132
  }
@@ -661,6 +663,14 @@ class FoundationAgent extends cdk.Stack {
661
663
  function secretArnPattern(secret) {
662
664
  return secret.secretFullArn ?? `${secret.secretArn}-??????`;
663
665
  }
666
+ function runtimeSecretDigestContext(value) {
667
+ if (value === undefined || value === "")
668
+ return;
669
+ const digest = String(value);
670
+ if (!/^[0-9a-f]{16}$/.test(digest))
671
+ throw new Error(`runtimeSecretDigest must be 16 lowercase hex characters, got "${digest}"`);
672
+ return digest;
673
+ }
664
674
 
665
675
  // src/stacks/api-stack.ts
666
676
  import { readFileSync } from "node:fs";
package/dist/src/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  FoundationPipeline,
10
10
  NewsletterStack,
11
11
  deployStatements
12
- } from "../chunk-my0awr1g.js";
12
+ } from "../chunk-revzxtba.js";
13
13
  import {
14
14
  BUN_VERSION,
15
15
  LAMBDA_ENTRY_POINTS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -11,6 +11,7 @@
11
11
  * `--phase1` deploys everything except the runtime (`-c deployRuntime=false`);
12
12
  * a plain `deploy` afterwards builds, pushes and creates it.
13
13
  */
14
+ import { createHash } from "node:crypto";
14
15
  import { existsSync } from "node:fs";
15
16
  import { createRequire } from "node:module";
16
17
  import { join } from "node:path";
@@ -18,7 +19,15 @@ import {
18
19
  parseKnockOAuthClient,
19
20
  registerKnockPublicClient,
20
21
  } from "@deployfoundation/foundation-connectors";
21
- import { type AwsContext, aws, cdkEnv, putSecretJson, readSecretJson, stackOutput } from "./aws.ts";
22
+ import {
23
+ type AwsContext,
24
+ aws,
25
+ cdkEnv,
26
+ putSecretJson,
27
+ readSecretJson,
28
+ readSecretString,
29
+ stackOutput,
30
+ } from "./aws.ts";
22
31
  import { configSync } from "./config-sync.ts";
23
32
  import { adminsCsvFromFile } from "./config.ts";
24
33
  import {
@@ -206,6 +215,8 @@ export interface CdkDeployOptions {
206
215
  phase1?: boolean;
207
216
  /** A verified release; its manifest is what the app reads its artifacts from. */
208
217
  release?: ResolvedRelease;
218
+ /** {@link runtimeSecretDigest} of the runtime secret the new version should boot with. */
219
+ runtimeSecretDigest?: string;
209
220
  }
210
221
 
211
222
  export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promise<void> {
@@ -225,6 +236,9 @@ export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promis
225
236
  ? []
226
237
  : ["-c", `agentImageTag=${opts.tag}`]),
227
238
  ...(opts.release === undefined ? [] : releaseContext(opts.release)),
239
+ ...(opts.runtimeSecretDigest === undefined
240
+ ? []
241
+ : ["-c", `runtimeSecretDigest=${opts.runtimeSecretDigest}`]),
228
242
  ];
229
243
  await run(
230
244
  [
@@ -410,6 +424,68 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
410
424
  return Object.keys(value);
411
425
  }
412
426
 
427
+ /**
428
+ * A short, one-way digest of the runtime secret as stored. It ends up in the
429
+ * runtime's environment (see the Agent stack), so it is truncated SHA-256 of
430
+ * a document full of high-entropy tokens, never anything reversible.
431
+ */
432
+ export function runtimeSecretDigest(value: string): string {
433
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
434
+ }
435
+
436
+ /** The digest of the runtime secret now, or undefined when there is none yet (or in a dry run). */
437
+ export async function currentRuntimeSecretDigest(ctx: AwsContext): Promise<string | undefined> {
438
+ if (ctx.dryRun === true) return undefined;
439
+ try {
440
+ return runtimeSecretDigest(await readSecretString(ctx, ctx.names.secretRuntime));
441
+ } catch {
442
+ return undefined;
443
+ }
444
+ }
445
+
446
+ export interface RuntimeSecretRefresh {
447
+ /** Rewrite the secret from the stack outputs; returns the key names. */
448
+ sync: () => Promise<string[]>;
449
+ /** The secret's digest as it stands now. */
450
+ digest: () => Promise<string | undefined>;
451
+ /** `cdk deploy` again with this digest, which cuts a new runtime version. */
452
+ redeploy: (digest: string) => Promise<void>;
453
+ log?: (line: string) => void;
454
+ }
455
+
456
+ /**
457
+ * Refresh the runtime secret, and when that changed it, roll the runtime.
458
+ *
459
+ * A container reads the secret once, at boot, and AgentCore boots a pool of
460
+ * them ahead of demand as soon as a version exists. The secret is composed from
461
+ * stack outputs (the runtime's own ARN among them), so it can only be written
462
+ * AFTER `cdk deploy` created the version, by which time that pool has already
463
+ * booted on the old value: a first deploy's placeholder, or an upgrade's stale
464
+ * outputs. Those containers answer with the fallback config until they
465
+ * recycle, and every session they serve in the meantime is broken. Found by
466
+ * migrating Sky, where a dozen of them failed smoke after smoke.
467
+ *
468
+ * Writing first is impossible, and `update-agent-runtime` from the CLI would
469
+ * resend an incomplete definition (the CLI does not carry the S3 Files mount),
470
+ * so the new version comes from CloudFormation: `cdk deploy` once more with the
471
+ * new digest in the runtime's environment. An upgrade whose outputs did not
472
+ * change produces the same digest and costs nothing.
473
+ *
474
+ * `before` is the digest the version just deployed was given.
475
+ */
476
+ export async function refreshRuntimeSecret(
477
+ before: string | undefined,
478
+ refresh: RuntimeSecretRefresh,
479
+ ): Promise<"unchanged" | "rolled"> {
480
+ const log = refresh.log ?? ((line: string) => console.log(line));
481
+ log(` keys: ${(await refresh.sync()).join(", ")}`);
482
+ const after = await refresh.digest();
483
+ if (after === undefined || after === before) return "unchanged";
484
+ log("▶ the runtime secret changed: cdk deploy once more, so no container boots on the old one");
485
+ await refresh.redeploy(after);
486
+ return "rolled";
487
+ }
488
+
413
489
  /**
414
490
  * What the workflow does after a deploy, for a deploy run from a laptop: smoke
415
491
  * the version just created on DEFAULT, move the pinned `live` endpoint onto
@@ -543,20 +619,29 @@ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void
543
619
  : ` (image ${tag})`
544
620
  }`,
545
621
  );
546
- await cdkDeploy(ctx, {
547
- admins,
548
- alarmEmail,
549
- tag,
550
- phase1,
551
- ...(release === undefined ? {} : { release }),
552
- });
622
+ const deployed = await currentRuntimeSecretDigest(ctx);
623
+ const deployStacks = (runtimeSecretDigest: string | undefined) =>
624
+ cdkDeploy(ctx, {
625
+ admins,
626
+ alarmEmail,
627
+ tag,
628
+ phase1,
629
+ ...(release === undefined ? {} : { release }),
630
+ ...(runtimeSecretDigest === undefined ? {} : { runtimeSecretDigest }),
631
+ });
632
+ await deployStacks(deployed);
553
633
  if (ctx.instance.integrations.knock) {
554
634
  console.log(`▶ Knock OAuth public-client registration (${ctx.names.secretKnockOauthClient})`);
555
635
  console.log(` ${await syncKnockOAuthClient(ctx)}`);
556
636
  }
557
637
 
558
638
  console.log(`▶ runtime secret refresh (${ctx.names.secretRuntime})`);
559
- console.log(` keys: ${(await syncRuntimeSecret(ctx)).join(", ")}`);
639
+ await refreshRuntimeSecret(phase1 ? undefined : deployed, {
640
+ sync: () => syncRuntimeSecret(ctx),
641
+ // Phase 1 has no runtime to roll.
642
+ digest: async () => (phase1 ? undefined : currentRuntimeSecretDigest(ctx)),
643
+ redeploy: (digest) => deployStacks(digest),
644
+ });
560
645
 
561
646
  console.log("▶ config sync");
562
647
  const bucket = await configSync(ctx, {
@@ -31,6 +31,8 @@ import {
31
31
  buildAndPushImage,
32
32
  cdkCommand,
33
33
  cdkDeploy,
34
+ currentRuntimeSecretDigest,
35
+ refreshRuntimeSecret,
34
36
  syncKnockOAuthClient,
35
37
  syncRuntimeSecret,
36
38
  } from "./deploy.ts";
@@ -375,12 +377,25 @@ export async function setup(
375
377
  await buildAndPushImage(ctx, { tag, repositoryUri });
376
378
 
377
379
  step(8, "phase 2: deploy the AgentCore runtime");
378
- await cdkDeploy(ctx, { admins, alarmEmail, tag });
380
+ const phase2 = (runtimeSecretDigest: string | undefined) =>
381
+ cdkDeploy(ctx, {
382
+ admins,
383
+ alarmEmail,
384
+ tag,
385
+ ...(runtimeSecretDigest === undefined ? {} : { runtimeSecretDigest }),
386
+ });
387
+ const written = await currentRuntimeSecretDigest(ctx);
388
+ await phase2(written);
379
389
 
380
390
  // Only FOUNDATION_AGENT_RUNTIME_ARN needs phase 2, but it is what a routine's
381
391
  // schedule targets, so the secret is composed once more now that it exists.
392
+ // That changes it, and phase 2's containers booted without it: roll them.
382
393
  step(9, `refresh ${ctx.names.secretRuntime} with the runtime arn`);
383
- await writeRuntimeSecret(ctx);
394
+ await refreshRuntimeSecret(written, {
395
+ sync: () => syncRuntimeSecret(ctx),
396
+ digest: () => currentRuntimeSecretDigest(ctx),
397
+ redeploy: phase2,
398
+ });
384
399
 
385
400
  step(10, "create the pinned `live` endpoint");
386
401
  await ensureLiveEndpoint(ctx);
@@ -108,6 +108,7 @@ export class FoundationAgent extends cdk.Stack {
108
108
 
109
109
  const deployRuntime = String(this.node.tryGetContext("deployRuntime") ?? "true") !== "false";
110
110
  const imageTag: string | undefined = this.node.tryGetContext("agentImageTag");
111
+ const secretDigest = runtimeSecretDigestContext(this.node.tryGetContext("runtimeSecretDigest"));
111
112
 
112
113
  this.repository = new ecr.Repository(this, "AgentRepository", {
113
114
  repositoryName: names.ecrRepo,
@@ -237,6 +238,12 @@ export class FoundationAgent extends cdk.Stack {
237
238
  RUNTIME_SECRET_ID: props.secrets.runtime.secretName,
238
239
  AWS_REGION: this.region,
239
240
  FOUNDATION_WORKSPACE_ROOT: "/workspace",
241
+ // Which runtime secret the containers of this version boot with.
242
+ // Nothing reads it: it is here so that a deploy whose secret refresh
243
+ // changed the secret can cut a new version through CloudFormation
244
+ // (the only path that carries every property, the mount included),
245
+ // and no pre-warmed container keeps serving the old value.
246
+ ...(secretDigest === undefined ? {} : { FOUNDATION_RUNTIME_SECRET_DIGEST: secretDigest }),
240
247
  },
241
248
  // The App-level tag aspect cannot reach a raw CfnResource.
242
249
  Tags: { project: props.instance.naming.secretPrefix },
@@ -1004,3 +1011,16 @@ export class FoundationAgent extends cdk.Stack {
1004
1011
  function secretArnPattern(secret: secretsmanager.ISecret): string {
1005
1012
  return secret.secretFullArn ?? `${secret.secretArn}-??????`;
1006
1013
  }
1014
+
1015
+ /**
1016
+ * `-c runtimeSecretDigest=<16 hex>`, from `runtimeSecretDigest()` in the
1017
+ * deploy tool. Absent means "not known", which a first `cdk deploy` of a
1018
+ * brand-new instance is: its secret does not exist yet.
1019
+ */
1020
+ function runtimeSecretDigestContext(value: unknown): string | undefined {
1021
+ if (value === undefined || value === "") return undefined;
1022
+ const digest = String(value);
1023
+ if (!/^[0-9a-f]{16}$/.test(digest))
1024
+ throw new Error(`runtimeSecretDigest must be 16 lowercase hex characters, got "${digest}"`);
1025
+ return digest;
1026
+ }