@intentius/chant-lexicon-aws 0.46.0 → 0.50.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 (183) hide show
  1. package/dist/api/read-client.d.ts +10 -2
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/codegen/generate-lexicon.d.ts +5 -0
  4. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  5. package/dist/components/host-delivery.d.ts +4 -3
  6. package/dist/components/host-delivery.d.ts.map +1 -1
  7. package/dist/composites/agentcore-agent.d.ts +26 -1
  8. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  9. package/dist/composites/bucket-deployment.d.ts +82 -0
  10. package/dist/composites/bucket-deployment.d.ts.map +1 -0
  11. package/dist/composites/dynamodb-table.d.ts +45 -0
  12. package/dist/composites/dynamodb-table.d.ts.map +1 -0
  13. package/dist/composites/ec2-instance-bundle.d.ts +61 -0
  14. package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
  15. package/dist/composites/ecr-repository.d.ts +49 -0
  16. package/dist/composites/ecr-repository.d.ts.map +1 -0
  17. package/dist/composites/fargate-service.d.ts +16 -0
  18. package/dist/composites/fargate-service.d.ts.map +1 -1
  19. package/dist/composites/index.d.ts +16 -2
  20. package/dist/composites/index.d.ts.map +1 -1
  21. package/dist/composites/monitoring-stack.d.ts +99 -0
  22. package/dist/composites/monitoring-stack.d.ts.map +1 -0
  23. package/dist/composites/nlb-service.d.ts +48 -0
  24. package/dist/composites/nlb-service.d.ts.map +1 -0
  25. package/dist/composites/step-functions-workflow.d.ts +36 -0
  26. package/dist/composites/step-functions-workflow.d.ts.map +1 -0
  27. package/dist/deep-observe.d.ts +45 -0
  28. package/dist/deep-observe.d.ts.map +1 -1
  29. package/dist/deep-topology.d.ts +104 -0
  30. package/dist/deep-topology.d.ts.map +1 -0
  31. package/dist/effect-receipt-row.d.ts +92 -0
  32. package/dist/effect-receipt-row.d.ts.map +1 -0
  33. package/dist/index.d.ts +7 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/integrity.json +14 -6
  36. package/dist/lint/audit-catalog.d.ts.map +1 -1
  37. package/dist/lint/post-synth/cf-refs.d.ts +17 -0
  38. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  39. package/dist/lint/post-synth/index.d.ts.map +1 -1
  40. package/dist/lint/post-synth/waw016.d.ts +10 -2
  41. package/dist/lint/post-synth/waw016.d.ts.map +1 -1
  42. package/dist/lint/post-synth/waw061.d.ts +19 -0
  43. package/dist/lint/post-synth/waw061.d.ts.map +1 -0
  44. package/dist/lint/post-synth/waw062.d.ts +19 -0
  45. package/dist/lint/post-synth/waw062.d.ts.map +1 -0
  46. package/dist/lint/post-synth/waw063.d.ts +35 -0
  47. package/dist/lint/post-synth/waw063.d.ts.map +1 -0
  48. package/dist/lint/post-synth/waw064.d.ts +17 -0
  49. package/dist/lint/post-synth/waw064.d.ts.map +1 -0
  50. package/dist/lint/post-synth/waw065.d.ts +23 -0
  51. package/dist/lint/post-synth/waw065.d.ts.map +1 -0
  52. package/dist/lint/post-synth/waw066.d.ts +25 -0
  53. package/dist/lint/post-synth/waw066.d.ts.map +1 -0
  54. package/dist/lint/post-synth/waw067.d.ts +24 -0
  55. package/dist/lint/post-synth/waw067.d.ts.map +1 -0
  56. package/dist/lint/post-synth/waw068.d.ts +20 -0
  57. package/dist/lint/post-synth/waw068.d.ts.map +1 -0
  58. package/dist/manifest.json +1 -1
  59. package/dist/meta.json +34 -0
  60. package/dist/okf/index.md +8 -0
  61. package/dist/okf/rules/WAW061.md +17 -0
  62. package/dist/okf/rules/WAW062.md +27 -0
  63. package/dist/okf/rules/WAW063.md +19 -0
  64. package/dist/okf/rules/WAW064.md +18 -0
  65. package/dist/okf/rules/WAW065.md +25 -0
  66. package/dist/okf/rules/WAW066.md +22 -0
  67. package/dist/okf/rules/WAW067.md +20 -0
  68. package/dist/okf/rules/WAW068.md +19 -0
  69. package/dist/okf/types/Action.md +1 -0
  70. package/dist/okf/types/AutoScalingGroup.md +1 -0
  71. package/dist/okf/types/Bucket.md +1 -0
  72. package/dist/okf/types/EC2Route.md +2 -0
  73. package/dist/okf/types/ECRRepository.md +1 -0
  74. package/dist/okf/types/Function.md +1 -0
  75. package/dist/okf/types/IamPolicy.md +1 -0
  76. package/dist/okf/types/InternetGateway.md +4 -0
  77. package/dist/okf/types/LaunchTemplate.md +1 -0
  78. package/dist/okf/types/LoadBalancer.md +1 -0
  79. package/dist/okf/types/ManagedPolicy.md +2 -0
  80. package/dist/okf/types/Map.md +5 -0
  81. package/dist/okf/types/NatGateway.md +4 -0
  82. package/dist/okf/types/Role.md +2 -0
  83. package/dist/okf/types/RouteTable.md +4 -0
  84. package/dist/okf/types/SecurityGroup.md +1 -0
  85. package/dist/okf/types/StateMachine.md +4 -0
  86. package/dist/okf/types/Subnet.md +6 -0
  87. package/dist/okf/types/SubnetRouteTableAssociation.md +5 -0
  88. package/dist/okf/types/Table.md +4 -0
  89. package/dist/okf/types/TransitGateway.md +6 -0
  90. package/dist/okf/types/TransitGatewayAttachment.md +4 -0
  91. package/dist/okf/types/TransitGatewayPeeringAttachment.md +4 -0
  92. package/dist/okf/types/TransitGatewayRoute.md +5 -0
  93. package/dist/okf/types/TransitGatewayRouteTable.md +4 -0
  94. package/dist/okf/types/TransitGatewayRouteTableAssociation.md +4 -0
  95. package/dist/okf/types/TransitGatewayRouteTablePropagation.md +4 -0
  96. package/dist/okf/types/TransitGatewayVpcAttachment.md +4 -0
  97. package/dist/okf/types/Type.md +8 -0
  98. package/dist/okf/types/VPNConnection.md +4 -0
  99. package/dist/okf/types/VPNGateway.md +4 -0
  100. package/dist/okf/types/Vpc.md +1 -0
  101. package/dist/op/activities/index.d.ts +6 -0
  102. package/dist/op/activities/index.d.ts.map +1 -1
  103. package/dist/op/builders.d.ts +47 -0
  104. package/dist/op/builders.d.ts.map +1 -0
  105. package/dist/plugin.d.ts.map +1 -1
  106. package/dist/receipt-store.d.ts +109 -0
  107. package/dist/receipt-store.d.ts.map +1 -0
  108. package/dist/rules/cf-refs.ts +40 -0
  109. package/dist/rules/waw016.ts +37 -16
  110. package/dist/rules/waw061.ts +67 -0
  111. package/dist/rules/waw062.ts +0 -0
  112. package/dist/rules/waw063.ts +0 -0
  113. package/dist/rules/waw064.ts +52 -0
  114. package/dist/rules/waw065.ts +110 -0
  115. package/dist/rules/waw066.ts +155 -0
  116. package/dist/rules/waw067.ts +114 -0
  117. package/dist/rules/waw068.ts +78 -0
  118. package/dist/serializer.d.ts.map +1 -1
  119. package/dist/skills/chant-aws-carve-terraform.md +26 -23
  120. package/dist/spec/parse.d.ts +7 -0
  121. package/dist/spec/parse.d.ts.map +1 -1
  122. package/package.json +3 -3
  123. package/src/api/read-client.test.ts +9 -0
  124. package/src/api/read-client.ts +15 -2
  125. package/src/codegen/fallback.ts +1 -0
  126. package/src/codegen/generate-lexicon.ts +6 -0
  127. package/src/codegen/sam.ts +9 -0
  128. package/src/components/host-delivery.ts +4 -3
  129. package/src/composites/agentcore-agent.ts +35 -13
  130. package/src/composites/bucket-deployment.test.ts +84 -0
  131. package/src/composites/bucket-deployment.ts +153 -0
  132. package/src/composites/composites.test.ts +325 -0
  133. package/src/composites/dynamodb-table.test.ts +154 -0
  134. package/src/composites/dynamodb-table.ts +124 -0
  135. package/src/composites/ec2-instance-bundle.ts +134 -0
  136. package/src/composites/ecr-repository.test.ts +118 -0
  137. package/src/composites/ecr-repository.ts +117 -0
  138. package/src/composites/fargate-service.ts +19 -3
  139. package/src/composites/index.ts +21 -2
  140. package/src/composites/monitoring-stack.test.ts +130 -0
  141. package/src/composites/monitoring-stack.ts +164 -0
  142. package/src/composites/nlb-service.ts +117 -0
  143. package/src/composites/step-functions-workflow.ts +127 -0
  144. package/src/deep-observe.test.ts +201 -1
  145. package/src/deep-observe.ts +204 -0
  146. package/src/deep-topology.test.ts +392 -0
  147. package/src/deep-topology.ts +237 -0
  148. package/src/effect-receipt-row.test.ts +164 -0
  149. package/src/effect-receipt-row.ts +147 -0
  150. package/src/generated/lexicon-aws.json +34 -0
  151. package/src/index.ts +36 -7
  152. package/src/lint/audit-catalog.ts +16 -0
  153. package/src/lint/post-synth/cf-refs.ts +40 -0
  154. package/src/lint/post-synth/index.ts +16 -0
  155. package/src/lint/post-synth/waw016.test.ts +61 -4
  156. package/src/lint/post-synth/waw016.ts +37 -16
  157. package/src/lint/post-synth/waw061.test.ts +99 -0
  158. package/src/lint/post-synth/waw061.ts +67 -0
  159. package/src/lint/post-synth/waw062.test.ts +109 -0
  160. package/src/lint/post-synth/waw062.ts +0 -0
  161. package/src/lint/post-synth/waw063.test.ts +220 -0
  162. package/src/lint/post-synth/waw063.ts +0 -0
  163. package/src/lint/post-synth/waw064.test.ts +79 -0
  164. package/src/lint/post-synth/waw064.ts +52 -0
  165. package/src/lint/post-synth/waw065.test.ts +90 -0
  166. package/src/lint/post-synth/waw065.ts +110 -0
  167. package/src/lint/post-synth/waw066.test.ts +108 -0
  168. package/src/lint/post-synth/waw066.ts +155 -0
  169. package/src/lint/post-synth/waw067.test.ts +79 -0
  170. package/src/lint/post-synth/waw067.ts +114 -0
  171. package/src/lint/post-synth/waw068.test.ts +74 -0
  172. package/src/lint/post-synth/waw068.ts +78 -0
  173. package/src/lsp/hover.ts +17 -2
  174. package/src/op/activities/index.ts +15 -0
  175. package/src/op/builders.test.ts +61 -0
  176. package/src/op/builders.ts +72 -0
  177. package/src/plugin.ts +41 -10
  178. package/src/receipt-store.test.ts +204 -0
  179. package/src/receipt-store.ts +315 -0
  180. package/src/serializer.ts +105 -2
  181. package/src/skills/chant-aws-carve-terraform.md +26 -23
  182. package/src/spec/parse.test.ts +74 -0
  183. package/src/spec/parse.ts +26 -6
