@intentius/chant-lexicon-aws 0.44.9 → 0.44.12
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/agentcore/trace-fetch.d.ts +332 -0
- package/dist/agentcore/trace-fetch.d.ts.map +1 -0
- package/dist/agentcore/trace-render.d.ts +256 -0
- package/dist/agentcore/trace-render.d.ts.map +1 -0
- package/dist/api/read-client.d.ts +43 -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/op/activities/index.d.ts +10 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/agentcore/trace-fetch.test.ts +588 -0
- package/src/agentcore/trace-fetch.ts +754 -0
- package/src/agentcore/trace-render.test.ts +366 -0
- package/src/agentcore/trace-render.ts +589 -0
- package/src/api/read-client.test.ts +87 -4
- package/src/api/read-client.ts +95 -21
- package/src/api/sigv4.test.ts +260 -0
- package/src/api/sigv4.ts +243 -0
- package/src/identity-observe.test.ts +146 -0
- package/src/identity-observe.ts +137 -0
- package/src/index.ts +13 -0
- package/src/lifecycle-integration.test.ts +69 -0
- package/src/op/activities/index.ts +15 -0
- package/src/plugin.ts +19 -5
package/src/api/sigv4.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4, for the shared read transport (#1686).
|
|
3
|
+
*
|
|
4
|
+
* Two modules asked for this by name before it existed: `./read-client.ts` and
|
|
5
|
+
* cedar's `lexicons/cedar/src/avp/client.ts` both send a placeholder
|
|
6
|
+
* `Signature=unsigned` `Authorization` header that carries a credential scope
|
|
7
|
+
* and nothing else, and both say in prose that the real thing belongs here
|
|
8
|
+
* once rather than twice. Nothing else in the repo signs an AWS request — the
|
|
9
|
+
* appliers (`op/activities/aws-apply.ts`) are unsigned too, which is why the
|
|
10
|
+
* native paths have been emulator-scoped.
|
|
11
|
+
*
|
|
12
|
+
* ## No dependency
|
|
13
|
+
*
|
|
14
|
+
* SigV4 is four HMACs and two SHA-256s. `node:crypto` has both, so signing
|
|
15
|
+
* costs nothing in the dependency tree — which matters here, because the whole
|
|
16
|
+
* reason these transports are hand-written is to keep `@aws-sdk/*` and its
|
|
17
|
+
* thirty-odd transitive packages out of a lexicon.
|
|
18
|
+
*
|
|
19
|
+
* ## What is signed, and what is not
|
|
20
|
+
*
|
|
21
|
+
* Header signing only. Query-string (presigned-URL) signing has no caller: the
|
|
22
|
+
* read paths POST to a service endpoint, they do not hand out URLs.
|
|
23
|
+
*
|
|
24
|
+
* `host` is signed but never emitted. `fetch` computes `Host` from the URL and
|
|
25
|
+
* forbids overriding it, so the signer derives the same value from the URL it
|
|
26
|
+
* was given. A transport that rewrites the URL underneath the signer — a proxy
|
|
27
|
+
* that keeps the original `Host`, say — would invalidate the signature; the
|
|
28
|
+
* injectable-HTTP seam is for tests and emulators, not for URL rewriting.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash, createHmac } from "node:crypto";
|
|
31
|
+
|
|
32
|
+
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
33
|
+
const TERMINATOR = "aws4_request";
|
|
34
|
+
|
|
35
|
+
/** SHA-256 of the empty string — the payload hash of every bodyless request. */
|
|
36
|
+
export const EMPTY_PAYLOAD_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Headers that must not be signed even when a caller sets them: `authorization`
|
|
40
|
+
* is the output, and the others are rewritten in transit by proxies and agents,
|
|
41
|
+
* so signing them produces intermittent 403s rather than security.
|
|
42
|
+
*/
|
|
43
|
+
const UNSIGNABLE = new Set(["authorization", "connection", "expect", "user-agent", "x-amzn-trace-id"]);
|
|
44
|
+
|
|
45
|
+
/** A resolved credential set. `sessionToken` is present for STS/role credentials. */
|
|
46
|
+
export interface AwsCredentials {
|
|
47
|
+
accessKeyId: string;
|
|
48
|
+
secretAccessKey: string;
|
|
49
|
+
sessionToken?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The credential injection seam: a function the caller supplies to decide, by
|
|
54
|
+
* whatever means it likes, what to sign with. Returning `undefined` means "this
|
|
55
|
+
* process has no credentials" and is respected as an answer, not overridden.
|
|
56
|
+
*/
|
|
57
|
+
export type AwsCredentialResolver = () => AwsCredentials | undefined;
|
|
58
|
+
|
|
59
|
+
/** Either literal credentials or a resolver for them. */
|
|
60
|
+
export type AwsCredentialSource = AwsCredentials | AwsCredentialResolver;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Credentials for a request: explicit → environment → absent.
|
|
64
|
+
*
|
|
65
|
+
* A resolver function is authoritative — if one is injected and it declines,
|
|
66
|
+
* the environment is not consulted behind its back, because the point of
|
|
67
|
+
* injecting one is to control the answer. Literal credentials are likewise
|
|
68
|
+
* final. Only the no-source case falls through to `AWS_ACCESS_KEY_ID` /
|
|
69
|
+
* `AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN` when set), and a half-set
|
|
70
|
+
* environment — a key id with no secret — is absent rather than a signature
|
|
71
|
+
* that cannot verify.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately not implemented: the profile file, IMDS, and the container
|
|
74
|
+
* credential endpoints. Each is a separate transport with its own failure and
|
|
75
|
+
* caching story; a caller that has those can resolve them itself and pass the
|
|
76
|
+
* result in, which is what the resolver seam is for.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveCredentials(
|
|
79
|
+
source?: AwsCredentialSource,
|
|
80
|
+
env: Record<string, string | undefined> = process.env,
|
|
81
|
+
): AwsCredentials | undefined {
|
|
82
|
+
if (typeof source === "function") return source();
|
|
83
|
+
if (source) return source;
|
|
84
|
+
const accessKeyId = env.AWS_ACCESS_KEY_ID;
|
|
85
|
+
const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
|
|
86
|
+
if (!accessKeyId || !secretAccessKey) return undefined;
|
|
87
|
+
return {
|
|
88
|
+
accessKeyId,
|
|
89
|
+
secretAccessKey,
|
|
90
|
+
...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One request to sign. `headers` are the caller's; the signer adds its own. */
|
|
95
|
+
export interface SigV4Request {
|
|
96
|
+
method: string;
|
|
97
|
+
/** Absolute URL. Its host is signed and its path/query are canonicalized. */
|
|
98
|
+
url: string;
|
|
99
|
+
headers: Record<string, string>;
|
|
100
|
+
body: string;
|
|
101
|
+
/** Service name as it appears in the credential scope (`cloudformation`, `cloudcontrolapi`, …). */
|
|
102
|
+
service: string;
|
|
103
|
+
region: string;
|
|
104
|
+
credentials: AwsCredentials;
|
|
105
|
+
/** Signing clock. Injected by tests; otherwise now. */
|
|
106
|
+
now?: Date;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `YYYYMMDDTHHMMSSZ` — the `X-Amz-Date` format, which is ISO-8601 basic. */
|
|
110
|
+
export function amzDate(date: Date): string {
|
|
111
|
+
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Lowercase hex SHA-256. */
|
|
115
|
+
export function sha256Hex(payload: string): string {
|
|
116
|
+
return createHash("sha256").update(payload, "utf8").digest("hex");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hmac(key: Buffer | string, data: string): Buffer {
|
|
120
|
+
return createHmac("sha256", key).update(data, "utf8").digest();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The signing key: four chained HMACs from the secret, so the key on the wire
|
|
125
|
+
* is scoped to one day, one region and one service rather than being the
|
|
126
|
+
* account secret itself.
|
|
127
|
+
*/
|
|
128
|
+
export function signingKey(secretAccessKey: string, day: string, region: string, service: string): Buffer {
|
|
129
|
+
const dateKey = hmac(`AWS4${secretAccessKey}`, day);
|
|
130
|
+
const regionKey = hmac(dateKey, region);
|
|
131
|
+
const serviceKey = hmac(regionKey, service);
|
|
132
|
+
return hmac(serviceKey, TERMINATOR);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* RFC 3986 percent-encoding. `encodeURIComponent` leaves `!'()*` alone and AWS
|
|
137
|
+
* does not, which is the difference between a signature that verifies and one
|
|
138
|
+
* that does not for any path or parameter containing them.
|
|
139
|
+
*/
|
|
140
|
+
function encodeRfc3986(value: string): string {
|
|
141
|
+
return encodeURIComponent(value).replace(
|
|
142
|
+
/[!'()*]/g,
|
|
143
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The canonical path.
|
|
149
|
+
*
|
|
150
|
+
* Every caller of this module POSTs to `/`, so the one place implementations
|
|
151
|
+
* genuinely diverge — S3 signs the path once-encoded, every other service signs
|
|
152
|
+
* it twice-encoded — never arises. Segments are encoded once; a future caller
|
|
153
|
+
* with a real path on a non-S3 service is the moment to revisit that.
|
|
154
|
+
*/
|
|
155
|
+
function canonicalPath(pathname: string): string {
|
|
156
|
+
if (!pathname || pathname === "/") return "/";
|
|
157
|
+
return pathname.split("/").map(encodeRfc3986).join("/");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Query parameters sorted by name, then by value, each side encoded. */
|
|
161
|
+
function canonicalQuery(search: URLSearchParams): string {
|
|
162
|
+
const pairs: Array<[string, string]> = [];
|
|
163
|
+
search.forEach((value, name) => pairs.push([encodeRfc3986(name), encodeRfc3986(value)]));
|
|
164
|
+
pairs.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
|
|
165
|
+
return pairs.map(([name, value]) => `${name}=${value}`).join("&");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Header names lowercased, values trimmed with internal whitespace runs
|
|
170
|
+
* collapsed, sorted by name. Returns the canonical block and the `;`-joined
|
|
171
|
+
* signed-header list that has to agree with it exactly.
|
|
172
|
+
*/
|
|
173
|
+
export function canonicalHeaders(headers: Record<string, string>): { canonical: string; signed: string } {
|
|
174
|
+
const entries = Object.entries(headers)
|
|
175
|
+
.map(([name, value]) => [name.toLowerCase().trim(), value.trim().replace(/\s+/g, " ")] as const)
|
|
176
|
+
.filter(([name]) => !UNSIGNABLE.has(name))
|
|
177
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
178
|
+
return {
|
|
179
|
+
canonical: entries.map(([name, value]) => `${name}:${value}\n`).join(""),
|
|
180
|
+
signed: entries.map(([name]) => name).join(";"),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The canonical request, verbatim as it is hashed into the string to sign. */
|
|
185
|
+
export function canonicalRequest(
|
|
186
|
+
method: string,
|
|
187
|
+
url: URL,
|
|
188
|
+
headers: Record<string, string>,
|
|
189
|
+
payloadHash: string,
|
|
190
|
+
): { canonical: string; signed: string } {
|
|
191
|
+
const { canonical: headerBlock, signed } = canonicalHeaders(headers);
|
|
192
|
+
return {
|
|
193
|
+
canonical: [
|
|
194
|
+
method.toUpperCase(),
|
|
195
|
+
canonicalPath(url.pathname),
|
|
196
|
+
canonicalQuery(url.searchParams),
|
|
197
|
+
headerBlock,
|
|
198
|
+
signed,
|
|
199
|
+
payloadHash,
|
|
200
|
+
].join("\n"),
|
|
201
|
+
signed,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The string to sign: algorithm, timestamp, scope, hashed canonical request. */
|
|
206
|
+
export function stringToSign(timestamp: string, scope: string, canonical: string): string {
|
|
207
|
+
return [ALGORITHM, timestamp, scope, sha256Hex(canonical)].join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The caller's headers plus everything SigV4 adds: `x-amz-date`,
|
|
212
|
+
* `x-amz-content-sha256`, `x-amz-security-token` when the credentials are
|
|
213
|
+
* temporary, and the `Authorization` header itself.
|
|
214
|
+
*
|
|
215
|
+
* `host` is signed (AWS requires it) but not returned — see the module header.
|
|
216
|
+
*/
|
|
217
|
+
export function signRequest(request: SigV4Request): Record<string, string> {
|
|
218
|
+
const url = new URL(request.url);
|
|
219
|
+
const timestamp = amzDate(request.now ?? new Date());
|
|
220
|
+
const day = timestamp.slice(0, 8);
|
|
221
|
+
const payloadHash = request.body.length === 0 ? EMPTY_PAYLOAD_SHA256 : sha256Hex(request.body);
|
|
222
|
+
|
|
223
|
+
const signable: Record<string, string> = {
|
|
224
|
+
...request.headers,
|
|
225
|
+
host: url.host,
|
|
226
|
+
"x-amz-date": timestamp,
|
|
227
|
+
"x-amz-content-sha256": payloadHash,
|
|
228
|
+
...(request.credentials.sessionToken ? { "x-amz-security-token": request.credentials.sessionToken } : {}),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const { canonical, signed } = canonicalRequest(request.method, url, signable, payloadHash);
|
|
232
|
+
const scope = `${day}/${request.region}/${request.service}/${TERMINATOR}`;
|
|
233
|
+
const key = signingKey(request.credentials.secretAccessKey, day, request.region, request.service);
|
|
234
|
+
const signature = hmac(key, stringToSign(timestamp, scope, canonical)).toString("hex");
|
|
235
|
+
|
|
236
|
+
const { host: _host, ...emitted } = signable;
|
|
237
|
+
return {
|
|
238
|
+
...emitted,
|
|
239
|
+
authorization:
|
|
240
|
+
`${ALGORITHM} Credential=${request.credentials.accessKeyId}/${scope}, ` +
|
|
241
|
+
`SignedHeaders=${signed}, Signature=${signature}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity fallback (#1647) — unit half. The carve state in miniature: a
|
|
3
|
+
* declared Bucket absent from every stack, present live, named precisely by
|
|
4
|
+
* its own `BucketName`. The Cloud Control edge is the injected `http` seam
|
|
5
|
+
* `read-client` already exposes.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { declaredIdentifier, observeByIdentity } from "./identity-observe";
|
|
9
|
+
|
|
10
|
+
const entity = (entityType: string, props: Record<string, unknown>) => ({ entityType, props });
|
|
11
|
+
|
|
12
|
+
describe("declaredIdentifier (#1647)", () => {
|
|
13
|
+
test("a scalar primary identifier reads straight off the props", () => {
|
|
14
|
+
expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: "acme-platform-assets-prod" })).toBe(
|
|
15
|
+
"acme-platform-assets-prod",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("absent, empty, or non-scalar identifier parts refuse — a Ref is not an identity", () => {
|
|
20
|
+
expect(declaredIdentifier("AWS::S3::Bucket", {})).toBeUndefined();
|
|
21
|
+
expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: "" })).toBeUndefined();
|
|
22
|
+
expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: { Ref: "Other" } })).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("an unknown type has no identifier to spell", () => {
|
|
26
|
+
expect(declaredIdentifier("AWS::Made::Up", { Name: "x" })).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** A Cloud Control `GetResource` answer, double-encoded the way the API is. */
|
|
31
|
+
const ccFound = (identifier: string, properties: Record<string, unknown>) => ({
|
|
32
|
+
status: 200,
|
|
33
|
+
text: JSON.stringify({
|
|
34
|
+
ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) },
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const ccError = (type: string, message: string) => ({
|
|
39
|
+
status: 400,
|
|
40
|
+
text: JSON.stringify({ __type: type, message }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("observeByIdentity (#1647)", () => {
|
|
44
|
+
const bucket = new Map([["assets", entity("AWS::S3::Bucket", { BucketName: "acme-platform-assets-prod" })]]);
|
|
45
|
+
|
|
46
|
+
test("a live identifier-named resource reads OBSERVED — external, foreign, never absent", async () => {
|
|
47
|
+
const calls: Array<{ url: string; body: string }> = [];
|
|
48
|
+
const { resources, queried } = await observeByIdentity(["assets"], bucket, {}, {
|
|
49
|
+
http: async (url, init) => {
|
|
50
|
+
calls.push({ url, body: init.body });
|
|
51
|
+
return ccFound("acme-platform-assets-prod", { BucketName: "acme-platform-assets-prod", SecretToken: "s3cr3t" });
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(calls).toHaveLength(1);
|
|
55
|
+
expect(calls[0].url).toContain("cloudcontrol");
|
|
56
|
+
expect(resources.assets).toMatchObject({
|
|
57
|
+
type: "AWS::S3::Bucket",
|
|
58
|
+
physicalId: "acme-platform-assets-prod",
|
|
59
|
+
status: "EXTERNAL",
|
|
60
|
+
ownership: "foreign",
|
|
61
|
+
});
|
|
62
|
+
// Live properties ride along, sensitive keys scrubbed like stack outputs.
|
|
63
|
+
expect(resources.assets.attributes).toMatchObject({ BucketName: "acme-platform-assets-prod", SecretToken: "[REDACTED]" });
|
|
64
|
+
// #1620: the attempted address is on the wire whatever the verdict.
|
|
65
|
+
expect(queried.assets).toContain("AWS::S3::Bucket");
|
|
66
|
+
expect(queried.assets).toContain("acme-platform-assets-prod");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a genuine miss keeps the stack's absent verdict, with the address still recorded", async () => {
|
|
70
|
+
const { resources, queried } = await observeByIdentity(["assets"], bucket, {}, {
|
|
71
|
+
http: async () => ccError("ResourceNotFoundException", "no such bucket"),
|
|
72
|
+
});
|
|
73
|
+
expect(resources.assets).toBeUndefined();
|
|
74
|
+
expect(queried.assets).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("an emulator without Cloud Control at all keeps the absent verdict — never a hole, or pre-first-apply plans stop proposing create", async () => {
|
|
78
|
+
const { resources } = await observeByIdentity(["assets"], bucket, {}, {
|
|
79
|
+
http: async () => ccError("UnsupportedOperation", "not supported"),
|
|
80
|
+
});
|
|
81
|
+
expect(resources.assets).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Floci serves ListResources but answers UnsupportedOperation for
|
|
85
|
+
// GetResource (read-client's own note) — and the emulator is where the carve
|
|
86
|
+
// walkthrough films the observe beat. Verified against Floci 1.5.34 on the
|
|
87
|
+
// behold demo: the list leg is what turns the miss into a read.
|
|
88
|
+
test("GetResource-unsupported falls back to ListResources and matches the identifier (Floci)", async () => {
|
|
89
|
+
const targets: string[] = [];
|
|
90
|
+
const { resources } = await observeByIdentity(["assets"], bucket, {}, {
|
|
91
|
+
http: async (_url, init) => {
|
|
92
|
+
const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
|
|
93
|
+
targets.push(target);
|
|
94
|
+
if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "Operation GetResource is not supported.");
|
|
95
|
+
return {
|
|
96
|
+
status: 200,
|
|
97
|
+
text: JSON.stringify({
|
|
98
|
+
ResourceDescriptions: [
|
|
99
|
+
{ Identifier: "some-other-bucket", Properties: JSON.stringify({ BucketName: "some-other-bucket" }) },
|
|
100
|
+
{ Identifier: "acme-platform-assets-prod", Properties: JSON.stringify({ BucketName: "acme-platform-assets-prod" }) },
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
expect(targets.some((t) => t.endsWith("ListResources"))).toBe(true);
|
|
107
|
+
expect(resources.assets).toMatchObject({
|
|
108
|
+
type: "AWS::S3::Bucket",
|
|
109
|
+
physicalId: "acme-platform-assets-prod",
|
|
110
|
+
status: "EXTERNAL",
|
|
111
|
+
ownership: "foreign",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the list leg missing the identifier keeps the absent verdict", async () => {
|
|
116
|
+
const { resources } = await observeByIdentity(["assets"], bucket, {}, {
|
|
117
|
+
http: async (_url, init) => {
|
|
118
|
+
const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
|
|
119
|
+
if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "not supported");
|
|
120
|
+
return { status: 200, text: JSON.stringify({ ResourceDescriptions: [] }) };
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(resources.assets).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("entities the stack already answered for are never re-read", async () => {
|
|
127
|
+
let called = 0;
|
|
128
|
+
const already = { assets: { type: "AWS::S3::Bucket", physicalId: "b", status: "CREATE_COMPLETE" } };
|
|
129
|
+
const { resources } = await observeByIdentity(["assets"], bucket, already, {
|
|
130
|
+
http: async () => ((called += 1), ccFound("x", {})),
|
|
131
|
+
});
|
|
132
|
+
expect(called).toBe(0);
|
|
133
|
+
expect(resources).toEqual({});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("entities with no spellable identifier are skipped without a call", async () => {
|
|
137
|
+
let called = 0;
|
|
138
|
+
const anonymous = new Map([["q", entity("AWS::SQS::Queue", {})]]);
|
|
139
|
+
const { resources, queried } = await observeByIdentity(["q"], anonymous, {}, {
|
|
140
|
+
http: async () => ((called += 1), ccFound("x", {})),
|
|
141
|
+
});
|
|
142
|
+
expect(called).toBe(0);
|
|
143
|
+
expect(resources).toEqual({});
|
|
144
|
+
expect(queried).toEqual({});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
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 { AwsReadError, getResource, listResources, type AwsReadClientOptions, type CloudControlDescription } 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
|
+
/**
|
|
67
|
+
* `GetResource`, with a `ListResources` + identifier-match fallback when the
|
|
68
|
+
* endpoint says the operation itself is unsupported. That is Floci's exact
|
|
69
|
+
* answer (read-client's own note) while it serves `ListResources` fine — and
|
|
70
|
+
* an emulator is exactly where the carve state lives, so without this leg the
|
|
71
|
+
* fallback proves itself everywhere except the one place the walkthrough
|
|
72
|
+
* films it. Every other refusal propagates: the caller decides what a failed
|
|
73
|
+
* refinement means.
|
|
74
|
+
*/
|
|
75
|
+
async function readByIdentity(
|
|
76
|
+
typeName: string,
|
|
77
|
+
identifier: string,
|
|
78
|
+
client: AwsReadClientOptions,
|
|
79
|
+
): Promise<CloudControlDescription | null> {
|
|
80
|
+
try {
|
|
81
|
+
return await getResource(typeName, identifier, client);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (!(err instanceof AwsReadError) || err.code !== "UnsupportedOperation") throw err;
|
|
84
|
+
const listed = await listResources(typeName, client);
|
|
85
|
+
return listed.find((d) => d.identifier === identifier) ?? null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The same scrub the stack-output path applies, on live property KEYS. */
|
|
90
|
+
function redactSensitive(properties: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
91
|
+
const out: Record<string, unknown> = {};
|
|
92
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
93
|
+
out[key] = /password|secret|token/i.test(key) ? "[REDACTED]" : value;
|
|
94
|
+
}
|
|
95
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Identity-read every entity the stack did not answer for and whose props
|
|
100
|
+
* spell an identifier. `already` is the stack's answer — an entity the stack
|
|
101
|
+
* DID return is never re-read. The `queried` map records the address each
|
|
102
|
+
* attempted read was issued against (#1620), whatever the verdict.
|
|
103
|
+
*/
|
|
104
|
+
export async function observeByIdentity(
|
|
105
|
+
entityNames: string[],
|
|
106
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }> | undefined,
|
|
107
|
+
already: Record<string, ResourceMetadata>,
|
|
108
|
+
client: AwsReadClientOptions,
|
|
109
|
+
): Promise<{ resources: Record<string, ResourceMetadata>; queried: Record<string, string> }> {
|
|
110
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
111
|
+
const queried: Record<string, string> = {};
|
|
112
|
+
if (!entities) return { resources, queried };
|
|
113
|
+
for (const name of entityNames) {
|
|
114
|
+
if (already[name]) continue;
|
|
115
|
+
const entity = entities.get(name);
|
|
116
|
+
if (!entity) continue;
|
|
117
|
+
const identifier = declaredIdentifier(entity.entityType, entity.props ?? {});
|
|
118
|
+
if (!identifier) continue;
|
|
119
|
+
queried[name] = `cloudcontrol:GetResource:${entity.entityType}:${identifier}`;
|
|
120
|
+
try {
|
|
121
|
+
const found = await readByIdentity(entity.entityType, identifier, client);
|
|
122
|
+
if (!found) continue;
|
|
123
|
+
resources[name] = {
|
|
124
|
+
type: entity.entityType,
|
|
125
|
+
physicalId: found.identifier || identifier,
|
|
126
|
+
status: "EXTERNAL",
|
|
127
|
+
ownership: "foreign",
|
|
128
|
+
...(redactSensitive(found.properties) ? { attributes: redactSensitive(found.properties) } : {}),
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
// Refusals of every kind keep the stack's verdict — see the module
|
|
132
|
+
// comment for why a failed refinement must not become a hole.
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { resources, queried };
|
|
137
|
+
}
|
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
|
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* emulator lifecycle (`flociUp`/`flociDown`) and the native CloudFormation
|
|
5
5
|
* applier (`awsApply`), which calls the CloudFormation API directly rather than
|
|
6
6
|
* shelling `aws` — the direct twin of `azApply`/`gcpApply`.
|
|
7
|
+
*
|
|
8
|
+
* The registry keys every exported *function* here by its name, so only the
|
|
9
|
+
* activities themselves belong in this barrel. `awsAgentCoreFetchTrace`'s
|
|
10
|
+
* helpers — the normalizer, the JSON coercion, the `ListEvents` walk — stay
|
|
11
|
+
* importable from `@intentius/chant-lexicon-aws/agentcore/trace-fetch` rather
|
|
12
|
+
* than being registered as activities nobody would ever name in a step, and the
|
|
13
|
+
* pure renderer lives one module further out again, in
|
|
14
|
+
* `@intentius/chant-lexicon-aws/agentcore/trace-render`.
|
|
7
15
|
*/
|
|
8
16
|
export {
|
|
9
17
|
flociUp,
|
|
@@ -35,3 +43,10 @@ export {
|
|
|
35
43
|
isTerminalStatus,
|
|
36
44
|
} from "./aws-apply";
|
|
37
45
|
export type { AwsApplyArgs, AwsHttp } from "./aws-apply";
|
|
46
|
+
|
|
47
|
+
export { awsAgentCoreFetchTrace } from "../../agentcore/trace-fetch";
|
|
48
|
+
export type {
|
|
49
|
+
AgentCoreTraceSource,
|
|
50
|
+
AwsAgentCoreFetchTraceArgs,
|
|
51
|
+
AwsAgentCoreFetchTraceResult,
|
|
52
|
+
} from "../../agentcore/trace-fetch";
|