@intentius/chant-lexicon-aws 0.44.9 → 0.44.10
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/api/read-client.d.ts +27 -5
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/sigv4.d.ts +82 -0
- package/dist/api/sigv4.d.ts.map +1 -0
- package/dist/identity-observe.d.ts +23 -0
- package/dist/identity-observe.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/api/read-client.test.ts +87 -4
- package/src/api/read-client.ts +89 -21
- package/src/api/sigv4.test.ts +260 -0
- package/src/api/sigv4.ts +243 -0
- package/src/identity-observe.test.ts +104 -0
- package/src/identity-observe.ts +114 -0
- package/src/index.ts +13 -0
- package/src/lifecycle-integration.test.ts +69 -0
- package/src/plugin.ts +19 -5
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity fallback on the stack read (#1647).
|
|
3
|
+
*
|
|
4
|
+
* `describeResources` asks CloudFormation exactly one question — is there a
|
|
5
|
+
* logical id named <entity> in this stack? — which reads a freshly
|
|
6
|
+
* carve-emitted, still-Terraform-owned resource as confirmed-absent even
|
|
7
|
+
* though the declared `BucketName` names it precisely. When a declared entity
|
|
8
|
+
* is absent from the stack AND its props spell the type's full primary
|
|
9
|
+
* identifier (the spec knowledge the codegen already compiled into
|
|
10
|
+
* `lexicon-aws.json`), ask Cloud Control for it by identity. Found means
|
|
11
|
+
* OBSERVED: `ownership: "foreign"` (it exists and something other than this
|
|
12
|
+
* stack owns it — Terraform, a console hand, another tool) and
|
|
13
|
+
* `status: "EXTERNAL"` (live outside the stack, deliberately not a
|
|
14
|
+
* CloudFormation status word).
|
|
15
|
+
*
|
|
16
|
+
* Best-effort ON TOP of a stack answer, never instead of one: a genuine miss
|
|
17
|
+
* (`ResourceNotFoundException`) keeps the stack's absent verdict, and so does
|
|
18
|
+
* every other refusal (`UnsupportedOperation` — a Floci without Cloud
|
|
19
|
+
* Control — credentials, throttling). The stack read succeeded; absence at
|
|
20
|
+
* stack scope is an honest verdict this fallback can refine but must never
|
|
21
|
+
* degrade into a hole, or a pre-first-apply plan would stop proposing
|
|
22
|
+
* `create` the moment the emulator lacks Cloud Control.
|
|
23
|
+
*/
|
|
24
|
+
import { createRequire } from "module";
|
|
25
|
+
import { getResource, type AwsReadClientOptions } from "./api/read-client";
|
|
26
|
+
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
|
|
30
|
+
interface LexiconEntry {
|
|
31
|
+
resourceType: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
primaryIdentifier?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let byResourceType: Map<string, LexiconEntry> | undefined;
|
|
37
|
+
function manifestByType(): Map<string, LexiconEntry> {
|
|
38
|
+
if (!byResourceType) {
|
|
39
|
+
const manifest = require("./generated/lexicon-aws.json") as Record<string, LexiconEntry>;
|
|
40
|
+
byResourceType = new Map(Object.values(manifest).map((e) => [e.resourceType, e]));
|
|
41
|
+
}
|
|
42
|
+
return byResourceType;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Cloud Control identifier the declared props spell, or undefined when the
|
|
47
|
+
* type has no primary identifier on record or any part of it is absent or
|
|
48
|
+
* non-scalar (a Ref, an intrinsic, a server-assigned name). Multi-part
|
|
49
|
+
* identifiers join with `|`, Cloud Control's own separator.
|
|
50
|
+
*/
|
|
51
|
+
export function declaredIdentifier(entityType: string, props: Record<string, unknown>): string | undefined {
|
|
52
|
+
const entry = manifestByType().get(entityType);
|
|
53
|
+
const parts = entry?.primaryIdentifier;
|
|
54
|
+
if (!entry || entry.kind !== "resource" || !parts || parts.length === 0) return undefined;
|
|
55
|
+
const values: string[] = [];
|
|
56
|
+
for (const part of parts) {
|
|
57
|
+
const v = props[part];
|
|
58
|
+
if (typeof v !== "string" && typeof v !== "number") return undefined;
|
|
59
|
+
const s = String(v);
|
|
60
|
+
if (!s) return undefined;
|
|
61
|
+
values.push(s);
|
|
62
|
+
}
|
|
63
|
+
return values.join("|");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The same scrub the stack-output path applies, on live property KEYS. */
|
|
67
|
+
function redactSensitive(properties: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
68
|
+
const out: Record<string, unknown> = {};
|
|
69
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
70
|
+
out[key] = /password|secret|token/i.test(key) ? "[REDACTED]" : value;
|
|
71
|
+
}
|
|
72
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Identity-read every entity the stack did not answer for and whose props
|
|
77
|
+
* spell an identifier. `already` is the stack's answer — an entity the stack
|
|
78
|
+
* DID return is never re-read. The `queried` map records the address each
|
|
79
|
+
* attempted read was issued against (#1620), whatever the verdict.
|
|
80
|
+
*/
|
|
81
|
+
export async function observeByIdentity(
|
|
82
|
+
entityNames: string[],
|
|
83
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }> | undefined,
|
|
84
|
+
already: Record<string, ResourceMetadata>,
|
|
85
|
+
client: AwsReadClientOptions,
|
|
86
|
+
): Promise<{ resources: Record<string, ResourceMetadata>; queried: Record<string, string> }> {
|
|
87
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
88
|
+
const queried: Record<string, string> = {};
|
|
89
|
+
if (!entities) return { resources, queried };
|
|
90
|
+
for (const name of entityNames) {
|
|
91
|
+
if (already[name]) continue;
|
|
92
|
+
const entity = entities.get(name);
|
|
93
|
+
if (!entity) continue;
|
|
94
|
+
const identifier = declaredIdentifier(entity.entityType, entity.props ?? {});
|
|
95
|
+
if (!identifier) continue;
|
|
96
|
+
queried[name] = `cloudcontrol:GetResource:${entity.entityType}:${identifier}`;
|
|
97
|
+
try {
|
|
98
|
+
const found = await getResource(entity.entityType, identifier, client);
|
|
99
|
+
if (!found) continue;
|
|
100
|
+
resources[name] = {
|
|
101
|
+
type: entity.entityType,
|
|
102
|
+
physicalId: found.identifier || identifier,
|
|
103
|
+
status: "EXTERNAL",
|
|
104
|
+
ownership: "foreign",
|
|
105
|
+
...(redactSensitive(found.properties) ? { attributes: redactSensitive(found.properties) } : {}),
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
// Refusals of every kind keep the stack's verdict — see the module
|
|
109
|
+
// comment for why a failed refinement must not become a hole.
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { resources, queried };
|
|
114
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -52,8 +52,21 @@ export {
|
|
|
52
52
|
type AwsReadClientOptions,
|
|
53
53
|
type CloudControlDescription,
|
|
54
54
|
type StackResource,
|
|
55
|
+
type AwsCredentials,
|
|
56
|
+
type AwsCredentialResolver,
|
|
57
|
+
type AwsCredentialSource,
|
|
55
58
|
} from "./api/read-client";
|
|
56
59
|
|
|
60
|
+
// SigV4 (#1686). Exported as its own surface because it is deliberately not
|
|
61
|
+
// specific to this transport: cedar's AVP client is the next caller, and the
|
|
62
|
+
// point of the module is that there is one signer rather than one per lexicon.
|
|
63
|
+
export {
|
|
64
|
+
signRequest,
|
|
65
|
+
resolveCredentials,
|
|
66
|
+
EMPTY_PAYLOAD_SHA256,
|
|
67
|
+
type SigV4Request,
|
|
68
|
+
} from "./api/sigv4";
|
|
69
|
+
|
|
57
70
|
// Intrinsics
|
|
58
71
|
export {
|
|
59
72
|
Sub,
|
|
@@ -143,6 +143,75 @@ describe("aws lifecycle integration (#163)", () => {
|
|
|
143
143
|
expect(cs2.entries.find((e) => e.name === "MyBucket")!.action).toBe("noop");
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
// #1647 — the carve state, end to end: terraform applied the bucket, carve
|
|
147
|
+
// emitted a carveout declaring it by BucketName, and no CFN stack has ever
|
|
148
|
+
// heard of it. The stack read alone said confirmed-absent (missing → a plan
|
|
149
|
+
// proposing create for a bucket that EXISTS); the identity fallback asks
|
|
150
|
+
// Cloud Control by the declared identifier and the verdict comes back
|
|
151
|
+
// observed.
|
|
152
|
+
test("identity fallback: a declared, stack-absent, live resource reads observed, not missing (#1647)", async () => {
|
|
153
|
+
const routeBoth = (cc: { status?: number; text: string }): void => {
|
|
154
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((async (url: string, init: { body: string; headers?: Record<string, string> }) => {
|
|
155
|
+
const target = init.headers?.["x-amz-target"] ?? "";
|
|
156
|
+
if (target.endsWith("GetResource")) return { status: cc.status ?? 200, text: () => Promise.resolve(cc.text) };
|
|
157
|
+
const action = new URLSearchParams(init.body).get("Action") ?? "";
|
|
158
|
+
return {
|
|
159
|
+
status: 200,
|
|
160
|
+
text: () => Promise.resolve(action === "DescribeStackResources" ? stackResourcesXml([]) : stackOutputsXml()),
|
|
161
|
+
};
|
|
162
|
+
}) as unknown as typeof fetch);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const entities = new Map([
|
|
166
|
+
["assets", { entityType: "AWS::S3::Bucket", props: { BucketName: "acme-platform-assets-prod" } }],
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
routeBoth({
|
|
170
|
+
text: JSON.stringify({
|
|
171
|
+
ResourceDescription: {
|
|
172
|
+
Identifier: "acme-platform-assets-prod",
|
|
173
|
+
Properties: JSON.stringify({ BucketName: "acme-platform-assets-prod" }),
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
const observed = normalizeObservation(
|
|
178
|
+
await awsPlugin.describeResources!({
|
|
179
|
+
environment: "prod",
|
|
180
|
+
buildOutput: "",
|
|
181
|
+
entityNames: ["assets"],
|
|
182
|
+
entities,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
expect(observed.resources.assets).toMatchObject({
|
|
186
|
+
type: "AWS::S3::Bucket",
|
|
187
|
+
status: "EXTERNAL",
|
|
188
|
+
ownership: "foreign",
|
|
189
|
+
});
|
|
190
|
+
// #1620: the identity read's address rides the observation.
|
|
191
|
+
expect(observed.queried.assets).toContain("acme-platform-assets-prod");
|
|
192
|
+
|
|
193
|
+
// Through the change set: declared + live → noop, never create. `foreign`
|
|
194
|
+
// ownership never escalates anything (#120's rule holds).
|
|
195
|
+
const cs = buildChangeSet("prod", { declared: new Set(["assets"]), observedNow: observed.resources, observedThen: undefined });
|
|
196
|
+
expect(cs.entries.find((e) => e.name === "assets")!.action).toBe("noop");
|
|
197
|
+
|
|
198
|
+
// An emulator without Cloud Control keeps today's verdict exactly: absent,
|
|
199
|
+
// create proposed — the fallback must not turn pre-first-apply into a hole.
|
|
200
|
+
routeBoth({ status: 400, text: JSON.stringify({ __type: "UnsupportedOperation", message: "not supported" }) });
|
|
201
|
+
const degraded = normalizeObservation(
|
|
202
|
+
await awsPlugin.describeResources!({
|
|
203
|
+
environment: "prod",
|
|
204
|
+
buildOutput: "",
|
|
205
|
+
entityNames: ["assets"],
|
|
206
|
+
entities,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
expect(degraded.resources.assets).toBeUndefined();
|
|
210
|
+
expect(degraded.unobserved.assets).toBeUndefined();
|
|
211
|
+
const cs2 = buildChangeSet("prod", { declared: new Set(["assets"]), observedNow: degraded.resources, observedThen: undefined });
|
|
212
|
+
expect(cs2.entries.find((e) => e.name === "assets")!.action).toBe("create");
|
|
213
|
+
});
|
|
214
|
+
|
|
146
215
|
describe("describeStackStatus (#57 — per-component stack presence)", () => {
|
|
147
216
|
const err = (stderr: string) => ({ stdout: "", stderr, exitCode: 255 });
|
|
148
217
|
|
package/src/plugin.ts
CHANGED
|
@@ -548,6 +548,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
548
548
|
environment: string;
|
|
549
549
|
buildOutput: string;
|
|
550
550
|
entityNames: string[];
|
|
551
|
+
entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
551
552
|
stack?: string;
|
|
552
553
|
region?: string;
|
|
553
554
|
owned?: boolean;
|
|
@@ -592,9 +593,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
592
593
|
// A stack that doesn't exist yet is the pre-first-apply state: nothing is
|
|
593
594
|
// deployed for this env, so there are no live resources (every declared
|
|
594
595
|
// resource is "pending") — not an error. That is a real absence, so the
|
|
595
|
-
// empty result is the honest one and `create` is the right proposal
|
|
596
|
+
// empty result is the honest one and `create` is the right proposal —
|
|
597
|
+
// EXCEPT for an entity whose props spell its own physical identity
|
|
598
|
+
// (#1647): a freshly carve-emitted, still-Terraform-owned resource lives
|
|
599
|
+
// in no stack at all, and only an identity read can see it.
|
|
596
600
|
if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
|
|
597
|
-
|
|
601
|
+
const { observeByIdentity } = await import("./identity-observe");
|
|
602
|
+
const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
|
|
603
|
+
return observation({ ...resources, ...identity.resources }, undefined, identity.queried);
|
|
598
604
|
}
|
|
599
605
|
// Any other failure (credentials, throttling, a region that can't be
|
|
600
606
|
// reached) establishes nothing about what is deployed. Reporting every
|
|
@@ -655,6 +661,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
655
661
|
};
|
|
656
662
|
}
|
|
657
663
|
|
|
664
|
+
// The identity fallback (#1647): entities the stack did not answer for but
|
|
665
|
+
// whose declared props spell a full primary identifier get a Cloud Control
|
|
666
|
+
// read before "absent" stands. Computed against the stack's answer and
|
|
667
|
+
// merged at the return sites, so the own-property enrichment below neither
|
|
668
|
+
// re-describes nor un-observes what identity found.
|
|
669
|
+
const { observeByIdentity } = await import("./identity-observe");
|
|
670
|
+
const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
|
|
671
|
+
|
|
658
672
|
// Each resource's OWN properties, on top of the stack outputs above (#1279).
|
|
659
673
|
// Until this, a node's `attrs` were the stack's exports replicated onto
|
|
660
674
|
// every member, so no instance carried its own `VpcId`.
|
|
@@ -687,13 +701,13 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
687
701
|
detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
|
|
688
702
|
};
|
|
689
703
|
}
|
|
690
|
-
return observation(described, holes);
|
|
704
|
+
return observation({ ...described, ...identity.resources }, holes, identity.queried);
|
|
691
705
|
}
|
|
692
706
|
|
|
693
707
|
// Every entity the stack answered for was answered for: an entity the
|
|
694
708
|
// template doesn't carry is genuinely not in this stack, which is an
|
|
695
|
-
// absence, not a hole.
|
|
696
|
-
return observation(withProperties);
|
|
709
|
+
// absence, not a hole — unless the identity fallback saw it live (#1647).
|
|
710
|
+
return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried);
|
|
697
711
|
},
|
|
698
712
|
|
|
699
713
|
/**
|