@intentius/chant-lexicon-aws 0.46.0 → 0.49.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/api/read-client.d.ts +10 -2
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/composites/agentcore-agent.d.ts +26 -1
- package/dist/composites/agentcore-agent.d.ts.map +1 -1
- package/dist/composites/bucket-deployment.d.ts +82 -0
- package/dist/composites/bucket-deployment.d.ts.map +1 -0
- package/dist/composites/dynamodb-table.d.ts +45 -0
- package/dist/composites/dynamodb-table.d.ts.map +1 -0
- package/dist/composites/ec2-instance-bundle.d.ts +61 -0
- package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
- package/dist/composites/ecr-repository.d.ts +49 -0
- package/dist/composites/ecr-repository.d.ts.map +1 -0
- package/dist/composites/fargate-service.d.ts +16 -0
- package/dist/composites/fargate-service.d.ts.map +1 -1
- package/dist/composites/index.d.ts +16 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/monitoring-stack.d.ts +99 -0
- package/dist/composites/monitoring-stack.d.ts.map +1 -0
- package/dist/composites/nlb-service.d.ts +48 -0
- package/dist/composites/nlb-service.d.ts.map +1 -0
- package/dist/composites/step-functions-workflow.d.ts +36 -0
- package/dist/composites/step-functions-workflow.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +45 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-topology.d.ts +104 -0
- package/dist/deep-topology.d.ts.map +1 -0
- package/dist/effect-receipt-row.d.ts +92 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +7 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +17 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw061.d.ts +19 -0
- package/dist/lint/post-synth/waw061.d.ts.map +1 -0
- package/dist/lint/post-synth/waw062.d.ts +19 -0
- package/dist/lint/post-synth/waw062.d.ts.map +1 -0
- package/dist/lint/post-synth/waw063.d.ts +35 -0
- package/dist/lint/post-synth/waw063.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +3 -0
- package/dist/okf/rules/WAW061.md +17 -0
- package/dist/okf/rules/WAW062.md +27 -0
- package/dist/okf/rules/WAW063.md +19 -0
- package/dist/okf/types/Action.md +1 -0
- package/dist/okf/types/AutoScalingGroup.md +1 -0
- package/dist/okf/types/Bucket.md +1 -0
- package/dist/okf/types/ECRRepository.md +1 -0
- package/dist/okf/types/Function.md +1 -0
- package/dist/okf/types/IamPolicy.md +1 -0
- package/dist/okf/types/LaunchTemplate.md +1 -0
- package/dist/okf/types/LoadBalancer.md +1 -0
- package/dist/okf/types/ManagedPolicy.md +2 -0
- package/dist/okf/types/Map.md +1 -0
- package/dist/okf/types/Role.md +2 -0
- package/dist/okf/types/SecurityGroup.md +1 -0
- package/dist/okf/types/StateMachine.md +4 -0
- package/dist/okf/types/Subnet.md +4 -0
- package/dist/okf/types/Table.md +1 -0
- package/dist/okf/types/Type.md +3 -0
- package/dist/okf/types/Vpc.md +1 -0
- package/dist/op/activities/index.d.ts +6 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +109 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/cf-refs.ts +40 -0
- package/dist/rules/waw061.ts +67 -0
- package/dist/rules/waw062.ts +0 -0
- package/dist/rules/waw063.ts +0 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-aws-carve-terraform.md +26 -23
- package/package.json +3 -3
- package/src/api/read-client.test.ts +9 -0
- package/src/api/read-client.ts +15 -2
- package/src/composites/agentcore-agent.ts +35 -13
- package/src/composites/bucket-deployment.test.ts +84 -0
- package/src/composites/bucket-deployment.ts +153 -0
- package/src/composites/composites.test.ts +325 -0
- package/src/composites/dynamodb-table.test.ts +154 -0
- package/src/composites/dynamodb-table.ts +124 -0
- package/src/composites/ec2-instance-bundle.ts +134 -0
- package/src/composites/ecr-repository.test.ts +118 -0
- package/src/composites/ecr-repository.ts +117 -0
- package/src/composites/fargate-service.ts +19 -3
- package/src/composites/index.ts +21 -2
- package/src/composites/monitoring-stack.test.ts +130 -0
- package/src/composites/monitoring-stack.ts +164 -0
- package/src/composites/nlb-service.ts +117 -0
- package/src/composites/step-functions-workflow.ts +127 -0
- package/src/deep-observe.test.ts +201 -1
- package/src/deep-observe.ts +204 -0
- package/src/deep-topology.test.ts +392 -0
- package/src/deep-topology.ts +237 -0
- package/src/effect-receipt-row.test.ts +164 -0
- package/src/effect-receipt-row.ts +147 -0
- package/src/index.ts +28 -7
- package/src/lint/audit-catalog.ts +6 -0
- package/src/lint/post-synth/cf-refs.ts +40 -0
- package/src/lint/post-synth/index.ts +6 -0
- package/src/lint/post-synth/waw061.test.ts +99 -0
- package/src/lint/post-synth/waw061.ts +67 -0
- package/src/lint/post-synth/waw062.test.ts +109 -0
- package/src/lint/post-synth/waw062.ts +0 -0
- package/src/lint/post-synth/waw063.test.ts +220 -0
- package/src/lint/post-synth/waw063.ts +0 -0
- package/src/op/activities/index.ts +15 -0
- package/src/plugin.ts +41 -10
- package/src/receipt-store.test.ts +204 -0
- package/src/receipt-store.ts +315 -0
- package/src/serializer.ts +105 -2
- package/src/skills/chant-aws-carve-terraform.md +26 -23
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The aws `ReceiptStore` (#1835, epic #1703) — core's injectable receipt seam
|
|
3
|
+
* (#1834, `@intentius/chant/op/receipt-store`) implemented over SSM Parameter
|
|
4
|
+
* Store, plain `String`, at the path identity ./effect-receipt-row.ts derives
|
|
5
|
+
* from the ownership marker fields.
|
|
6
|
+
*
|
|
7
|
+
* The transport is the lexicon's own read/apply transport (./api/read-client,
|
|
8
|
+
* #1206), pointed at the SSM JSON API: `fetch`, SigV4 when credentials
|
|
9
|
+
* resolve, and the one endpoint-override rule (#1694) — the `endpoint`
|
|
10
|
+
* option, else `AWS_ENDPOINT_URL_SSM`, else `AWS_ENDPOINT_URL` — so a local
|
|
11
|
+
* emulator lane reads and writes receipts without any store-specific wiring.
|
|
12
|
+
*
|
|
13
|
+
* Write discipline (epic decision 3): `write` exists for the `effect()` step
|
|
14
|
+
* alone — the step's read-compare-run-write is the only path that reaches it,
|
|
15
|
+
* on success, last. `PutParameter` is `Type: "String"` always; the first
|
|
16
|
+
* write creates the parameter with the ownership tags, and a later write
|
|
17
|
+
* overwrites the value (SSM refuses `Overwrite` and `Tags` in one call, so
|
|
18
|
+
* tags ride creation only — they never change after).
|
|
19
|
+
*
|
|
20
|
+
* Identity: the parameter name needs `<stack>` and `<env>`, which the
|
|
21
|
+
* activity args deliberately do not carry (the `EffectReceiptRef` is
|
|
22
|
+
* identity-of-the-effect, not identity-of-the-deployment). The store resolves
|
|
23
|
+
* them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
|
|
24
|
+
* `chant run --env` sets) and the project's `ownership` block — the same
|
|
25
|
+
* fields that stamp markers (epic decision 4). Nothing resolving is an error,
|
|
26
|
+
* never a guessed segment.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { loadChantConfigUpward, resolveOwnershipStack } from "@intentius/chant/config";
|
|
30
|
+
import { ownershipEntries } from "@intentius/chant/ownership";
|
|
31
|
+
import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
|
|
32
|
+
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
33
|
+
import type { UnobservedReason } from "@intentius/chant/observation";
|
|
34
|
+
import {
|
|
35
|
+
AwsReadError,
|
|
36
|
+
requestHeaders,
|
|
37
|
+
serviceUrl,
|
|
38
|
+
withEndpointOverride,
|
|
39
|
+
type AwsCredentialSource,
|
|
40
|
+
type AwsReadClientOptions,
|
|
41
|
+
type AwsReadHttp,
|
|
42
|
+
} from "./api/read-client";
|
|
43
|
+
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
44
|
+
import { AWS_EFFECT_RECEIPT_ENTITY_TYPE, EFFECT_RECEIPTS_METADATA_KEY, receiptParameterName } from "./effect-receipt-row";
|
|
45
|
+
|
|
46
|
+
const SSM_SERVICE = "ssm";
|
|
47
|
+
const SSM_TARGET_PREFIX = "AmazonSSM";
|
|
48
|
+
|
|
49
|
+
/** Options for {@link awsReceiptStore}. All optional: the default store reads
|
|
50
|
+
* its identity from the project and its endpoint from the environment. */
|
|
51
|
+
export interface AwsReceiptStoreOptions {
|
|
52
|
+
/** The path's `<stack>` segment. Omitted, the project's `ownership.stack`
|
|
53
|
+
* (chant.config.ts, found upward from `cwd`) answers. */
|
|
54
|
+
stack?: string;
|
|
55
|
+
/** The path's `<env>` segment — explicit by decision 4. Omitted, `CHANT_ENV`
|
|
56
|
+
* (set by `chant run --env`) answers, then a literal `ownership.env`. */
|
|
57
|
+
environment?: string;
|
|
58
|
+
/** Where to look for chant.config.ts. Defaults to the working directory. */
|
|
59
|
+
cwd?: string;
|
|
60
|
+
/** Endpoint override; omitted, `AWS_ENDPOINT_URL[_SSM]` answers (#1694). */
|
|
61
|
+
endpoint?: string;
|
|
62
|
+
/** Region for the real-AWS host. */
|
|
63
|
+
region?: string;
|
|
64
|
+
/** Injectable HTTP, mirroring the read client's. Tests avoid the network. */
|
|
65
|
+
http?: AwsReadHttp;
|
|
66
|
+
/** Environment record the endpoint/credential/identity fallbacks read.
|
|
67
|
+
* Defaults to `process.env`; injectable for tests. */
|
|
68
|
+
env?: Record<string, string | undefined>;
|
|
69
|
+
/** What to sign with — same seam as the read client. */
|
|
70
|
+
credentials?: AwsCredentialSource;
|
|
71
|
+
/** Sign even against an endpoint override — for an override that is real AWS. */
|
|
72
|
+
signEndpointOverride?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One SSM JSON call. Exported for the observation leg (plugin.ts), which
|
|
76
|
+
* reads the same parameters the store writes. */
|
|
77
|
+
export async function ssmCall(
|
|
78
|
+
action: string,
|
|
79
|
+
payload: Record<string, unknown>,
|
|
80
|
+
options: AwsReadClientOptions,
|
|
81
|
+
): Promise<{ status: number; json: Record<string, unknown> }> {
|
|
82
|
+
const settled = withEndpointOverride(SSM_SERVICE, options);
|
|
83
|
+
const url = serviceUrl(SSM_SERVICE, settled.endpoint, settled.region);
|
|
84
|
+
const body = JSON.stringify(payload);
|
|
85
|
+
const base = {
|
|
86
|
+
"content-type": "application/x-amz-json-1.1",
|
|
87
|
+
"x-amz-target": `${SSM_TARGET_PREFIX}.${action}`,
|
|
88
|
+
};
|
|
89
|
+
const headers = requestHeaders(SSM_SERVICE, url, body, base, settled);
|
|
90
|
+
const http: AwsReadHttp =
|
|
91
|
+
settled.http ??
|
|
92
|
+
(async (u, init, signal) => {
|
|
93
|
+
const res = await fetch(u, { method: "POST", headers: init.headers, body: init.body, signal });
|
|
94
|
+
return { status: res.status, text: await res.text() };
|
|
95
|
+
});
|
|
96
|
+
const res = await http(url, { headers, body }, settled.signal);
|
|
97
|
+
let json: Record<string, unknown> = {};
|
|
98
|
+
try {
|
|
99
|
+
json = res.text ? (JSON.parse(res.text) as Record<string, unknown>) : {};
|
|
100
|
+
} catch {
|
|
101
|
+
if (res.status < 400) {
|
|
102
|
+
throw new AwsReadError(`SSM ${action}: unparseable response (status ${res.status})`, res.status);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { status: res.status, json };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The API's own error code from a JSON-protocol error body — `__type`, with
|
|
109
|
+
* any `namespace#` prefix stripped. */
|
|
110
|
+
export function ssmErrorCode(json: Record<string, unknown>): string | undefined {
|
|
111
|
+
const type = json.__type;
|
|
112
|
+
if (typeof type !== "string") return undefined;
|
|
113
|
+
return type.includes("#") ? type.slice(type.indexOf("#") + 1) : type;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ssmError(action: string, status: number, json: Record<string, unknown>): AwsReadError {
|
|
117
|
+
const code = ssmErrorCode(json);
|
|
118
|
+
const message = typeof json.message === "string" ? json.message : typeof json.Message === "string" ? json.Message : "";
|
|
119
|
+
return new AwsReadError(`SSM ${action} failed (status ${status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`, status, code);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `GetParameter` by name. Absent (`ParameterNotFound`) is `undefined` — a real
|
|
124
|
+
* answer, distinct from a failed read, which throws.
|
|
125
|
+
*/
|
|
126
|
+
export async function ssmGetParameter(
|
|
127
|
+
name: string,
|
|
128
|
+
options: AwsReadClientOptions = {},
|
|
129
|
+
): Promise<string | undefined> {
|
|
130
|
+
const { status, json } = await ssmCall("GetParameter", { Name: name }, options);
|
|
131
|
+
if (status >= 400) {
|
|
132
|
+
if (ssmErrorCode(json) === "ParameterNotFound") return undefined;
|
|
133
|
+
throw ssmError("GetParameter", status, json);
|
|
134
|
+
}
|
|
135
|
+
const value = (json.Parameter as { Value?: unknown } | undefined)?.Value;
|
|
136
|
+
return typeof value === "string" ? value : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `PutParameter`, plain `String`. Creation carries `tags`; an existing
|
|
141
|
+
* parameter is overwritten (`Overwrite: true`) without them — SSM refuses
|
|
142
|
+
* `Overwrite` and `Tags` in the same call, and ownership tags never change.
|
|
143
|
+
*/
|
|
144
|
+
export async function ssmPutParameter(
|
|
145
|
+
name: string,
|
|
146
|
+
value: string,
|
|
147
|
+
tags: Record<string, string>,
|
|
148
|
+
options: AwsReadClientOptions = {},
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const tagList = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
|
|
151
|
+
const create = await ssmCall(
|
|
152
|
+
"PutParameter",
|
|
153
|
+
{ Name: name, Value: value, Type: "String", ...(tagList.length > 0 ? { Tags: tagList } : {}) },
|
|
154
|
+
options,
|
|
155
|
+
);
|
|
156
|
+
if (create.status < 400) return;
|
|
157
|
+
if (ssmErrorCode(create.json) !== "ParameterAlreadyExists") {
|
|
158
|
+
throw ssmError("PutParameter", create.status, create.json);
|
|
159
|
+
}
|
|
160
|
+
const overwrite = await ssmCall(
|
|
161
|
+
"PutParameter",
|
|
162
|
+
{ Name: name, Value: value, Type: "String", Overwrite: true },
|
|
163
|
+
options,
|
|
164
|
+
);
|
|
165
|
+
if (overwrite.status >= 400) throw ssmError("PutParameter", overwrite.status, overwrite.json);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The resolved path identity plus the tags a creation stamps. */
|
|
169
|
+
interface ReceiptIdentity {
|
|
170
|
+
stack: string;
|
|
171
|
+
env: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function resolveIdentity(options: AwsReceiptStoreOptions): Promise<ReceiptIdentity> {
|
|
175
|
+
const processEnv = options.env ?? process.env;
|
|
176
|
+
let stack = options.stack;
|
|
177
|
+
let env = options.environment ?? processEnv.CHANT_ENV;
|
|
178
|
+
if (!stack || !env) {
|
|
179
|
+
let config;
|
|
180
|
+
try {
|
|
181
|
+
config = (await loadChantConfigUpward(options.cwd ?? process.cwd())).config;
|
|
182
|
+
} catch {
|
|
183
|
+
config = undefined;
|
|
184
|
+
}
|
|
185
|
+
if (config) {
|
|
186
|
+
stack = stack ?? resolveOwnershipStack(config);
|
|
187
|
+
// Only a literal env can answer here: a `{ param }` reference resolves
|
|
188
|
+
// per build, and an op run has no build parameters — `--env` does.
|
|
189
|
+
const configEnv = config.ownership?.env;
|
|
190
|
+
env = env ?? (typeof configEnv === "string" ? configEnv : undefined);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!stack) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
"aws receipt store: no stack identity — the receipt path is " +
|
|
196
|
+
"/chant-receipts/<stack>/<env>/<effect>, derived from the same ownership fields that stamp " +
|
|
197
|
+
"markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!env) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
"aws receipt store: no environment resolved — the receipt path's <env> segment is explicit " +
|
|
203
|
+
"(chant #1703, decision 4). Run with --env <name>, set CHANT_ENV, or set a literal " +
|
|
204
|
+
"ownership.env in chant.config.ts.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return { stack, env };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** What the observation leg learned about the declared receipt rows. */
|
|
211
|
+
export interface ReceiptRowObservation {
|
|
212
|
+
resources: Record<string, ResourceMetadata>;
|
|
213
|
+
unobserved: Record<string, { type: string; reason: UnobservedReason; detail: string }>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The plan-side live read of the receipt rows (#1835's observation leg).
|
|
218
|
+
*
|
|
219
|
+
* A receipt is not a stack member — the applier never writes it (#1832) — so
|
|
220
|
+
* `describe-stack-resources` honestly reports it absent even while the
|
|
221
|
+
* parameter exists. The serializer renders each receipt's derived path into
|
|
222
|
+
* the template's `Metadata` (./serializer.ts), so this leg reads the paths
|
|
223
|
+
* back from the build output — one derivation, decision 4 — and asks SSM
|
|
224
|
+
* `GetParameter` for each. Present maps the stored value onto
|
|
225
|
+
* `attributes.value` (core's `RECEIPT_VALUE_ATTRIBUTE`); `ParameterNotFound`
|
|
226
|
+
* is a real absence and stays one; a failed read is an `unobserved` hole,
|
|
227
|
+
* never a wrong answer — a receipt nobody could read must not arrive
|
|
228
|
+
* downstream as "the effect never ran".
|
|
229
|
+
*/
|
|
230
|
+
export async function observeReceiptRows(
|
|
231
|
+
entityNames: string[],
|
|
232
|
+
buildOutput: string,
|
|
233
|
+
options: AwsReadClientOptions = {},
|
|
234
|
+
): Promise<ReceiptRowObservation> {
|
|
235
|
+
const out: ReceiptRowObservation = { resources: {}, unobserved: {} };
|
|
236
|
+
let rows: Record<string, { Properties?: { Name?: unknown } }> | undefined;
|
|
237
|
+
try {
|
|
238
|
+
const template = JSON.parse(buildOutput) as { Metadata?: Record<string, unknown> };
|
|
239
|
+
const block = template.Metadata?.[EFFECT_RECEIPTS_METADATA_KEY];
|
|
240
|
+
if (typeof block === "object" && block !== null) {
|
|
241
|
+
rows = block as Record<string, { Properties?: { Name?: unknown } }>;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
rows = undefined; // Not a JSON template — no receipt rows to read.
|
|
245
|
+
}
|
|
246
|
+
if (!rows) return out;
|
|
247
|
+
|
|
248
|
+
for (const name of entityNames) {
|
|
249
|
+
const rendered = rows[name]?.Properties?.Name;
|
|
250
|
+
if (typeof rendered !== "string") continue;
|
|
251
|
+
try {
|
|
252
|
+
const value = await ssmGetParameter(rendered, options);
|
|
253
|
+
if (value === undefined) continue; // Confirmed absent — the effect has not stamped it yet.
|
|
254
|
+
out.resources[name] = {
|
|
255
|
+
type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
256
|
+
physicalId: rendered,
|
|
257
|
+
// Live outside the stack by design — the same word the identity
|
|
258
|
+
// fallback (#1647) uses for a resource CloudFormation does not hold.
|
|
259
|
+
status: "EXTERNAL",
|
|
260
|
+
// GetParameter returns no tags, so the marker channel is unreadable
|
|
261
|
+
// here; `unknown` is the total verdict, never a guess (#1089).
|
|
262
|
+
ownership: "unknown",
|
|
263
|
+
attributes: { value },
|
|
264
|
+
};
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const detail =
|
|
267
|
+
err instanceof AwsReadError && err.code
|
|
268
|
+
? `${err.code}: ${err.message}`
|
|
269
|
+
: String(err instanceof Error ? err.message : err);
|
|
270
|
+
const reason: UnobservedReason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail)
|
|
271
|
+
? "no-credentials"
|
|
272
|
+
: "read-failed";
|
|
273
|
+
out.unobserved[name] = {
|
|
274
|
+
type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
275
|
+
reason,
|
|
276
|
+
detail: `GetParameter failed for receipt "${rendered}": ${detail}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The `ReceiptStore` over SSM. Bind it once in the op activities barrel —
|
|
285
|
+
* `receiptActivities(awsReceiptStore())` — and the registry resolves
|
|
286
|
+
* `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
|
|
287
|
+
* `ensureSecret` (#1830). Identity and endpoint resolve lazily at first use,
|
|
288
|
+
* so module load never reads the project or the environment.
|
|
289
|
+
*/
|
|
290
|
+
export function awsReceiptStore(options: AwsReceiptStoreOptions = {}): ReceiptStore {
|
|
291
|
+
let identity: Promise<ReceiptIdentity> | undefined;
|
|
292
|
+
const identityOf = () => (identity ??= resolveIdentity(options));
|
|
293
|
+
|
|
294
|
+
const client = (): AwsReadClientOptions => ({
|
|
295
|
+
...(options.endpoint !== undefined ? { endpoint: options.endpoint } : {}),
|
|
296
|
+
...(options.region !== undefined ? { region: options.region } : {}),
|
|
297
|
+
...(options.http !== undefined ? { http: options.http } : {}),
|
|
298
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
299
|
+
...(options.credentials !== undefined ? { credentials: options.credentials } : {}),
|
|
300
|
+
...(options.signEndpointOverride !== undefined ? { signEndpointOverride: options.signEndpointOverride } : {}),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
async read(receipt: EffectReceiptRef): Promise<string | undefined> {
|
|
305
|
+
const { stack, env } = await identityOf();
|
|
306
|
+
return ssmGetParameter(receiptParameterName(stack, env, receipt.effect), client());
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
|
|
310
|
+
const { stack, env } = await identityOf();
|
|
311
|
+
const tags = ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, { stack, env });
|
|
312
|
+
await ssmPutParameter(receiptParameterName(stack, env, receipt.effect), expectation, tags, client());
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
package/src/serializer.ts
CHANGED
|
@@ -2,7 +2,19 @@ import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
|
|
|
2
2
|
import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
|
|
3
3
|
import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
|
|
4
4
|
import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership";
|
|
5
|
+
import {
|
|
6
|
+
isEffectReceipt,
|
|
7
|
+
receiptExpectation,
|
|
8
|
+
referenceInputPaths,
|
|
9
|
+
type EffectReceiptDeclaration,
|
|
10
|
+
} from "@intentius/chant/effect-receipt";
|
|
5
11
|
import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "./ownership";
|
|
12
|
+
import {
|
|
13
|
+
AWS_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
14
|
+
EFFECT_RECEIPTS_METADATA_KEY,
|
|
15
|
+
RECEIPT_UNRESOLVED_VALUE_NOTE,
|
|
16
|
+
receiptParameterName,
|
|
17
|
+
} from "./effect-receipt-row";
|
|
6
18
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
7
19
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
8
20
|
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
@@ -154,6 +166,80 @@ function toProperties(
|
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
|
|
169
|
+
/** One rendered receipt row — a CFN-resource-shaped object that lives in the
|
|
170
|
+
* template's `Metadata`, never in `Resources` (#1832: the applier writes from
|
|
171
|
+
* `Resources`, and the `effect()` step is a receipt's sole writer). */
|
|
172
|
+
interface ReceiptRow {
|
|
173
|
+
Type: typeof AWS_EFFECT_RECEIPT_ENTITY_TYPE;
|
|
174
|
+
Properties: {
|
|
175
|
+
Name: string;
|
|
176
|
+
Type: "String";
|
|
177
|
+
Value: string;
|
|
178
|
+
Tags: Array<{ Key: string; Value: string }>;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The rendered `Value`: the synthesis-time expectation when the receipt is
|
|
183
|
+
* fully static, a placeholder note when reference inputs resolve later —
|
|
184
|
+
* never a digest hashed over placeholders (epic #1703, decision 5). */
|
|
185
|
+
function receiptRowValue(receipt: EffectReceiptDeclaration): string {
|
|
186
|
+
if (receipt.flavor === "hash" && referenceInputPaths(receipt).length > 0) {
|
|
187
|
+
return RECEIPT_UNRESOLVED_VALUE_NOTE;
|
|
188
|
+
}
|
|
189
|
+
return receiptExpectation(receipt);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Render the effect receipts (#1835) the build withheld from the apply-bound
|
|
194
|
+
* entity set (`SerializeContext.receipts`, #1832) as `AWS::SSM::Parameter`
|
|
195
|
+
* rows: plain `String`, named `/chant-receipts/<stack>/<env>/<effect>` from
|
|
196
|
+
* the resolved ownership marker (epic decision 4 — the same fields that stamp
|
|
197
|
+
* tags), carrying the ownership tags. Visibility only: the rows go under the
|
|
198
|
+
* template's `Metadata`, and the receipt store (./receipt-store.ts) is what
|
|
199
|
+
* actually writes the parameter — through the `effect()` step, on success,
|
|
200
|
+
* last.
|
|
201
|
+
*
|
|
202
|
+
* The env segment is explicit: a receipt with no resolved `ownership.env` is
|
|
203
|
+
* an error here, never a guessed path.
|
|
204
|
+
*/
|
|
205
|
+
function renderReceiptRows(
|
|
206
|
+
receipts: ReadonlyMap<string, Declarable>,
|
|
207
|
+
ownership: OwnershipMarker | undefined,
|
|
208
|
+
): Record<string, ReceiptRow> {
|
|
209
|
+
const rows: Record<string, ReceiptRow> = {};
|
|
210
|
+
const names = [...receipts.keys()].join(", ");
|
|
211
|
+
if (!ownership) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`aws receipts (${names}): no ownership marker resolved — the receipt path is ` +
|
|
214
|
+
`/chant-receipts/<stack>/<env>/<effect>, derived from the same ownership fields that ` +
|
|
215
|
+
`stamp markers (chant #1703, decision 4). Set ownership: { stack, env } in chant.config.ts.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!ownership.env) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`aws receipts (${names}): ownership resolved no env — the receipt path's <env> segment is ` +
|
|
221
|
+
`explicit (chant #1703, decision 4). Set ownership.env in chant.config.ts (a literal, or ` +
|
|
222
|
+
`{ param: "env" } with --param env=<name>).`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const tags = Object.entries(ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership)).map(
|
|
226
|
+
([Key, Value]) => ({ Key, Value }),
|
|
227
|
+
);
|
|
228
|
+
for (const [name, entity] of receipts) {
|
|
229
|
+
if (!isEffectReceipt(entity)) continue;
|
|
230
|
+
rows[name] = {
|
|
231
|
+
Type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
232
|
+
Properties: {
|
|
233
|
+
Name: receiptParameterName(ownership.stack, ownership.env, entity.effect),
|
|
234
|
+
Type: "String",
|
|
235
|
+
Value: receiptRowValue(entity),
|
|
236
|
+
Tags: tags,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return rows;
|
|
241
|
+
}
|
|
242
|
+
|
|
157
243
|
/**
|
|
158
244
|
* Serialize a set of entities into a CFTemplate object (without JSON.stringify).
|
|
159
245
|
*/
|
|
@@ -163,6 +249,7 @@ function serializeToTemplate(
|
|
|
163
249
|
extraParameters?: Record<string, CFParameter>,
|
|
164
250
|
extraOutputs?: Record<string, CFOutput>,
|
|
165
251
|
ownership?: OwnershipMarker,
|
|
252
|
+
receiptRows?: Record<string, ReceiptRow>,
|
|
166
253
|
): CFTemplate {
|
|
167
254
|
const template: CFTemplate = {
|
|
168
255
|
AWSTemplateFormatVersion: "2010-09-09",
|
|
@@ -196,6 +283,14 @@ function serializeToTemplate(
|
|
|
196
283
|
[OWNERSHIP_METADATA_KEY]: ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership),
|
|
197
284
|
};
|
|
198
285
|
}
|
|
286
|
+
// Effect receipt rows (#1835) — visibility only, deliberately outside
|
|
287
|
+
// `Resources`: the applier's desired and prune sets both read `Resources`,
|
|
288
|
+
// and the `effect()` step is a receipt's sole writer (#1832, epic #1703
|
|
289
|
+
// decision 3). The observation leg (plugin.ts) reads the paths back from
|
|
290
|
+
// this block, so the identity is derived exactly once, here.
|
|
291
|
+
if (receiptRows && Object.keys(receiptRows).length > 0) {
|
|
292
|
+
template.Metadata = { ...(template.Metadata ?? {}), [EFFECT_RECEIPTS_METADATA_KEY]: receiptRows };
|
|
293
|
+
}
|
|
199
294
|
for (const [, entity] of entities) {
|
|
200
295
|
if (isDefaultTags(entity)) {
|
|
201
296
|
defaultTagEntries.push(...entity.tags);
|
|
@@ -412,6 +507,14 @@ export const awsSerializer: Serializer = {
|
|
|
412
507
|
serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[], context?: SerializeContext): string | SerializerResult {
|
|
413
508
|
const ownership = context?.ownership;
|
|
414
509
|
|
|
510
|
+
// Effect receipts (#1835): withheld from `entities` by the build (#1832),
|
|
511
|
+
// rendered here as Metadata rows — never as `Resources` an applier would
|
|
512
|
+
// write. Env-less ownership is an error, not a guessed path segment.
|
|
513
|
+
const receiptRows =
|
|
514
|
+
context?.receipts && context.receipts.size > 0
|
|
515
|
+
? renderReceiptRows(context.receipts, ownership)
|
|
516
|
+
: undefined;
|
|
517
|
+
|
|
415
518
|
// Check if any entities are child projects (nested stacks)
|
|
416
519
|
const childProjects = new Map<string, ChildProjectInstance>();
|
|
417
520
|
let hasChildProjects = false;
|
|
@@ -425,7 +528,7 @@ export const awsSerializer: Serializer = {
|
|
|
425
528
|
|
|
426
529
|
// No nested stacks — use the simple path
|
|
427
530
|
if (!hasChildProjects) {
|
|
428
|
-
const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership);
|
|
531
|
+
const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows);
|
|
429
532
|
return JSON.stringify(template, null, 2);
|
|
430
533
|
}
|
|
431
534
|
|
|
@@ -465,7 +568,7 @@ export const awsSerializer: Serializer = {
|
|
|
465
568
|
}
|
|
466
569
|
|
|
467
570
|
// Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
|
|
468
|
-
const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership);
|
|
571
|
+
const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows);
|
|
469
572
|
const primary = JSON.stringify(parentTemplate, null, 2);
|
|
470
573
|
|
|
471
574
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
skill: chant-aws-carve-terraform
|
|
3
|
-
description: Demo carving a resource out of Terraform into native chant — advise, emit, bridge, apply — fully offline
|
|
3
|
+
description: Demo carving a resource out of Terraform into native chant — advise, emit, audit, bridge, apply — fully offline
|
|
4
4
|
user-invocable: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ graduation.
|
|
|
15
15
|
## When to use
|
|
16
16
|
|
|
17
17
|
- Someone asks "how does chant move things off Terraform?"
|
|
18
|
-
- You want to show the advise → emit → bridge → apply loop end to end.
|
|
18
|
+
- You want to show the advise → emit → audit → bridge → apply loop end to end.
|
|
19
19
|
- You are evaluating whether a Terraform estate is worth carving.
|
|
20
20
|
|
|
21
21
|
## Preconditions
|
|
@@ -27,7 +27,7 @@ graduation.
|
|
|
27
27
|
## The fastest path: run the bundled demo
|
|
28
28
|
|
|
29
29
|
The `examples/terraform-carve-out` example ships a runnable estate + state and a
|
|
30
|
-
script that runs all
|
|
30
|
+
script that runs all five steps with commentary:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
cd examples/terraform-carve-out
|
|
@@ -54,28 +54,30 @@ From `examples/terraform-carve-out`, with `TF=./terraform`:
|
|
|
54
54
|
--state ./terraform/terraform.tfstate --output ./carveout
|
|
55
55
|
```
|
|
56
56
|
Show `./carveout/src/assets.ts` — a real `new Bucket({ BucketName, Tags })` with
|
|
57
|
-
CloudFormation-style properties mapped from the Terraform state attributes
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
CloudFormation-style properties mapped from the Terraform state attributes,
|
|
58
|
+
and the `aws_s3_bucket_versioning` sub-resource folded into
|
|
59
|
+
`VersioningConfiguration`. Emit also scaffolds `./carveout` into a buildable
|
|
60
|
+
chant project (`chant.config.ts`, `package.json`) and persists a carve
|
|
61
|
+
manifest (`*.carve.json`) — bridge and apply read the target from it, so
|
|
62
|
+
`--select` is only needed once. Explain: a Terraform-managed resource is not
|
|
63
|
+
in any CloudFormation stack, so the correct source of its live shape is the
|
|
64
|
+
state file, not a cloud read.
|
|
65
|
+
|
|
66
|
+
3. **Audit the inherited resource.**
|
|
65
67
|
```bash
|
|
66
|
-
chant
|
|
68
|
+
chant build ./carveout/src --lexicon aws
|
|
67
69
|
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
The first build fails, deliberately: the post-synth audit refuses the
|
|
71
|
+
adopted bucket because Terraform managed it without a public-access block or
|
|
72
|
+
a TLS-only policy. This is a feature of carving: chant immediately tells the
|
|
73
|
+
person what is wrong with what they adopted. Show the fix (add
|
|
74
|
+
`PublicAccessBlockConfiguration` and a companion `S3BucketPolicy` with an
|
|
75
|
+
`aws:SecureTransport = false` Deny — the tutorial page has the exact code),
|
|
76
|
+
then re-run the build and show the valid CloudFormation template.
|
|
75
77
|
|
|
76
78
|
4. **Bridge — patch the surviving Terraform.**
|
|
77
79
|
```bash
|
|
78
|
-
chant carve bridge --from ./terraform --
|
|
80
|
+
chant carve bridge --from ./terraform --output ./carveout
|
|
79
81
|
```
|
|
80
82
|
Show the generated `data "aws_s3_bucket" "assets"` and the rewired survivor.
|
|
81
83
|
Emphasize it is dry-run: nothing in `./terraform` changed. `--apply-rewrites`
|
|
@@ -83,11 +85,12 @@ From `examples/terraform-carve-out`, with `TF=./terraform`:
|
|
|
83
85
|
|
|
84
86
|
5. **Apply — graduation plan.**
|
|
85
87
|
```bash
|
|
86
|
-
chant carve apply --from ./terraform --
|
|
88
|
+
chant carve apply --from ./terraform --output ./carveout --env prod --stack assets
|
|
87
89
|
```
|
|
88
90
|
Show the ownership marker (`chant:managed-by/stack/env`) and the finalized
|
|
89
|
-
runbook.
|
|
90
|
-
|
|
91
|
+
runbook. `--write-source` additionally stamps the marker tags into the
|
|
92
|
+
emitted source. Stress this makes no cloud call — the apply is the person's
|
|
93
|
+
own lifecycle; chant just plans it.
|
|
91
94
|
|
|
92
95
|
## Key points to land
|
|
93
96
|
|