@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,272 @@
1
+ /**
2
+ * The license check a deploy runs before it deploys (migration step 15).
3
+ *
4
+ * An instance carries an annual key in its own `instance.yaml`
5
+ * (`license.key`). Before `cdk deploy`, `foundation-deploy` asks the Foundry 41
6
+ * endpoint whether that key is good for this instance and this version. The
7
+ * request carries the key, the instance id and the version — nothing else. No
8
+ * telemetry rides along, and there is no second call anywhere in the tool.
9
+ *
10
+ * The rules that matter are the failure modes, because a license check sits in
11
+ * front of every customer's deploy:
12
+ *
13
+ * - **No key configured** — skipped, loudly. Keys are not yet issued and no
14
+ * endpoint is live; a check that blocked deploys today would block them
15
+ * for nothing.
16
+ * - **A definite "no"** — refused. An invalid, expired or revoked key stops
17
+ * the deploy; that is what the check is for.
18
+ * - **Anything else** (timeout, 5xx, DNS, a rate limit) — retried, then a
19
+ * 30-DAY GRACE against the last verification this instance cached in its
20
+ * own Secrets Manager. Foundry 41's availability must never be a customer's
21
+ * outage; a deploy is allowed to proceed for a month on the last good
22
+ * answer, saying so each time.
23
+ *
24
+ * The cache lives at `<prefix>/license/last-verified` in the INSTANCE's
25
+ * account: the customer can read it, and Foundry 41 keeps no record of the
26
+ * deploy beyond answering the question.
27
+ */
28
+
29
+ /** Where the check is sent unless the environment says otherwise. */
30
+ export const DEFAULT_LICENSE_ENDPOINT = "https://license.foundry41.com/v1/verify";
31
+
32
+ export const LICENSE_ENDPOINT_ENV = "FOUNDATION_LICENSE_ENDPOINT";
33
+
34
+ /** How long a cached verification keeps a customer deploying while the endpoint is down. */
35
+ export const GRACE_DAYS = 30;
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+
38
+ /** What this instance cached the last time the endpoint said yes. */
39
+ export interface LastVerified {
40
+ /** ISO 8601, when the endpoint last answered "valid". */
41
+ verifiedAt: string;
42
+ /** The version that was deployed then; informational. */
43
+ version?: string;
44
+ /** The license's own expiry, when the endpoint reports one. */
45
+ expiresAt?: string;
46
+ }
47
+
48
+ /** Read and write the last-good verification. Secrets Manager in a real deploy. */
49
+ export interface LicenseCache {
50
+ read(): Promise<LastVerified | undefined>;
51
+ write(value: LastVerified): Promise<void>;
52
+ }
53
+
54
+ export interface VerifyLicenseOptions {
55
+ /** The deployment asking; sent as `instanceId`. */
56
+ instanceId: string;
57
+ /** The Foundation version being deployed; sent as `version`. */
58
+ version: string;
59
+ endpoint?: string;
60
+ cache?: LicenseCache;
61
+ fetchImpl?: typeof fetch;
62
+ /** Attempts before falling back to the grace period. */
63
+ attempts?: number;
64
+ /** Overridable for tests; the default backs off 1s, 2s, 4s. */
65
+ sleep?: (ms: number) => Promise<void>;
66
+ now?: () => Date;
67
+ /** Per-attempt timeout. */
68
+ timeoutMs?: number;
69
+ }
70
+
71
+ export type LicenseOutcome =
72
+ /** The endpoint said yes. */
73
+ | "verified"
74
+ /** The endpoint could not be reached; a recent cached yes is standing in. */
75
+ | "grace"
76
+ /** No key is configured. */
77
+ | "skipped";
78
+
79
+ export interface LicenseResult {
80
+ outcome: LicenseOutcome;
81
+ /** One line for the deploy log. */
82
+ message: string;
83
+ expiresAt?: string;
84
+ }
85
+
86
+ /** The endpoint's answer. Anything else is treated as "could not reach it". */
87
+ interface LicenseResponse {
88
+ status: "valid" | "invalid";
89
+ expiresAt?: string;
90
+ message?: string;
91
+ }
92
+
93
+ /** Where to send the check: an explicit endpoint, the environment, then the default. */
94
+ export function licenseEndpoint(
95
+ explicit?: string,
96
+ env: Record<string, string | undefined> = process.env,
97
+ ): string {
98
+ return explicit ?? env[LICENSE_ENDPOINT_ENV] ?? DEFAULT_LICENSE_ENDPOINT;
99
+ }
100
+
101
+ /**
102
+ * Check one license key. Resolves with what happened; throws only when the
103
+ * deploy must not proceed — a definite rejection, or an unreachable endpoint
104
+ * with no cached verification inside the grace period.
105
+ */
106
+ export async function verifyLicense(
107
+ key: string | undefined,
108
+ options: VerifyLicenseOptions,
109
+ ): Promise<LicenseResult> {
110
+ if (key === undefined || key === "")
111
+ return {
112
+ outcome: "skipped",
113
+ message:
114
+ "no license.key in the instance file — skipping the Foundry 41 license check. Set license.key once your key is issued.",
115
+ };
116
+
117
+ const endpoint = licenseEndpoint(options.endpoint);
118
+ const now = options.now ?? (() => new Date());
119
+ const attempts = options.attempts ?? 3;
120
+ const sleep = options.sleep ?? ((ms: number) => new Promise((done) => setTimeout(done, ms)));
121
+ const fetchImpl = options.fetchImpl ?? fetch;
122
+
123
+ let lastFailure = "";
124
+ for (let attempt = 1; attempt <= attempts; attempt++) {
125
+ let response: Response;
126
+ try {
127
+ response = await fetchImpl(endpoint, {
128
+ method: "POST",
129
+ headers: { "content-type": "application/json" },
130
+ // Exactly three fields. Adding a fourth here is a product decision,
131
+ // not a convenience.
132
+ body: JSON.stringify({
133
+ key,
134
+ instanceId: options.instanceId,
135
+ version: options.version,
136
+ }),
137
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
138
+ });
139
+ } catch (error) {
140
+ lastFailure = error instanceof Error ? error.message : String(error);
141
+ if (attempt < attempts) await sleep(2 ** (attempt - 1) * 1000);
142
+ continue;
143
+ }
144
+
145
+ // A definite "no" is the endpoint doing its job; do not retry it and do
146
+ // not fall back to a cached yes.
147
+ if (response.status === 402 || response.status === 403)
148
+ throw new Error(
149
+ `license refused for instance ${options.instanceId}: ${await failureText(response)}`,
150
+ );
151
+
152
+ if (!response.ok) {
153
+ lastFailure = `HTTP ${response.status}`;
154
+ if (attempt < attempts) await sleep(2 ** (attempt - 1) * 1000);
155
+ continue;
156
+ }
157
+
158
+ let body: LicenseResponse;
159
+ try {
160
+ body = (await response.json()) as LicenseResponse;
161
+ } catch (error) {
162
+ lastFailure = `unreadable response: ${error instanceof Error ? error.message : String(error)}`;
163
+ if (attempt < attempts) await sleep(2 ** (attempt - 1) * 1000);
164
+ continue;
165
+ }
166
+
167
+ if (body.status === "invalid")
168
+ throw new Error(
169
+ `license refused for instance ${options.instanceId}: ${body.message ?? "the key is not valid for this instance"}`,
170
+ );
171
+ if (body.status !== "valid") {
172
+ lastFailure = `unexpected status "${String(body.status)}"`;
173
+ if (attempt < attempts) await sleep(2 ** (attempt - 1) * 1000);
174
+ continue;
175
+ }
176
+
177
+ const verifiedAt = now().toISOString();
178
+ await cacheQuietly(options.cache, {
179
+ verifiedAt,
180
+ version: options.version,
181
+ ...(body.expiresAt === undefined ? {} : { expiresAt: body.expiresAt }),
182
+ });
183
+ return {
184
+ outcome: "verified",
185
+ message: `license verified for ${options.instanceId}${body.expiresAt === undefined ? "" : ` (expires ${body.expiresAt})`}`,
186
+ ...(body.expiresAt === undefined ? {} : { expiresAt: body.expiresAt }),
187
+ };
188
+ }
189
+
190
+ return grace(lastFailure, options, now());
191
+ }
192
+
193
+ /**
194
+ * The endpoint could not be reached. Stand on the last verification this
195
+ * instance cached, if it is recent enough.
196
+ */
197
+ async function grace(
198
+ failure: string,
199
+ options: VerifyLicenseOptions,
200
+ now: Date,
201
+ ): Promise<LicenseResult> {
202
+ const cached = await readQuietly(options.cache);
203
+ if (cached === undefined)
204
+ throw new Error(
205
+ `the Foundation license endpoint could not be reached (${failure}) and this instance has no cached verification to fall back on. Retry, or set ${LICENSE_ENDPOINT_ENV} if you use a private endpoint.`,
206
+ );
207
+ const verifiedAt = Date.parse(cached.verifiedAt);
208
+ if (Number.isNaN(verifiedAt))
209
+ throw new Error(
210
+ `the Foundation license endpoint could not be reached (${failure}) and the cached verification is unreadable (verifiedAt: ${cached.verifiedAt})`,
211
+ );
212
+ const ageDays = (now.getTime() - verifiedAt) / DAY_MS;
213
+ if (ageDays > GRACE_DAYS)
214
+ throw new Error(
215
+ `the Foundation license endpoint could not be reached (${failure}) and the last verification is ${Math.floor(ageDays)} days old, past the ${GRACE_DAYS}-day grace period`,
216
+ );
217
+ const remaining = Math.max(0, Math.ceil(GRACE_DAYS - ageDays));
218
+ return {
219
+ outcome: "grace",
220
+ message: `the Foundation license endpoint could not be reached (${failure}); continuing on the verification cached ${Math.floor(ageDays)} days ago — ${remaining} days of grace left`,
221
+ ...(cached.expiresAt === undefined ? {} : { expiresAt: cached.expiresAt }),
222
+ };
223
+ }
224
+
225
+ /** The cache is a convenience, never a reason to fail a deploy that was allowed. */
226
+ async function cacheQuietly(cache: LicenseCache | undefined, value: LastVerified): Promise<void> {
227
+ if (cache === undefined) return;
228
+ try {
229
+ await cache.write(value);
230
+ } catch (error) {
231
+ console.warn(
232
+ ` could not cache the license verification: ${error instanceof Error ? error.message : String(error)}`,
233
+ );
234
+ }
235
+ }
236
+
237
+ async function readQuietly(cache: LicenseCache | undefined): Promise<LastVerified | undefined> {
238
+ if (cache === undefined) return undefined;
239
+ try {
240
+ return await cache.read();
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * What a dry run does instead: name the check, contact nobody.
248
+ *
249
+ * A `--dry-run` has no credentials and should reach no third party — least of
250
+ * all one that would record a deploy that is not happening.
251
+ */
252
+ export async function plannedLicenseCheck(
253
+ key: string | undefined,
254
+ options: VerifyLicenseOptions,
255
+ ): Promise<LicenseResult> {
256
+ return {
257
+ outcome: "skipped",
258
+ message:
259
+ key === undefined || key === ""
260
+ ? "no license.key in the instance file — nothing to verify"
261
+ : `dry run — would verify ${options.instanceId} (${options.version}) against ${licenseEndpoint(options.endpoint)}`,
262
+ };
263
+ }
264
+
265
+ async function failureText(response: Response): Promise<string> {
266
+ try {
267
+ const body = (await response.json()) as { message?: string };
268
+ return body.message ?? `HTTP ${response.status}`;
269
+ } catch {
270
+ return `HTTP ${response.status}`;
271
+ }
272
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Where this package's own files are, and where the CDK app lives.
3
+ *
4
+ * Deliberately separate from the instance's paths: everything here belongs to
5
+ * Foundation (the CDK app, the agent image's Dockerfile, the product skills the
6
+ * release ships), and everything about the deployment comes from the instance
7
+ * file the caller named. The two must never be confused — that confusion is
8
+ * exactly what `packages/infra` exists to remove.
9
+ *
10
+ * The package root is FOUND rather than counted out in `..`s, because this
11
+ * code runs from two layouts: `src/deploy/paths.ts` in the Foundation
12
+ * workspace, and a bundle at `dist/bin/*.js` in the package published to npm.
13
+ * A fixed number of parent directories is right in one and silently wrong in
14
+ * the other.
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { dirname, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ /** The deploy tool's own package name; how its root is recognized. */
21
+ export const PACKAGE_NAME = "@deployfoundation/foundation-deploy";
22
+
23
+ /** Nearest ancestor directory holding this package's `package.json`. */
24
+ function packageRoot(from: string): string {
25
+ let dir = from;
26
+ for (;;) {
27
+ const manifest = resolve(dir, "package.json");
28
+ if (existsSync(manifest)) {
29
+ try {
30
+ const { name } = JSON.parse(readFileSync(manifest, "utf8")) as { name?: string };
31
+ if (name === PACKAGE_NAME) return dir;
32
+ } catch {
33
+ // A malformed package.json above us is not ours; keep climbing.
34
+ }
35
+ }
36
+ const parent = dirname(dir);
37
+ if (parent === dir)
38
+ throw new Error(`cannot find the ${PACKAGE_NAME} package root above ${from}`);
39
+ dir = parent;
40
+ }
41
+ }
42
+
43
+ /** The infra package root: where `cdk.json` sits, so `cdk` runs from here. */
44
+ export const INFRA_ROOT = packageRoot(dirname(fileURLToPath(import.meta.url)));
45
+
46
+ /**
47
+ * The Foundation checkout this package sits in — `<root>/packages/infra` →
48
+ * `<root>`.
49
+ *
50
+ * In the published package there is no such checkout: the two levels up are
51
+ * `node_modules/@deployfoundation`, which holds no `packages/gateway`. That absence
52
+ * is the signal `release.ts` uses to decide that a bare `deploy` deploys a
53
+ * release rather than a local build.
54
+ */
55
+ export const FOUNDATION_ROOT = resolve(INFRA_ROOT, "..", "..");
56
+
57
+ /** The agent container's Dockerfile, relative to {@link FOUNDATION_ROOT}. */
58
+ export const AGENT_DOCKERFILE = "packages/infra/agent-image/Dockerfile";
59
+
60
+ /**
61
+ * Files this package ships and reads at runtime — the Slack and GitHub App
62
+ * manifest templates. They are packaged under `src/deploy/assets`, which is
63
+ * published as-is, so this one path works from both layouts.
64
+ */
65
+ export const PACKAGE_ASSETS = resolve(INFRA_ROOT, "src", "deploy", "assets");
@@ -0,0 +1,97 @@
1
+ /**
2
+ * What happens after `deploy` succeeds: prove the new runtime version answers,
3
+ * then move the pinned `live` endpoint onto it and prove that answers too.
4
+ *
5
+ * foundation-deploy post-deploy --instance .foundation/instance.yaml
6
+ *
7
+ * It is a command rather than pipeline shell because there is more than one
8
+ * deployer: a GitHub Actions workflow and a per-instance CodePipeline
9
+ * (`pipeline-stack.ts`) run the very same steps, and two copies of a promotion
10
+ * gate is one copy too many — a fix to one would silently not reach the other.
11
+ *
12
+ * The gate is the point. Humans talk to `live`, never DEFAULT, so a version
13
+ * that fails either probe is never promoted and a broken image never answers a
14
+ * real message. Any failure exits non-zero, which fails the deploy.
15
+ */
16
+ import { type AwsContext, stackOutput } from "./aws.ts";
17
+ import {
18
+ type EndpointRunner,
19
+ cliRunner,
20
+ currentRuntimeVersion,
21
+ promoteEndpoint,
22
+ runtimeIdFromArn,
23
+ smokeInvoke,
24
+ smokeSessionId,
25
+ } from "./endpoint.ts";
26
+
27
+ /** The endpoint humans talk to. */
28
+ export const LIVE = "live";
29
+
30
+ /**
31
+ * The filesystem probe's payload and what a healthy answer says. The persistent
32
+ * mount is where the agent's memory lives; a runtime that cannot write it looks
33
+ * fine to `_smoke_test` and forgets everything.
34
+ */
35
+ export const FS_PROBE_PAYLOAD = '{"_fs_probe":true}';
36
+ export const FS_PROBE_EXPECT = ['"writable":true'];
37
+
38
+ /**
39
+ * Smoke DEFAULT, probe the mount, promote `live`, smoke `live` — the order the
40
+ * workflow ran them in, with the same assertions.
41
+ *
42
+ * `runner` and `arn` are test seams: with both supplied nothing here talks to
43
+ * AWS, so the sequence can be asserted on argv alone.
44
+ */
45
+ export async function postDeploy(
46
+ ctx: AwsContext,
47
+ opts: { runner?: EndpointRunner; arn?: string; log?: (line: string) => void } = {},
48
+ ): Promise<void> {
49
+ const dry = ctx.dryRun === true;
50
+ const log = opts.log ?? ((line: string) => console.log(line));
51
+ const runner = opts.runner ?? cliRunner(ctx);
52
+ const arn =
53
+ opts.arn ??
54
+ (dry
55
+ ? `<${ctx.names.agent}.AgentRuntimeArn>`
56
+ : await stackOutput(ctx, ctx.names.agent, "AgentRuntimeArn"));
57
+ const id =
58
+ dry && opts.arn === undefined ? `<${ctx.names.agent}.runtimeId>` : runtimeIdFromArn(arn);
59
+ const version = dry ? "<version>" : await currentRuntimeVersion(runner, id);
60
+ log(`deployed runtime version ${version}`);
61
+
62
+ log("▶ smoke test — authenticated (new version)");
63
+ await smokeInvoke(runner, {
64
+ arn,
65
+ qualifier: "DEFAULT",
66
+ sessionId: smokeSessionId("ci-smoke"),
67
+ dryRun: ctx.dryRun,
68
+ log,
69
+ });
70
+
71
+ log("▶ smoke test — persistent mount writable (new version)");
72
+ await smokeInvoke(runner, {
73
+ arn,
74
+ qualifier: "DEFAULT",
75
+ sessionId: smokeSessionId("ci-probe"),
76
+ payload: FS_PROBE_PAYLOAD,
77
+ expect: FS_PROBE_EXPECT,
78
+ dryRun: ctx.dryRun,
79
+ log,
80
+ });
81
+
82
+ log(`▶ promote version ${version} to the ${LIVE} endpoint`);
83
+ await promoteEndpoint(runner, { id, name: LIVE, version, dryRun: ctx.dryRun, log });
84
+
85
+ // `live` is asserted on `authenticated` alone, as the workflow did: the
86
+ // version was already held to the stricter bar on DEFAULT a moment ago, and
87
+ // this call only has to show the endpoint now serves it.
88
+ log(`▶ smoke test — ${LIVE} endpoint answers`);
89
+ await smokeInvoke(runner, {
90
+ arn,
91
+ qualifier: LIVE,
92
+ sessionId: smokeSessionId("ci-live"),
93
+ expect: ['"authenticated":true'],
94
+ dryRun: ctx.dryRun,
95
+ log,
96
+ });
97
+ }