@@ -56,10 +56,19 @@ import {
56
56
  AwsReadError,
57
57
  describeStackResources,
58
58
  getResource,
59
+ listResources,
59
60
  type AwsReadClientOptions,
60
61
  type AwsReadHttp,
62
+ type StackResource,
61
63
  } from "./api/read-client";
64
+ import { declaredIdentifier } from "./identity-observe";
62
65
  import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
66
+ import {
67
+ AWS_DEEP_BLIND_SPOTS,
68
+ EC2_TOPOLOGY_GENERATED_NAMES,
69
+ EC2_TOPOLOGY_SERVICE_DEFAULTS,
70
+ EC2_TOPOLOGY_SOURCES,
71
+ } from "./deep-topology";
63
72
  import { applyAwsEndpointArgv } from "./components/cloud-executor";
64
73
  import { toIngressRules } from "./dependencies";
65
74
  import { createRequire } from "node:module";
@@ -106,6 +115,9 @@ export const DEEP_SOURCES: Record<string, DeepSource> = {
106
115
  id: "GroupId",
107
116
  toModel: securityGroupToModel,
108
117
  },
118
+ // The EC2 topology types the folds fold over (#1269), sources and
119
+ // translations alongside their type in ./deep-topology.ts.
120
+ ...EC2_TOPOLOGY_SOURCES,
109
121
  };
