@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,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-deploy orchestration. Idempotent: every step is safe to re-run, so on
|
|
3
|
+
* a failure you fix the cause and run the whole thing again.
|
|
4
|
+
*
|
|
5
|
+
* foundation-deploy setup --instance .foundation/instance.yaml [--seed-codex]
|
|
6
|
+
*
|
|
7
|
+
* The two-phase shape exists because the AgentCore Runtime validates its
|
|
8
|
+
* container URI at create time: phase 1 deploys the stacks with
|
|
9
|
+
* `deployRuntime=false` (creating the ECR repository), the image is built and
|
|
10
|
+
* pushed, then phase 2 creates the runtime against a URI that resolves.
|
|
11
|
+
*
|
|
12
|
+
* `--seed-codex` copies a local Codex credential into the instance's Codex
|
|
13
|
+
* secret so the teammate is authenticated on day one without a Slack `login`.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
type AwsContext,
|
|
19
|
+
aws,
|
|
20
|
+
callerAccountId,
|
|
21
|
+
cdkEnv,
|
|
22
|
+
putSecretString,
|
|
23
|
+
secretExists,
|
|
24
|
+
stackExists,
|
|
25
|
+
stackOutput,
|
|
26
|
+
} from "./aws.ts";
|
|
27
|
+
import { configSync } from "./config-sync.ts";
|
|
28
|
+
import { adminsCsvFromFile } from "./config.ts";
|
|
29
|
+
import {
|
|
30
|
+
alarmEmailFor,
|
|
31
|
+
buildAndPushImage,
|
|
32
|
+
cdkCommand,
|
|
33
|
+
cdkDeploy,
|
|
34
|
+
syncKnockOAuthClient,
|
|
35
|
+
syncRuntimeSecret,
|
|
36
|
+
} from "./deploy.ts";
|
|
37
|
+
import {
|
|
38
|
+
cliRunner,
|
|
39
|
+
currentRuntimeVersion,
|
|
40
|
+
ensureEndpoint,
|
|
41
|
+
runtimeIdFromArn,
|
|
42
|
+
smokeInvoke,
|
|
43
|
+
smokeSessionId,
|
|
44
|
+
} from "./endpoint.ts";
|
|
45
|
+
import { currentImageTag } from "./image.ts";
|
|
46
|
+
import { FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
|
|
47
|
+
import { run, runCapture } from "./sh.ts";
|
|
48
|
+
import { slackManifestFor } from "./slack-manifest.ts";
|
|
49
|
+
import { stageCustomization } from "./stage-customization.ts";
|
|
50
|
+
import { cliTracingRunner, ensureTransactionSearch } from "./tracing.ts";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default location of a local Codex credential to seed, beside the instance
|
|
54
|
+
* file in an ignored directory. `--codex-file` overrides it.
|
|
55
|
+
*/
|
|
56
|
+
export function defaultCodexFile(instanceRoot: string): string {
|
|
57
|
+
return resolve(instanceRoot, ".foundation-local", "codex.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function step(n: number, title: string): void {
|
|
61
|
+
console.log(`\n▶ ${n}. ${title}`);
|
|
62
|
+
}
|
|
63
|
+
/** Register a first-deploy Knock client immediately after the API callback exists. */
|
|
64
|
+
export async function syncSetupKnockOAuth(
|
|
65
|
+
ctx: AwsContext,
|
|
66
|
+
sync: typeof syncKnockOAuthClient = syncKnockOAuthClient,
|
|
67
|
+
): Promise<"skipped" | "unchanged" | "registered"> {
|
|
68
|
+
return sync(ctx);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stand-ins shaped like the real thing, so --dry-run gets past the parsers. */
|
|
72
|
+
function placeholder(ctx: AwsContext, stack: string, key: string): string {
|
|
73
|
+
if (key === "RepositoryUri")
|
|
74
|
+
return `${ctx.instance.aws.account}.dkr.ecr.${ctx.region}.amazonaws.com/${ctx.names.ecrRepo}`;
|
|
75
|
+
return `<${stack}.${key}>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A stack output, tolerating a not-yet-deployed stack under --dry-run so the
|
|
80
|
+
* whole plan can be printed before anything exists.
|
|
81
|
+
*/
|
|
82
|
+
async function output(ctx: AwsContext, stack: string, key: string): Promise<string> {
|
|
83
|
+
if (ctx.dryRun !== true) return stackOutput(ctx, stack, key);
|
|
84
|
+
try {
|
|
85
|
+
return await stackOutput(ctx, stack, key);
|
|
86
|
+
} catch {
|
|
87
|
+
return placeholder(ctx, stack, key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Install what the rest of the run shells out to. Idempotent and quick on an
|
|
93
|
+
* up-to-date checkout; a stale `node_modules` otherwise fails the Lambda
|
|
94
|
+
* bundling step several minutes in, with an error that names neither the cause
|
|
95
|
+
* nor the fix. One install now, rather than sky-tags' two: the CDK app is a
|
|
96
|
+
* workspace of this repo rather than a separate npm project.
|
|
97
|
+
*/
|
|
98
|
+
async function installDependencies(ctx: AwsContext): Promise<void> {
|
|
99
|
+
await run(["bun", "install", "--frozen-lockfile"], {
|
|
100
|
+
cwd: FOUNDATION_ROOT,
|
|
101
|
+
dryRun: ctx.dryRun,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function preflight(ctx: AwsContext): Promise<void> {
|
|
106
|
+
await installDependencies(ctx);
|
|
107
|
+
const expected = ctx.instance.aws.account;
|
|
108
|
+
const account = await callerAccountId(ctx);
|
|
109
|
+
if (account !== expected)
|
|
110
|
+
throw new Error(
|
|
111
|
+
`profile ${ctx.profile} is account ${account}, expected ${expected} for instance ${ctx.instance.name}`,
|
|
112
|
+
);
|
|
113
|
+
console.log(` account ${account} via profile ${ctx.profile} (${ctx.region})`);
|
|
114
|
+
|
|
115
|
+
if (ctx.dryRun === true) {
|
|
116
|
+
await run(["docker", "info"], { dryRun: true });
|
|
117
|
+
} else {
|
|
118
|
+
const docker = Bun.spawn(["docker", "info"], { stdout: "ignore", stderr: "ignore" });
|
|
119
|
+
if ((await docker.exited) !== 0) throw new Error("docker is not running");
|
|
120
|
+
console.log(" docker ✓");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const id of [
|
|
124
|
+
ctx.names.secretSlackSigning,
|
|
125
|
+
ctx.names.secretSlackApp,
|
|
126
|
+
ctx.names.secretGithubApp,
|
|
127
|
+
]) {
|
|
128
|
+
if (!(await secretExists(ctx, id)))
|
|
129
|
+
throw new Error(`secret ${id} is missing — create it before running setup`);
|
|
130
|
+
console.log(` secret ${id} ✓`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function bootstrap(ctx: AwsContext): Promise<void> {
|
|
135
|
+
if (ctx.dryRun !== true && (await stackExists(ctx, "CDKToolkit"))) {
|
|
136
|
+
console.log(" CDKToolkit already present — skipping bootstrap");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await run([...cdkCommand(), "bootstrap", `aws://${ctx.instance.aws.account}/${ctx.region}`], {
|
|
140
|
+
cwd: INFRA_ROOT,
|
|
141
|
+
env: { AWS_PROFILE: ctx.profile, AWS_REGION: ctx.region, CDK_DEFAULT_REGION: ctx.region },
|
|
142
|
+
dryRun: ctx.dryRun,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function writeRuntimeSecret(ctx: AwsContext): Promise<void> {
|
|
147
|
+
// Same composition `deploy.ts` re-runs after every deploy.
|
|
148
|
+
console.log(` keys: ${(await syncRuntimeSecret(ctx)).join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function seedCodex(ctx: AwsContext, codexFile: string): Promise<void> {
|
|
152
|
+
if (!existsSync(codexFile))
|
|
153
|
+
throw new Error(`${codexFile} not found — sign in locally first, or pass --codex-file`);
|
|
154
|
+
const document = await Bun.file(codexFile).text();
|
|
155
|
+
JSON.parse(document); // fail here rather than storing a broken store document
|
|
156
|
+
await putSecretString(ctx, ctx.names.secretCodex, document);
|
|
157
|
+
console.log(` ${ctx.names.secretCodex} seeded from ${codexFile}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Print the Slack app manifest for the deployed API, and the two commands that
|
|
162
|
+
* apply it.
|
|
163
|
+
*
|
|
164
|
+
* Foundation does not run the Slack CLI itself: the CLI is authed against the
|
|
165
|
+
* workspace from wherever the operator works, and its `get-manifest` hook
|
|
166
|
+
* belongs to the instance repository (pointing at `foundation-deploy
|
|
167
|
+
* slack-manifest`), not to the product. Printing the manifest and naming the
|
|
168
|
+
* commands keeps app-as-code without this package pretending to own an
|
|
169
|
+
* operator's Slack session.
|
|
170
|
+
*/
|
|
171
|
+
async function reportSlackManifest(ctx: AwsContext): Promise<void> {
|
|
172
|
+
const { appId, teamId } = ctx.instance.slack;
|
|
173
|
+
const manifest = slackManifestFor(ctx.instance, {
|
|
174
|
+
EVENTS_REQUEST_URL: await output(ctx, ctx.names.api, "EventsUrl"),
|
|
175
|
+
COMMANDS_REQUEST_URL: await output(ctx, ctx.names.api, "CommandsUrl"),
|
|
176
|
+
INTERACTIVE_REQUEST_URL: await output(ctx, ctx.names.api, "InteractiveUrl"),
|
|
177
|
+
});
|
|
178
|
+
console.log(` manifest: ${JSON.stringify(manifest)}`);
|
|
179
|
+
console.log(
|
|
180
|
+
[
|
|
181
|
+
" apply it from the instance repository, whose .slack/hooks.json runs",
|
|
182
|
+
" `foundation-deploy slack-manifest --instance <path>`:",
|
|
183
|
+
` slack manifest diff --app ${appId} --team ${teamId}`,
|
|
184
|
+
` slack app install --app ${appId} --team ${teamId} --force`,
|
|
185
|
+
" if scopes changed, re-approve in Slack when prompted",
|
|
186
|
+
].join("\n"),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function smokeTest(ctx: AwsContext): Promise<void> {
|
|
191
|
+
const arn = await output(ctx, ctx.names.agent, "AgentRuntimeArn");
|
|
192
|
+
await smokeInvoke(cliRunner(ctx), {
|
|
193
|
+
arn,
|
|
194
|
+
sessionId: smokeSessionId("smoke"),
|
|
195
|
+
dryRun: ctx.dryRun,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The pinned endpoint humans talk to. CDK does not manage it on purpose — a
|
|
201
|
+
* deploy creates a new runtime version and must not flip `live` onto it before
|
|
202
|
+
* the smoke tests pass — so `setup` creates it once, here, and every deploy
|
|
203
|
+
* after that only promotes it.
|
|
204
|
+
*/
|
|
205
|
+
async function ensureLiveEndpoint(ctx: AwsContext): Promise<void> {
|
|
206
|
+
const runner = cliRunner(ctx);
|
|
207
|
+
const arn = await output(ctx, ctx.names.agent, "AgentRuntimeArn");
|
|
208
|
+
const id = ctx.dryRun === true ? `<${ctx.names.agent}.runtimeId>` : runtimeIdFromArn(arn);
|
|
209
|
+
const version = ctx.dryRun === true ? "<version>" : await currentRuntimeVersion(runner, id);
|
|
210
|
+
await ensureEndpoint(runner, { id, name: "live", version, dryRun: ctx.dryRun });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Account-level, not per-instance: exported spans are only indexed — and only
|
|
215
|
+
* visible under Transaction Search and the GenAI Observability views — once
|
|
216
|
+
* this is on. Idempotent, so a second instance in the same account reads it and
|
|
217
|
+
* changes nothing.
|
|
218
|
+
*/
|
|
219
|
+
async function enableTransactionSearch(ctx: AwsContext): Promise<void> {
|
|
220
|
+
const result = await ensureTransactionSearch(cliTracingRunner(ctx), {
|
|
221
|
+
account: ctx.instance.aws.account,
|
|
222
|
+
region: ctx.region,
|
|
223
|
+
...(ctx.dryRun === true ? { dryRun: true } : {}),
|
|
224
|
+
});
|
|
225
|
+
console.log(
|
|
226
|
+
` ingestion policy: ${result.ingestionPolicy}; destination: ${result.destination}; indexing rule: ${result.indexing}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The CodeConnections connection's status, or `UNKNOWN` when the call fails.
|
|
232
|
+
* A new connection is `PENDING` until a human authorises the GitHub App in the
|
|
233
|
+
* console; nothing here can do that, and the pipeline cannot source until it
|
|
234
|
+
* is `AVAILABLE`.
|
|
235
|
+
*/
|
|
236
|
+
export async function connectionStatus(ctx: AwsContext, arn: string): Promise<string> {
|
|
237
|
+
try {
|
|
238
|
+
return await aws(ctx, [
|
|
239
|
+
"codeconnections",
|
|
240
|
+
"get-connection",
|
|
241
|
+
"--connection-arn",
|
|
242
|
+
arn,
|
|
243
|
+
"--query",
|
|
244
|
+
"Connection.ConnectionStatus",
|
|
245
|
+
"--output",
|
|
246
|
+
"text",
|
|
247
|
+
]);
|
|
248
|
+
} catch {
|
|
249
|
+
return "UNKNOWN";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Where a human goes to authorise a pending connection. */
|
|
254
|
+
export function connectionConsoleUrl(region: string): string {
|
|
255
|
+
return `https://${region}.console.aws.amazon.com/codesuite/settings/connections?region=${region}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Deploy the instance's own pipeline, once, from here. After this the pipeline
|
|
260
|
+
* deploys itself: its build runs `cdk deploy --all`, which includes this stack.
|
|
261
|
+
*
|
|
262
|
+
* Deploying it while the connection is still `PENDING` would create a pipeline
|
|
263
|
+
* whose every run fails on the source stage, so this stops and says who has to
|
|
264
|
+
* click what instead.
|
|
265
|
+
*/
|
|
266
|
+
async function deployPipelineStack(ctx: AwsContext): Promise<void> {
|
|
267
|
+
const { deploy } = ctx.instance;
|
|
268
|
+
if (deploy.via !== "codepipeline") {
|
|
269
|
+
console.log(" skipped — this instance is deployed by GitHub Actions");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const arn = deploy.connectionArn ?? "";
|
|
273
|
+
const status = ctx.dryRun === true ? "AVAILABLE" : await connectionStatus(ctx, arn);
|
|
274
|
+
console.log(` connection ${arn}\n status: ${status}`);
|
|
275
|
+
if (status !== "AVAILABLE") {
|
|
276
|
+
console.log(
|
|
277
|
+
` not deploying ${ctx.names.pipeline}: authorise the connection first, at\n ${connectionConsoleUrl(ctx.region)}\n then re-run setup.`,
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
await run(
|
|
282
|
+
[
|
|
283
|
+
...cdkCommand(),
|
|
284
|
+
"deploy",
|
|
285
|
+
ctx.names.pipeline,
|
|
286
|
+
"--require-approval",
|
|
287
|
+
"never",
|
|
288
|
+
"-c",
|
|
289
|
+
`instanceFile=${ctx.paths.path}`,
|
|
290
|
+
],
|
|
291
|
+
{ cwd: INFRA_ROOT, env: cdkEnv(ctx), dryRun: ctx.dryRun },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The workflow variable naming this instance's deploy role: `ACME_DEPLOY_ROLE_ARN`. */
|
|
296
|
+
export function deployRoleVariableName(instanceName: string): string {
|
|
297
|
+
return `${instanceName.toUpperCase().replaceAll("-", "_")}_DEPLOY_ROLE_ARN`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Hand the instance repository's workflow the role it assumes. Without the
|
|
302
|
+
* variable a deploy job falls back to a default role and deploys the wrong
|
|
303
|
+
* account, so it is part of bring-up rather than a follow-up chore.
|
|
304
|
+
*/
|
|
305
|
+
async function setDeployRoleVariable(ctx: AwsContext): Promise<void> {
|
|
306
|
+
const arn = await output(ctx, ctx.names.ci, "DeployRoleArn");
|
|
307
|
+
const name = deployRoleVariableName(ctx.instance.name);
|
|
308
|
+
const cmd = ["gh", "variable", "set", name, "--repo", ctx.instance.github.repo, "--body", arn];
|
|
309
|
+
if (ctx.dryRun !== true) {
|
|
310
|
+
try {
|
|
311
|
+
await runCapture(["gh", "auth", "status"]);
|
|
312
|
+
} catch {
|
|
313
|
+
console.log(` gh is not authenticated — set it yourself: ${cmd.join(" ")}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
await run(cmd, { dryRun: ctx.dryRun });
|
|
318
|
+
console.log(` ${name} = ${arn}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function setup(
|
|
322
|
+
ctx: AwsContext,
|
|
323
|
+
opts: { seedCodex?: boolean; codexFile?: string; alarmEmail?: string } = {},
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
const customization = stageCustomization({ paths: ctx.paths, dryRun: ctx.dryRun });
|
|
326
|
+
const runtimeConfigPath = customization.runtimeConfigPath;
|
|
327
|
+
const admins = adminsCsvFromFile(ctx.paths.configPath);
|
|
328
|
+
const alarmEmail = alarmEmailFor(ctx, opts.alarmEmail);
|
|
329
|
+
|
|
330
|
+
step(1, "preflight: dependencies, credentials, secrets");
|
|
331
|
+
await preflight(ctx);
|
|
332
|
+
|
|
333
|
+
step(2, "cdk bootstrap");
|
|
334
|
+
await bootstrap(ctx);
|
|
335
|
+
|
|
336
|
+
step(3, "enable CloudWatch Transaction Search");
|
|
337
|
+
await enableTransactionSearch(ctx);
|
|
338
|
+
|
|
339
|
+
step(4, "phase 1: deploy stacks without the AgentCore runtime");
|
|
340
|
+
await cdkDeploy(ctx, { admins, alarmEmail, phase1: true });
|
|
341
|
+
const knockOAuth = await syncSetupKnockOAuth(ctx);
|
|
342
|
+
if (knockOAuth !== "skipped") console.log(` Knock OAuth: ${knockOAuth}`);
|
|
343
|
+
|
|
344
|
+
// Before phase 2, not after: the runtime's first container reads this secret
|
|
345
|
+
// at boot, and one written afterwards leaves that container serving the
|
|
346
|
+
// fallback config until something recycles it.
|
|
347
|
+
step(5, `write ${ctx.names.secretRuntime}`);
|
|
348
|
+
await writeRuntimeSecret(ctx);
|
|
349
|
+
|
|
350
|
+
// Likewise the bucket: the container fetches its config from S3 at boot.
|
|
351
|
+
step(6, "sync config + skills to S3");
|
|
352
|
+
const bucket = await configSync(ctx, {
|
|
353
|
+
bucket: await output(ctx, ctx.names.data, "BucketName"),
|
|
354
|
+
runtimeConfigPath,
|
|
355
|
+
});
|
|
356
|
+
console.log(` s3://${bucket}/`);
|
|
357
|
+
|
|
358
|
+
step(7, "build + push the agent image");
|
|
359
|
+
const tag = await currentImageTag(FOUNDATION_ROOT);
|
|
360
|
+
const repositoryUri = await output(ctx, ctx.names.agent, "RepositoryUri");
|
|
361
|
+
console.log(` ${repositoryUri}:${tag}`);
|
|
362
|
+
await buildAndPushImage(ctx, { tag, repositoryUri });
|
|
363
|
+
|
|
364
|
+
step(8, "phase 2: deploy the AgentCore runtime");
|
|
365
|
+
await cdkDeploy(ctx, { admins, alarmEmail, tag });
|
|
366
|
+
|
|
367
|
+
// Only FOUNDATION_AGENT_RUNTIME_ARN needs phase 2, but it is what a routine's
|
|
368
|
+
// schedule targets, so the secret is composed once more now that it exists.
|
|
369
|
+
step(9, `refresh ${ctx.names.secretRuntime} with the runtime arn`);
|
|
370
|
+
await writeRuntimeSecret(ctx);
|
|
371
|
+
|
|
372
|
+
step(10, "create the pinned `live` endpoint");
|
|
373
|
+
await ensureLiveEndpoint(ctx);
|
|
374
|
+
|
|
375
|
+
step(11, `seed ${ctx.names.secretCodex}`);
|
|
376
|
+
if (opts.seedCodex === true)
|
|
377
|
+
await seedCodex(ctx, opts.codexFile ?? defaultCodexFile(ctx.paths.root));
|
|
378
|
+
else console.log(" skipped (pass --seed-codex, or run `login` in Slack as an admin)");
|
|
379
|
+
|
|
380
|
+
step(12, "tell GitHub Actions which role to assume");
|
|
381
|
+
await setDeployRoleVariable(ctx);
|
|
382
|
+
|
|
383
|
+
step(13, "deploy the instance's own pipeline, if it has one");
|
|
384
|
+
await deployPipelineStack(ctx);
|
|
385
|
+
|
|
386
|
+
step(14, "the Slack app manifest for the deployed API");
|
|
387
|
+
await reportSlackManifest(ctx);
|
|
388
|
+
|
|
389
|
+
step(15, "smoke test the runtime");
|
|
390
|
+
await smokeTest(ctx);
|
|
391
|
+
|
|
392
|
+
console.log(`\nDone. Now say \`@${ctx.instance.displayName} hello\` in Slack.`);
|
|
393
|
+
}
|
package/src/deploy/sh.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny process helpers shared by the deploy scripts.
|
|
3
|
+
*
|
|
4
|
+
* `run` streams the child's output straight to this terminal (cdk, docker and
|
|
5
|
+
* the slack CLI are all long-running and worth watching); `runCapture` keeps
|
|
6
|
+
* stdout for the caller and only surfaces stderr when the command fails.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RunOptions {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
/** Print the command instead of executing it. */
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
/** Fed to the child on stdin, then closed. */
|
|
15
|
+
stdin?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A copy-pasteable rendering of a command, used for logs and `--dry-run`. */
|
|
19
|
+
export function formatCommand(cmd: string[], env?: Record<string, string>): string {
|
|
20
|
+
const prefix = Object.entries(env ?? {})
|
|
21
|
+
.map(([k, v]) => `${k}=${quote(v)}`)
|
|
22
|
+
.join(" ");
|
|
23
|
+
const body = cmd.map(quote).join(" ");
|
|
24
|
+
return prefix === "" ? body : `${prefix} ${body}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function quote(value: string): string {
|
|
28
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function spawn(cmd: string[], opts: RunOptions, stdout: "inherit" | "pipe") {
|
|
32
|
+
const [bin, ...rest] = cmd;
|
|
33
|
+
if (bin === undefined) throw new Error("run: empty command");
|
|
34
|
+
return Bun.spawn([bin, ...rest], {
|
|
35
|
+
cwd: opts.cwd,
|
|
36
|
+
env: { ...process.env, ...opts.env },
|
|
37
|
+
stdin: opts.stdin === undefined ? "ignore" : "pipe",
|
|
38
|
+
stdout,
|
|
39
|
+
stderr: stdout === "inherit" ? "inherit" : "pipe",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function feed(proc: { stdin: unknown }, stdin: string | undefined): Promise<void> {
|
|
44
|
+
if (stdin === undefined) return;
|
|
45
|
+
const sink = proc.stdin as { write: (s: string) => void; end: () => void } | null;
|
|
46
|
+
if (sink === null) return;
|
|
47
|
+
sink.write(stdin);
|
|
48
|
+
sink.end();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Run a command with inherited stdio. Throws if it exits non-zero. */
|
|
52
|
+
export async function run(cmd: string[], opts: RunOptions = {}): Promise<void> {
|
|
53
|
+
if (opts.dryRun === true) {
|
|
54
|
+
console.log(` $ ${formatCommand(cmd, opts.env)}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const proc = spawn(cmd, opts, "inherit");
|
|
58
|
+
await feed(proc, opts.stdin);
|
|
59
|
+
const code = await proc.exited;
|
|
60
|
+
if (code !== 0) throw new Error(`${formatCommand(cmd)} exited ${code}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Run a command and return its trimmed stdout. Throws with stderr on failure. */
|
|
64
|
+
export async function runCapture(cmd: string[], opts: RunOptions = {}): Promise<string> {
|
|
65
|
+
const proc = spawn(cmd, opts, "pipe");
|
|
66
|
+
await feed(proc, opts.stdin);
|
|
67
|
+
const [out, err, code] = await Promise.all([
|
|
68
|
+
new Response(proc.stdout).text(),
|
|
69
|
+
new Response(proc.stderr).text(),
|
|
70
|
+
proc.exited,
|
|
71
|
+
]);
|
|
72
|
+
if (code !== 0) throw new Error(`${formatCommand(cmd)} exited ${code}: ${err.trim()}`);
|
|
73
|
+
return out.trim();
|
|
74
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the Slack app manifest for one instance.
|
|
3
|
+
*
|
|
4
|
+
* `assets/slack-app-manifest.yml` — a package asset, not a file in anyone's
|
|
5
|
+
* repository — carries the instance-facing text (names, descriptions, slash
|
|
6
|
+
* commands, app home). Bot scopes and event subscriptions come from the
|
|
7
|
+
* capability registry so a capability's scope needs live next to the
|
|
8
|
+
* capability, and a manifest can never be missing a scope a shipped capability
|
|
9
|
+
* requires. The app is installed once per workspace and capabilities toggle at
|
|
10
|
+
* runtime, so the manifest asks for every registered capability's scopes, not
|
|
11
|
+
* only the currently enabled ones.
|
|
12
|
+
*
|
|
13
|
+
* Request URLs are substituted from env. When they are unset (no API deployed
|
|
14
|
+
* yet) the URL-bearing sections are removed entirely, because Slack verifies
|
|
15
|
+
* request URLs on manifest create and would reject a placeholder.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
BASE_SLACK_EVENTS,
|
|
21
|
+
CAPABILITY_IDS,
|
|
22
|
+
requiredSlackCommands,
|
|
23
|
+
requiredSlackScopes,
|
|
24
|
+
} from "@deployfoundation/foundation-core";
|
|
25
|
+
import { type Instance, slackCommandPrefix } from "@deployfoundation/foundation-core/instance";
|
|
26
|
+
import { parse } from "yaml";
|
|
27
|
+
import { PACKAGE_ASSETS } from "./paths.ts";
|
|
28
|
+
|
|
29
|
+
/** The Slack app manifest template this package ships. */
|
|
30
|
+
export const SLACK_APP_MANIFEST_PATH = join(PACKAGE_ASSETS, "slack-app-manifest.yml");
|
|
31
|
+
|
|
32
|
+
export interface SlackManifestInstance {
|
|
33
|
+
displayName: string;
|
|
34
|
+
/** Rendered into `/<prefix>-<suffix>` for every slash command. */
|
|
35
|
+
commandPrefix: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SlackManifestEnv {
|
|
39
|
+
EVENTS_REQUEST_URL?: string;
|
|
40
|
+
COMMANDS_REQUEST_URL?: string;
|
|
41
|
+
INTERACTIVE_REQUEST_URL?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SlackManifest {
|
|
45
|
+
features: { slash_commands?: Array<{ command: string }> };
|
|
46
|
+
oauth_config: { scopes: { bot: string[] } };
|
|
47
|
+
settings: {
|
|
48
|
+
event_subscriptions?: { request_url: string; bot_events: string[] };
|
|
49
|
+
interactivity?: unknown;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildSlackManifest(
|
|
54
|
+
template: string,
|
|
55
|
+
instance: SlackManifestInstance,
|
|
56
|
+
env: SlackManifestEnv,
|
|
57
|
+
): SlackManifest {
|
|
58
|
+
const events = env.EVENTS_REQUEST_URL;
|
|
59
|
+
const commands = env.COMMANDS_REQUEST_URL ?? events?.replace(/\/events$/, "/commands");
|
|
60
|
+
const interactive = env.INTERACTIVE_REQUEST_URL ?? events?.replace(/\/events$/, "/interactive");
|
|
61
|
+
|
|
62
|
+
const manifest = parse(
|
|
63
|
+
template
|
|
64
|
+
.replaceAll("${DISPLAY_NAME}", instance.displayName)
|
|
65
|
+
// Slack rejects an uppercase bot display name.
|
|
66
|
+
.replaceAll("${BOT_NAME}", instance.displayName.toLowerCase())
|
|
67
|
+
.replaceAll("${COMMAND_PREFIX}", instance.commandPrefix)
|
|
68
|
+
.replaceAll("${EVENTS_REQUEST_URL}", events ?? "")
|
|
69
|
+
.replaceAll("${COMMANDS_REQUEST_URL}", commands ?? "")
|
|
70
|
+
.replaceAll("${INTERACTIVE_REQUEST_URL}", interactive ?? ""),
|
|
71
|
+
) as SlackManifest & { oauth_config?: { scopes?: { bot?: unknown } } };
|
|
72
|
+
|
|
73
|
+
if (manifest.oauth_config?.scopes?.bot !== undefined)
|
|
74
|
+
throw new Error(
|
|
75
|
+
"app-manifest.yml: bot scopes are generated from the Foundation capability registry; remove them from the template",
|
|
76
|
+
);
|
|
77
|
+
manifest.oauth_config = { scopes: { bot: requiredSlackScopes(CAPABILITY_IDS) } };
|
|
78
|
+
|
|
79
|
+
// Every command a registered capability needs must be in the template; the
|
|
80
|
+
// template may carry more (help, drive) than the registry knows about.
|
|
81
|
+
const declared = new Set((manifest.features.slash_commands ?? []).map((c) => c.command));
|
|
82
|
+
for (const command of requiredSlackCommands(CAPABILITY_IDS, instance.commandPrefix))
|
|
83
|
+
if (!declared.has(command))
|
|
84
|
+
throw new Error(
|
|
85
|
+
`app-manifest.yml: missing slash command ${command} required by a capability`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (events === undefined) {
|
|
89
|
+
manifest.settings.event_subscriptions = undefined;
|
|
90
|
+
} else {
|
|
91
|
+
manifest.settings.event_subscriptions = {
|
|
92
|
+
request_url: events,
|
|
93
|
+
bot_events: [...BASE_SLACK_EVENTS],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (commands === undefined || commands === "") manifest.features.slash_commands = undefined;
|
|
97
|
+
if (interactive === undefined || interactive === "") manifest.settings.interactivity = undefined;
|
|
98
|
+
return manifest;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The manifest for one instance, read from the shipped template. The command
|
|
103
|
+
* prefix is the instance's own, so one declaration renders `/<prefix>-help`
|
|
104
|
+
* for every deployment and a different one for each.
|
|
105
|
+
*/
|
|
106
|
+
export function slackManifestFor(instance: Instance, env: SlackManifestEnv): SlackManifest {
|
|
107
|
+
return buildSlackManifest(
|
|
108
|
+
readFileSync(SLACK_APP_MANIFEST_PATH, "utf8"),
|
|
109
|
+
{ displayName: instance.displayName, commandPrefix: slackCommandPrefix(instance) },
|
|
110
|
+
env,
|
|
111
|
+
);
|
|
112
|
+
}
|