@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,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AgentCore runtime's endpoints, and the smoke invoke that decides whether
|
|
3
|
+
* a version deserves one.
|
|
4
|
+
*
|
|
5
|
+
* Humans never talk to DEFAULT: the invoker Lambda calls the pinned endpoint
|
|
6
|
+
* `live`. A deploy therefore smoke-tests the new version on DEFAULT and only
|
|
7
|
+
* then moves `live` onto it. `setup` (which has to create `live` in the first
|
|
8
|
+
* place), `deploy` and `post-deploy` all go through the helpers here, so there
|
|
9
|
+
* is one wait loop rather than three.
|
|
10
|
+
*
|
|
11
|
+
* Every call goes through an {@link EndpointRunner} so a test can hand in a
|
|
12
|
+
* double and assert on the argv instead of talking to AWS.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { type AwsCallContext, aws, awsMutate } from "./aws.ts";
|
|
18
|
+
|
|
19
|
+
export interface EndpointRunner {
|
|
20
|
+
/** Run an `aws` subcommand and return trimmed stdout. */
|
|
21
|
+
capture(args: string[]): Promise<string>;
|
|
22
|
+
/** Run a mutating `aws` subcommand (a dry run prints it instead). */
|
|
23
|
+
mutate(args: string[]): Promise<void>;
|
|
24
|
+
sleep(ms: number): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The runner that actually shells out, bound to a profile and region. */
|
|
28
|
+
export function cliRunner(ctx: AwsCallContext): EndpointRunner {
|
|
29
|
+
return {
|
|
30
|
+
capture: (args) => aws(ctx, args),
|
|
31
|
+
mutate: (args) => awsMutate(ctx, args),
|
|
32
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The runtime id is the last segment of the runtime ARN. */
|
|
37
|
+
export function runtimeIdFromArn(arn: string): string {
|
|
38
|
+
const id = arn.split("/").pop() ?? "";
|
|
39
|
+
if (id === "") throw new Error(`not an agent runtime arn: ${arn}`);
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EndpointState {
|
|
44
|
+
status: string;
|
|
45
|
+
liveVersion: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An endpoint's status and pinned version, or undefined when it does not exist. */
|
|
49
|
+
export async function endpointState(
|
|
50
|
+
runner: EndpointRunner,
|
|
51
|
+
id: string,
|
|
52
|
+
name: string,
|
|
53
|
+
): Promise<EndpointState | undefined> {
|
|
54
|
+
let text: string;
|
|
55
|
+
try {
|
|
56
|
+
text = await runner.capture([
|
|
57
|
+
"bedrock-agentcore-control",
|
|
58
|
+
"get-agent-runtime-endpoint",
|
|
59
|
+
"--agent-runtime-id",
|
|
60
|
+
id,
|
|
61
|
+
"--endpoint-name",
|
|
62
|
+
name,
|
|
63
|
+
"--query",
|
|
64
|
+
"[status, liveVersion]",
|
|
65
|
+
"--output",
|
|
66
|
+
"text",
|
|
67
|
+
]);
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const [status = "", liveVersion = ""] = text.trim().split(/\s+/);
|
|
72
|
+
return { status, liveVersion };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface EndpointOptions {
|
|
76
|
+
id: string;
|
|
77
|
+
/** Endpoint name; `live` everywhere. */
|
|
78
|
+
name: string;
|
|
79
|
+
version: string;
|
|
80
|
+
attempts?: number;
|
|
81
|
+
delayMs?: number;
|
|
82
|
+
dryRun?: boolean;
|
|
83
|
+
log?: (line: string) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Poll until the endpoint is READY on `version`. Throws on a failed status or a timeout. */
|
|
87
|
+
export async function waitForEndpoint(
|
|
88
|
+
runner: EndpointRunner,
|
|
89
|
+
opts: EndpointOptions,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const attempts = opts.attempts ?? 60;
|
|
92
|
+
const delayMs = opts.delayMs ?? 5000;
|
|
93
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
94
|
+
for (let i = 0; i < attempts; i++) {
|
|
95
|
+
const state = await endpointState(runner, opts.id, opts.name);
|
|
96
|
+
if (state !== undefined) {
|
|
97
|
+
log(` ${opts.name} endpoint: ${state.status} (version ${state.liveVersion})`);
|
|
98
|
+
if (state.status === "READY" && state.liveVersion === opts.version) return;
|
|
99
|
+
if (state.status.endsWith("_FAILED"))
|
|
100
|
+
throw new Error(`${opts.name} endpoint ${state.status}`);
|
|
101
|
+
}
|
|
102
|
+
await runner.sleep(delayMs);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(
|
|
105
|
+
`timed out waiting for the ${opts.name} endpoint to reach version ${opts.version}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create the endpoint pinned to `version` if it is not there yet. Idempotent:
|
|
111
|
+
* an existing endpoint is left exactly where it points (a deploy promotes it,
|
|
112
|
+
* `setup` must not move it backwards).
|
|
113
|
+
*/
|
|
114
|
+
export async function ensureEndpoint(
|
|
115
|
+
runner: EndpointRunner,
|
|
116
|
+
opts: EndpointOptions,
|
|
117
|
+
): Promise<"created" | "exists"> {
|
|
118
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
119
|
+
const create = [
|
|
120
|
+
"bedrock-agentcore-control",
|
|
121
|
+
"create-agent-runtime-endpoint",
|
|
122
|
+
"--agent-runtime-id",
|
|
123
|
+
opts.id,
|
|
124
|
+
"--name",
|
|
125
|
+
opts.name,
|
|
126
|
+
"--agent-runtime-version",
|
|
127
|
+
opts.version,
|
|
128
|
+
];
|
|
129
|
+
if (opts.dryRun === true) {
|
|
130
|
+
await runner.mutate(create);
|
|
131
|
+
return "created";
|
|
132
|
+
}
|
|
133
|
+
const state = await endpointState(runner, opts.id, opts.name);
|
|
134
|
+
if (state !== undefined) {
|
|
135
|
+
log(` ${opts.name} endpoint already exists: ${state.status} (version ${state.liveVersion})`);
|
|
136
|
+
return "exists";
|
|
137
|
+
}
|
|
138
|
+
await runner.mutate(create);
|
|
139
|
+
await waitForEndpoint(runner, opts);
|
|
140
|
+
return "created";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Point an existing endpoint at `version` and wait for it to be READY there. */
|
|
144
|
+
export async function promoteEndpoint(
|
|
145
|
+
runner: EndpointRunner,
|
|
146
|
+
opts: EndpointOptions,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
await runner.mutate([
|
|
149
|
+
"bedrock-agentcore-control",
|
|
150
|
+
"update-agent-runtime-endpoint",
|
|
151
|
+
"--agent-runtime-id",
|
|
152
|
+
opts.id,
|
|
153
|
+
"--endpoint-name",
|
|
154
|
+
opts.name,
|
|
155
|
+
"--agent-runtime-version",
|
|
156
|
+
opts.version,
|
|
157
|
+
]);
|
|
158
|
+
if (opts.dryRun === true) return;
|
|
159
|
+
await waitForEndpoint(runner, opts);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The version DEFAULT currently serves — the one a deploy just created. */
|
|
163
|
+
export async function currentRuntimeVersion(runner: EndpointRunner, id: string): Promise<string> {
|
|
164
|
+
const version = await runner.capture([
|
|
165
|
+
"bedrock-agentcore-control",
|
|
166
|
+
"get-agent-runtime",
|
|
167
|
+
"--agent-runtime-id",
|
|
168
|
+
id,
|
|
169
|
+
"--query",
|
|
170
|
+
"agentRuntimeVersion",
|
|
171
|
+
"--output",
|
|
172
|
+
"text",
|
|
173
|
+
]);
|
|
174
|
+
if (version === "" || version === "None") throw new Error(`runtime ${id} reports no version`);
|
|
175
|
+
return version;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What a healthy `_smoke_test` response says. `config_source` is here because
|
|
180
|
+
* a container that booted before its runtime secret named a bucket answers
|
|
181
|
+
* happily from the fallback config — a green smoke test hiding a teammate that
|
|
182
|
+
* knows none of its own configuration.
|
|
183
|
+
*/
|
|
184
|
+
export const SMOKE_EXPECT = ['"authenticated":true', '"config_source":"s3"'];
|
|
185
|
+
|
|
186
|
+
/** Session ids must be at least 33 characters; pad whatever the caller has. */
|
|
187
|
+
export function smokeSessionId(prefix: string): string {
|
|
188
|
+
return `${prefix}-${Math.floor(Date.now() / 1000)}`.padEnd(40, "0");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface SmokeOptions {
|
|
192
|
+
arn: string;
|
|
193
|
+
sessionId: string;
|
|
194
|
+
qualifier?: string;
|
|
195
|
+
/** The invoke payload; `{"_smoke_test":true}` unless a probe overrides it. */
|
|
196
|
+
payload?: string;
|
|
197
|
+
/** Substrings the response must contain. */
|
|
198
|
+
expect?: string[];
|
|
199
|
+
dryRun?: boolean;
|
|
200
|
+
log?: (line: string) => void;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Invoke the runtime and assert the response says what a healthy runtime says. */
|
|
204
|
+
export async function smokeInvoke(runner: EndpointRunner, opts: SmokeOptions): Promise<void> {
|
|
205
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
206
|
+
const payload = opts.payload ?? '{"_smoke_test":true}';
|
|
207
|
+
const args = [
|
|
208
|
+
"bedrock-agentcore",
|
|
209
|
+
"invoke-agent-runtime",
|
|
210
|
+
"--agent-runtime-arn",
|
|
211
|
+
opts.arn,
|
|
212
|
+
...(opts.qualifier === undefined ? [] : ["--qualifier", opts.qualifier]),
|
|
213
|
+
"--runtime-session-id",
|
|
214
|
+
opts.sessionId,
|
|
215
|
+
// aws CLI v2 expects blob params base64-encoded.
|
|
216
|
+
"--payload",
|
|
217
|
+
Buffer.from(payload).toString("base64"),
|
|
218
|
+
];
|
|
219
|
+
if (opts.dryRun === true) {
|
|
220
|
+
await runner.mutate([...args, "/dev/stdout"]);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// A real file, not `/dev/stdout`: when this process's stdout is a captured
|
|
224
|
+
// pipe or socket (GitHub Actions, CodeBuild), the CLI's open() of
|
|
225
|
+
// /dev/stdout fails with "No such device or address" (2026-09-07). The
|
|
226
|
+
// CLI's own stdout carries nothing useful for an outfile invoke.
|
|
227
|
+
const dir = await mkdtemp(join(tmpdir(), "foundation-smoke-"));
|
|
228
|
+
const outFile = join(dir, "response.json");
|
|
229
|
+
let out: string;
|
|
230
|
+
try {
|
|
231
|
+
// The real CLI writes the outfile and prints nothing; a runner double in
|
|
232
|
+
// tests answers on stdout and writes no file. Prefer the file, fall back
|
|
233
|
+
// to stdout, so both paths read the same response.
|
|
234
|
+
const printed = await runner.capture([...args, outFile]);
|
|
235
|
+
out = (await readFile(outFile, "utf8").catch(() => "")) || printed;
|
|
236
|
+
} finally {
|
|
237
|
+
await rm(dir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
log(` ${out.replaceAll("\n", "\n ")}`);
|
|
240
|
+
for (const needle of opts.expect ?? SMOKE_EXPECT)
|
|
241
|
+
if (!out.includes(needle)) throw new Error(`smoke test: response does not contain ${needle}`);
|
|
242
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create an instance's GitHub App on its org via GitHub's App Manifest flow.
|
|
3
|
+
*
|
|
4
|
+
* foundation-deploy github-app-create --instance .foundation/instance.yaml
|
|
5
|
+
* foundation-deploy github-app-create --instance … --dry-run
|
|
6
|
+
*
|
|
7
|
+
* `--dry-run` renders the shipped manifest template for the instance and
|
|
8
|
+
* prints it without serving anything or touching GitHub.
|
|
9
|
+
*
|
|
10
|
+
* The org, the secret id, the AWS profile and the region all default to the
|
|
11
|
+
* instance's; `--org`, `--secret`, `--profile` and `--region` still override.
|
|
12
|
+
*
|
|
13
|
+
* 1. Serves http://localhost:<port>/ with an auto-submitting form that POSTs
|
|
14
|
+
* the rendered manifest (as JSON) to GitHub's "new app from manifest" page.
|
|
15
|
+
* 2. You click "Create GitHub App"; GitHub redirects to /callback?code=…
|
|
16
|
+
* 3. Exchanges the single-use code (POST /app-manifests/{code}/conversions —
|
|
17
|
+
* no auth needed) for {id, slug, pem, html_url}.
|
|
18
|
+
* 4. Writes {app_id, private_key} to Secrets Manager via the aws CLI, PEM over
|
|
19
|
+
* stdin (never argv / never printed), then prints the install URL.
|
|
20
|
+
*
|
|
21
|
+
* Record the installation_id in the same secret after installing the App.
|
|
22
|
+
*/
|
|
23
|
+
import { instanceNames } from "@deployfoundation/foundation-core/instance";
|
|
24
|
+
import { loadAppManifest } from "./github-app-manifest.ts";
|
|
25
|
+
import type { InstanceContext } from "./instance.ts";
|
|
26
|
+
|
|
27
|
+
export interface GithubAppCreateOptions {
|
|
28
|
+
paths: InstanceContext;
|
|
29
|
+
org?: string;
|
|
30
|
+
port?: number;
|
|
31
|
+
secret?: string;
|
|
32
|
+
profile?: string;
|
|
33
|
+
region?: string;
|
|
34
|
+
dryRun?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function githubAppCreate(options: GithubAppCreateOptions): Promise<void> {
|
|
38
|
+
const instance = options.paths.instance;
|
|
39
|
+
const names = instanceNames(instance);
|
|
40
|
+
const org = options.org ?? instance.github.org;
|
|
41
|
+
const port = options.port ?? 8765;
|
|
42
|
+
const secretName = options.secret ?? names.secretGithubApp;
|
|
43
|
+
const profile = options.profile ?? instance.aws.profile;
|
|
44
|
+
const region = options.region ?? instance.aws.region;
|
|
45
|
+
|
|
46
|
+
// The manifest is a template: its name, url and description are this
|
|
47
|
+
// instance's. The permission set comes from the capability registry.
|
|
48
|
+
const manifest = loadAppManifest(instance);
|
|
49
|
+
if (options.dryRun === true) {
|
|
50
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const redirectUrl = `http://localhost:${port}/callback`;
|
|
54
|
+
const state = crypto.randomUUID();
|
|
55
|
+
const body = JSON.stringify({ ...manifest, redirect_url: redirectUrl });
|
|
56
|
+
|
|
57
|
+
async function writeSecret(value: string): Promise<void> {
|
|
58
|
+
const run = async (cmd: string[]) => {
|
|
59
|
+
const p = Bun.spawn(["aws", ...cmd, "--profile", profile, "--region", region], {
|
|
60
|
+
stdin: "pipe",
|
|
61
|
+
stdout: "pipe",
|
|
62
|
+
stderr: "pipe",
|
|
63
|
+
});
|
|
64
|
+
p.stdin.write(value);
|
|
65
|
+
p.stdin.end();
|
|
66
|
+
const code = await p.exited;
|
|
67
|
+
return { code, err: await new Response(p.stderr).text() };
|
|
68
|
+
};
|
|
69
|
+
let r = await run([
|
|
70
|
+
"secretsmanager",
|
|
71
|
+
"create-secret",
|
|
72
|
+
"--name",
|
|
73
|
+
secretName,
|
|
74
|
+
"--description",
|
|
75
|
+
`${instance.displayName} GitHub App: app_id, installation_id, private_key`,
|
|
76
|
+
"--secret-string",
|
|
77
|
+
"file:///dev/stdin",
|
|
78
|
+
]);
|
|
79
|
+
if (r.code !== 0 && r.err.includes("ResourceExistsException"))
|
|
80
|
+
r = await run([
|
|
81
|
+
"secretsmanager",
|
|
82
|
+
"put-secret-value",
|
|
83
|
+
"--secret-id",
|
|
84
|
+
secretName,
|
|
85
|
+
"--secret-string",
|
|
86
|
+
"file:///dev/stdin",
|
|
87
|
+
]);
|
|
88
|
+
if (r.code !== 0) throw new Error(`aws secretsmanager failed: ${r.err.trim()}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const server = Bun.serve({
|
|
92
|
+
port,
|
|
93
|
+
async fetch(req) {
|
|
94
|
+
const url = new URL(req.url);
|
|
95
|
+
if (url.pathname === "/") {
|
|
96
|
+
const html = `<!doctype html><title>Create ${instance.displayName} GitHub App</title><body style="font-family:system-ui;padding:2rem">
|
|
97
|
+
<h2>Creating the <b>${instance.displayName}</b> GitHub App on <b>${org}</b>…</h2><p>If nothing happens, click the button.</p>
|
|
98
|
+
<form id="f" method="post" action="https://github.com/organizations/${org}/settings/apps/new?state=${state}">
|
|
99
|
+
<input type="hidden" name="manifest" id="m"><button>Create GitHub App</button></form>
|
|
100
|
+
<script>document.getElementById('m').value=${JSON.stringify(body)};document.getElementById('f').submit();</script></body>`;
|
|
101
|
+
return new Response(html, { headers: { "content-type": "text/html" } });
|
|
102
|
+
}
|
|
103
|
+
if (url.pathname === "/callback") {
|
|
104
|
+
const code = url.searchParams.get("code");
|
|
105
|
+
if (url.searchParams.get("state") !== state || code === null)
|
|
106
|
+
return new Response("bad state/code", { status: 400 });
|
|
107
|
+
const res = await fetch(
|
|
108
|
+
`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`,
|
|
109
|
+
{
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
Accept: "application/vnd.github+json",
|
|
113
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
if (!res.ok) return new Response(`conversion failed: HTTP ${res.status}`, { status: 502 });
|
|
118
|
+
const app = (await res.json()) as {
|
|
119
|
+
id: number;
|
|
120
|
+
slug: string;
|
|
121
|
+
pem: string;
|
|
122
|
+
html_url: string;
|
|
123
|
+
};
|
|
124
|
+
await writeSecret(
|
|
125
|
+
JSON.stringify({ app_id: String(app.id), installation_id: "", private_key: app.pem }),
|
|
126
|
+
);
|
|
127
|
+
const installUrl = `https://github.com/apps/${app.slug}/installations/new`;
|
|
128
|
+
console.log(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
app_id: app.id,
|
|
131
|
+
slug: app.slug,
|
|
132
|
+
html_url: app.html_url,
|
|
133
|
+
install_url: installUrl,
|
|
134
|
+
secret: secretName,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
server.stop(true);
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}, 500);
|
|
141
|
+
return new Response(
|
|
142
|
+
`<!doctype html><body style="font-family:system-ui;padding:2rem"><h2>✅ App "${app.slug}" created (id ${app.id}); private key stored in Secrets Manager ${secretName}.</h2><p>Next: <a href="${installUrl}">install it on your repos</a>.</p></body>`,
|
|
143
|
+
{ headers: { "content-type": "text/html" } },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return new Response("not found", { status: 404 });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
console.log(`Open http://localhost:${port}/ to create the app (waiting up to 10 minutes)…`);
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
console.error("timed out");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}, 10 * 60_000);
|
|
154
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the GitHub App manifest template for one instance.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is a template shipped with this package: the App's name,
|
|
5
|
+
* user-facing URL and description identify the instance, so Apps remain
|
|
6
|
+
* distinguishable. Permissions are NOT in the template — they come from the
|
|
7
|
+
* capability registry (`requiredGithubPermissions`), plus `workflows: write`
|
|
8
|
+
* for instances that explicitly opt in per repo.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { requiredGithubPermissions } from "@deployfoundation/foundation-core";
|
|
13
|
+
import { type Instance, instanceNames } from "@deployfoundation/foundation-core/instance";
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
import { PACKAGE_ASSETS } from "./paths.ts";
|
|
16
|
+
|
|
17
|
+
/** The GitHub App manifest template this package ships. */
|
|
18
|
+
export const GITHUB_APP_MANIFEST_PATH = join(PACKAGE_ASSETS, "github-app-manifest.yml");
|
|
19
|
+
|
|
20
|
+
/** The App's homepage: the repo the agent works in, else the org itself. */
|
|
21
|
+
export function manifestUrl(instance: Instance): string {
|
|
22
|
+
const repo = instance.github.defaultRepo ?? instanceNames(instance).defaultRepo;
|
|
23
|
+
return `https://github.com/${repo === "" ? instance.github.org : repo}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Substitute `${displayName}` and `${url}`; returns the rendered YAML text. */
|
|
27
|
+
export function renderAppManifest(template: string, instance: Instance): string {
|
|
28
|
+
const values: Record<string, string> = {
|
|
29
|
+
displayName: instance.displayName,
|
|
30
|
+
url: manifestUrl(instance),
|
|
31
|
+
};
|
|
32
|
+
return template.replace(/\$\{(\w+)\}/g, (match, key: string) => {
|
|
33
|
+
const value = values[key];
|
|
34
|
+
if (value === undefined) throw new Error(`app-manifest.yml: unknown placeholder ${match}`);
|
|
35
|
+
return value;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the template and render it into the object GitHub is posted. */
|
|
40
|
+
export function loadAppManifest(instance: Instance, template?: string): Record<string, unknown> {
|
|
41
|
+
const text = template ?? readFileSync(GITHUB_APP_MANIFEST_PATH, "utf8");
|
|
42
|
+
const manifest = parseYaml(renderAppManifest(text, instance)) as Record<string, unknown>;
|
|
43
|
+
if ("default_permissions" in manifest)
|
|
44
|
+
throw new Error(
|
|
45
|
+
"app-manifest.yml: default_permissions are generated from the capability registry; remove them from the template",
|
|
46
|
+
);
|
|
47
|
+
// The App exists for the `github` capability; its permission set is that
|
|
48
|
+
// capability's declaration plus the per-instance workflow opt-in.
|
|
49
|
+
manifest.default_permissions = requiredGithubPermissions(["github"], {
|
|
50
|
+
workflowWrites: (instance.github.workflowWriteRepos?.length ?? 0) > 0,
|
|
51
|
+
});
|
|
52
|
+
return manifest;
|
|
53
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent image tag. One tag per commit so a deploy is traceable to source;
|
|
3
|
+
* a dirty tree gets a `-dirty` suffix, which the ECR repo's IMMUTABLE tags
|
|
4
|
+
* would reject on a second push — that is deliberate: commit, then deploy.
|
|
5
|
+
*/
|
|
6
|
+
import { type AwsCallContext, argv, aws } from "./aws.ts";
|
|
7
|
+
import { runCapture } from "./sh.ts";
|
|
8
|
+
|
|
9
|
+
/** Tag for a commit: the short sha, `-dirty` when the tree has changes. */
|
|
10
|
+
export function imageTag(shortSha: string, dirty: boolean): string {
|
|
11
|
+
const sha = shortSha.trim();
|
|
12
|
+
if (!/^[0-9a-f]{7,40}$/.test(sha)) throw new Error(`not a git short sha: ${JSON.stringify(sha)}`);
|
|
13
|
+
return dirty ? `${sha}-dirty` : sha;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The registry host of an ECR repository URI (`<acct>.dkr.ecr.<r>.amazonaws.com`). */
|
|
17
|
+
export function registryOf(repositoryUri: string): string {
|
|
18
|
+
const host = repositoryUri.split("/")[0];
|
|
19
|
+
if (host === undefined || !host.includes(".dkr.ecr."))
|
|
20
|
+
throw new Error(`not an ECR repository URI: ${repositoryUri}`);
|
|
21
|
+
return host;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The commit a CI system says it checked out, when it says so. CodeBuild
|
|
26
|
+
* receives CodePipeline's source as a zip WITHOUT `.git` (a pipeline's first
|
|
27
|
+
* run failed on `git rev-parse` for exactly that reason, 2026-09-07) but sets
|
|
28
|
+
* `CODEBUILD_RESOLVED_SOURCE_VERSION` to the full sha; GitHub Actions sets
|
|
29
|
+
* `GITHUB_SHA`. Either is the same 7-character tag `git rev-parse --short`
|
|
30
|
+
* would give, and a CI checkout is never dirty.
|
|
31
|
+
*/
|
|
32
|
+
export function ciCommitSha(env: Record<string, string | undefined> = process.env): string | null {
|
|
33
|
+
const sha = env.CODEBUILD_RESOLVED_SOURCE_VERSION ?? env.GITHUB_SHA ?? "";
|
|
34
|
+
return /^[0-9a-f]{7,40}$/.test(sha) ? sha.slice(0, 7) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve the tag for the working tree at `repoRoot`. */
|
|
38
|
+
export async function currentImageTag(repoRoot: string): Promise<string> {
|
|
39
|
+
const ci = ciCommitSha();
|
|
40
|
+
if (ci !== null) return imageTag(ci, false);
|
|
41
|
+
const sha = await runCapture(["git", "rev-parse", "--short", "HEAD"], { cwd: repoRoot });
|
|
42
|
+
const status = await runCapture(["git", "status", "--porcelain"], { cwd: repoRoot });
|
|
43
|
+
return imageTag(sha, status !== "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Is this tag already in the repository? ECR tags are IMMUTABLE here, so a
|
|
48
|
+
* push of one that exists fails ("cannot be overwritten") — which is what a
|
|
49
|
+
* re-run of a failed deploy job does, since the tag is the commit sha. The
|
|
50
|
+
* image is byte-identical anyway, so the answer is to skip the push, not to
|
|
51
|
+
* force it.
|
|
52
|
+
*
|
|
53
|
+
* A dry run prints the check and answers "absent", so the plan it prints is
|
|
54
|
+
* the full build.
|
|
55
|
+
*/
|
|
56
|
+
export async function ecrImageExists(
|
|
57
|
+
ctx: AwsCallContext,
|
|
58
|
+
opts: { repositoryName: string; tag: string },
|
|
59
|
+
): Promise<boolean> {
|
|
60
|
+
const args = [
|
|
61
|
+
"ecr",
|
|
62
|
+
"describe-images",
|
|
63
|
+
"--repository-name",
|
|
64
|
+
opts.repositoryName,
|
|
65
|
+
"--image-ids",
|
|
66
|
+
`imageTag=${opts.tag}`,
|
|
67
|
+
"--output",
|
|
68
|
+
"text",
|
|
69
|
+
];
|
|
70
|
+
if (ctx.dryRun === true) {
|
|
71
|
+
console.log(` $ ${argv(ctx, args).join(" ")}`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await aws(ctx, args);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a deploy command decides which deployment it is talking to.
|
|
3
|
+
*
|
|
4
|
+
* One input: `--instance <path>`, the path to the deployment's instance YAML.
|
|
5
|
+
* There is no instance *name* to resolve, no `instances/` directory to search,
|
|
6
|
+
* no `FOUNDATION_INSTANCE`, and no default. Foundation ships no instance file; the
|
|
7
|
+
* deployment's own repository owns it, and everything else — the runtime
|
|
8
|
+
* config, the skills directory, the staging path — is resolved relative to the
|
|
9
|
+
* directory that file sits in.
|
|
10
|
+
*
|
|
11
|
+
* That directory is the "instance root" below. An instance repository whose
|
|
12
|
+
* layout is `.foundation/{instance.yaml,config.yaml,skills/}` therefore needs
|
|
13
|
+
* no configuration beyond `--instance .foundation/instance.yaml`.
|
|
14
|
+
*/
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
type Instance,
|
|
18
|
+
type InstanceNames,
|
|
19
|
+
instanceNames,
|
|
20
|
+
} from "@deployfoundation/foundation-core/instance";
|
|
21
|
+
import { type InstanceFile, loadInstanceFile } from "../names.ts";
|
|
22
|
+
|
|
23
|
+
export { instanceNames };
|
|
24
|
+
export type { Instance, InstanceNames, InstanceFile };
|
|
25
|
+
|
|
26
|
+
/** Env var the CodeBuild pipeline passes instead of a flag. */
|
|
27
|
+
export const INSTANCE_FILE_ENV = "FOUNDATION_INSTANCE_FILE";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The instance file path a command was given: `--instance <path>` (or
|
|
31
|
+
* `--instance=<path>`), else {@link INSTANCE_FILE_ENV}. There is no fallback
|
|
32
|
+
* beyond that on purpose — a deploy that guessed its target would be a deploy
|
|
33
|
+
* into the wrong account.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveInstanceFilePath(
|
|
36
|
+
argv: readonly string[] = [],
|
|
37
|
+
env: Record<string, string | undefined> = {},
|
|
38
|
+
): string {
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const arg = argv[i] ?? "";
|
|
41
|
+
if (arg === "--instance") {
|
|
42
|
+
const next = argv[i + 1];
|
|
43
|
+
if (next === undefined || next.startsWith("-"))
|
|
44
|
+
throw new Error("--instance needs a path, e.g. --instance .foundation/instance.yaml");
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
if (arg.startsWith("--instance=")) {
|
|
48
|
+
const value = arg.slice("--instance=".length);
|
|
49
|
+
if (value === "") throw new Error("--instance needs a path");
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const fromEnv = env[INSTANCE_FILE_ENV];
|
|
54
|
+
if (fromEnv !== undefined && fromEnv !== "") return fromEnv;
|
|
55
|
+
throw new Error(
|
|
56
|
+
`--instance <path> is required (or set ${INSTANCE_FILE_ENV}); it is the path to the deployment's instance YAML`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Everything a command needs about where its deployment's files live. */
|
|
61
|
+
export interface InstanceContext extends InstanceFile {
|
|
62
|
+
/** Directory the instance file sits in; every other path resolves against it. */
|
|
63
|
+
root: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Load the instance a command targets, from an explicit path. */
|
|
67
|
+
export function loadInstanceContext(path: string, cwd: string = process.cwd()): InstanceContext {
|
|
68
|
+
const file = loadInstanceFile(path, cwd);
|
|
69
|
+
return { ...file, root: dirname(file.path) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load from argv/env. Commands call this once, at the top, so a bad or missing
|
|
74
|
+
* `--instance` fails before anything touches AWS.
|
|
75
|
+
*/
|
|
76
|
+
export function loadInstanceFromArgs(
|
|
77
|
+
argv: readonly string[] = process.argv.slice(2),
|
|
78
|
+
env: Record<string, string | undefined> = process.env,
|
|
79
|
+
): InstanceContext {
|
|
80
|
+
return loadInstanceContext(resolveInstanceFilePath(argv, env));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The banner every command prints before it touches AWS. */
|
|
84
|
+
export function instanceBanner(context: InstanceContext): string {
|
|
85
|
+
const { instance } = context;
|
|
86
|
+
return `instance: ${instance.name} (account ${instance.aws.account}, region ${instance.aws.region})\n file: ${context.path}\n config: ${context.configPath}`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an instance keeps the last time its license verified.
|
|
3
|
+
*
|
|
4
|
+
* `<prefix>/license/last-verified` in the INSTANCE's own Secrets Manager —
|
|
5
|
+
* the customer's account, readable by the customer. It holds a timestamp and
|
|
6
|
+
* an expiry, never the key, and it exists so that an outage at Foundry 41
|
|
7
|
+
* cannot stop a customer deploying for up to 30 days (`license.ts`).
|
|
8
|
+
*
|
|
9
|
+
* No stack creates it: an instance with no license has no reason to carry an
|
|
10
|
+
* empty secret, so the deploy creates it the first time a check succeeds. The
|
|
11
|
+
* deploy role is granted create/read/write on that one secret id, and only for
|
|
12
|
+
* an instance whose file declares a license (`deploy-permissions.ts`).
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
type AwsContext,
|
|
16
|
+
createSecretString,
|
|
17
|
+
putSecretString,
|
|
18
|
+
readSecretString,
|
|
19
|
+
secretExists,
|
|
20
|
+
} from "./aws.ts";
|
|
21
|
+
import type { LastVerified, LicenseCache } from "./license.ts";
|
|
22
|
+
|
|
23
|
+
export function secretsManagerLicenseCache(ctx: AwsContext): LicenseCache {
|
|
24
|
+
const secretId = ctx.names.secretLicenseLastVerified;
|
|
25
|
+
return {
|
|
26
|
+
async read(): Promise<LastVerified | undefined> {
|
|
27
|
+
if (ctx.dryRun === true) return undefined;
|
|
28
|
+
if (!(await secretExists(ctx, secretId))) return undefined;
|
|
29
|
+
const text = await readSecretString(ctx, secretId);
|
|
30
|
+
const value = JSON.parse(text) as LastVerified;
|
|
31
|
+
return typeof value.verifiedAt === "string" ? value : undefined;
|
|
32
|
+
},
|
|
33
|
+
async write(value: LastVerified): Promise<void> {
|
|
34
|
+
const json = JSON.stringify(value);
|
|
35
|
+
if (ctx.dryRun !== true && !(await secretExists(ctx, secretId))) {
|
|
36
|
+
await createSecretString(
|
|
37
|
+
ctx,
|
|
38
|
+
secretId,
|
|
39
|
+
json,
|
|
40
|
+
"Last successful Foundation license verification (no key material).",
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await putSecretString(ctx, secretId, json);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|