110
122
 
111
123
  /**
@@ -114,6 +126,74 @@ export const DEEP_SOURCES: Record<string, DeepSource> = {
114
126
  */
115
127
  export const DEEP_READABLE_TYPES: ReadonlySet<string> = new Set(Object.keys(DEEP_SOURCES));
116
128
 
129
+ /**
130
+ * A child type this reader enumerates under one declared parent (#1015's
131
+ * out-of-band case) — the console-added SNS subscription or inline role
132
+ * policy that no property diff can see, because it is not a property of
133
+ * anything declared: it is a whole resource CloudFormation never made.
134
+ *
135
+ * Cloud Control's `ListResources` takes the parent as a `ResourceModel`
136
+ * scope, which is what `model` builds from the physical id the stack read
137
+ * already resolved. Every listed child is then sorted into declared (it is
138
+ * some stack resource's physical id, or some declared entity's spelled
139
+ * identity) or out-of-band; the out-of-band ones ride into the observation
140
+ * as live resources nobody declared, which the deep diff reports as
141
+ * undeclared entities.
142
+ */
143
+ export interface DeepChildSource {
144
+ /** The child's CloudFormation type — what `ListResources` is asked for. */
145
+ childType: string;
146
+ /** The `ResourceModel` scoping the listing to one parent. */
147
+ model: (parentPhysicalId: string) => Record<string, unknown>;
148
+ /**
149
+ * Legacy declared forms of the same live child. `AWS::IAM::Policy` attached
150
+ * to a role creates the same inline policy `AWS::IAM::RolePolicy` lists, but
151
+ * under its own physical id — the policy name, which is the second part of
152
+ * the RolePolicy identifier. Without this leg every legacy inline policy
153
+ * reads as out-of-band on a stack that declared it.
154
+ */
155
+ declaredVia?: Array<{
156
+ type: string;
157
+ /** The part of the child identifier that carries that type's physical id. */
158
+ part: (identifier: string) => string | undefined;
159
+ }>;
160
+ }
161
+
162
+ /**
163
+ * Parent type → the child types worth listing under it. Scoped like
164
+ * {@link DEEP_SOURCES}: the high-signal cases first — the issue's own
165
+ * examples — and widened per type, not by wildcard. A child type Cloud
166
+ * Control cannot list parent-scoped does not belong here.
167
+ */
168
+ export const DEEP_CHILD_SOURCES: Record<string, readonly DeepChildSource[]> = {
169
+ "AWS::IAM::Role": [
170
+ {
171
+ childType: "AWS::IAM::RolePolicy",
172
+ model: (roleName) => ({ RoleName: roleName }),
173
+ // A RolePolicy identifier is `PolicyName|RoleName` — the schema's own
174
+ // primaryIdentifier order, which is what the manifest records and what
175
+ // `declaredIdentifier` joins by. The legacy type's physical id is the
176
+ // policy name, the first part.
177
+ declaredVia: [{ type: "AWS::IAM::Policy", part: (id) => id.split("|")[0] }],
178
+ },
179
+ ],
180
+ "AWS::SNS::Topic": [
181
+ {
182
+ childType: "AWS::SNS::Subscription",
183
+ model: (topicArn) => ({ TopicArn: topicArn }),
184
+ },
185
+ ],
186
+ };
187
+
188
+ /**
189
+ * The name an out-of-band child reports under. There is no chant entity name
190
+ * to use — nobody declared it — so the name has to carry the finding on its
191
+ * own: what kind of thing, and which one.
192
+ */
193
+ export function outOfBandChildName(childType: string, identifier: string): string {
194
+ return `${childType}:${identifier}`;
195
+ }
196
+
117
197
  /**
118
198
  * `describe-security-groups` -> the `AWS::EC2::SecurityGroup` resource model.
119
199
  *
@@ -205,6 +285,7 @@ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
205
285
  "SecurityGroupEgress[].CidrIp": "0.0.0.0/0",
206
286
  "SecurityGroupEgress[].IpProtocol": "-1",
207
287
  },
288
+ ...EC2_TOPOLOGY_SERVICE_DEFAULTS,
208
289
  };
209
290
 
210
291
  /**
@@ -218,6 +299,7 @@ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
218
299
  */
