@intentius/chant-lexicon-aws 0.44.13 → 0.45.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.
Files changed (52) hide show
  1. package/dist/agentcore/trace-fetch.d.ts +4 -1
  2. package/dist/agentcore/trace-fetch.d.ts.map +1 -1
  3. package/dist/api/read-client.d.ts +31 -1
  4. package/dist/api/read-client.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/components/capability-plugin.d.ts.map +1 -1
  7. package/dist/components/cloud-executor.d.ts +9 -0
  8. package/dist/components/cloud-executor.d.ts.map +1 -1
  9. package/dist/composites/agentcore-agent.d.ts +33 -19
  10. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  11. package/dist/composites/index.d.ts +1 -1
  12. package/dist/composites/index.d.ts.map +1 -1
  13. package/dist/deep-observe.d.ts +21 -0
  14. package/dist/deep-observe.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/integrity.json +2 -2
  18. package/dist/manifest.json +1 -1
  19. package/dist/op/activities/aws-apply.d.ts +14 -4
  20. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  21. package/dist/plugin.d.ts.map +1 -1
  22. package/dist/properties.d.ts +4 -3
  23. package/dist/properties.d.ts.map +1 -1
  24. package/dist/spec/fetch.d.ts +12 -1
  25. package/dist/spec/fetch.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/agentcore/trace-fetch.test.ts +17 -0
  28. package/src/agentcore/trace-fetch.ts +7 -2
  29. package/src/api/read-client.test.ts +87 -0
  30. package/src/api/read-client.ts +57 -1
  31. package/src/codegen/docs-links.test.ts +44 -30
  32. package/src/codegen/docs.ts +2 -1034
  33. package/src/components/capability-plugin.ts +5 -2
  34. package/src/components/cloud-executor.test.ts +29 -1
  35. package/src/components/cloud-executor.ts +23 -5
  36. package/src/composites/agentcore-agent.test.ts +32 -12
  37. package/src/composites/agentcore-agent.ts +43 -23
  38. package/src/composites/index.ts +1 -1
  39. package/src/composites/microvm-app.test.ts +2 -2
  40. package/src/deep-observe.test.ts +94 -0
  41. package/src/deep-observe.ts +58 -2
  42. package/src/import/roundtrip-fixtures.test.ts +1 -1
  43. package/src/index.ts +3 -1
  44. package/src/lifecycle-integration.test.ts +89 -0
  45. package/src/op/activities/aws-apply.test.ts +40 -4
  46. package/src/op/activities/aws-apply.ts +23 -7
  47. package/src/plugin.ts +34 -35
  48. package/src/properties.test.ts +5 -5
  49. package/src/properties.ts +6 -5
  50. package/src/serializer.test.ts +73 -33
  51. package/src/spec/fetch.test.ts +40 -0
  52. package/src/spec/fetch.ts +24 -3
@@ -69,8 +69,11 @@ export const awsCapabilityPlugin: CapabilityPlugin = {
69
69
  name: "aws",
70
70
  // The lexicon package's own version (#1505) — the "1.0.0" this shipped with
71
71
  // was the starter plugin's literal, copied along in the #681 extraction and
72
- // never a real package version.
73
- version: ownPackageVersion(import.meta.url),
72
+ // never a real package version. A getter, so the package.json read happens
73
+ // on first access rather than at import time.
74
+ get version(): string {
75
+ return ownPackageVersion(import.meta.url);
76
+ },
74
77
  capabilities: awsCapabilities,
75
78
  families: () => AWS_VERB_FAMILIES,
76
79
  };
@@ -1,5 +1,13 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { applyAwsEndpoint, applyAwsEndpointArgv, awsDeployCapabilities, ecsDeploymentId, ecsServiceStable } from "./cloud-executor";
2
+ import {
3
+ applyAwsEndpoint,
4
+ applyAwsEndpointArgv,
5
+ awsDeployCapabilities,
6
+ awsDeployCapabilitiesForBody,
7
+ awsDeployCapabilityList,
8
+ ecsDeploymentId,
9
+ ecsServiceStable,
10
+ } from "./cloud-executor";
3
11
 
