@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,282 @@
1
+ import * as cdk from "aws-cdk-lib";
2
+ import * as ec2 from "aws-cdk-lib/aws-ec2";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import type { Construct } from "constructs";
5
+ import { type Instance, capabilityEnabled } from "../names.ts";
6
+
7
+ /**
8
+ * Network — VPC + endpoints.
9
+ *
10
+ * - "ingress": public subnets, present only to host the NAT gateways.
11
+ * - "egress": PRIVATE_WITH_EGRESS, NAT-routed. AgentCore session ENIs
12
+ * (networkMode VPC) live here so github.com / slack.com / the Codex API are
13
+ * reachable, while AWS API traffic still resolves to the interface endpoints
14
+ * through private DNS and S3/DynamoDB ride the gateway endpoints.
15
+ * - "app": PRIVATE_ISOLATED, hosts the interface VPC endpoints.
16
+ *
17
+ * AgentCore AZ constraint: in us-east-1 the AgentCore Runtime only schedules
18
+ * session ENIs into the AZ-IDs `use1-az1`, `use1-az2` and `use1-az4`. CDK
19
+ * picks AZs by *name* (us-east-1a…), and the name→AZ-ID mapping differs per
20
+ * account, so this stack does not try to pin them. If the two AZs CDK picks
21
+ * turn out not to be AgentCore-supported, the escape hatch is FoundationAgent's
22
+ * `-c agentSubnetIds=subnet-a,subnet-b` context override, which selects the
23
+ * egress subnets by id instead of by tier.
24
+ *
25
+ * Existing-VPC import: `-c vpcId=vpc-...` (needs CDK_DEFAULT_ACCOUNT for the
26
+ * context lookup); the imported VPC must already have private-with-NAT
27
+ * subnets. Endpoints are still created by default; `-c createVpcEndpoints=false`
28
+ * skips them all and `-c skipEndpoints=s3,secretsmanager` skips named ones
29
+ * that the imported VPC already provides.
30
+ */
31
+ /** MongoDB's wire-protocol port. Atlas serves every cluster on it. */
32
+ const MONGODB_PORT = 27017;
33
+
34
+ export interface FoundationNetworkProps extends cdk.StackProps {
35
+ /** The deployment this stack belongs to. Only its naming prefix is used here. */
36
+ instance: Instance;
37
+ /** The instance's runtime config, read at synth for the Mongo egress switch. */
38
+ configPath: string;
39
+ }
40
+
41
+ export class FoundationNetwork extends cdk.Stack {
42
+ public readonly vpc: ec2.IVpc;
43
+ /** Only set when this stack creates the endpoints (createVpcEndpoints). */
44
+ public endpointSecurityGroup?: ec2.SecurityGroup;
45
+ /** Private-with-NAT subnets for the AgentCore session ENIs. */
46
+ public readonly egressSubnets: ec2.SubnetSelection;
47
+ /** SG for AgentCore session ENIs: 443 + DNS egress only. */
48
+ public readonly agentSecurityGroup: ec2.SecurityGroup;
49
+ /** SG for VPC-attached Lambda ENIs, should we ever attach them: 443 + DNS. */
50
+ public readonly gatewaySecurityGroup: ec2.SecurityGroup;
51
+ /** SG for the S3 Files mount targets: NFS (2049) from the agent ENIs only. */
52
+ public readonly mountTargetSecurityGroup: ec2.SecurityGroup;
53
+
54
+ constructor(scope: Construct, id: string, props: FoundationNetworkProps) {
55
+ super(scope, id, props);
56
+ // Security-group descriptions are immutable in CloudFormation: a changed
57
+ // description forces SG replacement, which fails while the group id is an
58
+ // in-use cross-stack export. So they key off the stable naming PREFIX, not
59
+ // the cosmetic displayName — renaming a teammate wedged a live pipeline
60
+ // for exactly this reason (2026-09, sky-tags#94).
61
+ const { prefix } = props.instance.naming;
62
+
63
+ const importedVpcId: string | undefined = this.node.tryGetContext("vpcId");
64
+ const createVpcEndpoints: boolean =
65
+ String(this.node.tryGetContext("createVpcEndpoints") ?? "true") !== "false";
66
+
67
+ if (importedVpcId) {
68
+ this.vpc = ec2.Vpc.fromLookup(this, "ImportedVpc", { vpcId: importedVpcId });
69
+ this.egressSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS };
70
+ } else {
71
+ this.vpc = new ec2.Vpc(this, "FoundationVpc", {
72
+ maxAzs: 2,
73
+ // One NAT per AZ: the Slack ack / chat.update path must survive a
74
+ // single-AZ failure.
75
+ natGateways: 2,
76
+ subnetConfiguration: [
77
+ { name: "ingress", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
78
+ { name: "egress", subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
79
+ { name: "app", subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 },
80
+ ],
81
+ });
82
+ this.egressSubnets = { subnetGroupName: "egress" };
83
+ // The addresses everything in the VPC leaves through, so a third party
84
+ // (an Atlas IP allowlist, say) can name this instance and no other.
85
+ // One per AZ, matching `natGateways: 2` above.
86
+ new cdk.CfnOutput(this, "NatEgressIp", {
87
+ value: cdk.Fn.join(
88
+ ",",
89
+ this.vpc.publicSubnets
90
+ .map(
91
+ (subnet) =>
92
+ (subnet.node.tryFindChild("EIP") as ec2.CfnEIP | undefined)?.attrPublicIp ?? "",
93
+ )
94
+ .filter((value) => value !== ""),
95
+ ),
96
+ description: "Public egress addresses (NAT gateway EIPs) for allowlisting this instance",
97
+ });
98
+ }
99
+
100
+ // HTTPS anywhere (NAT for github.com/slack.com; private DNS steers AWS
101
+ // APIs onto the interface endpoints) plus DNS to the VPC resolver.
102
+ // Nothing else leaves. Per-channel host allowlisting narrows further
103
+ // inside the agent.
104
+ const egressOnly = (constructId: string, description: string): ec2.SecurityGroup => {
105
+ const sg = new ec2.SecurityGroup(this, constructId, {
106
+ vpc: this.vpc,
107
+ description,
108
+ allowAllOutbound: false,
109
+ });
110
+ sg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS egress");
111
+ sg.addEgressRule(
112
+ ec2.Peer.ipv4(this.vpc.vpcCidrBlock),
113
+ ec2.Port.udp(53),
114
+ "DNS to VPC resolver",
115
+ );
116
+ sg.addEgressRule(
117
+ ec2.Peer.ipv4(this.vpc.vpcCidrBlock),
118
+ ec2.Port.tcp(53),
119
+ "DNS over TCP to VPC resolver",
120
+ );
121
+ return sg;
122
+ };
123
+ this.agentSecurityGroup = egressOnly(
124
+ "AgentSg",
125
+ `${prefix} AgentCore session ENIs: 443 + DNS egress only`,
126
+ );
127
+ this.gatewaySecurityGroup = egressOnly(
128
+ "GatewaySg",
129
+ `${prefix} gateway Lambda ENIs: 443 + DNS egress only`,
130
+ );
131
+
132
+ // MongoDB Atlas speaks the wire protocol on 27017, not HTTPS, so the
133
+ // agent needs one more egress rule to reach it. Without it every
134
+ // connection is dropped by this group and the driver reports a plain
135
+ // TIMEOUT — nothing anywhere says a firewall ate it. One instance ran
136
+ // with `mongodbReadonly` on and every query timing out for days on
137
+ // exactly that failure.
138
+ //
139
+ // The GATEWAY deliberately gets no such rule: only the agent queries
140
+ // Mongo. The destination is open because Atlas resolves an SRV record to
141
+ // shard hostnames whose addresses rotate, so there is no stable CIDR to
142
+ // name; the credential and Atlas's own IP allowlist are what actually
143
+ // gate access.
144
+ //
145
+ // Gated on the capability so an instance that does not use Mongo keeps
146
+ // the port shut. Turning `mongodbReadonly` ON is therefore a deploy, not
147
+ // just a config sync; turning it OFF still removes the tools on the next
148
+ // turn.
149
+ if (capabilityEnabled(props.configPath, "mongodbReadonly")) {
150
+ this.agentSecurityGroup.addEgressRule(
151
+ ec2.Peer.anyIpv4(),
152
+ ec2.Port.tcp(MONGODB_PORT),
153
+ "MongoDB Atlas",
154
+ );
155
+ }
156
+
157
+ // The persistent mount speaks NFS, not HTTPS, so the agent SG needs one
158
+ // extra egress rule and the mount targets one matching ingress. Both are
159
+ // scoped to the other security group — nothing else in the VPC can reach
160
+ // the file system, and the agent can reach nothing else on 2049.
161
+ this.mountTargetSecurityGroup = new ec2.SecurityGroup(this, "MountTargetSg", {
162
+ vpc: this.vpc,
163
+ description: `${prefix} S3 Files mount targets: NFS from the agent ENIs only`,
164
+ allowAllOutbound: false,
165
+ });
166
+ this.mountTargetSecurityGroup.addIngressRule(
167
+ this.agentSecurityGroup,
168
+ ec2.Port.tcp(2049),
169
+ "NFS from the AgentCore session ENIs",
170
+ );
171
+ this.agentSecurityGroup.addEgressRule(
172
+ this.mountTargetSecurityGroup,
173
+ ec2.Port.tcp(2049),
174
+ "NFS to the S3 Files mount targets",
175
+ );
176
+
177
+ if (!createVpcEndpoints) return;
178
+
179
+ const endpointSg = new ec2.SecurityGroup(this, "EndpointSg", {
180
+ vpc: this.vpc,
181
+ description: `${prefix} interface VPC endpoints: HTTPS from within the VPC only`,
182
+ allowAllOutbound: false,
183
+ });
184
+ endpointSg.addIngressRule(
185
+ ec2.Peer.ipv4(this.vpc.vpcCidrBlock),
186
+ ec2.Port.tcp(443),
187
+ "HTTPS from VPC",
188
+ );
189
+ this.endpointSecurityGroup = endpointSg;
190
+
191
+ // Interface endpoints sit in the isolated app tier when we created the
192
+ // VPC; on an imported VPC they land in its private subnets. Private DNS
193
+ // makes them authoritative for the whole VPC either way.
194
+ const endpointSubnets: ec2.SubnetSelection = importedVpcId
195
+ ? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }
196
+ : { subnetGroupName: "app" };
197
+ // Gateway endpoints attach to every private route table (app + egress) so
198
+ // S3/DynamoDB traffic from the NAT tier also stays on AWS's network.
199
+ const gatewayEndpointSubnets: ec2.SubnetSelection[] = importedVpcId
200
+ ? [{ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }]
201
+ : [{ subnetGroupName: "app" }, { subnetGroupName: "egress" }];
202
+
203
+ // Endpoint policy: only principals from this account may use the endpoint
204
+ // (blocks exfiltration to foreign-account resources through our
205
+ // endpoints; per-resource authorization stays in the IAM roles).
206
+ const sameAccountOnly = new iam.PolicyStatement({
207
+ effect: iam.Effect.ALLOW,
208
+ principals: [new iam.AnyPrincipal()],
209
+ actions: ["*"],
210
+ resources: ["*"],
211
+ conditions: { StringEquals: { "aws:PrincipalAccount": this.account } },
212
+ });
213
+
214
+ // Endpoints an imported VPC already has must be skipped — a second S3
215
+ // gateway endpoint conflicts on the route-table prefix list, and a second
216
+ // interface endpoint with private DNS conflicts on the DNS name.
217
+ const skipEndpoints = new Set(
218
+ String(this.node.tryGetContext("skipEndpoints") ?? "")
219
+ .split(",")
220
+ .map((s) => s.trim().toLowerCase())
221
+ .filter(Boolean),
222
+ );
223
+
224
+ if (!skipEndpoints.has("s3")) {
225
+ const s3Endpoint = this.vpc.addGatewayEndpoint("S3Endpoint", {
226
+ service: ec2.GatewayVpcEndpointAwsService.S3,
227
+ subnets: gatewayEndpointSubnets,
228
+ });
229
+ s3Endpoint.addToPolicy(sameAccountOnly);
230
+ // ECR serves image layers from an ECR-OWNED bucket via presigned URLs.
231
+ // A same-account-only endpoint policy blocks that fetch (the request is
232
+ // signed by ECR, not by us) and every AgentCore cold start fails with
233
+ // `403 Forbidden` on `/v2/<repo>/blobs/<sha256>` (2026-09-05).
234
+ s3Endpoint.addToPolicy(
235
+ new iam.PolicyStatement({
236
+ sid: "EcrLayerBucket",
237
+ effect: iam.Effect.ALLOW,
238
+ principals: [new iam.AnyPrincipal()],
239
+ actions: ["s3:GetObject"],
240
+ resources: [`arn:${this.partition}:s3:::prod-${this.region}-starport-layer-bucket/*`],
241
+ }),
242
+ );
243
+ }
244
+ if (!skipEndpoints.has("dynamodb")) {
245
+ const dynamoEndpoint = this.vpc.addGatewayEndpoint("DynamoDbEndpoint", {
246
+ service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
247
+ subnets: gatewayEndpointSubnets,
248
+ });
249
+ dynamoEndpoint.addToPolicy(sameAccountOnly);
250
+ }
251
+
252
+ // No Bedrock Runtime endpoint: the agent talks to the Codex API over
253
+ // the NAT, never to a Bedrock model. Only the AgentCore control plane
254
+ // (InvokeAgentRuntime) needs a private path.
255
+ // Construct id, the name `-c skipEndpoints=` uses for it, and the service.
256
+ // One list rather than two parallel maps: a pair that disagreed would make
257
+ // an endpoint unskippable and the mismatch would be invisible.
258
+ const interfaceEndpoints: readonly [string, string, ec2.IInterfaceVpcEndpointService][] = [
259
+ [
260
+ "BedrockAgentCoreEndpoint",
261
+ "bedrock-agentcore",
262
+ new ec2.InterfaceVpcEndpointService(`com.amazonaws.${this.region}.bedrock-agentcore`, 443),
263
+ ],
264
+ [
265
+ "SecretsManagerEndpoint",
266
+ "secretsmanager",
267
+ ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
268
+ ],
269
+ ["CloudWatchLogsEndpoint", "logs", ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS],
270
+ ];
271
+ for (const [endpointId, skipName, service] of interfaceEndpoints) {
272
+ if (skipEndpoints.has(skipName)) continue;
273
+ const endpoint = this.vpc.addInterfaceEndpoint(endpointId, {
274
+ service,
275
+ subnets: endpointSubnets,
276
+ securityGroups: [endpointSg],
277
+ privateDnsEnabled: true,
278
+ });
279
+ endpoint.addToPolicy(sameAccountOnly);
280
+ }
281
+ }
282
+ }