@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,224 @@
1
+ /**
2
+ * Validate and stage a tenant-owned runtime configuration.
3
+ *
4
+ * The instance repository's own config is never modified: CDK and deploy-time
5
+ * policy always read that file. Config sync alone consumes the staged runtime
6
+ * copy.
7
+ */
8
+ import {
9
+ type Stats,
10
+ lstatSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { dirname, isAbsolute, posix, relative, resolve, sep } from "node:path";
19
+ import { isDeepStrictEqual } from "node:util";
20
+ import {
21
+ type InstanceConfig,
22
+ parseInstanceConfig,
23
+ parseSchedule,
24
+ } from "@deployfoundation/foundation-core";
25
+ import { CUSTOMIZATION_ARTIFACT_NAME } from "@deployfoundation/foundation-core/instance";
26
+ import type { InstanceContext } from "./instance.ts";
27
+
28
+ export type CustomizationStageStatus = "not-configured" | "migration-fallback" | "staged";
29
+
30
+ export interface CustomizationStageResult {
31
+ status: CustomizationStageStatus;
32
+ runtimeConfigPath: string;
33
+ }
34
+
35
+ export interface StageCustomizationOptions {
36
+ /** The deployment being staged: its instance file, config path and root. */
37
+ paths: InstanceContext;
38
+ /** Injectable for tests; defaults to the build process environment. */
39
+ env?: Readonly<Record<string, string | undefined>>;
40
+ /** Injectable so optional migration fallbacks remain observable to callers. */
41
+ log?: (message: string) => void;
42
+ /** Validate and plan without deleting or writing the local handoff. */
43
+ dryRun?: boolean;
44
+ }
45
+
46
+ function sourceRootFor(sourceDir: string | undefined): string {
47
+ if (sourceDir === undefined || sourceDir === "")
48
+ throw new Error("Customization source is missing.");
49
+ let sourceStats: Stats;
50
+ try {
51
+ sourceStats = statSync(sourceDir);
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
54
+ throw new Error("Customization source is missing.");
55
+ throw error;
56
+ }
57
+ if (!sourceStats.isDirectory()) throw new Error("Customization source must be a directory.");
58
+ return realpathSync(sourceDir);
59
+ }
60
+
61
+ function isNormalizedRelativePosixPath(path: string): boolean {
62
+ return (
63
+ path.length > 0 &&
64
+ !path.includes("\\") &&
65
+ !posix.isAbsolute(path) &&
66
+ posix.normalize(path) === path &&
67
+ path !== "." &&
68
+ path.split("/").every((part) => part !== "" && part !== "." && part !== "..")
69
+ );
70
+ }
71
+ function assertCustomizationPath(path: string, label = "Customization path"): void {
72
+ if (!isNormalizedRelativePosixPath(path))
73
+ throw new Error(`${label} must be a normalized relative POSIX path.`);
74
+ }
75
+
76
+ function isWithin(root: string, candidate: string): boolean {
77
+ const pathFromRoot = relative(root, candidate);
78
+ return (
79
+ pathFromRoot !== "" &&
80
+ pathFromRoot !== ".." &&
81
+ !pathFromRoot.startsWith(`..${sep}`) &&
82
+ !isAbsolute(pathFromRoot)
83
+ );
84
+ }
85
+
86
+ /** Resolve a validated POSIX path and defend again against traversal at use time. */
87
+ function customizationFile(sourceRoot: string, configuredPath: string): string {
88
+ assertCustomizationPath(configuredPath);
89
+
90
+ const candidate = resolve(sourceRoot, ...configuredPath.split("/"));
91
+ if (!isWithin(sourceRoot, candidate))
92
+ throw new Error("Customization path escapes its source directory.");
93
+ return candidate;
94
+ }
95
+
96
+ /**
97
+ * Resolve each repository path component separately. ENOENT means a genuinely
98
+ * absent path; a dangling symlink is observed by lstat and then fails realpath.
99
+ */
100
+ function resolvedCustomizationFile(sourceRoot: string, configuredPath: string): string | undefined {
101
+ customizationFile(sourceRoot, configuredPath);
102
+ const parts = configuredPath.split("/");
103
+ let current = sourceRoot;
104
+ for (const [index, part] of parts.entries()) {
105
+ const entry = resolve(current, part);
106
+ let entryStats: Stats;
107
+ try {
108
+ entryStats = lstatSync(entry);
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
111
+ throw error;
112
+ }
113
+ current = entryStats.isSymbolicLink() ? realpathSync(entry) : entry;
114
+ if (current !== sourceRoot && !isWithin(sourceRoot, current))
115
+ throw new Error("Customization file escapes its source directory.");
116
+ const last = index === parts.length - 1;
117
+ const resolvedStats = statSync(current);
118
+ if (!last && !resolvedStats.isDirectory())
119
+ throw new Error("Customization path has a non-directory parent.");
120
+ if (last && !resolvedStats.isFile())
121
+ throw new Error("Customization path must identify a regular file.");
122
+ }
123
+ return current;
124
+ }
125
+
126
+ /**
127
+ * Where the validated tenant YAML is staged: beside the instance file, in an
128
+ * ignored directory, named for the instance. Config sync is its only consumer.
129
+ */
130
+ export function stagedCustomizationPath(paths: InstanceContext): string {
131
+ return resolve(paths.root, STAGING_DIRECTORY, `${paths.instance.name}.yaml`);
132
+ }
133
+
134
+ /** Ignored directory the staged copy is written to, beside the instance file. */
135
+ export const STAGING_DIRECTORY = ".foundation-staging";
136
+
137
+ function assertUnchanged(path: string, platform: unknown, tenant: unknown): void {
138
+ if (!isDeepStrictEqual(platform, tenant))
139
+ throw new Error(
140
+ `Customization changes platform-owned authorization or capability policy. Field: ${path}.`,
141
+ );
142
+ }
143
+
144
+ /** Remove only the three tenant-editable leaves from a complete config. */
145
+ function protectedConfig(config: InstanceConfig): unknown {
146
+ const { digestHour: _digestHour, ...todos } = config.capabilities.todos;
147
+ const { defined: _defined, ...routines } = config.capabilities.routines;
148
+ const { outreach: _outreach, ...crm } = config.capabilities.crm;
149
+ return {
150
+ ...config,
151
+ capabilities: {
152
+ ...config.capabilities,
153
+ todos,
154
+ routines,
155
+ crm,
156
+ },
157
+ };
158
+ }
159
+
160
+ function assertProtectedPolicy(platform: InstanceConfig, tenant: InstanceConfig): void {
161
+ assertUnchanged("config", protectedConfig(platform), protectedConfig(tenant));
162
+ }
163
+
164
+ function assertValidTenantRoutines(tenant: InstanceConfig): void {
165
+ for (const routine of tenant.capabilities.routines.defined) {
166
+ const parsed = parseSchedule(routine.schedule);
167
+ if (!parsed.ok) throw new Error(`Invalid defined routine "${routine.id}": ${parsed.reason}`);
168
+ }
169
+ }
170
+
171
+ function migrationFallback(
172
+ required: boolean,
173
+ log: (message: string) => void,
174
+ platformConfigPath: string,
175
+ ): CustomizationStageResult {
176
+ if (required) throw new Error("Customization file is missing.");
177
+ log(
178
+ "Customization file is absent; retaining the platform runtime configuration during migration.",
179
+ );
180
+ return { status: "migration-fallback", runtimeConfigPath: platformConfigPath };
181
+ }
182
+
183
+ /** Validate tenant YAML and stage it without changing deployment-owned input. */
184
+ export function stageCustomization(options: StageCustomizationOptions): CustomizationStageResult {
185
+ const instance = options.paths.instance;
186
+ const env = options.env ?? process.env;
187
+ const platformConfigPath = options.paths.configPath;
188
+ const stagedPath = stagedCustomizationPath(options.paths);
189
+ // Never let an old local staging result survive a missing or invalid new source.
190
+ if (options.dryRun !== true) rmSync(stagedPath, { force: true });
191
+
192
+ const customization = instance.customization;
193
+ if (customization === undefined)
194
+ return { status: "not-configured", runtimeConfigPath: platformConfigPath };
195
+
196
+ const log = options.log ?? console.log;
197
+ // A legacy CodeBuild project has only the primary artifact. It may use the
198
+ // optional-file fallback once to install the new secondary source action.
199
+ const sourceDir =
200
+ env[`CODEBUILD_SRC_DIR_${CUSTOMIZATION_ARTIFACT_NAME}`] ??
201
+ (customization.required === false ? env.CODEBUILD_SRC_DIR : undefined);
202
+ const sourceRoot = sourceRootFor(sourceDir);
203
+ const sourceFile = resolvedCustomizationFile(sourceRoot, customization.path);
204
+ if (sourceFile === undefined)
205
+ return migrationFallback(customization.required, log, platformConfigPath);
206
+
207
+ // Preserve original bytes after validation: runtime gets exactly the reviewed YAML.
208
+ const yaml = readFileSync(sourceFile);
209
+ const tenantConfig = parseInstanceConfig(yaml.toString("utf8"));
210
+ const platformConfig = parseInstanceConfig(readFileSync(platformConfigPath, "utf8"));
211
+ if (tenantConfig.name !== instance.displayName)
212
+ throw new Error("Customization config name must match the selected instance.");
213
+ assertValidTenantRoutines(tenantConfig);
214
+ assertProtectedPolicy(platformConfig, tenantConfig);
215
+
216
+ if (options.dryRun !== true) {
217
+ mkdirSync(dirname(stagedPath), { recursive: true });
218
+ writeFileSync(stagedPath, yaml);
219
+ }
220
+ return {
221
+ status: "staged",
222
+ runtimeConfigPath: options.dryRun === true ? sourceFile : stagedPath,
223
+ };
224
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * CloudWatch Transaction Search, the account-level switch that makes exported
3
+ * spans readable.
4
+ *
5
+ * The agent SigV4-posts its spans to the X-Ray OTLP endpoint (see
6
+ * `packages/agent/src/observability/otel.ts`), but they are only indexed — and only
7
+ * visible under CloudWatch → Application Signals → Transaction Search and the
8
+ * GenAI Observability views — once the account's trace segment destination is
9
+ * `CloudWatchLogs` and an indexing rule samples them.
10
+ *
11
+ * Both settings are PER ACCOUNT and PER REGION, not per instance, so this runs
12
+ * once per account and is idempotent: an account already configured is read,
13
+ * reported and left alone.
14
+ *
15
+ * **The cost knob is the sampling percentage.** Indexed spans are billed per
16
+ * trace, so 100% is right only while volume is small (one Slack team's turns).
17
+ * Lower it here if a deployment ever gets busy.
18
+ */
19
+ import { type AwsCallContext, aws, awsMutate } from "./aws.ts";
20
+
21
+ export interface TracingRunner {
22
+ /** Run an `aws` subcommand and return trimmed stdout. */
23
+ capture(args: string[]): Promise<string>;
24
+ /** Run a mutating `aws` subcommand (a dry run prints it instead). */
25
+ mutate(args: string[]): Promise<void>;
26
+ }
27
+
28
+ /** The runner that actually shells out, bound to a profile and region. */
29
+ export function cliTracingRunner(ctx: AwsCallContext): TracingRunner {
30
+ return {
31
+ capture: (args) => aws(ctx, args),
32
+ mutate: (args) => awsMutate(ctx, args),
33
+ };
34
+ }
35
+
36
+ /** The destination that puts spans in CloudWatch Logs rather than X-Ray only. */
37
+ export const CLOUDWATCH_LOGS_DESTINATION = "CloudWatchLogs";
38
+
39
+ /** The one indexing rule the service ships; there is no second one to name. */
40
+ export const DEFAULT_INDEXING_RULE = "Default";
41
+
42
+ /** Index everything: one deployment's span volume is a handful of turns an hour. */
43
+ export const DEFAULT_SAMPLING_PERCENTAGE = 100;
44
+
45
+ /** What one setting was found to be, and whether this run changed it. */
46
+ export type SettingOutcome = "already-set" | "updated";
47
+
48
+ export interface TransactionSearchResult {
49
+ destination: SettingOutcome;
50
+ indexing: SettingOutcome;
51
+ /** The Logs resource policy that lets X-Ray write spans; see {@link spansIngestionPolicy}. */
52
+ ingestionPolicy: SettingOutcome;
53
+ }
54
+
55
+ /** Name of the Logs resource policy this helper owns. */
56
+ export const SPANS_INGESTION_POLICY_NAME = "FoundationSpansIngestion";
57
+
58
+ /**
59
+ * The CloudWatch Logs resource policy X-Ray needs to deliver spans into
60
+ * `aws/spans` (and Application Signals metrics into its group). Enabling
61
+ * Transaction Search from the console writes an equivalent managed policy;
62
+ * enabling it from the CLI does NOT, and a hand-written one that grants only
63
+ * `PutLogEvents` fails every export with HTTP 400 — 2026-09-07 —
64
+ * because X-Ray must also `CreateLogStream`. Both actions, both groups.
65
+ */
66
+ export function spansIngestionPolicy(account: string, region: string): string {
67
+ const statement = (sid: string, group: string) => ({
68
+ Sid: sid,
69
+ Effect: "Allow",
70
+ Principal: { Service: "xray.amazonaws.com" },
71
+ Action: ["logs:PutLogEvents", "logs:CreateLogStream"],
72
+ Resource: group.endsWith("*")
73
+ ? `arn:aws:logs:${region}:${account}:log-group:${group}`
74
+ : `arn:aws:logs:${region}:${account}:log-group:${group}:*`,
75
+ Condition: {
76
+ StringEquals: { "aws:SourceAccount": account },
77
+ ArnLike: { "aws:SourceArn": `arn:aws:xray:${region}:${account}:*` },
78
+ },
79
+ });
80
+ return JSON.stringify({
81
+ Version: "2012-10-17",
82
+ Statement: [
83
+ statement("SpansFromXray", "aws/spans"),
84
+ statement("ApplicationSignalsFromXray", "/aws/application-signals/data"),
85
+ // The agent names its OWN runtime log group in `x-aws-log-group`, and
86
+ // X-Ray delivers there too: without this, exports fail with "not
87
+ // authorized to perform logs:PutLogEvents on /aws/bedrock-agentcore/
88
+ // runtimes/<runtime>-live" (2026-09-07).
89
+ statement("AgentCoreRuntimeSpansFromXray", "/aws/bedrock-agentcore/runtimes/*"),
90
+ ],
91
+ });
92
+ }
93
+
94
+ /** True when some resource policy already lets X-Ray create streams in `aws/spans`. */
95
+ export function hasSpansIngestionPolicy(describeOutput: string): boolean {
96
+ let policies: unknown;
97
+ try {
98
+ policies = (JSON.parse(describeOutput) as { resourcePolicies?: unknown }).resourcePolicies;
99
+ } catch {
100
+ return false;
101
+ }
102
+ if (!Array.isArray(policies)) return false;
103
+ return policies.some((p) => {
104
+ const doc = String((p as { policyDocument?: unknown }).policyDocument ?? "");
105
+ return (
106
+ doc.includes("aws/spans") &&
107
+ doc.includes("logs:CreateLogStream") &&
108
+ doc.includes("/aws/bedrock-agentcore/runtimes/")
109
+ );
110
+ });
111
+ }
112
+
113
+ /** `{"Destination":"CloudWatchLogs","Status":"ACTIVE"}` → the destination. */
114
+ export function parseDestination(text: string): string {
115
+ try {
116
+ return String((JSON.parse(text) as { Destination?: unknown }).Destination ?? "");
117
+ } catch {
118
+ return "";
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The sampling percentage of one indexing rule, or null when the account has
124
+ * no such rule (which is itself a reason to write one).
125
+ */
126
+ export function parseSamplingPercentage(text: string, ruleName: string): number | null {
127
+ let rules: unknown;
128
+ try {
129
+ rules = (JSON.parse(text) as { IndexingRules?: unknown }).IndexingRules;
130
+ } catch {
131
+ return null;
132
+ }
133
+ if (!Array.isArray(rules)) return null;
134
+ for (const rule of rules as {
135
+ Name?: unknown;
136
+ Rule?: { Probabilistic?: { DesiredSamplingPercentage?: unknown } };
137
+ }[]) {
138
+ if (rule.Name !== ruleName) continue;
139
+ const percentage = rule.Rule?.Probabilistic?.DesiredSamplingPercentage;
140
+ return typeof percentage === "number" ? percentage : null;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ export interface EnsureTransactionSearchOptions {
146
+ samplingPercentage?: number;
147
+ /** Needed to write the ingestion policy; when absent the policy step is skipped. */
148
+ account?: string;
149
+ region?: string;
150
+ /** Print the two mutations instead of reading the account and applying them. */
151
+ dryRun?: boolean;
152
+ }
153
+
154
+ /**
155
+ * Turn Transaction Search on for this account and region, if it is not on
156
+ * already. Read first, write only what differs — running it twice makes one
157
+ * pair of read calls and no change.
158
+ */
159
+ export async function ensureTransactionSearch(
160
+ runner: TracingRunner,
161
+ options: EnsureTransactionSearchOptions = {},
162
+ ): Promise<TransactionSearchResult> {
163
+ const percentage = options.samplingPercentage ?? DEFAULT_SAMPLING_PERCENTAGE;
164
+ const rule = JSON.stringify({ Probabilistic: { DesiredSamplingPercentage: percentage } });
165
+ // A dry run has nothing to compare against, so it prints both mutations —
166
+ // the honest upper bound on what the real run would do.
167
+ if (options.dryRun === true) {
168
+ if (options.account !== undefined && options.region !== undefined) {
169
+ await runner.mutate([
170
+ "logs",
171
+ "put-resource-policy",
172
+ "--policy-name",
173
+ SPANS_INGESTION_POLICY_NAME,
174
+ "--policy-document",
175
+ spansIngestionPolicy(options.account, options.region),
176
+ ]);
177
+ }
178
+ await runner.mutate([
179
+ "xray",
180
+ "update-trace-segment-destination",
181
+ "--destination",
182
+ CLOUDWATCH_LOGS_DESTINATION,
183
+ ]);
184
+ await runner.mutate([
185
+ "xray",
186
+ "update-indexing-rule",
187
+ "--name",
188
+ DEFAULT_INDEXING_RULE,
189
+ "--rule",
190
+ rule,
191
+ ]);
192
+ return { destination: "updated", indexing: "updated", ingestionPolicy: "updated" };
193
+ }
194
+
195
+ // The policy FIRST: a destination switched on with no policy behind it
196
+ // accepts nothing, and the failure only shows up as 400s from the agent.
197
+ let ingestionPolicy: SettingOutcome = "already-set";
198
+ if (options.account !== undefined && options.region !== undefined) {
199
+ if (!hasSpansIngestionPolicy(await runner.capture(["logs", "describe-resource-policies"]))) {
200
+ await runner.mutate([
201
+ "logs",
202
+ "put-resource-policy",
203
+ "--policy-name",
204
+ SPANS_INGESTION_POLICY_NAME,
205
+ "--policy-document",
206
+ spansIngestionPolicy(options.account, options.region),
207
+ ]);
208
+ ingestionPolicy = "updated";
209
+ }
210
+ }
211
+
212
+ let destination: SettingOutcome = "already-set";
213
+ if (
214
+ parseDestination(await runner.capture(["xray", "get-trace-segment-destination"])) !==
215
+ CLOUDWATCH_LOGS_DESTINATION
216
+ ) {
217
+ await runner.mutate([
218
+ "xray",
219
+ "update-trace-segment-destination",
220
+ "--destination",
221
+ CLOUDWATCH_LOGS_DESTINATION,
222
+ ]);
223
+ destination = "updated";
224
+ }
225
+
226
+ let indexing: SettingOutcome = "already-set";
227
+ const current = parseSamplingPercentage(
228
+ await runner.capture(["xray", "get-indexing-rules"]),
229
+ DEFAULT_INDEXING_RULE,
230
+ );
231
+ if (current !== percentage) {
232
+ await runner.mutate([
233
+ "xray",
234
+ "update-indexing-rule",
235
+ "--name",
236
+ DEFAULT_INDEXING_RULE,
237
+ "--rule",
238
+ rule,
239
+ ]);
240
+ indexing = "updated";
241
+ }
242
+ return { destination, indexing, ingestionPolicy };
243
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * What a deployer is allowed to do, defined once.
3
+ *
4
+ * An instance is deployed either by GitHub Actions, through the OIDC role in
5
+ * `ci-stack.ts`, or by a CodePipeline in its own account, through the CodeBuild
6
+ * role in `pipeline-stack.ts`. Both run the very same `foundation-deploy`, so
7
+ * both need the very same permissions — and two hand-maintained copies of an
8
+ * IAM policy drift the first time one deploy step is added. They share this.
9
+ *
10
+ * The permissions are those the deploy tool actually exercises. Most of the
11
+ * real work happens through the CDK bootstrap roles (`cdk-hnb659fds-*`), so
12
+ * `sts:AssumeRole` on `cdk-*` is the broad grant here — admin-equivalent in the
13
+ * account by design, which is why what may assume the *role* (an OIDC subject,
14
+ * or CodeBuild in this account) is the security boundary, not this policy.
15
+ */
16
+ import type * as cdk from "aws-cdk-lib";
17
+ import * as iam from "aws-cdk-lib/aws-iam";
18
+ import type * as kms from "aws-cdk-lib/aws-kms";
19
+ import type * as s3 from "aws-cdk-lib/aws-s3";
20
+ import { type Instance, type InstanceNames, provisionsIntegration } from "./names.ts";
21
+
22
+ export interface DeployTargets {
23
+ /** The data stack's bucket — config and skills are synced into it. */
24
+ bucket: s3.IBucket;
25
+ /** The CMK that bucket is encrypted with. */
26
+ dataKey: kms.IKey;
27
+ }
28
+
29
+ /**
30
+ * The statements a deploy role carries. `stack` supplies partition, account and
31
+ * region; everything else is referenced by ARN *pattern* rather than by
32
+ * construct, so a deployer stack depends on the data stack and nothing more —
33
+ * naming the agent stack's repository or runtime directly would create a
34
+ * cross-stack cycle for no benefit.
35
+ */
36
+ export function deployStatements(
37
+ stack: cdk.Stack,
38
+ names: InstanceNames,
39
+ targets: DeployTargets,
40
+ instance?: Instance,
41
+ ): iam.PolicyStatement[] {
42
+ // Imported secrets carry a partial ARN (no service-generated 6-character
43
+ // suffix), which IAM will not match — append the wildcard, the same way
44
+ // CDK's own `grantRead` does. It is a suffix wildcard on a named secret, not
45
+ // a wildcard over secrets: nothing here can read a secret it is not named.
46
+ const secretArn = (name: string) =>
47
+ `arn:${stack.partition}:secretsmanager:${stack.region}:${stack.account}:secret:${name}-??????`;
48
+ const runtimeSecret = secretArn(names.secretRuntime);
49
+ const knockOauthClient = secretArn(names.secretKnockOauthClient);
50
+
51
+ return [
52
+ // How `cdk deploy` does everything else: it assumes the bootstrap roles for
53
+ // lookup, asset publishing and the CloudFormation call.
54
+ new iam.PolicyStatement({
55
+ sid: "AssumeCdkBootstrapRoles",
56
+ actions: ["sts:AssumeRole"],
57
+ resources: [`arn:${stack.partition}:iam::${stack.account}:role/cdk-*`],
58
+ }),
59
+ // The deploy reads stack outputs (repository uri, table, bucket, runtime
60
+ // arn) before and after `cdk deploy`.
61
+ new iam.PolicyStatement({
62
+ sid: "ReadStackOutputs",
63
+ actions: ["cloudformation:DescribeStacks"],
64
+ resources: ["*"],
65
+ }),
66
+ // The image is pushed with the caller's own credentials, not the
67
+ // image-publishing role. GetAuthorizationToken is account-wide by design —
68
+ // it takes no resource.
69
+ new iam.PolicyStatement({
70
+ sid: "EcrLogin",
71
+ actions: ["ecr:GetAuthorizationToken"],
72
+ resources: ["*"],
73
+ }),
74
+ new iam.PolicyStatement({
75
+ sid: "EcrPushAgentImage",
76
+ actions: [
77
+ "ecr:BatchCheckLayerAvailability",
78
+ "ecr:CompleteLayerUpload",
79
+ "ecr:InitiateLayerUpload",
80
+ "ecr:PutImage",
81
+ "ecr:UploadLayerPart",
82
+ "ecr:BatchGetImage",
83
+ "ecr:GetDownloadUrlForLayer",
84
+ "ecr:DescribeImages",
85
+ ],
86
+ resources: [
87
+ `arn:${stack.partition}:ecr:${stack.region}:${stack.account}:repository/${names.ecrRepo}`,
88
+ ],
89
+ }),
90
+ // The GitHub App id is baked into the image; the Slack bot token and the
91
+ // runtime env map are re-composed after every deploy.
92
+ new iam.PolicyStatement({
93
+ sid: "ReadDeploySecrets",
94
+ actions: ["secretsmanager:GetSecretValue"],
95
+ resources: [secretArn(names.secretSlackApp), secretArn(names.secretGithubApp), runtimeSecret],
96
+ }),
97
+ new iam.PolicyStatement({
98
+ sid: "WriteRuntimeSecret",
99
+ actions: ["secretsmanager:PutSecretValue"],
100
+ resources: [runtimeSecret],
101
+ }),
102
+ // The deploy caches its last successful license verification here, and
103
+ // creates the secret the first time. Only for an instance that HAS a
104
+ // license: the check is skipped otherwise, so the grant would be dead.
105
+ ...(instance?.license === undefined
106
+ ? []
107
+ : [
108
+ new iam.PolicyStatement({
109
+ sid: "CacheLicenseVerification",
110
+ actions: [
111
+ "secretsmanager:CreateSecret",
112
+ "secretsmanager:DescribeSecret",
113
+ "secretsmanager:GetSecretValue",
114
+ "secretsmanager:PutSecretValue",
115
+ ],
116
+ resources: [secretArn(names.secretLicenseLastVerified)],
117
+ }),
118
+ ]),
119
+ ...(instance !== undefined && provisionsIntegration(instance, "knock")
120
+ ? [
121
+ new iam.PolicyStatement({
122
+ sid: "RegisterKnockOauthClient",
123
+ actions: ["secretsmanager:GetSecretValue", "secretsmanager:PutSecretValue"],
124
+ resources: [knockOauthClient],
125
+ }),
126
+ ]
127
+ : []),
128
+ // `foundation-deploy config:sync` puts config/ and skills/ in the bucket.
129
+ new iam.PolicyStatement({
130
+ sid: "SyncConfigAndSkills",
131
+ actions: ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket", "s3:GetObject"],
132
+ resources: [
133
+ targets.bucket.bucketArn,
134
+ targets.bucket.arnForObjects("config/*"),
135
+ targets.bucket.arnForObjects("skills/*"),
136
+ ],
137
+ }),
138
+ new iam.PolicyStatement({
139
+ sid: "UseDataKey",
140
+ actions: ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey*"],
141
+ resources: [targets.dataKey.keyArn],
142
+ }),
143
+ // `foundation-deploy post-deploy`: the smoke test (`_smoke_test`,
144
+ // `_fs_probe`) against DEFAULT, then promotion of the new version onto `live`.
145
+ new iam.PolicyStatement({
146
+ sid: "SmokeTestRuntime",
147
+ actions: [
148
+ "bedrock-agentcore:InvokeAgentRuntime",
149
+ "bedrock-agentcore:GetAgentRuntime",
150
+ "bedrock-agentcore:ListAgentRuntimeVersions",
151
+ "bedrock-agentcore:GetAgentRuntimeEndpoint",
152
+ "bedrock-agentcore:UpdateAgentRuntimeEndpoint",
153
+ ],
154
+ resources: [
155
+ `arn:${stack.partition}:bedrock-agentcore:${stack.region}:${stack.account}:runtime/${names.runtimeName}*`,
156
+ `arn:${stack.partition}:bedrock-agentcore:${stack.region}:${stack.account}:runtime/${names.runtimeName}*/runtime-endpoint/*`,
157
+ ],
158
+ }),
159
+ new iam.PolicyStatement({
160
+ sid: "WhoAmI",
161
+ actions: ["sts:GetCallerIdentity"],
162
+ resources: ["*"],
163
+ }),
164
+ ];
165
+ }
package/src/index.ts ADDED
@@ -0,0 +1,60 @@
1
+ export {
2
+ BUN_VERSION,
3
+ LAMBDA_ENTRY_POINTS,
4
+ RELEASE_BUCKET_ENV,
5
+ RELEASE_DIR_ENV,
6
+ type LambdaArtifactId,
7
+ type ReleaseSource,
8
+ agentImage,
9
+ agentImageTagRequired,
10
+ ecrRepositoryArn,
11
+ lambdaCode,
12
+ releaseCacheDir,
13
+ releaseImageRepositoryArn,
14
+ releaseSource,
15
+ skipBundle,
16
+ } from "./artifacts.ts";
17
+ export {
18
+ type KmsVerifier,
19
+ type ReleaseArtifact,
20
+ type ReleaseManifest,
21
+ type ReleaseSignature,
22
+ type SigningAlgorithm,
23
+ buildManifest,
24
+ lambdaKey,
25
+ manifestKey,
26
+ manifestSigningPayload,
27
+ parseManifest,
28
+ skillsKey,
29
+ verifyManifest,
30
+ } from "./release/manifest.ts";
31
+ export { deployStatements, type DeployTargets } from "./deploy-permissions.ts";
32
+ export { lambdaBundleContext } from "./lambda-bundle-context.ts";
33
+ export {
34
+ LIVE_ENDPOINT_NAME,
35
+ WEB_SEARCH_CONNECTOR_VERSION,
36
+ WEB_SEARCH_TARGET,
37
+ adminsFor,
38
+ capabilityEnabled,
39
+ configPathFor,
40
+ enabledIntegrations,
41
+ type Instance,
42
+ type InstanceFile,
43
+ type InstanceNames,
44
+ loadInstanceFile,
45
+ namesFor,
46
+ provisionsIntegration,
47
+ requiresAuthenticatedQueue,
48
+ } from "./names.ts";
49
+ export { FoundationAgent, type FoundationAgentProps } from "./stacks/agent-stack.ts";
50
+ export { FoundationApi, type FoundationApiProps } from "./stacks/api-stack.ts";
51
+ export { FoundationCi, type FoundationCiProps } from "./stacks/ci-stack.ts";
52
+ export { FoundationData, type FoundationDataProps } from "./stacks/data-stack.ts";
53
+ export { FoundationNetwork, type FoundationNetworkProps } from "./stacks/network-stack.ts";
54
+ export { NewsletterStack, type NewsletterStackProps } from "./stacks/newsletter-stack.ts";
55
+ export {
56
+ BUILD_TIMEOUT,
57
+ DEFAULT_BUILDSPEC_PATH,
58
+ FoundationPipeline,
59
+ type FoundationPipelineProps,
60
+ } from "./stacks/pipeline-stack.ts";