@deployfoundation/foundation-deploy 0.2.34 → 0.2.36

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
@@ -49,7 +49,7 @@ bun run packages/infra/bin/foundation-deploy.ts <command> --instance <path>
49
49
  | --- | --- |
50
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
- | `config:sync` | Upload the runtime config and Foundation's product skills to S3. |
52
+ | `config:sync` | Upload the runtime config (for the deployed Foundation version, which must be this tool's) and Foundation's product skills to S3. |
53
53
  | `setup` | First-deploy orchestration, idempotent. |
54
54
  | `github-app-create` | Create the instance's GitHub App from the shipped manifest template. |
55
55
  | `slack-manifest` | Print the instance's Slack app manifest as JSON (the Slack CLI's `get-manifest` hook contract). |
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-nn1v23ar.js";
11
+ } from "../chunk-zbw2awg7.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-x093sh8z.js";
17
+ } from "../chunk-hps5vshg.js";
18
18
 
19
19
  // bin/app.ts
20
20
  import { execFileSync } from "node:child_process";
@@ -12,6 +12,7 @@ import {
12
12
  callerAccountId,
13
13
  cdkEnv,
14
14
  createSecretString,
15
+ foundationVersion,
15
16
  hasFoundationWorkspace,
16
17
  instanceBanner,
17
18
  loadInstanceContext,
@@ -29,7 +30,7 @@ import {
29
30
  stackExists,
30
31
  stackOutput,
31
32
  toolVersion
32
- } from "../chunk-k5wwqj2j.js";
33
+ } from "../chunk-gmdzzrc4.js";
33
34
  import {
34
35
  BASE_SLACK_EVENTS,
35
36
  CAPABILITY_IDS,
@@ -44,8 +45,9 @@ import {
44
45
  requiredSlackCommands,
45
46
  requiredSlackScopes,
46
47
  skillsKey,
47
- slackCommandPrefix
48
- } from "../chunk-x093sh8z.js";
48
+ slackCommandPrefix,
49
+ versionedConfigKey
50
+ } from "../chunk-hps5vshg.js";
49
51
 
50
52
  // src/deploy/config-sync.ts
51
53
  import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
@@ -58,12 +60,52 @@ function productSkillsDir(foundationRoot = FOUNDATION_ROOT) {
58
60
  const entries = readdirSync(dir).filter((name) => name !== "README.md");
59
61
  return entries.length > 0 ? dir : undefined;
60
62
  }
