@deployfoundation/foundation-deploy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/agent-image/Dockerfile +254 -0
- package/agent-image/bin/aws +36 -0
- package/agent-image/bin/gh +193 -0
- package/agent-image/bin/git-credential-sky +89 -0
- package/agent-image/security-overlay.yml +176 -0
- package/cdk.json +6 -0
- package/dist/bin/app.js +112 -0
- package/dist/bin/foundation-deploy.js +1906 -0
- package/dist/bin/release-account.js +154 -0
- package/dist/chunk-4aye5cee.js +2416 -0
- package/dist/chunk-9ddxyvq2.js +1455 -0
- package/dist/chunk-v7tz8g50.js +428 -0
- package/dist/src/index.js +88 -0
- package/package.json +38 -0
- package/pipeline/buildspec.yml +34 -0
- package/src/artifacts.ts +318 -0
- package/src/deploy/assets/github-app-manifest.yml +29 -0
- package/src/deploy/assets/slack-app-manifest.yml +95 -0
- package/src/deploy/aws.ts +265 -0
- package/src/deploy/cli.ts +212 -0
- package/src/deploy/config-sync.ts +93 -0
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy.ts +566 -0
- package/src/deploy/endpoint.ts +242 -0
- package/src/deploy/github-app-create.ts +154 -0
- package/src/deploy/github-app-manifest.ts +53 -0
- package/src/deploy/image.ts +80 -0
- package/src/deploy/instance.ts +87 -0
- package/src/deploy/license-cache.ts +47 -0
- package/src/deploy/license.ts +272 -0
- package/src/deploy/paths.ts +65 -0
- package/src/deploy/post-deploy.ts +97 -0
- package/src/deploy/release.ts +282 -0
- package/src/deploy/runtime-secret.ts +241 -0
- package/src/deploy/setup.ts +393 -0
- package/src/deploy/sh.ts +74 -0
- package/src/deploy/slack-manifest.ts +112 -0
- package/src/deploy/stage-customization.ts +224 -0
- package/src/deploy/tracing.ts +243 -0
- package/src/deploy-permissions.ts +165 -0
- package/src/index.ts +60 -0
- package/src/lambda-bundle-context.ts +64 -0
- package/src/names.ts +170 -0
- package/src/release/kms.ts +86 -0
- package/src/release/manifest.ts +265 -0
- package/src/stacks/agent-stack.ts +938 -0
- package/src/stacks/api-stack.ts +1005 -0
- package/src/stacks/ci-stack.ts +96 -0
- package/src/stacks/data-stack.ts +446 -0
- package/src/stacks/network-stack.ts +282 -0
- package/src/stacks/newsletter-stack.ts +572 -0
- package/src/stacks/pipeline-stack.ts +242 -0
- package/src/stacks/release-account-stack.ts +229 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { CUSTOMIZATION_ARTIFACT_NAME } from "@deployfoundation/foundation-core/instance";
|
|
2
|
+
/**
|
|
3
|
+
* FoundationPipeline — the instance's own deployer, living in the instance's own AWS
|
|
4
|
+
* account.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists rather than the GitHub Actions job that does the same work:
|
|
7
|
+
* a customer running Foundation should own its build, its logs and its
|
|
8
|
+
* credentials. A deploy through Actions depends on a workflow in someone
|
|
9
|
+
* else's repository and on an OIDC trust reaching into the customer's account;
|
|
10
|
+
* this depends on neither. One merge no longer deploys every instance in
|
|
11
|
+
* lock-step either — an instance can hold or pin without touching the others.
|
|
12
|
+
*
|
|
13
|
+
* The build runs `foundation-deploy deploy` and then `foundation-deploy
|
|
14
|
+
* post-deploy`: the first stages any reviewed tenant runtime config, builds
|
|
15
|
+
* the image, runs `cdk deploy --all`, refreshes the runtime secret and syncs
|
|
16
|
+
* config; the second smokes DEFAULT, probes the mount, promotes `live` and
|
|
17
|
+
* smokes `live`. `live` moves only after the probes pass.
|
|
18
|
+
*
|
|
19
|
+
* `cdk deploy --all` inside the build includes this stack, so a change to the
|
|
20
|
+
* pipeline definition applies on the pipeline's next run — self-updating,
|
|
21
|
+
* without CDK Pipelines' extra mutate stage. The first deploy is by hand, from
|
|
22
|
+
* a laptop with the instance's profile (`foundation-deploy setup`, or `cdk
|
|
23
|
+
* deploy <Prefix>Pipeline -c instanceFile=<path>`).
|
|
24
|
+
*
|
|
25
|
+
* The buildspec is read from the INSTANCE repository the pipeline sources, not
|
|
26
|
+
* from Foundation: the instance repo owns its own build, and step 8 shrinks
|
|
27
|
+
* that file to one `npx @deployfoundation/foundation-deploy@<version>` line.
|
|
28
|
+
* `pipeline/buildspec.yml` in this package is the reference it starts from.
|
|
29
|
+
*
|
|
30
|
+
* Only synthesized for instances whose `deploy.via` is `codepipeline`; see
|
|
31
|
+
* `bin/app.ts`. Exactly one deployer owns an instance.
|
|
32
|
+
*/
|
|
33
|
+
import * as cdk from "aws-cdk-lib";
|
|
34
|
+
import * as codebuild from "aws-cdk-lib/aws-codebuild";
|
|
35
|
+
import * as codepipeline from "aws-cdk-lib/aws-codepipeline";
|
|
36
|
+
import * as actions from "aws-cdk-lib/aws-codepipeline-actions";
|
|
37
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
38
|
+
import type * as kms from "aws-cdk-lib/aws-kms";
|
|
39
|
+
import type * as s3 from "aws-cdk-lib/aws-s3";
|
|
40
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
41
|
+
import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
|
|
42
|
+
import type { Construct } from "constructs";
|
|
43
|
+
import { deployStatements } from "../deploy-permissions.ts";
|
|
44
|
+
import { type Instance, namesFor } from "../names.ts";
|
|
45
|
+
|
|
46
|
+
export interface FoundationPipelineProps extends cdk.StackProps {
|
|
47
|
+
/** The deployment this pipeline deploys; every name below comes from it. */
|
|
48
|
+
instance: Instance;
|
|
49
|
+
/** The data stack's bucket — config and skills are synced into it. */
|
|
50
|
+
bucket: s3.IBucket;
|
|
51
|
+
/** The CMK that bucket is encrypted with. */
|
|
52
|
+
dataKey: kms.IKey;
|
|
53
|
+
/**
|
|
54
|
+
* Where the instance file sits inside the instance repository the pipeline
|
|
55
|
+
* sources, so the build can name it. Relative to that checkout's root.
|
|
56
|
+
*/
|
|
57
|
+
instanceFilePath: string;
|
|
58
|
+
/** Address told when a pipeline run fails. Defaults to the instance's own. */
|
|
59
|
+
alarmEmail?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Where the buildspec sits inside the instance repository the pipeline
|
|
62
|
+
* sources. Overridable because an instance repo chooses its own layout.
|
|
63
|
+
*/
|
|
64
|
+
buildspecPath?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Default buildspec path inside the INSTANCE repository. `packages/infra/pipeline/buildspec.yml`
|
|
69
|
+
* is the reference version of this file; an instance repo copies it and owns it
|
|
70
|
+
* from then on.
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_BUILDSPEC_PATH = "buildspec.yml";
|
|
73
|
+
|
|
74
|
+
/** A whole deploy is ~8 minutes warm; an hour is room for a cold image build. */
|
|
75
|
+
export const BUILD_TIMEOUT = cdk.Duration.minutes(60);
|
|
76
|
+
|
|
77
|
+
export class FoundationPipeline extends cdk.Stack {
|
|
78
|
+
public readonly pipeline: codepipeline.Pipeline;
|
|
79
|
+
public readonly buildRole: iam.Role;
|
|
80
|
+
|
|
81
|
+
constructor(scope: Construct, id: string, props: FoundationPipelineProps) {
|
|
82
|
+
super(scope, id, props);
|
|
83
|
+
const names = namesFor(props.instance);
|
|
84
|
+
const { deploy } = props.instance;
|
|
85
|
+
const customization = props.instance.customization;
|
|
86
|
+
if (deploy.connectionArn === undefined)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`instance ${props.instance.name}: deploy.connectionArn is required for a pipeline`,
|
|
89
|
+
);
|
|
90
|
+
const [owner = "", repo = ""] = props.instance.github.repo.split("/");
|
|
91
|
+
|
|
92
|
+
// The deployer itself. Same statements as the GitHub Actions role, from
|
|
93
|
+
// one definition, so the two cannot drift; what may assume this role —
|
|
94
|
+
// CodeBuild in this account, reached only through the pipeline — is the
|
|
95
|
+
// boundary, exactly as the OIDC trust is there.
|
|
96
|
+
this.buildRole = new iam.Role(this, "BuildRole", {
|
|
97
|
+
description: `CodePipeline deployer for ${props.instance.name}`,
|
|
98
|
+
assumedBy: new iam.ServicePrincipal("codebuild.amazonaws.com"),
|
|
99
|
+
inlinePolicies: {
|
|
100
|
+
FoundationDeploy: new iam.PolicyDocument({
|
|
101
|
+
statements: deployStatements(
|
|
102
|
+
this,
|
|
103
|
+
names,
|
|
104
|
+
{ bucket: props.bucket, dataKey: props.dataKey },
|
|
105
|
+
props.instance,
|
|
106
|
+
),
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// One topic and subscription cover both failures and approval requests.
|
|
112
|
+
// Keeping it here gives the optional approval action the existing alarm
|
|
113
|
+
// path without creating a second email subscription.
|
|
114
|
+
const failureTopic = new sns.Topic(this, "FailureTopic", {
|
|
115
|
+
displayName: `${props.instance.displayName} deploy failures`,
|
|
116
|
+
});
|
|
117
|
+
const alarmEmail = props.alarmEmail ?? props.instance.aws.alarmEmail;
|
|
118
|
+
if (alarmEmail !== undefined && alarmEmail !== "")
|
|
119
|
+
failureTopic.addSubscription(new subscriptions.EmailSubscription(alarmEmail));
|
|
120
|
+
|
|
121
|
+
const project = new codebuild.PipelineProject(this, "Build", {
|
|
122
|
+
projectName: `${props.instance.naming.prefix}Deploy`,
|
|
123
|
+
description: `Build and deploy ${props.instance.name} from ${props.instance.github.repo}@${deploy.branch}`,
|
|
124
|
+
role: this.buildRole,
|
|
125
|
+
timeout: BUILD_TIMEOUT,
|
|
126
|
+
environment: {
|
|
127
|
+
// arm64 throughout: AgentCore runs arm64 only, and building natively
|
|
128
|
+
// beats qemu by minutes. `privileged` is what lets the build run a
|
|
129
|
+
// Docker daemon at all.
|
|
130
|
+
buildImage: codebuild.LinuxArmBuildImage.AMAZON_LINUX_2023_STANDARD_3_0,
|
|
131
|
+
computeType: codebuild.ComputeType.LARGE,
|
|
132
|
+
privileged: true,
|
|
133
|
+
},
|
|
134
|
+
// Best-effort and free: warm when the build lands on a host that ran a
|
|
135
|
+
// recent build, cold otherwise. `FOUNDATION_IMAGE_CACHE=local` is the deploy
|
|
136
|
+
// script's half of this — buildx with no cache export, because there is
|
|
137
|
+
// no Actions cache backend here to export to.
|
|
138
|
+
cache: codebuild.Cache.local(
|
|
139
|
+
codebuild.LocalCacheMode.DOCKER_LAYER,
|
|
140
|
+
codebuild.LocalCacheMode.SOURCE,
|
|
141
|
+
),
|
|
142
|
+
environmentVariables: {
|
|
143
|
+
// The instance file is the tool's only input. The path is the instance
|
|
144
|
+
// repository's own layout, so the buildspec and this value agree.
|
|
145
|
+
FOUNDATION_INSTANCE_FILE: { value: props.instanceFilePath },
|
|
146
|
+
// No `--profile`: the build's credentials are the role above.
|
|
147
|
+
FOUNDATION_NO_PROFILE: { value: "1" },
|
|
148
|
+
FOUNDATION_IMAGE_CACHE: { value: "local" },
|
|
149
|
+
// The buildspec runs `post-deploy` as its own phase, so the deploy
|
|
150
|
+
// command must not also promote `live`.
|
|
151
|
+
FOUNDATION_SKIP_POST_DEPLOY: { value: "1" },
|
|
152
|
+
},
|
|
153
|
+
// From the instance repository's source, not inline: that repo reviews
|
|
154
|
+
// and versions its own build, and a change to it needs no stack deploy.
|
|
155
|
+
buildSpec: codebuild.BuildSpec.fromSourceFilename(
|
|
156
|
+
props.buildspecPath ?? DEFAULT_BUILDSPEC_PATH,
|
|
157
|
+
),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const source = new codepipeline.Artifact("Source");
|
|
161
|
+
const sourceActions = [
|
|
162
|
+
new actions.CodeStarConnectionsSourceAction({
|
|
163
|
+
actionName: "GitHub",
|
|
164
|
+
connectionArn: deploy.connectionArn,
|
|
165
|
+
owner,
|
|
166
|
+
repo,
|
|
167
|
+
branch: deploy.branch,
|
|
168
|
+
output: source,
|
|
169
|
+
// Every merge to the tracked branch deploys, as merging to main has
|
|
170
|
+
// always done.
|
|
171
|
+
triggerOnPush: true,
|
|
172
|
+
}),
|
|
173
|
+
];
|
|
174
|
+
let customizationSource: codepipeline.Artifact | undefined;
|
|
175
|
+
let customizationSourceAction: actions.CodeStarConnectionsSourceAction | undefined;
|
|
176
|
+
if (customization !== undefined) {
|
|
177
|
+
const [customizationOwner = "", customizationRepo = ""] = customization.repository.split("/");
|
|
178
|
+
const customizationArtifact = new codepipeline.Artifact(CUSTOMIZATION_ARTIFACT_NAME);
|
|
179
|
+
customizationSource = customizationArtifact;
|
|
180
|
+
customizationSourceAction = new actions.CodeStarConnectionsSourceAction({
|
|
181
|
+
actionName: CUSTOMIZATION_ARTIFACT_NAME,
|
|
182
|
+
connectionArn: deploy.connectionArn,
|
|
183
|
+
owner: customizationOwner,
|
|
184
|
+
repo: customizationRepo,
|
|
185
|
+
branch: customization.branch,
|
|
186
|
+
output: customizationArtifact,
|
|
187
|
+
triggerOnPush: true,
|
|
188
|
+
});
|
|
189
|
+
sourceActions.push(customizationSourceAction);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const stages: codepipeline.StageProps[] = [{ stageName: "Source", actions: sourceActions }];
|
|
193
|
+
if (customization !== undefined && customizationSourceAction !== undefined) {
|
|
194
|
+
const customizationLinkPath = customization.path
|
|
195
|
+
.split("/")
|
|
196
|
+
.map((segment) => encodeURIComponent(segment))
|
|
197
|
+
.join("/");
|
|
198
|
+
stages.push({
|
|
199
|
+
stageName: "Approval",
|
|
200
|
+
actions: [
|
|
201
|
+
new actions.ManualApprovalAction({
|
|
202
|
+
actionName: "ReviewCustomization",
|
|
203
|
+
notificationTopic: failureTopic,
|
|
204
|
+
additionalInformation: `Review ${customization.path}: approve only intended runtime behavior; infrastructure and credentials stay deployment-owned.`,
|
|
205
|
+
externalEntityLink: `https://github.com/${customization.repository}/blob/${customizationSourceAction.variables.commitId}/${customizationLinkPath}`,
|
|
206
|
+
}),
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
stages.push({
|
|
211
|
+
stageName: "Deploy",
|
|
212
|
+
actions: [
|
|
213
|
+
new actions.CodeBuildAction({
|
|
214
|
+
actionName: "BuildAndDeploy",
|
|
215
|
+
project,
|
|
216
|
+
input: source,
|
|
217
|
+
extraInputs: customizationSource === undefined ? undefined : [customizationSource],
|
|
218
|
+
}),
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
this.pipeline = new codepipeline.Pipeline(this, "Pipeline", {
|
|
222
|
+
pipelineName: `${props.instance.naming.prefix}Deploy`,
|
|
223
|
+
// V2: cheaper for a pipeline that idles between merges, and the only
|
|
224
|
+
// type that takes triggers and variables.
|
|
225
|
+
pipelineType: codepipeline.PipelineType.V2,
|
|
226
|
+
restartExecutionOnUpdate: false,
|
|
227
|
+
stages,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Nobody watches a pipeline that usually succeeds, so a failure has to
|
|
231
|
+
// come and find someone. The topic and its one email subscription above
|
|
232
|
+
// also notify the same human when a customization needs approval.
|
|
233
|
+
this.pipeline.onStateChange("OnFailure", {
|
|
234
|
+
description: `${props.instance.name} deploy failed`,
|
|
235
|
+
eventPattern: { detail: { state: ["FAILED"] } },
|
|
236
|
+
target: new cdk.aws_events_targets.SnsTopic(failureTopic),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
new cdk.CfnOutput(this, "PipelineName", { value: this.pipeline.pipelineName });
|
|
240
|
+
new cdk.CfnOutput(this, "BuildRoleArn", { value: this.buildRole.roleArn });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Foundry 41 release account: where a Foundation release is published
|
|
3
|
+
* FROM, and what customers are allowed to read.
|
|
4
|
+
*
|
|
5
|
+
* This is the only stack in this package that is not an instance's. It is
|
|
6
|
+
* deployed once, by hand, into Foundry 41's own account, and it holds four
|
|
7
|
+
* things:
|
|
8
|
+
*
|
|
9
|
+
* - the release **bucket** — `releases/vX.Y.Z/{manifest.json,lambda/*.zip,
|
|
10
|
+
* skills.tar.gz}` — readable by the registered customer accounts;
|
|
11
|
+
* - the agent **image repository**, `foundation/agent`, pullable by the same
|
|
12
|
+
* accounts (the adopted decision: a private repository plus a policy
|
|
13
|
+
* listing customers, not ECR Public);
|
|
14
|
+
* - the **signing key**, asymmetric and sign/verify only, which the release
|
|
15
|
+
* workflow signs the manifest with and every deployer verifies against;
|
|
16
|
+
* - the **OIDC role** the release workflow assumes, trusted only for tag
|
|
17
|
+
* pushes on the Foundation repository.
|
|
18
|
+
*
|
|
19
|
+
* The customer account list is the customer list. Adding a customer is a
|
|
20
|
+
* parameter change and a redeploy of this stack — deliberately a Foundry 41
|
|
21
|
+
* action, and the only one in a customer's install path.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here is deployed by `foundation-deploy`, and no instance stack
|
|
24
|
+
* depends on it. `bin/release-account.ts` is its entry point.
|
|
25
|
+
*/
|
|
26
|
+
import * as cdk from "aws-cdk-lib";
|
|
27
|
+
import * as ecr from "aws-cdk-lib/aws-ecr";
|
|
28
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
29
|
+
import * as kms from "aws-cdk-lib/aws-kms";
|
|
30
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
31
|
+
import type { Construct } from "constructs";
|
|
32
|
+
|
|
33
|
+
export interface ReleaseAccountStackProps extends cdk.StackProps {
|
|
34
|
+
/** The release bucket's name. Global, so it is an input rather than derived. */
|
|
35
|
+
bucketName: string;
|
|
36
|
+
/** The agent image repository, without a registry. */
|
|
37
|
+
repositoryName?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Customer AWS account ids allowed to read releases and pull the image.
|
|
40
|
+
* May be empty before the first customer is registered.
|
|
41
|
+
*/
|
|
42
|
+
customerAccountIds: readonly string[];
|
|
43
|
+
/** `owner/repo` whose TAG pushes may publish a release. */
|
|
44
|
+
githubRepository: string;
|
|
45
|
+
/**
|
|
46
|
+
* The repository's immutable numeric ids.
|
|
47
|
+
*
|
|
48
|
+
* GitHub can issue the OIDC `sub` claim in two shapes, and which one it
|
|
49
|
+
* sends is the repository's setting, not ours: a repository with immutable
|
|
50
|
+
* subject claims on sends
|
|
51
|
+
* `repo:<owner>@<ownerId>/<repo>@<repoId>:ref:refs/tags/vX`, and the plain
|
|
52
|
+
* `repo:<owner>/<repo>:...` form never appears. Trusting only the plain form
|
|
53
|
+
* is why the first `v0.1.0` tag could not assume this role. `ci-stack` had
|
|
54
|
+
* always trusted both; this stack had not.
|
|
55
|
+
*/
|
|
56
|
+
githubRepositoryIds: { owner: number; repo: number };
|
|
57
|
+
/** Alias for the signing key, without the `alias/` prefix. */
|
|
58
|
+
signingKeyAlias?: string;
|
|
59
|
+
/** The role the release workflow assumes through OIDC. */
|
|
60
|
+
releaseRoleName?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ReleaseAccountStack extends cdk.Stack {
|
|
64
|
+
public readonly bucket: s3.Bucket;
|
|
65
|
+
public readonly repository: ecr.Repository;
|
|
66
|
+
public readonly signingKey: kms.Key;
|
|
67
|
+
public readonly releaseRole: iam.Role;
|
|
68
|
+
|
|
69
|
+
constructor(scope: Construct, id: string, props: ReleaseAccountStackProps) {
|
|
70
|
+
super(scope, id, props);
|
|
71
|
+
|
|
72
|
+
const repositoryName = props.repositoryName ?? "foundation/agent";
|
|
73
|
+
const customers = [...props.customerAccountIds];
|
|
74
|
+
// An empty list must not become a policy that names nobody and therefore
|
|
75
|
+
// grants nothing to everybody by accident; each grant below is omitted
|
|
76
|
+
// entirely until there is an account to name.
|
|
77
|
+
const customerPrincipals = customers.map((account) => new iam.AccountPrincipal(account));
|
|
78
|
+
|
|
79
|
+
this.bucket = new s3.Bucket(this, "ReleaseBucket", {
|
|
80
|
+
bucketName: props.bucketName,
|
|
81
|
+
// Every release is immutable, and versioning is what makes deleting one
|
|
82
|
+
// by accident recoverable rather than terminal.
|
|
83
|
+
versioned: true,
|
|
84
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
85
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
86
|
+
enforceSSL: true,
|
|
87
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
88
|
+
});
|
|
89
|
+
if (customerPrincipals.length > 0)
|
|
90
|
+
this.bucket.addToResourcePolicy(
|
|
91
|
+
new iam.PolicyStatement({
|
|
92
|
+
sid: "CustomersReadReleases",
|
|
93
|
+
principals: customerPrincipals,
|
|
94
|
+
actions: ["s3:GetObject", "s3:GetObjectVersion"],
|
|
95
|
+
// Only under `releases/`: nothing else in this bucket is published.
|
|
96
|
+
resources: [this.bucket.arnForObjects("releases/*")],
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
this.repository = new ecr.Repository(this, "AgentRepository", {
|
|
101
|
+
repositoryName,
|
|
102
|
+
imageScanOnPush: true,
|
|
103
|
+
// A release tag is written once and referenced by digest thereafter.
|
|
104
|
+
imageTagMutability: ecr.TagMutability.IMMUTABLE,
|
|
105
|
+
encryption: ecr.RepositoryEncryption.AES_256,
|
|
106
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
107
|
+
});
|
|
108
|
+
if (customerPrincipals.length > 0)
|
|
109
|
+
this.repository.addToResourcePolicy(
|
|
110
|
+
new iam.PolicyStatement({
|
|
111
|
+
sid: "CustomersPullAgentImage",
|
|
112
|
+
principals: customerPrincipals,
|
|
113
|
+
actions: [
|
|
114
|
+
"ecr:BatchGetImage",
|
|
115
|
+
"ecr:GetDownloadUrlForLayer",
|
|
116
|
+
"ecr:BatchCheckLayerAvailability",
|
|
117
|
+
"ecr:DescribeImages",
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Asymmetric and SIGN_VERIFY: the private half never leaves KMS, and a
|
|
123
|
+
// deployer needs only `kms:Verify`, which the policy below grants
|
|
124
|
+
// cross-account. RSA_4096 with RSASSA_PSS_SHA_256 is what
|
|
125
|
+
// `src/release/kms.ts` signs with.
|
|
126
|
+
this.signingKey = new kms.Key(this, "ReleaseSigningKey", {
|
|
127
|
+
alias: props.signingKeyAlias ?? "foundation-release",
|
|
128
|
+
description: "Signs Foundation release manifests. Customers verify against it.",
|
|
129
|
+
keySpec: kms.KeySpec.RSA_4096,
|
|
130
|
+
keyUsage: kms.KeyUsage.SIGN_VERIFY,
|
|
131
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
132
|
+
});
|
|
133
|
+
if (customerPrincipals.length > 0)
|
|
134
|
+
this.signingKey.addToResourcePolicy(
|
|
135
|
+
new iam.PolicyStatement({
|
|
136
|
+
sid: "CustomersVerifyManifests",
|
|
137
|
+
principals: customerPrincipals,
|
|
138
|
+
// Verify and GetPublicKey only. Nothing here can sign.
|
|
139
|
+
actions: ["kms:Verify", "kms:GetPublicKey", "kms:DescribeKey"],
|
|
140
|
+
resources: ["*"],
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Imported, not created: an account may hold only one provider per url, and
|
|
145
|
+
// a `cdk destroy` of a created one would take it from every other
|
|
146
|
+
// repository that trusts GitHub.
|
|
147
|
+
//
|
|
148
|
+
// A DEDICATED release account starts with no provider at all, though, and
|
|
149
|
+
// then this import silently yields an ARN that does not resolve: the role
|
|
150
|
+
// is created, and every assume fails with "the web identity token provided
|
|
151
|
+
// could not be validated". That is what happened to this account. Create it
|
|
152
|
+
// once, before deploying this stack:
|
|
153
|
+
//
|
|
154
|
+
// aws iam create-open-id-connect-provider \
|
|
155
|
+
// --url https://token.actions.githubusercontent.com \
|
|
156
|
+
// --client-id-list sts.amazonaws.com \
|
|
157
|
+
// --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
|
|
158
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
159
|
+
this,
|
|
160
|
+
"GitHubOidc",
|
|
161
|
+
`arn:${this.partition}:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const [owner, repo] = props.githubRepository.split("/");
|
|
165
|
+
const ids = props.githubRepositoryIds;
|
|
166
|
+
const tagSubjects = [
|
|
167
|
+
`repo:${props.githubRepository}:ref:refs/tags/v*`,
|
|
168
|
+
`repo:${owner}@${ids.owner}/${repo}@${ids.repo}:ref:refs/tags/v*`,
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
this.releaseRole = new iam.Role(this, "ReleaseRole", {
|
|
172
|
+
roleName: props.releaseRoleName ?? "FoundationRelease",
|
|
173
|
+
description: `Publishes Foundation releases from tag pushes on ${props.githubRepository}`,
|
|
174
|
+
maxSessionDuration: cdk.Duration.hours(1),
|
|
175
|
+
assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
|
|
176
|
+
StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
|
|
177
|
+
// TAGS ONLY. A branch push, a pull request and a workflow_dispatch all
|
|
178
|
+
// carry a different subject, so none of them can publish a release.
|
|
179
|
+
// Both shapes GitHub can issue, for the reason on
|
|
180
|
+
// {@link ReleaseAccountStackProps.githubRepositoryIds}.
|
|
181
|
+
StringLike: { "token.actions.githubusercontent.com:sub": tagSubjects },
|
|
182
|
+
}),
|
|
183
|
+
inlinePolicies: {
|
|
184
|
+
PublishRelease: new iam.PolicyDocument({
|
|
185
|
+
statements: [
|
|
186
|
+
new iam.PolicyStatement({
|
|
187
|
+
sid: "UploadRelease",
|
|
188
|
+
// No delete: a release is written once. Overwriting one is a
|
|
189
|
+
// separate, deliberate act by a human with other credentials.
|
|
190
|
+
actions: ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
|
|
191
|
+
resources: [this.bucket.bucketArn, this.bucket.arnForObjects("releases/*")],
|
|
192
|
+
}),
|
|
193
|
+
new iam.PolicyStatement({
|
|
194
|
+
sid: "PushAgentImage",
|
|
195
|
+
actions: [
|
|
196
|
+
"ecr:BatchCheckLayerAvailability",
|
|
197
|
+
"ecr:CompleteLayerUpload",
|
|
198
|
+
"ecr:InitiateLayerUpload",
|
|
199
|
+
"ecr:PutImage",
|
|
200
|
+
"ecr:UploadLayerPart",
|
|
201
|
+
"ecr:BatchGetImage",
|
|
202
|
+
"ecr:DescribeImages",
|
|
203
|
+
],
|
|
204
|
+
resources: [this.repository.repositoryArn],
|
|
205
|
+
}),
|
|
206
|
+
new iam.PolicyStatement({
|
|
207
|
+
sid: "EcrLogin",
|
|
208
|
+
actions: ["ecr:GetAuthorizationToken"],
|
|
209
|
+
resources: ["*"],
|
|
210
|
+
}),
|
|
211
|
+
new iam.PolicyStatement({
|
|
212
|
+
sid: "SignManifest",
|
|
213
|
+
actions: ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
|
|
214
|
+
resources: [this.signingKey.keyArn],
|
|
215
|
+
}),
|
|
216
|
+
],
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The three values a release needs: the workflow's variables, and the key
|
|
222
|
+
// ARN customers verify against.
|
|
223
|
+
new cdk.CfnOutput(this, "ReleaseBucketName", { value: this.bucket.bucketName });
|
|
224
|
+
new cdk.CfnOutput(this, "AgentRepositoryUri", { value: this.repository.repositoryUri });
|
|
225
|
+
new cdk.CfnOutput(this, "SigningKeyArn", { value: this.signingKey.keyArn });
|
|
226
|
+
new cdk.CfnOutput(this, "ReleaseRoleArn", { value: this.releaseRole.roleArn });
|
|
227
|
+
new cdk.CfnOutput(this, "CustomerAccountIds", { value: customers.join(",") });
|
|
228
|
+
}
|
|
229
|
+
}
|