@intentius/chant-lexicon-aws 0.33.1 → 0.34.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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "82f137b9dc9816456d5230a0150bcdd48c79224e6fb237c3bf4951dfe6a7608b",
5
- "meta.json": "e991aef2b315b2172b66aaa916ac1ff93a13446ec08f169c6c219aa4850f965a",
6
- "types/index.d.ts": "fd13489a9f92b01b620576a283d21423b231e085a0783869c389be9a9c5d9a60",
4
+ "manifest.json": "3c7b5e21a39f86218b481199f51f2d045af99311e67dbae6d3f4f0a7a3569506",
5
+ "meta.json": "ffe08a1fc279b969d8760332917ddaa0fdf2eca083f861d608f2c55ee8ec147d",
6
+ "types/index.d.ts": "cd6ca943e0753ae32530aadad245b72f910990d5ee7244d5e79cf22f9ff0cf1f",
7
7
  "rules/hardcoded-region.ts": "5a0eaf7ab391231fe6cd51426ece29539cb4b36f31c8dd060956638fed55722a",
8
8
  "rules/iam-wildcard.ts": "135d7217d278fef50939e605c5da32603ba35674b8c4b5c4d66a06c77903e945",
9
9
  "rules/s3-encryption.ts": "c4f9f8aa8dc382ed98c7b04e820a600590cc75335a8db2e087cbfbfe4003d09e",
@@ -59,5 +59,5 @@
59
59
  "skills/chant-aws-eks.md": "8789255709ff004ad0a875fd5999edcdc66fc6e33d710db058d9f42703bcfdfe",
60
60
  "skills/chant-aws-carve-terraform.md": "f4c6fe1c702250665f90d82b3bdaba5ea50ae75e380a8ae321dad6eb1c39683e"
61
61
  },
62
- "composite": "7b518a1dfe34d3687de1345b606674017c87c7a687ed837870c953ec24841aae"
62
+ "composite": "b23b837556489ce43cf6d8ba4ebb13ecf700868ac0cd8698be4af926585915c3"
63
63
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aws",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "AWS",
6
6
  "intrinsics": [
package/dist/meta.json CHANGED
@@ -44553,6 +44553,67 @@
44553
44553
  "kind": "property",
44554
44554
  "lexicon": "aws"
44555
44555
  },
44556
+ "HarnessEndpoint": {
44557
+ "resourceType": "AWS::BedrockAgentCore::HarnessEndpoint",
44558
+ "kind": "resource",
44559
+ "lexicon": "aws",
44560
+ "attrs": {
44561
+ "Arn": "Arn",
44562
+ "HarnessName": "HarnessName",
44563
+ "Status": "Status",
44564
+ "LiveVersion": "LiveVersion",
44565
+ "CreatedAt": "CreatedAt",
44566
+ "UpdatedAt": "UpdatedAt"
44567
+ },
44568
+ "propertyConstraints": {
44569
+ "HarnessId": {
44570
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,39}-[a-zA-Z0-9]{10}$"
44571
+ },
44572
+ "EndpointName": {
44573
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,47}$"
44574
+ },
44575
+ "TargetVersion": {
44576
+ "pattern": "^([1-9][0-9]{0,4})$",
44577
+ "minLength": 1,
44578
+ "maxLength": 5
44579
+ },
44580
+ "LiveVersion": {
44581
+ "pattern": "^([1-9][0-9]{0,4})$",
44582
+ "minLength": 1,
44583
+ "maxLength": 5
44584
+ },
44585
+ "Description": {
44586
+ "minLength": 1,
44587
+ "maxLength": 256
44588
+ },
44589
+ "CreatedAt": {
44590
+ "format": "date-time"
44591
+ },
44592
+ "UpdatedAt": {
44593
+ "format": "date-time"
44594
+ }
44595
+ },
44596
+ "createOnly": [
44597
+ "HarnessId",
44598
+ "EndpointName"
44599
+ ],
44600
+ "writeOnly": [
44601
+ "TargetVersion"
44602
+ ],
44603
+ "primaryIdentifier": [
44604
+ "Arn"
44605
+ ],
44606
+ "tagging": {
44607
+ "taggable": true,
44608
+ "tagOnCreate": true,
44609
+ "tagUpdatable": true
44610
+ }
44611
+ },
44612
+ "HarnessEndpoint_Tag": {
44613
+ "resourceType": "AWS::BedrockAgentCore::HarnessEndpoint.Tag",
44614
+ "kind": "property",
44615
+ "lexicon": "aws"
44616
+ },
44556
44617
  "HarnessEnvironmentArtifact": {
44557
44618
  "resourceType": "AWS::BedrockAgentCore::Harness.HarnessEnvironmentArtifact",
44558
44619
  "kind": "property",
@@ -70884,7 +70945,7 @@
70884
70945
  "maxLength": 256
70885
70946
  },