61
- async function configSync(ctx, opts = {}) {
63
+ async function deployedFoundationVersion(ctx) {
64
+ try {
65
+ return await stackOutput(ctx, ctx.names.agent, "FoundationVersion");
66
+ } catch (error) {
67
+ if (error instanceof Error && error.message.includes("has no output"))
68
+ return;
69
+ throw error;
70
+ }
71
+ }
72
+ function syncVersion(ctx, deployed, tool, release) {
73
+ if (deployed === undefined)
74
+ throw new Error(`config:sync refused: ${ctx.names.agent} records no Foundation version, so its runtime predates per-version config. Deploy ${tool} first; the deploy writes this config itself.`);
75
+ for (const [what, version] of [
76
+ ["this foundation-deploy", tool],
77
+ ["--release", release]
78
+ ]) {
79
+ if (version === undefined || version === deployed)
80
+ continue;
81
+ throw new Error(`config:sync refused: ${what} is ${version}, but ${ctx.names.agent} runs Foundation ${deployed}, whose runtime may refuse a config ${version} accepts. Run config:sync with foundation-deploy@${deployed.slice(1)}, or deploy ${version} first.`);
82
+ }
83
+ return deployed;
84
+ }
85
+ async function dataBucket(ctx, bucket) {
86
+ if (bucket !== undefined)
87
+ return bucket;
88
+ return ctx.dryRun === true ? `<${ctx.names.data}.BucketName>` : await stackOutput(ctx, ctx.names.data, "BucketName");
89
+ }
90
+ async function uploadRuntimeConfig(ctx, opts) {
62
91
  if (opts.runtimeConfigPath === undefined && ctx.instance.customization !== undefined)
63
92
  throw new Error("customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`");
64
93
  const source = opts.runtimeConfigPath ?? ctx.paths.configPath;
65
- const bucket = opts.bucket ?? (ctx.dryRun === true ? `<${ctx.names.data}.BucketName>` : await stackOutput(ctx, ctx.names.data, "BucketName"));
66
- await awsMutate(ctx, ["s3", "cp", source, `s3://${bucket}/${ctx.names.configKey}`]);
94
+ const key = versionedConfigKey(ctx.names.configKey, opts.version);
95
+ await awsMutate(ctx, ["s3", "cp", source, `s3://${opts.bucket}/${key}`]);
96
+ return key;
97
+ }
98
+ async function configSync(ctx, opts = {}) {
99
+ if (opts.runtimeConfigPath === undefined && ctx.instance.customization !== undefined)
100
+ throw new Error("customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`");
101
+ const tool = opts.toolVersion ?? `v${toolVersion()}`;
102
+ const version = opts.version ?? (ctx.dryRun === true ? tool : syncVersion(ctx, await (opts.deployedVersion ?? deployedFoundationVersion)(ctx), tool, opts.release?.version));
103
+ const bucket = await dataBucket(ctx, opts.bucket);
104
+ await uploadRuntimeConfig(ctx, {
105
+ bucket,
106
+ version,
107
+ ...opts.runtimeConfigPath === undefined ? {} : { runtimeConfigPath: opts.runtimeConfigPath }
108
+ });
67
109
  const skills = opts.release === undefined ? productSkillsDir(opts.foundationRoot) : await (opts.releaseSkillsDir ?? unpackReleaseSkills)(ctx, opts.release);
68
110
  if (skills !== undefined)
69
111
  await awsMutate(ctx, ["s3", "sync", `${skills}/`, `s3://${bucket}/skills/`, "--delete"]);
@@ -104,10 +146,11 @@ var MAX_GOOGLE_TOKEN_RESPONSE_BYTES = 128 * 1024;
104
146
  var GMAIL_LIST_SUMMARY_MAX_BYTES = 1024 * 1024;
105
147
  var MAX_GMAIL_ATTACHMENT_BYTES = 3.5 * 1024 * 1024;
106
148
  var MAX_GMAIL_ATTACHMENT_RESPONSE_BYTES = 5 * 1024 * 1024;
107
- var GMAIL_SCOPES = [
149
+ var GMAIL_PERSONAL_SCOPES = [
108
150
  "https://www.googleapis.com/auth/gmail.compose",
109
151
  "https://www.googleapis.com/auth/gmail.modify"
110
152
  ].join(" ");
153
+ var GMAIL_SCOPES = `${GMAIL_PERSONAL_SCOPES} https://www.googleapis.com/auth/gmail.settings.basic`;
111
154
  var MAX_PART_TEXT_BYTES = 200 * 1024;
112
155
  var MAX_GMAIL_DECODED_TEXT_BYTES = 512 * 1024;
113
156
  var MAX_GMAIL_JSON_RESPONSE_BYTES = 2 * 1024 * 1024;
@@ -1175,6 +1218,8 @@ async function cdkDeploy(ctx, opts) {
1175
1218
  ...opts.phase1 === true ? ["-c", "deployRuntime=false"] : [],
1176
1219
  ...opts.tag === undefined || opts.release !== undefined ? [] : ["-c", `agentImageTag=${opts.tag}`],
1177
1220
  ...opts.release === undefined ? [] : releaseContext(opts.release),
1221
+ "-c",
1222
+ `foundationVersion=${foundationVersion(opts.release)}`,
1178
1223
  ...opts.runtimeSecretDigest === undefined ? [] : ["-c", `runtimeSecretDigest=${opts.runtimeSecretDigest}`],
1179
1224
  ...await pagesDomainContext(ctx)
1180
1225
  ];
@@ -1357,7 +1402,7 @@ async function checkLicense(ctx, opts, release) {
1357
1402
  const verify = opts.verifyLicense ?? (ctx.dryRun === true ? plannedLicenseCheck : verifyLicense);
1358
1403
  const result = await verify(ctx.instance.license?.key, {
1359
1404
  instanceId: ctx.instance.name,
1360
- version: release?.version ?? `v${toolVersion()}`,
1405
+ version: foundationVersion(release),
1361
1406
  cache: secretsManagerLicenseCache(ctx)
1362
1407
  });
1363
1408
  console.log(`▶ license: ${result.message}`);
@@ -1381,6 +1426,17 @@ async function deploy(ctx, opts) {
1381
1426
  console.log(`▶ build + push agent image (${tag})`);
1382
1427
  console.log(` ${await buildAndPushImage(ctx, { tag })}`);
1383
1428
  }
1429
+ const version = foundationVersion(release);
1430
+ const bucket = phase1 ? undefined : ctx.dryRun === true ? `<${ctx.names.data}.BucketName>` : await optionalOutput(ctx, ctx.names.data, "BucketName");
1431
+ if (bucket !== undefined) {
1432
+ console.log(`▶ config for ${version}, ahead of the runtime version that reads it`);
1433
+ const key = await uploadRuntimeConfig(ctx, {
1434
+ bucket,
1435
+ version,
1436
+ runtimeConfigPath: customization.runtimeConfigPath
1437
+ });
1438
+ console.log(` s3://${bucket}/${key}`);
1439
+ }
1384
1440
  console.log(`▶ cdk deploy --all${phase1 ? " (phase 1: no runtime)" : release !== undefined ? ` (release ${release.version})` : ` (image ${tag})`}`);
1385
1441
  const deployed = await currentRuntimeSecretDigest(ctx);
1386
1442
  const deployStacks = (runtimeSecretDigest2) => cdkDeploy(ctx, {
@@ -1403,12 +1459,13 @@ async function deploy(ctx, opts) {
1403
1459
  digest: async () => phase1 ? undefined : currentRuntimeSecretDigest(ctx),
1404
1460
  redeploy: (digest) => deployStacks(digest)
1405
1461
  });
1406
- console.log("▶ config sync");
1407
- const bucket = await configSync(ctx, {
1462
+ console.log(`▶ config sync (${version})`);
1463
+ const synced = await configSync(ctx, {
1464
+ version,
1408
1465
  runtimeConfigPath: customization.runtimeConfigPath,
1409
1466
  ...release === undefined ? {} : { release }
1410
1467
  });
1411
- console.log(`config + skills synced to s3://${bucket}/`);
1468
+ console.log(`config + skills synced to s3://${synced}/`);
1412
1469
  if (!phase1 && promotesInline()) {
1413
1470
  console.log("▶ smoke test, then promote the `live` endpoint");
1414
1471
  await promoteLive(ctx);
@@ -2033,7 +2090,8 @@ async function setup(ctx, opts = {}) {
2033
2090
  step(6, "sync config + skills to S3");
2034
2091
  const bucket = await configSync(ctx, {
2035
2092
  bucket: await output(ctx, ctx.names.data, "BucketName"),
2036
- runtimeConfigPath
2093
+ runtimeConfigPath,
2094
+ version: foundationVersion()
2037
2095
  });
2038
2096
  console.log(` s3://${bucket}/`);
2039
2097
  step(7, "build + push the agent image");
@@ -2093,7 +2151,8 @@ commands
2093
2151
  deploy build and push the agent image, cdk deploy --all, refresh
2094
2152
  the runtime secret, sync config
2095
2153
  post-deploy smoke DEFAULT, probe the mount, promote \`live\`, smoke \`live\`
2096
- config:sync upload the runtime config and product skills to S3
2154
+ config:sync upload the deployed version's runtime config and the
2155
+ product skills to S3
2097
2156
  setup first-deploy orchestration (idempotent)
2098
2157
  github-app-create create the instance's GitHub App from the shipped manifest
2099
2158
  slack-manifest print the instance's Slack app manifest as JSON
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  DEFAULT_RELEASE_BUCKET
4
- } from "../chunk-k5wwqj2j.js";
5
- import"../chunk-x093sh8z.js";
4
+ } from "../chunk-gmdzzrc4.js";
5
+ import"../chunk-hps5vshg.js";
6
6
 
7
7
  // bin/release-account.ts
8
8
  import * as cdk2 from "aws-cdk-lib";
@@ -6,7 +6,7 @@ import {
6
6
  manifestKey,
7
7
  releaseCacheDir,
8
8
  verifyManifest
9
- } from "./chunk-x093sh8z.js";
9
+ } from "./chunk-hps5vshg.js";
10
10
 
11
11
  // src/deploy/release.ts
12
12
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2 } from "node:fs";
@@ -335,6 +335,9 @@ function toolVersion(infraRoot = INFRA_ROOT) {
335
335
  const { version } = JSON.parse(readFileSync2(join2(infraRoot, "package.json"), "utf8"));
336
336
  return version;
337
337
  }
338
+ function foundationVersion(release) {
339
+ return release?.version ?? `v${toolVersion()}`;
340
+ }
338
341
  function hasFoundationWorkspace(foundationRoot = FOUNDATION_ROOT) {
339
342
  return existsSync2(join2(foundationRoot, "packages", "gateway", "package.json"));
340
343
  }
@@ -436,4 +439,4 @@ function releaseContext(release) {
436
439
  ];
437
440
  }
438
441
 
439
- export { resolveInstanceFilePath, loadInstanceContext, instanceBanner, run, runCapture, awsContext, argv, cdkEnv, aws, awsMutate, stackOutput, stackExists, secretExists, readSecretString, readSecretJson, putSecretString, createSecretString, putSecretJson, callerAccountId, INFRA_ROOT, FOUNDATION_ROOT, AGENT_DOCKERFILE, PACKAGE_ASSETS, DEFAULT_RELEASE_BUCKET, toolVersion, hasFoundationWorkspace, releaseRequest, resolveRelease, releaseContext };
442
+ export { resolveInstanceFilePath, loadInstanceContext, instanceBanner, run, runCapture, awsContext, argv, cdkEnv, aws, awsMutate, stackOutput, stackExists, secretExists, readSecretString, readSecretJson, putSecretString, createSecretString, putSecretJson, callerAccountId, INFRA_ROOT, FOUNDATION_ROOT, AGENT_DOCKERFILE, PACKAGE_ASSETS, DEFAULT_RELEASE_BUCKET, toolVersion, foundationVersion, hasFoundationWorkspace, releaseRequest, resolveRelease, releaseContext };
@@ -504,6 +504,7 @@ var RawEmailIdentity = z.object({
504
504
  channel: z.string().regex(/^[CGD][A-Z0-9]{8,}$/),
505
505
  owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id").optional(),
506
506
  signature: z.string().min(1).max(2000),
507
+ organization: z.boolean().optional(),
507
508
  sendAsAddresses: z.array(z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i)).max(20).default([]).refine((addresses) => new Set(addresses.map((address) => address.toLowerCase())).size === addresses.length, "duplicate send-as address")
508
509
  }).strict().superRefine((identity, ctx) => {
509
510
  if (identity.channel.startsWith("D") && identity.owner === undefined) {
@@ -513,12 +514,26 @@ var RawEmailIdentity = z.object({
513
514
  message: "personal email identities in D channels require an owner"
514
515
  });
515
516
  }
517
+ if (identity.organization === true && identity.owner !== undefined) {
518
+ ctx.addIssue({
519
+ code: z.ZodIssueCode.custom,
520
+ path: ["organization"],
521
+ message: "mailbox organization is shared-only"
522
+ });
523
+ }
516
524
  });
517
525
  var RawEmailCapability = z.object({
518
526
  enabled: z.boolean().default(false),
519
527
  identities: z.array(RawEmailIdentity).default([]),
520
528
  inboxCheck: z.enum(["hourly", "off"]).default("hourly")
521
529
  }).strict().superRefine((value, ctx) => {
530
+ if (value.identities.filter((identity) => identity.organization === true).length > 1) {
531
+ ctx.addIssue({
532
+ code: z.ZodIssueCode.custom,
533
+ path: ["identities"],
534
+ message: "only one shared identity may have mailbox-wide organization access"
535
+ });
536
+ }
522
537
  for (const field of ["address", "channel"]) {
523
538
  const seen = new Set;
524
539
  for (const [index, identity] of value.identities.entries()) {
@@ -727,7 +742,7 @@ var CAPABILITIES = [
727
742
  },
728
743
  {
729
744
  id: "email",
730
- summary: "Read a company mailbox and draft replies; sending stays with a person.",
745
+ summary: "Read, draft and organize company mail; sending stays with a person.",
731
746
  config: RawEmailCapability,
732
747
  ...NONE,
733
748
  secrets: ["google/email", GOOGLE_OAUTH_SECRET],
@@ -736,6 +751,7 @@ var CAPABILITIES = [
736
751
  googleScopes: [
737
752
  "https://www.googleapis.com/auth/gmail.compose",
738
753
  "https://www.googleapis.com/auth/gmail.modify",
754
+ "https://www.googleapis.com/auth/gmail.settings.basic",
739
755
  ...GOOGLE_IDENTITY_SCOPES
740
756
  ]
741
757
  },
@@ -1125,6 +1141,12 @@ function newsletterManifestFor(instance) {
1125
1141
  }))
1126
1142
  };
1127
1143
  }
1144
+ var FOUNDATION_VERSION_RE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
1145
+ function versionedConfigKey(configKey, version) {
1146
+ if (!FOUNDATION_VERSION_RE.test(version))
1147
+ throw new Error(`not a Foundation version: "${version}" (expected one like v0.2.34)`);
1148
+ return `config/${version}/${configKey.slice(configKey.lastIndexOf("/") + 1)}`;
1149
+ }
1128
1150
  function instanceNames(instance) {
1129
1151
  const { prefix, secretPrefix, runtimeName } = instance.naming;
1130
1152
  const stack = (suffix) => `${prefix}${suffix}`;
@@ -1864,4 +1886,4 @@ function ecrRepositoryArn(repositoryUri) {
1864
1886
  return `arn:aws:ecr:${region}:${account}:repository/${name}`;
1865
1887
  }
1866
1888
 
1867
- export { parseSchedule, BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, instanceNames, slackCommandPrefix, slackCommandPrefixes, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
1889
+ export { parseSchedule, BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, FOUNDATION_VERSION_RE, versionedConfigKey, instanceNames, slackCommandPrefix, slackCommandPrefixes, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CUSTOMIZATION_ARTIFACT_NAME,
3
+ FOUNDATION_VERSION_RE,
3
4
  LIVE_ENDPOINT_NAME,
4
5
  WEB_SEARCH_TARGET,
5
6
  agentImage,
@@ -16,11 +17,12 @@ import {
16
17
  parseSchedule,
17
18
  provisionsIntegration,
18
19
  releaseImageRepositoryArn,
20
+ releaseSource,
19
21
  requiresAuthenticatedQueue,
20
22
  scheduledJobBindings,
21
23
  slackCommandPrefixes,
22
24
  webhookJobBindings
23
- } from "./chunk-x093sh8z.js";
25
+ } from "./chunk-hps5vshg.js";
24
26
 
25
27
  // src/stacks/agent-stack.ts
26
28
  import * as cdk from "aws-cdk-lib";
@@ -85,6 +87,7 @@ class FoundationAgent extends cdk.Stack {
85
87
  if (!imageTag && agentImageTagRequired(this)) {
86
88
  throw new Error(`${id}: -c agentImageTag=<tag> is required when deploying the runtime. Pass the tag of an image already pushed to the ${names.ecrRepo} repository, deploy a release with -c release=<version>, or run the bootstrap phase with -c deployRuntime=false.`);
87
89
  }
90
+ const foundationVersion = foundationVersionContext(this, id);
88
91
  const explicitSubnets = String(this.node.tryGetContext("agentSubnetIds") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
89
92
  const runtimeSubnets = explicitSubnets.length > 0 ? explicitSubnets : props.vpc.selectSubnets(props.egressSubnets).subnetIds;
90
93
  const mountTargets = runtimeSubnets.map((subnetId, index) => new cdk.CfnResource(this, `MountTarget${index}`, {
@@ -129,6 +132,7 @@ class FoundationAgent extends cdk.Stack {
129
132
  RUNTIME_SECRET_ID: props.secrets.runtime.secretName,
130
133
  AWS_REGION: this.region,
131
134
  FOUNDATION_WORKSPACE_ROOT: "/workspace",
135
+ FOUNDATION_VERSION: foundationVersion,
132
136
  ...secretDigest === undefined ? {} : { FOUNDATION_RUNTIME_SECRET_DIGEST: secretDigest }
133
137
  },
134
138
  Tags: { project: props.instance.naming.secretPrefix }
@@ -140,6 +144,7 @@ class FoundationAgent extends cdk.Stack {
140
144
  runtime.node.addDependency(mountTarget);
141
145
  this.agentRuntimeArn = runtime.getAtt("AgentRuntimeArn").toString();
142
146
  this.outputs();
147
+ new cdk.CfnOutput(this, "FoundationVersion", { value: foundationVersion });
143
148
  }
144
149
  outputs() {
145
150
  new cdk.CfnOutput(this, "AgentRuntimeArn", { value: this.agentRuntimeArn });
@@ -783,6 +788,18 @@ class FoundationAgent extends cdk.Stack {
783
788
  function secretArnPattern(secret) {
784
789
  return secret.secretFullArn ?? `${secret.secretArn}-??????`;
785
790
  }
791
+ function foundationVersionContext(scope, id) {
792
+ const explicit = String(scope.node.tryGetContext("foundationVersion") ?? "");
793
+ const release = releaseSource(scope)?.version;
794
+ if (explicit !== "" && release !== undefined && explicit !== release)
795
+ throw new Error(`${id}: -c foundationVersion=${explicit} but the release being deployed is ${release}`);
796
+ const version = explicit !== "" ? explicit : release ?? "";
797
+ if (version === "")
798
+ throw new Error(`${id}: -c foundationVersion=<vX.Y.Z> is required when deploying the runtime: it reads only its own version's config. foundation-deploy passes it.`);
799
+ if (!FOUNDATION_VERSION_RE.test(version))
800
+ throw new Error(`${id}: foundationVersion must look like v0.2.34, got "${version}"`);
801
+ return version;
802
+ }
786
803
  function runtimeSecretDigestContext(value) {
787
804
  if (value === undefined || value === "")
788
805
  return;
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-nn1v23ar.js";
13
+ } from "../chunk-zbw2awg7.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -43,7 +43,7 @@ import {
43
43
  skillsKey,
44
44
  skipBundle,
45
45
  verifyManifest
46
- } from "../chunk-x093sh8z.js";
46
+ } from "../chunk-hps5vshg.js";
47
47
  export {
48
48
  BUILD_TIMEOUT,
49
49
  BUN_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
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,
package/src/deploy/cli.ts CHANGED
@@ -42,7 +42,8 @@ commands
42
42
  deploy build and push the agent image, cdk deploy --all, refresh
43
43
  the runtime secret, sync config
44
44
  post-deploy smoke DEFAULT, probe the mount, promote \`live\`, smoke \`live\`
45
- config:sync upload the runtime config and product skills to S3
45
+ config:sync upload the deployed version's runtime config and the
46
+ product skills to S3
46
47
  setup first-deploy orchestration (idempotent)
47
48
  github-app-create create the instance's GitHub App from the shipped manifest
48
49
  slack-manifest print the instance's Slack app manifest as JSON
@@ -7,6 +7,11 @@
7
7
  * redeploy — except for `admins`, which is also baked into the gateway
8
8
  * Lambda's environment.
9
9
  *
10
+ * The config goes to ONE Foundation version's object (`versionedConfigKey`),
11
+ * which only that version's runtime reads. `deploy` names the version it
12
+ * deploys. A standalone `config:sync` writes for the version the Agent stack
13
+ * last deployed, and refuses unless it is that version itself.
14
+ *
10
15
  * Product skills are part of the product, not of the deployment: they come
11
16
  * from the release's `skills.tar.gz` when one is being deployed, and from this
12
17
  * checkout's `skills/` otherwise. A company's own skills are a source in its
@@ -15,10 +20,11 @@
15
20
  import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
16
21
  import { tmpdir } from "node:os";
17
22
  import { join, resolve } from "node:path";
23
+ import { versionedConfigKey } from "@deployfoundation/foundation-core/instance";
18
24
  import { skillsKey } from "../release/manifest.ts";
19
25
  import { type AwsContext, awsMutate, stackOutput } from "./aws.ts";
20
26
  import { FOUNDATION_ROOT } from "./paths.ts";
21
- import type { ResolvedRelease } from "./release.ts";
27
+ import { type ResolvedRelease, toolVersion } from "./release.ts";
22
28
  import { run } from "./sh.ts";
23
29
 
24
30
  /**
@@ -43,22 +49,115 @@ export interface ConfigSyncOptions {
43
49
  release?: ResolvedRelease;
44
50
  /** Test seam for the download-and-unpack above. */
45
51
  releaseSkillsDir?: (ctx: AwsContext, release: ResolvedRelease) => Promise<string>;
52
+ /**
53
+ * The Foundation version whose config this writes. `deploy` and `setup`
54
+ * pass the version they deploy. Absent, it is the deployed version, which
55
+ * {@link syncVersion} checks against this tool's own.
56
+ */
57
+ version?: string;
58
+ /** Test seam: the Agent stack's `FoundationVersion` output. */
59
+ deployedVersion?: (ctx: AwsContext) => Promise<string | undefined>;
60
+ /** Test seam: this tool's own version, as `vX.Y.Z`. */
61
+ toolVersion?: string;
46
62
  }
47
63
 
48
- export async function configSync(ctx: AwsContext, opts: ConfigSyncOptions = {}): Promise<string> {
64
+ /**
65
+ * The Foundation version the Agent stack last deployed, from its
66
+ * `FoundationVersion` output. `undefined` when the stack has none: a runtime
67
+ * deployed before config was kept per version.
68
+ */
69
+ export async function deployedFoundationVersion(ctx: AwsContext): Promise<string | undefined> {
70
+ try {
71
+ return await stackOutput(ctx, ctx.names.agent, "FoundationVersion");
72
+ } catch (error) {
73
+ if (error instanceof Error && error.message.includes("has no output")) return undefined;
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The version a standalone `config:sync` may write for: the deployed one, and
80
+ * only when this tool (and the release it was asked for, if any) is that same
81
+ * version. The tool validated the config with its own schema; the deployed
82
+ * runtime parses it with its own, and those must be one schema.
83
+ */
84
+ export function syncVersion(
85
+ ctx: AwsContext,
86
+ deployed: string | undefined,
87
+ tool: string,
88
+ release?: string,
89
+ ): string {
90
+ if (deployed === undefined)
91
+ throw new Error(
92
+ `config:sync refused: ${ctx.names.agent} records no Foundation version, so its runtime predates per-version config. Deploy ${tool} first; the deploy writes this config itself.`,
93
+ );
94
+ for (const [what, version] of [
95
+ ["this foundation-deploy", tool],
96
+ ["--release", release],
97
+ ] as const) {
98
+ if (version === undefined || version === deployed) continue;
99
+ throw new Error(
100
+ `config:sync refused: ${what} is ${version}, but ${ctx.names.agent} runs Foundation ${deployed}, whose runtime may refuse a config ${version} accepts. Run config:sync with foundation-deploy@${deployed.slice(1)}, or deploy ${version} first.`,
101
+ );
102
+ }
103
+ return deployed;
104
+ }
105
+
106
+ /**
107
+ * The data bucket. A dry run has no credentials to read a stack output with,
108
+ * and printing the placeholder is what every other dry-run path here does.
109
+ */
110
+ async function dataBucket(ctx: AwsContext, bucket: string | undefined): Promise<string> {
111
+ if (bucket !== undefined) return bucket;
112
+ return ctx.dryRun === true
113
+ ? `<${ctx.names.data}.BucketName>`
114
+ : await stackOutput(ctx, ctx.names.data, "BucketName");
115
+ }
116
+
117
+ /**
118
+ * Upload the runtime config to `version`'s own object, and nothing else.
119
+ *
120
+ * Safe at any point of a deploy, because no runtime of another version reads
121
+ * this object. `deploy` therefore also calls it BEFORE `cdk deploy`, so the
122
+ * new version's pre-warmed containers find their config at boot.
123
+ */
124
+ export async function uploadRuntimeConfig(
125
+ ctx: AwsContext,
126
+ opts: { bucket: string; version: string; runtimeConfigPath?: string },
127
+ ): Promise<string> {
49
128
  if (opts.runtimeConfigPath === undefined && ctx.instance.customization !== undefined)
50
129
  throw new Error(
51
130
  "customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`",
52
131
  );
53
132
  const source = opts.runtimeConfigPath ?? ctx.paths.configPath;
54
- // A dry run has no credentials to read a stack output with, and printing
55
- // the placeholder is what every other dry-run path here does.
56
- const bucket =
57
- opts.bucket ??
133
+ const key = versionedConfigKey(ctx.names.configKey, opts.version);
134
+ await awsMutate(ctx, ["s3", "cp", source, `s3://${opts.bucket}/${key}`]);
135
+ return key;
136
+ }
137
+
138
+ export async function configSync(ctx: AwsContext, opts: ConfigSyncOptions = {}): Promise<string> {
139
+ if (opts.runtimeConfigPath === undefined && ctx.instance.customization !== undefined)
140
+ throw new Error(
141
+ "customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`",
142
+ );
143
+ const tool = opts.toolVersion ?? `v${toolVersion()}`;
144
+ // A dry run cannot read the stack output either, so it plans this tool's.
145
+ const version =
146
+ opts.version ??
58
147
  (ctx.dryRun === true
59
- ? `<${ctx.names.data}.BucketName>`
60
- : await stackOutput(ctx, ctx.names.data, "BucketName"));
61
- await awsMutate(ctx, ["s3", "cp", source, `s3://${bucket}/${ctx.names.configKey}`]);
148
+ ? tool
149
+ : syncVersion(
150
+ ctx,
151
+ await (opts.deployedVersion ?? deployedFoundationVersion)(ctx),
152
+ tool,
153
+ opts.release?.version,
154
+ ));
155
+ const bucket = await dataBucket(ctx, opts.bucket);
156
+ await uploadRuntimeConfig(ctx, {
157
+ bucket,
158
+ version,
159
+ ...(opts.runtimeConfigPath === undefined ? {} : { runtimeConfigPath: opts.runtimeConfigPath }),
160
+ });
62
161
  const skills =
63
162
  opts.release === undefined
64
163
  ? productSkillsDir(opts.foundationRoot)
@@ -28,7 +28,7 @@ import {
28
28
  readSecretString,
29
29
  stackOutput,
30
30
  } from "./aws.ts";
31
- import { configSync } from "./config-sync.ts";
31
+ import { configSync, uploadRuntimeConfig } from "./config-sync.ts";
32
32
  import { adminsCsvFromFile } from "./config.ts";
33
33
  import {
34
34
  cliRunner,
@@ -57,9 +57,9 @@ import { AGENT_DOCKERFILE, FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
57
57
  import {
58
58
  type ReleaseRequest,
59
59
  type ResolvedRelease,
60
+ foundationVersion,
60
61
  releaseContext,
61
62
  resolveRelease,
62
- toolVersion,
63
63
  } from "./release.ts";
64
64
  import { type SlackAppSecret, composeRuntimeSecret } from "./runtime-secret.ts";
65
65
  import { run } from "./sh.ts";
@@ -243,6 +243,9 @@ export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promis
243
243
  ? []
244
244
  : ["-c", `agentImageTag=${opts.tag}`]),
245
245
  ...(opts.release === undefined ? [] : releaseContext(opts.release)),
246
+ // The runtime's own version, and so the one config object it reads.
247
+ "-c",
248
+ `foundationVersion=${foundationVersion(opts.release)}`,
246
249
  ...(opts.runtimeSecretDigest === undefined
247
250
  ? []
248
251
  : ["-c", `runtimeSecretDigest=${opts.runtimeSecretDigest}`]),
@@ -604,7 +607,7 @@ async function checkLicense(
604
607
  const verify = opts.verifyLicense ?? (ctx.dryRun === true ? plannedLicenseCheck : verifyLicense);
605
608
  const result = await verify(ctx.instance.license?.key, {
606
609
  instanceId: ctx.instance.name,
607
- version: release?.version ?? `v${toolVersion()}`,
610
+ version: foundationVersion(release),
608
611
  cache: secretsManagerLicenseCache(ctx),
609
612
  });
610
613
  console.log(`▶ license: ${result.message}`);
@@ -666,6 +669,28 @@ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void
666
669
  console.log(`▶ build + push agent image (${tag})`);
667
670
  console.log(` ${await buildAndPushImage(ctx, { tag })}`);
668
671
  }
672
+
673
+ // The config of the version going in, written BEFORE `cdk deploy` creates
674
+ // that version, so its pre-warmed containers boot with it rather than with
675
+ // the fallback. Nothing else reads this object: the version serving now
676
+ // reads its own, which this deploy never touches. A brand-new instance has
677
+ // no bucket yet; the config sync after `cdk deploy` covers it.
678
+ const version = foundationVersion(release);
679
+ const bucket = phase1
680
+ ? undefined
681
+ : ctx.dryRun === true
682
+ ? `<${ctx.names.data}.BucketName>`
683
+ : await optionalOutput(ctx, ctx.names.data, "BucketName");
684
+ if (bucket !== undefined) {
685
+ console.log(`▶ config for ${version}, ahead of the runtime version that reads it`);
686
+ const key = await uploadRuntimeConfig(ctx, {
687
+ bucket,
688
+ version,
689
+ runtimeConfigPath: customization.runtimeConfigPath,
690
+ });
691
+ console.log(` s3://${bucket}/${key}`);
692
+ }
693
+
669
694
  console.log(
670
695
  `▶ cdk deploy --all${
671
696
  phase1
@@ -700,12 +725,13 @@ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void
700
725
  redeploy: (digest) => deployStacks(digest),
701
726
  });
702
727
 
703
- console.log("▶ config sync");
704
- const bucket = await configSync(ctx, {
728
+ console.log(`▶ config sync (${version})`);
729
+ const synced = await configSync(ctx, {
730
+ version,
705
731
  runtimeConfigPath: customization.runtimeConfigPath,
706
732
  ...(release === undefined ? {} : { release }),
707
733
  });
708
- console.log(`config + skills synced to s3://${bucket}/`);
734
+ console.log(`config + skills synced to s3://${synced}/`);
709
735
 
710
736
  if (!phase1 && promotesInline()) {
711
737
  console.log("▶ smoke test, then promote the `live` endpoint");
@@ -93,6 +93,14 @@ export function toolVersion(infraRoot: string = INFRA_ROOT): string {
93
93
  return version;
94
94
  }
95
95
 
96
+ /**
97
+ * The Foundation version a deploy puts in: the release's, or this tool's own
98
+ * for a local build, whose image is built from this same checkout.
99
+ */
100
+ export function foundationVersion(release?: { version: string }): string {
101
+ return release?.version ?? `v${toolVersion()}`;
102
+ }
103
+
96
104
  /**
97
105
  * Is there a Foundation workspace here to build Lambda bundles from?
98
106
  *
@@ -47,7 +47,7 @@ import {
47
47
  } from "./endpoint.ts";
48
48
  import { currentImageTag } from "./image.ts";
49
49
  import { FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
50
- import { hasFoundationWorkspace } from "./release.ts";
50
+ import { foundationVersion, hasFoundationWorkspace } from "./release.ts";
51
51
  import { run, runCapture } from "./sh.ts";
52
52
  import { slackManifestFor } from "./slack-manifest.ts";
53
53
  import { stageCustomization } from "./stage-customization.ts";
@@ -374,6 +374,8 @@ export async function setup(
374
374
  const bucket = await configSync(ctx, {
375
375
  bucket: await output(ctx, ctx.names.data, "BucketName"),
376
376
  runtimeConfigPath,
377
+ // Setup builds the image from this checkout, so it is this tool's version.
378
+ version: foundationVersion(),
377
379
  });
378
380
  console.log(` s3://${bucket}/`);
379
381
 
@@ -1,3 +1,4 @@
1
+ import { FOUNDATION_VERSION_RE } from "@deployfoundation/foundation-core/instance";
1
2
  import * as cdk from "aws-cdk-lib";
2
3
  import type * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
4
  import type * as ec2 from "aws-cdk-lib/aws-ec2";
@@ -8,7 +9,12 @@ import * as logs from "aws-cdk-lib/aws-logs";
8
9
  import type * as s3 from "aws-cdk-lib/aws-s3";
9
10
  import type * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
10
11
  import type { Construct } from "constructs";
11
- import { agentImage, agentImageTagRequired, releaseImageRepositoryArn } from "../artifacts.ts";
12
+ import {
13
+ agentImage,
14
+ agentImageTagRequired,
15
+ releaseImageRepositoryArn,
16
+ releaseSource,
17
+ } from "../artifacts.ts";
12
18
  import {
13
19
  type Instance,
14
20
  type InstanceNames,
@@ -177,6 +183,7 @@ export class FoundationAgent extends cdk.Stack {
177
183
  `${id}: -c agentImageTag=<tag> is required when deploying the runtime. Pass the tag of an image already pushed to the ${names.ecrRepo} repository, deploy a release with -c release=<version>, or run the bootstrap phase with -c deployRuntime=false.`,
178
184
  );
179
185
  }
186
+ const foundationVersion = foundationVersionContext(this, id);
180
187
 
181
188
  const explicitSubnets = String(this.node.tryGetContext("agentSubnetIds") ?? "")
182
189
  .split(",")
@@ -242,6 +249,11 @@ export class FoundationAgent extends cdk.Stack {
242
249
  RUNTIME_SECRET_ID: props.secrets.runtime.secretName,
243
250
  AWS_REGION: this.region,
244
251
  FOUNDATION_WORKSPACE_ROOT: "/workspace",
252
+ // Which Foundation this version runs, and so the one config object
253
+ // it reads (`config/<version>/…`). Here and not in the runtime
254
+ // secret: that secret is shared by every version still serving,
255
+ // while this map belongs to one runtime version.
256
+ FOUNDATION_VERSION: foundationVersion,
245
257
  // Which runtime secret the containers of this version boot with.
246
258
  // Nothing reads it: it is here so that a deploy whose secret refresh
247
259
  // changed the secret can cut a new version through CloudFormation
@@ -260,6 +272,9 @@ export class FoundationAgent extends cdk.Stack {
260
272
 
261
273
  this.agentRuntimeArn = runtime.getAtt("AgentRuntimeArn").toString();
262
274
  this.outputs();
275
+ // What `config:sync` writes for, and refuses to write past: the version of
276
+ // the runtime this stack last deployed.
277
+ new cdk.CfnOutput(this, "FoundationVersion", { value: foundationVersion });
263
278
  }
264
279
 
265
280
  private outputs(): void {
@@ -999,7 +1014,9 @@ export class FoundationAgent extends cdk.Stack {
999
1014
  }
1000
1015
  // Config and curated skills are deployment-owned: direct S3 API writes are
1001
1016
  // limited to memory/*. The separate fs/* persistent mount remains writable
1002
- // through S3 Files for workspace state and learned skills.
1017
+ // through S3 Files for workspace state and learned skills. Reads are not
1018
+ // scoped to one version's `config/<version>/`: this one role serves every
1019
+ // runtime version still running, the outgoing one included.
1003
1020
  role.addToPolicy(
1004
1021
  new iam.PolicyStatement({
1005
1022
  sid: "ConfigAndSkills",
@@ -1201,6 +1218,28 @@ function secretArnPattern(secret: secretsmanager.ISecret): string {
1201
1218
  return secret.secretFullArn ?? `${secret.secretArn}-??????`;
1202
1219
  }
1203
1220
 
1221
+ /**
1222
+ * The Foundation version this runtime version runs, which picks the one config
1223
+ * object it reads (`versionedConfigKey`). `-c foundationVersion=vX.Y.Z` comes
1224
+ * from the deploy tool; a release synth may omit it and use the release's own.
1225
+ */
1226
+ function foundationVersionContext(scope: Construct, id: string): string {
1227
+ const explicit = String(scope.node.tryGetContext("foundationVersion") ?? "");
1228
+ const release = releaseSource(scope)?.version;
1229
+ if (explicit !== "" && release !== undefined && explicit !== release)
1230
+ throw new Error(
1231
+ `${id}: -c foundationVersion=${explicit} but the release being deployed is ${release}`,
1232
+ );
1233
+ const version = explicit !== "" ? explicit : (release ?? "");
1234
+ if (version === "")
1235
+ throw new Error(
1236
+ `${id}: -c foundationVersion=<vX.Y.Z> is required when deploying the runtime: it reads only its own version's config. foundation-deploy passes it.`,
1237
+ );
1238
+ if (!FOUNDATION_VERSION_RE.test(version))
1239
+ throw new Error(`${id}: foundationVersion must look like v0.2.34, got "${version}"`);
1240
+ return version;
1241
+ }
1242
+
1204
1243
  /**
1205
1244
  * `-c runtimeSecretDigest=<16 hex>`, from `runtimeSecretDigest()` in the
1206
1245
  * deploy tool. Absent means "not known", which a first `cdk deploy` of a