@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,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
|
+
}
|