70886
70947
  "FunctionName": {
70887
- "pattern": "^(arn:(aws[a-zA-Z-]*)?:lambda:)?((eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?$",
70948
+ "pattern": "^(arn:(aws[a-zA-Z-]*)?:lambda:)?((eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST(\\.PUBLISHED)?|[a-zA-Z0-9-_]+))?$",
70888
70949
  "minLength": 1,
70889
70950
  "maxLength": 140
70890
70951
  },
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAyJ,MAAM,0BAA0B,CAAC;AA0BrN;yEACyE;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AA6DnD,eAAO,MAAM,SAAS,EAAE,aAm2BvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAgL,MAAM,0BAA0B,CAAC;AA6B5O;yEACyE;AACzE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAWnD,eAAO,MAAM,SAAS,EAAE,aAm5BvB,CAAC"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A managed resource's own properties (#1279).
3
+ *
4
+ * `describe-stack-resources` returns identity and status — logical id, physical
5
+ * id, type, timestamp — and nothing about the resource itself. So the managed
6
+ * observation filled `attributes` with the *stack's* outputs instead, copied
7
+ * onto every resource in the stack. Every node in a stack came out carrying the
8
+ * same `expVpcId`/`expWebIp` keys, and no node carried its own `VpcId`.
9
+ *
10
+ * That is invisible until something asks. `search --show VpcId` printed a blank
11
+ * column for six instances, which reads as "the estate has no VPCs" rather than
12
+ * "chant never read that". An agent asked which instances were outside the
13
+ * default VPC concluded all six were, because nothing in the graph said
14
+ * otherwise.
15
+ *
16
+ * A deep read (Cloud Control) answers this properly but is a per-resource call
17
+ * and is not available on every endpoint. This is the cheap middle: one describe
18
+ * per kind for the whole observation, joined back by physical id. Stack outputs
19
+ * are kept — they were the only attributes for a long time and queries lean on
20
+ * them — but a resource's own properties win a name collision, because they are
21
+ * the resource's.
22
+ */
23
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
24
+ /**
25
+ * Stamp the region each resource was observed in (#1279).
26
+ *
27
+ * The observation is already scoped per stack and each stack declares its
28
+ * region, so the reader knows this and was throwing it away. Without it the
29
+ * only route to "which region is this in" was parsing
30
+ * `Placement.AvailabilityZone` and trimming the last character — a trick that
31
+ * happens to work for EC2 and for nothing else. Region is a dimension of the
32
+ * estate, not a substring of an availability zone.
33
+ */
34
+ export declare function stampRegion(resources: Record<string, ResourceMetadata>, region?: string): Record<string, ResourceMetadata>;
35
+ /** True when this lexicon can read the kind's own properties. */
36
+ export declare function canDescribe(kind: string): boolean;
37
+ /**
38
+ * Merge each resource's own properties into an observation, in place of nothing.
39
+ *
40
+ * Best-effort per kind, and per kind only: an endpoint that cannot answer
41
+ * `describe-subnets` still yields instance properties, and a total failure
42
+ * leaves the observation exactly as it arrived. The managed observation is
43
+ * already complete without any of this — these are additional facts about
44
+ * resources chant has already identified, so a miss costs detail, never
45
+ * correctness.
46
+ */
47
+ export declare function describeOwnProperties(resources: Record<string, ResourceMetadata>, region?: string): Promise<Record<string, ResourceMetadata>>;
48
+ //# sourceMappingURL=properties.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"properties.d.ts","sourceRoot":"","sources":["../src/properties.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAkCjE;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC3C,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAUlC;AAED,iEAAiE;AACjE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjD;AAED;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CACzC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC3C,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAgE3C"}
@@ -1 +1 @@
1
- {"version":3,"file":"reference-catalog.d.ts","sourceRoot":"","sources":["../src/reference-catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,EAAE,gBAoEjC,CAAC"}
1
+ {"version":3,"file":"reference-catalog.d.ts","sourceRoot":"","sources":["../src/reference-catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,EAAE,gBAyFjC,CAAC"}
@@ -16403,6 +16403,38 @@ export declare class Harness {
16403
16403
  readonly Version: string;
16404
16404
  }
16405
16405
 
16406
+ export declare class HarnessEndpoint {
16407
+ constructor(props: {
16408
+ /** The name of the endpoint. Must start with a letter and contain only alphanumeric characters and underscores. */
16409
+ EndpointName: string;
16410
+ /** The ID of the harness that the endpoint belongs to. */
16411
+ HarnessId: string;
16412
+ /** The Amazon Resource Name (ARN) of the endpoint. */
16413
+ Arn?: string;
16414
+ /** The timestamp when the endpoint was created. */
16415
+ CreatedAt?: string;
16416
+ /** The description of the endpoint. */
16417
+ Description?: string;
16418
+ /** The name of the harness that the endpoint belongs to. */
16419
+ HarnessName?: string;
16420
+ /** The harness version that the endpoint is currently serving. */
16421
+ LiveVersion?: string;
16422
+ Status?: HarnessEndpoint_HarnessEndpointStatus;
16423
+ /** Tags to apply to the harness endpoint resource. */
16424
+ Tags?: HarnessEndpoint_Tag[];
16425
+ /** The harness version that the endpoint points to and serves invocations from. */
16426
+ TargetVersion?: string;
16427
+ /** The timestamp when the endpoint was last updated. */
16428
+ UpdatedAt?: string;
16429
+ }, attributes?: CFResourceAttributes);
16430
+ readonly Arn: string;
16431
+ readonly CreatedAt: string;
16432
+ readonly HarnessName: string;
16433
+ readonly LiveVersion: string;
16434
+ readonly Status: HarnessEndpoint_HarnessEndpointStatus;
16435
+ readonly UpdatedAt: string;
16436
+ }
16437
+
16406
16438
  export declare class HealthCheck {
16407
16439
  constructor(props: {
16408
16440
  /** A complex type that contains information about the health check. */
@@ -57089,7 +57121,7 @@ export declare class ContainerRecipe_EbsInstanceBlockDeviceSpecification {
57089
57121
  /** Use to override the device's volume size. */
57090
57122
  VolumeSize?: number;
57091
57123
  /** Use to override the device's volume type. */
57092
- VolumeType?: "gp2" | "gp3" | "io1" | "io2" | "sc1" | "st1" | "standard";
57124
+ VolumeType?: "gp2" | "gp3" | "gp3a" | "io1" | "io2" | "io2a" | "sc1" | "st1" | "standard";
57093
57125
  });
57094
57126
  }
57095
57127
 
@@ -75141,7 +75173,7 @@ export declare class GlobalTable_ReplicaSSESpecification {
75141
75173
 
75142
75174
  export declare class GlobalTable_ReplicaStreamSpecification {
75143
75175
  constructor(props: {
75144
- ResourcePolicy?: GlobalTable_ResourcePolicy;
75176
+ ResourcePolicy: GlobalTable_ResourcePolicy;
75145
75177
  });
75146
75178
  }
75147
75179
 
@@ -76693,6 +76725,13 @@ export declare class HarnessBedrockModelConfig {
76693
76725
  });
76694
76726
  }
76695
76727
 
76728
+ export declare class HarnessEndpoint_Tag {
76729
+ constructor(props: {
76730
+ Key: string;
76731
+ Value: string;
76732
+ });
76733
+ }
76734
+
76696
76735
  export declare class HarnessEnvironmentArtifact {
76697
76736
  constructor(props: {
76698
76737
  ContainerConfiguration?: Harness_ContainerConfiguration;
@@ -78724,7 +78763,7 @@ export declare class ImageRecipe_EbsInstanceBlockDeviceSpecification {
78724
78763
  /** Use to override the device's volume size. */
78725
78764
  VolumeSize?: number;
78726
78765
  /** Use to override the device's volume type. */
78727
- VolumeType?: "gp2" | "gp3" | "io1" | "io2" | "sc1" | "st1" | "standard";
78766
+ VolumeType?: "gp2" | "gp3" | "gp3a" | "io1" | "io2" | "io2a" | "sc1" | "st1" | "standard";
78728
78767
  });
78729
78768
  }
78730
78769
 
@@ -86435,7 +86474,9 @@ export declare class Listener_RedirectConfig {
86435
86474
 
86436
86475
  export declare class Listener_Tag {
86437
86476
  constructor(props: {
86477
+ /** The key of the tag. */
86438
86478
  Key: string;
86479
+ /** The value of the tag. */
86439
86480
  Value: string;
86440
86481
  });
86441
86482
  }
@@ -106283,7 +106324,7 @@ export declare class ReplicaSSESpecification {
106283
106324
 
106284
106325
  export declare class ReplicaStreamSpecification {
106285
106326
  constructor(props: {
106286
- ResourcePolicy?: GlobalTable_ResourcePolicy;
106327
+ ResourcePolicy: GlobalTable_ResourcePolicy;
106287
106328
  });
106288
106329
  }
106289
106330
 
@@ -112163,6 +112204,8 @@ export declare class Scraper_Destination {
112163
112204
  constructor(props: {
112164
112205
  /** Configuration for Amazon Managed Prometheus metrics destination */
112165
112206
  AmpConfiguration?: Record<string, unknown>;
112207
+ /** Configuration for CloudWatch metrics destination */
112208
+ CloudWatchConfiguration?: Record<string, unknown>;
112166
112209
  });
112167
112210
  }
112168
112211
 
@@ -130350,6 +130393,15 @@ export type Harness_HarnessStatus =
130350
130393
  | "UPDATE_FAILED"
130351
130394
  | "UPDATING";
130352
130395
 
130396
+ export type HarnessEndpoint_HarnessEndpointStatus =
130397
+ | "CREATE_FAILED"
130398
+ | "CREATING"
130399
+ | "DELETE_FAILED"
130400
+ | "DELETING"
130401
+ | "READY"
130402
+ | "UPDATE_FAILED"
130403
+ | "UPDATING";
130404
+
130353
130405
  export type HealthImagingDatastore_DatastoreStatus =
130354
130406
  | "ACTIVE"
130355
130407
  | "CREATE_FAILED"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-aws",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -80,7 +80,7 @@
80
80
  "typescript": "^5.9.3"
81
81
  },
82
82
  "peerDependencies": {
83
- "@intentius/chant": "^0.33.1",
83
+ "@intentius/chant": "^0.34.0",
84
84
  "typescript": "^5.9.3"
85
85
  }
86
86
  }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ const spawnMock = vi.fn();
4
+ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
6
+ return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
7
+ });
8
+
9
+ const { observeAwsAmbient, canEnumerate } = await import("./ambient");
10
+
11
+ const ok = (body: unknown) => ({ stdout: JSON.stringify(body), stderr: "", exitCode: 0 });
12
+ const GROUPS = {
13
+ SecurityGroups: [
14
+ { GroupId: "sg-default", GroupName: "default", VpcId: "vpc-1" },
15
+ { GroupId: "sg-mine", GroupName: "web", VpcId: "vpc-1" },
16
+ { GroupId: "sg-stray", GroupName: "leftover", VpcId: "vpc-1" },
17
+ ],
18
+ };
19
+
20
+ // #1278 — a resource nothing points at is invisible to both other observation
21
+ // paths, because both resolve outward from what is declared. "Which of my
22
+ // security groups are unused" is a question about exactly those.
23
+ describe("observeAwsAmbient (#1278)", () => {
24
+ beforeEach(() => spawnMock.mockReset());
25
+
26
+ it("reports groups that exist but are not managed", async () => {
27
+ spawnMock.mockResolvedValue(ok(GROUPS));
28
+ const found = await observeAwsAmbient({
29
+ kinds: ["AWS::EC2::SecurityGroup"],
30
+ observed: { web: { type: "AWS::EC2::SecurityGroup", status: "OK", physicalId: "sg-mine" } },
31
+ });
32
+ // sg-mine is managed, so it is not ambient — it would otherwise be counted twice.
33
+ expect(Object.keys(found).sort()).toEqual(["sg-default", "sg-stray"]);
34
+ expect(found["sg-default"]).toMatchObject({ ambient: true, ownership: "foreign" });
35
+ });
36
+
37
+ it("carries the whole payload, leaving 'unused' to the graph", async () => {
38
+ // Deciding attachment in the reader would put a conclusion in the
39
+ // observation — the mistake liveInternetFacing made and #1271 undid.
40
+ spawnMock.mockResolvedValue(ok(GROUPS));
41
+ const found = await observeAwsAmbient({ kinds: ["AWS::EC2::SecurityGroup"], observed: {} });
42
+ expect(found["sg-default"].attributes).toMatchObject({ GroupName: "default", VpcId: "vpc-1" });
43
+ });
44
+
45
+ it("is bounded by the kinds the project declares", async () => {
46
+ // A project managing security groups is not made to enumerate the account.
47
+ const found = await observeAwsAmbient({ kinds: ["AWS::S3::Bucket"], observed: {} });
48
+ expect(found).toEqual({});
49
+ expect(spawnMock).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("targets the stack's region", async () => {
53
+ spawnMock.mockResolvedValue(ok(GROUPS));
54
+ await observeAwsAmbient({ kinds: ["AWS::EC2::SecurityGroup"], observed: {}, region: "us-west-2" });
55
+ const argv = spawnMock.mock.calls[0][0] as string[];
56
+ expect(argv).toContain("--region");
57
+ expect(argv).toContain("us-west-2");
58
+ });
59
+
60
+ it("a failed enumeration yields nothing rather than throwing", async () => {
61
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "denied", exitCode: 255 });
62
+ await expect(
63
+ observeAwsAmbient({ kinds: ["AWS::EC2::SecurityGroup"], observed: {} }),
64
+ ).resolves.toEqual({});
65
+ });
66
+
67
+ it("declares which kinds it can enumerate", () => {
68
+ expect(canEnumerate("AWS::EC2::SecurityGroup")).toBe(true);
69
+ expect(canEnumerate("AWS::EC2::Instance")).toBe(false);
70
+ });
71
+ });
package/src/ambient.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Resources that are simply *there* (#1278).
3
+ *
4
+ * `describeResources` answers "what do I manage" and `observeDependencies`
5
+ * answers "what do I rely on". Both resolve outward from the declared estate,
6
+ * so neither can see a resource nothing points at — and "which of my security
7
+ * groups are unused" is precisely a question about resources nothing points at.
8
+ *
9
+ * A state file cannot answer this at all: it knows what it created, and an
10
+ * unused resource is by definition not something it created and attached. A
11
+ * lexicon that can enumerate a kind can.
12
+ *
13
+ * Bounded by the kinds the project declares. A project managing security groups
14
+ * is asked about security groups, not about the account.
15
+ */
16
+
17
+ import { applyAwsEndpointArgv } from "./components/cloud-executor";
18
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
19
+
20
+ /** Enumerable kinds, and how to list them. Extending this is the way to widen. */
21
+ const ENUMERABLE: Record<string, { argv: string[]; key: string; id: string; name?: string }> = {
22
+ "AWS::EC2::SecurityGroup": {
23
+ argv: ["ec2", "describe-security-groups"],
24
+ key: "SecurityGroups",
25
+ id: "GroupId",
26
+ name: "GroupName",
27
+ },
28
+ // The account's default VPC is the archetype: nothing declares it, nothing
29
+ // in the declared estate points at it, and "which instances are outside a
30
+ // default VPC" cannot be answered without it.
31
+ "AWS::EC2::VPC": {
32
+ argv: ["ec2", "describe-vpcs"],
33
+ key: "Vpcs",
34
+ id: "VpcId",
35
+ },
36
+ // Nobody writes a network interface: EC2 creates one per instance. But the
37
+ // ENI is where a security group is actually attached, so "which of my
38
+ // security groups are unused" is an ENI question and cannot be answered
39
+ // without them. Asked exactly that, an agent queried
40
+ // `kind:EC2::NetworkInterface`, got nothing, and rebuilt the attachment map
41
+ // by hand from twenty-nine raw provider calls.
42
+ "AWS::EC2::NetworkInterface": {
43
+ argv: ["ec2", "describe-network-interfaces"],
44
+ key: "NetworkInterfaces",
45
+ id: "NetworkInterfaceId",
46
+ },
47
+ };
48
+
49
+ /**
50
+ * Kinds a declared kind implies, though nobody writes them.
51
+ *
52
+ * The scan is bounded by what the project declares — a project managing
53
+ * security groups is asked about security groups, not about the account. That
54
+ * bound is right, and it excluded exactly the resource that answers the
55
+ * question: EC2 creates a network interface per instance, and the ENI is where
56
+ * a security group is actually attached. So an estate full of instances had no
57
+ * ENIs recorded, and "which of my security groups are unused" — definitionally
58
+ * an ENI question — could not be answered from the snapshot at all.
59
+ *
60
+ * Declaring an instance is declaring its network interface. This widens the
61
+ * bound by implication rather than abandoning it: still nothing about the
62
+ * account at large.
63
+ */
64
+ const IMPLIED: Record<string, string[]> = {
65
+ "AWS::EC2::Instance": ["AWS::EC2::NetworkInterface"],
66
+ };
67
+
68
+ /** The requested kinds, plus the ones they imply. */
69
+ export function withImplied(kinds: string[]): string[] {
70
+ return [...new Set(kinds.flatMap((k) => [k, ...(IMPLIED[k] ?? [])]))];
71
+ }
72
+
73
+ /** The kinds this reader can enumerate. Declared so a caller can mention
74
+ * `--ambient` without paying for a scan to discover it is relevant. */
75
+ export const AMBIENT_KINDS: string[] = Object.keys(ENUMERABLE);
76
+
77
+ /** True when this lexicon can enumerate the kind, so a caller can say what it skipped. */
78
+ export function canEnumerate(kind: string): boolean {
79
+ return kind in ENUMERABLE;
80
+ }
81
+
82
+ /**
83
+ * List resources of the declared kinds that exist in the account but are
84
+ * neither managed nor already observed.
85
+ *
86
+ * Attachment is deliberately NOT decided here. Whether a security group is
87
+ * "unused" is a graph question — does anything reference it — and the graph
88
+ * answers it once the group is a node. Deciding it in the reader would put a
89
+ * conclusion in the observation, which is the mistake `liveInternetFacing` made
90
+ * and #1271 undid.
91
+ */
92
+ export async function observeAwsAmbient(options: {
93
+ kinds: string[];
94
+ observed: Record<string, ResourceMetadata>;
95
+ region?: string;
96
+ }): Promise<Record<string, ResourceMetadata>> {
97
+ const kinds = withImplied(options.kinds).filter(canEnumerate);
98
+ if (kinds.length === 0) return {};
99
+
100
+ const { getRuntime } = await import("@intentius/chant/runtime-adapter");
101
+ const rt = getRuntime();
102
+ const regionArgs = options.region ? ["--region", options.region] : [];
103
+
104
+ // Physical ids already accounted for, so a managed resource is never also
105
+ // reported as ambient.
106
+ const known = new Set(
107
+ Object.values(options.observed)
108
+ .map((m) => m.physicalId)
109
+ .filter((id): id is string => typeof id === "string"),
110
+ );
111
+
112
+ const ambient: Record<string, ResourceMetadata> = {};
113
+ for (const kind of kinds) {
114
+ const spec = ENUMERABLE[kind];
115
+ try {
116
+ const result = await rt.spawn(
117
+ applyAwsEndpointArgv(["aws", ...spec.argv, ...regionArgs, "--output", "json"], process.env.AWS_ENDPOINT_URL),
118
+ );
119
+ if (result.exitCode !== 0) continue;
120
+ const rows = (JSON.parse(result.stdout)[spec.key] ?? []) as Array<Record<string, unknown>>;
121
+ for (const row of rows) {
122
+ const id = row[spec.id];
123
+ if (typeof id !== "string" || known.has(id)) continue;
124
+ ambient[id] = {
125
+ type: kind,
126
+ status: "OBSERVED",
127
+ physicalId: id,
128
+ // The whole payload, so the graph can decide what "unused" means
129
+ // rather than the reader pre-deciding it.
130
+ attributes: row,
131
+ ownership: "foreign",
132
+ ambient: true,
133
+ };
134
+ }
135
+ } catch {
136
+ // Best-effort per kind: one unreadable kind does not sink the others, and
137
+ // the managed observation is already complete without any of this.
138
+ continue;
139
+ }
140
+ }
141
+ // Same stamps the managed path applies, so one query shape works across both
142
+ // (#1279). Defaults matter most here: a default VPC or security group is
143
+ // ambient by definition — nobody declared it.
144
+ const { stampRegion } = await import("./properties");
145
+ const { stampProviderDefaults } = await import("./defaults");
146
+ return stampProviderDefaults(stampRegion(ambient, options.region));
147
+ }
@@ -246,6 +246,11 @@ export interface AwsDeepObserveOptions {
246
246
  entityNames: string[];
247
247
  entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
248
248
  stack?: string;
249
+ /** Region this stack is deployed in (#1267). Same reason the thin path takes
250
+ * one (#1261): without it a multi-region estate reads every stack against the
251
+ * ambient region, the out-of-region ones come back empty, and a deep snapshot
252
+ * silently records no properties for them. */
253
+ region?: string;
249
254
  owned?: boolean;
250
255
  }
251
256
 
@@ -270,9 +275,12 @@ export async function observeResourcesDeepAws(
270
275
  const stackName = options.stack ?? options.environment;
271
276
  const endpoint = process.env.AWS_ENDPOINT_URL;
272
277
 
278
+ const regionArgs = options.region ? ["--region", options.region] : [];
279
+
273
280
  const listResult = await rt.spawn(applyAwsEndpointArgv([
274
281
  "aws", "cloudformation", "describe-stack-resources",
275
282
  "--stack-name", stackName,
283
+ ...regionArgs,
276
284
  "--output", "json",
277
285
  ], endpoint));
278
286
 
@@ -334,6 +342,7 @@ export async function observeResourcesDeepAws(
334
342
  "aws", "cloudcontrol", "get-resource",
335
343
  "--type-name", type,
336
344
  "--identifier", identifier,
345
+ ...regionArgs,
337
346
  "--output", "json",
338
347
  ], endpoint));
339
348
 
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stampProviderDefaults, canDetectDefault, DEFAULT_AWARE_KINDS } from "./defaults";
3
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
4
+
5
+ const res = (type: string, attributes: Record<string, unknown>): ResourceMetadata => ({
6
+ type,
7
+ status: "OBSERVED",
8
+ physicalId: "x",
9
+ attributes,
10
+ });
11
+
12
+ const flag = (type: string, attributes: Record<string, unknown>) =>
13
+ stampProviderDefaults({ r: res(type, attributes) }).r.attributes?.providerDefault;
14
+
15
+ // Every account arrives with resources nobody wrote. Asked which security
16
+ // groups were unused, agents split three ways on the same correct set of four,
17
+ // because chant gave them no way to tell a VPC's default group from one someone
18
+ // created.
19
+ describe("provider defaults (#1278)", () => {
20
+ it("reads the marker AWS already sets, per kind", () => {
21
+ expect(flag("AWS::EC2::VPC", { IsDefault: true })).toBe(true);
22
+ expect(flag("AWS::EC2::Subnet", { DefaultForAz: true })).toBe(true);
23
+ expect(flag("AWS::EC2::NetworkAcl", { IsDefault: true })).toBe(true);
24
+ expect(flag("AWS::KMS::Key", { KeyManager: "AWS" })).toBe(true);
25
+ expect(flag("AWS::IAM::ManagedPolicy", { Arn: "arn:aws:iam::aws:policy/ReadOnlyAccess" })).toBe(true);
26
+ });
27
+
28
+ it("does not mark what the account holder created", () => {
29
+ expect(flag("AWS::EC2::VPC", { IsDefault: false })).toBeUndefined();
30
+ expect(flag("AWS::EC2::Subnet", { DefaultForAz: false })).toBeUndefined();
31
+ expect(flag("AWS::KMS::Key", { KeyManager: "CUSTOMER" })).toBeUndefined();
32
+ expect(flag("AWS::IAM::ManagedPolicy", { Arn: "arn:aws:iam::000000000000:policy/Mine" })).toBeUndefined();
33
+ });
34
+
35
+ it("treats the reserved group name as the marker it is", () => {
36
+ // AWS refuses `default` on create, so a group carrying it is the one AWS
37
+ // made with the VPC.
38
+ expect(flag("AWS::EC2::SecurityGroup", { GroupName: "default" })).toBe(true);
39
+ expect(flag("AWS::EC2::SecurityGroup", { GroupName: "default-web" })).toBeUndefined();
40
+ expect(flag("AWS::EC2::SecurityGroup", { GroupName: "webSecurityGroup" })).toBeUndefined();
41
+ });
42
+
43
+ it("finds the main route table through its associations", () => {
44
+ expect(flag("AWS::EC2::RouteTable", { Associations: [{ Main: true }] })).toBe(true);
45
+ expect(flag("AWS::EC2::RouteTable", { Associations: [{ Main: false }, { Main: true }] })).toBe(true);
46
+ expect(flag("AWS::EC2::RouteTable", { Associations: [{ Main: false }] })).toBeUndefined();
47
+ expect(flag("AWS::EC2::RouteTable", {})).toBeUndefined();
48
+ });
49
+
50
+ it("leaves a kind it cannot judge unmarked rather than guessing", () => {
51
+ // Absent means "not a default, or chant cannot tell". A blanket false would
52
+ // hide the difference.
53
+ expect(flag("AWS::EC2::Instance", { InstanceId: "i-1" })).toBeUndefined();
54
+ expect(canDetectDefault("AWS::EC2::Instance")).toBe(false);
55
+ expect(canDetectDefault("AWS::EC2::VPC")).toBe(true);
56
+ });
57
+
58
+ it("keeps every attribute it was given", () => {
59
+ const out = stampProviderDefaults({
60
+ r: res("AWS::EC2::SecurityGroup", { GroupName: "default", GroupId: "sg-1", region: "us-east-1" }),
61
+ }).r.attributes;
62
+ expect(out).toMatchObject({ GroupId: "sg-1", region: "us-east-1", providerDefault: true });
63
+ });
64
+
65
+ it("survives a payload it cannot read", () => {
66
+ expect(flag("AWS::EC2::RouteTable", { Associations: "not-a-list" })).toBeUndefined();
67
+ });
68
+
69
+ it("reports the kinds it can judge, so a caller need not guess", () => {
70
+ expect(DEFAULT_AWARE_KINDS).toContain("AWS::EC2::SecurityGroup");
71
+ expect(DEFAULT_AWARE_KINDS.every(canDetectDefault)).toBe(true);
72
+ });
73
+ });