@intentius/chant-lexicon-k8s 0.57.0 → 0.59.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/fake-cluster.d.ts +7 -6
- package/dist/api/fake-cluster.d.ts.map +1 -1
- package/dist/api/sweep-types.d.ts +1 -2
- package/dist/api/sweep-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/cron-schedule.d.ts +26 -0
- package/dist/composites/cron-schedule.d.ts.map +1 -0
- package/dist/composites/cron-workload.d.ts +1 -1
- package/dist/composites/cron-workload.d.ts.map +1 -1
- package/dist/composites/operator-stack.d.ts +20 -19
- package/dist/composites/operator-stack.d.ts.map +1 -1
- package/dist/config-schema.d.ts +3 -0
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config.d.ts +26 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +4 -4
- package/dist/deep-observe.d.ts +23 -16
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/effect-receipt-row.d.ts +164 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/export-resources.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +10 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8505.d.ts +3 -1
- package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/WK8505.md +2 -2
- package/dist/okf/types/Kustomization.md +1 -1
- package/dist/op/activities/argo.d.ts +4 -4
- package/dist/op/activities/argo.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +15 -7
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +7 -7
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +6 -5
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/op/builders.d.ts +13 -13
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +113 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/wk8505.ts +4 -2
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-k8s-argo.md +27 -13
- package/dist/subscribe-changes.d.ts +84 -0
- package/dist/subscribe-changes.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/fake-cluster.ts +17 -4
- package/src/api/sweep-types.ts +1 -2
- package/src/codegen/docs.ts +7 -0
- package/src/composites/composites.test.ts +72 -3
- package/src/composites/cron-schedule.ts +47 -0
- package/src/composites/cron-workload.ts +4 -1
- package/src/composites/operator-stack.ts +25 -24
- package/src/config-schema.ts +5 -0
- package/src/config.ts +27 -4
- package/src/crd/crd-sources.ts +4 -4
- package/src/deep-observe-hooks.ts +4 -4
- package/src/deep-observe.test.ts +219 -46
- package/src/deep-observe.ts +54 -28
- package/src/describe-resources.ts +31 -7
- package/src/effect-receipt-row.test.ts +285 -0
- package/src/effect-receipt-row.ts +268 -0
- package/src/export-resources.ts +1 -1
- package/src/index.ts +22 -0
- package/src/lint/audit-catalog.ts +6 -2
- package/src/lint/audit-lineage.ts +127 -0
- package/src/lint/post-synth/wk8505.ts +4 -2
- package/src/op/activities/argo.test.ts +7 -8
- package/src/op/activities/argo.ts +4 -4
- package/src/op/activities/index.ts +23 -7
- package/src/op/activities/kubectl.test.ts +43 -4
- package/src/op/activities/kubectl.ts +67 -67
- package/src/op/activities/wait-for-ready.test.ts +2 -2
- package/src/op/activities/wait-for-ready.ts +7 -7
- package/src/op/builders.ts +13 -13
- package/src/plugin.ts +16 -1
- package/src/receipt-store.test.ts +380 -0
- package/src/receipt-store.ts +290 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +92 -1
- package/src/skills/chant-k8s-argo.md +27 -13
- package/src/subscribe-changes.test.ts +368 -0
- package/src/subscribe-changes.ts +210 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s `ReceiptStore` (#2074, epic #1703): core's injectable receipt seam
|
|
3
|
+
* (#1834, `@intentius/chant/op/receipt-store`) implemented over a core
|
|
4
|
+
* `ConfigMap`, at the address ./effect-receipt-row.ts derives from the
|
|
5
|
+
* ownership marker fields, plus the plan-side live read of the same rows.
|
|
6
|
+
*
|
|
7
|
+
* The transport is the lexicon's own typed client (./api/connect.ts), so a
|
|
8
|
+
* receipt read and write take the same environment-to-cluster binding
|
|
9
|
+
* (#1100/#1155), the same credential policy and the same field manager as
|
|
10
|
+
* every other k8s mutation. No `kubectl` binary is involved.
|
|
11
|
+
*
|
|
12
|
+
* Write discipline (epic decision 3): `write` exists for the `effect()` step
|
|
13
|
+
* alone, because the step's read-compare-run-write is the only path that reaches it,
|
|
14
|
+
* on success, last. It is a server-side apply as `chant:<stack>`, stamping the
|
|
15
|
+
* ownership marker labels and {@link RECEIPT_LABEL_KEY}, so a later write
|
|
16
|
+
* updates the value in place and the owned-only prune retains rather than
|
|
17
|
+
* deletes it (./op/activities/kubectl.ts).
|
|
18
|
+
*
|
|
19
|
+
* Identity: the ConfigMap name needs `<stack>` and `<env>`, which the activity
|
|
20
|
+
* args deliberately do not carry (the `EffectReceiptRef` is
|
|
21
|
+
* identity-of-the-effect, not identity-of-the-deployment). The store resolves
|
|
22
|
+
* them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
|
|
23
|
+
* `chant run --env` sets) and the project's `ownership` block, the same fields
|
|
24
|
+
* that stamp markers (epic decision 4). Nothing resolving is an error, never a
|
|
25
|
+
* guessed segment.
|
|
26
|
+
*
|
|
27
|
+
* Not exported from the package entry point. Like ./secret-store.ts and
|
|
28
|
+
* ./teardown.ts, this module names the API client, which must stay off the
|
|
29
|
+
* build path (chant #1074, examples/k8s-client-boundary.test.ts). Consumers
|
|
30
|
+
* reach it by subpath: `@intentius/chant-lexicon-k8s/receipt-store`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { loadChantConfigUpward, resolveOwnershipStack } from "@intentius/chant/config";
|
|
34
|
+
import {
|
|
35
|
+
LABEL_OWNERSHIP_KEYS,
|
|
36
|
+
ownershipEntries,
|
|
37
|
+
classifyOwnership,
|
|
38
|
+
readOwnership,
|
|
39
|
+
} from "@intentius/chant/ownership";
|
|
40
|
+
import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
|
|
41
|
+
import type { ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
42
|
+
import type { DeepResourceObservation } from "@intentius/chant/deep-observation";
|
|
43
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
44
|
+
import { defaultK8sConnector, type K8sConnector } from "./api/connect";
|
|
45
|
+
import { classifyApiFailure } from "./api/classify";
|
|
46
|
+
import {
|
|
47
|
+
K8S_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
48
|
+
RECEIPT_CONFIGMAP_REF,
|
|
49
|
+
RECEIPT_DATA_KEY,
|
|
50
|
+
RECEIPT_LABEL_KEY,
|
|
51
|
+
parseReceiptComment,
|
|
52
|
+
receiptConfigMapRef,
|
|
53
|
+
receiptNamespaceFrom,
|
|
54
|
+
type RenderedReceiptRow,
|
|
55
|
+
} from "./effect-receipt-row";
|
|
56
|
+
|
|
57
|
+
/** Options for {@link k8sReceiptStore}. All optional: the default store reads
|
|
58
|
+
* its identity and its namespace from the project, and its cluster from the
|
|
59
|
+
* environment binding. */
|
|
60
|
+
export interface K8sReceiptStoreOptions {
|
|
61
|
+
/** The name's `<stack>` segment. Omitted, the project's `ownership.stack`
|
|
62
|
+
* (chant.config.ts, found upward from `cwd`) answers. */
|
|
63
|
+
stack?: string;
|
|
64
|
+
/** The name's `<env>` segment, explicit by decision 4. Omitted, `CHANT_ENV`
|
|
65
|
+
* (set by `chant run --env`) answers, then a literal `ownership.env`. */
|
|
66
|
+
environment?: string;
|
|
67
|
+
/** Namespace the receipts live in. Omitted, `k8s.receipts.namespace`
|
|
68
|
+
* answers, then `default`. */
|
|
69
|
+
namespace?: string;
|
|
70
|
+
/** Where to look for chant.config.ts. Defaults to the working directory. */
|
|
71
|
+
cwd?: string;
|
|
72
|
+
/** Explicit kubectl context, for a caller that already resolved one. */
|
|
73
|
+
context?: string;
|
|
74
|
+
/** The connector to build a client with. Test seam. */
|
|
75
|
+
connect?: K8sConnector;
|
|
76
|
+
/** Environment record the identity fallback reads. Defaults to `process.env`. */
|
|
77
|
+
env?: Record<string, string | undefined>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The resolved name identity. */
|
|
81
|
+
interface ReceiptIdentity {
|
|
82
|
+
stack: string;
|
|
83
|
+
env: string;
|
|
84
|
+
namespace: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveIdentity(options: K8sReceiptStoreOptions): Promise<ReceiptIdentity> {
|
|
88
|
+
const processEnv = options.env ?? process.env;
|
|
89
|
+
let stack = options.stack;
|
|
90
|
+
let env = options.environment ?? processEnv.CHANT_ENV;
|
|
91
|
+
let namespace = options.namespace;
|
|
92
|
+
if (!stack || !env || !namespace) {
|
|
93
|
+
let config;
|
|
94
|
+
try {
|
|
95
|
+
config = (await loadChantConfigUpward(options.cwd ?? process.cwd())).config;
|
|
96
|
+
} catch {
|
|
97
|
+
config = undefined;
|
|
98
|
+
}
|
|
99
|
+
if (config) {
|
|
100
|
+
stack = stack ?? resolveOwnershipStack(config);
|
|
101
|
+
// Only a literal env can answer here: a `{ param }` reference resolves
|
|
102
|
+
// per build, and an op run has no build parameters, and `--env` does.
|
|
103
|
+
const configEnv = config.ownership?.env;
|
|
104
|
+
env = env ?? (typeof configEnv === "string" ? configEnv : undefined);
|
|
105
|
+
namespace = namespace ?? receiptNamespaceFrom(config as unknown as Record<string, unknown>);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!stack) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
"k8s receipt store: no stack identity. The receipt ConfigMap is named " +
|
|
111
|
+
"chant-receipt.<stack>.<env>.<effect>, derived from the same ownership fields that stamp " +
|
|
112
|
+
"markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (!env) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"k8s receipt store: no environment resolved. The receipt name's <env> segment is explicit " +
|
|
118
|
+
"(chant #1703, decision 4). Run with --env <name>, set CHANT_ENV, or set a literal " +
|
|
119
|
+
"ownership.env in chant.config.ts.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { stack, env, namespace: namespace ?? receiptNamespaceFrom(undefined) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The stored expectation on a live receipt ConfigMap, or undefined when the
|
|
126
|
+
* object holds none. */
|
|
127
|
+
export function receiptValueOf(object: K8sObject | undefined): string | undefined {
|
|
128
|
+
const data = (object as { data?: Record<string, unknown> } | undefined)?.data;
|
|
129
|
+
const value = data?.[RECEIPT_DATA_KEY];
|
|
130
|
+
return typeof value === "string" ? value : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The `ReceiptStore` over ConfigMaps. Bind it once in the op activities barrel
|
|
135
|
+
* as `receiptActivities(k8sReceiptStore())`, and the registry resolves
|
|
136
|
+
* `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
|
|
137
|
+
* `ensureSecret` (#1830). Identity and cluster resolve lazily at first use, so
|
|
138
|
+
* module load never reads the project or connects to anything.
|
|
139
|
+
*/
|
|
140
|
+
export function k8sReceiptStore(options: K8sReceiptStoreOptions = {}): ReceiptStore {
|
|
141
|
+
let identity: Promise<ReceiptIdentity> | undefined;
|
|
142
|
+
const identityOf = () => (identity ??= resolveIdentity(options));
|
|
143
|
+
|
|
144
|
+
let pending: Promise<K8sClient> | undefined;
|
|
145
|
+
const clientOf = (): Promise<K8sClient> =>
|
|
146
|
+
(pending ??= (options.connect ?? defaultK8sConnector)({
|
|
147
|
+
...(options.environment !== undefined ? { environment: options.environment } : {}),
|
|
148
|
+
...(options.context !== undefined ? { context: options.context } : {}),
|
|
149
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
150
|
+
}).then((connected) => connected.client));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
async read(receipt: EffectReceiptRef): Promise<string | undefined> {
|
|
154
|
+
const { stack, env, namespace } = await identityOf();
|
|
155
|
+
const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace);
|
|
156
|
+
const client = await clientOf();
|
|
157
|
+
const live = await client.readIfPresent({ ...RECEIPT_CONFIGMAP_REF, ...ref });
|
|
158
|
+
return receiptValueOf(live);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
|
|
162
|
+
const { stack, env, namespace } = await identityOf();
|
|
163
|
+
const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace);
|
|
164
|
+
const client = await clientOf();
|
|
165
|
+
const configMap: K8sObject = {
|
|
166
|
+
...RECEIPT_CONFIGMAP_REF,
|
|
167
|
+
metadata: {
|
|
168
|
+
...ref,
|
|
169
|
+
labels: {
|
|
170
|
+
...ownershipEntries(LABEL_OWNERSHIP_KEYS, { stack, env }),
|
|
171
|
+
[RECEIPT_LABEL_KEY]: receipt.effect,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
data: { [RECEIPT_DATA_KEY]: expectation },
|
|
175
|
+
} as K8sObject;
|
|
176
|
+
await client.apply(configMap, { fieldManager: `chant:${stack}` });
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What the observation leg learned about the declared receipt rows. */
|
|
182
|
+
export interface ReceiptRowObservation {
|
|
183
|
+
resources: Record<string, ResourceMetadata>;
|
|
184
|
+
unobserved: Record<string, UnobservedEntity>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The receipt rows a build output carries, keyed by entity name. Only the
|
|
188
|
+
* entities this observation was asked about. */
|
|
189
|
+
export function receiptRowsFor(
|
|
190
|
+
entityNames: readonly string[],
|
|
191
|
+
buildOutput: string | undefined,
|
|
192
|
+
): Map<string, RenderedReceiptRow> {
|
|
193
|
+
const rows = parseReceiptComment(buildOutput ?? "");
|
|
194
|
+
const wanted = new Set(entityNames);
|
|
195
|
+
const out = new Map<string, RenderedReceiptRow>();
|
|
196
|
+
for (const [name, row] of Object.entries(rows)) {
|
|
197
|
+
if (!wanted.has(name)) continue;
|
|
198
|
+
if (typeof row?.name !== "string" || typeof row?.namespace !== "string") continue;
|
|
199
|
+
out.set(name, row);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The plan-side live read of the receipt rows (#2074's observation leg).
|
|
206
|
+
*
|
|
207
|
+
* A receipt is not a document the applier ever wrote (#1832), and it carries
|
|
208
|
+
* no `props` on the declared side, so the generic declared-entity sweep in
|
|
209
|
+
* ./describe-resources.ts has neither a `metadata.name` to query by nor an
|
|
210
|
+
* honest verdict to give. The serializer rendered each receipt's derived
|
|
211
|
+
* ConfigMap address into the build output's receipt comment, so this leg reads
|
|
212
|
+
* the addresses back from there, one derivation, decision 4, and asks the
|
|
213
|
+
* cluster for each.
|
|
214
|
+
*
|
|
215
|
+
* Present maps the stored value onto `attributes.value` (core's
|
|
216
|
+
* `RECEIPT_VALUE_ATTRIBUTE`); a genuine 404 is a real absence and stays one; a
|
|
217
|
+
* failed read is an `unobserved` hole, never a wrong answer: a receipt nobody
|
|
218
|
+
* could read must not arrive downstream as "the effect never ran".
|
|
219
|
+
*/
|
|
220
|
+
export async function observeReceiptRows(
|
|
221
|
+
client: K8sClient,
|
|
222
|
+
rows: ReadonlyMap<string, RenderedReceiptRow>,
|
|
223
|
+
): Promise<ReceiptRowObservation> {
|
|
224
|
+
const out: ReceiptRowObservation = { resources: {}, unobserved: {} };
|
|
225
|
+
await client.concurrently([...rows], async ([entityName, row]) => {
|
|
226
|
+
try {
|
|
227
|
+
const live = await client.read({
|
|
228
|
+
...RECEIPT_CONFIGMAP_REF,
|
|
229
|
+
name: row.name,
|
|
230
|
+
namespace: row.namespace,
|
|
231
|
+
});
|
|
232
|
+
out.resources[entityName] = {
|
|
233
|
+
type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
234
|
+
physicalId: live.metadata?.uid,
|
|
235
|
+
// Live outside anything the applier wrote, by design. The same word
|
|
236
|
+
// the aws row's observation uses for a receipt parameter (#1835).
|
|
237
|
+
status: "EXTERNAL",
|
|
238
|
+
ownership: classifyOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
239
|
+
marker: readOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
240
|
+
attributes: {
|
|
241
|
+
namespace: row.namespace,
|
|
242
|
+
// Core's RECEIPT_VALUE_ATTRIBUTE, which is what `readReceiptValue` reads.
|
|
243
|
+
value: receiptValueOf(live) ?? "",
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const outcome = classifyApiFailure(err);
|
|
248
|
+
if (outcome.kind === "unobserved") {
|
|
249
|
+
out.unobserved[entityName] = {
|
|
250
|
+
type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
251
|
+
reason: outcome.reason,
|
|
252
|
+
detail: `reading receipt ConfigMap ${row.namespace}/${row.name}: ${outcome.detail}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// `absent` records nothing: in neither map is how the contract spells
|
|
256
|
+
// "asked, and it is not there", which is what the plan reads as "the
|
|
257
|
+
// effect has not fired for these inputs".
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The deep read's answer for the receipt rows.
|
|
265
|
+
*
|
|
266
|
+
* A receipt is read back here for the same reason the thin path reads it: a
|
|
267
|
+
* declared entity nobody looked at is a hole, and a hole in the deep read is
|
|
268
|
+
* noise on every `lifecycle diff --live --deep` a project with receipts runs.
|
|
269
|
+
* What it deliberately contributes is an EMPTY property tree: the declaration
|
|
270
|
+
* has no `props`, so every live path would land outside the claimed-field set
|
|
271
|
+
* (`@intentius/chant/claimed-fields`) and be reported unclaimed, and the
|
|
272
|
+
* receipt's stored value is not drift on any reading, because a stale receipt is an
|
|
273
|
+
* `effect` row from `planReceipts` (#1832), never an update. Presence and the
|
|
274
|
+
* uid are the whole of what the deep read has to say about a receipt.
|
|
275
|
+
*/
|
|
276
|
+
export async function observeReceiptRowsDeep(
|
|
277
|
+
client: K8sClient,
|
|
278
|
+
rows: ReadonlyMap<string, RenderedReceiptRow>,
|
|
279
|
+
): Promise<{ resources: Record<string, DeepResourceObservation>; unobserved: Record<string, UnobservedEntity> }> {
|
|
280
|
+
const thin = await observeReceiptRows(client, rows);
|
|
281
|
+
const resources: Record<string, DeepResourceObservation> = {};
|
|
282
|
+
for (const [name, meta] of Object.entries(thin.resources)) {
|
|
283
|
+
resources[name] = {
|
|
284
|
+
type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
285
|
+
...(meta.physicalId ? { physicalId: meta.physicalId } : {}),
|
|
286
|
+
properties: {},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return { resources, unobserved: thin.unobserved };
|
|
290
|
+
}
|
package/src/serializer.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { k8sSerializer } from "./serializer";
|
|
3
3
|
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
4
|
+
import { heldElsewhere } from "@intentius/chant";
|
|
4
5
|
import {
|
|
5
6
|
defaultLabels,
|
|
6
7
|
defaultAnnotations,
|
|
@@ -69,6 +70,31 @@ describe("k8sSerializer", () => {
|
|
|
69
70
|
expect(result).toContain("replicas: 2");
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
// #2162 — the honest example the issue names: an HPA owns `spec.replicas`
|
|
74
|
+
// on a Deployment after the first apply. `heldElsewhere()` omits the field
|
|
75
|
+
// from the manifest entirely, every apply, so the API server defaults it
|
|
76
|
+
// once at creation and the HPA is free to write it from there without
|
|
77
|
+
// chant's own apply fighting it back.
|
|
78
|
+
test("a heldElsewhere() replicas field never reaches the manifest", () => {
|
|
79
|
+
const entities = new Map<string, any>();
|
|
80
|
+
entities.set(
|
|
81
|
+
"myApp",
|
|
82
|
+
mockResource("K8s::Apps::Deployment", {
|
|
83
|
+
metadata: { name: "my-app", labels: { app: "my-app" } },
|
|
84
|
+
spec: {
|
|
85
|
+
replicas: heldElsewhere<number>({ by: "hpa", reason: "the autoscaler owns replicas after the first apply" }),
|
|
86
|
+
selector: { matchLabels: { app: "my-app" } },
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const result = k8sSerializer.serialize(entities);
|
|
92
|
+
expect(result).toContain("kind: Deployment");
|
|
93
|
+
expect(result).toContain("selector:");
|
|
94
|
+
expect(result).not.toContain("replicas");
|
|
95
|
+
expect(result).not.toContain("heldElsewhere");
|
|
96
|
+
});
|
|
97
|
+
|
|
72
98
|
test("metadata.name auto-generated from logical name (camelCase→kebab-case)", () => {
|
|
73
99
|
const entities = new Map<string, any>();
|
|
74
100
|
entities.set(
|
package/src/serializer.ts
CHANGED
|
@@ -10,6 +10,21 @@ import type { Declarable } from "@intentius/chant/declarable";
|
|
|
10
10
|
import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
|
|
11
11
|
import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
|
|
12
12
|
import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
13
|
+
import {
|
|
14
|
+
isEffectReceipt,
|
|
15
|
+
receiptExpectation,
|
|
16
|
+
referenceInputPaths,
|
|
17
|
+
type EffectReceiptDeclaration,
|
|
18
|
+
} from "@intentius/chant/effect-receipt";
|
|
19
|
+
import {
|
|
20
|
+
RECEIPT_CONFIGMAP_REF,
|
|
21
|
+
RECEIPT_DATA_KEY,
|
|
22
|
+
RECEIPT_UNRESOLVED_VALUE_NOTE,
|
|
23
|
+
receiptConfigMapRef,
|
|
24
|
+
receiptNamespaceFrom,
|
|
25
|
+
renderReceiptComment,
|
|
26
|
+
type RenderedReceiptRow,
|
|
27
|
+
} from "./effect-receipt-row";
|
|
13
28
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
14
29
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
15
30
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
@@ -180,6 +195,65 @@ function resolveK8sAttr(entity: Declarable | undefined, logicalName: string, att
|
|
|
180
195
|
);
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
/** The rendered expectation: the synthesis-time value when the receipt is
|
|
199
|
+
* fully static, the placeholder note when reference inputs remain (#1703
|
|
200
|
+
* decision 5, since synthesis resolves nothing). */
|
|
201
|
+
function receiptRowValue(receipt: EffectReceiptDeclaration): string {
|
|
202
|
+
if (receipt.flavor === "hash" && referenceInputPaths(receipt).length > 0) {
|
|
203
|
+
return RECEIPT_UNRESOLVED_VALUE_NOTE;
|
|
204
|
+
}
|
|
205
|
+
return receiptExpectation(receipt);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render the effect receipts (#2074) the build withheld from the apply-bound
|
|
210
|
+
* entity set (`SerializeContext.receipts`, #1832) as ConfigMap rows: named
|
|
211
|
+
* `chant-receipt.<stack>.<env>.<effect>` from the ownership marker fields
|
|
212
|
+
* (epic decision 4), in the project's receipt namespace, with the expectation
|
|
213
|
+
* under `data.expectation`.
|
|
214
|
+
*
|
|
215
|
+
* Visibility only. The rows ride a YAML comment at the end of the manifest
|
|
216
|
+
* stream (./effect-receipt-row.ts) rather than a document, because a document
|
|
217
|
+
* is what an applier applies and the `effect()` step is a receipt's sole
|
|
218
|
+
* writer (#1832, epic #1703 decision 3).
|
|
219
|
+
*
|
|
220
|
+
* The env segment is explicit: a receipt with no resolved `ownership.env` is
|
|
221
|
+
* an error, never a guessed segment.
|
|
222
|
+
*/
|
|
223
|
+
function renderReceiptRows(
|
|
224
|
+
receipts: ReadonlyMap<string, Declarable>,
|
|
225
|
+
ownership: { stack: string; env?: string } | undefined,
|
|
226
|
+
namespace: string,
|
|
227
|
+
): Record<string, RenderedReceiptRow> {
|
|
228
|
+
const rows: Record<string, RenderedReceiptRow> = {};
|
|
229
|
+
const names = [...receipts.keys()].join(", ");
|
|
230
|
+
if (!ownership?.stack) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`k8s receipts (${names}): no ownership marker resolved. The receipt ConfigMap is named ` +
|
|
233
|
+
`chant-receipt.<stack>.<env>.<effect>, derived from the same ownership fields that ` +
|
|
234
|
+
`stamp markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (!ownership.env) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`k8s receipts (${names}): ownership resolved no env. The receipt name's <env> segment is ` +
|
|
240
|
+
`explicit (chant #1703, decision 4). Set ownership: { env } in chant.config.ts, or ` +
|
|
241
|
+
`build with an env-valued parameter that resolves it.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
for (const [name, entity] of receipts) {
|
|
245
|
+
if (!isEffectReceipt(entity)) continue;
|
|
246
|
+
const ref = receiptConfigMapRef(ownership.stack, ownership.env, entity.effect, namespace);
|
|
247
|
+
rows[name] = {
|
|
248
|
+
kind: RECEIPT_CONFIGMAP_REF.kind,
|
|
249
|
+
namespace: ref.namespace,
|
|
250
|
+
name: ref.name,
|
|
251
|
+
data: { [RECEIPT_DATA_KEY]: receiptRowValue(entity) },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return rows;
|
|
255
|
+
}
|
|
256
|
+
|
|
183
257
|
/**
|
|
184
258
|
* K8s visitor for the generic serializer walker.
|
|
185
259
|
*/
|
|
@@ -416,7 +490,24 @@ export const k8sSerializer: Serializer = {
|
|
|
416
490
|
}
|
|
417
491
|
}
|
|
418
492
|
|
|
419
|
-
|
|
493
|
+
let primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
|
|
494
|
+
|
|
495
|
+
// Effect receipt rows (#2074): visibility only, deliberately NOT a
|
|
496
|
+
// document: appliers apply documents, and the `effect()` step is a
|
|
497
|
+
// receipt's sole writer (#1832, epic #1703 decision 3). The comment rides
|
|
498
|
+
// the same build output the observation leg is handed, which is how it
|
|
499
|
+
// learns each receipt's ConfigMap address.
|
|
500
|
+
if (context?.receipts && context.receipts.size > 0) {
|
|
501
|
+
const rows = renderReceiptRows(
|
|
502
|
+
context.receipts,
|
|
503
|
+
context.ownership,
|
|
504
|
+
receiptNamespaceFrom(context.config),
|
|
505
|
+
);
|
|
506
|
+
if (Object.keys(rows).length > 0) {
|
|
507
|
+
primary = primary.length > 0 ? `${primary}\n${renderReceiptComment(rows)}\n` : `${renderReceiptComment(rows)}\n`;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
420
511
|
// A bare string when there is nothing extra to write, so the common case
|
|
421
512
|
// stays byte-identical to what every existing consumer already reads.
|
|
422
513
|
if (Object.keys(files).length === 0 && warnings.length === 0) return primary;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
skill: chant-k8s-argo
|
|
3
|
-
description: Argo CD composites for GitOps reconciliation — ArgoAppFor, ArgoAppSetForRegions, AppProject scoping, cluster registration, and
|
|
3
|
+
description: Argo CD composites for GitOps reconciliation — ArgoAppFor, ArgoAppSetForRegions, AppProject scoping, cluster registration, and how a deploy splits between Argo and a chant Op
|
|
4
4
|
user-invocable: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -14,9 +14,9 @@ Chant authors typed infrastructure into manifests. Argo CD continuously reconcil
|
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| **Chant** | Authoring typed infra → manifests | the lexicons |
|
|
16
16
|
| **Argo CD** | Continuously reconciling declarative manifests (the apply layer) | `ArgoAppFor` / `ArgoAppSetForRegions` |
|
|
17
|
-
| **
|
|
17
|
+
| **A chant Op** | Procedural steps Argo can't express: ordering, human gates, one-shot RPCs | an `Op` in the project, plus this lexicon's `waitForArgoSync` |
|
|
18
18
|
|
|
19
|
-
Rule of thumb: **if it's declarative and converges, let Argo reconcile it. If it's a procedure with ordering, gates, or out-of-band steps,
|
|
19
|
+
Rule of thumb: **if it's declarative and converges, let Argo reconcile it. If it's a procedure with ordering, gates, or out-of-band steps, write it as a chant Op and run it from CI or a steward.** Prefer Argo CD over Argo Workflows; the procedural layer stays an Op.
|
|
20
20
|
|
|
21
21
|
## Prerequisites
|
|
22
22
|
|
|
@@ -138,9 +138,9 @@ Produces a `Secret` labelled `argocd.argoproj.io/secret-type: cluster`. After th
|
|
|
138
138
|
|
|
139
139
|
---
|
|
140
140
|
|
|
141
|
-
##
|
|
141
|
+
## Splitting a deploy between Argo and an Op
|
|
142
142
|
|
|
143
|
-
When a deploy has both declarative and procedural parts, let each layer own what it's good at. Example
|
|
143
|
+
When a deploy has both declarative and procedural parts, let each layer own what it's good at. Example, the multi-region CockroachDB deploy:
|
|
144
144
|
|
|
145
145
|
| Step | Owner | Why |
|
|
146
146
|
|---|---|---|
|
|
@@ -148,16 +148,30 @@ When a deploy has both declarative and procedural parts, let each layer own what
|
|
|
148
148
|
| Install ESO / operators (Helm) | **Argo** | Declarative Helm source |
|
|
149
149
|
| Apply per-cluster K8s manifests | **Argo** (`ApplicationSet`) | One App per workload cluster |
|
|
150
150
|
| Wait for workloads Healthy | **Argo** (`Health=Healthy`) | Subsumed by Application health |
|
|
151
|
-
| Wait for DNS delegation | **
|
|
152
|
-
| Generate + push TLS certs | **
|
|
153
|
-
| `cockroach init`, configure regions | **
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
| Wait for DNS delegation | **an Op** | Out of band, and a human confirms it |
|
|
152
|
+
| Generate + push TLS certs | **an Op** | One-shot procedure, secrets not in git |
|
|
153
|
+
| `cockroach init`, configure regions | **an Op** | Ordered one-shot RPCs |
|
|
154
|
+
|
|
155
|
+
Argo owns the sync. The Op owns the ordering and the gates: its phases run in
|
|
156
|
+
sequence in one process (`packages/core/src/op/local-executor.ts`), and a `gate`
|
|
157
|
+
step reads the gate ledger, so a run that reaches a gate nobody has approved
|
|
158
|
+
records the pending fact, ends with status `gated` and exits 3. Someone runs
|
|
159
|
+
`chant approve <op> <gate>`, the next run reads the resolution and walks
|
|
160
|
+
through. CI is what runs the Op, on whatever cadence the Op's `schedule`
|
|
161
|
+
names.
|
|
162
|
+
|
|
163
|
+
To make a step wait on Argo, use this lexicon's `waitForArgoSync` activity. It is
|
|
164
|
+
exported from `lexicons/k8s/src/op/activities/index.ts`, and the core activity
|
|
165
|
+
registry resolves it by export name once `k8s` is in the project's `lexicons`.
|
|
166
|
+
Give the step core's `argoSync` profile
|
|
167
|
+
(`packages/core/src/op/activity-profiles.ts`): a 30m timeout, five attempts
|
|
168
|
+
backing off from 10s, and `ArgoSyncFailedError` marked non-retryable so a
|
|
169
|
+
terminally unhealthy Application fails fast instead of polling to the cap.
|
|
156
170
|
|
|
157
171
|
```typescript
|
|
158
|
-
// In
|
|
159
|
-
|
|
160
|
-
//
|
|
172
|
+
// In an Op phase:
|
|
173
|
+
activity("waitForArgoSync", { appName: "east-crdb", namespace: "argocd" }, "argoSync"),
|
|
174
|
+
// Later steps in the phase run once the workloads are Healthy.
|
|
161
175
|
```
|
|
162
176
|
|
|
163
177
|
`waitForArgoSync` is dependency-free — it polls the Application's status (`health=Healthy && sync=Synced`) and never imports the Argo CRD types.
|