4
12
  describe("ecsDeploymentId / ecsServiceStable — tolerate Floci's missing deployments (#937)", () => {
5
13
  test("deployment id: real AWS shape", () => {
@@ -74,4 +82,24 @@ describe("awsDeployCapabilities — CAPABILITY_AUTO_EXPAND for Transform macros"
74
82
  );
75
83
  expect(awsDeployCapabilities({ Transform: ["AWS::LanguageExtensions"] })).toContain("CAPABILITY_AUTO_EXPAND");
76
84
  });
85
+ test("list form mirrors the string form", () => {
86
+ expect(awsDeployCapabilityList({})).toEqual(["CAPABILITY_NAMED_IAM"]);
87
+ expect(awsDeployCapabilityList({ Transform: "AWS::Serverless-2016-10-31" })).toEqual([
88
+ "CAPABILITY_NAMED_IAM",
89
+ "CAPABILITY_AUTO_EXPAND",
90
+ ]);
91
+ expect(awsDeployCapabilityList({ Transform: ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"] })).toEqual([
92
+ "CAPABILITY_NAMED_IAM",
93
+ "CAPABILITY_AUTO_EXPAND",
94
+ ]);
95
+ });
96
+ test("raw body: parses JSON, falls back to NAMED_IAM for non-JSON or empty input", () => {
97
+ expect(awsDeployCapabilitiesForBody(JSON.stringify({ Transform: "AWS::SecretsManager-2020-07-23", Resources: {} }))).toEqual([
98
+ "CAPABILITY_NAMED_IAM",
99
+ "CAPABILITY_AUTO_EXPAND",
100
+ ]);
101
+ expect(awsDeployCapabilitiesForBody(JSON.stringify({ Resources: {} }))).toEqual(["CAPABILITY_NAMED_IAM"]);
102
+ expect(awsDeployCapabilitiesForBody("Resources:\n B:\n Type: AWS::S3::Bucket\n")).toEqual(["CAPABILITY_NAMED_IAM"]);
103
+ expect(awsDeployCapabilitiesForBody("")).toEqual(["CAPABILITY_NAMED_IAM"]);
104
+ });
77
105
  });
@@ -393,9 +393,26 @@ async function cfnChangeSetType(stackName: string): Promise<"CREATE" | "UPDATE">
393
393
  * CloudFormation refuses without the acknowledgement.
394
394
  */
395
395
  export function awsDeployCapabilities(template: { Transform?: unknown }): string {
396
+ return awsDeployCapabilityList(template).join(" ");
397
+ }
398
+
399
+ /** {@link awsDeployCapabilities} as a list, for the CFN API's `Capabilities.member.N` params. */
400
+ export function awsDeployCapabilityList(template: { Transform?: unknown }): string[] {
396
401
  return template.Transform !== undefined
397
- ? "CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND"
398
- : "CAPABILITY_NAMED_IAM";
402
+ ? ["CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]
403
+ : ["CAPABILITY_NAMED_IAM"];
404
+ }
405
+
406
+ /**
407
+ * {@link awsDeployCapabilityList} for a raw template body. A body that isn't
408
+ * JSON gets the default list; the deploy itself reports the real problem.
409
+ */
410
+ export function awsDeployCapabilitiesForBody(body: string): string[] {
411
+ try {
412
+ return awsDeployCapabilityList(JSON.parse(body) as { Transform?: unknown });
413
+ } catch {
414
+ return ["CAPABILITY_NAMED_IAM"];
415
+ }
399
416
  }
400
417
 
401
418
  const realCloudFormation: CloudFormationClient = {
@@ -410,10 +427,11 @@ const realCloudFormation: CloudFormationClient = {
410
427
  // for one still in REVIEW_IN_PROGRESS (a prior CREATE change set never
411
428
  // executed), mirroring what `aws cloudformation deploy` does under the hood.
412
429
  const changeSetType = await cfnChangeSetType(args.stackName);
413
- let capabilities = "CAPABILITY_NAMED_IAM";
430
+ let body = "";
414
431
  try {
415
- capabilities = awsDeployCapabilities(JSON.parse(readFileSync(args.templatePath, "utf8")) as { Transform?: unknown });
416
- } catch { /* unreadable/non-JSON template — keep the default capability */ }
432
+ body = readFileSync(args.templatePath, "utf8");
433
+ } catch { /* unreadable template — create-change-set reports it */ }
434
+ const capabilities = awsDeployCapabilitiesForBody(body).join(" ");
417
435
  await run(
418
436
  `aws cloudformation create-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)} ` +
419
437
  `--change-set-type ${changeSetType} ` +
@@ -6,7 +6,7 @@ import { AttrRef } from "@intentius/chant/attrref";
6
6
  // reaching into core directly here mirrors nested-stack-integration.test.ts's
7
7
  // precedent for aws-lexicon integration-style tests.
8
8
  import { resolveAttrRefs } from "../../../../packages/core/src/discovery/resolve";
9
- import { AgentCoreAgent } from "./agentcore-agent";
9
+ import { AgentCoreAgent, agentCoreDefaultEndpointArn } from "./agentcore-agent";
10
10
  import { Split, Ref } from "../intrinsics";
11
11
  import { awsSerializer } from "../serializer";
12
12
 
@@ -15,12 +15,12 @@ const baseProps = {
15
15
  containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/support-agent:latest",
16
16
  };
17
17
 
18
- // The endpoint is opt-in (#978) it races the Runtime's async version-READY when
19
- // created in the same apply. Tests that exercise the endpoint pass this variant.
20
- const withEndpoint = { ...baseProps, provisionEndpoint: true };
18
+ // An explicit endpoint exists only for a non-DEFAULT alias (#978): AgentCore owns DEFAULT,
19
+ // and declaring it races the Runtime's async version-READY. Endpoint tests use this variant.
20
+ const withEndpoint = { ...baseProps, endpointName: "PROD" };
21
21
 
22
22
  describe("AgentCoreAgent", () => {
23
- test("returns 7 members by default; the endpoint is opt-in (#978)", () => {
23
+ test("returns 7 members by default; no RuntimeEndpoint (#978)", () => {
24
24
  const instance = AgentCoreAgent(baseProps);
25
25
  expect(Object.keys(instance.members)).toEqual([
26
26
  "role", "gatewayRole", "runtime", "memory",
@@ -29,7 +29,7 @@ describe("AgentCoreAgent", () => {
29
29
  expect((instance as any).endpoint).toBeUndefined();
30
30
  });
31
31
 
32
- test("provisionEndpoint adds the endpoint as an 8th member", () => {
32
+ test("endpointName adds an explicit endpoint as an 8th member", () => {
33
33
  const instance = AgentCoreAgent(withEndpoint);
34
34
  expect(Object.keys(instance.members)).toContain("endpoint");
35
35
  expect(Object.keys(instance.members)).toHaveLength(8);
@@ -40,13 +40,13 @@ describe("AgentCoreAgent", () => {
40
40
  expect(expanded.has("agentRole")).toBe(true);
41
41
  expect(expanded.has("agentGatewayRole")).toBe(true);
42
42
  expect(expanded.has("agentRuntime")).toBe(true);
43
- expect(expanded.has("agentEndpoint")).toBe(false); // opt-in (#978)
43
+ expect(expanded.has("agentEndpoint")).toBe(false); // managed DEFAULT, not declared (#978)
44
44
  expect(expanded.has("agentMemory")).toBe(true);
45
45
  expect(expanded.has("agentWorkloadIdentity")).toBe(true);
46
46
  expect(expanded.has("agentGateway")).toBe(true);
47
47
  expect(expanded.has("agentGatewayTarget")).toBe(true);
48
48
  expect(expanded.size).toBe(7);
49
- // With the endpoint opted in, it appears as agentEndpoint.
49
+ // With an explicit non-DEFAULT endpoint, it appears as agentEndpoint.
50
50
  expect(expandComposite("agent", AgentCoreAgent(withEndpoint)).has("agentEndpoint")).toBe(true);
51
51
  });
52
52
 
@@ -69,7 +69,7 @@ describe("AgentCoreAgent", () => {
69
69
  const memoryProps = (instance.memory as any).props;
70
70
  expect(runtimeProps.AgentRuntimeName).toBe("support_agent");
71
71
  expect(runtimeProps.AgentRuntimeName).toMatch(/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/);
72
- expect(endpointProps.Name).toBe("DEFAULT");
72
+ expect(endpointProps.Name).toBe("PROD");
73
73
  expect(memoryProps.Name).toBe("support_agentMemory");
74
74
  expect(memoryProps.Name).toMatch(/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/);
75
75
  });
@@ -144,7 +144,7 @@ describe("AgentCoreAgent", () => {
144
144
  code: { s3Bucket: "loom-artifacts", s3Prefix: "agents/assistant.zip", runtime: "PYTHON_3_12", entryPoint: ["app.py"] },
145
145
  }));
146
146
  resolveAttrRefs(expanded);
147
- const template = JSON.parse(awsSerializer.serialize(expanded));
147
+ const template = JSON.parse(awsSerializer.serialize(expanded) as string);
148
148
  const artifact = template.Resources.agentRuntime.Properties.AgentRuntimeArtifact;
149
149
  expect(artifact.ContainerConfiguration).toBeUndefined();
150
150
  expect(artifact.CodeConfiguration).toEqual({
@@ -195,7 +195,27 @@ describe("AgentCoreAgent", () => {
195
195
  );
196
196
  });
197
197
 
198
- test("RuntimeEndpoint references runtime.AgentRuntimeId (when opted in)", () => {
198
+ test("endpointName \"DEFAULT\" is rejected AgentCore provisions the managed DEFAULT endpoint (#978)", () => {
199
+ expect(() => AgentCoreAgent({ ...baseProps, endpointName: "DEFAULT" })).toThrow(
200
+ "AgentCoreAgent endpointName must not be \"DEFAULT\"",
201
+ );
202
+ });
203
+
204
+ test("explicit endpoint name is sanitized to the Runtime identifier pattern", () => {
205
+ const instance = AgentCoreAgent({ ...baseProps, endpointName: "blue-green" });
206
+ expect((instance.endpoint as any).props.Name).toBe("blue_green");
207
+ });
208
+
209
+ test("agentCoreDefaultEndpointArn derives the managed DEFAULT endpoint ARN from the Runtime ARN", () => {
210
+ const instance = AgentCoreAgent(baseProps);
211
+ const expanded = expandComposite("agent", instance);
212
+ resolveAttrRefs(expanded);
213
+ expect(JSON.parse(JSON.stringify(agentCoreDefaultEndpointArn(instance.runtime)))).toEqual({
214
+ "Fn::Sub": "${agentRuntime.AgentRuntimeArn}/runtime-endpoint/DEFAULT",
215
+ });
216
+ });
217
+
218
+ test("RuntimeEndpoint references runtime.AgentRuntimeId (when an explicit endpoint is named)", () => {
199
219
  const instance = AgentCoreAgent(withEndpoint);
200
220
  const endpointProps = (instance.endpoint as any).props;
201
221
  expect(endpointProps.AgentRuntimeId).toBeInstanceOf(AttrRef);
@@ -245,7 +265,7 @@ describe("AgentCoreAgent", () => {
245
265
  test("serializes to a valid CloudFormation template with the expected resource types", () => {
246
266
  const expanded = expandComposite("agent", AgentCoreAgent(withEndpoint));
247
267
  resolveAttrRefs(expanded);
248
- const output = awsSerializer.serialize(expanded);
268
+ const output = awsSerializer.serialize(expanded) as string;
249
269
  const template = JSON.parse(output);
250
270
 
251
271
  expect(template.AWSTemplateFormatVersion).toBe("2010-09-09");
@@ -30,6 +30,19 @@ export type AgentManagedRuntime =
30
30
  | "PYTHON_3_13"
31
31
  | "PYTHON_3_14";
32
32
  import { agentCoreTrustPolicy } from "./agentcore-trust-policy";
33
+ import { Sub, type SubIntrinsic } from "../intrinsics";
34
+
35
+ /**
36
+ * ARN of the managed `DEFAULT` endpoint AgentCore provisions with a Runtime,
37
+ * as an `Fn::Sub` over the Runtime's ARN. No CloudFormation attribute carries
38
+ * it (the Runtime schema exposes only `AgentRuntimeArn`/`AgentRuntimeId`/
39
+ * `AgentRuntimeVersion`/`Status`), but the format is fixed by the
40
+ * CreateAgentRuntimeEndpoint API:
41
+ * `arn:aws:bedrock-agentcore:<region>:<account>:runtime/<id>/runtime-endpoint/<name>`.
42
+ */
43
+ export function agentCoreDefaultEndpointArn(runtime: InstanceType<typeof Runtime>): SubIntrinsic {
44
+ return Sub`${runtime.AgentRuntimeArn}/runtime-endpoint/DEFAULT`;
45
+ }
33
46
 
34
47
  /**
35
48
  * AgentCore's `Runtime`/`RuntimeEndpoint`/`Memory` `Name`/`AgentRuntimeName`
@@ -70,7 +83,7 @@ export interface AgentCoreCodeArtifact {
70
83
  export interface AgentCoreAgentProps {
71
84
  /**
72
85
  * Base name for the agent's resources. `toRuntimeIdentifier(name)` derives
73
- * the Runtime/RuntimeEndpoint/Memory names; Gateway/GatewayTarget/
86
+ * the Runtime/Memory (and any explicit endpoint) names; Gateway/GatewayTarget/
74
87
  * WorkloadIdentity use `name` as-is (hyphens are valid there).
75
88
  */
76
89
  name: string;
@@ -94,20 +107,16 @@ export interface AgentCoreAgentProps {
94
107
  protocolConfiguration?: "A2A" | "AGUI" | "HTTP" | "MCP";
95
108
  /** Environment variables passed to the Runtime container. */
96
109
  environmentVariables?: Record<string, string>;
97
- /** RuntimeEndpoint name — the alias a version-promotion capability would repoint (deferred, see #882). Default: "DEFAULT". */
98
- endpointName?: string;
99
110
  /**
100
- * Create the `RuntimeEndpoint` in this template. **Default: false.** A
101
- * RuntimeEndpoint can only be created once the Runtime's agent *version* is
102
- * READY, which is asynchronous and is NOT gated by the Runtime resource's own
103
- * CloudFormation `CREATE_COMPLETE` so creating the endpoint in the same apply
104
- * as the Runtime races and fails on a real deploy ("Agent version 1 must be in
105
- * READY status. Current status: CREATING", #978). Leave this off and create the
106
- * endpoint out-of-band once the runtime is READY which is what Bedrock
107
- * AgentCore's own tooling (and Loom's app) does. Opt in only when you know the
108
- * Runtime will already be READY (e.g. a version-promotion flow on an existing runtime).
111
+ * Name of an explicit, non-`DEFAULT` `RuntimeEndpoint` to create alongside
112
+ * the Runtime (e.g. `"PROD"`), the alias a version-promotion flow would
113
+ * later repoint (deferred, see #882). Omit it and no endpoint resource is
114
+ * created: AgentCore provisions a managed `DEFAULT` endpoint with every
115
+ * Runtime, and it tracks the latest version on its own. `"DEFAULT"` is
116
+ * rejected, since a CloudFormation endpoint of that name duplicates the
117
+ * managed one and fails on a real apply (#978, see the composite doc).
109
118
  */
110
- provisionEndpoint?: boolean;
119
+ endpointName?: string;
111
120
  /** Memory event retention, in days. CFN bounds: 3-365. Default: 30. */
112
121
  memoryEventExpiryDays?: number;
113
122
  /** Gateway authorizer. Mirrors the generated `BedrockAgentCoreGateway_AuthorizerType` CFN enum. Default: "AWS_IAM". */
@@ -134,7 +143,7 @@ export type AgentCoreAgentResult = {
134
143
  role: InstanceType<typeof Role>;
135
144
  gatewayRole: InstanceType<typeof Role>;
136
145
  runtime: InstanceType<typeof Runtime>;
137
- /** Present only when `provisionEndpoint` is set see that prop (#978). */
146
+ /** Present only when `endpointName` names an explicit non-DEFAULT endpoint (#978). */
138
147
  endpoint?: InstanceType<typeof RuntimeEndpoint>;
139
148
  memory: InstanceType<typeof Memory>;
140
149
  workloadIdentity: InstanceType<typeof WorkloadIdentity>;
@@ -144,7 +153,7 @@ export type AgentCoreAgentResult = {
144
153
 
145
154
  /**
146
155
  * A Bedrock AgentCore agent as one CloudFormation-serializable bundle — the
147
- * composite/base path from #882: `Runtime` + `RuntimeEndpoint` + `Memory` +
156
+ * composite/base path from #882: `Runtime` + `Memory` +
148
157
  * `Gateway`/`GatewayTarget` + `WorkloadIdentity` + IAM, deployable with
149
158
  * `cfn-deploy` + `wait-for-stack` and no bespoke verb.
150
159
  *
@@ -156,10 +165,18 @@ export type AgentCoreAgentResult = {
156
165
  * for workflows (e.g. a future credential-provider capability) that need an
157
166
  * explicit workload identity of their own.
158
167
  *
159
- * The `agentcore-deploy` version-promotion capability that would repoint
160
- * `endpoint`'s `TargetVersion`/`LiveVersion` is deferred (GA-gated, #882)
161
- * this composite only wires the CloudFormation shape it would eventually
162
- * apply against.
168
+ * There is no `RuntimeEndpoint` in the bundle by default. AgentCore creates a
169
+ * managed `DEFAULT` endpoint with every Runtime and repoints it at each new
170
+ * version on its own, so a CloudFormation `DEFAULT` endpoint is redundant. It
171
+ * is also the race that sank a live deploy (#978): the Runtime resource's
172
+ * `CREATE_COMPLETE` fires while AgentCore is still turning the artifact into
173
+ * a READY agent version, and the endpoint's CREATE then fails with "Agent
174
+ * version 1 must be in READY status". Invoking the Runtime with no qualifier
175
+ * hits the managed DEFAULT endpoint; {@link agentCoreDefaultEndpointArn}
176
+ * builds its ARN from the Runtime's. `endpointName` adds an explicit
177
+ * non-DEFAULT endpoint for the `agentcore-deploy` version-promotion flow,
178
+ * which is deferred (GA-gated, #882) — this composite only wires the
179
+ * CloudFormation shape it would eventually apply against.
163
180
  */
164
181
  export const AgentCoreAgent = Composite<AgentCoreAgentProps, AgentCoreAgentResult>((props) => {
165
182
  const { defaults } = props;
@@ -241,12 +258,15 @@ export const AgentCoreAgent = Composite<AgentCoreAgentProps, AgentCoreAgentResul
241
258
  EnvironmentVariables: props.environmentVariables,
242
259
  }, defaults?.runtime));
243
260
 
244
- // Opt-in only (#978): the endpoint races the Runtime's async version-READY when
245
- // created in the same apply. Off by default; create it out-of-band post-READY.
246
- const endpoint = props.provisionEndpoint
261
+ // Explicit endpoints only for a non-DEFAULT alias (#978): AgentCore owns
262
+ // DEFAULT, and creating it here races the Runtime's async version-READY.
263
+ if (props.endpointName === "DEFAULT") {
264
+ throw new Error("AgentCoreAgent endpointName must not be \"DEFAULT\": AgentCore provisions the managed DEFAULT endpoint itself");
265
+ }
266
+ const endpoint = props.endpointName !== undefined
247
267
  ? new RuntimeEndpoint(mergeDefaults({
248
268
  AgentRuntimeId: runtime.AgentRuntimeId,
249
- Name: toRuntimeIdentifier(props.endpointName ?? "DEFAULT"),
269
+ Name: toRuntimeIdentifier(props.endpointName),
250
270
  }, defaults?.endpoint))
251
271
  : undefined;
252
272
 
@@ -43,5 +43,5 @@ export { EksCluster } from "./eks-cluster";
43
43
  export type { EksClusterProps } from "./eks-cluster";
44
44
  export { MicrovmApp, MICROVM_LIMITS } from "./microvm-app";
45
45
  export type { MicrovmAppProps, MicrovmAppResult, MicrovmAppBuildConnectorProps, MicrovmMemoryMiB } from "./microvm-app";
46
- export { AgentCoreAgent } from "./agentcore-agent";
46
+ export { AgentCoreAgent, agentCoreDefaultEndpointArn } from "./agentcore-agent";
47
47
  export type { AgentCoreAgentProps, AgentCoreAgentResult } from "./agentcore-agent";
@@ -149,7 +149,7 @@ describe("MicrovmApp", () => {
149
149
  const instance = MicrovmApp(baseProps);
150
150
  const entities = expandComposite("worker", instance);
151
151
  resolveAttrRefs(entities);
152
- const output = awsSerializer.serialize(entities);
152
+ const output = awsSerializer.serialize(entities) as string;
153
153
  const template = JSON.parse(output);
154
154
 
155
155
  expect(template.AWSTemplateFormatVersion).toBe("2010-09-09");
@@ -204,7 +204,7 @@ describe("MicrovmApp", () => {
204
204
  });
205
205
  const entities = expandComposite("worker", instance);
206
206
  resolveAttrRefs(entities);
207
- const output = awsSerializer.serialize(entities);
207
+ const output = awsSerializer.serialize(entities) as string;
208
208
  const template = JSON.parse(output);
209
209
 
210
210
  const connector = template.Resources.workerConnector;
@@ -24,6 +24,7 @@ const {
24
24
  observeResourcesDeepAws,
25
25
  awsDeepNormalizationHooks,
26
26
  hasOwnershipMarker,
27
+ schemaReadOnlyPatterns,
27
28
  } = await import("./deep-observe");
28
29
  const { parseResourceDescription } = await import("./api/read-client");
29
30
  const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
@@ -252,6 +253,99 @@ describe("the aws noise rules", () => {
252
253
  });
253
254
  });
254
255
 
256
+ // A property the schema marks read-only is a GetAtt attribute, never a
257
+ // declared input. The live read still reports it, so without the schema-driven
258
+ // rule every clean apply shows `<undeclared> -> value` for it.
259
+ describe("schema read-only properties are attributes, not drift (#1641)", () => {
260
+ test("the registry is read off the schema's readOnlyProperties, arrays spelled as patterns", () => {
261
+ expect([...schemaReadOnlyPatterns("AWS::IAM::ManagedPolicy")]).toEqual(
262
+ expect.arrayContaining(["PolicyArn", "PolicyId", "AttachmentCount", "DefaultVersionId"]),
263
+ );
264
+ expect([...schemaReadOnlyPatterns("AWS::RDS::DBInstance")]).toEqual(
265
+ expect.arrayContaining(["Endpoint.Address", "Endpoint.Port", "DbiResourceId", "DBInstanceStatus"]),
266
+ );
267
+ expect([...schemaReadOnlyPatterns("AWS::CE::AnomalySubscription")]).toContain("Subscribers[].Status");
268
+ expect(schemaReadOnlyPatterns("AWS::Made::Up").size).toBe(0);
269
+ });
270
+
271
+ test("ManagedPolicy: a live read carrying PolicyArn is not property drift", async () => {
272
+ vi.spyOn(globalThis, "fetch").mockImplementation((async (_url: string, init: { headers: Record<string, string>; body: string }) => {
273
+ const target = init.headers["x-amz-target"];
274
+ if (!target) return { status: 200, text: () => Promise.resolve(stackResources([["ReadOnly", "AWS::IAM::ManagedPolicy", "arn:aws:iam::000000000000:policy/S3VectorsReadOnlyAccess"]]).text) };
275
+ const r = cloudControl("arn:aws:iam::000000000000:policy/S3VectorsReadOnlyAccess", {
276
+ ManagedPolicyName: "S3VectorsReadOnlyAccess",
277
+ Path: "/",
278
+ PolicyDocument: { Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: ["s3vectors:Get*"], Resource: "*" }] },
279
+ // Every readOnlyProperties entry for the type, as real AWS and the
280
+ // emulator return them. PolicyArn is also the primary identifier.
281
+ PolicyArn: "arn:aws:iam::000000000000:policy/S3VectorsReadOnlyAccess",
282
+ PolicyId: "ANPA000000000000EXAMPLE",
283
+ AttachmentCount: 1,
284
+ DefaultVersionId: "v1",
285
+ IsAttachable: true,
286
+ PermissionsBoundaryUsageCount: 0,
287
+ CreateDate: "2026-01-01T00:00:00Z",
288
+ UpdateDate: "2026-01-01T00:00:00Z",
289
+ });
290
+ return { status: r.status, text: () => Promise.resolve(r.text) };
291
+ }) as unknown as typeof fetch);
292
+ try {
293
+ const result = await deepDiffForLexicon(awsPlugin, {
294
+ environment: "prod",
295
+ buildOutput: "",
296
+ entities: entities({
297
+ ReadOnly: {
298
+ entityType: "AWS::IAM::ManagedPolicy",
299
+ props: {
300
+ ManagedPolicyName: "S3VectorsReadOnlyAccess",
301
+ PolicyDocument: { Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: ["s3vectors:Get*"], Resource: "*" }] },
302
+ },
303
+ },
304
+ }),
305
+ });
306
+ expect(result.drifted).toEqual([]);
307
+ expect(result.unchanged).toEqual(["ReadOnly"]);
308
+ } finally {
309
+ vi.restoreAllMocks();
310
+ }
311
+ });
312
+
313
+ test("a second type with nested read-only paths: RDS DBInstance's endpoint and status are pruned, inputs are kept", () => {
314
+ const out = normalizeDeepProperties(
315
+ {
316
+ DBInstanceIdentifier: "db-1",
317
+ DBInstanceClass: "db.t4g.micro",
318
+ Endpoint: { Address: "db-1.abc.us-east-1.rds.amazonaws.com", Port: "5432", HostedZoneId: "Z1" },
319
+ DbiResourceId: "db-ABCDEF",
320
+ DBInstanceStatus: "available",
321
+ InstanceCreateTime: "2026-01-01T00:00:00Z",
322
+ CertificateDetails: { CAIdentifier: "rds-ca-rsa2048-g1", ValidTill: "2027-01-01T00:00:00Z" },
323
+ ProcessorFeatures: [{ Name: "coreCount", Value: "2" }],
324
+ },
325
+ { entityType: "AWS::RDS::DBInstance", side: "live", hooks: awsDeepNormalizationHooks },
326
+ );
327
+ expect(out).toEqual({
328
+ DBInstanceIdentifier: "db-1",
329
+ DBInstanceClass: "db.t4g.micro",
330
+ ProcessorFeatures: [{ Name: "coreCount", Value: "2" }],
331
+ });
332
+ });
333
+
334
+ test("an array element path from the schema prunes inside the array", () => {
335
+ const out = normalizeDeepProperties(
336
+ {
337
+ SubscriptionName: "spend",
338
+ Subscribers: [{ Address: "a@example.com", Type: "EMAIL", Status: "CONFIRMED" }],
339
+ },
340
+ { entityType: "AWS::CE::AnomalySubscription", side: "live", hooks: awsDeepNormalizationHooks },
341
+ );
342
+ expect(out).toEqual({
343
+ SubscriptionName: "spend",
344
+ Subscribers: [{ Address: "a@example.com", Type: "EMAIL" }],
345
+ });
346
+ });
347
+ });
348
+
255
349
  describe("hasOwnershipMarker", () => {
256
350
  test("reads chant's tag out of the live tree", () => {
257
351
  expect(hasOwnershipMarker({ Tags: [{ Key: "chant:managed-by", Value: "chant" }] })).toBe(true);
@@ -62,6 +62,9 @@ import {
62
62
  import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
63
63
  import { applyAwsEndpointArgv } from "./components/cloud-executor";
64
64
  import { toIngressRules } from "./dependencies";
65
+ import { createRequire } from "node:module";
66
+
67
+ const require = createRequire(import.meta.url);
65
68
 
66
69
  /**
67
70
  * Where each type's live model comes from.
@@ -226,6 +229,58 @@ function canonicalJson(value: unknown): string {
226
229
  ) ?? "";
227
230
  }
228
231
 
232
+ /**
233
+ * The read-only properties of one type, as index-erased patterns, straight from
234
+ * the CloudFormation schema's `readOnlyProperties` (#1641).
235
+ *
236
+ * The codegen already turns that list into each class's GetAtt attributes
237
+ * (`attrs` in `lexicon-aws.json`), so this reads the same registry from the
238
+ * other side: a path the schema says only the service can write is an
239
+ * attribute, and an attribute the live read reports is not property drift. No
240
+ * declaration can contain one, so `<undeclared> -> value` is the shape every
241
+ * clean apply would otherwise produce. `AWS::IAM::ManagedPolicy.PolicyArn`,
242
+ * which is also the type's Cloud Control primary identifier, is the case that
243
+ * surfaced it.
244
+ *
245
+ * The manifest spells an array element `Subscribers.*.Status`; the
246
+ * normalization pass spells the same thing `Subscribers[].Status`.
247
+ *
248
+ * Complements {@link AWS_READ_ONLY_NAMES} rather than replacing it: the
249
+ * name-based list also covers translated models (an EC2 row mapped onto the
250
+ * CloudFormation shape) and nested documents the schema never enumerates.
251
+ */
252
+ export function schemaReadOnlyPatterns(entityType: string): ReadonlySet<string> {
253
+ const cached = readOnlyByType.get(entityType);
254
+ if (cached) return cached;
255
+ const entry = manifestByType().get(entityType);
256
+ const patterns = new Set<string>();
257
+ for (const attr of Object.values(entry?.attrs ?? {})) {
258
+ patterns.add(attr.replace(/\.\*(?=\.|$)/g, "[]"));
259
+ }
260
+ readOnlyByType.set(entityType, patterns);
261
+ return patterns;
262
+ }
263
+
264
+ interface ManifestEntry {
265
+ resourceType: string;
266
+ kind: string;
267
+ attrs?: Record<string, string>;
268
+ }
269
+
270
+ const readOnlyByType = new Map<string, ReadonlySet<string>>();
271
+ let manifestIndex: Map<string, ManifestEntry> | undefined;
272
+ function manifestByType(): Map<string, ManifestEntry> {
273
+ if (!manifestIndex) {
274
+ const manifest = require("./generated/lexicon-aws.json") as Record<string, ManifestEntry>;
275
+ manifestIndex = new Map(
276
+ Object.values(manifest)
277
+ .filter((e) => e.kind === "resource")
278
+ .map((e) => [e.resourceType, e]),
279
+ );
280
+ }
281
+ return manifestIndex;
282
+ }
283
+
229
284
  /** The final segment of an index-erased pattern (`Policies[].PolicyName` → `PolicyName`). */
230
285
  function lastSegment(pattern: string): string {
231
286
  const withoutIndex = pattern.replace(/\[\]$/, "");
@@ -243,7 +298,9 @@ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
243
298
  prune(node: DeepNode): boolean {
244
299
  // Read-only / server-populated. Pruned on both sides: if source somehow
245
300
  // declares an arn-shaped output, comparing it to the live one is still
246
- // meaningless.
301
+ // meaningless. The schema's own `readOnlyProperties` first (#1641), then
302
+ // the name-based list for what the schema does not enumerate.
303
+ if (schemaReadOnlyPatterns(node.entityType).has(node.pattern)) return true;
247
304
  if (AWS_READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true;
248
305
 
249
306
  // Provider defaults, on the live side only, and only where source is silent
@@ -424,7 +481,6 @@ export async function observeResourcesDeepAws(
424
481
 
425
482
  const stackName = options.stack ?? options.environment;
426
483
  const client: AwsReadClientOptions = {
427
- ...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL } : {}),
428
484
  ...(options.region ? { region: options.region } : {}),
429
485
  ...(options.http ? { http: options.http } : {}),
430
486
  };
@@ -87,7 +87,7 @@ describe("parameters.json build roundtrip", () => {
87
87
  const result = await build(srcDir, [awsSerializer]);
88
88
  expect(result.errors).toHaveLength(0);
89
89
 
90
- const template = JSON.parse(result.outputs.get("aws")!);
90
+ const template = JSON.parse(result.outputs.get("aws") as string);
91
91
 
92
92
  // Verify parameters round-tripped correctly (names preserved as-is)
93
93
  for (const [name, param] of Object.entries(source.Parameters ?? {})) {
package/src/index.ts CHANGED
@@ -47,6 +47,8 @@ export {
47
47
  getResource,
48
48
  listResources,
49
49
  parseResourceDescription,
50
+ resolveEndpointOverride,
51
+ serviceEndpointEnvVar,
50
52
  AwsReadError,
51
53
  type AwsReadHttp,
52
54
  type AwsReadClientOptions,
@@ -126,7 +128,7 @@ export {
126
128
  Ec2InstanceRole, MinimalVpc, EksCluster,
127
129
  SolrFargateService,
128
130
  MicrovmApp, MICROVM_LIMITS,
129
- AgentCoreAgent,
131
+ AgentCoreAgent, agentCoreDefaultEndpointArn,
130
132
  OrganizationRoot, GovernanceFoundation, RegionRestriction, OrganizationTrail,
131
133
  } from "./composites/index";
132
134
  export type {