@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,96 @@
|
|
|
1
|
+
import * as cdk from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import type * as kms from "aws-cdk-lib/aws-kms";
|
|
4
|
+
import type * as s3 from "aws-cdk-lib/aws-s3";
|
|
5
|
+
import type { Construct } from "constructs";
|
|
6
|
+
import { deployStatements } from "../deploy-permissions.ts";
|
|
7
|
+
import { type Instance, namesFor } from "../names.ts";
|
|
8
|
+
|
|
9
|
+
export interface FoundationCiProps extends cdk.StackProps {
|
|
10
|
+
/** The deployment this stack belongs to; every name below comes from it. */
|
|
11
|
+
instance: Instance;
|
|
12
|
+
/** The data stack's bucket — config and skills are synced into it by the deploy. */
|
|
13
|
+
bucket: s3.IBucket;
|
|
14
|
+
/** The CMK that bucket is encrypted with. */
|
|
15
|
+
dataKey: kms.IKey;
|
|
16
|
+
/** `owner/repo` allowed to assume the deploy role. */
|
|
17
|
+
repository?: string;
|
|
18
|
+
/**
|
|
19
|
+
* GitHub's numeric owner and repository ids. GitHub now issues the
|
|
20
|
+
* rename-proof subject `repo:<owner>@<ownerId>/<repo>@<repoId>:ref:…` by
|
|
21
|
+
* default; without these the trust policy never matches.
|
|
22
|
+
*/
|
|
23
|
+
repositoryIds?: { owner: number; repo: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* FoundationCi — the GitHub OIDC trust for the deploy job in the instance
|
|
28
|
+
* repository's own workflow. One per instance: each has its own repo, its own
|
|
29
|
+
* account and its own deploy role.
|
|
30
|
+
*
|
|
31
|
+
* No long-lived AWS keys live in GitHub: Actions presents its short-lived
|
|
32
|
+
* OIDC token and assumes the instance's deploy role, whose trust policy pins both the
|
|
33
|
+
* audience and the exact `sub` — `repo:<owner>/<repo>:ref:refs/heads/main`.
|
|
34
|
+
* Pull requests carry a different `sub` (`…:pull_request`) and so cannot
|
|
35
|
+
* assume it; that is what keeps a fork's PR from deploying.
|
|
36
|
+
*
|
|
37
|
+
* The permissions live in `deploy-permissions.ts`, shared with the CodeBuild
|
|
38
|
+
* role in `pipeline-stack.ts`: both run the same deploy scripts, and two
|
|
39
|
+
* copies of that policy would drift. They are admin-equivalent in this account
|
|
40
|
+
* by design (`sts:AssumeRole` on the CDK bootstrap roles), which is why the
|
|
41
|
+
* trust policy above, not the permission policy, is the security boundary.
|
|
42
|
+
* Keep `main` protected.
|
|
43
|
+
*/
|
|
44
|
+
export class FoundationCi extends cdk.Stack {
|
|
45
|
+
public readonly deployRole: iam.Role;
|
|
46
|
+
|
|
47
|
+
constructor(scope: Construct, id: string, props: FoundationCiProps) {
|
|
48
|
+
super(scope, id, props);
|
|
49
|
+
const names = namesFor(props.instance);
|
|
50
|
+
|
|
51
|
+
const repository = props.repository ?? props.instance.github.repo;
|
|
52
|
+
const ids = props.repositoryIds ?? props.instance.github.repoIds;
|
|
53
|
+
const [owner, repo] = repository.split("/");
|
|
54
|
+
// Both subject shapes GitHub can issue for a push to main: the plain
|
|
55
|
+
// `owner/repo` form and the immutable-id form seen in CloudTrail
|
|
56
|
+
// (`repo:<owner>@<ownerId>/<repo>@<repoId>:ref:refs/heads/main`).
|
|
57
|
+
const mainSubjects = [
|
|
58
|
+
`repo:${repository}:ref:refs/heads/main`,
|
|
59
|
+
`repo:${owner}@${ids.owner}/${repo}@${ids.repo}:ref:refs/heads/main`,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// The provider is account-wide and already exists (an account can hold
|
|
63
|
+
// only one per url), so it is imported, never created: CDK would fail on
|
|
64
|
+
// the duplicate and a `cdk destroy` here would take it from every other
|
|
65
|
+
// repo that trusts it.
|
|
66
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
67
|
+
this,
|
|
68
|
+
"GitHubOidc",
|
|
69
|
+
`arn:${this.partition}:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
this.deployRole = new iam.Role(this, "FoundationDeployRole", {
|
|
73
|
+
roleName: names.deployRole,
|
|
74
|
+
description: `GitHub Actions deploy role for ${repository} (main only)`,
|
|
75
|
+
maxSessionDuration: cdk.Duration.hours(1),
|
|
76
|
+
assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
|
|
77
|
+
StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
|
|
78
|
+
StringLike: {
|
|
79
|
+
"token.actions.githubusercontent.com:sub": mainSubjects,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
inlinePolicies: {
|
|
83
|
+
FoundationDeploy: new iam.PolicyDocument({
|
|
84
|
+
statements: deployStatements(
|
|
85
|
+
this,
|
|
86
|
+
names,
|
|
87
|
+
{ bucket: props.bucket, dataKey: props.dataKey },
|
|
88
|
+
props.instance,
|
|
89
|
+
),
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
new cdk.CfnOutput(this, "DeployRoleArn", { value: this.deployRole.roleArn });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import * as cdk from "aws-cdk-lib";
|
|
2
|
+
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import * as kms from "aws-cdk-lib/aws-kms";
|
|
5
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
6
|
+
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
|
|
7
|
+
import type { Construct } from "constructs";
|
|
8
|
+
import { type Instance, namesFor, provisionsIntegration } from "../names.ts";
|
|
9
|
+
|
|
10
|
+
export interface FoundationDataProps extends cdk.StackProps {
|
|
11
|
+
/** The deployment this stack belongs to; every name below comes from it. */
|
|
12
|
+
instance: Instance;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* FoundationData — the durable layer: one CMK, two S3 buckets (config +
|
|
17
|
+
* skills, and channel documents), two always-present DynamoDB tables, an
|
|
18
|
+
* optional CRM table, an optional proxy-only Upwork approval table, an optional
|
|
19
|
+
* retained Upwork OAuth secret, and the Secrets Manager entries.
|
|
20
|
+
* The two always-present DynamoDB tables use
|
|
21
|
+
* different access patterns: `FoundationTable` is `pk` (S) only:
|
|
22
|
+
* dedupe rows, the Codex credential lease and the thread→session index, every
|
|
23
|
+
* one of them a single-item get/put under a prefixed key
|
|
24
|
+
* (`dedupe#…`, `lease#…`, `thread#…`) — matching `DynamoLeaseBackend` and
|
|
25
|
+
* `ts/gateway/src/lambda.ts` from Plan 2. `FoundationItems` is `pk` + `sk`, for the
|
|
26
|
+
* collections the agent reads back whole: a channel's todos, a channel's
|
|
27
|
+
* routines. Those need a Query over a partition, which a table without a sort
|
|
28
|
+
* key cannot answer.
|
|
29
|
+
*
|
|
30
|
+
* Secrets: the signing, app and GitHub App entries already exist in the
|
|
31
|
+
* account (created out of band before the first deploy), so they are
|
|
32
|
+
* *imported* by name and never managed here — CDK would otherwise try to
|
|
33
|
+
* create and then own them. The remaining managed entries are created as
|
|
34
|
+
* empty shells (the generateSecretString placeholder trick) and filled in by
|
|
35
|
+
* `scripts/setup.ts`. Durable data and credential shells use RETAIN: a
|
|
36
|
+
* `cdk destroy` must never take the credentials or the table with
|
|
37
|
+
* it.
|
|
38
|
+
*/
|
|
39
|
+
export class FoundationData extends cdk.Stack {
|
|
40
|
+
public readonly dataKey: kms.Key;
|
|
41
|
+
public readonly bucket: s3.Bucket;
|
|
42
|
+
/** Dedicated encrypted, versioned store for durable per-channel documents. */
|
|
43
|
+
public readonly documentBucket: s3.Bucket;
|
|
44
|
+
/** S3 Files file system over the bucket's `fs/` prefix — the persistent mount. */
|
|
45
|
+
public readonly fileSystem: cdk.CfnResource;
|
|
46
|
+
/** Its single access point (POSIX 10001:10001 = the container's `sky` user). */
|
|
47
|
+
public readonly table: dynamodb.Table;
|
|
48
|
+
/** `pk` + `sk`: the agent's queryable collections (todos, routines). */
|
|
49
|
+
public readonly itemsTable: dynamodb.Table;
|
|
50
|
+
/** Dedicated profile/activity table for an enabled self-hosted CRM integration. */
|
|
51
|
+
public readonly crmTable?: dynamodb.Table;
|
|
52
|
+
/** Ephemeral proposal drafts; accessible only to the credential-isolated Upwork proxy. */
|
|
53
|
+
public readonly upworkApprovalTable?: dynamodb.Table;
|
|
54
|
+
public readonly secrets: {
|
|
55
|
+
signing: secretsmanager.ISecret;
|
|
56
|
+
slackApp: secretsmanager.ISecret;
|
|
57
|
+
githubApp: secretsmanager.ISecret;
|
|
58
|
+
codex: secretsmanager.ISecret;
|
|
59
|
+
googleAiStudio: secretsmanager.ISecret;
|
|
60
|
+
googleDrive: secretsmanager.ISecret;
|
|
61
|
+
googleCalendar: secretsmanager.ISecret;
|
|
62
|
+
googleOauth: secretsmanager.ISecret;
|
|
63
|
+
googleEmail: secretsmanager.ISecret;
|
|
64
|
+
mongodbReadonly: secretsmanager.ISecret;
|
|
65
|
+
otterApi?: secretsmanager.ISecret;
|
|
66
|
+
/** Dynamic public Knock OAuth client; no client secret is stored. */
|
|
67
|
+
knockOauthClient?: secretsmanager.ISecret;
|
|
68
|
+
/** Connected Knock OAuth credential, readable only by its fixed proxy. */
|
|
69
|
+
knockCredential?: secretsmanager.ISecret;
|
|
70
|
+
/** Combined Upwork OAuth client and token secret, provisioned only when opted in. */
|
|
71
|
+
upwork?: secretsmanager.ISecret;
|
|
72
|
+
runtime: secretsmanager.ISecret;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
constructor(scope: Construct, id: string, props: FoundationDataProps) {
|
|
76
|
+
super(scope, id, props);
|
|
77
|
+
const names = namesFor(props.instance);
|
|
78
|
+
const display = props.instance.displayName;
|
|
79
|
+
// `crmStorage` is the one integration switch that is deliberately NOT the
|
|
80
|
+
// registry's `crm` flag: storage outlives the proxy so pausing the tools
|
|
81
|
+
// cannot orphan a retained table CloudFormation could not re-adopt.
|
|
82
|
+
const crmStorage = props.instance.integrations.crmStorage;
|
|
83
|
+
|
|
84
|
+
this.dataKey = new kms.Key(this, "FoundationDataKey", {
|
|
85
|
+
description: `${display} CMK for the S3 buckets, DynamoDB table and SQS queues`,
|
|
86
|
+
enableKeyRotation: true,
|
|
87
|
+
alias: names.keyAlias,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.bucket = new s3.Bucket(this, "FoundationBucket", {
|
|
91
|
+
versioned: true,
|
|
92
|
+
encryption: s3.BucketEncryption.KMS,
|
|
93
|
+
encryptionKey: this.dataKey,
|
|
94
|
+
bucketKeyEnabled: true,
|
|
95
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
96
|
+
enforceSSL: true,
|
|
97
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.documentBucket = new s3.Bucket(this, "DocumentsBucket", {
|
|
101
|
+
versioned: true,
|
|
102
|
+
encryption: s3.BucketEncryption.KMS,
|
|
103
|
+
encryptionKey: this.dataKey,
|
|
104
|
+
bucketKeyEnabled: true,
|
|
105
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
106
|
+
enforceSSL: true,
|
|
107
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.table = new dynamodb.Table(this, "FoundationTable", {
|
|
111
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
112
|
+
timeToLiveAttribute: "ttl",
|
|
113
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
114
|
+
encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
|
|
115
|
+
encryptionKey: this.dataKey,
|
|
116
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
117
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.itemsTable = new dynamodb.Table(this, "FoundationItems", {
|
|
121
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
122
|
+
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
|
123
|
+
timeToLiveAttribute: "ttl",
|
|
124
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
125
|
+
encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
|
|
126
|
+
encryptionKey: this.dataKey,
|
|
127
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
128
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
this.crmTable = crmStorage
|
|
132
|
+
? new dynamodb.Table(this, "CrmTable", {
|
|
133
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
134
|
+
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
|
135
|
+
timeToLiveAttribute: "ttl",
|
|
136
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
137
|
+
encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
|
|
138
|
+
encryptionKey: this.dataKey,
|
|
139
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
140
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
141
|
+
})
|
|
142
|
+
: undefined;
|
|
143
|
+
this.crmTable?.addGlobalSecondaryIndex({
|
|
144
|
+
indexName: "RecordsIndex",
|
|
145
|
+
partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
|
|
146
|
+
sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING },
|
|
147
|
+
projectionType: dynamodb.ProjectionType.INCLUDE,
|
|
148
|
+
nonKeyAttributes: ["recordType", "recordId"],
|
|
149
|
+
});
|
|
150
|
+
this.upworkApprovalTable = provisionsIntegration(props.instance, "upwork")
|
|
151
|
+
? new dynamodb.Table(this, "UpworkApprovalTable", {
|
|
152
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
153
|
+
timeToLiveAttribute: "ttl",
|
|
154
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
155
|
+
encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
|
|
156
|
+
encryptionKey: this.dataKey,
|
|
157
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
158
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
159
|
+
})
|
|
160
|
+
: undefined;
|
|
161
|
+
|
|
162
|
+
// An empty shell CDK is willing to synth without a real value; the real
|
|
163
|
+
// value is written out of band and survives every later deploy because
|
|
164
|
+
// CDK only manages the resource, not its current version.
|
|
165
|
+
const secretShell = (constructId: string, name: string, description: string) =>
|
|
166
|
+
new secretsmanager.Secret(this, constructId, {
|
|
167
|
+
secretName: name,
|
|
168
|
+
description,
|
|
169
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
170
|
+
generateSecretString: {
|
|
171
|
+
secretStringTemplate: "{}",
|
|
172
|
+
generateStringKey: "placeholder",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
// Unlike long-lived auth stores, this integration credential is destroyed
|
|
176
|
+
// with the opt-in resources. That avoids stranding a retained fixed-name
|
|
177
|
+
// secret which CloudFormation could not adopt if the integration is later
|
|
178
|
+
// re-enabled. Operators must repopulate the key after a disable/re-enable.
|
|
179
|
+
const otterApi = provisionsIntegration(props.instance, "otter")
|
|
180
|
+
? new secretsmanager.Secret(this, "OtterApiSecret", {
|
|
181
|
+
secretName: names.secretOtterApi,
|
|
182
|
+
description: `Otter Enterprise Public API key for ${display} ({ api_key })`,
|
|
183
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
184
|
+
generateSecretString: {
|
|
185
|
+
secretStringTemplate: "{}",
|
|
186
|
+
generateStringKey: "placeholder",
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
: undefined;
|
|
190
|
+
// Knock's public-client registration and connected credential are distinct:
|
|
191
|
+
// the deployer writes only the former, while the callback writes only the
|
|
192
|
+
// latter. Both are destroyed with the opt-in integration so a later
|
|
193
|
+
// re-enable must deliberately reconnect a tenant.
|
|
194
|
+
const knockOauthClient = provisionsIntegration(props.instance, "knock")
|
|
195
|
+
? new secretsmanager.Secret(this, "KnockOauthClientSecret", {
|
|
196
|
+
secretName: names.secretKnockOauthClient,
|
|
197
|
+
description: `Knock public OAuth client for ${display} ({ client_id, redirect_uri })`,
|
|
198
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
199
|
+
generateSecretString: {
|
|
200
|
+
secretStringTemplate: "{}",
|
|
201
|
+
generateStringKey: "placeholder",
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
: undefined;
|
|
205
|
+
const knockCredential = provisionsIntegration(props.instance, "knock")
|
|
206
|
+
? new secretsmanager.Secret(this, "KnockCredentialSecret", {
|
|
207
|
+
secretName: names.secretKnockCredential,
|
|
208
|
+
description: `Knock OAuth credential for ${display}; callback writes and proxy alone reads`,
|
|
209
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
210
|
+
generateSecretString: {
|
|
211
|
+
secretStringTemplate: "{}",
|
|
212
|
+
generateStringKey: "placeholder",
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
: undefined;
|
|
216
|
+
|
|
217
|
+
// Unlike the disposable Otter API key, the Upwork OAuth secret carries
|
|
218
|
+
// client credentials plus refresh state. Retaining it makes a deploy safe
|
|
219
|
+
// and leaves manually entered client credentials intact.
|
|
220
|
+
const upwork = provisionsIntegration(props.instance, "upwork")
|
|
221
|
+
? secretShell(
|
|
222
|
+
"UpworkSecret",
|
|
223
|
+
names.secretUpwork,
|
|
224
|
+
`Upwork OAuth client and token store for ${display} ({ client_id, client_secret, access_token?, refresh_token?, expires_at?, tenant_id? })`,
|
|
225
|
+
)
|
|
226
|
+
: undefined;
|
|
227
|
+
|
|
228
|
+
this.secrets = {
|
|
229
|
+
// Already created out of band — import, never manage.
|
|
230
|
+
signing: secretsmanager.Secret.fromSecretNameV2(
|
|
231
|
+
this,
|
|
232
|
+
"SlackSigningSecret",
|
|
233
|
+
names.secretSlackSigning,
|
|
234
|
+
),
|
|
235
|
+
slackApp: secretsmanager.Secret.fromSecretNameV2(
|
|
236
|
+
this,
|
|
237
|
+
"SlackAppSecret",
|
|
238
|
+
names.secretSlackApp,
|
|
239
|
+
),
|
|
240
|
+
githubApp: secretsmanager.Secret.fromSecretNameV2(
|
|
241
|
+
this,
|
|
242
|
+
"GithubAppSecret",
|
|
243
|
+
names.secretGithubApp,
|
|
244
|
+
),
|
|
245
|
+
codex: secretShell(
|
|
246
|
+
"CodexSecret",
|
|
247
|
+
names.secretCodex,
|
|
248
|
+
"OpenAI Codex credential store (rotated tokens are written back here)",
|
|
249
|
+
),
|
|
250
|
+
// The image-generation key. A placeholder is fine: the container reads
|
|
251
|
+
// it lazily, so `generate_image` reports "not configured" until the
|
|
252
|
+
// real value is written out of band.
|
|
253
|
+
googleAiStudio: new secretsmanager.Secret(this, "GoogleAiStudioSecret", {
|
|
254
|
+
secretName: names.secretGoogleAiStudio,
|
|
255
|
+
encryptionKey: this.dataKey,
|
|
256
|
+
description: "Google AI Studio API key for image generation ({ api_key })",
|
|
257
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
258
|
+
generateSecretString: {
|
|
259
|
+
secretStringTemplate: "{}",
|
|
260
|
+
generateStringKey: "placeholder",
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
googleDrive: new secretsmanager.Secret(this, "GoogleDriveSecret", {
|
|
264
|
+
secretName: names.secretGoogleDrive,
|
|
265
|
+
encryptionKey: this.dataKey,
|
|
266
|
+
description: `Google Drive service identity: the company-owned Drive account ${display} reads as. JSON { client_email, private_key, token_uri? }, optionally nested under identities.default. Access is granted by sharing files/folders with client_email; scope is read-only.`,
|
|
267
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
268
|
+
generateSecretString: {
|
|
269
|
+
secretStringTemplate: "{}",
|
|
270
|
+
generateStringKey: "placeholder",
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
// The Google Calendar OAuth *client* — never a person's refresh token,
|
|
274
|
+
// which lives per-user in `FoundationItems`. A placeholder is fine: both the
|
|
275
|
+
// agent and the gateway treat an unparseable value as "capability not
|
|
276
|
+
// configured" rather than a boot failure.
|
|
277
|
+
googleCalendar: secretShell(
|
|
278
|
+
"GoogleCalendarSecret",
|
|
279
|
+
names.secretGoogleCalendar,
|
|
280
|
+
`Google Calendar OAuth client for ${display}'s per-person calendar connections`,
|
|
281
|
+
),
|
|
282
|
+
// The connected mailbox. Written by the gateway's OAuth callback and
|
|
283
|
+
// read by the EMAIL PROXY LAMBDA'S ROLE ALONE — the agent execution role
|
|
284
|
+
// carries an explicit Deny on it, which is what makes it true that no
|
|
285
|
+
// credential able to send mail exists inside the container.
|
|
286
|
+
googleEmail: secretShell(
|
|
287
|
+
"GoogleEmailSecret",
|
|
288
|
+
names.secretGoogleEmail,
|
|
289
|
+
`Gmail identity for ${display}, connected from Slack — written by the gateway, read only by the email proxy`,
|
|
290
|
+
),
|
|
291
|
+
// The read-only Atlas connection string, `{ uri, databases? }`. A
|
|
292
|
+
// placeholder is fine and is what ships: the agent reads it on first
|
|
293
|
+
// tool use, so an unfilled secret means "no MongoDB", not a boot error.
|
|
294
|
+
mongodbReadonly: new secretsmanager.Secret(this, "MongodbReadonlySecret", {
|
|
295
|
+
secretName: names.secretMongodbReadonly,
|
|
296
|
+
encryptionKey: this.dataKey,
|
|
297
|
+
description: `Read-only MongoDB Atlas credential for ${display}. JSON { uri, databases? } where uri authenticates an Atlas user provisioned with the \`read\` role, and databases (optional) narrows which databases the agent may name.`,
|
|
298
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
299
|
+
generateSecretString: {
|
|
300
|
+
secretStringTemplate: "{}",
|
|
301
|
+
generateStringKey: "placeholder",
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
...(otterApi === undefined ? {} : { otterApi }),
|
|
305
|
+
...(knockOauthClient === undefined ? {} : { knockOauthClient }),
|
|
306
|
+
...(knockCredential === undefined ? {} : { knockCredential }),
|
|
307
|
+
...(upwork === undefined ? {} : { upwork }),
|
|
308
|
+
// The OAuth client every Google capability shares: Calendar builds a
|
|
309
|
+
// per-person consent link with it, Drive connects the company identity
|
|
310
|
+
// with it. Never a refresh token. A placeholder is fine — an unparseable
|
|
311
|
+
// value reads as "not configured", not as a boot failure.
|
|
312
|
+
googleOauth: secretShell(
|
|
313
|
+
"GoogleOauthSecret",
|
|
314
|
+
names.secretGoogleOauth,
|
|
315
|
+
`Google OAuth client shared by ${display}'s Google capabilities ({ client_id, client_secret, redirect_uri })`,
|
|
316
|
+
),
|
|
317
|
+
runtime: secretShell(
|
|
318
|
+
"RuntimeSecret",
|
|
319
|
+
names.secretRuntime,
|
|
320
|
+
"Agent runtime env map, written by scripts/setup.ts after deploy",
|
|
321
|
+
),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
this.fileSystem = this.buildFileSystem(display);
|
|
325
|
+
|
|
326
|
+
new cdk.CfnOutput(this, "FoundationFileSystemId", {
|
|
327
|
+
value: this.fileSystem.getAtt("FileSystemId").toString(),
|
|
328
|
+
});
|
|
329
|
+
new cdk.CfnOutput(this, "BucketName", { value: this.bucket.bucketName });
|
|
330
|
+
new cdk.CfnOutput(this, "DocumentsBucketName", { value: this.documentBucket.bucketName });
|
|
331
|
+
new cdk.CfnOutput(this, "TableName", { value: this.table.tableName });
|
|
332
|
+
new cdk.CfnOutput(this, "ItemsTableName", { value: this.itemsTable.tableName });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The S3 Files file system that backs `/mnt/sky` in the agent container.
|
|
337
|
+
*
|
|
338
|
+
* It is a *view* of this bucket under the `fs/` prefix, so learned skills
|
|
339
|
+
* and caches are ordinary S3 objects an operator can list and read — that
|
|
340
|
+
* visibility is the whole reason S3 Files was chosen over EFS. The service
|
|
341
|
+
* reaches the bucket as its own role, not as the caller's, hence the
|
|
342
|
+
* service role below; `AcceptBucketWarning` is the acknowledgement that
|
|
343
|
+
* other writers to the same prefix could confuse the file-system view
|
|
344
|
+
* (nothing else writes under `fs/`).
|
|
345
|
+
*/
|
|
346
|
+
private buildFileSystem(display: string): cdk.CfnResource {
|
|
347
|
+
const role = new iam.Role(this, "S3FilesServiceRole", {
|
|
348
|
+
description: `S3 Files service role for the ${display} persistent mount`,
|
|
349
|
+
// S3 Files runs on the EFS control plane: the documented trust principal is
|
|
350
|
+
// elasticfilesystem.amazonaws.com, scoped to this account's s3files file systems
|
|
351
|
+
// (docs: AmazonS3/latest/userguide/s3-files-prereq-policies.html).
|
|
352
|
+
assumedBy: new iam.ServicePrincipal("elasticfilesystem.amazonaws.com", {
|
|
353
|
+
conditions: {
|
|
354
|
+
StringEquals: { "aws:SourceAccount": this.account },
|
|
355
|
+
ArnLike: {
|
|
356
|
+
"aws:SourceArn": `arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/*`,
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
role.addToPolicy(
|
|
362
|
+
new iam.PolicyStatement({
|
|
363
|
+
sid: "MountObjects",
|
|
364
|
+
actions: [
|
|
365
|
+
"s3:AbortMultipartUpload",
|
|
366
|
+
"s3:DeleteObject*",
|
|
367
|
+
"s3:GetObject*",
|
|
368
|
+
"s3:List*",
|
|
369
|
+
"s3:PutObject*",
|
|
370
|
+
],
|
|
371
|
+
resources: [this.bucket.arnForObjects("fs/*")],
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
role.addToPolicy(
|
|
375
|
+
new iam.PolicyStatement({
|
|
376
|
+
sid: "MountBucket",
|
|
377
|
+
actions: [
|
|
378
|
+
"s3:ListBucket",
|
|
379
|
+
"s3:ListBucketVersions",
|
|
380
|
+
"s3:GetBucketLocation",
|
|
381
|
+
"s3:ListBucketMultipartUploads",
|
|
382
|
+
],
|
|
383
|
+
resources: [this.bucket.bucketArn],
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
role.addToPolicy(
|
|
387
|
+
new iam.PolicyStatement({
|
|
388
|
+
sid: "MountKeyUsage",
|
|
389
|
+
actions: [
|
|
390
|
+
"kms:Decrypt",
|
|
391
|
+
"kms:Encrypt",
|
|
392
|
+
"kms:GenerateDataKey*",
|
|
393
|
+
"kms:ReEncryptFrom",
|
|
394
|
+
"kms:ReEncryptTo",
|
|
395
|
+
"kms:DescribeKey",
|
|
396
|
+
],
|
|
397
|
+
resources: [this.dataKey.keyArn],
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
// S3 Files manages EventBridge rules for bucket→file-system synchronization.
|
|
401
|
+
role.addToPolicy(
|
|
402
|
+
new iam.PolicyStatement({
|
|
403
|
+
sid: "EventBridgeManage",
|
|
404
|
+
actions: [
|
|
405
|
+
"events:DeleteRule",
|
|
406
|
+
"events:DisableRule",
|
|
407
|
+
"events:EnableRule",
|
|
408
|
+
"events:PutRule",
|
|
409
|
+
"events:PutTargets",
|
|
410
|
+
"events:RemoveTargets",
|
|
411
|
+
],
|
|
412
|
+
resources: [`arn:${this.partition}:events:*:*:rule/DO-NOT-DELETE-S3-Files*`],
|
|
413
|
+
conditions: { StringEquals: { "events:ManagedBy": "elasticfilesystem.amazonaws.com" } },
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
role.addToPolicy(
|
|
417
|
+
new iam.PolicyStatement({
|
|
418
|
+
sid: "EventBridgeRead",
|
|
419
|
+
actions: [
|
|
420
|
+
"events:DescribeRule",
|
|
421
|
+
"events:ListRuleNamesByTarget",
|
|
422
|
+
"events:ListRules",
|
|
423
|
+
"events:ListTargetsByRule",
|
|
424
|
+
],
|
|
425
|
+
resources: [`arn:${this.partition}:events:*:*:rule/*`],
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const fileSystem = new cdk.CfnResource(this, "FoundationFileSystem", {
|
|
430
|
+
type: "AWS::S3Files::FileSystem",
|
|
431
|
+
properties: {
|
|
432
|
+
Bucket: this.bucket.bucketArn,
|
|
433
|
+
Prefix: "fs/",
|
|
434
|
+
KmsKeyId: this.dataKey.keyArn,
|
|
435
|
+
RoleArn: role.roleArn,
|
|
436
|
+
AcceptBucketWarning: true,
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
fileSystem.node.addDependency(role);
|
|
440
|
+
// Same reasoning as the bucket: a `cdk destroy` must not take the
|
|
441
|
+
// learned skills with it. The objects survive regardless, but the
|
|
442
|
+
// file-system view is what makes them a mount.
|
|
443
|
+
fileSystem.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
|
|
444
|
+
return fileSystem;
|
|
445
|
+
}
|
|
446
|
+
}
|