@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,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
+ }