@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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build context a Lambda bundle is produced from: the Foundation
|
|
3
|
+
* workspace, and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* It is a copy rather than the checkout itself so the Docker builder never
|
|
6
|
+
* receives a developer's repository root, where ignored local credential files
|
|
7
|
+
* may sit. Symlinks are rejected outright rather than followed, because a
|
|
8
|
+
* symlink is how something outside the workspace would get into the image.
|
|
9
|
+
*/
|
|
10
|
+
import { copyFileSync, cpSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
const contextByRoot = new Map<string, string>();
|
|
15
|
+
const IGNORED_DIRECTORIES: Record<string, true> = {
|
|
16
|
+
".git": true,
|
|
17
|
+
"cdk.out": true,
|
|
18
|
+
dist: true,
|
|
19
|
+
node_modules: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Root files a frozen workspace install needs; every one must exist. */
|
|
23
|
+
const WORKSPACE_FILES = ["package.json", "bun.lock", "bunfig.toml", "tsconfig.json"] as const;
|
|
24
|
+
|
|
25
|
+
function copySourceTree(source: string, destination: string): void {
|
|
26
|
+
cpSync(source, destination, {
|
|
27
|
+
recursive: true,
|
|
28
|
+
filter(candidate) {
|
|
29
|
+
const relative = path.relative(source, candidate);
|
|
30
|
+
if (relative === "") return true;
|
|
31
|
+
if (relative.split(path.sep).some((component) => component in IGNORED_DIRECTORIES))
|
|
32
|
+
return false;
|
|
33
|
+
if (lstatSync(candidate).isSymbolicLink())
|
|
34
|
+
throw new Error(`Lambda bundle input contains a symlink: ${candidate}`);
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the minimal workspace required for frozen-install Lambda bundling and
|
|
42
|
+
* return its path. Cached per root: one synth bundles several entry points out
|
|
43
|
+
* of the same tree, and copying it once per Lambda would dominate the run.
|
|
44
|
+
*/
|
|
45
|
+
export function lambdaBundleContext(workspaceRoot: string): string {
|
|
46
|
+
const existing = contextByRoot.get(workspaceRoot);
|
|
47
|
+
if (existing !== undefined) return existing;
|
|
48
|
+
|
|
49
|
+
const context = mkdtempSync(path.join(tmpdir(), "foundation-lambda-bundle-"));
|
|
50
|
+
try {
|
|
51
|
+
for (const file of WORKSPACE_FILES)
|
|
52
|
+
copyFileSync(path.join(workspaceRoot, file), path.join(context, file));
|
|
53
|
+
// Every workspace, because `bun install --frozen-lockfile` refuses a
|
|
54
|
+
// workspace the lockfile names and the disk lacks — and because a proxy
|
|
55
|
+
// bundle pulls its capability package, which pulls core and connectors.
|
|
56
|
+
copySourceTree(path.join(workspaceRoot, "packages"), path.join(context, "packages"));
|
|
57
|
+
} catch (error) {
|
|
58
|
+
rmSync(context, { recursive: true, force: true });
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
contextByRoot.set(workspaceRoot, context);
|
|
63
|
+
return context;
|
|
64
|
+
}
|
package/src/names.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names for the CDK app, and how it finds the deployment it is synthesizing.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here identifies a deployment: `namesFor(instance)` derives every
|
|
5
|
+
* stack, secret, role, gateway and ECR name from the instance file the caller
|
|
6
|
+
* names, and the deploy tool imports the same function from core so the two
|
|
7
|
+
* cannot drift.
|
|
8
|
+
*
|
|
9
|
+
* The instance file is a PATH the caller supplies (`-c instanceFile=…`), not a
|
|
10
|
+
* name looked up in an `instances/` directory this package does not have.
|
|
11
|
+
* Foundation ships no instance file and no company configuration; an instance
|
|
12
|
+
* repository owns both, and its `config:` key resolves relative to the instance
|
|
13
|
+
* file's own directory rather than to a repository root.
|
|
14
|
+
*
|
|
15
|
+
* What remains below are genuine constants — strings that are the same for
|
|
16
|
+
* every deployment because they name an AWS-side thing.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
20
|
+
import { type CapabilityId, INTEGRATION_CAPABILITY_IDS } from "@deployfoundation/foundation-core";
|
|
21
|
+
import {
|
|
22
|
+
type Instance,
|
|
23
|
+
type InstanceNames,
|
|
24
|
+
instanceNames,
|
|
25
|
+
parseInstance,
|
|
26
|
+
} from "@deployfoundation/foundation-core/instance";
|
|
27
|
+
import { parse as parseYaml } from "yaml";
|
|
28
|
+
|
|
29
|
+
export { instanceNames as namesFor };
|
|
30
|
+
export type { Instance, InstanceNames };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One instance file, parsed, plus the paths everything else resolves against.
|
|
34
|
+
*
|
|
35
|
+
* Stacks take `instance` and, where they read runtime configuration at synth,
|
|
36
|
+
* `configPath`. Both travel explicitly rather than through a module-level repo
|
|
37
|
+
* root, because there is no repository root to assume: the file may sit
|
|
38
|
+
* anywhere the deployer's checkout puts it.
|
|
39
|
+
*/
|
|
40
|
+
export interface InstanceFile {
|
|
41
|
+
instance: Instance;
|
|
42
|
+
/** Absolute path of the instance YAML itself. */
|
|
43
|
+
path: string;
|
|
44
|
+
/** Absolute path of the runtime config `instance.config` names. */
|
|
45
|
+
configPath: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read and validate the instance file at `path`, absolute or relative to
|
|
50
|
+
* `cwd`. A plain file read — the CDK app must synthesize with no AWS
|
|
51
|
+
* credentials and no network.
|
|
52
|
+
*
|
|
53
|
+
* Unlike the sky-tags version this does not check the parsed `name` against a
|
|
54
|
+
* filename: the file no longer has to be called anything in particular, and
|
|
55
|
+
* the name is one of the values it declares rather than the key it was found
|
|
56
|
+
* under.
|
|
57
|
+
*/
|
|
58
|
+
export function loadInstanceFile(path: string, cwd: string = process.cwd()): InstanceFile {
|
|
59
|
+
const absolute = isAbsolute(path) ? path : resolve(cwd, path);
|
|
60
|
+
let text: string;
|
|
61
|
+
try {
|
|
62
|
+
text = readFileSync(absolute, "utf8");
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`no instance file at ${absolute}`);
|
|
65
|
+
}
|
|
66
|
+
const instance = parseInstance(text);
|
|
67
|
+
return { instance, path: absolute, configPath: configPathFor(instance, absolute) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where an instance's runtime config lives: `config:` resolved against the
|
|
72
|
+
* DIRECTORY OF THE INSTANCE FILE. An instance repository holding
|
|
73
|
+
* `instance.yaml` and `config.yaml` side by side therefore writes
|
|
74
|
+
* `config: config.yaml`, and moving the pair somewhere else needs no edit.
|
|
75
|
+
*/
|
|
76
|
+
export function configPathFor(instance: Instance, instanceFilePath: string): string {
|
|
77
|
+
return isAbsolute(instance.config)
|
|
78
|
+
? instance.config
|
|
79
|
+
: resolve(dirname(instanceFilePath), instance.config);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The runtime config document, read at synth. Never cached across paths. */
|
|
83
|
+
function configDocument(configPath: string): Record<string, unknown> {
|
|
84
|
+
const doc = parseYaml(readFileSync(configPath, "utf8")) as Record<string, unknown> | null;
|
|
85
|
+
return doc ?? {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Is `capability` switched ON in the instance's runtime config?
|
|
90
|
+
*
|
|
91
|
+
* Read at synth the same way {@link adminsFor} reads `admins`: the CDK app
|
|
92
|
+
* must synthesize with no AWS credentials and no network, so the file on disk
|
|
93
|
+
* is the only source. Defaults to `false`, matching the schema default for
|
|
94
|
+
* every opt-in capability — this is not the place to learn a capability's
|
|
95
|
+
* default, only whether an instance asked for it.
|
|
96
|
+
*
|
|
97
|
+
* Use it ONLY for a capability that needs infrastructure of its own (Mongo's
|
|
98
|
+
* 27017 egress). That makes turning such a capability ON a deploy rather than
|
|
99
|
+
* a config sync; turning one OFF still removes its tools on the next turn.
|
|
100
|
+
*/
|
|
101
|
+
export function capabilityEnabled(configPath: string, capability: CapabilityId): boolean {
|
|
102
|
+
const doc = configDocument(configPath) as {
|
|
103
|
+
capabilities?: Record<string, { enabled?: unknown } | undefined>;
|
|
104
|
+
};
|
|
105
|
+
const enabled = doc.capabilities?.[capability]?.enabled;
|
|
106
|
+
if (enabled !== undefined && typeof enabled !== "boolean")
|
|
107
|
+
throw new Error(`${configPath}: capabilities.${capability}.enabled must be a boolean`);
|
|
108
|
+
return enabled ?? false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether normal queue turns must be gateway-signed before the invoker accepts
|
|
113
|
+
* them.
|
|
114
|
+
*
|
|
115
|
+
* The switch is the capability registry, not a hand-kept list: an integration
|
|
116
|
+
* capability is one whose infrastructure the instance file opts into, and
|
|
117
|
+
* every one of them mints actor capabilities the agent must not be able to
|
|
118
|
+
* forge. A new integration capability is therefore covered by its registry
|
|
119
|
+
* entry alone.
|
|
120
|
+
*/
|
|
121
|
+
export function requiresAuthenticatedQueue(instance: Instance): boolean {
|
|
122
|
+
return enabledIntegrations(instance).length > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The integration capabilities this instance opted into, from the registry.
|
|
127
|
+
* `INTEGRATION_CAPABILITY_IDS` is the source of truth for which capabilities
|
|
128
|
+
* have an `integrations.<id>` switch at all.
|
|
129
|
+
*/
|
|
130
|
+
export function enabledIntegrations(instance: Instance): CapabilityId[] {
|
|
131
|
+
const integrations = instance.integrations as Record<string, boolean | undefined>;
|
|
132
|
+
return INTEGRATION_CAPABILITY_IDS.filter((id) => integrations[id] === true);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Does this instance provision `id`'s durable infrastructure?
|
|
137
|
+
*
|
|
138
|
+
* Every per-capability branch in the stacks asks this rather than reading
|
|
139
|
+
* `instance.integrations.<id>` directly, so the registry's `integration` flag
|
|
140
|
+
* is visibly what decides. An id with no registry entry, or one whose entry
|
|
141
|
+
* carries no `integration` flag, is a programming error rather than a silent
|
|
142
|
+
* `false`: it would otherwise quietly stop provisioning a capability's IAM.
|
|
143
|
+
*/
|
|
144
|
+
export function provisionsIntegration(instance: Instance, id: CapabilityId): boolean {
|
|
145
|
+
if (!INTEGRATION_CAPABILITY_IDS.includes(id))
|
|
146
|
+
throw new Error(`${id} is not an integration capability in the registry`);
|
|
147
|
+
return (instance.integrations as Record<string, boolean | undefined>)[id] === true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The instance's admin Slack user ids, as the CSV the gateway Lambda expects.
|
|
152
|
+
*
|
|
153
|
+
* `-c admins=…` overrides this; without it a bare `cdk synth` still produces
|
|
154
|
+
* the same list the running deployment has, because it comes from the very
|
|
155
|
+
* runtime config the agent reads at session start. No id is written down here.
|
|
156
|
+
*/
|
|
157
|
+
export function adminsFor(configPath: string): string {
|
|
158
|
+
const doc = configDocument(configPath) as { admins?: unknown };
|
|
159
|
+
const admins = doc.admins;
|
|
160
|
+
if (admins === undefined || admins === null) return "";
|
|
161
|
+
if (!Array.isArray(admins) || admins.some((id) => typeof id !== "string"))
|
|
162
|
+
throw new Error(`${configPath}: admins must be a list of Slack user ids`);
|
|
163
|
+
return (admins as string[]).join(",");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Target name; the MCP tool is exposed as `<target>___<tool>`. */
|
|
167
|
+
export const WEB_SEARCH_TARGET = "websearch";
|
|
168
|
+
export const WEB_SEARCH_CONNECTOR_VERSION = "1.2.0";
|
|
169
|
+
/** The runtime endpoint humans talk to; the pipeline promotes versions onto it after smoke tests. */
|
|
170
|
+
export const LIVE_ENDPOINT_NAME = "live";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real KMS client behind manifest signing and verification.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from `manifest.ts` so that module stays pure: a CDK synth reads
|
|
5
|
+
* manifests and must not drag an AWS client into the synth, and the manifest
|
|
6
|
+
* tests must not need one either.
|
|
7
|
+
*
|
|
8
|
+
* The key lives in the Foundation release account and the REGION comes from
|
|
9
|
+
* its ARN, not from the deployment: a customer deploying into `eu-west-1`
|
|
10
|
+
* still verifies against the key where it was created. Its key policy is what
|
|
11
|
+
* lets them — `kms:Verify` and `kms:GetPublicKey` for the registered customer
|
|
12
|
+
* accounts, exactly as the ECR repository policy lets them pull the image
|
|
13
|
+
* (`release-account-stack.ts`).
|
|
14
|
+
*/
|
|
15
|
+
import { KMSClient, SignCommand, VerifyCommand } from "@aws-sdk/client-kms";
|
|
16
|
+
import {
|
|
17
|
+
type KmsVerifier,
|
|
18
|
+
type ReleaseManifest,
|
|
19
|
+
type ReleaseSignature,
|
|
20
|
+
type SigningAlgorithm,
|
|
21
|
+
manifestSigningPayload,
|
|
22
|
+
} from "./manifest.ts";
|
|
23
|
+
|
|
24
|
+
/** Default signing algorithm for a new Foundation release key (RSA 4096). */
|
|
25
|
+
export const DEFAULT_SIGNING_ALGORITHM: SigningAlgorithm = "RSASSA_PSS_SHA_256";
|
|
26
|
+
|
|
27
|
+
/** `arn:aws:kms:<region>:<account>:key/<id>` → `<region>`. */
|
|
28
|
+
export function regionOfKeyArn(keyArn: string): string {
|
|
29
|
+
const region = keyArn.split(":")[3];
|
|
30
|
+
if (region === undefined || region === "")
|
|
31
|
+
throw new Error(`not a KMS key ARN: ${keyArn} (expected arn:aws:kms:<region>:<account>:key/…)`);
|
|
32
|
+
return region;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KmsOptions {
|
|
36
|
+
/** Named AWS profile, or `""`/omitted for the ambient credential chain. */
|
|
37
|
+
profile?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function client(keyArn: string, options: KmsOptions = {}): KMSClient {
|
|
41
|
+
const profile = options.profile ?? "";
|
|
42
|
+
return new KMSClient({
|
|
43
|
+
region: regionOfKeyArn(keyArn),
|
|
44
|
+
...(profile === "" ? {} : { profile }),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A {@link KmsVerifier} backed by the AWS SDK. */
|
|
49
|
+
export function kmsVerifier(keyArn: string, options: KmsOptions = {}): KmsVerifier {
|
|
50
|
+
const kms = client(keyArn, options);
|
|
51
|
+
return {
|
|
52
|
+
async verify(input) {
|
|
53
|
+
const result = await kms.send(new VerifyCommand(input));
|
|
54
|
+
return {
|
|
55
|
+
...(result.SignatureValid === undefined ? {} : { SignatureValid: result.SignatureValid }),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sign a freshly built manifest. Only the release workflow calls this, with
|
|
63
|
+
* the Foundation release role's credentials; a deployer never signs anything.
|
|
64
|
+
*/
|
|
65
|
+
export async function signManifest(
|
|
66
|
+
manifest: ReleaseManifest,
|
|
67
|
+
keyArn: string,
|
|
68
|
+
options: KmsOptions & { algorithm?: SigningAlgorithm } = {},
|
|
69
|
+
): Promise<ReleaseSignature> {
|
|
70
|
+
const algorithm = options.algorithm ?? DEFAULT_SIGNING_ALGORITHM;
|
|
71
|
+
const kms = client(keyArn, options);
|
|
72
|
+
const result = await kms.send(
|
|
73
|
+
new SignCommand({
|
|
74
|
+
KeyId: keyArn,
|
|
75
|
+
Message: manifestSigningPayload(manifest),
|
|
76
|
+
MessageType: "RAW",
|
|
77
|
+
SigningAlgorithm: algorithm,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
if (result.Signature === undefined) throw new Error(`KMS returned no signature for ${keyArn}`);
|
|
81
|
+
return {
|
|
82
|
+
kmsKeyArn: keyArn,
|
|
83
|
+
algorithm,
|
|
84
|
+
value: Buffer.from(result.Signature).toString("base64"),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release manifest: what `vX.Y.Z` *is*, and how a deployer proves it.
|
|
3
|
+
*
|
|
4
|
+
* Foundation publishes one immutable release per tag — an agent image in
|
|
5
|
+
* Foundation's own ECR, one Lambda bundle per entry point in the release
|
|
6
|
+
* bucket, and the product skills tarball — and this document is the list of
|
|
7
|
+
* their digests, signed with a KMS key in the Foundation account. It replaces
|
|
8
|
+
* `foundation.lock.json` and the tree-hash gate: pinning is the version, and
|
|
9
|
+
* integrity is the signature over these digests.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately pure. Nothing here talks to AWS or to the network: the builder
|
|
12
|
+
* hashes files on disk, and {@link verifyManifest} takes the KMS verifier and
|
|
13
|
+
* the artifact reader as arguments. `src/release/kms.ts` supplies the real
|
|
14
|
+
* ones; a test supplies fakes.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
/** `vMAJOR.MINOR.PATCH` — the Git tag a release is built from, verbatim. */
|
|
21
|
+
export const RELEASE_VERSION_RE = /^v\d+\.\d+\.\d+$/;
|
|
22
|
+
|
|
23
|
+
const Sha256 = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256");
|
|
24
|
+
|
|
25
|
+
const ReleaseArtifactSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
/** Key inside the release bucket, e.g. `releases/v0.1.0/lambda/gateway.zip`. */
|
|
28
|
+
key: z.string().min(1),
|
|
29
|
+
sha256: Sha256,
|
|
30
|
+
bytes: z.number().int().nonnegative(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How a manifest may be signed. Only asymmetric algorithms KMS offers for
|
|
36
|
+
* `Verify`, listed explicitly so a tampered manifest cannot name a weaker one
|
|
37
|
+
* — the signature is checked against the algorithm the *manifest* declares,
|
|
38
|
+
* so that field has to be constrained.
|
|
39
|
+
*/
|
|
40
|
+
export const SIGNING_ALGORITHMS = [
|
|
41
|
+
"RSASSA_PSS_SHA_256",
|
|
42
|
+
"RSASSA_PSS_SHA_384",
|
|
43
|
+
"RSASSA_PSS_SHA_512",
|
|
44
|
+
"RSASSA_PKCS1_V1_5_SHA_256",
|
|
45
|
+
"RSASSA_PKCS1_V1_5_SHA_384",
|
|
46
|
+
"RSASSA_PKCS1_V1_5_SHA_512",
|
|
47
|
+
"ECDSA_SHA_256",
|
|
48
|
+
"ECDSA_SHA_384",
|
|
49
|
+
"ECDSA_SHA_512",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export type SigningAlgorithm = (typeof SIGNING_ALGORITHMS)[number];
|
|
53
|
+
|
|
54
|
+
const ReleaseManifestSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
version: z.string().regex(RELEASE_VERSION_RE, "must be vMAJOR.MINOR.PATCH"),
|
|
57
|
+
/** The Foundation commit the release was built from. */
|
|
58
|
+
gitCommit: z.string().regex(/^[0-9a-f]{40}$/, "must be a full commit sha"),
|
|
59
|
+
createdAt: z.string().datetime(),
|
|
60
|
+
agentImage: z
|
|
61
|
+
.object({
|
|
62
|
+
/** ECR repository URI without a tag: `<account>.dkr.ecr.<region>.amazonaws.com/foundation/agent`. */
|
|
63
|
+
repository: z.string().min(1),
|
|
64
|
+
tag: z.string().min(1),
|
|
65
|
+
/** The manifest digest the runtime is pinned to. */
|
|
66
|
+
digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
67
|
+
})
|
|
68
|
+
.strict(),
|
|
69
|
+
/** One entry per `LAMBDA_ENTRY_POINTS` id. */
|
|
70
|
+
lambda: z.record(ReleaseArtifactSchema),
|
|
71
|
+
/** `skills.tar.gz`; no `bytes`, because nothing streams it in pieces. */
|
|
72
|
+
skills: z.object({ key: z.string().min(1), sha256: Sha256 }).strict(),
|
|
73
|
+
/**
|
|
74
|
+
* Absent only in the moment between building and signing. A deploy
|
|
75
|
+
* refuses an unsigned manifest — see {@link verifyManifest}.
|
|
76
|
+
*/
|
|
77
|
+
signature: z
|
|
78
|
+
.object({
|
|
79
|
+
kmsKeyArn: z.string().min(1),
|
|
80
|
+
algorithm: z.enum(SIGNING_ALGORITHMS),
|
|
81
|
+
/** Base64 of the raw KMS signature. */
|
|
82
|
+
value: z.string().min(1),
|
|
83
|
+
})
|
|
84
|
+
.strict()
|
|
85
|
+
.optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
|
|
89
|
+
export type ReleaseArtifact = z.infer<typeof ReleaseArtifactSchema>;
|
|
90
|
+
export type ReleaseManifest = z.infer<typeof ReleaseManifestSchema>;
|
|
91
|
+
export type ReleaseSignature = NonNullable<ReleaseManifest["signature"]>;
|
|
92
|
+
|
|
93
|
+
/** Parse and validate a manifest document. Throws on any schema violation. */
|
|
94
|
+
export function parseManifest(json: string | unknown): ReleaseManifest {
|
|
95
|
+
return ReleaseManifestSchema.parse(typeof json === "string" ? JSON.parse(json) : json);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Where a release's files live in the release bucket. One layout, one place. */
|
|
99
|
+
export function releasePrefix(version: string): string {
|
|
100
|
+
return `releases/${version}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function manifestKey(version: string): string {
|
|
104
|
+
return `${releasePrefix(version)}/manifest.json`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function lambdaKey(version: string, id: string): string {
|
|
108
|
+
return `${releasePrefix(version)}/lambda/${id}.zip`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function skillsKey(version: string): string {
|
|
112
|
+
return `${releasePrefix(version)}/skills.tar.gz`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Lowercase hex sha256 of some bytes. */
|
|
116
|
+
export function sha256Hex(bytes: Uint8Array | string): string {
|
|
117
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Lowercase hex sha256 of a file, read whole: release artifacts are megabytes, not gigabytes. */
|
|
121
|
+
export function sha256File(path: string): string {
|
|
122
|
+
return sha256Hex(readFileSync(path));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface BuildManifestInput {
|
|
126
|
+
version: string;
|
|
127
|
+
gitCommit: string;
|
|
128
|
+
/** Defaults to now; pinned by the tests so a manifest is reproducible. */
|
|
129
|
+
createdAt?: string;
|
|
130
|
+
agentImage: ReleaseManifest["agentImage"];
|
|
131
|
+
/** Lambda id → the built `.zip` on disk. The ids become the manifest's keys. */
|
|
132
|
+
lambda: Record<string, string>;
|
|
133
|
+
/** The product skills tarball on disk. */
|
|
134
|
+
skills: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Hash what was built and describe it. The result is unsigned: signing is a
|
|
139
|
+
* separate step because it needs AWS and this does not.
|
|
140
|
+
*/
|
|
141
|
+
export function buildManifest(input: BuildManifestInput): ReleaseManifest {
|
|
142
|
+
const lambda: Record<string, ReleaseArtifact> = {};
|
|
143
|
+
for (const [id, path] of Object.entries(input.lambda).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
144
|
+
lambda[id] = {
|
|
145
|
+
key: lambdaKey(input.version, id),
|
|
146
|
+
sha256: sha256File(path),
|
|
147
|
+
bytes: statSync(path).size,
|
|
148
|
+
};
|
|
149
|
+
return parseManifest({
|
|
150
|
+
version: input.version,
|
|
151
|
+
gitCommit: input.gitCommit,
|
|
152
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
153
|
+
agentImage: input.agentImage,
|
|
154
|
+
lambda,
|
|
155
|
+
skills: { key: skillsKey(input.version), sha256: sha256File(input.skills) },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The exact bytes a signature covers: the manifest without its `signature`,
|
|
161
|
+
* serialized canonically (object keys sorted, no insignificant whitespace).
|
|
162
|
+
*
|
|
163
|
+
* Canonical because the signer and the verifier are different programs on
|
|
164
|
+
* different machines, and `JSON.stringify` preserves insertion order — a
|
|
165
|
+
* round trip through S3 must not be able to change one byte of what was
|
|
166
|
+
* signed.
|
|
167
|
+
*/
|
|
168
|
+
export function manifestSigningPayload(manifest: ReleaseManifest): Uint8Array {
|
|
169
|
+
const { signature: _signature, ...unsigned } = manifest;
|
|
170
|
+
return new TextEncoder().encode(canonicalJson(unsigned));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** JSON with every object's keys in sorted order. Arrays keep their order. */
|
|
174
|
+
export function canonicalJson(value: unknown): string {
|
|
175
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
176
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
177
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
178
|
+
.filter(([, v]) => v !== undefined)
|
|
179
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
180
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What {@link verifyManifest} needs of a KMS client: one `Verify` call. */
|
|
184
|
+
export interface KmsVerifier {
|
|
185
|
+
verify(input: {
|
|
186
|
+
KeyId: string;
|
|
187
|
+
Message: Uint8Array;
|
|
188
|
+
MessageType: "RAW";
|
|
189
|
+
Signature: Uint8Array;
|
|
190
|
+
SigningAlgorithm: SigningAlgorithm;
|
|
191
|
+
}): Promise<{ SignatureValid?: boolean }>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface VerifyManifestOptions {
|
|
195
|
+
/** The key the deployer expects. A manifest naming another key is rejected. */
|
|
196
|
+
kmsKeyArn: string;
|
|
197
|
+
kms: KmsVerifier;
|
|
198
|
+
/**
|
|
199
|
+
* Reads one artifact's bytes by its manifest key. Omitted, only the
|
|
200
|
+
* signature is checked — which is what a `--dry-run` wants and what a
|
|
201
|
+
* deploy must NOT settle for.
|
|
202
|
+
*/
|
|
203
|
+
readArtifact?: (key: string) => Promise<Uint8Array>;
|
|
204
|
+
/** Refuse a manifest that is not this version (the version the caller asked for). */
|
|
205
|
+
expectVersion?: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Prove a manifest is Foundation's and that the bytes behind it are the ones
|
|
210
|
+
* it names. Throws with a specific message on the first failure; returns the
|
|
211
|
+
* parsed manifest.
|
|
212
|
+
*
|
|
213
|
+
* Order matters and is the point of this function: the signature is checked
|
|
214
|
+
* BEFORE anything is downloaded or deployed, because every other value in the
|
|
215
|
+
* document — the image digest included — is only as trustworthy as it is.
|
|
216
|
+
*/
|
|
217
|
+
export async function verifyManifest(
|
|
218
|
+
manifest: ReleaseManifest | unknown,
|
|
219
|
+
options: VerifyManifestOptions,
|
|
220
|
+
): Promise<ReleaseManifest> {
|
|
221
|
+
const parsed = parseManifest(manifest);
|
|
222
|
+
if (options.expectVersion !== undefined && parsed.version !== options.expectVersion)
|
|
223
|
+
throw new Error(
|
|
224
|
+
`release manifest is ${parsed.version}, not the requested ${options.expectVersion}`,
|
|
225
|
+
);
|
|
226
|
+
const signature = parsed.signature;
|
|
227
|
+
if (signature === undefined)
|
|
228
|
+
throw new Error(`release ${parsed.version} carries an unsigned manifest; refusing to deploy`);
|
|
229
|
+
if (signature.kmsKeyArn !== options.kmsKeyArn)
|
|
230
|
+
throw new Error(
|
|
231
|
+
`release ${parsed.version} is signed by ${signature.kmsKeyArn}, not the expected ${options.kmsKeyArn}`,
|
|
232
|
+
);
|
|
233
|
+
const result = await options.kms.verify({
|
|
234
|
+
KeyId: options.kmsKeyArn,
|
|
235
|
+
Message: manifestSigningPayload(parsed),
|
|
236
|
+
MessageType: "RAW",
|
|
237
|
+
Signature: Uint8Array.from(Buffer.from(signature.value, "base64")),
|
|
238
|
+
SigningAlgorithm: signature.algorithm,
|
|
239
|
+
});
|
|
240
|
+
if (result.SignatureValid !== true)
|
|
241
|
+
throw new Error(`release ${parsed.version}: KMS rejected the manifest signature`);
|
|
242
|
+
|
|
243
|
+
const readArtifact = options.readArtifact;
|
|
244
|
+
if (readArtifact === undefined) return parsed;
|
|
245
|
+
for (const [id, artifact] of Object.entries(parsed.lambda)) {
|
|
246
|
+
const bytes = await readArtifact(artifact.key);
|
|
247
|
+
if (bytes.byteLength !== artifact.bytes)
|
|
248
|
+
throw new Error(
|
|
249
|
+
`release ${parsed.version}: ${id} is ${bytes.byteLength} bytes, manifest says ${artifact.bytes}`,
|
|
250
|
+
);
|
|
251
|
+
assertDigest(`${parsed.version}: ${id}`, bytes, artifact.sha256);
|
|
252
|
+
}
|
|
253
|
+
assertDigest(
|
|
254
|
+
`${parsed.version}: skills`,
|
|
255
|
+
await readArtifact(parsed.skills.key),
|
|
256
|
+
parsed.skills.sha256,
|
|
257
|
+
);
|
|
258
|
+
return parsed;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function assertDigest(what: string, bytes: Uint8Array, expected: string): void {
|
|
262
|
+
const actual = sha256Hex(bytes);
|
|
263
|
+
if (actual !== expected)
|
|
264
|
+
throw new Error(`release ${what}: sha256 ${actual}, manifest says ${expected}`);
|
|
265
|
+
}
|