@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.
- package/README.md +174 -0
- package/agent-image/Dockerfile +254 -0
- package/agent-image/bin/aws +36 -0
- package/agent-image/bin/gh +193 -0
- package/agent-image/bin/git-credential-sky +89 -0
- package/agent-image/security-overlay.yml +176 -0
- package/cdk.json +6 -0
- package/dist/bin/app.js +112 -0
- package/dist/bin/foundation-deploy.js +1906 -0
- package/dist/bin/release-account.js +154 -0
- package/dist/chunk-4aye5cee.js +2416 -0
- package/dist/chunk-9ddxyvq2.js +1455 -0
- package/dist/chunk-v7tz8g50.js +428 -0
- package/dist/src/index.js +88 -0
- package/package.json +38 -0
- package/pipeline/buildspec.yml +34 -0
- package/src/artifacts.ts +318 -0
- package/src/deploy/assets/github-app-manifest.yml +29 -0
- package/src/deploy/assets/slack-app-manifest.yml +95 -0
- package/src/deploy/aws.ts +265 -0
- package/src/deploy/cli.ts +212 -0
- package/src/deploy/config-sync.ts +93 -0
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy.ts +566 -0
- package/src/deploy/endpoint.ts +242 -0
- package/src/deploy/github-app-create.ts +154 -0
- package/src/deploy/github-app-manifest.ts +53 -0
- package/src/deploy/image.ts +80 -0
- package/src/deploy/instance.ts +87 -0
- package/src/deploy/license-cache.ts +47 -0
- package/src/deploy/license.ts +272 -0
- package/src/deploy/paths.ts +65 -0
- package/src/deploy/post-deploy.ts +97 -0
- package/src/deploy/release.ts +282 -0
- package/src/deploy/runtime-secret.ts +241 -0
- package/src/deploy/setup.ts +393 -0
- package/src/deploy/sh.ts +74 -0
- package/src/deploy/slack-manifest.ts +112 -0
- package/src/deploy/stage-customization.ts +224 -0
- package/src/deploy/tracing.ts +243 -0
- package/src/deploy-permissions.ts +165 -0
- package/src/index.ts +60 -0
- package/src/lambda-bundle-context.ts +64 -0
- package/src/names.ts +170 -0
- package/src/release/kms.ts +86 -0
- package/src/release/manifest.ts +265 -0
- package/src/stacks/agent-stack.ts +938 -0
- package/src/stacks/api-stack.ts +1005 -0
- package/src/stacks/ci-stack.ts +96 -0
- package/src/stacks/data-stack.ts +446 -0
- package/src/stacks/network-stack.ts +282 -0
- package/src/stacks/newsletter-stack.ts +572 -0
- package/src/stacks/pipeline-stack.ts +242 -0
- 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
|
+
}
|