219
300
  export const AWS_GENERATED_NAMES: Record<string, ReadonlySet<string>> = {
220
301
  "AWS::EC2::SecurityGroup": new Set(["GroupName"]),
302
+ ...EC2_TOPOLOGY_GENERATED_NAMES,
221
303
  };
222
304
 
223
305
  /** Stable JSON with sorted keys — the fallback ordering key for a set-like array. */
@@ -303,6 +385,18 @@ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
303
385
  if (schemaReadOnlyPatterns(node.entityType).has(node.pattern)) return true;
304
386
  if (AWS_READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true;
305
387
 
388
+ // A declared property this type's source cannot see (#1269). With nothing
389
+ // live to stand against it would report `value -> <absent>` on every clean
390
+ // apply — a blind spot rendered as drift. Counterpart-gated the other way
391
+ // round: a live tree that does carry the field is still compared.
392
+ if (
393
+ node.side === "declared" &&
394
+ node.counterpart === "absent" &&
395
+ AWS_DEEP_BLIND_SPOTS[node.entityType]?.has(node.pattern)
396
+ ) {
397
+ return true;
398
+ }
399
+
306
400
  // Provider defaults, on the live side only, and only where source is silent
307
401
  // about the property. `"unknown"` (a one-sided normalization) never prunes:
308
402
  // the reader must not decide this before the declared tree is in hand.
@@ -606,9 +700,119 @@ export async function observeResourcesDeepAws(
606
700
  };
607
701
  });
608
702
 
703
+ // The out-of-band pass (#1015): under each declared parent whose type names
704
+ // child sources, list what actually exists and keep what nothing declared.
705
+ // A parent `--owned` withheld keeps its children out too — reporting a
706
+ // foreign resource's children while withholding the resource itself would
707
+ // be the filter half-applied. A parent with no deep reader of its own
708
+ // (`unsupported-kind`) still gets its children enumerated: listing under it
709
+ // needs only the physical id the stack already resolved.
710
+ const parents: Array<{ resource: StackResource; sources: readonly DeepChildSource[] }> = [];
711
+ for (const entityName of options.entityNames) {
712
+ const resource = byLogicalId.get(entityName);
713
+ const sources = resource ? DEEP_CHILD_SOURCES[resource.type] : undefined;
714
+ if (!resource?.physicalId || !sources) continue;
715
+ if (unobserved[entityName]?.reason === "filtered") continue;
716
+ parents.push({ resource, sources });
717
+ }
718
+ const children = await enumerateOutOfBandChildren({
719
+ parents,
720
+ allStackResources: stackResources,
721
+ entities: options.entities,
722
+ client,
723
+ normalize: (childType, properties) =>
724
+ normalizeDeepProperties(properties, {
725
+ entityType: childType,
726
+ side: "live",
727
+ hooks: awsDeepNormalizationHooks,
728
+ }),
729
+ concurrently: boundedConcurrently,
730
+ });
731
+ for (const [name, child] of Object.entries(children)) {
732
+ if (!resources[name]) resources[name] = child;
733
+ }
734
+
609
735
  return deepObservation(resources, unobserved);
610
736
  }
611
737
 
