@deployfoundation/foundation-deploy 0.2.48 → 0.2.50

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.
@@ -161,7 +161,12 @@ ENV AWS_EC2_METADATA_SERVICE_ENDPOINT=http://169.254.169.254 \
161
161
  # loopback broker with it. poppler-utils supplies `pdftotext`, the ONLY way a
162
162
  # PDF dropped in a thread becomes readable text (files.ts shells out to it
163
163
  # rather than adding a PDF parser dependency); missing here, every PDF
164
- # silently degrades to a path the model cannot open.
164
+ # silently degrades to a path the model cannot open. xz-utils (`xz`, the CLI
165
+ # over the liblzma5 already in the base) unpacks `.tar.xz` downloads, and
166
+ # `file` names what an attachment or download really is before the model
167
+ # picks a tool for it. Together about 12 MB installed (most of it libmagic's
168
+ # database); both are plain Debian CLIs that run no daemon and hold no
169
+ # credentials. Olivia's turns reached for both and found neither.
165
170
  #
166
171
  # `upgrade` (not just `install`) is what picks up Debian security patches for
167
172
  # packages the base image baked in — without it, glibc/systemd/libcap2/liblzma5
@@ -175,7 +180,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
175
180
  libc6 libc-bin libsystemd0 libudev1 libcap2 liblzma5 sed \
176
181
  && apt-get install -y --no-install-recommends \
177
182
  python3 python3-venv python3-pip ca-certificates git curl \
178
- poppler-utils \
183
+ poppler-utils xz-utils file \
179
184
  libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
180
185
  libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \
181
186
  libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-z4gsy5kh.js";
11
+ } from "../chunk-efqdhd2e.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-2tachxjf.js";
17
+ } from "../chunk-gnz747z2.js";
18
18
  import"../chunk-dz91avrb.js";
19
19
 
20
20
  // bin/app.ts
@@ -22,12 +22,15 @@ import {
22
22
  slackCommandPrefix,
23
23
  verifyManifest,
24
24
  versionedConfigKey
25
- } from "../chunk-2tachxjf.js";
25
+ } from "../chunk-gnz747z2.js";
26
26
  import {
27
27
  DEFAULT_RELEASE_BUCKET,
28
28
  expectedKeyArn
29
29
  } from "../chunk-dz91avrb.js";
30
30
 
31
+ // src/deploy/cli.ts
32
+ import { readFileSync as readFileSync10 } from "node:fs";
33
+
31
34
  // src/deploy/aws.ts
32
35
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
36
  import { tmpdir } from "node:os";
