@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,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aws` CLI wrappers for the deploy scripts. The CLI (not an SDK) so that
|
|
3
|
+
* every call is one a human can copy out of OPERATIONS.md and re-run.
|
|
4
|
+
*
|
|
5
|
+
* Secret values travel through a short-lived 0600 temp file, so they never
|
|
6
|
+
* appear in an argv the process table can show.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
type Instance,
|
|
13
|
+
type InstanceContext,
|
|
14
|
+
type InstanceNames,
|
|
15
|
+
instanceNames,
|
|
16
|
+
loadInstanceFromArgs,
|
|
17
|
+
} from "./instance.ts";
|
|
18
|
+
import { run, runCapture } from "./sh.ts";
|
|
19
|
+
|
|
20
|
+
export interface AwsContext {
|
|
21
|
+
/** Empty string means "no `--profile` flag" — use the default credential chain. */
|
|
22
|
+
profile: string;
|
|
23
|
+
region: string;
|
|
24
|
+
/** Read-only calls still run; mutating helpers print instead. */
|
|
25
|
+
dryRun?: boolean;
|
|
26
|
+
/** Which deployment this command targets. Every name below is derived from it. */
|
|
27
|
+
instance: Instance;
|
|
28
|
+
/** Where the instance file and its runtime config live. */
|
|
29
|
+
paths: InstanceContext;
|
|
30
|
+
/** `instanceNames(ctx.instance)`, computed once. */
|
|
31
|
+
names: InstanceNames;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pick the AWS profile, or `""` for "pass no `--profile` at all".
|
|
36
|
+
*
|
|
37
|
+
* Locally a named profile is how credentials are found; in a pipeline the
|
|
38
|
+
* credentials come from the build role in the environment and naming a profile
|
|
39
|
+
* that does not exist there would break every call. `FOUNDATION_NO_PROFILE=1` (or
|
|
40
|
+
* `--profile ""`) forces the default chain explicitly.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveProfile(
|
|
43
|
+
fallback: string,
|
|
44
|
+
explicit?: string,
|
|
45
|
+
env: Record<string, string | undefined> = process.env,
|
|
46
|
+
): string {
|
|
47
|
+
if (env.FOUNDATION_NO_PROFILE === "1") return "";
|
|
48
|
+
if (explicit !== undefined) return explicit;
|
|
49
|
+
if (env.AWS_PROFILE !== undefined && env.AWS_PROFILE !== "") return env.AWS_PROFILE;
|
|
50
|
+
if (env.GITHUB_ACTIONS === "true") return "";
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the context every helper below takes. The instance supplies the
|
|
56
|
+
* account, region and profile defaults; a flag or the environment may still
|
|
57
|
+
* override the last two.
|
|
58
|
+
*/
|
|
59
|
+
export function awsContext(
|
|
60
|
+
overrides: Partial<Omit<AwsContext, "names" | "instance">> & {
|
|
61
|
+
/**
|
|
62
|
+
* The environment the profile and region fall back to. Injectable so a
|
|
63
|
+
* test is not at the mercy of the machine it runs on: `GITHUB_ACTIONS`
|
|
64
|
+
* and `AWS_REGION` both change the answer, so a test that means "just the
|
|
65
|
+
* instance file" has to be able to say so by passing `{}`.
|
|
66
|
+
*/
|
|
67
|
+
env?: Record<string, string | undefined>;
|
|
68
|
+
} = {},
|
|
69
|
+
): AwsContext {
|
|
70
|
+
const env = overrides.env ?? process.env;
|
|
71
|
+
const paths = overrides.paths ?? loadInstanceFromArgs();
|
|
72
|
+
const instance = paths.instance;
|
|
73
|
+
return {
|
|
74
|
+
profile: resolveProfile(instance.aws.profile, overrides.profile, env),
|
|
75
|
+
region: overrides.region ?? env.AWS_REGION ?? instance.aws.region,
|
|
76
|
+
dryRun: overrides.dryRun ?? false,
|
|
77
|
+
instance,
|
|
78
|
+
paths,
|
|
79
|
+
names: instanceNames(instance),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What the plain `aws` wrappers need. Instance-free, so a helper that only
|
|
85
|
+
* shells out to the CLI cannot accidentally depend on a resource name.
|
|
86
|
+
*/
|
|
87
|
+
export type AwsCallContext = Pick<AwsContext, "profile" | "region" | "dryRun">;
|
|
88
|
+
|
|
89
|
+
/** The full argv for an `aws` call, `--profile` included only when set. */
|
|
90
|
+
export function argv(ctx: AwsCallContext, args: string[]): string[] {
|
|
91
|
+
const profile = ctx.profile === "" ? [] : ["--profile", ctx.profile];
|
|
92
|
+
return ["aws", ...args, ...profile, "--region", ctx.region];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Child-process env for `cdk`: no `AWS_PROFILE` when the default chain is in use. */
|
|
96
|
+
export function cdkEnv(ctx: AwsCallContext): Record<string, string> {
|
|
97
|
+
return {
|
|
98
|
+
...(ctx.profile === "" ? {} : { AWS_PROFILE: ctx.profile }),
|
|
99
|
+
AWS_REGION: ctx.region,
|
|
100
|
+
CDK_DEFAULT_REGION: ctx.region,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Run an `aws` subcommand and return trimmed stdout. */
|
|
105
|
+
export async function aws(ctx: AwsCallContext, args: string[], stdin?: string): Promise<string> {
|
|
106
|
+
return runCapture(argv(ctx, args), { stdin });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Run a mutating `aws` subcommand, honouring `ctx.dryRun`. */
|
|
110
|
+
export async function awsMutate(
|
|
111
|
+
ctx: AwsCallContext,
|
|
112
|
+
args: string[],
|
|
113
|
+
stdin?: string,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (ctx.dryRun === true) {
|
|
116
|
+
console.log(` $ ${argv(ctx, args).join(" ")}${stdin === undefined ? "" : " < (stdin)"}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await run(argv(ctx, args), { stdin });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One CloudFormation stack output, by OutputKey. */
|
|
123
|
+
export async function stackOutput(
|
|
124
|
+
ctx: AwsCallContext,
|
|
125
|
+
stack: string,
|
|
126
|
+
key: string,
|
|
127
|
+
): Promise<string> {
|
|
128
|
+
const value = await aws(ctx, [
|
|
129
|
+
"cloudformation",
|
|
130
|
+
"describe-stacks",
|
|
131
|
+
"--stack-name",
|
|
132
|
+
stack,
|
|
133
|
+
"--query",
|
|
134
|
+
`Stacks[0].Outputs[?OutputKey=='${key}'].OutputValue`,
|
|
135
|
+
"--output",
|
|
136
|
+
"text",
|
|
137
|
+
]);
|
|
138
|
+
if (value === "" || value === "None")
|
|
139
|
+
throw new Error(`stack ${stack} has no output ${key} — has it been deployed?`);
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function stackExists(ctx: AwsCallContext, stack: string): Promise<boolean> {
|
|
144
|
+
try {
|
|
145
|
+
await aws(ctx, [
|
|
146
|
+
"cloudformation",
|
|
147
|
+
"describe-stacks",
|
|
148
|
+
"--stack-name",
|
|
149
|
+
stack,
|
|
150
|
+
"--output",
|
|
151
|
+
"text",
|
|
152
|
+
]);
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function secretExists(ctx: AwsCallContext, secretId: string): Promise<boolean> {
|
|
160
|
+
try {
|
|
161
|
+
await aws(ctx, [
|
|
162
|
+
"secretsmanager",
|
|
163
|
+
"describe-secret",
|
|
164
|
+
"--secret-id",
|
|
165
|
+
secretId,
|
|
166
|
+
"--output",
|
|
167
|
+
"text",
|
|
168
|
+
]);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function readSecretString(ctx: AwsCallContext, secretId: string): Promise<string> {
|
|
176
|
+
return aws(ctx, [
|
|
177
|
+
"secretsmanager",
|
|
178
|
+
"get-secret-value",
|
|
179
|
+
"--secret-id",
|
|
180
|
+
secretId,
|
|
181
|
+
"--query",
|
|
182
|
+
"SecretString",
|
|
183
|
+
"--output",
|
|
184
|
+
"text",
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function readSecretJson<T>(ctx: AwsCallContext, secretId: string): Promise<T> {
|
|
189
|
+
return JSON.parse(await readSecretString(ctx, secretId)) as T;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Overwrite a secret's value. The JSON never touches argv (visible in `ps` and
|
|
194
|
+
* shell history): it goes through a 0600 file in a private temp directory that
|
|
195
|
+
* is removed as soon as the CLI returns. (`file:///dev/stdin` was used before,
|
|
196
|
+
* but on Linux the CLI cannot open it when stdin is a socket, as under Bun.)
|
|
197
|
+
*/
|
|
198
|
+
export async function putSecretString(
|
|
199
|
+
ctx: AwsCallContext,
|
|
200
|
+
secretId: string,
|
|
201
|
+
value: string,
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
await throughSecretFile(value, (file) =>
|
|
204
|
+
awsMutate(ctx, [
|
|
205
|
+
"secretsmanager",
|
|
206
|
+
"put-secret-value",
|
|
207
|
+
"--secret-id",
|
|
208
|
+
secretId,
|
|
209
|
+
"--secret-string",
|
|
210
|
+
`file://${file}`,
|
|
211
|
+
]),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Create a secret that does not exist yet, with the same argv discipline.
|
|
217
|
+
* Used by the deploy for secrets no stack owns — the license verification
|
|
218
|
+
* cache, which only exists for an instance that has a license at all.
|
|
219
|
+
*/
|
|
220
|
+
export async function createSecretString(
|
|
221
|
+
ctx: AwsCallContext,
|
|
222
|
+
secretId: string,
|
|
223
|
+
value: string,
|
|
224
|
+
description: string,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
await throughSecretFile(value, (file) =>
|
|
227
|
+
awsMutate(ctx, [
|
|
228
|
+
"secretsmanager",
|
|
229
|
+
"create-secret",
|
|
230
|
+
"--name",
|
|
231
|
+
secretId,
|
|
232
|
+
"--description",
|
|
233
|
+
description,
|
|
234
|
+
"--secret-string",
|
|
235
|
+
`file://${file}`,
|
|
236
|
+
]),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Hand a value to the CLI as a 0600 file in a private temp dir, then remove it. */
|
|
241
|
+
async function throughSecretFile(
|
|
242
|
+
value: string,
|
|
243
|
+
body: (file: string) => Promise<void>,
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
const dir = mkdtempSync(join(tmpdir(), "foundation-secret-"));
|
|
246
|
+
const file = join(dir, "value.json");
|
|
247
|
+
try {
|
|
248
|
+
writeFileSync(file, value, { mode: 0o600 });
|
|
249
|
+
await body(file);
|
|
250
|
+
} finally {
|
|
251
|
+
rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function putSecretJson(
|
|
256
|
+
ctx: AwsCallContext,
|
|
257
|
+
secretId: string,
|
|
258
|
+
value: Record<string, string>,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
await putSecretString(ctx, secretId, JSON.stringify(value));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function callerAccountId(ctx: AwsCallContext): Promise<string> {
|
|
264
|
+
return aws(ctx, ["sts", "get-caller-identity", "--query", "Account", "--output", "text"]);
|
|
265
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `foundation-deploy` — every deploy-time command for one Foundation instance.
|
|
3
|
+
*
|
|
4
|
+
* One required input, everywhere: `--instance <path>`, the path to the
|
|
5
|
+
* deployment's instance YAML. There is no instance name to resolve, no
|
|
6
|
+
* `instances/` directory, no `FOUNDATION_INSTANCE`, and no default: a deploy that
|
|
7
|
+
* guessed its target would be a deploy into the wrong account.
|
|
8
|
+
*
|
|
9
|
+
* The tool deliberately does not import the agent package. It runs in a
|
|
10
|
+
* pipeline, not in the agent container, and pulling the agent's dependency
|
|
11
|
+
* tree into a deploy would make every release of the tool carry a model
|
|
12
|
+
* runtime it never executes.
|
|
13
|
+
*/
|
|
14
|
+
import { type AwsContext, awsContext } from "./aws.ts";
|
|
15
|
+
import { configSync } from "./config-sync.ts";
|
|
16
|
+
import { deploy } from "./deploy.ts";
|
|
17
|
+
import { githubAppCreate } from "./github-app-create.ts";
|
|
18
|
+
import { instanceBanner, loadInstanceContext, resolveInstanceFilePath } from "./instance.ts";
|
|
19
|
+
import { postDeploy } from "./post-deploy.ts";
|
|
20
|
+
import { releaseRequest, resolveRelease } from "./release.ts";
|
|
21
|
+
import { setup } from "./setup.ts";
|
|
22
|
+
import { slackManifestFor } from "./slack-manifest.ts";
|
|
23
|
+
import { stageCustomization } from "./stage-customization.ts";
|
|
24
|
+
|
|
25
|
+
export const COMMANDS = [
|
|
26
|
+
"deploy",
|
|
27
|
+
"post-deploy",
|
|
28
|
+
"config:sync",
|
|
29
|
+
"setup",
|
|
30
|
+
"github-app-create",
|
|
31
|
+
"slack-manifest",
|
|
32
|
+
"stage-customization",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export type Command = (typeof COMMANDS)[number];
|
|
36
|
+
|
|
37
|
+
export const USAGE = `usage: foundation-deploy <command> --instance <path> [options]
|
|
38
|
+
|
|
39
|
+
commands
|
|
40
|
+
deploy build and push the agent image, cdk deploy --all, refresh
|
|
41
|
+
the runtime secret, sync config
|
|
42
|
+
post-deploy smoke DEFAULT, probe the mount, promote \`live\`, smoke \`live\`
|
|
43
|
+
config:sync upload the runtime config and product skills to S3
|
|
44
|
+
setup first-deploy orchestration (idempotent)
|
|
45
|
+
github-app-create create the instance's GitHub App from the shipped manifest
|
|
46
|
+
slack-manifest print the instance's Slack app manifest as JSON
|
|
47
|
+
stage-customization validate and stage a tenant-owned runtime config
|
|
48
|
+
|
|
49
|
+
options
|
|
50
|
+
--instance <path> REQUIRED: path to the deployment's instance YAML
|
|
51
|
+
(or set FOUNDATION_INSTANCE_FILE)
|
|
52
|
+
--profile <p> AWS profile (default: the instance's aws.profile; "" or
|
|
53
|
+
FOUNDATION_NO_PROFILE=1 for the default credential chain)
|
|
54
|
+
--region <r> AWS region (default: the instance's aws.region)
|
|
55
|
+
--alarm-email <a> DLQ alarm subscriber (default: the instance's aws.alarmEmail)
|
|
56
|
+
--dry-run print the commands without running them
|
|
57
|
+
-h, --help this message
|
|
58
|
+
|
|
59
|
+
release options (deploy, config:sync)
|
|
60
|
+
--release <vX.Y.Z> deploy a published Foundation release: verify its signed
|
|
61
|
+
manifest, then deploy the artifacts it names. A copy of
|
|
62
|
+
this tool installed from npm defaults to its OWN version;
|
|
63
|
+
a Foundation checkout defaults to building locally.
|
|
64
|
+
--manifest <ref> that release's manifest, as a path or an s3:// URI
|
|
65
|
+
--release-bucket <b> where releases live (default: the Foundation bucket, or
|
|
66
|
+
FOUNDATION_RELEASE_BUCKET)
|
|
67
|
+
|
|
68
|
+
deploy options
|
|
69
|
+
--tag <tag> image tag to deploy (default: git short sha, +"-dirty");
|
|
70
|
+
ignored in release mode, which pins the image by digest
|
|
71
|
+
--skip-image do not build/push; deploy the stacks against --tag
|
|
72
|
+
--phase1 bootstrap deploy: stacks only, no AgentCore runtime
|
|
73
|
+
|
|
74
|
+
setup options
|
|
75
|
+
--seed-codex copy a local Codex credential into the instance's secret
|
|
76
|
+
--codex-file <path> where that credential is (default: beside the instance
|
|
77
|
+
file, .foundation-local/codex.json)
|
|
78
|
+
|
|
79
|
+
github-app-create options
|
|
80
|
+
--org <org> GitHub org (default: the instance's github.org)
|
|
81
|
+
--port <n> local callback port (default 8765)
|
|
82
|
+
--secret <id> Secrets Manager id (default: the instance's github/app)`;
|
|
83
|
+
|
|
84
|
+
function flag(args: readonly string[], name: string): string | undefined {
|
|
85
|
+
const index = args.indexOf(name);
|
|
86
|
+
return index === -1 ? undefined : args[index + 1];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The AWS context for a command, with the instance file already resolved. */
|
|
90
|
+
function contextFor(args: readonly string[]): AwsContext {
|
|
91
|
+
const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
|
|
92
|
+
return awsContext({
|
|
93
|
+
paths,
|
|
94
|
+
profile: flag(args, "--profile"),
|
|
95
|
+
region: flag(args, "--region"),
|
|
96
|
+
dryRun: args.includes("--dry-run"),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
|
|
101
|
+
const [command, ...args] = argv;
|
|
102
|
+
if (command === undefined || command === "--help" || command === "-h") {
|
|
103
|
+
console.log(USAGE);
|
|
104
|
+
return command === undefined ? 1 : 0;
|
|
105
|
+
}
|
|
106
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
107
|
+
console.log(USAGE);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (!(COMMANDS as readonly string[]).includes(command)) {
|
|
111
|
+
console.error(`unknown command: ${command}\n\n${USAGE}`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// `slack-manifest` writes a machine-readable document to stdout and must not
|
|
116
|
+
// print a banner into it; every other command announces its target first.
|
|
117
|
+
if (command === "slack-manifest") {
|
|
118
|
+
const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
|
|
119
|
+
console.log(
|
|
120
|
+
JSON.stringify(
|
|
121
|
+
slackManifestFor(paths.instance, {
|
|
122
|
+
...(process.env.EVENTS_REQUEST_URL === undefined
|
|
123
|
+
? {}
|
|
124
|
+
: { EVENTS_REQUEST_URL: process.env.EVENTS_REQUEST_URL }),
|
|
125
|
+
...(process.env.COMMANDS_REQUEST_URL === undefined
|
|
126
|
+
? {}
|
|
127
|
+
: { COMMANDS_REQUEST_URL: process.env.COMMANDS_REQUEST_URL }),
|
|
128
|
+
...(process.env.INTERACTIVE_REQUEST_URL === undefined
|
|
129
|
+
? {}
|
|
130
|
+
: { INTERACTIVE_REQUEST_URL: process.env.INTERACTIVE_REQUEST_URL }),
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (command === "github-app-create") {
|
|
138
|
+
const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
|
|
139
|
+
console.log(instanceBanner(paths));
|
|
140
|
+
const port = flag(args, "--port");
|
|
141
|
+
await githubAppCreate({
|
|
142
|
+
paths,
|
|
143
|
+
...(flag(args, "--org") === undefined ? {} : { org: flag(args, "--org") as string }),
|
|
144
|
+
...(port === undefined ? {} : { port: Number(port) }),
|
|
145
|
+
...(flag(args, "--secret") === undefined ? {} : { secret: flag(args, "--secret") as string }),
|
|
146
|
+
...(flag(args, "--profile") === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { profile: flag(args, "--profile") as string }),
|
|
149
|
+
...(flag(args, "--region") === undefined ? {} : { region: flag(args, "--region") as string }),
|
|
150
|
+
dryRun: args.includes("--dry-run"),
|
|
151
|
+
});
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ctx = contextFor(args);
|
|
156
|
+
console.log(instanceBanner(ctx.paths));
|
|
157
|
+
if (ctx.dryRun === true) console.log("dry run — no changes will be made");
|
|
158
|
+
|
|
159
|
+
switch (command) {
|
|
160
|
+
case "deploy": {
|
|
161
|
+
const tag = flag(args, "--tag");
|
|
162
|
+
const alarmEmail = flag(args, "--alarm-email");
|
|
163
|
+
const release = releaseRequest(args);
|
|
164
|
+
await deploy(ctx, {
|
|
165
|
+
...(tag === undefined ? {} : { tag }),
|
|
166
|
+
skipImage: args.includes("--skip-image"),
|
|
167
|
+
phase1: args.includes("--phase1"),
|
|
168
|
+
...(alarmEmail === undefined ? {} : { alarmEmail }),
|
|
169
|
+
...(release === undefined ? {} : { release }),
|
|
170
|
+
});
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
case "post-deploy":
|
|
174
|
+
await postDeploy(ctx);
|
|
175
|
+
return 0;
|
|
176
|
+
case "config:sync": {
|
|
177
|
+
const customization = stageCustomization({ paths: ctx.paths, dryRun: ctx.dryRun });
|
|
178
|
+
const bucket = flag(args, "--bucket");
|
|
179
|
+
// Same rule as `deploy`: the product skills are the release's when a
|
|
180
|
+
// release is what is deployed, and this checkout's otherwise.
|
|
181
|
+
const request = releaseRequest(args);
|
|
182
|
+
const release = request === undefined ? undefined : await resolveRelease(ctx, request);
|
|
183
|
+
const synced = await configSync(ctx, {
|
|
184
|
+
...(bucket === undefined ? {} : { bucket }),
|
|
185
|
+
...(customization.runtimeConfigPath === undefined
|
|
186
|
+
? {}
|
|
187
|
+
: { runtimeConfigPath: customization.runtimeConfigPath }),
|
|
188
|
+
...(release === undefined ? {} : { release }),
|
|
189
|
+
});
|
|
190
|
+
console.log(`config + product skills synced to s3://${synced}/`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
case "setup": {
|
|
194
|
+
const codexFile = flag(args, "--codex-file");
|
|
195
|
+
const alarmEmail = flag(args, "--alarm-email");
|
|
196
|
+
await setup(ctx, {
|
|
197
|
+
seedCodex: args.includes("--seed-codex"),
|
|
198
|
+
...(codexFile === undefined ? {} : { codexFile }),
|
|
199
|
+
...(alarmEmail === undefined ? {} : { alarmEmail }),
|
|
200
|
+
});
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
case "stage-customization": {
|
|
204
|
+
const result = stageCustomization({ paths: ctx.paths, dryRun: ctx.dryRun });
|
|
205
|
+
console.log(` ${result.status}: ${result.runtimeConfigPath}`);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
console.error(`unhandled command: ${command}\n\n${USAGE}`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push runtime configuration and product skills to S3.
|
|
3
|
+
*
|
|
4
|
+
* Uploads a validated staged tenant config when one exists, otherwise the
|
|
5
|
+
* instance repository's own config, then mirrors product skills into the data
|
|
6
|
+
* bucket. Both are read by the agent at session start, so this needs no
|
|
7
|
+
* redeploy — except for `admins`, which is also baked into the gateway
|
|
8
|
+
* Lambda's environment.
|
|
9
|
+
*
|
|
10
|
+
* Product skills are part of the product, not of the deployment: they come
|
|
11
|
+
* from the release's `skills.tar.gz` when one is being deployed, and from this
|
|
12
|
+
* checkout's `skills/` otherwise. A company's own skills are a source in its
|
|
13
|
+
* runtime config (a GitHub repo), never a directory in this package.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { skillsKey } from "../release/manifest.ts";
|
|
19
|
+
import { type AwsContext, awsMutate, stackOutput } from "./aws.ts";
|
|
20
|
+
import { FOUNDATION_ROOT } from "./paths.ts";
|
|
21
|
+
import type { ResolvedRelease } from "./release.ts";
|
|
22
|
+
import { run } from "./sh.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Foundation's product skills directory, or `undefined` when this checkout
|
|
26
|
+
* ships none. A directory holding only a README is "no skills": `--delete`
|
|
27
|
+
* below would otherwise empty the bucket prefix.
|
|
28
|
+
*/
|
|
29
|
+
export function productSkillsDir(foundationRoot: string = FOUNDATION_ROOT): string | undefined {
|
|
30
|
+
const dir = resolve(foundationRoot, "skills");
|
|
31
|
+
if (!existsSync(dir)) return undefined;
|
|
32
|
+
const entries = readdirSync(dir).filter((name) => name !== "README.md");
|
|
33
|
+
return entries.length > 0 ? dir : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ConfigSyncOptions {
|
|
37
|
+
bucket?: string;
|
|
38
|
+
/** The staged tenant config, when `stageCustomization` produced one. */
|
|
39
|
+
runtimeConfigPath?: string;
|
|
40
|
+
/** Overridable for tests. */
|
|
41
|
+
foundationRoot?: string;
|
|
42
|
+
/** In release mode the skills come out of the release, not this checkout. */
|
|
43
|
+
release?: ResolvedRelease;
|
|
44
|
+
/** Test seam for the download-and-unpack above. */
|
|
45
|
+
releaseSkillsDir?: (ctx: AwsContext, release: ResolvedRelease) => Promise<string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function configSync(ctx: AwsContext, opts: ConfigSyncOptions = {}): Promise<string> {
|
|
49
|
+
if (opts.runtimeConfigPath === undefined && ctx.instance.customization !== undefined)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`",
|
|
52
|
+
);
|
|
53
|
+
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 ??
|
|
58
|
+
(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}`]);
|
|
62
|
+
const skills =
|
|
63
|
+
opts.release === undefined
|
|
64
|
+
? productSkillsDir(opts.foundationRoot)
|
|
65
|
+
: await (opts.releaseSkillsDir ?? unpackReleaseSkills)(ctx, opts.release);
|
|
66
|
+
if (skills !== undefined)
|
|
67
|
+
await awsMutate(ctx, ["s3", "sync", `${skills}/`, `s3://${bucket}/skills/`, "--delete"]);
|
|
68
|
+
return bucket;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The product skills a release carries: `releases/vX.Y.Z/skills.tar.gz`,
|
|
73
|
+
* fetched and unpacked into a temp directory the sync then mirrors.
|
|
74
|
+
*
|
|
75
|
+
* The tarball's digest was checked against the signed manifest before the
|
|
76
|
+
* deploy began (`verifyRelease`), so these are the release's skills and not
|
|
77
|
+
* whatever a bucket happens to hold now.
|
|
78
|
+
*/
|
|
79
|
+
export async function unpackReleaseSkills(
|
|
80
|
+
ctx: AwsContext,
|
|
81
|
+
release: ResolvedRelease,
|
|
82
|
+
): Promise<string> {
|
|
83
|
+
const key = release.manifest?.skills.key ?? skillsKey(release.version);
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), "foundation-skills-"));
|
|
85
|
+
const tarball = join(dir, "skills.tar.gz");
|
|
86
|
+
const contents = join(dir, "skills");
|
|
87
|
+
mkdirSync(contents, { recursive: true });
|
|
88
|
+
// `awsMutate` rather than a plain read, only so a dry run prints the copy
|
|
89
|
+
// instead of performing it.
|
|
90
|
+
await awsMutate(ctx, ["s3", "cp", `s3://${release.bucket}/${key}`, tarball]);
|
|
91
|
+
await run(["tar", "-xzf", tarball, "-C", contents], { dryRun: ctx.dryRun });
|
|
92
|
+
return contents;
|
|
93
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the bits of the instance's runtime config the deploy needs.
|
|
3
|
+
*
|
|
4
|
+
* `admins` is baked into the gateway Lambda's environment at deploy time
|
|
5
|
+
* (`-c admins=…`), so a change to the list needs a redeploy, not just a config
|
|
6
|
+
* sync. The file is named by the instance file's `config:` key, resolved
|
|
7
|
+
* against the instance file's own directory — never a path this package knows.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { parse } from "yaml";
|
|
11
|
+
|
|
12
|
+
/** `admins` from a runtime config document, as the CSV the CDK context expects. */
|
|
13
|
+
export function adminsCsv(yamlText: string, source = "runtime config"): string {
|
|
14
|
+
const doc = parse(yamlText) as { admins?: unknown } | null;
|
|
15
|
+
const admins = doc?.admins;
|
|
16
|
+
if (!Array.isArray(admins) || admins.length === 0)
|
|
17
|
+
throw new Error(`${source}: \`admins\` must be a non-empty list of Slack user ids`);
|
|
18
|
+
return admins
|
|
19
|
+
.map((a) => {
|
|
20
|
+
if (typeof a !== "string" || a.trim() === "")
|
|
21
|
+
throw new Error(`${source}: admins entry is not a string: ${JSON.stringify(a)}`);
|
|
22
|
+
return a.trim();
|
|
23
|
+
})
|
|
24
|
+
.join(",");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function adminsCsvFromFile(path: string): string {
|
|
28
|
+
return adminsCsv(readFileSync(path, "utf8"), path);
|
|
29
|
+
}
|