738
+ /**
739
+ * List every child-sourced type under its declared parents and return the
740
+ * resources nothing declared, named by {@link outOfBandChildName}.
741
+ *
742
+ * "Declared" is answered three ways, in order of how directly the stack says
743
+ * it: the child identifier is some stack resource's physical id (the normal
744
+ * case — a declared `AWS::SNS::Subscription` deploys under its ARN); a
745
+ * `declaredVia` legacy form's physical id is the identifier's relevant part;
746
+ * or a declared entity's props spell the identifier (`declaredIdentifier`,
747
+ * the carve case — declared in source, not yet in any stack).
748
+ *
749
+ * A listing that fails is silence about that parent's children, never a
750
+ * finding and never a hole: there is no declared entity to hang a hole on,
751
+ * and fabricating one would put an undeclared name in the unobserved report.
752
+ * `UnsupportedOperation` — an emulator without parent-scoped listing — lands
753
+ * here too, which is what keeps the Floci lanes quiet.
754
+ */
755
+ async function enumerateOutOfBandChildren(args: {
756
+ parents: Array<{ resource: StackResource; sources: readonly DeepChildSource[] }>;
757
+ allStackResources: StackResource[];
758
+ entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
759
+ client: AwsReadClientOptions;
760
+ normalize: (childType: string, properties: Record<string, unknown>) => Record<string, unknown>;
761
+ concurrently: <T>(items: T[], fn: (item: T) => Promise<void>) => Promise<unknown>;
762
+ }): Promise<Record<string, DeepResourceObservation>> {
763
+ const out: Record<string, DeepResourceObservation> = {};
764
+ if (args.parents.length === 0) return out;
765
+
766
+ // Physical ids by type, over the WHOLE stack — a stack resource that is not
767
+ // a declared chant entity (a nested stack's row, a resource another tool
768
+ // added to the same stack) is still stack-managed, not out-of-band.
769
+ const addTo = (map: Map<string, Set<string>>, key: string, value: string) => {
770
+ const set = map.get(key) ?? new Set<string>();
771
+ set.add(value);
772
+ map.set(key, set);
773
+ };
774
+ const stackIdsByType = new Map<string, Set<string>>();
775
+ for (const r of args.allStackResources) {
776
+ if (r.physicalId) addTo(stackIdsByType, r.type, r.physicalId);
777
+ }
778
+ const declaredIdsByType = new Map<string, Set<string>>();
779
+ for (const entity of args.entities?.values() ?? []) {
780
+ const id = declaredIdentifier(entity.entityType, entity.props ?? {});
781
+ if (id) addTo(declaredIdsByType, entity.entityType, id);
782
+ }
783
+
784
+ const isDeclared = (source: DeepChildSource, identifier: string): boolean => {
785
+ if (stackIdsByType.get(source.childType)?.has(identifier)) return true;
786
+ if (declaredIdsByType.get(source.childType)?.has(identifier)) return true;
787
+ for (const via of source.declaredVia ?? []) {
788
+ const part = via.part(identifier);
789
+ if (part && stackIdsByType.get(via.type)?.has(part)) return true;
790
+ }
791
+ return false;
792
+ };
793
+
794
+ const listings = args.parents.flatMap(({ resource, sources }) =>
795
+ sources.map((source) => ({ physicalId: resource.physicalId as string, source })),
796
+ );
797
+ await args.concurrently(listings, async ({ physicalId, source }) => {
798
+ let listed;
799
+ try {
800
+ listed = await listResources(source.childType, args.client, source.model(physicalId));
801
+ } catch {
802
+ return;
803
+ }
804
+ for (const child of listed) {
805
+ if (!child.identifier || isDeclared(source, child.identifier)) continue;
806
+ out[outOfBandChildName(source.childType, child.identifier)] = {
807
+ type: source.childType,
808
+ physicalId: child.identifier,
809
+ properties: args.normalize(source.childType, child.properties),
810
+ };
811
+ }
812
+ });
813
+ return out;
814
+ }
815
+
612
816
  /**
613
817
  * Read every `ec2`-sourced type in bulk, keyed by physical id.
614
818
  *
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Deep-read coverage for the EC2 topology types (#1269).
3
+ *
4
+ * The folds' inputs — VPC, subnet, route table, internet gateway, and the
5
+ * joins between them — were exactly the types the deep reader could not read,
6
+ * so a snapshot-backed query could answer identity questions and not a single
7
+ * fold question. These tests drive each added type through the real reader
8
+ * against faked describes, and close with the fold-level proof: a question
9
+ * that was previously unanswerable (is this VPC the provider's default?)
10
+ * answered from the deep read alone.
11
+ *
12
+ * Same seams as deep-observe.test.ts: Cloud Control and CloudFormation are
13
+ * faked at the injected `http` transport, the EC2 describes at the runtime's
14
+ * `spawn`.
15
+ */
16
+ import { describe, test, expect, vi, beforeEach } from "vitest";
17
+
18
+ const spawnMock = vi.fn();
19
+ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
21
+ return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
22
+ });
23
+
24
+ const { observeResourcesDeepAws, DEEP_READABLE_TYPES, awsDeepNormalizationHooks } = await import("./deep-observe");
25
+ const { vpcToModel, subnetToModel, routeTableToModel, internetGatewayToModel } = await import("./deep-topology");
26
+ const { stampProviderDefaults } = await import("./defaults");
27
+ const { normalizeDeepObservation } = await import("@intentius/chant/deep-observation");
28
+ const { diffDeepObservation } = await import("@intentius/chant/lifecycle/deep-observe");
29
+
30
+ const ok = (text: string) => ({ status: 200, text });
31
+
32
+ /** A Cloud Control `GetResource` body — the model arrives as a JSON string. */
33
+ const cloudControl = (identifier: string, properties: Record<string, unknown>) =>
34
+ ok(JSON.stringify({ ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) } }));
35
+
36
+ const stackResources = (rows: Array<[string, string, string]>) =>
37
+ ok(
38
+ `<DescribeStackResourcesResponse><DescribeStackResourcesResult><StackResources>${rows
39
+ .map(
40
+ ([logicalId, type, physicalId]) =>
41
+ `<member><LogicalResourceId>${logicalId}</LogicalResourceId><ResourceType>${type}</ResourceType>` +
42
+ `<PhysicalResourceId>${physicalId}</PhysicalResourceId><ResourceStatus>CREATE_COMPLETE</ResourceStatus>` +
43
+ `<Timestamp>2026-01-01T00:00:00Z</Timestamp></member>`,
44
+ )
45
+ .join("")}</StackResources></DescribeStackResourcesResult></DescribeStackResourcesResponse>`,
46
+ );
47
+
48
+ type FakeResponse = { status: number; text: string };
49
+
50
+ /** Route on the Cloud Control identifier; `undefined` is the CloudFormation call. */
51
+ function httpFake(route: (identifier: string | undefined) => FakeResponse) {
52
+ return async (_url: string, init: { headers: Record<string, string>; body: string }) => {
53
+ const target = init.headers["x-amz-target"];
54
+ const identifier = target ? (JSON.parse(init.body) as { Identifier?: string }).Identifier : undefined;
55
+ return route(identifier);
56
+ };
57
+ }
58
+
59
+ /** Route each EC2 bulk describe on its subcommand (`argv[2]`). */
60
+ function ec2Describes(byCommand: Record<string, Record<string, unknown[]>>) {
61
+ spawnMock.mockImplementation((argv: string[]) => {
62
+ const body = byCommand[argv[2]];
63
+ if (!body) return Promise.resolve({ stdout: "", stderr: `no fake for ${argv[2]}`, exitCode: 255 });
64
+ return Promise.resolve({ stdout: JSON.stringify(body), stderr: "", exitCode: 0 });
65
+ });
66
+ }
67
+
68
+ const entities = (
69
+ record: Record<string, { entityType: string; props: Record<string, unknown> }>,
70
+ ): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
71
+
72
+ const vpcRow = {
73
+ VpcId: "vpc-01",
74
+ State: "available",
75
+ OwnerId: "111122223333",
76
+ CidrBlock: "10.0.0.0/16",
77
+ DhcpOptionsId: "dopt-01",
78
+ InstanceTenancy: "default",
79
+ IsDefault: false,
80
+ CidrBlockAssociationSet: [{ AssociationId: "vpc-cidr-assoc-01", CidrBlock: "10.0.0.0/16" }],
81
+ Tags: [{ Key: "env", Value: "prod" }],
82
+ };
83
+
84
+ // Braces matter: `mockReset()` returns the mock, and a function returned from
85
+ // a vitest hook is called as a cleanup hook — the mock invoked with no argv.
86
+ beforeEach(() => {
87
+ spawnMock.mockReset();
88
+ });
89
+
90
+ describe("the topology translations", () => {
91
+ test("a VPC row maps onto the resource model, keeping the provider's default marker", () => {
92
+ expect(vpcToModel(vpcRow)).toEqual({
93
+ VpcId: "vpc-01",
94
+ CidrBlock: "10.0.0.0/16",
95
+ InstanceTenancy: "default",
96
+ IsDefault: false,
97
+ Tags: [{ Key: "env", Value: "prod" }],
98
+ });
99
+ });
100
+
101
+ test("a subnet row keeps both fold inputs: the VPC hop and MapPublicIpOnLaunch", () => {
102
+ expect(
103
+ subnetToModel({
104
+ SubnetId: "subnet-01",
105
+ VpcId: "vpc-01",
106
+ State: "available",
107
+ OwnerId: "111122223333",
108
+ CidrBlock: "10.0.1.0/24",
109
+ AvailabilityZone: "us-east-1a",
110
+ AvailabilityZoneId: "use1-az1",
111
+ AvailableIpAddressCount: 250,
112
+ MapPublicIpOnLaunch: true,
113
+ AssignIpv6AddressOnCreation: false,
114
+ DefaultForAz: false,
115
+ Ipv6Native: false,
116
+ PrivateDnsNameOptionsOnLaunch: { HostnameType: "ip-name" },
117
+ }),
118
+ ).toEqual({
119
+ SubnetId: "subnet-01",
120
+ VpcId: "vpc-01",
121
+ CidrBlock: "10.0.1.0/24",
122
+ AvailabilityZone: "us-east-1a",
123
+ AvailabilityZoneId: "use1-az1",
124
+ MapPublicIpOnLaunch: true,
125
+ AssignIpv6AddressOnCreation: false,
126
+ Ipv6Native: false,
127
+ DefaultForAz: false,
128
+ });
129
+ });
130
+
131
+ test("a route table's routes and associations are its own declared resources, not carried twice", () => {
132
+ expect(
133
+ routeTableToModel({
134
+ RouteTableId: "rtb-01",
135
+ VpcId: "vpc-01",
136
+ Routes: [{ DestinationCidrBlock: "0.0.0.0/0", GatewayId: "igw-01" }],
137
+ Associations: [{ Main: false, SubnetId: "subnet-01" }],
138
+ PropagatingVgws: [],
139
+ }),
140
+ ).toEqual({ RouteTableId: "rtb-01", VpcId: "vpc-01" });
141
+ });
142
+
143
+ test("an internet gateway is tags and nothing else; an empty tag set is absence, not a value", () => {
144
+ expect(internetGatewayToModel({ InternetGatewayId: "igw-01", Attachments: [], Tags: [] })).toEqual({
145
+ InternetGatewayId: "igw-01",
146
+ });
147
+ });
148
+ });
149
+
150
+ describe("deep-reading an EC2 topology stack", () => {
151
+ const topologyStack = stackResources([
152
+ ["Net", "AWS::EC2::VPC", "vpc-01"],
153
+ ["PublicSubnet", "AWS::EC2::Subnet", "subnet-01"],
154
+ ["PublicRoutes", "AWS::EC2::RouteTable", "rtb-01"],
155
+ ["Igw", "AWS::EC2::InternetGateway", "igw-01"],
156
+ ]);
157
+
158
+ test("every topology type from #1269 is readable", () => {
159
+ for (const type of [
160
+ "AWS::EC2::VPC",
161
+ "AWS::EC2::Subnet",
162
+ "AWS::EC2::RouteTable",
163
+ "AWS::EC2::Route",
164
+ "AWS::EC2::SubnetRouteTableAssociation",
165
+ "AWS::EC2::InternetGateway",
166
+ "AWS::EC2::VPCGatewayAttachment",
167
+ "AWS::EC2::Instance",
168
+ "AWS::EC2::LaunchTemplate",
169
+ ]) {
170
+ expect(DEEP_READABLE_TYPES.has(type)).toBe(true);
171
+ }
172
+ });
173
+
174
+ test("one bulk describe per type, and every tree is recorded", async () => {
175
+ ec2Describes({
176
+ "describe-vpcs": { Vpcs: [vpcRow] },
177
+ "describe-subnets": {
178
+ Subnets: [{ SubnetId: "subnet-01", VpcId: "vpc-01", CidrBlock: "10.0.1.0/24", MapPublicIpOnLaunch: true }],
179
+ },
180
+ "describe-route-tables": { RouteTables: [{ RouteTableId: "rtb-01", VpcId: "vpc-01", Routes: [] }] },
181
+ "describe-internet-gateways": { InternetGateways: [{ InternetGatewayId: "igw-01", Attachments: [] }] },
182
+ });
183
+ const result = normalizeDeepObservation(
184
+ await observeResourcesDeepAws({
185
+ environment: "prod",
186
+ entityNames: ["Net", "PublicSubnet", "PublicRoutes", "Igw"],
187
+ http: httpFake(() => topologyStack),
188
+ }),
189
+ );
190
+
191
+ expect(Object.keys(result.resources).sort()).toEqual(["Igw", "Net", "PublicRoutes", "PublicSubnet"]);
192
+ expect(result.unobserved).toEqual({});
193
+
194
+ // The containment hop and the public-IP fact the folds read.
195
+ expect(result.resources.PublicSubnet.properties).toMatchObject({
196
+ VpcId: "vpc-01",
197
+ MapPublicIpOnLaunch: true,
198
+ });
199
+ // Server-minted ids are pruned by the schema's own readOnlyProperties.
200
+ expect(result.resources.Net.properties.VpcId).toBeUndefined();
201
+ expect(result.resources.PublicSubnet.properties.SubnetId).toBeUndefined();
202
+ // The provider's default marker survives into the snapshot for the fold.
203
+ expect(result.resources.Net.properties.IsDefault).toBe(false);
204
+
205
+ // Four types, four describes — never one call per resource.
206
+ expect(spawnMock).toHaveBeenCalledTimes(4);
207
+ const commands = spawnMock.mock.calls.map((c) => (c[0] as string[])[2]).sort();
208
+ expect(commands).toEqual([
209
+ "describe-internet-gateways",
210
+ "describe-route-tables",
211
+ "describe-subnets",
212
+ "describe-vpcs",
213
+ ]);
214
+ });
215
+
216
+ test("the routing-chain joins read through Cloud Control by their physical id", async () => {
217
+ const http = httpFake((identifier) => {
218
+ if (identifier === undefined) {
219
+ return stackResources([
220
+ ["DefaultRoute", "AWS::EC2::Route", "rtb-01|0.0.0.0/0"],
221
+ ["SubnetRoutes", "AWS::EC2::SubnetRouteTableAssociation", "rtbassoc-01"],
222
+ ["IgwAttachment", "AWS::EC2::VPCGatewayAttachment", "IGW|vpc-01"],
223
+ ]);
224
+ }
225
+ if (identifier === "rtb-01|0.0.0.0/0") {
226
+ return cloudControl(identifier, {
227
+ RouteTableId: "rtb-01",
228
+ DestinationCidrBlock: "0.0.0.0/0",
229
+ GatewayId: "igw-01",
230
+ });
231
+ }
232
+ if (identifier === "rtbassoc-01") {
233
+ return cloudControl(identifier, { Id: "rtbassoc-01", RouteTableId: "rtb-01", SubnetId: "subnet-01" });
234
+ }
235
+ return cloudControl(identifier, { InternetGatewayId: "igw-01", VpcId: "vpc-01" });
236
+ });
237
+ const result = normalizeDeepObservation(
238
+ await observeResourcesDeepAws({
239
+ environment: "prod",
240
+ entityNames: ["DefaultRoute", "SubnetRoutes", "IgwAttachment"],
241
+ http,
242
+ }),
243
+ );
244
+
245
+ // The `0.0.0.0/0` -> igw edge `internetFacing` walks.
246
+ expect(result.resources.DefaultRoute.properties).toEqual({
247
+ RouteTableId: "rtb-01",
248
+ DestinationCidrBlock: "0.0.0.0/0",
249
+ GatewayId: "igw-01",
250
+ });
251
+ // Subnet -> route table, with the association's own minted id pruned.
252
+ expect(result.resources.SubnetRoutes.properties).toEqual({
253
+ RouteTableId: "rtb-01",
254
+ SubnetId: "subnet-01",
255
+ });
256
+ // Igw -> VPC.
257
+ expect(result.resources.IgwAttachment.properties).toEqual({
258
+ InternetGatewayId: "igw-01",
259
+ VpcId: "vpc-01",
260
+ });
261
+ expect(spawnMock).not.toHaveBeenCalled();
262
+ });
263
+
264
+ test("a type without a source is still a stated hole, not a silent one", async () => {
265
+ const result = normalizeDeepObservation(
266
+ await observeResourcesDeepAws({
267
+ environment: "prod",
268
+ entityNames: ["Ip"],
269
+ http: httpFake(() => stackResources([["Ip", "AWS::EC2::EIP", "203.0.113.7"]])),
270
+ }),
271
+ );
272
+ expect(result.resources).toEqual({});
273
+ expect(result.unobserved.Ip.reason).toBe("unsupported-kind");
274
+ });
275
+ });
276
+
277
+ describe("the fold answers (#1269)", () => {
278
+ const vpcStack = stackResources([
279
+ ["Net", "AWS::EC2::VPC", "vpc-01"],
280
+ ["Legacy", "AWS::EC2::VPC", "vpc-02"],
281
+ ]);
282
+
283
+ test("IsDefault on a VPC — previously unanswerable — answers from the deep read", async () => {
284
+ ec2Describes({
285
+ "describe-vpcs": {
286
+ Vpcs: [vpcRow, { ...vpcRow, VpcId: "vpc-02", CidrBlock: "172.31.0.0/16", IsDefault: true, Tags: [] }],
287
+ },
288
+ });
289
+ const observed = normalizeDeepObservation(
290
+ await observeResourcesDeepAws({
291
+ environment: "prod",
292
+ entityNames: ["Net", "Legacy"],
293
+ http: httpFake(() => vpcStack),
294
+ }),
295
+ );
296
+
297
+ // The classification fold reads the provider's own marker off the
298
+ // recorded properties — nothing to re-fetch, no heuristics.
299
+ const stamped = stampProviderDefaults(
300
+ Object.fromEntries(
301
+ Object.entries(observed.resources).map(([name, r]) => [
302
+ name,
303
+ { type: r.type, status: "CREATE_COMPLETE", attributes: r.properties },
304
+ ]),
305
+ ),
306
+ );
307
+ expect(stamped.Legacy.attributes?.providerDefault).toBe(true);
308
+ expect(stamped.Net.attributes?.providerDefault).toBeUndefined();
309
+ });
310
+
311
+ test("the marker feeds the fold without surfacing as drift on a declared VPC", async () => {
312
+ ec2Describes({ "describe-vpcs": { Vpcs: [vpcRow] } });
313
+ const observed = normalizeDeepObservation(
314
+ await observeResourcesDeepAws({
315
+ environment: "prod",
316
+ entityNames: ["Net"],
317
+ http: httpFake(() => stackResources([["Net", "AWS::EC2::VPC", "vpc-01"]])),
318
+ }),
319
+ );
320
+ const declared = entities({
321
+ Net: {
322
+ entityType: "AWS::EC2::VPC",
323
+ props: { CidrBlock: "10.0.0.0/16", Tags: [{ Key: "env", Value: "prod" }] },
324
+ },
325
+ });
326
+
327
+ // In the snapshot for a query to read…
328
+ expect(observed.resources.Net.properties.IsDefault).toBe(false);
329
+ // …and subtracted in the diff, where source never declared it.
330
+ const diff = diffDeepObservation(declared, observed, awsDeepNormalizationHooks);
331
+ expect(diff.drifted).toEqual([]);
332
+ expect(diff.unchanged).toEqual(["Net"]);
333
+ });
334
+
335
+ test("declared DNS attributes are a source blind spot, not absence drift; a visible change still reports", async () => {
336
+ ec2Describes({ "describe-vpcs": { Vpcs: [{ ...vpcRow, CidrBlock: "10.9.0.0/16", Tags: [] }] } });
337
+ const observed = normalizeDeepObservation(
338
+ await observeResourcesDeepAws({
339
+ environment: "prod",
340
+ entityNames: ["Net"],
341
+ http: httpFake(() => stackResources([["Net", "AWS::EC2::VPC", "vpc-01"]])),
342
+ }),
343
+ );
344
+ const declared = entities({
345
+ Net: {
346
+ entityType: "AWS::EC2::VPC",
347
+ props: { CidrBlock: "10.0.0.0/16", EnableDnsSupport: true, EnableDnsHostnames: true },
348
+ },
349
+ });
350
+
351
+ const diff = diffDeepObservation(declared, observed, awsDeepNormalizationHooks);
352
+ expect(diff.drifted).toEqual([
353
+ {
354
+ name: "Net",
355
+ type: "AWS::EC2::VPC",
356
+ changes: [
357
+ { path: "CidrBlock", kind: "changed", declared: "10.0.0.0/16", live: "10.9.0.0/16" },
358
+ ],
359
+ },
360
+ ]);
361
+ });
362
+
363
+ test("an out-of-band MapPublicIpOnLaunch flip reports; the service default does not", async () => {
364
+ const subnetRow = (mapPublic: boolean) => ({
365
+ Subnets: [{ SubnetId: "subnet-01", VpcId: "vpc-01", CidrBlock: "10.0.1.0/24", MapPublicIpOnLaunch: mapPublic }],
366
+ });
367
+ const declared = entities({
368
+ PublicSubnet: { entityType: "AWS::EC2::Subnet", props: { VpcId: "vpc-01", CidrBlock: "10.0.1.0/24" } },
369
+ });
370
+ const observe = async () =>
371
+ normalizeDeepObservation(
372
+ await observeResourcesDeepAws({
373
+ environment: "prod",
374
+ entityNames: ["PublicSubnet"],
375
+ http: httpFake(() => stackResources([["PublicSubnet", "AWS::EC2::Subnet", "subnet-01"]])),
376
+ }),
377
+ );
378
+
379
+ ec2Describes({ "describe-subnets": subnetRow(false) });
380
+ expect(diffDeepObservation(declared, await observe(), awsDeepNormalizationHooks).drifted).toEqual([]);
381
+
382
+ ec2Describes({ "describe-subnets": subnetRow(true) });
383
+ const diff = diffDeepObservation(declared, await observe(), awsDeepNormalizationHooks);
384
+ expect(diff.drifted).toEqual([
385
+ {
386
+ name: "PublicSubnet",
387
+ type: "AWS::EC2::Subnet",
388
+ changes: [{ path: "MapPublicIpOnLaunch", kind: "undeclared", live: true }],
389
+ },
390
+ ]);
391
+ });
392
+ });