@@ -859,6 +862,33 @@ var UPWORK_GRAPHQL_DOCUMENTS = Object.freeze({
859
862
  $filter: VendorProposalFilter!
860
863
  $sortAttribute: VendorProposalSortAttribute!
861
864
  $pagination: Pagination!
865
+ ) {
866
+ vendorProposals(
867
+ filter: $filter
868
+ sortAttribute: $sortAttribute
869
+ pagination: $pagination
870
+ ) {
871
+ totalCount
872
+ edges {
873
+ node {
874
+ id
875
+ status { status }
876
+ marketplaceJobPosting { id content { title } }
877
+ terms { chargeRate { displayValue } }
878
+ viewedByClient
879
+ auditDetails { createdDateTime { rawValue } }
880
+ }
881
+ }
882
+ pageInfo { endCursor hasNextPage }
883
+ }
884
+ }`
885
+ }),
886
+ list_proposals_basic: Object.freeze({
887
+ operationName: "FreelancerVendorProposalsBasic",
888
+ query: `query FreelancerVendorProposalsBasic(
889
+ $filter: VendorProposalFilter!
890
+ $sortAttribute: VendorProposalSortAttribute!
891
+ $pagination: Pagination!
862
892
  ) {
863
893
  vendorProposals(
864
894
  filter: $filter
@@ -2560,6 +2590,7 @@ Done. Now say \`@${ctx.instance.displayName} hello\` in Slack.`);
2560
2590
 
2561
2591
  // src/deploy/cli.ts
2562
2592
  var COMMANDS = [
2593
+ "check",
2563
2594
  "deploy",
2564
2595
  "post-deploy",
2565
2596
  "config:sync",
@@ -2572,6 +2603,10 @@ var COMMANDS = [
2572
2603
  var USAGE = `usage: foundation-deploy <command> --instance <path> [options]
2573
2604
 
2574
2605
  commands
2606
+ check parse the instance and config files with THIS release's
2607
+ schema; no AWS. Run it on config PRs with the pinned
2608
+ version: a config the deployed release rejects puts the
2609
+ teammate on the fallback config.
2575
2610
  deploy build and push the agent image, cdk deploy --all, refresh
2576
2611
  the runtime secret, sync config
2577
2612
  post-deploy smoke DEFAULT, probe the mount, promote \`live\`, smoke \`live\`
@@ -2650,6 +2685,18 @@ async function main(argv2 = process.argv.slice(2)) {
2650
2685
  ${USAGE}`);
2651
2686
  return 1;
2652
2687
  }
2688
+ if (command === "check") {
2689
+ const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
2690
+ try {
2691
+ parseInstanceConfig(readFileSync10(paths.configPath, "utf8"));
2692
+ } catch (error) {
2693
+ console.error(`${paths.configPath} does not parse with this release (v${toolVersion()}):
2694
+ ${error instanceof Error ? error.message : String(error)}`);
2695
+ return 1;
2696
+ }
2697
+ console.log(`${paths.configPath} parses with this release (v${toolVersion()})`);
2698
+ return 0;
2699
+ }
2653
2700
  if (command === "slack-manifest") {
2654
2701
  const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
2655
2702
  if (process.env.EVENTS_REQUEST_URL === undefined)
@@ -111,6 +111,11 @@ class ReleaseAccountStack extends cdk.Stack {
111
111
  actions: ["ecr:GetAuthorizationToken"],
112
112
  resources: ["*"]
113
113
  }),
114
+ new iam.PolicyStatement({
115
+ sid: "EcrPublicLogin",
116
+ actions: ["ecr-public:GetAuthorizationToken", "sts:GetServiceBearerToken"],
117
+ resources: ["*"]
118
+ }),
114
119
  new iam.PolicyStatement({
115
120
  sid: "SignManifest",
116
121
  actions: ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
@@ -24,7 +24,7 @@ import {
24
24
  scheduledJobBindings,
25
25
  slackCommandPrefixes,
26
26
  webhookJobBindings
27
- } from "./chunk-2tachxjf.js";
27
+ } from "./chunk-gnz747z2.js";
28
28
 
29
29
  // src/stacks/agent-stack.ts
30
30
  import * as cdk from "aws-cdk-lib";
@@ -670,9 +670,14 @@ class FoundationAgent extends cdk.Stack {
670
670
  }));
671
671
  role.addToPolicy(new iam.PolicyStatement({
672
672
  sid: "ConfigList",
673
- actions: ["s3:ListBucket"],
673
+ actions: ["s3:ListBucket", "s3:ListBucketVersions"],
674
674
  resources: [props.bucket.bucketArn]
675
675
  }));
676
+ role.addToPolicy(new iam.PolicyStatement({
677
+ sid: "ConfigLastGood",
678
+ actions: ["s3:GetObjectVersion"],
679
+ resources: [props.bucket.arnForObjects("config/*")]
680
+ }));
676
681
  role.addToPolicy(new iam.PolicyStatement({
677
682
  sid: "DocumentsObjects",
678
683
  actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
@@ -839,6 +844,7 @@ import * as cwActions from "aws-cdk-lib/aws-cloudwatch-actions";
839
844
  import * as iam2 from "aws-cdk-lib/aws-iam";
840
845
  import * as lambda from "aws-cdk-lib/aws-lambda";
841
846
  import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
847
+ import * as logs2 from "aws-cdk-lib/aws-logs";
842
848
  import * as scheduler from "aws-cdk-lib/aws-scheduler";
843
849
  import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
844
850
  import * as sns from "aws-cdk-lib/aws-sns";
@@ -1629,18 +1635,31 @@ class FoundationApi extends cdk2.Stack {
1629
1635
  ["InvokeDlqDepthAlarm", this.invokeDlq]
1630
1636
  ]) {
1631
1637
  const alarm = new cloudwatch.Alarm(this, name, {
1632
- alarmDescription: `${name}: messages are sitting in the DLQ`,
1633
- metric: queue.metricApproximateNumberOfMessagesVisible({
1638
+ alarmDescription: `${name}: new messages arrived in the DLQ. Inspect them before any redrive.`,
1639
+ metric: new cloudwatch.MathExpression({
1640
+ expression: "DIFF(FILL(depth, 0))",
1641
+ usingMetrics: {
1642
+ depth: queue.metricApproximateNumberOfMessagesVisible({
1643
+ period: cdk2.Duration.minutes(5),
1644
+ statistic: "Maximum"
1645
+ })
1646
+ },
1634
1647
  period: cdk2.Duration.minutes(5),
1635
- statistic: "Maximum"
1648
+ label: "Messages added to the DLQ"
1636
1649
  }),
1637
- threshold: 1,
1638
- evaluationPeriods: 1,
1639
- comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
1650
+ threshold: 0,
1651
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
1652
+ evaluationPeriods: 3,
1653
+ datapointsToAlarm: 1,
1640
1654
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING
1641
1655
  });
1642
1656
  alarm.addAlarmAction(alarmAction);
1643
1657
  }
1658
+ addConfigLoadAlarm(this, {
1659
+ instance: props.instance,
1660
+ agentRuntimeArn: props.agentRuntimeArn,
1661
+ alarmAction
1662
+ });
1644
1663
  let dispatcherFunctionName;
1645
1664
  if (configuredScheduledJobs.length > 0) {
1646
1665
  const dispatchRole = new iam2.Role(this, "ScheduledJobDispatcherRole", {
@@ -1757,6 +1776,65 @@ class FoundationApi extends cdk2.Stack {
1757
1776
  });
1758
1777
  }
1759
1778
  }
1779
+ var CONFIG_LOAD_FAILURE_EVENTS = ["config_fallback_active", "config_reload_failed"];
1780
+ function addConfigLoadAlarm(stack, args) {
1781
+ if (args.agentRuntimeArn.endsWith("-pending"))
1782
+ return;
1783
+ const runtimeId = cdk2.Fn.select(1, cdk2.Fn.split("/", args.agentRuntimeArn));
1784
+ const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${LIVE_ENDPOINT_NAME}`;
1785
+ const role = new iam2.Role(stack, "AgentLiveLogGroupRole", {
1786
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
1787
+ description: `${args.instance.displayName} creates the runtime's live log group when missing`,
1788
+ managedPolicies: [
1789
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
1790
+ ]
1791
+ });
1792
+ role.addToPolicy(new iam2.PolicyStatement({
1793
+ sid: "CreateRuntimeLiveLogGroup",
1794
+ actions: ["logs:CreateLogGroup"],
1795
+ resources: [
1796
+ `arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:${logGroupName}`
1797
+ ]
1798
+ }));
1799
+ const ensureFunction = new lambda.Function(stack, "AgentLiveLogGroupFunction", {
1800
+ runtime: lambda.Runtime.NODEJS_22_X,
1801
+ handler: "index.runtimeLogGroupHandler",
1802
+ code: lambdaCode(stack, "gateway"),
1803
+ role,
1804
+ timeout: cdk2.Duration.seconds(30),
1805
+ memorySize: 128
1806
+ });
1807
+ const ensureLogGroup = new cdk2.CustomResource(stack, "AgentLiveLogGroup", {
1808
+ serviceToken: ensureFunction.functionArn,
1809
+ resourceType: "Custom::FoundationRuntimeLogGroup",
1810
+ properties: { LogGroupName: logGroupName }
1811
+ });
1812
+ ensureLogGroup.node.addDependency(role);
1813
+ const filter = new logs2.MetricFilter(stack, "ConfigLoadFailureMetricFilter", {
1814
+ logGroup: logs2.LogGroup.fromLogGroupName(stack, "AgentLiveLogs", logGroupName),
1815
+ filterPattern: logs2.FilterPattern.any(...CONFIG_LOAD_FAILURE_EVENTS.map((event) => logs2.FilterPattern.stringValue("$.event", "=", event))),
1816
+ metricNamespace: `Foundation/${args.instance.naming.prefix}`,
1817
+ metricName: "ConfigLoadFailures",
1818
+ metricValue: "1"
1819
+ });
1820
+ filter.node.addDependency(ensureLogGroup);
1821
+ const alarm = new cloudwatch.Alarm(stack, "ConfigLoadFailureAlarm", {
1822
+ alarmDescription: [
1823
+ `${args.instance.displayName}'s runtime is not serving the config deployed for it: the`,
1824
+ "config failed to load, so it runs on its last good config with company-data",
1825
+ "capabilities off, or on the built-in fallback. The config_fallback_active or",
1826
+ "config_reload_failed line in the runtime's live log group names the refused field.",
1827
+ "Fix config.yaml, run foundation-deploy check, then deploy or config:sync."
1828
+ ].join(" "),
1829
+ metric: filter.metric({ statistic: "Sum", period: cdk2.Duration.hours(1) }),
1830
+ threshold: 1,
1831
+ evaluationPeriods: 1,
1832
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
1833
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING
1834
+ });
1835
+ alarm.addAlarmAction(args.alarmAction);
1836
+ return alarm;
1837
+ }
1760
1838
  function crmConfigForProvisioning(configPath) {
1761
1839
  const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
1762
1840
  const crm = config.capabilities.crm;
@@ -1975,4 +1975,4 @@ function ecrRepositoryArn(repositoryUri) {
1975
1975
  return `arn:aws:ecr:${region}:${account}:repository/${name}`;
1976
1976
  }
1977
1977
 
1978
- export { parseSchedule, BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, encodeProactiveChannels, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, FOUNDATION_VERSION_RE, versionedConfigKey, instanceNames, slackCommandPrefix, slackCommandPrefixes, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, releaseAccess, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
1978
+ export { parseSchedule, BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, encodeProactiveChannels, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, FOUNDATION_VERSION_RE, versionedConfigKey, instanceNames, slackCommandPrefix, slackCommandPrefixes, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, releaseAccess, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-z4gsy5kh.js";
13
+ } from "../chunk-efqdhd2e.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -44,7 +44,7 @@ import {
44
44
  skillsKey,
45
45
  skipBundle,
46
46
  verifyManifest
47
- } from "../chunk-2tachxjf.js";
47
+ } from "../chunk-gnz747z2.js";
48
48
  import"../chunk-dz91avrb.js";
49
49
  export {
50
50
  BUILD_TIMEOUT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.48",
3
+ "version": "0.2.50",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
package/src/deploy/cli.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * tree into a deploy would make every release of the tool carry a model
12
12
  * runtime it never executes.
13
13
  */
14
+ import { readFileSync } from "node:fs";
15
+ import { parseInstanceConfig } from "@deployfoundation/foundation-core";
14
16
  import { type AwsContext, awsContext } from "./aws.ts";
15
17
  import { configSync } from "./config-sync.ts";
16
18
  import { deploy } from "./deploy.ts";
@@ -18,12 +20,13 @@ import { githubAppCreate } from "./github-app-create.ts";
18
20
  import { instanceBanner, loadInstanceContext, resolveInstanceFilePath } from "./instance.ts";
19
21
  import { jobKey } from "./job-key.ts";
20
22
  import { postDeploy } from "./post-deploy.ts";
21
- import { releaseRequest, resolveRelease } from "./release.ts";
23
+ import { releaseRequest, resolveRelease, toolVersion } from "./release.ts";
22
24
  import { setup } from "./setup.ts";
23
25
  import { slackManifestFor } from "./slack-manifest.ts";
24
26
  import { stageCustomization } from "./stage-customization.ts";
25
27
 
26
28
  export const COMMANDS = [
29
+ "check",
27
30
  "deploy",
28
31
  "post-deploy",
29
32
  "config:sync",
@@ -39,6 +42,10 @@ export type Command = (typeof COMMANDS)[number];
39
42
  export const USAGE = `usage: foundation-deploy <command> --instance <path> [options]
40
43
 
41
44
  commands
45
+ check parse the instance and config files with THIS release's
46
+ schema; no AWS. Run it on config PRs with the pinned
47
+ version: a config the deployed release rejects puts the
48
+ teammate on the fallback config.
42
49
  deploy build and push the agent image, cdk deploy --all, refresh
43
50
  the runtime secret, sync config
44
51
  post-deploy smoke DEFAULT, probe the mount, promote \`live\`, smoke \`live\`
@@ -120,6 +127,23 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
120
127
  return 1;
121
128
  }
122
129
 
130
+ // `check` reads files only, so it runs in a pull request with no credentials.
131
+ if (command === "check") {
132
+ const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
133
+ try {
134
+ parseInstanceConfig(readFileSync(paths.configPath, "utf8"));
135
+ } catch (error) {
136
+ console.error(
137
+ `${paths.configPath} does not parse with this release (v${toolVersion()}):\n${
138
+ error instanceof Error ? error.message : String(error)
139
+ }`,
140
+ );
141
+ return 1;
142
+ }
143
+ console.log(`${paths.configPath} parses with this release (v${toolVersion()})`);
144
+ return 0;
145
+ }
146
+
123
147
  // `slack-manifest` writes a machine-readable document to stdout and must not
124
148
  // print a banner into it; every other command announces its target first.
125
149
  if (command === "slack-manifest") {
@@ -193,7 +193,9 @@ export async function currentRuntimeVersion(runner: EndpointRunner, id: string):
193
193
  * What a healthy `_smoke_test` response says. `config_source` is here because
194
194
  * a container that booted before its runtime secret named a bucket answers
195
195
  * happily from the fallback config — a green smoke test hiding a teammate that
196
- * knows none of its own configuration.
196
+ * knows none of its own configuration. A runtime whose config did not parse
197
+ * answers `last_good` (an earlier version of its config, fail-closed) or
198
+ * `fallback`, and fails here too.
197
199
  */
198
200
  export const SMOKE_EXPECT = ['"authenticated":true', '"config_source":"s3"'];
199
201
 
@@ -1045,13 +1045,25 @@ export class FoundationAgent extends cdk.Stack {
1045
1045
  resources: [props.bucket.arnForObjects("memory/*")],
1046
1046
  }),
1047
1047
  );
1048
+ // A boot whose own config object does not parse falls back to the newest
1049
+ // earlier VERSION of that object that does (`loadLastGoodConfig` in the
1050
+ // agent's server.ts): deployment-written, read-only, and readable only
1051
+ // under `config/`. Listing versions reveals no more than `ListBucket`
1052
+ // already does (key names), so it is not scoped further.
1048
1053
  role.addToPolicy(
1049
1054
  new iam.PolicyStatement({
1050
1055
  sid: "ConfigList",
1051
- actions: ["s3:ListBucket"],
1056
+ actions: ["s3:ListBucket", "s3:ListBucketVersions"],
1052
1057
  resources: [props.bucket.bucketArn],
1053
1058
  }),
1054
1059
  );
1060
+ role.addToPolicy(
1061
+ new iam.PolicyStatement({
1062
+ sid: "ConfigLastGood",
1063
+ actions: ["s3:GetObjectVersion"],
1064
+ resources: [props.bucket.arnForObjects("config/*")],
1065
+ }),
1066
+ );
1055
1067
  role.addToPolicy(
1056
1068
  new iam.PolicyStatement({
1057
1069
  sid: "DocumentsObjects",
@@ -27,6 +27,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
27
27
  import type * as kms from "aws-cdk-lib/aws-kms";
28
28
  import * as lambda from "aws-cdk-lib/aws-lambda";
29
29
  import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
30
+ import * as logs from "aws-cdk-lib/aws-logs";
30
31
  import * as scheduler from "aws-cdk-lib/aws-scheduler";
31
32
  import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
32
33
  import * as sns from "aws-cdk-lib/aws-sns";
@@ -100,9 +101,11 @@ export interface FoundationApiProps extends cdk.StackProps {
100
101
  /**
101
102
  * FoundationApi — the edge: API Gateway → gateway Lambda → SQS → invoker
102
103
  * Lambda → `InvokeAgentRuntime`. The gateway must answer Slack inside 3
103
- * seconds, so it only verifies, dedupes and enqueues; the invoker holds the
104
- * blocking agent turn for up to Lambda's 15 minutes (which is also
105
- * AgentCore's per-invoke ceiling).
104
+ * seconds, so it only verifies, dedupes and enqueues; the invoker follows the
105
+ * blocking agent turn for up to Lambda's 15 minutes. A longer turn keeps
106
+ * running in its session and posts its own answer: the invoker stops waiting
107
+ * just before its deadline and returns, so the message is deleted rather than
108
+ * failed into the DLQ (`DETACH_MARGIN_MS` in gateway/src/invoker.ts).
106
109
  *
107
110
  * Neither Lambda is VPC-attached. Everything they call — Secrets Manager,
108
111
  * SQS, DynamoDB, AgentCore — is a public AWS endpoint, so the VPC would buy
@@ -193,9 +196,9 @@ export class FoundationApi extends cdk.Stack {
193
196
  encryption: sqs.QueueEncryption.KMS,
194
197
  encryptionMasterKey: props.dataKey,
195
198
  enforceSSL: true,
196
- // Matches the invoker's 15-minute timeout: Lambda extends visibility
197
- // while the turn runs, and a dead invoker's message surfaces once and
198
- // then rolls to the DLQ.
199
+ // Matches the invoker's 15-minute timeout. The invoker always returns
200
+ // before it, even on a longer turn, so only a crashed invoker's message
201
+ // surfaces again, and then it rolls to the DLQ instead of re-running.
199
202
  visibilityTimeout: cdk.Duration.seconds(900),
200
203
  retentionPeriod: cdk.Duration.hours(6),
201
204
  deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 },
@@ -1229,25 +1232,51 @@ export class FoundationApi extends cdk.Stack {
1229
1232
  });
1230
1233
  }
1231
1234
  const alarmAction = new cwActions.SnsAction(alarmTopic);
1232
- // Depth ≥ 1 on either DLQ means a user's message was silently dropped —
1233
- // the only way anyone learns it needs a redrive.
1235
+ // A message ARRIVING in either DLQ means a user's message was dropped, so
1236
+ // the alarm fires on growth, not depth. A depth alarm (≥ 1) stays in ALARM
1237
+ // while anything sits in the queue and never notifies again: Olivia's
1238
+ // InvokeDlq alarm sat in ALARM from 2026-09-26 through every later
1239
+ // arrival. This one clears once the depth stops growing and fires again
1240
+ // on the next arrival.
1241
+ //
1242
+ // Not `NumberOfMessagesSent`: SQS does not count a message its redrive
1243
+ // policy moves into a DLQ, which is how every InvokeDlq message arrives.
1244
+ // FILL turns the first arrival into an empty, idle queue (which reports
1245
+ // no data at all) into a step up from 0. One breaching period in three,
1246
+ // because FILL also fills the newest period while its datapoint is still
1247
+ // on the way; the step is caught once it lands. The 5-minute Maximum
1248
+ // hides the brief dip of someone inspecting the queue.
1234
1249
  for (const [name, queue] of [
1235
1250
  ["GatewayDlqDepthAlarm", this.gatewayDlq],
1236
1251
  ["InvokeDlqDepthAlarm", this.invokeDlq],
1237
1252
  ] as const) {
1238
1253
  const alarm = new cloudwatch.Alarm(this, name, {
1239
- alarmDescription: `${name}: messages are sitting in the DLQ`,
1240
- metric: queue.metricApproximateNumberOfMessagesVisible({
1254
+ alarmDescription: `${name}: new messages arrived in the DLQ. Inspect them before any redrive.`,
1255
+ metric: new cloudwatch.MathExpression({
1256
+ expression: "DIFF(FILL(depth, 0))",
1257
+ usingMetrics: {
1258
+ depth: queue.metricApproximateNumberOfMessagesVisible({
1259
+ period: cdk.Duration.minutes(5),
1260
+ statistic: "Maximum",
1261
+ }),
1262
+ },
1241
1263
  period: cdk.Duration.minutes(5),
1242
- statistic: "Maximum",
1264
+ label: "Messages added to the DLQ",
1243
1265
  }),
1244
- threshold: 1,
1245
- evaluationPeriods: 1,
1246
- comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
1266
+ threshold: 0,
1267
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
1268
+ evaluationPeriods: 3,
1269
+ datapointsToAlarm: 1,
1247
1270
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
1248
1271
  });
1249
1272
  alarm.addAlarmAction(alarmAction);
1250
1273
  }
1274
+ // The runtime serving anything but its deployed config.
1275
+ addConfigLoadAlarm(this, {
1276
+ instance: props.instance,
1277
+ agentRuntimeArn: props.agentRuntimeArn,
1278
+ alarmAction,
1279
+ });
1251
1280
 
1252
1281
  // --- deployment-owned scheduled jobs --------------------------------
1253
1282
  // Config jobs never share the agent-managed routine group or pass role.
@@ -1387,6 +1416,102 @@ export class FoundationApi extends cdk.Stack {
1387
1416
  }
1388
1417
  }
1389
1418
 
1419
+ /** What the runtime logs when it is not serving its deployed config. */
1420
+ export const CONFIG_LOAD_FAILURE_EVENTS = ["config_fallback_active", "config_reload_failed"];
1421
+
1422
+ /**
1423
+ * Alarm when the runtime is not serving the config deployed for it.
1424
+ *
1425
+ * A config the runtime cannot parse used to leave it on the built-in fallback
1426
+ * for hours with nobody told (Sky 2026-10-19, Olivia 2026-10-23). The runtime
1427
+ * now logs `config_fallback_active` once per boot and `config_reload_failed`
1428
+ * once per distinct failure in a warm container; this counts both in the
1429
+ * `live` endpoint's log group, where every human's turn runs, and raises the
1430
+ * instance's alarm topic.
1431
+ *
1432
+ * AgentCore creates that log group on the endpoint's first invocation, and
1433
+ * `live` itself is created by the deploy tool after CloudFormation, so a new
1434
+ * instance has no group for the metric filter to attach to. A custom resource
1435
+ * (`runtimeLogGroupHandler`, in the release-signed gateway bundle) therefore
1436
+ * creates it when it is missing, and leaves it alone when it exists or when
1437
+ * this stack is deleted. A phase-one deploy has no runtime: nothing to watch.
1438
+ *
1439
+ * The period is an hour, not five minutes: the condition lasts until someone
1440
+ * fixes the config, and a short period would re-notify after every quiet gap
1441
+ * between turns.
1442
+ */
1443
+ export function addConfigLoadAlarm(
1444
+ stack: cdk.Stack,
1445
+ args: {
1446
+ instance: Instance;
1447
+ agentRuntimeArn: string;
1448
+ alarmAction: cloudwatch.IAlarmAction;
1449
+ },
1450
+ ): cloudwatch.Alarm | undefined {
1451
+ if (args.agentRuntimeArn.endsWith("-pending")) return undefined;
1452
+ const runtimeId = cdk.Fn.select(1, cdk.Fn.split("/", args.agentRuntimeArn));
1453
+ const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${LIVE_ENDPOINT_NAME}`;
1454
+ const role = new iam.Role(stack, "AgentLiveLogGroupRole", {
1455
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
1456
+ description: `${args.instance.displayName} creates the runtime's live log group when missing`,
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,
1480
+ resourceType: "Custom::FoundationRuntimeLogGroup",
1481
+ properties: { LogGroupName: logGroupName },
1482
+ });
1483
+ // The function needs its permission before CloudFormation invokes it.
1484
+ ensureLogGroup.node.addDependency(role);
1485
+ const filter = new logs.MetricFilter(stack, "ConfigLoadFailureMetricFilter", {
1486
+ logGroup: logs.LogGroup.fromLogGroupName(stack, "AgentLiveLogs", logGroupName),
1487
+ filterPattern: logs.FilterPattern.any(
1488
+ ...CONFIG_LOAD_FAILURE_EVENTS.map((event) =>
1489
+ logs.FilterPattern.stringValue("$.event", "=", event),
1490
+ ),
1491
+ ),
1492
+ metricNamespace: `Foundation/${args.instance.naming.prefix}`,
1493
+ metricName: "ConfigLoadFailures",
1494
+ metricValue: "1",
1495
+ });
1496
+ filter.node.addDependency(ensureLogGroup);
1497
+ const alarm = new cloudwatch.Alarm(stack, "ConfigLoadFailureAlarm", {
1498
+ alarmDescription: [
1499
+ `${args.instance.displayName}'s runtime is not serving the config deployed for it: the`,
1500
+ "config failed to load, so it runs on its last good config with company-data",
1501
+ "capabilities off, or on the built-in fallback. The config_fallback_active or",
1502
+ "config_reload_failed line in the runtime's live log group names the refused field.",
1503
+ "Fix config.yaml, run foundation-deploy check, then deploy or config:sync.",
1504
+ ].join(" "),
1505
+ metric: filter.metric({ statistic: "Sum", period: cdk.Duration.hours(1) }),
1506
+ threshold: 1,
1507
+ evaluationPeriods: 1,
1508
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
1509
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
1510
+ });
1511
+ alarm.addAlarmAction(args.alarmAction);
1512
+ return alarm;
1513
+ }
1514
+
1390
1515
  interface ProvisionedCrmConfig {
1391
1516
  channels: string[];
1392
1517
  statuses: string[];
@@ -208,6 +208,13 @@ export class ReleaseAccountStack extends cdk.Stack {
208
208
  actions: ["ecr:GetAuthorizationToken"],
209
209
  resources: ["*"],
210
210
  }),
211
+ // Pull-only login to public ECR. Anonymous base-image pulls share
212
+ // the hosted runners' quota and failed v0.2.48's build with 429.
213
+ new iam.PolicyStatement({
214
+ sid: "EcrPublicLogin",
215
+ actions: ["ecr-public:GetAuthorizationToken", "sts:GetServiceBearerToken"],
216
+ resources: ["*"],
217
+ }),
211
218
  new iam.PolicyStatement({
212
219
  sid: "SignManifest",
213
220
  actions: ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],