@deployfoundation/foundation-deploy 0.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.
Files changed (54) hide show
  1. package/README.md +174 -0
  2. package/agent-image/Dockerfile +254 -0
  3. package/agent-image/bin/aws +36 -0
  4. package/agent-image/bin/gh +193 -0
  5. package/agent-image/bin/git-credential-sky +89 -0
  6. package/agent-image/security-overlay.yml +176 -0
  7. package/cdk.json +6 -0
  8. package/dist/bin/app.js +112 -0
  9. package/dist/bin/foundation-deploy.js +1906 -0
  10. package/dist/bin/release-account.js +154 -0
  11. package/dist/chunk-4aye5cee.js +2416 -0
  12. package/dist/chunk-9ddxyvq2.js +1455 -0
  13. package/dist/chunk-v7tz8g50.js +428 -0
  14. package/dist/src/index.js +88 -0
  15. package/package.json +38 -0
  16. package/pipeline/buildspec.yml +34 -0
  17. package/src/artifacts.ts +318 -0
  18. package/src/deploy/assets/github-app-manifest.yml +29 -0
  19. package/src/deploy/assets/slack-app-manifest.yml +95 -0
  20. package/src/deploy/aws.ts +265 -0
  21. package/src/deploy/cli.ts +212 -0
  22. package/src/deploy/config-sync.ts +93 -0
  23. package/src/deploy/config.ts +29 -0
  24. package/src/deploy/deploy.ts +566 -0
  25. package/src/deploy/endpoint.ts +242 -0
  26. package/src/deploy/github-app-create.ts +154 -0
  27. package/src/deploy/github-app-manifest.ts +53 -0
  28. package/src/deploy/image.ts +80 -0
  29. package/src/deploy/instance.ts +87 -0
  30. package/src/deploy/license-cache.ts +47 -0
  31. package/src/deploy/license.ts +272 -0
  32. package/src/deploy/paths.ts +65 -0
  33. package/src/deploy/post-deploy.ts +97 -0
  34. package/src/deploy/release.ts +282 -0
  35. package/src/deploy/runtime-secret.ts +241 -0
  36. package/src/deploy/setup.ts +393 -0
  37. package/src/deploy/sh.ts +74 -0
  38. package/src/deploy/slack-manifest.ts +112 -0
  39. package/src/deploy/stage-customization.ts +224 -0
  40. package/src/deploy/tracing.ts +243 -0
  41. package/src/deploy-permissions.ts +165 -0
  42. package/src/index.ts +60 -0
  43. package/src/lambda-bundle-context.ts +64 -0
  44. package/src/names.ts +170 -0
  45. package/src/release/kms.ts +86 -0
  46. package/src/release/manifest.ts +265 -0
  47. package/src/stacks/agent-stack.ts +938 -0
  48. package/src/stacks/api-stack.ts +1005 -0
  49. package/src/stacks/ci-stack.ts +96 -0
  50. package/src/stacks/data-stack.ts +446 -0
  51. package/src/stacks/network-stack.ts +282 -0
  52. package/src/stacks/newsletter-stack.ts +572 -0
  53. package/src/stacks/pipeline-stack.ts +242 -0
  54. package/src/stacks/release-account-stack.ts +229 -0
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Deploy one instance: build and push the agent image, `cdk deploy --all`,
3
+ * refresh the runtime secret, sync config.
4
+ *
5
+ * foundation-deploy deploy --instance .foundation/instance.yaml
6
+ * foundation-deploy deploy --instance … --skip-image --tag abc1234
7
+ * foundation-deploy deploy --instance … --phase1
8
+ *
9
+ * Two-phase bootstrap: the AgentCore Runtime validates its container URI when
10
+ * it is created, so the ECR repository must exist and hold the image first.
11
+ * `--phase1` deploys everything except the runtime (`-c deployRuntime=false`);
12
+ * a plain `deploy` afterwards builds, pushes and creates it.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { createRequire } from "node:module";
16
+ import { join } from "node:path";
17
+ import {
18
+ parseKnockOAuthClient,
19
+ registerKnockPublicClient,
20
+ } from "@deployfoundation/foundation-connectors";
21
+ import { type AwsContext, aws, cdkEnv, putSecretJson, readSecretJson, stackOutput } from "./aws.ts";
22
+ import { configSync } from "./config-sync.ts";
23
+ import { adminsCsvFromFile } from "./config.ts";
24
+ import {
25
+ cliRunner,
26
+ currentRuntimeVersion,
27
+ promoteEndpoint,
28
+ runtimeIdFromArn,
29
+ smokeInvoke,
30
+ smokeSessionId,
31
+ } from "./endpoint.ts";
32
+ import { currentImageTag, ecrImageExists, registryOf } from "./image.ts";
33
+ import { secretsManagerLicenseCache } from "./license-cache.ts";
34
+ import {
35
+ type LicenseResult,
36
+ type VerifyLicenseOptions,
37
+ plannedLicenseCheck,
38
+ verifyLicense,
39
+ } from "./license.ts";
40
+ import { AGENT_DOCKERFILE, FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
41
+ import {
42
+ type ReleaseRequest,
43
+ type ResolvedRelease,
44
+ releaseContext,
45
+ resolveRelease,
46
+ toolVersion,
47
+ } from "./release.ts";
48
+ import { type SlackAppSecret, composeRuntimeSecret } from "./runtime-secret.ts";
49
+ import { run } from "./sh.ts";
50
+ import { stageCustomization } from "./stage-customization.ts";
51
+
52
+ /**
53
+ * The `--cache-from`/`--cache-to` flags for a cache mode, or `undefined` when
54
+ * the mode is not a buildx one at all (a laptop, which uses `docker build`).
55
+ * `local` carries no flags: CodeBuild's own local layer cache is the whole
56
+ * mechanism, and there is nowhere to export to.
57
+ */
58
+ export function buildxCacheFlags(mode: string, scope: string): string[] | undefined {
59
+ if (mode === "gha")
60
+ return [
61
+ "--cache-from",
62
+ `type=gha,scope=${scope}`,
63
+ "--cache-to",
64
+ `type=gha,scope=${scope},mode=max`,
65
+ ];
66
+ if (mode === "local") return [];
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Build the arm64 agent image (AgentCore runs arm64 only) and push it.
72
+ * Returns the full image reference that was pushed.
73
+ */
74
+ export async function buildAndPushImage(
75
+ ctx: AwsContext,
76
+ opts: {
77
+ tag: string;
78
+ /** Foundation checkout the image is built from; overridable for tests. */
79
+ foundationRoot?: string;
80
+ repositoryUri?: string;
81
+ /** Test seam for the ECR lookup below. */
82
+ imageExists?: typeof ecrImageExists;
83
+ },
84
+ ): Promise<string> {
85
+ const foundationRoot = opts.foundationRoot ?? FOUNDATION_ROOT;
86
+ const repositoryUri =
87
+ opts.repositoryUri ?? (await stackOutput(ctx, ctx.names.agent, "RepositoryUri"));
88
+ const registry = registryOf(repositoryUri);
89
+ const image = `${repositoryUri}:${opts.tag}`;
90
+
91
+ // The tag is the commit sha and ECR tags are immutable, so re-running a
92
+ // failed deploy job would otherwise die on the push rather than on whatever
93
+ // actually failed. The existing image is this commit's image; use it.
94
+ const exists = await (opts.imageExists ?? ecrImageExists)(ctx, {
95
+ repositoryName: ctx.names.ecrRepo,
96
+ tag: opts.tag,
97
+ });
98
+ if (exists) {
99
+ console.log(` image_tag_exists ${image} — skipping the build and push`);
100
+ return image;
101
+ }
102
+
103
+ // A dry run must not mint a registry token or read the App id.
104
+ const password =
105
+ ctx.dryRun === true ? "<ecr-token>" : await aws(ctx, ["ecr", "get-login-password"]);
106
+ await run(["docker", "login", "--username", "AWS", "--password-stdin", registry], {
107
+ stdin: password,
108
+ dryRun: ctx.dryRun,
109
+ });
110
+
111
+ // The App id is compiled into the image's git credential helper; it is not
112
+ // a secret (the private key stays in Secrets Manager) but it is not public
113
+ // either, so it comes from the secret rather than a checked-in constant.
114
+ const { app_id } =
115
+ ctx.dryRun === true
116
+ ? { app_id: `<${ctx.names.secretGithubApp}.app_id>` }
117
+ : await readSecretJson<{ app_id: string }>(ctx, ctx.names.secretGithubApp);
118
+ // How the layer cache is kept, by deployer:
119
+ //
120
+ // gha GitHub Actions. buildx exports the cache to the Actions cache, so
121
+ // an unchanged gh/Go stage is a hit instead of a multi-minute
122
+ // rebuild. (An ECR `buildcache` tag was tried first: the repository
123
+ // has immutable tags, so the second export failed.)
124
+ // local CodeBuild. The Actions cache backend does not exist there, so the
125
+ // build exports nothing and relies on the project's own
126
+ // LOCAL_DOCKER_LAYER_CACHE — best effort and free, warm on a
127
+ // re-used build host and cold otherwise. buildx still, because
128
+ // `--push` in one step is what keeps this identical to `gha`.
129
+ // unset A laptop. The plain daemon cache is fine, so `docker build` and
130
+ // `docker push` stay as they were.
131
+ const cacheMode = process.env.FOUNDATION_IMAGE_CACHE ?? "";
132
+ const buildArgs = [
133
+ "--platform",
134
+ "linux/arm64",
135
+ "--build-arg",
136
+ `FOUNDATION_GITHUB_APP_ID=${app_id}`,
137
+ "-f",
138
+ AGENT_DOCKERFILE,
139
+ "-t",
140
+ image,
141
+ ];
142
+ const cacheFlags = buildxCacheFlags(cacheMode, ctx.names.ecrRepo);
143
+ if (cacheFlags !== undefined) {
144
+ await run(["docker", "buildx", "build", ...buildArgs, ...cacheFlags, "--push", "."], {
145
+ cwd: foundationRoot,
146
+ dryRun: ctx.dryRun,
147
+ });
148
+ } else {
149
+ await run(["docker", "build", ...buildArgs, "."], {
150
+ cwd: foundationRoot,
151
+ dryRun: ctx.dryRun,
152
+ });
153
+ await run(["docker", "push", image], { dryRun: ctx.dryRun });
154
+ }
155
+ return image;
156
+ }
157
+
158
+ /**
159
+ * The DLQ alarm subscriber: an explicit flag, then `FOUNDATION_ALARM_EMAIL`, then the
160
+ * instance's own address. An instance with none gets no email hop.
161
+ */
162
+ export function alarmEmailFor(ctx: AwsContext, explicit?: string): string {
163
+ return explicit ?? process.env.FOUNDATION_ALARM_EMAIL ?? ctx.instance.aws.alarmEmail ?? "";
164
+ }
165
+
166
+ /**
167
+ * How the CDK CLI is invoked.
168
+ *
169
+ * Resolved through this package's own dependency on `aws-cdk` rather than
170
+ * through PATH: the tool runs from a Foundation checkout, from a CodeBuild
171
+ * container and from `npx @deployfoundation/foundation-deploy`, and only the first of
172
+ * those has `bunx cdk` meaning anything predictable. `process.execPath` is
173
+ * whichever runtime is already running this — Bun in a checkout, node under
174
+ * npx — and both execute the CLI's entry file.
175
+ */
176
+ export function cdkCommand(): string[] {
177
+ try {
178
+ return [process.execPath, createRequire(import.meta.url).resolve("aws-cdk/bin/cdk")];
179
+ } catch {
180
+ return ["bunx", "cdk"];
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The CDK app itself: `--app` rather than whatever `cdk.json` says.
186
+ *
187
+ * The published package carries the app as bundled JavaScript under `dist/`
188
+ * and cannot assume Bun is installed; a checkout runs the TypeScript directly.
189
+ * One `cdk.json` cannot say both, and passing `--app` explicitly means the
190
+ * command in a deploy log names the file that actually ran.
191
+ *
192
+ * The TypeScript wins where it exists, so a contributor who ran the build once
193
+ * does not silently keep deploying that stale bundle. It exists only in a
194
+ * checkout: `bin/` is not published.
195
+ */
196
+ export function cdkAppCommand(infraRoot: string = INFRA_ROOT): string {
197
+ const source = join(infraRoot, "bin", "app.ts");
198
+ if (existsSync(source)) return `bun run ${source}`;
199
+ return `${process.execPath} ${join(infraRoot, "dist", "bin", "app.js")}`;
200
+ }
201
+
202
+ export interface CdkDeployOptions {
203
+ admins: string;
204
+ alarmEmail: string;
205
+ tag?: string;
206
+ phase1?: boolean;
207
+ /** A verified release; its manifest is what the app reads its artifacts from. */
208
+ release?: ResolvedRelease;
209
+ }
210
+
211
+ export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promise<void> {
212
+ const context = [
213
+ // The CDK app's only required input: the path to the instance file this
214
+ // command was given. No instance name, no `instances/` lookup.
215
+ "-c",
216
+ `instanceFile=${ctx.paths.path}`,
217
+ "-c",
218
+ `admins=${opts.admins}`,
219
+ // Omitted rather than empty when the instance names no address: the API
220
+ // stack treats a missing alarmEmail as "no email subscription".
221
+ ...(opts.alarmEmail === "" ? [] : ["-c", `alarmEmail=${opts.alarmEmail}`]),
222
+ ...(opts.phase1 === true ? ["-c", "deployRuntime=false"] : []),
223
+ // A release names the image by digest, so there is no tag to pass.
224
+ ...(opts.tag === undefined || opts.release !== undefined
225
+ ? []
226
+ : ["-c", `agentImageTag=${opts.tag}`]),
227
+ ...(opts.release === undefined ? [] : releaseContext(opts.release)),
228
+ ];
229
+ await run(
230
+ [
231
+ ...cdkCommand(),
232
+ "deploy",
233
+ "--all",
234
+ "--app",
235
+ cdkAppCommand(),
236
+ "--require-approval",
237
+ "never",
238
+ ...context,
239
+ ],
240
+ {
241
+ cwd: INFRA_ROOT,
242
+ env: cdkEnv(ctx),
243
+ dryRun: ctx.dryRun,
244
+ },
245
+ );
246
+ }
247
+
248
+ /** A stack output that may not exist yet (a stack deployed before it was added). */
249
+ async function optionalOutput(
250
+ ctx: AwsContext,
251
+ stack: string,
252
+ key: string,
253
+ ): Promise<string | undefined> {
254
+ try {
255
+ return await stackOutput(ctx, stack, key);
256
+ } catch {
257
+ return undefined;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Register the public Knock client only in the deploy path, after the API
263
+ * stack has emitted its real callback URL. Slack's command path only reads the
264
+ * finished `{ client_id, redirect_uri }` secret and never performs DCR.
265
+ */
266
+ export async function syncKnockOAuthClient(
267
+ ctx: AwsContext,
268
+ options: {
269
+ fetchImpl?: typeof fetch;
270
+ redirectUri?: string;
271
+ } = {},
272
+ ): Promise<"skipped" | "unchanged" | "registered"> {
273
+ if (!ctx.instance.integrations.knock) return "skipped";
274
+ const redirectUri =
275
+ options.redirectUri ??
276
+ (ctx.dryRun === true
277
+ ? `<${ctx.names.api}.KnockOAuthRedirectUrl>`
278
+ : await stackOutput(ctx, ctx.names.api, "KnockOAuthRedirectUrl"));
279
+ if (ctx.dryRun === true) {
280
+ await putSecretJson(ctx, ctx.names.secretKnockOauthClient, {
281
+ client_id: "<knock-dynamic-client-id>",
282
+ redirect_uri: redirectUri,
283
+ });
284
+ return "registered";
285
+ }
286
+ try {
287
+ const existing = parseKnockOAuthClient(
288
+ JSON.stringify(await readSecretJson(ctx, ctx.names.secretKnockOauthClient)),
289
+ );
290
+ if (existing.redirect_uri === redirectUri) return "unchanged";
291
+ } catch {
292
+ // A first-deploy placeholder or stale registration is replaced below.
293
+ }
294
+ const client = await registerKnockPublicClient({
295
+ redirectUri,
296
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
297
+ });
298
+ await putSecretJson(ctx, ctx.names.secretKnockOauthClient, {
299
+ client_id: client.client_id,
300
+ redirect_uri: client.redirect_uri,
301
+ });
302
+ return "registered";
303
+ }
304
+
305
+ /**
306
+ * Re-compose the instance's `<prefix>/agent/runtime` from the stack outputs as they stand now.
307
+ *
308
+ * The container reads that secret at boot and nothing else, so any output it
309
+ * carries (the tables, buckets and web search gateway url) goes stale the
310
+ * moment a stack changes. Running this after EVERY `cdk deploy` — not only in
311
+ * `setup.ts` — is what keeps the env contract honest.
312
+ *
313
+ * Returns the key names written (never the values).
314
+ */
315
+ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
316
+ const dry = ctx.dryRun === true;
317
+ const { data, agent, api, secretSlackApp, secretRuntime } = ctx.names;
318
+ const tableName = dry ? `<${data}.TableName>` : await stackOutput(ctx, data, "TableName");
319
+ const bucketName = dry ? `<${data}.BucketName>` : await stackOutput(ctx, data, "BucketName");
320
+ const documentsBucketName = dry
321
+ ? `<${data}.DocumentsBucketName>`
322
+ : await optionalOutput(ctx, data, "DocumentsBucketName");
323
+ const itemsTableName = dry
324
+ ? `<${data}.ItemsTableName>`
325
+ : await optionalOutput(ctx, data, "ItemsTableName");
326
+ const webSearchUrl = dry
327
+ ? `<${agent}.WebSearchGatewayUrl>`
328
+ : await optionalOutput(ctx, agent, "WebSearchGatewayUrl");
329
+ const invokeQueueUrl = dry
330
+ ? `<${api}.InvokeQueueUrl>`
331
+ : await optionalOutput(ctx, api, "InvokeQueueUrl");
332
+ const routineSchedulerRoleArn = dry
333
+ ? `<${api}.RoutineSchedulerRoleArn>`
334
+ : await optionalOutput(ctx, api, "RoutineSchedulerRoleArn");
335
+ const agentRuntimeArn = dry
336
+ ? `<${agent}.AgentRuntimeArn>`
337
+ : await optionalOutput(ctx, agent, "AgentRuntimeArn");
338
+ const readOnlyRoleArn = dry
339
+ ? `<${agent}.ReadOnlyRoleArn>`
340
+ : await optionalOutput(ctx, agent, "ReadOnlyRoleArn");
341
+ const emailProxyFunctionArn = dry
342
+ ? `<${api}.EmailProxyFunctionArn>`
343
+ : await optionalOutput(ctx, api, "EmailProxyFunctionArn");
344
+ const browserProxyFunctionArn = dry
345
+ ? `<${api}.BrowserProxyFunctionArn>`
346
+ : await optionalOutput(ctx, api, "BrowserProxyFunctionArn");
347
+ // CRM is an infrastructure opt-in: do not fabricate a binding for an
348
+ // instance whose API stack deliberately has no CRM proxy output.
349
+ const crmProxyFunctionArn = ctx.instance.integrations.crm
350
+ ? dry
351
+ ? `<${api}.CrmProxyFunctionArn>`
352
+ : await optionalOutput(ctx, api, "CrmProxyFunctionArn")
353
+ : undefined;
354
+ const crmPolicyFingerprint = ctx.instance.integrations.crm
355
+ ? dry
356
+ ? `<${api}.CrmPolicyFingerprint>`
357
+ : await optionalOutput(ctx, api, "CrmPolicyFingerprint")
358
+ : undefined;
359
+ const otterProxyFunctionArn = dry
360
+ ? `<${api}.OtterProxyFunctionArn>`
361
+ : await optionalOutput(ctx, api, "OtterProxyFunctionArn");
362
+ const knockProxyFunctionArn = ctx.instance.integrations.knock
363
+ ? dry
364
+ ? `<${api}.KnockProxyFunctionArn>`
365
+ : await optionalOutput(ctx, api, "KnockProxyFunctionArn")
366
+ : undefined;
367
+ const upworkProxyFunctionArn = ctx.instance.integrations.upwork
368
+ ? dry
369
+ ? `<${api}.UpworkProxyFunctionArn>`
370
+ : await optionalOutput(ctx, api, "UpworkProxyFunctionArn")
371
+ : undefined;
372
+ const routineIngressFunctionArn = dry
373
+ ? `<${api}.RoutineIngressFunctionArn>`
374
+ : await optionalOutput(ctx, api, "RoutineIngressFunctionArn");
375
+ const slackApp: SlackAppSecret = dry
376
+ ? {
377
+ bot_token: `<${secretSlackApp}.bot_token>`,
378
+ bot_user_id: `<${secretSlackApp}.bot_user_id>`,
379
+ }
380
+ : await readSecretJson<SlackAppSecret>(ctx, secretSlackApp);
381
+
382
+ const value = composeRuntimeSecret({
383
+ instance: ctx.instance,
384
+ slackApp,
385
+ tableName,
386
+ bucketName,
387
+ documentsBucketName,
388
+ ...(itemsTableName === undefined ? {} : { itemsTableName }),
389
+ ...(webSearchUrl === undefined ? {} : { webSearchUrl }),
390
+ ...(invokeQueueUrl === undefined ? {} : { invokeQueueUrl }),
391
+ ...(routineSchedulerRoleArn === undefined ? {} : { routineSchedulerRoleArn }),
392
+ ...(agentRuntimeArn === undefined ? {} : { agentRuntimeArn }),
393
+ ...(readOnlyRoleArn === undefined ? {} : { readOnlyRoleArn }),
394
+ ...(emailProxyFunctionArn === undefined ? {} : { emailProxyFunctionArn }),
395
+ ...(browserProxyFunctionArn === undefined ? {} : { browserProxyFunctionArn }),
396
+ ...(crmProxyFunctionArn === undefined ? {} : { crmProxyFunctionArn }),
397
+ ...(crmPolicyFingerprint === undefined ? {} : { crmPolicyFingerprint }),
398
+ ...(otterProxyFunctionArn === undefined ? {} : { otterProxyFunctionArn }),
399
+ ...(knockProxyFunctionArn === undefined ? {} : { knockProxyFunctionArn }),
400
+ ...(upworkProxyFunctionArn === undefined ? {} : { upworkProxyFunctionArn }),
401
+ ...(routineIngressFunctionArn === undefined ? {} : { routineIngressFunctionArn }),
402
+ });
403
+ await putSecretJson(ctx, secretRuntime, value);
404
+ return Object.keys(value);
405
+ }
406
+
407
+ /**
408
+ * What the workflow does after a deploy, for a deploy run from a laptop: smoke
409
+ * the version just created on DEFAULT, move the pinned `live` endpoint onto
410
+ * it, and smoke `live` once more. Where a deployer runs `scripts/post-deploy.ts`
411
+ * as its own step — GitHub Actions, and the per-instance CodePipelines — that
412
+ * step owns the promotion instead (it also runs the filesystem probe), and
413
+ * this is skipped; see {@link promotesInline}.
414
+ */
415
+ export async function promoteLive(ctx: AwsContext): Promise<void> {
416
+ const dry = ctx.dryRun === true;
417
+ const runner = cliRunner(ctx);
418
+ const arn = dry
419
+ ? `<${ctx.names.agent}.AgentRuntimeArn>`
420
+ : await stackOutput(ctx, ctx.names.agent, "AgentRuntimeArn");
421
+ const id = dry ? `<${ctx.names.agent}.runtimeId>` : runtimeIdFromArn(arn);
422
+ const version = dry ? "<version>" : await currentRuntimeVersion(runner, id);
423
+ await smokeInvoke(runner, {
424
+ arn,
425
+ qualifier: "DEFAULT",
426
+ sessionId: smokeSessionId("deploy-smoke"),
427
+ dryRun: ctx.dryRun,
428
+ });
429
+ await promoteEndpoint(runner, { id, name: "live", version, dryRun: ctx.dryRun });
430
+ await smokeInvoke(runner, {
431
+ arn,
432
+ qualifier: "live",
433
+ sessionId: smokeSessionId("deploy-live"),
434
+ dryRun: ctx.dryRun,
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Turn a release request into verified bytes, or into a plan.
440
+ *
441
+ * A dry run fetches nothing and verifies nothing — it has no credentials to
442
+ * do either with — so it reports the release it would have checked and lets
443
+ * the printed commands show the rest.
444
+ */
445
+ async function resolveReleaseFor(
446
+ ctx: AwsContext,
447
+ opts: DeployOptions,
448
+ ): Promise<ResolvedRelease | undefined> {
449
+ if (opts.release === undefined) return undefined;
450
+ return (opts.resolveRelease ?? resolveRelease)(ctx, opts.release);
451
+ }
452
+
453
+ /**
454
+ * Ask the Foundry 41 endpoint about this instance's license, and say what
455
+ * came back. Throws when the deploy must not proceed.
456
+ *
457
+ * The version sent is the release being deployed, or this tool's own version
458
+ * for a local build — the two are the same thing from the endpoint's side:
459
+ * which Foundation is going in.
460
+ */
461
+ async function checkLicense(
462
+ ctx: AwsContext,
463
+ opts: DeployOptions,
464
+ release: ResolvedRelease | undefined,
465
+ ): Promise<void> {
466
+ const verify = opts.verifyLicense ?? (ctx.dryRun === true ? plannedLicenseCheck : verifyLicense);
467
+ const result = await verify(ctx.instance.license?.key, {
468
+ instanceId: ctx.instance.name,
469
+ version: release?.version ?? `v${toolVersion()}`,
470
+ cache: secretsManagerLicenseCache(ctx),
471
+ });
472
+ console.log(`▶ license: ${result.message}`);
473
+ }
474
+
475
+ /**
476
+ * Does this deploy promote `live` itself? Only when nothing else will: GitHub
477
+ * Actions and the CodePipeline buildspec both run `scripts/post-deploy.ts` as a
478
+ * separate step, and promoting twice would move the endpoint under a smoke test
479
+ * that is still running.
480
+ */
481
+ export function promotesInline(env: Record<string, string | undefined> = process.env): boolean {
482
+ return env.GITHUB_ACTIONS !== "true" && env.FOUNDATION_SKIP_POST_DEPLOY !== "1";
483
+ }
484
+
485
+ export interface DeployOptions {
486
+ tag?: string;
487
+ skipImage?: boolean;
488
+ phase1?: boolean;
489
+ alarmEmail?: string;
490
+ /** Deploy a published release instead of this checkout. */
491
+ release?: ReleaseRequest;
492
+ /** How a release is turned into verified bytes. Overridable for tests. */
493
+ resolveRelease?: (ctx: AwsContext, request: ReleaseRequest) => Promise<ResolvedRelease>;
494
+ /** The license check. Overridable for tests; never skipped in a real deploy. */
495
+ verifyLicense?: (
496
+ key: string | undefined,
497
+ options: VerifyLicenseOptions,
498
+ ) => Promise<LicenseResult>;
499
+ }
500
+
501
+ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void> {
502
+ // FIRST, before anything is staged, built, or deployed: prove the release is
503
+ // Foundation's. Every artifact reference after this point comes out of the
504
+ // manifest this line verified.
505
+ const release = await resolveReleaseFor(ctx, opts);
506
+ if (release !== undefined)
507
+ console.log(
508
+ `▶ release ${release.version}${release.manifest === undefined ? " (dry run: not verified)" : " verified"} — ${release.manifestPath}`,
509
+ );
510
+
511
+ // SECOND: is this deployment licensed? A definite "no" stops here; an
512
+ // unreachable endpoint falls back to this instance's own cached
513
+ // verification for 30 days, and no key at all is a warning (see license.ts).
514
+ await checkLicense(ctx, opts, release);
515
+
516
+ const customization = stageCustomization({ paths: ctx.paths, dryRun: ctx.dryRun });
517
+ if (customization.status === "staged") console.log("▶ staged reviewed runtime customization");
518
+ const admins = adminsCsvFromFile(ctx.paths.configPath);
519
+ const alarmEmail = alarmEmailFor(ctx, opts.alarmEmail);
520
+ const phase1 = opts.phase1 === true;
521
+ // A release carries the image; only a local deploy has a tag to compute.
522
+ const tag =
523
+ phase1 || release !== undefined
524
+ ? undefined
525
+ : (opts.tag ?? (await currentImageTag(FOUNDATION_ROOT)));
526
+
527
+ if (!phase1 && release === undefined && opts.skipImage !== true && tag !== undefined) {
528
+ console.log(`▶ build + push agent image (${tag})`);
529
+ console.log(` ${await buildAndPushImage(ctx, { tag })}`);
530
+ }
531
+ console.log(
532
+ `▶ cdk deploy --all${
533
+ phase1
534
+ ? " (phase 1: no runtime)"
535
+ : release !== undefined
536
+ ? ` (release ${release.version})`
537
+ : ` (image ${tag})`
538
+ }`,
539
+ );
540
+ await cdkDeploy(ctx, {
541
+ admins,
542
+ alarmEmail,
543
+ tag,
544
+ phase1,
545
+ ...(release === undefined ? {} : { release }),
546
+ });
547
+ if (ctx.instance.integrations.knock) {
548
+ console.log(`▶ Knock OAuth public-client registration (${ctx.names.secretKnockOauthClient})`);
549
+ console.log(` ${await syncKnockOAuthClient(ctx)}`);
550
+ }
551
+
552
+ console.log(`▶ runtime secret refresh (${ctx.names.secretRuntime})`);
553
+ console.log(` keys: ${(await syncRuntimeSecret(ctx)).join(", ")}`);
554
+
555
+ console.log("▶ config sync");
556
+ const bucket = await configSync(ctx, {
557
+ runtimeConfigPath: customization.runtimeConfigPath,
558
+ ...(release === undefined ? {} : { release }),
559
+ });
560
+ console.log(`config + skills synced to s3://${bucket}/`);
561
+
562
+ if (!phase1 && promotesInline()) {
563
+ console.log("▶ smoke test, then promote the `live` endpoint");
564
+ await promoteLive(ctx);
565
+ }
566
+ }