@intentius/chant-lexicon-aws 0.29.0 → 0.31.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.
- package/dist/deep-observe.d.ts +101 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/meta.json +6 -0
- package/dist/plugin.d.ts +3 -4
- package/dist/plugin.d.ts.map +1 -1
- package/dist/stack-errors.d.ts +13 -0
- package/dist/stack-errors.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/deep-observe.test.ts +484 -0
- package/src/deep-observe.ts +384 -0
- package/src/generated/lexicon-aws.json +6 -0
- package/src/index.ts +12 -0
- package/src/plugin.ts +31 -7
- package/src/stack-errors.ts +15 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS deep observation (#1015) — the reference implementation of the epic's
|
|
3
|
+
* deep-observe contract (#1014).
|
|
4
|
+
*
|
|
5
|
+
* `describeResources` reads `cloudformation describe-stack-resources`, which
|
|
6
|
+
* returns a status, a physical id and a timestamp per resource. That is
|
|
7
|
+
* CloudFormation's view of the world, and CloudFormation only compares
|
|
8
|
+
* properties it was told about. A property somebody edited in the console — an
|
|
9
|
+
* inline policy, a bucket setting, a security-group rule — is invisible to it.
|
|
10
|
+
* That gap is why go-to-k/cdk-real-drift exists, and it is what this reader
|
|
11
|
+
* closes: the live resource model comes from the **Cloud Control API**, which
|
|
12
|
+
* bypasses CloudFormation entirely and returns the resource as the service
|
|
13
|
+
* actually holds it.
|
|
14
|
+
*
|
|
15
|
+
* Correlation is unchanged: Cloud Control is addressed by the physical id that
|
|
16
|
+
* `describe-stack-resources` already reports per logical id, so the results
|
|
17
|
+
* line up with the same IR node ids `live-attrs.ts` relies on.
|
|
18
|
+
*
|
|
19
|
+
* ## Scope of the first cut
|
|
20
|
+
*
|
|
21
|
+
* Four high-signal types (S3 buckets, IAM roles and managed policies, EC2
|
|
22
|
+
* security groups) rather than all 30+. The point of the first row is to prove
|
|
23
|
+
* the contract and the noise rules; widening the type table is additive and
|
|
24
|
+
* needs no contract change. A declared resource of any other type reports
|
|
25
|
+
* NOT-OBSERVED with `unsupported-kind` — it may well exist, and saying nothing
|
|
26
|
+
* about it is the only honest answer.
|
|
27
|
+
*
|
|
28
|
+
* ## Nothing here talks to real AWS on its own terms
|
|
29
|
+
*
|
|
30
|
+
* Every call goes through the runtime adapter's `spawn` and
|
|
31
|
+
* `applyAwsEndpointArgv`, so `AWS_ENDPOINT_URL` redirects the whole reader at a
|
|
32
|
+
* local emulator exactly as the existing describe path does.
|
|
33
|
+
*/
|
|
34
|
+
import type { DeepNormalizationHooks, DeepObservationResult } from "@intentius/chant/lexicon";
|
|
35
|
+
/**
|
|
36
|
+
* CloudFormation types this reader can read live. Each is addressable in Cloud
|
|
37
|
+
* Control by the physical id CloudFormation already reports.
|
|
38
|
+
*/
|
|
39
|
+
export declare const DEEP_READABLE_TYPES: ReadonlySet<string>;
|
|
40
|
+
/**
|
|
41
|
+
* Property names that are server-populated wherever they appear — identifiers
|
|
42
|
+
* the service mints, timestamps it stamps, counters it maintains. Matched on
|
|
43
|
+
* the final path segment, because AWS repeats these names at every nesting
|
|
44
|
+
* depth and a per-type list of full paths would be a maintenance trap.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately excludes ambiguous names like `Id` and `Name`: `VpcId` and
|
|
47
|
+
* `BucketName` are declared inputs, and pruning a declared input is how a
|
|
48
|
+
* normalization pass starts hiding real drift.
|
|
49
|
+
*/
|
|
50
|
+
export declare const AWS_READ_ONLY_NAMES: ReadonlySet<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Service defaults, per type, as index-erased property paths. A live value
|
|
53
|
+
* equal to its default is subtracted **only when source never declared that
|
|
54
|
+
* property** — cdk-real-drift's default subtraction, and the reason
|
|
55
|
+
* {@link DeepNode.counterpart} exists. Declaring the default explicitly keeps
|
|
56
|
+
* the property in the diff, so a later change to it still reports.
|
|
57
|
+
*/
|
|
58
|
+
export declare const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>>;
|
|
59
|
+
/**
|
|
60
|
+
* The aws lexicon's noise rules. The three classes the epic names for AWS —
|
|
61
|
+
* server-populated fields, unstable ordering (tags, policy statements), and
|
|
62
|
+
* provider defaults — plus nothing else: a rule that is not one of those is a
|
|
63
|
+
* rule that hides drift.
|
|
64
|
+
*/
|
|
65
|
+
export declare const awsDeepNormalizationHooks: DeepNormalizationHooks;
|
|
66
|
+
/** One live resource as `cloudcontrol get-resource` returns it. Exported for tests. */
|
|
67
|
+
export interface CloudControlResource {
|
|
68
|
+
identifier: string;
|
|
69
|
+
properties: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse a `cloudcontrol get-resource` payload. Cloud Control returns the model
|
|
73
|
+
* as a JSON *string* inside the envelope, so this unwraps twice. Returns null
|
|
74
|
+
* for anything that does not parse to an object — an unparseable body is a
|
|
75
|
+
* failed read, not an empty resource.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseCloudControlResource(stdout: string): CloudControlResource | null;
|
|
78
|
+
/** True when the live property tree carries chant's ownership marker tag. */
|
|
79
|
+
export declare function hasOwnershipMarker(properties: Record<string, unknown>): boolean;
|
|
80
|
+
export interface AwsDeepObserveOptions {
|
|
81
|
+
environment: string;
|
|
82
|
+
entityNames: string[];
|
|
83
|
+
entities?: Map<string, {
|
|
84
|
+
entityType: string;
|
|
85
|
+
props: Record<string, unknown>;
|
|
86
|
+
}>;
|
|
87
|
+
stack?: string;
|
|
88
|
+
owned?: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Read the live property tree for each declared entity via Cloud Control.
|
|
92
|
+
*
|
|
93
|
+
* Two reads per run plus one per readable resource: `describe-stack-resources`
|
|
94
|
+
* resolves logical id → (type, physical id), then `cloudcontrol get-resource`
|
|
95
|
+
* fetches each model. The first read's failure modes are the thin path's,
|
|
96
|
+
* verbatim — a stack that does not exist yet is a real absence (nothing is
|
|
97
|
+
* deployed, so there are no properties to drift), anything else is a hole for
|
|
98
|
+
* every declared entity.
|
|
99
|
+
*/
|
|
100
|
+
export declare function observeResourcesDeepAws(options: AwsDeepObserveOptions): Promise<DeepObservationResult>;
|
|
101
|
+
//# sourceMappingURL=deep-observe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deep-observe.d.ts","sourceRoot":"","sources":["../src/deep-observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAGV,sBAAsB,EACtB,qBAAqB,EAItB,MAAM,0BAA0B,CAAC;AAKlC;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAKlD,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAkBlD,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgBxE,CAAC;AAkBF;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,EAAE,sBAmDvC,CAAC;AAMF,uFAAuF;AACvF,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,oBAAoB,GAAG,IAAI,CAuBrF;AASD,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAI/E;AAED,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,qBAAqB,CAAC,CAwHhC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type { StackOutput } from "@intentius/chant/stack-output";
|
|
|
13
13
|
export { output, isLexiconOutput } from "@intentius/chant/lexicon-output";
|
|
14
14
|
export type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
15
15
|
export { awsPlugin } from "./plugin.js";
|
|
16
|
+
export { observeResourcesDeepAws, awsDeepNormalizationHooks, parseCloudControlResource, hasOwnershipMarker, DEEP_READABLE_TYPES, AWS_READ_ONLY_NAMES, AWS_SERVICE_DEFAULTS, } from "./deep-observe.js";
|
|
16
17
|
export { Sub, Ref, GetAtt, If, Join, Select, Split, Base64, GetAZs, SubIntrinsic, RefIntrinsic, GetAttIntrinsic, IfIntrinsic, JoinIntrinsic, SelectIntrinsic, SplitIntrinsic, Base64Intrinsic, GetAZsIntrinsic, } from "./intrinsics.js";
|
|
17
18
|
export { AWS, StackName, Region, AccountId, StackId, URLSuffix, NoValue, NotificationARNs, Partition, } from "./pseudo.js";
|
|
18
19
|
export * from "./generated/index.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAGxC,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACjF,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AACzG,YAAY,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAG9D,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG7C,OAAO,EAAE,WAAW,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACvI,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAG9E,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AACtF,YAAY,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAChG,YAAY,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAC1E,YAAY,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAGrE,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAGxC,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACjF,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AACzG,YAAY,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAG9D,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG7C,OAAO,EAAE,WAAW,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACvI,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAG9E,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AACtF,YAAY,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAChG,YAAY,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAC1E,YAAY,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAGrE,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAIrC,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,yBAAyB,EACzB,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EACL,GAAG,EACH,GAAG,EACH,MAAM,EACN,EAAE,EACF,IAAI,EACJ,MAAM,EACN,KAAK,EACL,MAAM,EACN,MAAM,EACN,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,WAAW,EACX,aAAa,EACb,eAAe,EACf,cAAc,EACd,eAAe,EACf,eAAe,GAChB,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,GAAG,EACH,SAAS,EACT,MAAM,EACN,SAAS,EACT,OAAO,EACP,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,SAAS,GACV,MAAM,UAAU,CAAC;AAIlB,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,YAAY,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChF,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5E,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,cAAc,EAAE,eAAe,EAAE,kBAAkB,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAG5J,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAGrJ,OAAO,EACL,cAAc,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAClE,SAAS,EACT,eAAe,EAAE,eAAe,EAChC,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAE,QAAQ,EAAE,SAAS,EACjE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,cAAc,EAAE,WAAW,EAAE,WAAW,EAC3E,kBAAkB,EAClB,eAAe,EAAE,UAAU,EAC3B,cAAc,GACf,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EACzD,cAAc,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,aAAa,EAAE,cAAc,EAC1F,eAAe,EAAE,eAAe,EAAE,cAAc,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,gBAAgB,EACzG,uBAAuB,EACvB,oBAAoB,EAAE,eAAe,EACrC,mBAAmB,EAAE,oBAAoB,GAC1C,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAKvE,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
5
|
-
"meta.json": "
|
|
4
|
+
"manifest.json": "cf84a41274c51c53a2433ad0b20af2dc274b62432ee74de735eb0334df6030e7",
|
|
5
|
+
"meta.json": "0f5a9d9dc16e57fc2fcc56b7c6e9a50d2f75a3d9c76c5934a4024df0be86f3dc",
|
|
6
6
|
"types/index.d.ts": "da899ff303a18a7fdb270570bce784703283d4081f806e69ac8c9dfd4bac7c50",
|
|
7
7
|
"rules/hardcoded-region.ts": "5a0eaf7ab391231fe6cd51426ece29539cb4b36f31c8dd060956638fed55722a",
|
|
8
8
|
"rules/iam-wildcard.ts": "135d7217d278fef50939e605c5da32603ba35674b8c4b5c4d66a06c77903e945",
|
|
@@ -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": "
|
|
62
|
+
"composite": "5a065810934f3fec09337e1d85137ad4317c44638978a32862baf80b499d48bd"
|
|
63
63
|
}
|
package/dist/manifest.json
CHANGED
package/dist/meta.json
CHANGED
|
@@ -63656,6 +63656,12 @@
|
|
|
63656
63656
|
"pattern": "[0-9]*.[0-9]*.[0-9]*",
|
|
63657
63657
|
"minLength": 0,
|
|
63658
63658
|
"maxLength": 16
|
|
63659
|
+
},
|
|
63660
|
+
"CreationTime": {
|
|
63661
|
+
"format": "date-time"
|
|
63662
|
+
},
|
|
63663
|
+
"LastModifiedTime": {
|
|
63664
|
+
"format": "date-time"
|
|
63659
63665
|
}
|
|
63660
63666
|
},
|
|
63661
63667
|
"createOnly": [
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { LexiconPlugin } from "@intentius/chant/lexicon";
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
export declare function stackDoesNotExist(stderr: string): boolean;
|
|
2
|
+
/** Re-exported from ./stack-errors so the long-standing import path (and its
|
|
3
|
+
* tests) keep working now that the deep reader shares the classifier. */
|
|
4
|
+
export { stackDoesNotExist } from "./stack-errors.js";
|
|
6
5
|
/**
|
|
7
6
|
* AWS CloudFormation lexicon plugin.
|
|
8
7
|
*
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,
|
|
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;AAEnD;;;;;GAKG;AACH,eAAO,MAAM,SAAS,EAAE,aAk0BvB,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudFormation CLI error classification, shared by every live read path.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module (rather than in ./plugin.ts, where it started) so the
|
|
5
|
+
* deep reader (#1015) can classify the same failure the same way without
|
|
6
|
+
* importing the plugin that imports it. ./plugin.ts re-exports it, so the
|
|
7
|
+
* original import path is unchanged.
|
|
8
|
+
*/
|
|
9
|
+
/** True when a CloudFormation CLI error means the stack simply isn't there yet
|
|
10
|
+
* (`ValidationError … does not exist`) — the pre-first-apply state, which live
|
|
11
|
+
* queries should treat as "nothing deployed", not a failure. */
|
|
12
|
+
export declare function stackDoesNotExist(stderr: string): boolean;
|
|
13
|
+
//# sourceMappingURL=stack-errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stack-errors.d.ts","sourceRoot":"","sources":["../src/stack-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;gEAEgE;AAChE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-aws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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.
|
|
83
|
+
"@intentius/chant": "^0.31.0",
|
|
84
84
|
"typescript": "^5.9.3"
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS deep observation (#1015) — the reference row of the deep-observe contract
|
|
3
|
+
* (#1014).
|
|
4
|
+
*
|
|
5
|
+
* Every AWS interaction here is a mocked `spawn`. Nothing constructs a client,
|
|
6
|
+
* reads ambient credentials, or reaches a network: the reader's only edge is
|
|
7
|
+
* the runtime adapter, and it is replaced wholesale below.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
|
|
11
|
+
// Partial mock (`importOriginal`) for the same reason lifecycle-integration.test.ts
|
|
12
|
+
// uses one: this module is reachable from other real exports the plugin path
|
|
13
|
+
// touches, so replacing it wholesale breaks things unrelated to `spawn`.
|
|
14
|
+
const spawnMock = vi.fn();
|
|
15
|
+
vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
|
|
17
|
+
return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const { awsPlugin } = await import("./plugin");
|
|
21
|
+
const {
|
|
22
|
+
observeResourcesDeepAws,
|
|
23
|
+
awsDeepNormalizationHooks,
|
|
24
|
+
parseCloudControlResource,
|
|
25
|
+
hasOwnershipMarker,
|
|
26
|
+
} = await import("./deep-observe");
|
|
27
|
+
const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
|
|
28
|
+
const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
|
|
29
|
+
|
|
30
|
+
const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 });
|
|
31
|
+
const fail = (stderr: string) => ({ stdout: "", stderr, exitCode: 255 });
|
|
32
|
+
|
|
33
|
+
/** A `cloudcontrol get-resource` envelope — the model arrives as a JSON string. */
|
|
34
|
+
const cloudControl = (identifier: string, properties: Record<string, unknown>) =>
|
|
35
|
+
ok(JSON.stringify({ ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) } }));
|
|
36
|
+
|
|
37
|
+
const stackResources = (rows: Array<[string, string, string]>) =>
|
|
38
|
+
ok(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
StackResources: rows.map(([LogicalResourceId, ResourceType, PhysicalResourceId]) => ({
|
|
41
|
+
LogicalResourceId,
|
|
42
|
+
ResourceType,
|
|
43
|
+
PhysicalResourceId,
|
|
44
|
+
ResourceStatus: "CREATE_COMPLETE",
|
|
45
|
+
Timestamp: "2026-01-01T00:00:00Z",
|
|
46
|
+
})),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const entities = (
|
|
51
|
+
record: Record<string, { entityType: string; props: Record<string, unknown> }>,
|
|
52
|
+
): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
|
|
53
|
+
|
|
54
|
+
const argvOf = (call: unknown[]): string[] => call[0] as string[];
|
|
55
|
+
|
|
56
|
+
describe("parseCloudControlResource", () => {
|
|
57
|
+
test("unwraps the doubly-encoded model", () => {
|
|
58
|
+
expect(parseCloudControlResource(cloudControl("b", { BucketName: "b" }).stdout)).toEqual({
|
|
59
|
+
identifier: "b",
|
|
60
|
+
properties: { BucketName: "b" },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an unparseable body is a failed read, not an empty resource", () => {
|
|
65
|
+
expect(parseCloudControlResource("not json")).toBeNull();
|
|
66
|
+
expect(parseCloudControlResource(JSON.stringify({ ResourceDescription: {} }))).toBeNull();
|
|
67
|
+
expect(
|
|
68
|
+
parseCloudControlResource(JSON.stringify({ ResourceDescription: { Properties: "{oops" } })),
|
|
69
|
+
).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("the aws noise rules", () => {
|
|
74
|
+
test("prunes server-populated names wherever they appear", () => {
|
|
75
|
+
const out = normalizeDeepProperties(
|
|
76
|
+
{ Arn: "arn:aws:s3:::b", BucketName: "b", Nested: { RegionalDomainName: "x", Keep: 1 } },
|
|
77
|
+
{ entityType: "AWS::S3::Bucket", side: "live", hooks: awsDeepNormalizationHooks },
|
|
78
|
+
);
|
|
79
|
+
expect(out).toEqual({ BucketName: "b", Nested: { Keep: 1 } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("canonicalizes tag order", () => {
|
|
83
|
+
const out = normalizeDeepProperties(
|
|
84
|
+
{ Tags: [{ Key: "team", Value: "b" }, { Key: "env", Value: "a" }] },
|
|
85
|
+
{ entityType: "AWS::S3::Bucket", side: "live", hooks: awsDeepNormalizationHooks },
|
|
86
|
+
);
|
|
87
|
+
expect(out.Tags).toEqual([{ Key: "env", Value: "a" }, { Key: "team", Value: "b" }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("canonicalizes policy statement and action order", () => {
|
|
91
|
+
const out = normalizeDeepProperties(
|
|
92
|
+
{
|
|
93
|
+
PolicyDocument: {
|
|
94
|
+
Statement: [
|
|
95
|
+
{ Sid: "Write", Action: ["s3:PutObject", "s3:DeleteObject"] },
|
|
96
|
+
{ Sid: "Read", Action: ["s3:GetObject"] },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{ entityType: "AWS::IAM::ManagedPolicy", side: "live", hooks: awsDeepNormalizationHooks },
|
|
101
|
+
);
|
|
102
|
+
const statements = (out.PolicyDocument as { Statement: Array<{ Sid: string; Action: string[] }> }).Statement;
|
|
103
|
+
expect(statements.map((s) => s.Sid)).toEqual(["Read", "Write"]);
|
|
104
|
+
expect(statements[1].Action).toEqual(["s3:DeleteObject", "s3:PutObject"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("subtracts a service default only where source is silent about the property", () => {
|
|
108
|
+
const declaredNothing = normalizeDeepProperties(
|
|
109
|
+
{ Path: "/", MaxSessionDuration: 3600, RoleName: "r" },
|
|
110
|
+
{
|
|
111
|
+
entityType: "AWS::IAM::Role",
|
|
112
|
+
side: "live",
|
|
113
|
+
hooks: awsDeepNormalizationHooks,
|
|
114
|
+
counterpartPaths: new Set(["RoleName"]),
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
expect(declaredNothing).toEqual({ RoleName: "r" });
|
|
118
|
+
|
|
119
|
+
const declaredPath = normalizeDeepProperties(
|
|
120
|
+
{ Path: "/", RoleName: "r" },
|
|
121
|
+
{
|
|
122
|
+
entityType: "AWS::IAM::Role",
|
|
123
|
+
side: "live",
|
|
124
|
+
hooks: awsDeepNormalizationHooks,
|
|
125
|
+
counterpartPaths: new Set(["Path", "RoleName"]),
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
expect(declaredPath).toEqual({ Path: "/", RoleName: "r" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a one-sided pass never subtracts defaults — the reader has no declared tree yet", () => {
|
|
132
|
+
const out = normalizeDeepProperties(
|
|
133
|
+
{ Path: "/", RoleName: "r" },
|
|
134
|
+
{ entityType: "AWS::IAM::Role", side: "live", hooks: awsDeepNormalizationHooks },
|
|
135
|
+
);
|
|
136
|
+
expect(out).toEqual({ Path: "/", RoleName: "r" });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("hasOwnershipMarker", () => {
|
|
141
|
+
test("reads chant's tag out of the live tree", () => {
|
|
142
|
+
expect(hasOwnershipMarker({ Tags: [{ Key: "chant:managed-by", Value: "chant" }] })).toBe(true);
|
|
143
|
+
expect(hasOwnershipMarker({ Tags: [{ Key: "env", Value: "prod" }] })).toBe(false);
|
|
144
|
+
expect(hasOwnershipMarker({})).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("observeResourcesDeepAws", () => {
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
// A bare arrow returning the mock would register the mock itself as
|
|
151
|
+
// vitest's cleanup hook, and vitest would then call it with no arguments.
|
|
152
|
+
spawnMock.mockReset();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("reads each resource through cloudcontrol, honoring AWS_ENDPOINT_URL", async () => {
|
|
156
|
+
const previous = process.env.AWS_ENDPOINT_URL;
|
|
157
|
+
process.env.AWS_ENDPOINT_URL = "http://127.0.0.1:5566";
|
|
158
|
+
try {
|
|
159
|
+
spawnMock.mockImplementation((argv: string[]) =>
|
|
160
|
+
Promise.resolve(
|
|
161
|
+
argv.includes("describe-stack-resources")
|
|
162
|
+
? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
|
|
163
|
+
: cloudControl("acme-assets", { BucketName: "acme-assets" }),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
const result = normalizeDeepObservation(
|
|
167
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
|
|
168
|
+
);
|
|
169
|
+
expect(result.resources.Assets.properties).toEqual({ BucketName: "acme-assets" });
|
|
170
|
+
expect(result.resources.Assets.physicalId).toBe("acme-assets");
|
|
171
|
+
for (const call of spawnMock.mock.calls) {
|
|
172
|
+
expect(argvOf(call)).toContain("--endpoint-url");
|
|
173
|
+
expect(argvOf(call)).toContain("http://127.0.0.1:5566");
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
if (previous === undefined) delete process.env.AWS_ENDPOINT_URL;
|
|
177
|
+
else process.env.AWS_ENDPOINT_URL = previous;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a type with no reader is unsupported-kind, never absent", async () => {
|
|
182
|
+
spawnMock.mockResolvedValue(stackResources([["Queue", "AWS::SQS::Queue", "q-1"]]));
|
|
183
|
+
const result = normalizeDeepObservation(
|
|
184
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Queue"] }),
|
|
185
|
+
);
|
|
186
|
+
expect(result.resources).toEqual({});
|
|
187
|
+
expect(result.unobserved.Queue.reason).toBe("unsupported-kind");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("an expired token on the deep read is no-credentials, per resource", async () => {
|
|
191
|
+
spawnMock.mockImplementation((argv: string[]) =>
|
|
192
|
+
Promise.resolve(
|
|
193
|
+
argv.includes("describe-stack-resources")
|
|
194
|
+
? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
|
|
195
|
+
: fail("An error occurred (ExpiredToken) when calling GetResource: The security token expired"),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
const result = normalizeDeepObservation(
|
|
199
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
|
|
200
|
+
);
|
|
201
|
+
expect(result.unobserved.Assets.reason).toBe("no-credentials");
|
|
202
|
+
expect(result.resources).toEqual({});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a stack that does not exist yet is a real absence — no properties, no holes", async () => {
|
|
206
|
+
spawnMock.mockResolvedValue(fail("An error occurred (ValidationError): Stack with id prod does not exist"));
|
|
207
|
+
const result = normalizeDeepObservation(
|
|
208
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
|
|
209
|
+
);
|
|
210
|
+
expect(result).toEqual({ resources: {}, unobserved: {} });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("any other stack-read failure is a hole for every declared entity", async () => {
|
|
214
|
+
spawnMock.mockResolvedValue(fail("Could not connect to the endpoint URL"));
|
|
215
|
+
const result = normalizeDeepObservation(
|
|
216
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"] }),
|
|
217
|
+
);
|
|
218
|
+
expect(Object.keys(result.unobserved)).toEqual(["A", "B"]);
|
|
219
|
+
expect(result.unobserved.A.reason).toBe("read-failed");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("--owned withholds an unmarked resource as `filtered`, not as absent", async () => {
|
|
223
|
+
spawnMock.mockImplementation((argv: string[]) =>
|
|
224
|
+
Promise.resolve(
|
|
225
|
+
argv.includes("describe-stack-resources")
|
|
226
|
+
? stackResources([
|
|
227
|
+
["Ours", "AWS::S3::Bucket", "ours"],
|
|
228
|
+
["Theirs", "AWS::S3::Bucket", "theirs"],
|
|
229
|
+
])
|
|
230
|
+
: argv.includes("ours")
|
|
231
|
+
? cloudControl("ours", { BucketName: "ours", Tags: [{ Key: "chant:managed-by", Value: "chant" }] })
|
|
232
|
+
: cloudControl("theirs", { BucketName: "theirs" }),
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
const result = normalizeDeepObservation(
|
|
236
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Ours", "Theirs"], owned: true }),
|
|
237
|
+
);
|
|
238
|
+
expect(Object.keys(result.resources)).toEqual(["Ours"]);
|
|
239
|
+
expect(result.unobserved.Theirs.reason).toBe("filtered");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("secret-bearing properties are masked before they reach the tree", async () => {
|
|
243
|
+
spawnMock.mockImplementation((argv: string[]) =>
|
|
244
|
+
Promise.resolve(
|
|
245
|
+
argv.includes("describe-stack-resources")
|
|
246
|
+
? stackResources([["Role", "AWS::IAM::Role", "app-role"]])
|
|
247
|
+
: cloudControl("app-role", { RoleName: "app-role", ClientSecret: "s3cr3t" }),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
const result = normalizeDeepObservation(
|
|
251
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Role"] }),
|
|
252
|
+
);
|
|
253
|
+
expect(result.resources.Role.properties.ClientSecret).toBe("[REDACTED]");
|
|
254
|
+
expect(JSON.stringify(result)).not.toContain("s3cr3t");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a multi-stack project reads the stack it was handed", async () => {
|
|
258
|
+
spawnMock.mockResolvedValue(stackResources([]));
|
|
259
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A"], stack: "payments-prod" });
|
|
260
|
+
expect(argvOf(spawnMock.mock.calls[0])).toContain("payments-prod");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The acceptance test for #1015: the real plugin, a mutated live tree, a
|
|
266
|
+
* baseline, and exactly the genuine drift.
|
|
267
|
+
*/
|
|
268
|
+
describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
269
|
+
beforeEach(() => {
|
|
270
|
+
// A bare arrow returning the mock would register the mock itself as
|
|
271
|
+
// vitest's cleanup hook, and vitest would then call it with no arguments.
|
|
272
|
+
spawnMock.mockReset();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const declared = entities({
|
|
276
|
+
// Declared with two tags and versioning on.
|
|
277
|
+
Assets: {
|
|
278
|
+
entityType: "AWS::S3::Bucket",
|
|
279
|
+
props: {
|
|
280
|
+
BucketName: "acme-assets",
|
|
281
|
+
VersioningConfiguration: { Status: "Enabled" },
|
|
282
|
+
Tags: [
|
|
283
|
+
{ Key: "env", Value: "prod" },
|
|
284
|
+
{ Key: "team", Value: "payments" },
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
// Declared with two statements, in source order.
|
|
289
|
+
AppRole: {
|
|
290
|
+
entityType: "AWS::IAM::Role",
|
|
291
|
+
props: {
|
|
292
|
+
RoleName: "app-role",
|
|
293
|
+
AssumeRolePolicyDocument: {
|
|
294
|
+
Version: "2012-10-17",
|
|
295
|
+
Statement: [
|
|
296
|
+
{ Sid: "Ec2", Effect: "Allow", Action: ["sts:AssumeRole"] },
|
|
297
|
+
{ Sid: "Ci", Effect: "Allow", Action: ["sts:AssumeRole", "sts:TagSession"] },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
// No Cloud Control reader for this type.
|
|
303
|
+
Jobs: { entityType: "AWS::SQS::Queue", props: { QueueName: "jobs" } },
|
|
304
|
+
// The deep read of this one fails outright.
|
|
305
|
+
Perimeter: { entityType: "AWS::EC2::SecurityGroup", props: { GroupDescription: "perimeter" } },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const wireMocks = (): void => {
|
|
309
|
+
spawnMock.mockImplementation((argv: string[]) => {
|
|
310
|
+
if (argv.includes("describe-stack-resources")) {
|
|
311
|
+
return Promise.resolve(
|
|
312
|
+
stackResources([
|
|
313
|
+
["Assets", "AWS::S3::Bucket", "acme-assets"],
|
|
314
|
+
["AppRole", "AWS::IAM::Role", "app-role"],
|
|
315
|
+
["Jobs", "AWS::SQS::Queue", "jobs"],
|
|
316
|
+
["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"],
|
|
317
|
+
]),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (argv.includes("acme-assets")) {
|
|
321
|
+
return Promise.resolve(
|
|
322
|
+
cloudControl("acme-assets", {
|
|
323
|
+
BucketName: "acme-assets",
|
|
324
|
+
// GENUINE: somebody turned versioning off in the console.
|
|
325
|
+
VersioningConfiguration: { Status: "Suspended" },
|
|
326
|
+
// NOISE: tags come back in a different order …
|
|
327
|
+
Tags: [
|
|
328
|
+
{ Key: "team", Value: "payments" },
|
|
329
|
+
// … and with one the platform team adds to every bucket.
|
|
330
|
+
{ Key: "cost-center", Value: "platform" },
|
|
331
|
+
{ Key: "env", Value: "prod" },
|
|
332
|
+
],
|
|
333
|
+
// NOISE: server-populated.
|
|
334
|
+
Arn: "arn:aws:s3:::acme-assets",
|
|
335
|
+
RegionalDomainName: "acme-assets.s3.us-east-1.amazonaws.com",
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (argv.includes("app-role")) {
|
|
340
|
+
return Promise.resolve(
|
|
341
|
+
cloudControl("app-role", {
|
|
342
|
+
RoleName: "app-role",
|
|
343
|
+
// NOISE: statements and actions in a different order than source.
|
|
344
|
+
AssumeRolePolicyDocument: {
|
|
345
|
+
Version: "2012-10-17",
|
|
346
|
+
Statement: [
|
|
347
|
+
{ Sid: "Ci", Effect: "Allow", Action: ["sts:TagSession", "sts:AssumeRole"] },
|
|
348
|
+
{ Sid: "Ec2", Effect: "Allow", Action: ["sts:AssumeRole"] },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
// NOISE: provider defaults nobody declared.
|
|
352
|
+
Path: "/",
|
|
353
|
+
MaxSessionDuration: 3600,
|
|
354
|
+
// NOISE: server-populated.
|
|
355
|
+
Arn: "arn:aws:iam::111122223333:role/app-role",
|
|
356
|
+
RoleId: "AROAEXAMPLE",
|
|
357
|
+
CreateDate: "2026-01-01T00:00:00Z",
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
if (argv.includes("sg-01")) {
|
|
362
|
+
return Promise.resolve(fail("An error occurred (ThrottlingException) when calling GetResource"));
|
|
363
|
+
}
|
|
364
|
+
return Promise.resolve(fail("unexpected call"));
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const baseline = {
|
|
369
|
+
Assets: {
|
|
370
|
+
type: "AWS::S3::Bucket",
|
|
371
|
+
accepted: [
|
|
372
|
+
{ path: "Tags[#cost-center].Key", value: "cost-center" },
|
|
373
|
+
{ path: "Tags[#cost-center].Value", value: "platform" },
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
test("exactly the genuine drift surfaces; noise, defaults and the accepted tag do not", async () => {
|
|
379
|
+
wireMocks();
|
|
380
|
+
const result = await deepDiffForLexicon(awsPlugin, {
|
|
381
|
+
environment: "prod",
|
|
382
|
+
buildOutput: "",
|
|
383
|
+
entities: declared,
|
|
384
|
+
baseline,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// One finding, one property: the console-flipped versioning setting.
|
|
388
|
+
expect(result.drifted).toEqual([
|
|
389
|
+
{
|
|
390
|
+
name: "Assets",
|
|
391
|
+
type: "AWS::S3::Bucket",
|
|
392
|
+
changes: [
|
|
393
|
+
{
|
|
394
|
+
path: "VersioningConfiguration.Status",
|
|
395
|
+
kind: "changed",
|
|
396
|
+
declared: "Enabled",
|
|
397
|
+
live: "Suspended",
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
]);
|
|
402
|
+
|
|
403
|
+
// The role is clean: reordering, defaults and server-populated fields are
|
|
404
|
+
// all subtracted.
|
|
405
|
+
expect(result.unchanged).toEqual(["AppRole"]);
|
|
406
|
+
|
|
407
|
+
// The platform team's tag is accepted, so it is reported as suppressed
|
|
408
|
+
// rather than as drift.
|
|
409
|
+
expect(result.accepted.map((e) => e.name)).toEqual(["Assets"]);
|
|
410
|
+
expect(result.accepted[0].changes.map((c) => c.path)).toEqual([
|
|
411
|
+
"Tags[#cost-center].Key",
|
|
412
|
+
"Tags[#cost-center].Value",
|
|
413
|
+
]);
|
|
414
|
+
|
|
415
|
+
// An unreadable deep read is a hole with a reason — never silence, never
|
|
416
|
+
// noise, and never a create.
|
|
417
|
+
expect(result.unobserved).toEqual([
|
|
418
|
+
{
|
|
419
|
+
name: "Jobs",
|
|
420
|
+
type: "AWS::SQS::Queue",
|
|
421
|
+
reason: "unsupported-kind",
|
|
422
|
+
detail: "no deep reader for AWS::SQS::Queue — Cloud Control coverage is opt-in per type",
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "Perimeter",
|
|
426
|
+
type: "AWS::EC2::SecurityGroup",
|
|
427
|
+
reason: "read-failed",
|
|
428
|
+
detail:
|
|
429
|
+
'cloudcontrol get-resource failed for AWS::EC2::SecurityGroup "sg-01": An error occurred (ThrottlingException) when calling GetResource',
|
|
430
|
+
},
|
|
431
|
+
]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("without the baseline the platform tag is drift, and accepting it is what silences it", async () => {
|
|
435
|
+
wireMocks();
|
|
436
|
+
const result = await deepDiffForLexicon(awsPlugin, {
|
|
437
|
+
environment: "prod",
|
|
438
|
+
buildOutput: "",
|
|
439
|
+
entities: declared,
|
|
440
|
+
});
|
|
441
|
+
const assets = result.drifted.find((d) => d.name === "Assets");
|
|
442
|
+
expect(assets?.changes.map((c) => c.path).sort()).toEqual([
|
|
443
|
+
"Tags[#cost-center].Key",
|
|
444
|
+
"Tags[#cost-center].Value",
|
|
445
|
+
"VersioningConfiguration.Status",
|
|
446
|
+
]);
|
|
447
|
+
expect(result.accepted).toEqual([]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("an accepted value that later changes is drift again, with all three axes", async () => {
|
|
451
|
+
wireMocks();
|
|
452
|
+
const result = await deepDiffForLexicon(awsPlugin, {
|
|
453
|
+
environment: "prod",
|
|
454
|
+
buildOutput: "",
|
|
455
|
+
entities: declared,
|
|
456
|
+
baseline: {
|
|
457
|
+
Assets: {
|
|
458
|
+
accepted: [{ path: "Tags[#cost-center].Value", value: "someone-elses-team" }],
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
const change = result.drifted
|
|
463
|
+
.find((d) => d.name === "Assets")
|
|
464
|
+
?.changes.find((c) => c.path === "Tags[#cost-center].Value");
|
|
465
|
+
expect(change).toEqual({
|
|
466
|
+
path: "Tags[#cost-center].Value",
|
|
467
|
+
kind: "undeclared",
|
|
468
|
+
live: "platform",
|
|
469
|
+
baseline: "someone-elses-team",
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("a whole-lexicon failure is a hole for every declared entity, not a clean report", async () => {
|
|
474
|
+
spawnMock.mockResolvedValue(fail("Unable to locate credentials"));
|
|
475
|
+
const result = await deepDiffForLexicon(awsPlugin, {
|
|
476
|
+
environment: "prod",
|
|
477
|
+
buildOutput: "",
|
|
478
|
+
entities: declared,
|
|
479
|
+
});
|
|
480
|
+
expect(result.drifted).toEqual([]);
|
|
481
|
+
expect(result.unobserved.map((u) => u.name).sort()).toEqual(["AppRole", "Assets", "Jobs", "Perimeter"]);
|
|
482
|
+
expect(new Set(result.unobserved.map((u) => u.reason))).toEqual(new Set(["no-credentials"]));
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS deep observation (#1015) — the reference implementation of the epic's
|
|
3
|
+
* deep-observe contract (#1014).
|
|
4
|
+
*
|
|
5
|
+
* `describeResources` reads `cloudformation describe-stack-resources`, which
|
|
6
|
+
* returns a status, a physical id and a timestamp per resource. That is
|
|
7
|
+
* CloudFormation's view of the world, and CloudFormation only compares
|
|
8
|
+
* properties it was told about. A property somebody edited in the console — an
|
|
9
|
+
* inline policy, a bucket setting, a security-group rule — is invisible to it.
|
|
10
|
+
* That gap is why go-to-k/cdk-real-drift exists, and it is what this reader
|
|
11
|
+
* closes: the live resource model comes from the **Cloud Control API**, which
|
|
12
|
+
* bypasses CloudFormation entirely and returns the resource as the service
|
|
13
|
+
* actually holds it.
|
|
14
|
+
*
|
|
15
|
+
* Correlation is unchanged: Cloud Control is addressed by the physical id that
|
|
16
|
+
* `describe-stack-resources` already reports per logical id, so the results
|
|
17
|
+
* line up with the same IR node ids `live-attrs.ts` relies on.
|
|
18
|
+
*
|
|
19
|
+
* ## Scope of the first cut
|
|
20
|
+
*
|
|
21
|
+
* Four high-signal types (S3 buckets, IAM roles and managed policies, EC2
|
|
22
|
+
* security groups) rather than all 30+. The point of the first row is to prove
|
|
23
|
+
* the contract and the noise rules; widening the type table is additive and
|
|
24
|
+
* needs no contract change. A declared resource of any other type reports
|
|
25
|
+
* NOT-OBSERVED with `unsupported-kind` — it may well exist, and saying nothing
|
|
26
|
+
* about it is the only honest answer.
|
|
27
|
+
*
|
|
28
|
+
* ## Nothing here talks to real AWS on its own terms
|
|
29
|
+
*
|
|
30
|
+
* Every call goes through the runtime adapter's `spawn` and
|
|
31
|
+
* `applyAwsEndpointArgv`, so `AWS_ENDPOINT_URL` redirects the whole reader at a
|
|
32
|
+
* local emulator exactly as the existing describe path does.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type {
|
|
36
|
+
DeepArrayElement,
|
|
37
|
+
DeepNode,
|
|
38
|
+
DeepNormalizationHooks,
|
|
39
|
+
DeepObservationResult,
|
|
40
|
+
DeepResourceObservation,
|
|
41
|
+
UnobservedEntity,
|
|
42
|
+
UnobservedReason,
|
|
43
|
+
} from "@intentius/chant/lexicon";
|
|
44
|
+
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
45
|
+
import { stackDoesNotExist } from "./stack-errors";
|
|
46
|
+
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* CloudFormation types this reader can read live. Each is addressable in Cloud
|
|
50
|
+
* Control by the physical id CloudFormation already reports.
|
|
51
|
+
*/
|
|
52
|
+
export const DEEP_READABLE_TYPES: ReadonlySet<string> = new Set([
|
|
53
|
+
"AWS::S3::Bucket",
|
|
54
|
+
"AWS::IAM::Role",
|
|
55
|
+
"AWS::IAM::ManagedPolicy",
|
|
56
|
+
"AWS::EC2::SecurityGroup",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Property names that are server-populated wherever they appear — identifiers
|
|
61
|
+
* the service mints, timestamps it stamps, counters it maintains. Matched on
|
|
62
|
+
* the final path segment, because AWS repeats these names at every nesting
|
|
63
|
+
* depth and a per-type list of full paths would be a maintenance trap.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately excludes ambiguous names like `Id` and `Name`: `VpcId` and
|
|
66
|
+
* `BucketName` are declared inputs, and pruning a declared input is how a
|
|
67
|
+
* normalization pass starts hiding real drift.
|
|
68
|
+
*/
|
|
69
|
+
export const AWS_READ_ONLY_NAMES: ReadonlySet<string> = new Set([
|
|
70
|
+
"Arn",
|
|
71
|
+
"RoleId",
|
|
72
|
+
"PolicyId",
|
|
73
|
+
"GroupId",
|
|
74
|
+
"OwnerId",
|
|
75
|
+
"AttachmentCount",
|
|
76
|
+
"PermissionsBoundaryUsageCount",
|
|
77
|
+
"DefaultVersionId",
|
|
78
|
+
"IsAttachable",
|
|
79
|
+
"CreateDate",
|
|
80
|
+
"CreationDate",
|
|
81
|
+
"UpdateDate",
|
|
82
|
+
"LastModified",
|
|
83
|
+
"LastModifiedTime",
|
|
84
|
+
"DualStackDomainName",
|
|
85
|
+
"RegionalDomainName",
|
|
86
|
+
"WebsiteURL",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Service defaults, per type, as index-erased property paths. A live value
|
|
91
|
+
* equal to its default is subtracted **only when source never declared that
|
|
92
|
+
* property** — cdk-real-drift's default subtraction, and the reason
|
|
93
|
+
* {@link DeepNode.counterpart} exists. Declaring the default explicitly keeps
|
|
94
|
+
* the property in the diff, so a later change to it still reports.
|
|
95
|
+
*/
|
|
96
|
+
export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
97
|
+
"AWS::S3::Bucket": {
|
|
98
|
+
"VersioningConfiguration.Status": "Suspended",
|
|
99
|
+
"AccelerateConfiguration.AccelerationStatus": "Suspended",
|
|
100
|
+
"ObjectLockEnabled": false,
|
|
101
|
+
},
|
|
102
|
+
"AWS::IAM::Role": {
|
|
103
|
+
"Path": "/",
|
|
104
|
+
"MaxSessionDuration": 3600,
|
|
105
|
+
},
|
|
106
|
+
"AWS::IAM::ManagedPolicy": {
|
|
107
|
+
"Path": "/",
|
|
108
|
+
},
|
|
109
|
+
"AWS::EC2::SecurityGroup": {
|
|
110
|
+
"GroupDescription": "default VPC security group",
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Stable JSON with sorted keys — the fallback ordering key for a set-like array. */
|
|
115
|
+
function canonicalJson(value: unknown): string {
|
|
116
|
+
return JSON.stringify(value, (_k, v: unknown) =>
|
|
117
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
118
|
+
? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
119
|
+
: v,
|
|
120
|
+
) ?? "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The final segment of an index-erased pattern (`Policies[].PolicyName` → `PolicyName`). */
|
|
124
|
+
function lastSegment(pattern: string): string {
|
|
125
|
+
const withoutIndex = pattern.replace(/\[\]$/, "");
|
|
126
|
+
const dot = withoutIndex.lastIndexOf(".");
|
|
127
|
+
return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The aws lexicon's noise rules. The three classes the epic names for AWS —
|
|
132
|
+
* server-populated fields, unstable ordering (tags, policy statements), and
|
|
133
|
+
* provider defaults — plus nothing else: a rule that is not one of those is a
|
|
134
|
+
* rule that hides drift.
|
|
135
|
+
*/
|
|
136
|
+
export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
|
|
137
|
+
prune(node: DeepNode): boolean {
|
|
138
|
+
// Read-only / server-populated. Pruned on both sides: if source somehow
|
|
139
|
+
// declares an arn-shaped output, comparing it to the live one is still
|
|
140
|
+
// meaningless.
|
|
141
|
+
if (AWS_READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true;
|
|
142
|
+
|
|
143
|
+
// Provider defaults, on the live side only, and only where source is silent
|
|
144
|
+
// about the property. `"unknown"` (a one-sided normalization) never prunes:
|
|
145
|
+
// the reader must not decide this before the declared tree is in hand.
|
|
146
|
+
if (node.side !== "live" || node.counterpart !== "absent") return false;
|
|
147
|
+
const defaults = AWS_SERVICE_DEFAULTS[node.entityType];
|
|
148
|
+
if (!defaults) return false;
|
|
149
|
+
if (!Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
|
|
150
|
+
return defaults[node.pattern] === node.value;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The key doubles as a path segment (`Tags[#env].Value`), so it is the
|
|
155
|
+
* element's own identity where AWS gives one — a tag key, a statement Sid, an
|
|
156
|
+
* action string — and canonical JSON only as a fallback.
|
|
157
|
+
*/
|
|
158
|
+
orderKey(element: DeepArrayElement): string | undefined {
|
|
159
|
+
const name = lastSegment(element.pattern);
|
|
160
|
+
const el = element.element;
|
|
161
|
+
|
|
162
|
+
// Tags are a set. AWS returns them in whatever order it likes, and a
|
|
163
|
+
// reordered tag list is the single loudest false positive in a raw diff.
|
|
164
|
+
if (name === "Tags") {
|
|
165
|
+
const key = isRecord(el) ? el.Key : undefined;
|
|
166
|
+
return typeof key === "string" ? key : canonicalJson(el);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// IAM policy statements are a set, and so are the Action/Resource lists
|
|
170
|
+
// inside them. `Sid` is the natural identity when the author gave one.
|
|
171
|
+
if (name === "Statement") {
|
|
172
|
+
const sid = isRecord(el) ? el.Sid : undefined;
|
|
173
|
+
return typeof sid === "string" ? sid : canonicalJson(el);
|
|
174
|
+
}
|
|
175
|
+
if (name === "Action" || name === "NotAction" || name === "Resource" || name === "NotResource") {
|
|
176
|
+
return typeof el === "string" ? el : canonicalJson(el);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Security-group rules are a set — the console appends, chant declares in
|
|
180
|
+
// source order, and neither order means anything to EC2.
|
|
181
|
+
if (name === "SecurityGroupIngress" || name === "SecurityGroupEgress" || name === "IpRanges") {
|
|
182
|
+
return canonicalJson(el);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return undefined;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
190
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One live resource as `cloudcontrol get-resource` returns it. Exported for tests. */
|
|
194
|
+
export interface CloudControlResource {
|
|
195
|
+
identifier: string;
|
|
196
|
+
properties: Record<string, unknown>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Parse a `cloudcontrol get-resource` payload. Cloud Control returns the model
|
|
201
|
+
* as a JSON *string* inside the envelope, so this unwraps twice. Returns null
|
|
202
|
+
* for anything that does not parse to an object — an unparseable body is a
|
|
203
|
+
* failed read, not an empty resource.
|
|
204
|
+
*/
|
|
205
|
+
export function parseCloudControlResource(stdout: string): CloudControlResource | null {
|
|
206
|
+
let envelope: unknown;
|
|
207
|
+
try {
|
|
208
|
+
envelope = JSON.parse(stdout);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (!isRecord(envelope)) return null;
|
|
213
|
+
const description = envelope.ResourceDescription;
|
|
214
|
+
if (!isRecord(description)) return null;
|
|
215
|
+
const raw = description.Properties;
|
|
216
|
+
if (typeof raw !== "string") return null;
|
|
217
|
+
let properties: unknown;
|
|
218
|
+
try {
|
|
219
|
+
properties = JSON.parse(raw);
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (!isRecord(properties)) return null;
|
|
224
|
+
return {
|
|
225
|
+
identifier: typeof description.Identifier === "string" ? description.Identifier : "",
|
|
226
|
+
properties,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Classify a failed AWS CLI call the same way the thin read does. */
|
|
231
|
+
function classifyFailure(stderr: string): UnobservedReason {
|
|
232
|
+
return /credential|token|expired|AccessDenied|not authorized|UnauthorizedOperation/i.test(stderr)
|
|
233
|
+
? "no-credentials"
|
|
234
|
+
: "read-failed";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** True when the live property tree carries chant's ownership marker tag. */
|
|
238
|
+
export function hasOwnershipMarker(properties: Record<string, unknown>): boolean {
|
|
239
|
+
const tags = properties.Tags;
|
|
240
|
+
if (!Array.isArray(tags)) return false;
|
|
241
|
+
return tags.some((t) => isRecord(t) && t.Key === AWS_TAG_OWNERSHIP_KEYS.managedBy);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface AwsDeepObserveOptions {
|
|
245
|
+
environment: string;
|
|
246
|
+
entityNames: string[];
|
|
247
|
+
entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
248
|
+
stack?: string;
|
|
249
|
+
owned?: boolean;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read the live property tree for each declared entity via Cloud Control.
|
|
254
|
+
*
|
|
255
|
+
* Two reads per run plus one per readable resource: `describe-stack-resources`
|
|
256
|
+
* resolves logical id → (type, physical id), then `cloudcontrol get-resource`
|
|
257
|
+
* fetches each model. The first read's failure modes are the thin path's,
|
|
258
|
+
* verbatim — a stack that does not exist yet is a real absence (nothing is
|
|
259
|
+
* deployed, so there are no properties to drift), anything else is a hole for
|
|
260
|
+
* every declared entity.
|
|
261
|
+
*/
|
|
262
|
+
export async function observeResourcesDeepAws(
|
|
263
|
+
options: AwsDeepObserveOptions,
|
|
264
|
+
): Promise<DeepObservationResult> {
|
|
265
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
266
|
+
const { deepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
|
|
267
|
+
const { unobservedAll } = await import("@intentius/chant/observation");
|
|
268
|
+
const rt = getRuntime();
|
|
269
|
+
|
|
270
|
+
const stackName = options.stack ?? options.environment;
|
|
271
|
+
const endpoint = process.env.AWS_ENDPOINT_URL;
|
|
272
|
+
|
|
273
|
+
const listResult = await rt.spawn(applyAwsEndpointArgv([
|
|
274
|
+
"aws", "cloudformation", "describe-stack-resources",
|
|
275
|
+
"--stack-name", stackName,
|
|
276
|
+
"--output", "json",
|
|
277
|
+
], endpoint));
|
|
278
|
+
|
|
279
|
+
if (listResult.exitCode !== 0) {
|
|
280
|
+
if (stackDoesNotExist(listResult.stderr)) return deepObservation({});
|
|
281
|
+
return deepObservation(
|
|
282
|
+
{},
|
|
283
|
+
unobservedAll(
|
|
284
|
+
options.entityNames,
|
|
285
|
+
classifyFailure(listResult.stderr),
|
|
286
|
+
`describe-stack-resources failed for stack "${stackName}": ${listResult.stderr.trim().split("\n")[0] ?? ""}`,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let stackResources: Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId?: string }> = [];
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(listResult.stdout) as {
|
|
294
|
+
StackResources?: Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId?: string }>;
|
|
295
|
+
};
|
|
296
|
+
stackResources = parsed.StackResources ?? [];
|
|
297
|
+
} catch {
|
|
298
|
+
return deepObservation(
|
|
299
|
+
{},
|
|
300
|
+
unobservedAll(options.entityNames, "read-failed", `unparseable describe-stack-resources output for stack "${stackName}"`),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const byLogicalId = new Map(stackResources.map((r) => [r.LogicalResourceId, r]));
|
|
305
|
+
const resources: Record<string, DeepResourceObservation> = {};
|
|
306
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
307
|
+
|
|
308
|
+
for (const entityName of options.entityNames) {
|
|
309
|
+
const stackResource = byLogicalId.get(entityName);
|
|
310
|
+
// Not in the stack at all. The thin read reports that absence; restating it
|
|
311
|
+
// here as a property hole would turn one finding into two.
|
|
312
|
+
if (!stackResource) continue;
|
|
313
|
+
|
|
314
|
+
const type = stackResource.ResourceType;
|
|
315
|
+
if (!DEEP_READABLE_TYPES.has(type)) {
|
|
316
|
+
unobserved[entityName] = {
|
|
317
|
+
type,
|
|
318
|
+
reason: "unsupported-kind",
|
|
319
|
+
detail: `no deep reader for ${type} — Cloud Control coverage is opt-in per type`,
|
|
320
|
+
};
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const identifier = stackResource.PhysicalResourceId;
|
|
324
|
+
if (!identifier) {
|
|
325
|
+
unobserved[entityName] = {
|
|
326
|
+
type,
|
|
327
|
+
reason: "read-failed",
|
|
328
|
+
detail: "the stack reports no physical id, so the live resource cannot be addressed",
|
|
329
|
+
};
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const getResult = await rt.spawn(applyAwsEndpointArgv([
|
|
334
|
+
"aws", "cloudcontrol", "get-resource",
|
|
335
|
+
"--type-name", type,
|
|
336
|
+
"--identifier", identifier,
|
|
337
|
+
"--output", "json",
|
|
338
|
+
], endpoint));
|
|
339
|
+
|
|
340
|
+
if (getResult.exitCode !== 0) {
|
|
341
|
+
unobserved[entityName] = {
|
|
342
|
+
type,
|
|
343
|
+
reason: classifyFailure(getResult.stderr),
|
|
344
|
+
detail: `cloudcontrol get-resource failed for ${type} "${identifier}": ${getResult.stderr.trim().split("\n")[0] ?? ""}`,
|
|
345
|
+
};
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const parsed = parseCloudControlResource(getResult.stdout);
|
|
350
|
+
if (!parsed) {
|
|
351
|
+
unobserved[entityName] = {
|
|
352
|
+
type,
|
|
353
|
+
reason: "read-failed",
|
|
354
|
+
detail: `unparseable cloudcontrol get-resource output for ${type} "${identifier}"`,
|
|
355
|
+
};
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Cloud Control *does* return tags, so unlike the thin path this one can
|
|
360
|
+
// answer the ownership question (#1015's open note). A resource withheld by
|
|
361
|
+
// the filter is `filtered`, never absent: it exists, it just isn't chant's.
|
|
362
|
+
const owned = hasOwnershipMarker(parsed.properties);
|
|
363
|
+
if (options.owned && !owned) {
|
|
364
|
+
unobserved[entityName] = {
|
|
365
|
+
type,
|
|
366
|
+
reason: "filtered",
|
|
367
|
+
detail: `live resource carries no ${AWS_TAG_OWNERSHIP_KEYS.managedBy} tag`,
|
|
368
|
+
};
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
resources[entityName] = {
|
|
373
|
+
type,
|
|
374
|
+
physicalId: identifier,
|
|
375
|
+
properties: normalizeDeepProperties(parsed.properties, {
|
|
376
|
+
entityType: type,
|
|
377
|
+
side: "live",
|
|
378
|
+
hooks: awsDeepNormalizationHooks,
|
|
379
|
+
}),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return deepObservation(resources, unobserved);
|
|
384
|
+
}
|
|
@@ -63656,6 +63656,12 @@
|
|
|
63656
63656
|
"pattern": "[0-9]*.[0-9]*.[0-9]*",
|
|
63657
63657
|
"minLength": 0,
|
|
63658
63658
|
"maxLength": 16
|
|
63659
|
+
},
|
|
63660
|
+
"CreationTime": {
|
|
63661
|
+
"format": "date-time"
|
|
63662
|
+
},
|
|
63663
|
+
"LastModifiedTime": {
|
|
63664
|
+
"format": "date-time"
|
|
63659
63665
|
}
|
|
63660
63666
|
},
|
|
63661
63667
|
"createOnly": [
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,18 @@ export type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
|
25
25
|
// Plugin
|
|
26
26
|
export { awsPlugin } from "./plugin";
|
|
27
27
|
|
|
28
|
+
// Deep observation (#1015): the Cloud Control reader and the noise rules it
|
|
29
|
+
// shares with core's normalization pass.
|
|
30
|
+
export {
|
|
31
|
+
observeResourcesDeepAws,
|
|
32
|
+
awsDeepNormalizationHooks,
|
|
33
|
+
parseCloudControlResource,
|
|
34
|
+
hasOwnershipMarker,
|
|
35
|
+
DEEP_READABLE_TYPES,
|
|
36
|
+
AWS_READ_ONLY_NAMES,
|
|
37
|
+
AWS_SERVICE_DEFAULTS,
|
|
38
|
+
} from "./deep-observe";
|
|
39
|
+
|
|
28
40
|
// Intrinsics
|
|
29
41
|
export {
|
|
30
42
|
Sub,
|
package/src/plugin.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
2
|
import { detectTemplate } from "./detect";
|
|
3
|
-
import type { LexiconPlugin, IntrinsicDef, ObservationResult, ResourceMetadata, ExportedTemplate, ResourceSelector, InitTemplateSet, StackStatusObservation } from "@intentius/chant/lexicon";
|
|
3
|
+
import type { LexiconPlugin, IntrinsicDef, ObservationResult, DeepObservationResult, ResourceMetadata, ExportedTemplate, ResourceSelector, InitTemplateSet, StackStatusObservation } from "@intentius/chant/lexicon";
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
6
6
|
import type { TemplateParser } from "@intentius/chant/import/parser";
|
|
@@ -16,6 +16,8 @@ import { fileURLToPath } from "url";
|
|
|
16
16
|
import { awsSerializer } from "./serializer";
|
|
17
17
|
import { FLOCI_EMULATOR } from "./op/activities/floci";
|
|
18
18
|
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
19
|
+
import { stackDoesNotExist } from "./stack-errors";
|
|
20
|
+
import { awsDeepNormalizationHooks, observeResourcesDeepAws } from "./deep-observe";
|
|
19
21
|
import { awsReferenceCatalog } from "./reference-catalog";
|
|
20
22
|
import { resolveTemplateAttrs } from "./live-attrs";
|
|
21
23
|
import { CFParser } from "./import/parser";
|
|
@@ -24,12 +26,9 @@ import { parseStackTemplate } from "./import/live-export";
|
|
|
24
26
|
import { awsCompletions } from "./lsp/completions";
|
|
25
27
|
import { awsHover } from "./lsp/hover";
|
|
26
28
|
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
export function stackDoesNotExist(stderr: string): boolean {
|
|
31
|
-
return /does not exist/i.test(stderr);
|
|
32
|
-
}
|
|
29
|
+
/** Re-exported from ./stack-errors so the long-standing import path (and its
|
|
30
|
+
* tests) keep working now that the deep reader shares the classifier. */
|
|
31
|
+
export { stackDoesNotExist } from "./stack-errors";
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
34
|
* AWS CloudFormation lexicon plugin.
|
|
@@ -657,6 +656,31 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
657
656
|
return observation(resources);
|
|
658
657
|
},
|
|
659
658
|
|
|
659
|
+
/**
|
|
660
|
+
* Property-level live read (#1015) via the Cloud Control API — past
|
|
661
|
+
* CloudFormation's view of the world, into the resource as the service
|
|
662
|
+
* actually holds it. Implementation in ./deep-observe.ts.
|
|
663
|
+
*/
|
|
664
|
+
async observeResourcesDeep(options: {
|
|
665
|
+
environment: string;
|
|
666
|
+
buildOutput: string;
|
|
667
|
+
entityNames: string[];
|
|
668
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
669
|
+
stack?: string;
|
|
670
|
+
owned?: boolean;
|
|
671
|
+
}): Promise<DeepObservationResult> {
|
|
672
|
+
return observeResourcesDeepAws({
|
|
673
|
+
environment: options.environment,
|
|
674
|
+
entityNames: options.entityNames,
|
|
675
|
+
entities: options.entities,
|
|
676
|
+
stack: options.stack,
|
|
677
|
+
owned: options.owned,
|
|
678
|
+
});
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
/** The noise rules the deep pass applies to both the live and declared trees. */
|
|
682
|
+
deepNormalizationHooks: awsDeepNormalizationHooks,
|
|
683
|
+
|
|
660
684
|
async describeStackStatus(options: { environment: string; stack: string }): Promise<StackStatusObservation | null> {
|
|
661
685
|
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
662
686
|
const rt = getRuntime();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudFormation CLI error classification, shared by every live read path.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module (rather than in ./plugin.ts, where it started) so the
|
|
5
|
+
* deep reader (#1015) can classify the same failure the same way without
|
|
6
|
+
* importing the plugin that imports it. ./plugin.ts re-exports it, so the
|
|
7
|
+
* original import path is unchanged.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** True when a CloudFormation CLI error means the stack simply isn't there yet
|
|
11
|
+
* (`ValidationError … does not exist`) — the pre-first-apply state, which live
|
|
12
|
+
* queries should treat as "nothing deployed", not a failure. */
|
|
13
|
+
export function stackDoesNotExist(stderr: string): boolean {
|
|
14
|
+
return /does not exist/i.test(stderr);
|
|
15
|
+
}
|