@deployfoundation/foundation-deploy 0.2.51 → 0.2.52
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 +4 -1
- package/dist/bin/app.js +6 -3
- package/dist/bin/foundation-deploy.js +139 -305
- package/dist/bin/release-account.js +1 -0
- package/dist/chunk-3614v9rr.js +907 -0
- package/dist/{chunk-efqdhd2e.js → chunk-59dpqyap.js} +254 -90
- package/dist/chunk-5wgwscd0.js +4 -0
- package/dist/{chunk-gnz747z2.js → chunk-96fjaysn.js} +204 -884
- package/dist/chunk-9zx95xs6.js +259 -0
- package/dist/chunk-jf5rw6h4.js +1117 -0
- package/dist/chunk-m76jt5rc.js +376 -0
- package/dist/chunk-mh1206ne.js +1764 -0
- package/dist/chunk-p7zkm4pp.js +151 -0
- package/dist/chunk-pxhftba1.js +336 -0
- package/dist/chunk-rxetj2p9.js +466 -0
- package/dist/chunk-sgdcmme0.js +301 -0
- package/dist/chunk-tey0gh86.js +38 -0
- package/dist/src/index.js +17 -13
- package/package.json +3 -1
- package/src/deploy/cli.ts +71 -0
- package/src/deploy/crm.ts +491 -0
- package/src/stacks/agent-stack.ts +34 -11
- package/src/stacks/api-stack.ts +340 -87
- package/src/stacks/data-stack.ts +27 -1
package/src/stacks/api-stack.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
3
|
type CrmOutreachConfig,
|
|
4
|
+
type CrmStatusPolicy,
|
|
5
|
+
type CrmStatusesByType,
|
|
4
6
|
type EmailIdentity,
|
|
5
7
|
type GoogleDocsIdentity,
|
|
6
8
|
type ScheduledJobBinding,
|
|
@@ -16,7 +18,10 @@ import {
|
|
|
16
18
|
webhookJobBindings,
|
|
17
19
|
} from "@deployfoundation/foundation-core";
|
|
18
20
|
import { JOB_ACTOR } from "@deployfoundation/foundation-core/actor-capability";
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
FOUNDATION_VERSION_RE,
|
|
23
|
+
slackCommandPrefixes,
|
|
24
|
+
} from "@deployfoundation/foundation-core/instance";
|
|
20
25
|
import { parseSchedule } from "@deployfoundation/foundation-core/schedule";
|
|
21
26
|
import * as cdk from "aws-cdk-lib";
|
|
22
27
|
import * as apigateway from "aws-cdk-lib/aws-apigateway";
|
|
@@ -34,7 +39,7 @@ import * as sns from "aws-cdk-lib/aws-sns";
|
|
|
34
39
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
35
40
|
import type { Construct } from "constructs";
|
|
36
41
|
import { parse as parseYaml } from "yaml";
|
|
37
|
-
import { lambdaCode } from "../artifacts.ts";
|
|
42
|
+
import { lambdaCode, releaseSource } from "../artifacts.ts";
|
|
38
43
|
import {
|
|
39
44
|
type Instance,
|
|
40
45
|
LIVE_ENDPOINT_NAME,
|
|
@@ -1129,17 +1134,26 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1129
1134
|
|
|
1130
1135
|
// --- CRM proxy -------------------------------------------------------
|
|
1131
1136
|
// Fixed operations and deployment-baked policy over the instance's own
|
|
1132
|
-
// self-hosted CRM table
|
|
1137
|
+
// self-hosted CRM table, or over the company's own CRM through `crm/1`
|
|
1138
|
+
// (`crm.provider` in the instance file). Either way this role is the only
|
|
1139
|
+
// principal that touches the records or holds a credential for them.
|
|
1133
1140
|
if (provisionsIntegration(props.instance, "crm")) {
|
|
1134
1141
|
const crm = crmConfigForProvisioning(props.configPath);
|
|
1142
|
+
const provider = props.instance.crm.provider;
|
|
1135
1143
|
const crmTable = props.crmTable;
|
|
1136
1144
|
if (crmTable === undefined)
|
|
1137
1145
|
throw new Error(
|
|
1138
1146
|
`${id}: integrations.crm is enabled but no CRM table was supplied by FoundationData`,
|
|
1139
1147
|
);
|
|
1148
|
+
// With `http` the table holds only the outreach cap ledger, so the
|
|
1149
|
+
// proxy's role gets it only when outreach is configured.
|
|
1150
|
+
const usesTable = provider.kind === "dynamodb" || crm.outreach !== undefined;
|
|
1140
1151
|
const crmProxyRole = new iam.Role(this, "CrmProxyRole", {
|
|
1141
1152
|
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
1142
|
-
description:
|
|
1153
|
+
description:
|
|
1154
|
+
provider.kind === "dynamodb"
|
|
1155
|
+
? `${display} CRM proxy Lambda role (self-hosted CRM table access only)`
|
|
1156
|
+
: `${display} CRM proxy Lambda role (crm/1 key and outreach ledger only)`,
|
|
1143
1157
|
managedPolicies: [
|
|
1144
1158
|
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
1145
1159
|
],
|
|
@@ -1151,41 +1165,128 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1151
1165
|
resources: [secretArnPattern(props.secrets.signing)],
|
|
1152
1166
|
}),
|
|
1153
1167
|
);
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1168
|
+
if (provider.kind === "dynamodb") {
|
|
1169
|
+
crmProxyRole.addToPolicy(
|
|
1170
|
+
new iam.PolicyStatement({
|
|
1171
|
+
sid: "CrmTableData",
|
|
1172
|
+
actions: [
|
|
1173
|
+
"dynamodb:GetItem",
|
|
1174
|
+
"dynamodb:PutItem",
|
|
1175
|
+
"dynamodb:UpdateItem",
|
|
1176
|
+
"dynamodb:DeleteItem",
|
|
1177
|
+
"dynamodb:BatchGetItem",
|
|
1178
|
+
"dynamodb:BatchWriteItem",
|
|
1179
|
+
"dynamodb:TransactWriteItems",
|
|
1180
|
+
// A transaction's ConditionCheck is its own IAM action; the
|
|
1181
|
+
// repository guards every upsert and activity with one.
|
|
1182
|
+
"dynamodb:ConditionCheckItem",
|
|
1183
|
+
],
|
|
1184
|
+
resources: [crmTable.tableArn],
|
|
1185
|
+
}),
|
|
1186
|
+
);
|
|
1187
|
+
crmProxyRole.addToPolicy(
|
|
1188
|
+
new iam.PolicyStatement({
|
|
1189
|
+
sid: "CrmRecordsIndex",
|
|
1190
|
+
actions: ["dynamodb:Query"],
|
|
1191
|
+
resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
|
|
1192
|
+
}),
|
|
1193
|
+
);
|
|
1194
|
+
} else {
|
|
1195
|
+
// Read only. The key is written by an operator (`crm-key`), never by
|
|
1196
|
+
// the proxy; the name is the fixed one FoundationData created it
|
|
1197
|
+
// under, so this stack holds no cross-stack reference to it.
|
|
1198
|
+
crmProxyRole.addToPolicy(
|
|
1199
|
+
new iam.PolicyStatement({
|
|
1200
|
+
sid: "CrmHttpSecret",
|
|
1201
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
1202
|
+
resources: [
|
|
1203
|
+
secretArnPattern(
|
|
1204
|
+
secretsmanager.Secret.fromSecretNameV2(this, "CrmHttpSecret", names.secretCrmHttp),
|
|
1205
|
+
),
|
|
1206
|
+
],
|
|
1207
|
+
}),
|
|
1208
|
+
);
|
|
1209
|
+
// The ledger's items only: its weekly counters and reservations under
|
|
1210
|
+
// one partition (`TENANT#<instance>#OUTREACH`). The profile and
|
|
1211
|
+
// activity items a DynamoDB-provider proxy wrote stay in the table,
|
|
1212
|
+
// unreadable to this role, and so does the records index. A reserve
|
|
1213
|
+
// is one transaction (Put plus Update); a release deletes the
|
|
1214
|
+
// reservation in another.
|
|
1215
|
+
if (usesTable)
|
|
1216
|
+
crmProxyRole.addToPolicy(
|
|
1217
|
+
new iam.PolicyStatement({
|
|
1218
|
+
sid: "CrmOutreachLedger",
|
|
1219
|
+
actions: [
|
|
1220
|
+
"dynamodb:GetItem",
|
|
1221
|
+
"dynamodb:PutItem",
|
|
1222
|
+
"dynamodb:UpdateItem",
|
|
1223
|
+
"dynamodb:DeleteItem",
|
|
1224
|
+
"dynamodb:TransactWriteItems",
|
|
1225
|
+
],
|
|
1226
|
+
resources: [crmTable.tableArn],
|
|
1227
|
+
conditions: {
|
|
1228
|
+
"ForAllValues:StringEquals": {
|
|
1229
|
+
"dynamodb:LeadingKeys": [crmOutreachLedgerPartition(props.instance.name)],
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
}),
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1179
1235
|
// The table is encrypted with the instance's data key, so every read and
|
|
1180
1236
|
// write needs it. Without this grant each call failed as `proxy_error`
|
|
1181
1237
|
// (KMS AccessDenied) the first time a deployment actually used its CRM.
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1238
|
+
if (usesTable)
|
|
1239
|
+
crmProxyRole.addToPolicy(
|
|
1240
|
+
new iam.PolicyStatement({
|
|
1241
|
+
sid: "CrmTableKey",
|
|
1242
|
+
actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
|
|
1243
|
+
resources: [(crmTable.encryptionKey ?? props.dataKey).keyArn],
|
|
1244
|
+
}),
|
|
1245
|
+
);
|
|
1246
|
+
const release = releaseVersionContext(this);
|
|
1247
|
+
const crmProxyEnvironment: Record<string, string> = {
|
|
1248
|
+
// Baked even when `http` has no ledger to keep (the proxy then ignores
|
|
1249
|
+
// it, and the role above has no grant on the table): dropping it would
|
|
1250
|
+
// drop FoundationData's export of the name while the API stack still
|
|
1251
|
+
// deployed imports it, and the data stack, deployed first, would fail.
|
|
1252
|
+
FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
|
|
1253
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
1254
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
1255
|
+
FOUNDATION_CRM_TENANT_ID: props.instance.name,
|
|
1256
|
+
FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
|
|
1257
|
+
FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
|
|
1258
|
+
FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
|
|
1259
|
+
FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
|
|
1260
|
+
// Which status changes the agent may make, and whether it may
|
|
1261
|
+
// delete: refused here, so a config sync alone cannot widen them.
|
|
1262
|
+
FOUNDATION_CRM_STATUS_POLICY: JSON.stringify(crm.statusPolicy),
|
|
1263
|
+
FOUNDATION_CRM_DELETES: String(crm.deletes),
|
|
1264
|
+
// The outreach caps are enforced here, so they are baked like the
|
|
1265
|
+
// rest of the policy: a config sync alone cannot lift them.
|
|
1266
|
+
...(crm.outreach === undefined
|
|
1267
|
+
? {}
|
|
1268
|
+
: {
|
|
1269
|
+
FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
|
|
1270
|
+
FOUNDATION_CRM_TIMEZONE: crm.timezone,
|
|
1271
|
+
}),
|
|
1272
|
+
// Where the records live. Unset means `dynamodb`, so a DynamoDB
|
|
1273
|
+
// deployment's environment is what it was before `http` existed.
|
|
1274
|
+
...(provider.kind === "http"
|
|
1275
|
+
? {
|
|
1276
|
+
FOUNDATION_CRM_PROVIDER: "http",
|
|
1277
|
+
FOUNDATION_CRM_HTTP_BASE_URL: provider.baseUrl,
|
|
1278
|
+
// The id, never the key: the proxy reads it at request time.
|
|
1279
|
+
FOUNDATION_CRM_HTTP_SECRET_ID: names.secretCrmHttp,
|
|
1280
|
+
FOUNDATION_CRM_HTTP_EXPECTED_TENANT: provider.expectedTenant,
|
|
1281
|
+
// Only the User-Agent the CRM sees, for its logs.
|
|
1282
|
+
...(release === undefined ? {} : { FOUNDATION_RELEASE: release }),
|
|
1283
|
+
}
|
|
1284
|
+
: {}),
|
|
1285
|
+
};
|
|
1286
|
+
// The config parser caps the policy JSON (CRM_MAX_POLICY_ENV_BYTES);
|
|
1287
|
+
// this checks the whole environment, names and table included, at synth
|
|
1288
|
+
// rather than as a failed Lambda update halfway through a deploy.
|
|
1289
|
+
assertLambdaEnvironmentBudget(crmProxyEnvironment);
|
|
1189
1290
|
this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
|
|
1190
1291
|
functionName: names.crmProxyFunctionName,
|
|
1191
1292
|
runtime: lambda.Runtime.NODEJS_22_X,
|
|
@@ -1194,24 +1295,7 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1194
1295
|
role: crmProxyRole,
|
|
1195
1296
|
timeout: cdk.Duration.seconds(30),
|
|
1196
1297
|
memorySize: 256,
|
|
1197
|
-
environment:
|
|
1198
|
-
FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
|
|
1199
|
-
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
1200
|
-
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
1201
|
-
FOUNDATION_CRM_TENANT_ID: props.instance.name,
|
|
1202
|
-
FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
|
|
1203
|
-
FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
|
|
1204
|
-
FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
|
|
1205
|
-
FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
|
|
1206
|
-
// The outreach caps are enforced here, so they are baked like the
|
|
1207
|
-
// rest of the policy: a config sync alone cannot lift them.
|
|
1208
|
-
...(crm.outreach === undefined
|
|
1209
|
-
? {}
|
|
1210
|
-
: {
|
|
1211
|
-
FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
|
|
1212
|
-
FOUNDATION_CRM_TIMEZONE: crm.timezone,
|
|
1213
|
-
}),
|
|
1214
|
-
},
|
|
1298
|
+
environment: crmProxyEnvironment,
|
|
1215
1299
|
});
|
|
1216
1300
|
new cdk.CfnOutput(this, "CrmProxyFunctionArn", {
|
|
1217
1301
|
value: this.crmProxyFunction.functionArn,
|
|
@@ -1277,6 +1361,13 @@ export class FoundationApi extends cdk.Stack {
|
|
|
1277
1361
|
agentRuntimeArn: props.agentRuntimeArn,
|
|
1278
1362
|
alarmAction,
|
|
1279
1363
|
});
|
|
1364
|
+
// A company's own CRM refusing, or answering as someone else.
|
|
1365
|
+
if (this.crmProxyFunction !== undefined && props.instance.crm.provider.kind === "http")
|
|
1366
|
+
addCrmProviderAlarms(this, {
|
|
1367
|
+
instance: props.instance,
|
|
1368
|
+
functionName: names.crmProxyFunctionName,
|
|
1369
|
+
alarmAction,
|
|
1370
|
+
});
|
|
1280
1371
|
|
|
1281
1372
|
// --- deployment-owned scheduled jobs --------------------------------
|
|
1282
1373
|
// Config jobs never share the agent-managed routine group or pass role.
|
|
@@ -1451,37 +1542,13 @@ export function addConfigLoadAlarm(
|
|
|
1451
1542
|
if (args.agentRuntimeArn.endsWith("-pending")) return undefined;
|
|
1452
1543
|
const runtimeId = cdk.Fn.select(1, cdk.Fn.split("/", args.agentRuntimeArn));
|
|
1453
1544
|
const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${LIVE_ENDPOINT_NAME}`;
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
managedPolicies: [
|
|
1458
|
-
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
1459
|
-
],
|
|
1460
|
-
});
|
|
1461
|
-
role.addToPolicy(
|
|
1462
|
-
new iam.PolicyStatement({
|
|
1463
|
-
sid: "CreateRuntimeLiveLogGroup",
|
|
1464
|
-
actions: ["logs:CreateLogGroup"],
|
|
1465
|
-
resources: [
|
|
1466
|
-
`arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:${logGroupName}`,
|
|
1467
|
-
],
|
|
1468
|
-
}),
|
|
1469
|
-
);
|
|
1470
|
-
const ensureFunction = new lambda.Function(stack, "AgentLiveLogGroupFunction", {
|
|
1471
|
-
runtime: lambda.Runtime.NODEJS_22_X,
|
|
1472
|
-
handler: "index.runtimeLogGroupHandler",
|
|
1473
|
-
code: lambdaCode(stack, "gateway"),
|
|
1474
|
-
role,
|
|
1475
|
-
timeout: cdk.Duration.seconds(30),
|
|
1476
|
-
memorySize: 128,
|
|
1477
|
-
});
|
|
1478
|
-
const ensureLogGroup = new cdk.CustomResource(stack, "AgentLiveLogGroup", {
|
|
1479
|
-
serviceToken: ensureFunction.functionArn,
|
|
1545
|
+
const ensureLogGroup = ensureLogGroupExists(stack, {
|
|
1546
|
+
id: "AgentLiveLogGroup",
|
|
1547
|
+
sid: "CreateRuntimeLiveLogGroup",
|
|
1480
1548
|
resourceType: "Custom::FoundationRuntimeLogGroup",
|
|
1481
|
-
|
|
1549
|
+
description: `${args.instance.displayName} creates the runtime's live log group when missing`,
|
|
1550
|
+
logGroupName,
|
|
1482
1551
|
});
|
|
1483
|
-
// The function needs its permission before CloudFormation invokes it.
|
|
1484
|
-
ensureLogGroup.node.addDependency(role);
|
|
1485
1552
|
const filter = new logs.MetricFilter(stack, "ConfigLoadFailureMetricFilter", {
|
|
1486
1553
|
logGroup: logs.LogGroup.fromLogGroupName(stack, "AgentLiveLogs", logGroupName),
|
|
1487
1554
|
filterPattern: logs.FilterPattern.any(
|
|
@@ -1512,11 +1579,193 @@ export function addConfigLoadAlarm(
|
|
|
1512
1579
|
return alarm;
|
|
1513
1580
|
}
|
|
1514
1581
|
|
|
1582
|
+
/**
|
|
1583
|
+
* A custom resource that creates `logGroupName` when it is missing and leaves
|
|
1584
|
+
* it alone when it exists (or when the stack is deleted), so a metric filter
|
|
1585
|
+
* can attach to a group that AWS, not CloudFormation, creates on first use.
|
|
1586
|
+
* The handler (`runtimeLogGroupHandler`, in the release-signed gateway bundle)
|
|
1587
|
+
* accepts only an AgentCore runtime's or a Lambda's group, and the role may
|
|
1588
|
+
* create exactly this one.
|
|
1589
|
+
*/
|
|
1590
|
+
function ensureLogGroupExists(
|
|
1591
|
+
stack: cdk.Stack,
|
|
1592
|
+
args: {
|
|
1593
|
+
/** Construct id prefix: `<id>Role`, `<id>Function`, and `<id>` for the resource. */
|
|
1594
|
+
id: string;
|
|
1595
|
+
sid: string;
|
|
1596
|
+
resourceType: string;
|
|
1597
|
+
description: string;
|
|
1598
|
+
logGroupName: string;
|
|
1599
|
+
},
|
|
1600
|
+
): cdk.CustomResource {
|
|
1601
|
+
const role = new iam.Role(stack, `${args.id}Role`, {
|
|
1602
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
1603
|
+
description: args.description,
|
|
1604
|
+
managedPolicies: [
|
|
1605
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
1606
|
+
],
|
|
1607
|
+
});
|
|
1608
|
+
role.addToPolicy(
|
|
1609
|
+
new iam.PolicyStatement({
|
|
1610
|
+
sid: args.sid,
|
|
1611
|
+
actions: ["logs:CreateLogGroup"],
|
|
1612
|
+
resources: [
|
|
1613
|
+
`arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:${args.logGroupName}`,
|
|
1614
|
+
],
|
|
1615
|
+
}),
|
|
1616
|
+
);
|
|
1617
|
+
const ensureFunction = new lambda.Function(stack, `${args.id}Function`, {
|
|
1618
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
1619
|
+
handler: "index.runtimeLogGroupHandler",
|
|
1620
|
+
code: lambdaCode(stack, "gateway"),
|
|
1621
|
+
role,
|
|
1622
|
+
timeout: cdk.Duration.seconds(30),
|
|
1623
|
+
memorySize: 128,
|
|
1624
|
+
});
|
|
1625
|
+
const ensureLogGroup = new cdk.CustomResource(stack, args.id, {
|
|
1626
|
+
serviceToken: ensureFunction.functionArn,
|
|
1627
|
+
resourceType: args.resourceType,
|
|
1628
|
+
properties: { LogGroupName: args.logGroupName },
|
|
1629
|
+
});
|
|
1630
|
+
// The function needs its permission before CloudFormation invokes it.
|
|
1631
|
+
ensureLogGroup.node.addDependency(role);
|
|
1632
|
+
return ensureLogGroup;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* The CRM proxy's log codes that mean a company's own CRM (`crm.provider`
|
|
1637
|
+
* kind `http`) needs a person. Each is one metric filter on the proxy's log
|
|
1638
|
+
* group and one alarm on the instance's alarm topic; the log line itself
|
|
1639
|
+
* carries only fixed words (the op, the status class, which `/meta` check
|
|
1640
|
+
* failed), never a record or a reply body.
|
|
1641
|
+
*/
|
|
1642
|
+
export const CRM_PROVIDER_ALARMS = [
|
|
1643
|
+
{
|
|
1644
|
+
event: "crm_proxy",
|
|
1645
|
+
code: "tenant_mismatch",
|
|
1646
|
+
name: "TenantMismatch",
|
|
1647
|
+
description:
|
|
1648
|
+
"The CRM answered as a different tenant from crm.provider.expectedTenant, so the CRM proxy refused the reply and the CRM tools fail closed. Check that the key written with crm-key was issued for that tenant, and that expectedTenant is right.",
|
|
1649
|
+
},
|
|
1650
|
+
{
|
|
1651
|
+
event: "crm_proxy",
|
|
1652
|
+
code: "upstream_unauthorized",
|
|
1653
|
+
name: "UpstreamUnauthorized",
|
|
1654
|
+
description:
|
|
1655
|
+
"The CRM refused the CRM proxy's key (401), even after the proxy re-read it: revoked, expired or wrong. Issue a new key in the CRM and run foundation-deploy crm-key; the proxy picks it up on its next call.",
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
event: "crm_proxy",
|
|
1659
|
+
code: "upstream_forbidden",
|
|
1660
|
+
name: "UpstreamForbidden",
|
|
1661
|
+
description:
|
|
1662
|
+
"The CRM answered 403: a scope the key lacks, the CRM's kill switch, or a WAF in front of it blocking the proxy. Check the key's scopes, the switch and the WAF's logs.",
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
event: "crm_proxy",
|
|
1666
|
+
code: "unconfigured",
|
|
1667
|
+
name: "Unconfigured",
|
|
1668
|
+
description:
|
|
1669
|
+
"The CRM proxy is unconfigured, so every CRM tool fails closed: no key written yet (run foundation-deploy crm-key), or the CRM's /meta does not match the deployment. The log line's check field names which: contract, statuses, activity_types, batch_size or operations.",
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
event: "crm_proxy_warning",
|
|
1673
|
+
code: "ledger_kept",
|
|
1674
|
+
name: "LedgerKept",
|
|
1675
|
+
description:
|
|
1676
|
+
"A draft's outreach reservation was kept because the CRM's answer was unknown (a timeout, a 5xx, a 429 or a reply without the contract header). The weekly cap counts it, failing closed. Check in the CRM whether that draft was logged.",
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
event: "crm_proxy_warning",
|
|
1680
|
+
code: "policy_mismatch",
|
|
1681
|
+
name: "PolicyMismatch",
|
|
1682
|
+
description:
|
|
1683
|
+
"The CRM's own policy in /meta differs from the deployment's capabilities.crm (the log line's fields name which). Each side enforces its own, so the stricter wins; align the two.",
|
|
1684
|
+
},
|
|
1685
|
+
] as const;
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Alarms on the CRM proxy's log group for a company's own CRM: one per
|
|
1689
|
+
* {@link CRM_PROVIDER_ALARMS} entry, on the instance's alarm topic.
|
|
1690
|
+
*
|
|
1691
|
+
* Lambda creates `/aws/lambda/<function>` on the first invocation, and a
|
|
1692
|
+
* proxy that ran before (on the DynamoDB provider) already has one that
|
|
1693
|
+
* CloudFormation does not own, so the group is ensured by a custom resource
|
|
1694
|
+
* exactly as the runtime's live group is.
|
|
1695
|
+
*
|
|
1696
|
+
* Five minutes, except `policy_mismatch`: it is logged at every `/meta` check
|
|
1697
|
+
* for as long as the two policies differ, so an hour keeps it from
|
|
1698
|
+
* re-notifying after every quiet gap.
|
|
1699
|
+
*/
|
|
1700
|
+
export function addCrmProviderAlarms(
|
|
1701
|
+
stack: cdk.Stack,
|
|
1702
|
+
args: { instance: Instance; functionName: string; alarmAction: cloudwatch.IAlarmAction },
|
|
1703
|
+
): cloudwatch.Alarm[] {
|
|
1704
|
+
const logGroupName = `/aws/lambda/${args.functionName}`;
|
|
1705
|
+
const ensureLogGroup = ensureLogGroupExists(stack, {
|
|
1706
|
+
id: "CrmProxyLogGroup",
|
|
1707
|
+
sid: "CreateCrmProxyLogGroup",
|
|
1708
|
+
resourceType: "Custom::FoundationLambdaLogGroup",
|
|
1709
|
+
description: `${args.instance.displayName} creates the CRM proxy's log group when missing`,
|
|
1710
|
+
logGroupName,
|
|
1711
|
+
});
|
|
1712
|
+
const logGroup = logs.LogGroup.fromLogGroupName(stack, "CrmProxyLogs", logGroupName);
|
|
1713
|
+
return CRM_PROVIDER_ALARMS.map((entry) => {
|
|
1714
|
+
const filter = new logs.MetricFilter(stack, `CrmProxy${entry.name}MetricFilter`, {
|
|
1715
|
+
logGroup,
|
|
1716
|
+
filterPattern: logs.FilterPattern.all(
|
|
1717
|
+
logs.FilterPattern.stringValue("$.event", "=", entry.event),
|
|
1718
|
+
logs.FilterPattern.stringValue("$.code", "=", entry.code),
|
|
1719
|
+
),
|
|
1720
|
+
metricNamespace: `Foundation/${args.instance.naming.prefix}`,
|
|
1721
|
+
metricName: `CrmProxy${entry.name}`,
|
|
1722
|
+
metricValue: "1",
|
|
1723
|
+
});
|
|
1724
|
+
filter.node.addDependency(ensureLogGroup);
|
|
1725
|
+
const alarm = new cloudwatch.Alarm(stack, `CrmProxy${entry.name}Alarm`, {
|
|
1726
|
+
alarmDescription: `${args.instance.displayName} CRM proxy logged ${entry.code}. ${entry.description}`,
|
|
1727
|
+
metric: filter.metric({
|
|
1728
|
+
statistic: "Sum",
|
|
1729
|
+
period: cdk.Duration.minutes(entry.code === "policy_mismatch" ? 60 : 5),
|
|
1730
|
+
}),
|
|
1731
|
+
threshold: 1,
|
|
1732
|
+
evaluationPeriods: 1,
|
|
1733
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
1734
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
1735
|
+
});
|
|
1736
|
+
alarm.addAlarmAction(args.alarmAction);
|
|
1737
|
+
return alarm;
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* The partition holding the outreach cap ledger, the only items the CRM
|
|
1743
|
+
* proxy's role may touch with the `http` provider. Kept equal to
|
|
1744
|
+
* `outreachLedgerPartition` in the CRM capability's `proxy/ledger.ts` (a test
|
|
1745
|
+
* pins it); not imported, so the CDK app does not load the proxy's AWS SDK.
|
|
1746
|
+
*/
|
|
1747
|
+
export function crmOutreachLedgerPartition(tenantId: string): string {
|
|
1748
|
+
return `TENANT#${tenantId}#OUTREACH`;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* The release a synth deploys, for the CRM proxy's User-Agent:
|
|
1753
|
+
* `-c foundationVersion`, which the deploy tool passes, else the release
|
|
1754
|
+
* being deployed. Absent on a bare synth, which the proxy tolerates.
|
|
1755
|
+
*/
|
|
1756
|
+
function releaseVersionContext(scope: Construct): string | undefined {
|
|
1757
|
+
const explicit = String(scope.node.tryGetContext("foundationVersion") ?? "");
|
|
1758
|
+
const version = explicit !== "" ? explicit : releaseSource(scope)?.version;
|
|
1759
|
+
return version !== undefined && FOUNDATION_VERSION_RE.test(version) ? version : undefined;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1515
1762
|
interface ProvisionedCrmConfig {
|
|
1516
1763
|
channels: string[];
|
|
1517
|
-
statuses:
|
|
1764
|
+
statuses: CrmStatusesByType;
|
|
1518
1765
|
activityTypes: string[];
|
|
1519
1766
|
maxBatchSize: number;
|
|
1767
|
+
statusPolicy: CrmStatusPolicy;
|
|
1768
|
+
deletes: boolean;
|
|
1520
1769
|
policyFingerprint: string;
|
|
1521
1770
|
/** Absent when no status is eligible for outreach. */
|
|
1522
1771
|
outreach?: CrmOutreachConfig;
|
|
@@ -1526,10 +1775,12 @@ interface ProvisionedCrmConfig {
|
|
|
1526
1775
|
function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
|
|
1527
1776
|
const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
|
|
1528
1777
|
const crm = config.capabilities.crm;
|
|
1778
|
+
// Each record type needs a status for the records an upsert creates.
|
|
1529
1779
|
const missing = [
|
|
1530
1780
|
...(crm.enabled ? [] : ["capabilities.crm.enabled"]),
|
|
1531
1781
|
...(crm.channels.length > 0 ? [] : ["capabilities.crm.channels"]),
|
|
1532
|
-
...(crm.statuses.length > 0 ? [] : ["capabilities.crm.statuses"]),
|
|
1782
|
+
...(crm.statuses.company.length > 0 ? [] : ["capabilities.crm.statuses.company"]),
|
|
1783
|
+
...(crm.statuses.contact.length > 0 ? [] : ["capabilities.crm.statuses.contact"]),
|
|
1533
1784
|
...(crm.activityTypes.length > 0 ? [] : ["capabilities.crm.activityTypes"]),
|
|
1534
1785
|
];
|
|
1535
1786
|
if (missing.length > 0)
|
|
@@ -1538,9 +1789,11 @@ function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
|
|
|
1538
1789
|
);
|
|
1539
1790
|
return {
|
|
1540
1791
|
channels: [...crm.channels],
|
|
1541
|
-
statuses: [...crm.statuses],
|
|
1792
|
+
statuses: { company: [...crm.statuses.company], contact: [...crm.statuses.contact] },
|
|
1542
1793
|
activityTypes: [...crm.activityTypes],
|
|
1543
1794
|
maxBatchSize: crm.maxBatchSize,
|
|
1795
|
+
statusPolicy: crm.statusPolicy,
|
|
1796
|
+
deletes: crm.deletes,
|
|
1544
1797
|
policyFingerprint: crmPolicyFingerprint(crm),
|
|
1545
1798
|
...(crmOutreachConfigured(crm.outreach) ? { outreach: crm.outreach } : {}),
|
|
1546
1799
|
timezone: config.timezone,
|
|
@@ -1672,7 +1925,7 @@ export function assertLambdaEnvironmentBudget(values: Readonly<Record<string, st
|
|
|
1672
1925
|
}
|
|
1673
1926
|
if (bytes > LAMBDA_ENVIRONMENT_MAX_BYTES) {
|
|
1674
1927
|
throw new Error(
|
|
1675
|
-
`
|
|
1928
|
+
`A Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`,
|
|
1676
1929
|
);
|
|
1677
1930
|
}
|
|
1678
1931
|
}
|
package/src/stacks/data-stack.ts
CHANGED
|
@@ -16,7 +16,8 @@ export interface FoundationDataProps extends cdk.StackProps {
|
|
|
16
16
|
* FoundationData — the durable layer: one CMK, two S3 buckets (config +
|
|
17
17
|
* skills, and channel documents), two always-present DynamoDB tables, an
|
|
18
18
|
* optional CRM table, an optional proxy-only Upwork approval table, an optional
|
|
19
|
-
* retained Upwork OAuth secret,
|
|
19
|
+
* retained Upwork OAuth secret, the optional key for a company's own CRM, and
|
|
20
|
+
* the Secrets Manager entries.
|
|
20
21
|
* The two always-present DynamoDB tables use
|
|
21
22
|
* different access patterns: `FoundationTable` is `pk` (S) only:
|
|
22
23
|
* dedupe rows, the Codex credential lease and the thread→session index, every
|
|
@@ -53,6 +54,12 @@ export class FoundationData extends cdk.Stack {
|
|
|
53
54
|
public readonly upworkApprovalTable?: dynamodb.Table;
|
|
54
55
|
/** Short-lived OOB request-token state; gateway-only and destroyed with the opt-in. */
|
|
55
56
|
public readonly etradePendingTable?: dynamodb.Table;
|
|
57
|
+
/**
|
|
58
|
+
* The `crm/1` key, present only with `crm.provider` kind `http`. Other stacks
|
|
59
|
+
* reach it by its fixed name, never through this object, so switching back to
|
|
60
|
+
* `dynamodb` can delete it without a cross-stack export holding it in place.
|
|
61
|
+
*/
|
|
62
|
+
public readonly crmHttpSecret?: secretsmanager.Secret;
|
|
56
63
|
public readonly secrets: {
|
|
57
64
|
signing: secretsmanager.ISecret;
|
|
58
65
|
slackApp: secretsmanager.ISecret;
|
|
@@ -230,6 +237,25 @@ export class FoundationData extends cdk.Stack {
|
|
|
230
237
|
})
|
|
231
238
|
: undefined;
|
|
232
239
|
|
|
240
|
+
// The key for a company's own CRM (`crm.provider` kind `http`): empty
|
|
241
|
+
// until `foundation-deploy crm-key` writes `{ api_key }`, and until then
|
|
242
|
+
// the CRM proxy answers `unconfigured`. Like the Otter key it is destroyed
|
|
243
|
+
// with the opt-in: a rollback to `dynamodb` must not strand a fixed-name
|
|
244
|
+
// secret that a later switch back to `http` could not create again, and
|
|
245
|
+
// the CRM issues a fresh key in seconds.
|
|
246
|
+
this.crmHttpSecret =
|
|
247
|
+
provisionsIntegration(props.instance, "crm") && props.instance.crm.provider.kind === "http"
|
|
248
|
+
? new secretsmanager.Secret(this, "CrmHttpSecret", {
|
|
249
|
+
secretName: names.secretCrmHttp,
|
|
250
|
+
description: `crm/1 key for ${display}'s CRM ({ api_key }); written by foundation-deploy crm-key, read only by the CRM proxy`,
|
|
251
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
252
|
+
generateSecretString: {
|
|
253
|
+
secretStringTemplate: "{}",
|
|
254
|
+
generateStringKey: "placeholder",
|
|
255
|
+
},
|
|
256
|
+
})
|
|
257
|
+
: undefined;
|
|
258
|
+
|
|
233
259
|
// Unlike the disposable Otter API key, the Upwork OAuth secret carries
|
|
234
260
|
// client credentials plus refresh state. Retaining it makes a deploy safe
|
|
235
261
|
// and leaves manually entered client credentials intact.
|