@intentius/behold 0.13.1 → 0.15.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/AGENTS.md +78 -7
- package/dist/cli.js +2310 -1874
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +144 -52
- package/example-carve/app/package-lock.json +9 -9
- package/example-carve/app/package.json +2 -2
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/package-lock.json +144 -52
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +3 -3
- package/web/app.js +124 -5
- package/web/index.html +24 -2
- package/web/operator.js +247 -14
- package/web/operator.test.js +188 -4
- package/web/release.js +66 -0
- package/web/release.test.js +52 -0
package/dist/cli.js
CHANGED
|
@@ -839,6 +839,31 @@ async function createK8sClient(options = {}) {
|
|
|
839
839
|
target: refText(ref)
|
|
840
840
|
});
|
|
841
841
|
}
|
|
842
|
+
async function selfSubjectReview(signal) {
|
|
843
|
+
for (const version of SELF_SUBJECT_REVIEW_VERSIONS) {
|
|
844
|
+
const path = `/apis/authentication.k8s.io/${version}/selfsubjectreviews`;
|
|
845
|
+
let response;
|
|
846
|
+
try {
|
|
847
|
+
response = await sendJson(path, "POST", {
|
|
848
|
+
body: JSON.stringify({ apiVersion: `authentication.k8s.io/${version}`, kind: "SelfSubjectReview" }),
|
|
849
|
+
signal,
|
|
850
|
+
target: `self subject review (${version})`
|
|
851
|
+
});
|
|
852
|
+
} catch (err) {
|
|
853
|
+
if (err instanceof K8sApiError && err.notFound) continue;
|
|
854
|
+
throw err;
|
|
855
|
+
}
|
|
856
|
+
const user2 = response.status?.userInfo;
|
|
857
|
+
if (user2?.username) {
|
|
858
|
+
return {
|
|
859
|
+
username: user2.username,
|
|
860
|
+
...user2.uid ? { uid: user2.uid } : {},
|
|
861
|
+
...user2.groups && user2.groups.length > 0 ? { groups: user2.groups } : {}
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return void 0;
|
|
866
|
+
}
|
|
842
867
|
async function resourcesOf(apiVersion, signal) {
|
|
843
868
|
const list4 = await apiResourceList(apiVersion, signal);
|
|
844
869
|
if (!list4) return [];
|
|
@@ -858,6 +883,7 @@ async function createK8sClient(options = {}) {
|
|
|
858
883
|
concurrently: (items, fn) => mapConcurrent(items, fn, concurrency),
|
|
859
884
|
resources: resourcesOf,
|
|
860
885
|
preferredGroupVersions,
|
|
886
|
+
selfSubjectReview,
|
|
861
887
|
discoveryCacheKeys: () => [...discoveryCache.keys()].sort()
|
|
862
888
|
};
|
|
863
889
|
}
|
|
@@ -874,7 +900,7 @@ function selectorText(selector) {
|
|
|
874
900
|
function refText(ref) {
|
|
875
901
|
return `${ref.apiVersion} ${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ""}${ref.name}`;
|
|
876
902
|
}
|
|
877
|
-
var clientNodeModule;
|
|
903
|
+
var clientNodeModule, SELF_SUBJECT_REVIEW_VERSIONS;
|
|
878
904
|
var init_client = __esm({
|
|
879
905
|
"node_modules/@intentius/chant-k8s-client/src/client.ts"() {
|
|
880
906
|
init_errors();
|
|
@@ -883,6 +909,7 @@ var init_client = __esm({
|
|
|
883
909
|
init_field_manager();
|
|
884
910
|
init_concurrency();
|
|
885
911
|
init_kubeconfig();
|
|
912
|
+
SELF_SUBJECT_REVIEW_VERSIONS = ["v1", "v1beta1", "v1alpha1"];
|
|
886
913
|
}
|
|
887
914
|
});
|
|
888
915
|
|
|
@@ -2283,1098 +2310,1713 @@ function joinCiProgress(ir, state) {
|
|
|
2283
2310
|
return ir;
|
|
2284
2311
|
}
|
|
2285
2312
|
|
|
2286
|
-
// src/
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
if (
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
case "unknown":
|
|
2309
|
-
default:
|
|
2310
|
-
return "neutral";
|
|
2313
|
+
// src/operator.ts
|
|
2314
|
+
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
2315
|
+
var strip = (s) => s.replace(ANSI, "");
|
|
2316
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2317
|
+
var HOW_TO_DECLARE = "An operating loop is a ConvergeOp: declare one in a `*.op.ts` (`ConvergeOp({ name, env, dial, rules })` from @intentius/chant-lexicon-temporal), build the project, and tick it with `chant operator` (or a cron / CronJob invoking it).";
|
|
2318
|
+
function readRow(value) {
|
|
2319
|
+
if (!isRecord(value)) return null;
|
|
2320
|
+
if (typeof value.op !== "string" || typeof value.env !== "string") return null;
|
|
2321
|
+
if (!Array.isArray(value.pendingGates)) return null;
|
|
2322
|
+
const gates = [];
|
|
2323
|
+
for (const g of value.pendingGates) {
|
|
2324
|
+
if (!isRecord(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
|
|
2325
|
+
gates.push({
|
|
2326
|
+
rule: g.rule,
|
|
2327
|
+
gate: g.gate,
|
|
2328
|
+
...typeof g.op === "string" ? { op: g.op } : {},
|
|
2329
|
+
// chant#2028's address, carried through verbatim and only when chant sent
|
|
2330
|
+
// one. The scheme check happens where the link is MINTED
|
|
2331
|
+
// ({@link approvalLink}), not here, so a row whose url behold won't link
|
|
2332
|
+
// still parses rather than refusing the whole strip.
|
|
2333
|
+
...typeof g.url === "string" && g.url ? { url: g.url } : {}
|
|
2334
|
+
});
|
|
2311
2335
|
}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
const
|
|
2336
|
+
const tick = isRecord(value.lastTick) ? value.lastTick : void 0;
|
|
2337
|
+
const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
|
|
2338
|
+
const lease = isRecord(value.lease) ? value.lease : void 0;
|
|
2315
2339
|
return {
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2340
|
+
op: value.op,
|
|
2341
|
+
env: value.env,
|
|
2342
|
+
...usable ? { lastTick: tick } : {},
|
|
2343
|
+
pendingGates: gates,
|
|
2344
|
+
...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
function readOperatorStatus(run3) {
|
|
2348
|
+
const err = strip(run3.stderr);
|
|
2349
|
+
const out = strip(run3.stdout).trim();
|
|
2350
|
+
if (run3.code !== 0) {
|
|
2351
|
+
const message = err.trim() || out || `chant operator status exited ${run3.code}`;
|
|
2352
|
+
const first = message.split("\n")[0].replace(/^error:\s*/i, "");
|
|
2353
|
+
if (/Unknown command|Unknown operator subcommand/i.test(message)) {
|
|
2320
2354
|
return {
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
reconciliation: row.reconciliation,
|
|
2327
|
-
detail: row.detail,
|
|
2328
|
-
...row.live !== void 0 ? { live: row.live } : {},
|
|
2329
|
-
...row.stack ? { stack: row.stack } : {},
|
|
2330
|
-
...row.resources ? { resources: row.resources } : {}
|
|
2331
|
-
}
|
|
2355
|
+
ok: false,
|
|
2356
|
+
refusal: {
|
|
2357
|
+
error: `This chant has no \`operator status\` \u2014 ${first}`,
|
|
2358
|
+
code: "no-operator-cli",
|
|
2359
|
+
remedy: "The operating loop's status surface landed in chant 0.52 (chant#1485). Upgrade chant to read it."
|
|
2332
2360
|
}
|
|
2333
2361
|
};
|
|
2334
|
-
})
|
|
2335
|
-
};
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
// src/overlay.ts
|
|
2339
|
-
function pruneImports(ir) {
|
|
2340
|
-
const drop = new Set((ir.imports ?? []).map((i) => i.node));
|
|
2341
|
-
if (drop.size === 0) return ir;
|
|
2342
|
-
ir.nodes = ir.nodes.filter((n) => !drop.has(n.id));
|
|
2343
|
-
if (ir.edges) ir.edges = ir.edges.filter((e) => !drop.has(e.from) && !drop.has(e.to));
|
|
2344
|
-
return ir;
|
|
2345
|
-
}
|
|
2346
|
-
function componentDir(node) {
|
|
2347
|
-
const parts = node.sourceLoc?.file?.split("/") ?? [];
|
|
2348
|
-
if (parts[0] !== "src" || parts[1] === "examples" || parts.length < 3) return void 0;
|
|
2349
|
-
return parts[1];
|
|
2350
|
-
}
|
|
2351
|
-
function reclassifyOverlay(ir) {
|
|
2352
|
-
const deployedComponents = /* @__PURE__ */ new Set();
|
|
2353
|
-
for (const n of ir.nodes) {
|
|
2354
|
-
if (n.attrs?._status === "good") {
|
|
2355
|
-
const c = componentDir(n);
|
|
2356
|
-
if (c) deployedComponents.add(c);
|
|
2357
2362
|
}
|
|
2363
|
+
return {
|
|
2364
|
+
ok: false,
|
|
2365
|
+
refusal: {
|
|
2366
|
+
error: `chant operator status failed: ${first}`,
|
|
2367
|
+
code: "operator-status",
|
|
2368
|
+
remedy: "Run `chant operator status --json` in the project to see the whole error."
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2358
2371
|
}
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2372
|
+
if (!out) {
|
|
2373
|
+
return {
|
|
2374
|
+
ok: false,
|
|
2375
|
+
refusal: {
|
|
2376
|
+
error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
|
|
2377
|
+
code: "no-operator",
|
|
2378
|
+
remedy: HOW_TO_DECLARE
|
|
2379
|
+
}
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
let parsed;
|
|
2383
|
+
try {
|
|
2384
|
+
parsed = JSON.parse(out);
|
|
2385
|
+
} catch (e) {
|
|
2386
|
+
return {
|
|
2387
|
+
ok: false,
|
|
2388
|
+
refusal: {
|
|
2389
|
+
error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
2390
|
+
code: "operator-status",
|
|
2391
|
+
remedy: "Run `chant operator status --json` in the project and check what it printed."
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
if (!Array.isArray(parsed)) {
|
|
2396
|
+
return {
|
|
2397
|
+
ok: false,
|
|
2398
|
+
refusal: {
|
|
2399
|
+
error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
|
|
2400
|
+
code: "operator-status",
|
|
2401
|
+
remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
|
|
2368
2402
|
}
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
2405
|
+
const rows = [];
|
|
2406
|
+
for (const value of parsed) {
|
|
2407
|
+
const row = readRow(value);
|
|
2408
|
+
if (!row) {
|
|
2409
|
+
return {
|
|
2410
|
+
ok: false,
|
|
2411
|
+
refusal: {
|
|
2412
|
+
error: "chant operator status --json answered with a row behold couldn't read (needs `op`, `env`, `pendingGates[]`).",
|
|
2413
|
+
code: "operator-status",
|
|
2414
|
+
remedy: "Check `chant operator status --json` in the project \u2014 and file the shape change if chant's is the newer one."
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2369
2417
|
}
|
|
2418
|
+
rows.push(row);
|
|
2370
2419
|
}
|
|
2371
|
-
return
|
|
2420
|
+
return { ok: true, rows };
|
|
2372
2421
|
}
|
|
2373
|
-
function
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2422
|
+
function declaredConvergeOps(ops) {
|
|
2423
|
+
return ops.filter((o) => o.searchAttributes?.Converge === "true").map((o) => ({
|
|
2424
|
+
name: o.name,
|
|
2425
|
+
...o.searchAttributes?.Env ? { env: o.searchAttributes.Env } : {},
|
|
2426
|
+
...o.searchAttributes?.Dial ? { dial: o.searchAttributes.Dial } : {}
|
|
2427
|
+
}));
|
|
2379
2428
|
}
|
|
2380
|
-
|
|
2381
|
-
|
|
2429
|
+
var MANAGED_BY = "app.kubernetes.io/managed-by";
|
|
2430
|
+
var COMPONENT = "app.kubernetes.io/component";
|
|
2431
|
+
var NAME_LABEL = "app.kubernetes.io/name";
|
|
2432
|
+
var CHANT = "chant";
|
|
2433
|
+
var CONVERGE_TICK = "converge-tick";
|
|
2434
|
+
var CRONJOB_KIND = "K8s::Batch::CronJob";
|
|
2435
|
+
var NAMESPACE_KIND = "K8s::Core::Namespace";
|
|
2436
|
+
var rec = (v) => isRecord(v) ? v : void 0;
|
|
2437
|
+
var str = (v) => typeof v === "string" && v ? v : void 0;
|
|
2438
|
+
var labelsOf = (n) => rec(rec(n.attrs?.metadata)?.labels) ?? {};
|
|
2439
|
+
function operatorHomes(ir) {
|
|
2440
|
+
const byNamespace = /* @__PURE__ */ new Map();
|
|
2441
|
+
const conflicted = /* @__PURE__ */ new Set();
|
|
2382
2442
|
for (const n of ir.nodes) {
|
|
2383
|
-
if (
|
|
2384
|
-
|
|
2385
|
-
|
|
2443
|
+
if (n.kind !== CRONJOB_KIND) continue;
|
|
2444
|
+
const labels = labelsOf(n);
|
|
2445
|
+
if (labels[MANAGED_BY] !== CHANT || labels[COMPONENT] !== CONVERGE_TICK) continue;
|
|
2446
|
+
const meta = rec(n.attrs?.metadata);
|
|
2447
|
+
const ns = str(meta?.namespace);
|
|
2448
|
+
const op = str(meta?.name);
|
|
2449
|
+
if (!ns || !op) continue;
|
|
2450
|
+
const home = byNamespace.get(ns) ?? { namespace: ns, ticks: [] };
|
|
2451
|
+
const stack = str(labels[NAME_LABEL]);
|
|
2452
|
+
if (stack && !conflicted.has(ns)) {
|
|
2453
|
+
if (home.stack === void 0) home.stack = stack;
|
|
2454
|
+
else if (home.stack !== stack) {
|
|
2455
|
+
conflicted.add(ns);
|
|
2456
|
+
delete home.stack;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
const schedule = str(rec(n.attrs?.spec)?.schedule);
|
|
2460
|
+
home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
|
|
2461
|
+
byNamespace.set(ns, home);
|
|
2386
2462
|
}
|
|
2387
|
-
if (
|
|
2388
|
-
const
|
|
2389
|
-
|
|
2390
|
-
|
|
2463
|
+
if (!byNamespace.size) return [];
|
|
2464
|
+
for (const n of ir.nodes) {
|
|
2465
|
+
if (n.kind !== NAMESPACE_KIND) continue;
|
|
2466
|
+
const name = str(rec(n.attrs?.metadata)?.name);
|
|
2467
|
+
const home = name ? byNamespace.get(name) : void 0;
|
|
2468
|
+
if (home && !home.node) home.node = n.id;
|
|
2391
2469
|
}
|
|
2392
|
-
|
|
2393
|
-
return
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
// src/value-match.ts
|
|
2397
|
-
function typeSegment(kind) {
|
|
2398
|
-
const parts = kind.split(kind.includes("::") ? "::" : "/");
|
|
2399
|
-
return parts[parts.length - 1] ?? kind;
|
|
2400
|
-
}
|
|
2401
|
-
function isOwnNameAttr(kind, key) {
|
|
2402
|
-
if (!/name$/i.test(key)) return false;
|
|
2403
|
-
const prefix = key.slice(0, -4).toLowerCase();
|
|
2404
|
-
if (prefix === "") return true;
|
|
2405
|
-
return typeSegment(kind).toLowerCase().includes(prefix);
|
|
2406
|
-
}
|
|
2407
|
-
var MIN_NAME_LEN = 6;
|
|
2408
|
-
var MAX_NAME_LEN = 200;
|
|
2409
|
-
var MAX_DEPTH = 12;
|
|
2410
|
-
var SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
2411
|
-
"status",
|
|
2412
|
-
"annotations",
|
|
2413
|
-
"labels",
|
|
2414
|
-
"matchLabels",
|
|
2415
|
-
"managedFields",
|
|
2416
|
-
"ownerReferences",
|
|
2417
|
-
"creationTimestamp",
|
|
2418
|
-
"resourceVersion",
|
|
2419
|
-
"uid",
|
|
2420
|
-
"selfLink",
|
|
2421
|
-
"generation",
|
|
2422
|
-
"finalizers"
|
|
2423
|
-
]);
|
|
2424
|
-
function joinable(v) {
|
|
2425
|
-
return v.length >= MIN_NAME_LEN && v.length <= MAX_NAME_LEN && !/\s/.test(v);
|
|
2470
|
+
for (const home of byNamespace.values()) home.ticks.sort((a, b) => a.op.localeCompare(b.op));
|
|
2471
|
+
return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
|
|
2426
2472
|
}
|
|
2427
|
-
function
|
|
2428
|
-
const
|
|
2429
|
-
|
|
2430
|
-
const
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2473
|
+
function markOperatorHome(ir) {
|
|
2474
|
+
const homes = operatorHomes(ir);
|
|
2475
|
+
if (!homes.length) return ir;
|
|
2476
|
+
const marks = /* @__PURE__ */ new Map();
|
|
2477
|
+
for (const home of homes) {
|
|
2478
|
+
if (home.node) {
|
|
2479
|
+
marks.set(home.node, {
|
|
2480
|
+
role: "home",
|
|
2481
|
+
namespace: home.namespace,
|
|
2482
|
+
...home.stack ? { stack: home.stack } : {},
|
|
2483
|
+
ticks: home.ticks.map((t) => t.op)
|
|
2484
|
+
});
|
|
2434
2485
|
}
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
step(val, k, path ? `${path}.${k}` : k, depth + 1);
|
|
2444
|
-
}
|
|
2486
|
+
for (const tick of home.ticks) {
|
|
2487
|
+
marks.set(tick.node, {
|
|
2488
|
+
role: "tick",
|
|
2489
|
+
namespace: home.namespace,
|
|
2490
|
+
...home.stack ? { stack: home.stack } : {},
|
|
2491
|
+
op: tick.op,
|
|
2492
|
+
...tick.schedule ? { schedule: tick.schedule } : {}
|
|
2493
|
+
});
|
|
2445
2494
|
}
|
|
2446
|
-
onPath.delete(v);
|
|
2447
|
-
};
|
|
2448
|
-
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
2449
|
-
if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
|
|
2450
|
-
step(v, k, k, 0);
|
|
2451
2495
|
}
|
|
2452
|
-
return
|
|
2496
|
+
return {
|
|
2497
|
+
...ir,
|
|
2498
|
+
nodes: ir.nodes.map((n) => {
|
|
2499
|
+
const mark = marks.get(n.id);
|
|
2500
|
+
return mark ? { ...n, attrs: { ...n.attrs, _operator: mark } } : n;
|
|
2501
|
+
})
|
|
2502
|
+
};
|
|
2453
2503
|
}
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
if (owner.size === 0) return ir;
|
|
2465
|
-
const declared = /* @__PURE__ */ new Set();
|
|
2466
|
-
for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
|
|
2467
|
-
const added = /* @__PURE__ */ new Set();
|
|
2468
|
-
for (const a of nodes) {
|
|
2469
|
-
for (const { path, value } of stringLeaves(a.attrs)) {
|
|
2470
|
-
if (!joinable(value)) continue;
|
|
2471
|
-
const b = owner.get(value);
|
|
2472
|
-
if (!b || b === a.id) continue;
|
|
2473
|
-
const key = `${a.id}\0${b}`;
|
|
2474
|
-
if (declared.has(key) || added.has(key)) continue;
|
|
2475
|
-
added.add(key);
|
|
2476
|
-
ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: path, inferred: true });
|
|
2477
|
-
}
|
|
2504
|
+
var OPERATOR_HOME_GLYPH = {
|
|
2505
|
+
body: '<path d="M16.5 6.63A7 7 0 1 1 6.64 7.5"/><path d="M16.5 6.63 14.3 5.9M16.5 6.63 15.9 8.1"/>'
|
|
2506
|
+
};
|
|
2507
|
+
function operatorHomeBoxMarks(ir, namespaceBoxes) {
|
|
2508
|
+
const out = {};
|
|
2509
|
+
for (const home of operatorHomes(ir)) {
|
|
2510
|
+
const box = namespaceBoxes[home.namespace];
|
|
2511
|
+
if (box) out[box] = OPERATOR_HOME_GLYPH;
|
|
2478
2512
|
}
|
|
2479
|
-
return
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
// src/k8s-edges.ts
|
|
2483
|
-
var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
|
|
2484
|
-
"K8s::Apps::Deployment",
|
|
2485
|
-
"K8s::Apps::StatefulSet",
|
|
2486
|
-
"K8s::Apps::DaemonSet",
|
|
2487
|
-
"K8s::Apps::ReplicaSet"
|
|
2488
|
-
]);
|
|
2489
|
-
var SELECTOR_CARRIERS = [
|
|
2490
|
-
{ kind: "K8s::Core::Service", read: (spec) => spec?.selector, viaAttr: "selector" },
|
|
2491
|
-
{ kind: "K8s::Policy::PodDisruptionBudget", read: (spec) => labelSelector(spec?.selector), viaAttr: "selector" },
|
|
2492
|
-
{ kind: "K8s::Networking::NetworkPolicy", read: (spec) => labelSelector(spec?.podSelector), viaAttr: "podSelector" }
|
|
2493
|
-
];
|
|
2494
|
-
function rec(v) {
|
|
2495
|
-
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
2496
|
-
}
|
|
2497
|
-
function str(v) {
|
|
2498
|
-
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
2513
|
+
return out;
|
|
2499
2514
|
}
|
|
2500
|
-
function
|
|
2501
|
-
|
|
2515
|
+
function operatorStrip(rows, now = Date.now()) {
|
|
2516
|
+
const out = rows.map((r) => {
|
|
2517
|
+
const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
|
|
2518
|
+
const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
|
|
2519
|
+
return {
|
|
2520
|
+
op: r.op,
|
|
2521
|
+
env: r.env,
|
|
2522
|
+
log: r.lastTick?.log ?? null,
|
|
2523
|
+
at: r.lastTick?.timestamp ?? null,
|
|
2524
|
+
tickId: typeof r.lastTick?.id === "string" && r.lastTick.id ? r.lastTick.id : null,
|
|
2525
|
+
lease,
|
|
2526
|
+
leaseHolder: r.lease?.holder ?? null,
|
|
2527
|
+
leaseExpiresAt: r.lease?.expiresAt ?? null,
|
|
2528
|
+
pendingGates: r.pendingGates.length
|
|
2529
|
+
};
|
|
2530
|
+
});
|
|
2531
|
+
return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
|
|
2502
2532
|
}
|
|
2503
|
-
|
|
2504
|
-
|
|
2533
|
+
var OPERATOR_INTERVAL_MS = 6e4;
|
|
2534
|
+
var TICK_VERDICT_TTL_MS = 15 * OPERATOR_INTERVAL_MS;
|
|
2535
|
+
function tickFreshness(at, now = Date.now()) {
|
|
2536
|
+
const t = at ? Date.parse(at) : NaN;
|
|
2537
|
+
if (!Number.isFinite(t)) return { ageMs: null, fresh: false };
|
|
2538
|
+
const age = now - t;
|
|
2539
|
+
if (age < 0) return { ageMs: null, fresh: false };
|
|
2540
|
+
return { ageMs: age, fresh: age <= TICK_VERDICT_TTL_MS };
|
|
2541
|
+
}
|
|
2542
|
+
var RECONCILIATIONS = /* @__PURE__ */ new Set(["reconciled", "unrecorded", "stale", "drifted", "unknown"]);
|
|
2543
|
+
function readVerdict(value) {
|
|
2544
|
+
if (!isRecord(value)) return null;
|
|
2545
|
+
if (typeof value.component !== "string" || !value.component) return null;
|
|
2546
|
+
if (typeof value.reconciliation !== "string" || !RECONCILIATIONS.has(value.reconciliation)) return null;
|
|
2547
|
+
const unobserved = isRecord(value.unobserved) && typeof value.unobserved.reason === "string" ? value.unobserved : void 0;
|
|
2548
|
+
return {
|
|
2549
|
+
component: value.component,
|
|
2550
|
+
reconciliation: value.reconciliation,
|
|
2551
|
+
detail: typeof value.detail === "string" ? value.detail : "",
|
|
2552
|
+
...typeof value.live === "boolean" ? { live: value.live } : {},
|
|
2553
|
+
...unobserved ? {
|
|
2554
|
+
unobserved: {
|
|
2555
|
+
reason: unobserved.reason,
|
|
2556
|
+
...typeof unobserved.detail === "string" ? { detail: unobserved.detail } : {}
|
|
2557
|
+
}
|
|
2558
|
+
} : {}
|
|
2559
|
+
};
|
|
2505
2560
|
}
|
|
2506
|
-
function
|
|
2507
|
-
const
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2561
|
+
function tickComponentVerdicts(rows) {
|
|
2562
|
+
const out = [];
|
|
2563
|
+
for (const row of rows) {
|
|
2564
|
+
const tick = row.lastTick;
|
|
2565
|
+
if (!tick || !Array.isArray(tick.components)) continue;
|
|
2566
|
+
const verdicts = [];
|
|
2567
|
+
for (const value of tick.components) {
|
|
2568
|
+
const verdict = readVerdict(value);
|
|
2569
|
+
if (verdict) verdicts.push(verdict);
|
|
2570
|
+
}
|
|
2571
|
+
out.push({
|
|
2572
|
+
op: row.op,
|
|
2573
|
+
env: tick.env || row.env,
|
|
2574
|
+
at: tick.timestamp,
|
|
2575
|
+
tickId: typeof tick.id === "string" && tick.id ? tick.id : null,
|
|
2576
|
+
verdicts
|
|
2577
|
+
});
|
|
2513
2578
|
}
|
|
2514
|
-
return
|
|
2579
|
+
return out;
|
|
2515
2580
|
}
|
|
2516
|
-
function
|
|
2517
|
-
const
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2581
|
+
function verdictsForEnv(all, env) {
|
|
2582
|
+
const when = (t) => {
|
|
2583
|
+
const ms = Date.parse(t.at);
|
|
2584
|
+
return Number.isFinite(ms) ? ms : -Infinity;
|
|
2585
|
+
};
|
|
2586
|
+
let best = null;
|
|
2587
|
+
for (const t of all) {
|
|
2588
|
+
if (t.env !== env) continue;
|
|
2589
|
+
if (!best || when(t) > when(best)) best = t;
|
|
2590
|
+
}
|
|
2591
|
+
return best;
|
|
2522
2592
|
}
|
|
2523
|
-
|
|
2524
|
-
|
|
2593
|
+
var CONVERGE_GATE_LOOP = "converge";
|
|
2594
|
+
var APPROVE_SEMANTICS = "Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
|
|
2595
|
+
function approvalLink(url) {
|
|
2596
|
+
if (!url) return void 0;
|
|
2597
|
+
try {
|
|
2598
|
+
const parsed = new URL(url);
|
|
2599
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : void 0;
|
|
2600
|
+
} catch {
|
|
2601
|
+
return void 0;
|
|
2602
|
+
}
|
|
2525
2603
|
}
|
|
2526
|
-
function
|
|
2527
|
-
const
|
|
2528
|
-
|
|
2604
|
+
function convergeGateCards(rows) {
|
|
2605
|
+
const out = [];
|
|
2606
|
+
for (const row of rows) {
|
|
2607
|
+
for (const g of row.pendingGates) {
|
|
2608
|
+
if (!g.op) continue;
|
|
2609
|
+
out.push({
|
|
2610
|
+
loop: CONVERGE_GATE_LOOP,
|
|
2611
|
+
rule: g.rule,
|
|
2612
|
+
op: g.op,
|
|
2613
|
+
gate: g.gate,
|
|
2614
|
+
convergeOp: row.op,
|
|
2615
|
+
env: row.env,
|
|
2616
|
+
since: row.lastTick?.timestamp ?? null,
|
|
2617
|
+
semantics: APPROVE_SEMANTICS,
|
|
2618
|
+
approve: {
|
|
2619
|
+
method: "POST",
|
|
2620
|
+
path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
|
|
2621
|
+
command: `chant approve ${g.op} ${g.gate}`
|
|
2622
|
+
},
|
|
2623
|
+
...approvalLink(g.url) ? { url: g.url } : {}
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
return out;
|
|
2529
2628
|
}
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2629
|
+
var OPERATOR_LOG_FLOOR = "0.53.1";
|
|
2630
|
+
var OPERATOR_LOG_LIMIT = 50;
|
|
2631
|
+
var OPERATOR_LOG_MAX_LIMIT = 200;
|
|
2632
|
+
function operatorLogWindow(raw) {
|
|
2633
|
+
let limit = OPERATOR_LOG_LIMIT;
|
|
2634
|
+
if (raw.limit !== void 0 && raw.limit !== null && raw.limit !== "") {
|
|
2635
|
+
const n = Number(raw.limit);
|
|
2636
|
+
if (!Number.isInteger(n) || n < 1) return { error: `limit must be a positive integer (got "${raw.limit}")` };
|
|
2637
|
+
limit = Math.min(n, OPERATOR_LOG_MAX_LIMIT);
|
|
2638
|
+
}
|
|
2639
|
+
if (raw.since !== void 0 && raw.since !== null && raw.since !== "") {
|
|
2640
|
+
if (Number.isNaN(new Date(raw.since).getTime())) {
|
|
2641
|
+
return { error: `since must be an ISO-8601 timestamp (got "${raw.since}")` };
|
|
2642
|
+
}
|
|
2643
|
+
return { limit, since: raw.since };
|
|
2644
|
+
}
|
|
2645
|
+
return { limit };
|
|
2533
2646
|
}
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
const kind = str(ref?.kind) ?? defaultKind;
|
|
2544
|
-
const name = str(ref?.name);
|
|
2545
|
-
if (!kind || !name) return void 0;
|
|
2546
|
-
return { kind, name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
|
|
2647
|
+
function operatorLogArgs(window) {
|
|
2648
|
+
return [
|
|
2649
|
+
"operator",
|
|
2650
|
+
"log",
|
|
2651
|
+
"--json",
|
|
2652
|
+
"--limit",
|
|
2653
|
+
String(window.limit),
|
|
2654
|
+
...window.since ? ["--since", window.since] : []
|
|
2655
|
+
];
|
|
2547
2656
|
}
|
|
2548
|
-
function
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
return { name, namespace: str(ref?.namespace) ?? namespaceOf(referrer) };
|
|
2657
|
+
function readTickRecord(value) {
|
|
2658
|
+
if (!isRecord(value)) return null;
|
|
2659
|
+
if (typeof value.op !== "string" || typeof value.timestamp !== "string" || typeof value.log !== "string") return null;
|
|
2660
|
+
return value;
|
|
2553
2661
|
}
|
|
2554
|
-
function
|
|
2555
|
-
|
|
2662
|
+
function readResolutionRecord(value) {
|
|
2663
|
+
if (!isRecord(value)) return null;
|
|
2664
|
+
const needed = ["op", "gate", "resolvedBy", "timestamp"];
|
|
2665
|
+
if (needed.some((k) => typeof value[k] !== "string")) return null;
|
|
2666
|
+
return value;
|
|
2556
2667
|
}
|
|
2557
|
-
function
|
|
2558
|
-
|
|
2668
|
+
function readOperatorLog(run3) {
|
|
2669
|
+
const err = strip(run3.stderr);
|
|
2670
|
+
const out = strip(run3.stdout).trim();
|
|
2671
|
+
if (run3.code !== 0) {
|
|
2672
|
+
const message = err.trim() || out || `chant operator log exited ${run3.code}`;
|
|
2673
|
+
const first = message.split("\n")[0].replace(/^error:\s*/i, "");
|
|
2674
|
+
if (/Unknown command|Unknown operator subcommand|Unknown flag/i.test(message)) {
|
|
2675
|
+
return {
|
|
2676
|
+
ok: false,
|
|
2677
|
+
refusal: {
|
|
2678
|
+
error: `This chant has no \`operator log\` \u2014 ${first}`,
|
|
2679
|
+
code: "no-operator-log-cli",
|
|
2680
|
+
remedy: `The converge tick history landed in chant ${OPERATOR_LOG_FLOOR} (chant#2029). Upgrade chant to read it; the strip above needs only 0.52.`
|
|
2681
|
+
}
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
return {
|
|
2685
|
+
ok: false,
|
|
2686
|
+
refusal: {
|
|
2687
|
+
error: `chant operator log failed: ${first}`,
|
|
2688
|
+
code: "operator-log",
|
|
2689
|
+
remedy: "Run `chant operator log --json` in the project to see the whole error."
|
|
2690
|
+
}
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
if (!out) {
|
|
2694
|
+
return {
|
|
2695
|
+
ok: false,
|
|
2696
|
+
refusal: {
|
|
2697
|
+
error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
|
|
2698
|
+
code: "no-operator",
|
|
2699
|
+
remedy: HOW_TO_DECLARE
|
|
2700
|
+
}
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
let parsed;
|
|
2704
|
+
try {
|
|
2705
|
+
parsed = JSON.parse(out);
|
|
2706
|
+
} catch (e) {
|
|
2707
|
+
return {
|
|
2708
|
+
ok: false,
|
|
2709
|
+
refusal: {
|
|
2710
|
+
error: `chant operator log --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
2711
|
+
code: "operator-log",
|
|
2712
|
+
remedy: "Run `chant operator log --json` in the project and check what it printed."
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
const unreadable = (why) => ({
|
|
2717
|
+
ok: false,
|
|
2718
|
+
refusal: {
|
|
2719
|
+
error: `chant operator log --json answered with something behold couldn't read: ${why}`,
|
|
2720
|
+
code: "operator-log",
|
|
2721
|
+
remedy: "behold reads `{entries: [{kind, timestamp, record}], malformed: {converge, gates}}`."
|
|
2722
|
+
}
|
|
2723
|
+
});
|
|
2724
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.entries)) return unreadable("no `entries` array");
|
|
2725
|
+
const entries = [];
|
|
2726
|
+
for (const value of parsed.entries) {
|
|
2727
|
+
if (!isRecord(value) || typeof value.timestamp !== "string") return unreadable("an entry with no timestamp");
|
|
2728
|
+
if (value.kind === "tick") {
|
|
2729
|
+
const record = readTickRecord(value.record);
|
|
2730
|
+
if (!record) return unreadable("a tick entry with no `op`/`timestamp`/`log`");
|
|
2731
|
+
entries.push({ kind: "tick", timestamp: value.timestamp, record });
|
|
2732
|
+
} else if (value.kind === "gate-resolution") {
|
|
2733
|
+
const record = readResolutionRecord(value.record);
|
|
2734
|
+
if (!record) return unreadable("a gate-resolution entry with no `op`/`gate`/`resolvedBy`/`timestamp`");
|
|
2735
|
+
entries.push({ kind: "gate-resolution", timestamp: value.timestamp, record });
|
|
2736
|
+
} else {
|
|
2737
|
+
return unreadable(`an entry of an unknown kind "${String(value.kind)}"`);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
const counts = isRecord(parsed.malformed) ? parsed.malformed : {};
|
|
2741
|
+
const count = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
|
|
2742
|
+
return { ok: true, entries, malformed: { converge: count(counts.converge), gates: count(counts.gates) } };
|
|
2559
2743
|
}
|
|
2560
|
-
function
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2744
|
+
function operatorTimeline(entries) {
|
|
2745
|
+
return entries.map((entry) => {
|
|
2746
|
+
if (entry.kind === "gate-resolution") {
|
|
2747
|
+
const r = entry.record;
|
|
2748
|
+
return {
|
|
2749
|
+
kind: "gate-resolution",
|
|
2750
|
+
at: r.timestamp,
|
|
2751
|
+
op: r.op,
|
|
2752
|
+
gate: r.gate,
|
|
2753
|
+
resolvedBy: r.resolvedBy,
|
|
2754
|
+
note: r.note ?? null,
|
|
2755
|
+
url: approvalLink(r.url) ?? null
|
|
2756
|
+
};
|
|
2572
2757
|
}
|
|
2758
|
+
const t = entry.record;
|
|
2759
|
+
return {
|
|
2760
|
+
kind: "tick",
|
|
2761
|
+
at: t.timestamp,
|
|
2762
|
+
op: t.op,
|
|
2763
|
+
env: typeof t.env === "string" ? t.env : null,
|
|
2764
|
+
id: typeof t.id === "string" && t.id ? t.id : null,
|
|
2765
|
+
log: t.log,
|
|
2766
|
+
gated: (Array.isArray(t.outcomes) ? t.outcomes : []).filter((o) => o && o.action === "gated" && typeof o.gateName === "string").map((o) => ({
|
|
2767
|
+
rule: o.ruleId,
|
|
2768
|
+
op: typeof o.op === "string" ? o.op : null,
|
|
2769
|
+
gate: o.gateName,
|
|
2770
|
+
url: approvalLink(o.url) ?? null
|
|
2771
|
+
}))
|
|
2772
|
+
};
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
function mergeOperatorLogs(answers, limit) {
|
|
2776
|
+
const malformed = answers.reduce(
|
|
2777
|
+
(acc, a) => ({ converge: acc.converge + a.malformed.converge, gates: acc.gates + a.malformed.gates }),
|
|
2778
|
+
{ converge: 0, gates: 0 }
|
|
2779
|
+
);
|
|
2780
|
+
if (answers.length === 1) {
|
|
2781
|
+
const only = answers[0].entries;
|
|
2782
|
+
return { entries: only.length > limit ? only.slice(-limit) : only, malformed };
|
|
2573
2783
|
}
|
|
2574
|
-
|
|
2784
|
+
const all = answers.flatMap((a) => a.entries).sort((a, b) => {
|
|
2785
|
+
const delta = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
|
2786
|
+
return delta !== 0 ? delta : a.kind === b.kind ? 0 : a.kind === "tick" ? -1 : 1;
|
|
2787
|
+
});
|
|
2788
|
+
return { entries: all.length > limit ? all.slice(-limit) : all, malformed };
|
|
2575
2789
|
}
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
if (!name) continue;
|
|
2589
|
-
byKindNsName.set(`${n.kind}\0${namespaceOf(n)}\0${name}`, n);
|
|
2590
|
-
if (n.kind === "K8s::Argo::AppProject") {
|
|
2591
|
-
const same = appProjectsByName.get(name) ?? [];
|
|
2592
|
-
same.push(n);
|
|
2593
|
-
appProjectsByName.set(name, same);
|
|
2594
|
-
}
|
|
2790
|
+
var initialOperatorState = {
|
|
2791
|
+
declared: [],
|
|
2792
|
+
strip: null,
|
|
2793
|
+
gates: [],
|
|
2794
|
+
verdicts: [],
|
|
2795
|
+
note: null,
|
|
2796
|
+
code: null,
|
|
2797
|
+
readAt: null
|
|
2798
|
+
};
|
|
2799
|
+
function operatorRead(state, result, at, now = Date.now()) {
|
|
2800
|
+
if (!result.ok) {
|
|
2801
|
+
return { ...state, strip: null, gates: [], verdicts: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
|
|
2595
2802
|
}
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
};
|
|
2605
|
-
const addSourceRef = (n, refObj, viaAttr, defaultKind) => addFluxRef(n, refObj, viaAttr, FLUX_SOURCE_KINDS, defaultKind);
|
|
2606
|
-
const addNamedRef = (n, refObj, kind, viaAttr) => {
|
|
2607
|
-
const ref = namedRefOf(refObj, n);
|
|
2608
|
-
if (!ref) return;
|
|
2609
|
-
const target = lookup(kind, ref.namespace, ref.name);
|
|
2610
|
-
if (target) add(n.id, target.id, viaAttr);
|
|
2611
|
-
};
|
|
2612
|
-
const addProject = (n, project, viaAttr) => {
|
|
2613
|
-
const candidates = project ? appProjectsByName.get(project) ?? [] : [];
|
|
2614
|
-
const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
|
|
2615
|
-
if (target) add(n.id, target.id, viaAttr);
|
|
2616
|
-
};
|
|
2617
|
-
const addSelector = (n, raw, viaAttr) => {
|
|
2618
|
-
const selector = labelRecord(raw);
|
|
2619
|
-
if (!selector) return;
|
|
2620
|
-
for (const w of workloads) {
|
|
2621
|
-
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
2622
|
-
const labels = templateLabels(w);
|
|
2623
|
-
if (labels && selects(selector, labels)) add(n.id, w.id, viaAttr);
|
|
2624
|
-
}
|
|
2803
|
+
return {
|
|
2804
|
+
...state,
|
|
2805
|
+
strip: operatorStrip(result.rows, now),
|
|
2806
|
+
gates: convergeGateCards(result.rows),
|
|
2807
|
+
verdicts: tickComponentVerdicts(result.rows),
|
|
2808
|
+
note: null,
|
|
2809
|
+
code: null,
|
|
2810
|
+
readAt: at
|
|
2625
2811
|
};
|
|
2626
|
-
for (const n of k8s) {
|
|
2627
|
-
const carrier = SELECTOR_CARRIERS.find((c) => c.kind === n.kind);
|
|
2628
|
-
if (carrier) {
|
|
2629
|
-
addSelector(n, carrier.read(rec(n.attrs?.spec)), carrier.viaAttr);
|
|
2630
|
-
continue;
|
|
2631
|
-
}
|
|
2632
|
-
if (n.kind === "K8s::Networking::Ingress") {
|
|
2633
|
-
for (const svcName of ingressBackendServices(n)) {
|
|
2634
|
-
const svc = lookup("K8s::Core::Service", namespaceOf(n), svcName);
|
|
2635
|
-
if (svc) add(n.id, svc.id, "ingress backend");
|
|
2636
|
-
}
|
|
2637
|
-
continue;
|
|
2638
|
-
}
|
|
2639
|
-
if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
|
|
2640
|
-
const ref = rec(rec(n.attrs?.spec)?.scaleTargetRef);
|
|
2641
|
-
const refKind = str(ref?.kind);
|
|
2642
|
-
const refName = str(ref?.name);
|
|
2643
|
-
if (!refKind || !refName) continue;
|
|
2644
|
-
for (const w of workloads) {
|
|
2645
|
-
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
2646
|
-
if (shortKind(w.kind) === refKind && nameOf(w) === refName) add(n.id, w.id, "scaleTargetRef");
|
|
2647
|
-
}
|
|
2648
|
-
continue;
|
|
2649
|
-
}
|
|
2650
|
-
if (n.kind === "K8s::Flux::Kustomization") {
|
|
2651
|
-
addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef");
|
|
2652
|
-
for (const dep of list(rec(n.attrs?.spec)?.dependsOn)) {
|
|
2653
|
-
addNamedRef(n, dep, "K8s::Flux::Kustomization", "dependsOn");
|
|
2654
|
-
}
|
|
2655
|
-
continue;
|
|
2656
|
-
}
|
|
2657
|
-
if (n.kind === "K8s::Flux::HelmRelease") {
|
|
2658
|
-
addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
|
|
2659
|
-
continue;
|
|
2660
|
-
}
|
|
2661
|
-
if (n.kind === "K8s::Flux::ImagePolicy") {
|
|
2662
|
-
addNamedRef(n, rec(n.attrs?.spec)?.imageRepositoryRef, "K8s::Flux::ImageRepository", "imageRepositoryRef");
|
|
2663
|
-
continue;
|
|
2664
|
-
}
|
|
2665
|
-
if (n.kind === "K8s::Flux::ImageUpdateAutomation") {
|
|
2666
|
-
addSourceRef(n, rec(n.attrs?.spec)?.sourceRef, "sourceRef", "GitRepository");
|
|
2667
|
-
continue;
|
|
2668
|
-
}
|
|
2669
|
-
if (n.kind === "K8s::Flux::Alert") {
|
|
2670
|
-
addNamedRef(n, rec(n.attrs?.spec)?.providerRef, "K8s::Flux::Provider", "providerRef");
|
|
2671
|
-
for (const src of list(rec(n.attrs?.spec)?.eventSources)) {
|
|
2672
|
-
addFluxRef(n, src, "eventSource");
|
|
2673
|
-
}
|
|
2674
|
-
continue;
|
|
2675
|
-
}
|
|
2676
|
-
if (n.kind === "K8s::Argo::Application") {
|
|
2677
|
-
addProject(n, str(rec(n.attrs?.spec)?.project), "project");
|
|
2678
|
-
continue;
|
|
2679
|
-
}
|
|
2680
|
-
if (n.kind === "K8s::Argo::ApplicationSet") {
|
|
2681
|
-
addProject(n, str(rec(rec(rec(n.attrs?.spec)?.template)?.spec)?.project), "template project");
|
|
2682
|
-
}
|
|
2683
|
-
}
|
|
2684
|
-
return out;
|
|
2685
2812
|
}
|
|
2686
|
-
function
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
const key = `${e.from}\0${e.to}`;
|
|
2690
|
-
if (existing.has(key)) continue;
|
|
2691
|
-
existing.add(key);
|
|
2692
|
-
ir.edges.push(e);
|
|
2813
|
+
function operatorNote(state, now = Date.now()) {
|
|
2814
|
+
if (!state.declared.length) {
|
|
2815
|
+
return "No operating loop: this project declares no ConvergeOp, so there is no operator strip.";
|
|
2693
2816
|
}
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
// src/helm-artifacts.ts
|
|
2698
|
-
function chartNameOf(node) {
|
|
2699
|
-
const name = node.attrs?.name;
|
|
2700
|
-
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
2701
|
-
}
|
|
2702
|
-
function releaseMatchesChart(observed, chartName) {
|
|
2703
|
-
const chart = observed.attributes?.chart;
|
|
2704
|
-
if (typeof chart !== "string") return false;
|
|
2705
|
-
return chart === chartName || chart.startsWith(`${chartName}-`);
|
|
2706
|
-
}
|
|
2707
|
-
function applyHelmArtifacts(ir, observed) {
|
|
2708
|
-
let charts = 0;
|
|
2709
|
-
let installed = 0;
|
|
2710
|
-
for (const node of ir.nodes) {
|
|
2711
|
-
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
2712
|
-
charts++;
|
|
2713
|
-
const attrs = node.attrs ??= {};
|
|
2714
|
-
if (observed === void 0) {
|
|
2715
|
-
attrs._status = "neutral";
|
|
2716
|
-
attrs._unobserved = "artifact-observation-unavailable";
|
|
2717
|
-
continue;
|
|
2718
|
-
}
|
|
2719
|
-
const chartName = chartNameOf(node);
|
|
2720
|
-
const entry = chartName ? Object.entries(observed).find(([key2, o2]) => key2.startsWith("release/") && releaseMatchesChart(o2, chartName)) : void 0;
|
|
2721
|
-
if (!entry) {
|
|
2722
|
-
attrs._status = "accent";
|
|
2723
|
-
continue;
|
|
2724
|
-
}
|
|
2725
|
-
const [key, o] = entry;
|
|
2726
|
-
const match = {
|
|
2727
|
-
release: key.replace(/^release\//, ""),
|
|
2728
|
-
...o.status ? { status: o.status } : {},
|
|
2729
|
-
...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
|
|
2730
|
-
...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
|
|
2731
|
-
};
|
|
2732
|
-
attrs._artifact = match;
|
|
2733
|
-
attrs._status = o.status === "deployed" ? "good" : "warn";
|
|
2734
|
-
if (o.status === "deployed") installed++;
|
|
2817
|
+
const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
|
|
2818
|
+
if (!state.strip) {
|
|
2819
|
+
return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
|
|
2735
2820
|
}
|
|
2736
|
-
|
|
2821
|
+
const bits = [];
|
|
2822
|
+
const ticked = state.strip.rows.filter((r) => r.at).length;
|
|
2823
|
+
bits.push(
|
|
2824
|
+
ticked ? `last tick per loop \u2014 the status surface answers with one tick each, and the history is its own read (chant#2029) reached from the strip` : `no tick recorded yet on any loop`
|
|
2825
|
+
);
|
|
2826
|
+
const named = state.verdicts.reduce((n, t) => n + t.verdicts.length, 0);
|
|
2827
|
+
if (named) {
|
|
2828
|
+
const stale = state.verdicts.filter((t) => t.verdicts.length && !tickFreshness(t.at, now).fresh);
|
|
2829
|
+
const painted = named - stale.reduce((n, t) => n + t.verdicts.length, 0);
|
|
2830
|
+
bits.push(
|
|
2831
|
+
painted ? `${painted} component verdict${painted === 1 ? "" : "s"} from that tick \u2014 joined onto the component DAG by component name, under the live read (never over it) and only while the tick is younger than ${Math.round(TICK_VERDICT_TTL_MS / 6e4)}m` : `${named} component verdict${named === 1 ? "" : "s"} from that tick, all older than ${Math.round(TICK_VERDICT_TTL_MS / 6e4)}m \u2014 named on the node, not painted: a colour would read as the estate's state now`
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
2834
|
+
const held = state.strip.rows.filter((r) => r.lease === "held").length;
|
|
2835
|
+
if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
|
|
2836
|
+
const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
|
|
2837
|
+
if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
|
|
2838
|
+
if (state.strip.pendingGates) {
|
|
2839
|
+
const addressed = state.gates.filter((g) => g.url).length;
|
|
2840
|
+
bits.push(
|
|
2841
|
+
`${state.strip.pendingGates} converge gate${state.strip.pendingGates === 1 ? "" : "s"} pending \u2014 approving one records a fact for the next tick, it does not release a workflow (that is the run gate, a different card)` + (addressed ? `, and ${addressed} name${addressed === 1 ? "s" : ""} where approval happens (chant#2028)` : "")
|
|
2842
|
+
);
|
|
2843
|
+
}
|
|
2844
|
+
return `Operating loop: ${loops} \u2014 ${bits.join("; ")}.`;
|
|
2737
2845
|
}
|
|
2738
2846
|
|
|
2739
|
-
// src/
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2847
|
+
// src/component-status.ts
|
|
2848
|
+
function componentStatusColor(row) {
|
|
2849
|
+
if (row.stack && row.stack.healthy === false) {
|
|
2850
|
+
if (row.stack.status && /ROLLBACK|FAILED/i.test(row.stack.status)) return "warn";
|
|
2851
|
+
return "accent";
|
|
2852
|
+
}
|
|
2853
|
+
const rollup = row.resources;
|
|
2854
|
+
if (rollup && rollup.total > 0) {
|
|
2855
|
+
if (rollup.unobserved > 0) return "accent";
|
|
2856
|
+
if (rollup.present === rollup.total) return "good";
|
|
2857
|
+
if (rollup.present === 0) return "neutral";
|
|
2858
|
+
return "warn";
|
|
2859
|
+
}
|
|
2860
|
+
if (row.live !== void 0) return row.live ? "good" : "neutral";
|
|
2861
|
+
switch (row.reconciliation) {
|
|
2862
|
+
case "reconciled":
|
|
2863
|
+
return "good";
|
|
2864
|
+
case "unrecorded":
|
|
2865
|
+
return row.detail.startsWith("live") ? "good" : "neutral";
|
|
2866
|
+
case "stale":
|
|
2867
|
+
case "drifted":
|
|
2868
|
+
return "warn";
|
|
2869
|
+
case "unknown":
|
|
2870
|
+
default:
|
|
2871
|
+
return "neutral";
|
|
2872
|
+
}
|
|
2753
2873
|
}
|
|
2754
|
-
function
|
|
2755
|
-
|
|
2756
|
-
const h = createHash("sha1");
|
|
2757
|
-
let any = false;
|
|
2758
|
-
const walk = (at) => {
|
|
2759
|
-
const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
2760
|
-
for (const e of entries) {
|
|
2761
|
-
if (SKIP.has(e.name)) continue;
|
|
2762
|
-
const path = join6(at, e.name);
|
|
2763
|
-
if (e.isDirectory()) {
|
|
2764
|
-
walk(path);
|
|
2765
|
-
continue;
|
|
2766
|
-
}
|
|
2767
|
-
if (!e.isFile()) continue;
|
|
2768
|
-
const st = statSync2(path);
|
|
2769
|
-
h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
|
|
2770
|
-
`);
|
|
2771
|
-
any = true;
|
|
2772
|
-
}
|
|
2773
|
-
};
|
|
2874
|
+
function httpUrl(url) {
|
|
2875
|
+
if (typeof url !== "string" || !url) return void 0;
|
|
2774
2876
|
try {
|
|
2775
|
-
|
|
2877
|
+
const p = new URL(url);
|
|
2878
|
+
return p.protocol === "http:" || p.protocol === "https:" ? url : void 0;
|
|
2776
2879
|
} catch {
|
|
2777
2880
|
return void 0;
|
|
2778
2881
|
}
|
|
2779
|
-
return any ? h.digest("hex") : void 0;
|
|
2780
|
-
}
|
|
2781
|
-
function cacheKey(dir, opts) {
|
|
2782
|
-
const chant = resolveChant(dir);
|
|
2783
|
-
const canonical = JSON.stringify(opts, Object.keys(opts).sort());
|
|
2784
|
-
return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
|
|
2785
2882
|
}
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2883
|
+
function releaseOrigin(rec6) {
|
|
2884
|
+
const origin = rec6.runOrigin;
|
|
2885
|
+
if (origin && typeof origin.forge === "string") {
|
|
2886
|
+
const url = httpUrl(origin.url);
|
|
2887
|
+
return {
|
|
2888
|
+
forge: origin.forge,
|
|
2889
|
+
originSource: "record",
|
|
2890
|
+
...origin.repo ? { repo: origin.repo } : {},
|
|
2891
|
+
...url ? { url } : {}
|
|
2892
|
+
};
|
|
2793
2893
|
}
|
|
2794
|
-
|
|
2795
|
-
for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
|
|
2796
|
-
}
|
|
2797
|
-
async function memberIr(dir, opts = {}) {
|
|
2798
|
-
const size = memberIrCacheSize();
|
|
2799
|
-
if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
|
|
2800
|
-
const stamp = memberSourceStamp(dir);
|
|
2801
|
-
const key = cacheKey(dir, opts);
|
|
2802
|
-
const hit = stamp !== void 0 ? cache.get(key) : void 0;
|
|
2803
|
-
if (hit && hit.stamp === stamp) {
|
|
2804
|
-
hits++;
|
|
2805
|
-
cache.delete(key);
|
|
2806
|
-
cache.set(key, hit);
|
|
2807
|
-
return structuredClone(hit.ir);
|
|
2808
|
-
}
|
|
2809
|
-
misses++;
|
|
2810
|
-
const ir = await graphIr(dir, opts);
|
|
2811
|
-
if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
|
|
2812
|
-
cache.delete(key);
|
|
2813
|
-
cache.set(key, { dir: resolve2(dir), stamp, ir });
|
|
2814
|
-
for (const oldest of cache.keys()) {
|
|
2815
|
-
if (cache.size <= size) break;
|
|
2816
|
-
cache.delete(oldest);
|
|
2817
|
-
}
|
|
2818
|
-
return structuredClone(ir);
|
|
2894
|
+
return { forge: rec6.runId.startsWith("local-") ? "local" : "unknown", originSource: "inferred" };
|
|
2819
2895
|
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
}
|
|
2830
|
-
if (zoom === "composites") {
|
|
2831
|
-
return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
|
|
2832
|
-
}
|
|
2833
|
-
if (zoom === "runtime") {
|
|
2834
|
-
const children = ir.nodes.filter((n) => n.runtimeOwner).length;
|
|
2835
|
-
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
2836
|
-
}
|
|
2837
|
-
return void 0;
|
|
2896
|
+
var FORGE_WORD = {
|
|
2897
|
+
github: "github",
|
|
2898
|
+
gitlab: "gitlab",
|
|
2899
|
+
op: "op",
|
|
2900
|
+
local: "laptop",
|
|
2901
|
+
unknown: "?"
|
|
2902
|
+
};
|
|
2903
|
+
function releaseChip(status) {
|
|
2904
|
+
return `${status.gitSha.slice(0, 7)} via ${FORGE_WORD[status.forge]}`;
|
|
2838
2905
|
}
|
|
2839
|
-
function
|
|
2840
|
-
if (
|
|
2841
|
-
return
|
|
2906
|
+
function releaseStatus(rec6) {
|
|
2907
|
+
if (!rec6) return void 0;
|
|
2908
|
+
return {
|
|
2909
|
+
runId: rec6.runId,
|
|
2910
|
+
...releaseOrigin(rec6),
|
|
2911
|
+
gitSha: rec6.gitSha,
|
|
2912
|
+
digest: rec6.digest,
|
|
2913
|
+
timestamp: rec6.timestamp,
|
|
2914
|
+
actor: rec6.actor,
|
|
2915
|
+
...rec6.approver ? { approver: rec6.approver } : {}
|
|
2916
|
+
};
|
|
2842
2917
|
}
|
|
2843
|
-
function
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2918
|
+
function joinComponentStatus(ir, rows) {
|
|
2919
|
+
const byComponent = new Map(rows.map((r) => [r.component, r]));
|
|
2920
|
+
return {
|
|
2921
|
+
...ir,
|
|
2922
|
+
nodes: ir.nodes.map((n) => {
|
|
2923
|
+
const row = byComponent.get(n.id);
|
|
2924
|
+
if (!row) return n;
|
|
2925
|
+
const release = releaseStatus(row.recorded);
|
|
2926
|
+
return {
|
|
2927
|
+
...n,
|
|
2928
|
+
attrs: {
|
|
2929
|
+
...n.attrs,
|
|
2930
|
+
...release ? { release: releaseChip(release), _release: release } : {},
|
|
2931
|
+
_status: componentStatusColor(row),
|
|
2932
|
+
_liveStatus: {
|
|
2933
|
+
reconciliation: row.reconciliation,
|
|
2934
|
+
detail: row.detail,
|
|
2935
|
+
...row.live !== void 0 ? { live: row.live } : {},
|
|
2936
|
+
...row.stack ? { stack: row.stack } : {},
|
|
2937
|
+
...row.resources ? { resources: row.resources } : {}
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
};
|
|
2941
|
+
})
|
|
2942
|
+
};
|
|
2850
2943
|
}
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2944
|
+
function joinTickVerdicts(ir, tick, now = Date.now()) {
|
|
2945
|
+
if (!tick || !tick.verdicts.length) return ir;
|
|
2946
|
+
const { fresh } = tickFreshness(tick.at, now);
|
|
2947
|
+
const byComponent = new Map(tick.verdicts.map((v) => [v.component, v]));
|
|
2948
|
+
let joined = false;
|
|
2949
|
+
const nodes = ir.nodes.map((n) => {
|
|
2950
|
+
const v = byComponent.get(n.id);
|
|
2951
|
+
if (!v) return n;
|
|
2952
|
+
joined = true;
|
|
2953
|
+
const status = {
|
|
2954
|
+
reconciliation: v.reconciliation,
|
|
2955
|
+
detail: v.detail,
|
|
2956
|
+
...v.live !== void 0 ? { live: v.live } : {},
|
|
2957
|
+
...v.unobserved ? { unobserved: v.unobserved } : {},
|
|
2958
|
+
op: tick.op,
|
|
2959
|
+
env: tick.env,
|
|
2960
|
+
at: tick.at,
|
|
2961
|
+
...tick.tickId ? { tickId: tick.tickId } : {},
|
|
2962
|
+
stale: !fresh
|
|
2963
|
+
};
|
|
2964
|
+
const paint = fresh && n.attrs?._status === void 0;
|
|
2965
|
+
return {
|
|
2966
|
+
...n,
|
|
2967
|
+
attrs: {
|
|
2968
|
+
...n.attrs,
|
|
2969
|
+
// The reconciliation tier and nothing else — `live`/`stack`/`resources`
|
|
2970
|
+
// are deliberately not passed, so this can only ever reach
|
|
2971
|
+
// `componentStatusColor`'s last branch.
|
|
2972
|
+
...paint ? { _status: componentStatusColor({ reconciliation: v.reconciliation, detail: v.detail }) } : {},
|
|
2973
|
+
_tickStatus: status
|
|
2974
|
+
}
|
|
2975
|
+
};
|
|
2976
|
+
});
|
|
2977
|
+
return joined ? { ...ir, nodes } : ir;
|
|
2866
2978
|
}
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2979
|
+
|
|
2980
|
+
// src/overlay.ts
|
|
2981
|
+
function pruneImports(ir) {
|
|
2982
|
+
const drop = new Set((ir.imports ?? []).map((i) => i.node));
|
|
2983
|
+
if (drop.size === 0) return ir;
|
|
2984
|
+
ir.nodes = ir.nodes.filter((n) => !drop.has(n.id));
|
|
2985
|
+
if (ir.edges) ir.edges = ir.edges.filter((e) => !drop.has(e.from) && !drop.has(e.to));
|
|
2986
|
+
return ir;
|
|
2871
2987
|
}
|
|
2872
|
-
function
|
|
2873
|
-
const
|
|
2874
|
-
|
|
2875
|
-
return
|
|
2988
|
+
function componentDir(node) {
|
|
2989
|
+
const parts = node.sourceLoc?.file?.split("/") ?? [];
|
|
2990
|
+
if (parts[0] !== "src" || parts[1] === "examples" || parts.length < 3) return void 0;
|
|
2991
|
+
return parts[1];
|
|
2876
2992
|
}
|
|
2877
|
-
function
|
|
2878
|
-
const
|
|
2879
|
-
if (values.length < 2) return void 0;
|
|
2880
|
-
const byLexicon = /* @__PURE__ */ new Map();
|
|
2993
|
+
function reclassifyOverlay(ir) {
|
|
2994
|
+
const deployedComponents = /* @__PURE__ */ new Set();
|
|
2881
2995
|
for (const n of ir.nodes) {
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
2886
|
-
c[status === "good" ? "good" : "accent"]++;
|
|
2887
|
-
byLexicon.set(lexicon, c);
|
|
2888
|
-
}
|
|
2889
|
-
for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
|
|
2890
|
-
if (accent < 2 || accent * 2 < good + accent) continue;
|
|
2891
|
-
const here = currentTier ? `tier "${currentTier}"` : "the default tier";
|
|
2892
|
-
const others = values.filter((v) => v !== currentTier).join(", ");
|
|
2893
|
-
return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
|
|
2894
|
-
}
|
|
2895
|
-
return void 0;
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
|
-
// src/estate.ts
|
|
2899
|
-
function estateReadPool(members, env = process.env) {
|
|
2900
|
-
const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
|
|
2901
|
-
const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
|
|
2902
|
-
return Math.max(1, Math.min(cap, members));
|
|
2903
|
-
}
|
|
2904
|
-
async function mapPool(items, width, fn) {
|
|
2905
|
-
const results = new Array(items.length);
|
|
2906
|
-
let next = 0;
|
|
2907
|
-
const worker = async () => {
|
|
2908
|
-
for (let i = next++; i < items.length; i = next++) {
|
|
2909
|
-
results[i] = await fn(items[i], i);
|
|
2996
|
+
if (n.attrs?._status === "good") {
|
|
2997
|
+
const c = componentDir(n);
|
|
2998
|
+
if (c) deployedComponents.add(c);
|
|
2910
2999
|
}
|
|
2911
|
-
}
|
|
2912
|
-
await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
|
|
2913
|
-
return results;
|
|
2914
|
-
}
|
|
2915
|
-
function estateMembers(projectDirs) {
|
|
2916
|
-
const names = shortStackNames([...projectDirs]);
|
|
2917
|
-
return projectDirs.map((dir, i) => ({ name: names[i], dir }));
|
|
2918
|
-
}
|
|
2919
|
-
async function composeEstate(projectDirs, opts = {}) {
|
|
2920
|
-
const names = shortStackNames(projectDirs);
|
|
2921
|
-
const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
|
|
2922
|
-
name: names[i],
|
|
2923
|
-
ir: await memberIr(dir, opts)
|
|
2924
|
-
}));
|
|
2925
|
-
return composeStacks(stacks);
|
|
2926
|
-
}
|
|
2927
|
-
var firstLine2 = (e) => {
|
|
2928
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2929
|
-
const m = msg.match(/exited \d+:\s*([\s\S]*)/);
|
|
2930
|
-
return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
|
|
2931
|
-
};
|
|
2932
|
-
function namespaceRuntimeOwners(name, ir) {
|
|
2933
|
-
const own = new Set(ir.nodes.map((n) => n.id));
|
|
3000
|
+
}
|
|
2934
3001
|
for (const n of ir.nodes) {
|
|
2935
|
-
|
|
3002
|
+
const file = n.sourceLoc?.file ?? "";
|
|
3003
|
+
if (file.startsWith("src/examples/")) {
|
|
3004
|
+
n.attrs = { ...n.attrs, _byo: true };
|
|
3005
|
+
delete n.attrs._status;
|
|
3006
|
+
} else if (n.kind === "AWS::CloudFormation::Parameter") {
|
|
3007
|
+
const c = componentDir(n);
|
|
3008
|
+
if (c && deployedComponents.has(c)) {
|
|
3009
|
+
n.attrs = { ...n.attrs, _status: "good" };
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
2936
3012
|
}
|
|
2937
3013
|
return ir;
|
|
2938
3014
|
}
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
};
|
|
2949
|
-
function declaredNamespaceBindings(ir) {
|
|
2950
|
-
const out = [];
|
|
3015
|
+
function pruneRuntimeChildren(ir) {
|
|
3016
|
+
const runtime = new Set(ir.nodes.filter((n) => n.runtimeOwner).map((n) => n.id));
|
|
3017
|
+
if (runtime.size === 0) return ir;
|
|
3018
|
+
ir.nodes = ir.nodes.filter((n) => !runtime.has(n.id));
|
|
3019
|
+
ir.edges = ir.edges.filter((e) => !runtime.has(e.from) && !runtime.has(e.to));
|
|
3020
|
+
return ir;
|
|
3021
|
+
}
|
|
3022
|
+
function attachRuntimeContainment(ir) {
|
|
3023
|
+
const byOwner = /* @__PURE__ */ new Map();
|
|
2951
3024
|
for (const n of ir.nodes) {
|
|
2952
|
-
if (n.
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
const path = str2(spec?.path);
|
|
2956
|
-
if (namespace && path) out.push({ path, namespace });
|
|
3025
|
+
if (!n.runtimeOwner) continue;
|
|
3026
|
+
if (!byOwner.has(n.runtimeOwner)) byOwner.set(n.runtimeOwner, /* @__PURE__ */ new Set());
|
|
3027
|
+
byOwner.get(n.runtimeOwner).add(n.id);
|
|
2957
3028
|
}
|
|
2958
|
-
return
|
|
2959
|
-
}
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
3029
|
+
if (byOwner.size === 0) return ir;
|
|
3030
|
+
const byContainer = { ...ir.groups.byContainer ?? {} };
|
|
3031
|
+
for (const [owner, children] of byOwner) {
|
|
3032
|
+
byContainer[owner] = [.../* @__PURE__ */ new Set([owner, ...byContainer[owner] ?? [], ...children])].sort();
|
|
3033
|
+
}
|
|
3034
|
+
ir.groups.byContainer = byContainer;
|
|
3035
|
+
return ir;
|
|
2964
3036
|
}
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
return
|
|
3037
|
+
|
|
3038
|
+
// src/value-match.ts
|
|
3039
|
+
function typeSegment(kind) {
|
|
3040
|
+
const parts = kind.split(kind.includes("::") ? "::" : "/");
|
|
3041
|
+
return parts[parts.length - 1] ?? kind;
|
|
2970
3042
|
}
|
|
2971
|
-
function
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
for (let k = Math.min(want.length, have.length); k >= 1; k--) {
|
|
2977
|
-
if (!have.slice(-k).every((s, i) => s === want[i])) continue;
|
|
2978
|
-
if (!isDir(join7(root, ...want.slice(k)))) continue;
|
|
2979
|
-
return k;
|
|
2980
|
-
}
|
|
2981
|
-
return 0;
|
|
3043
|
+
function isOwnNameAttr(kind, key) {
|
|
3044
|
+
if (!/name$/i.test(key)) return false;
|
|
3045
|
+
const prefix = key.slice(0, -4).toLowerCase();
|
|
3046
|
+
if (prefix === "") return true;
|
|
3047
|
+
return typeSegment(kind).toLowerCase().includes(prefix);
|
|
2982
3048
|
}
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
tied = true;
|
|
3003
|
-
}
|
|
3004
|
-
}
|
|
3005
|
-
if (!winner || tied) continue;
|
|
3006
|
-
const prev = best.get(winner.dir);
|
|
3007
|
-
if (prev === "ambiguous") continue;
|
|
3008
|
-
const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
|
|
3009
|
-
if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3010
|
-
else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
|
|
3011
|
-
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3012
|
-
}
|
|
3013
|
-
return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
|
|
3014
|
-
}
|
|
3015
|
-
async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
|
|
3016
|
-
if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
|
|
3017
|
-
const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
|
|
3018
|
-
const irs = await mapPool(
|
|
3019
|
-
projectDirs,
|
|
3020
|
-
estateReadPool(projectDirs.length),
|
|
3021
|
-
(dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
|
|
3022
|
-
);
|
|
3023
|
-
const scopes = /* @__PURE__ */ new Map();
|
|
3024
|
-
for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
|
|
3025
|
-
if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
|
|
3026
|
-
scopes.set(j.dir, j.namespace);
|
|
3027
|
-
}
|
|
3028
|
-
return scopes;
|
|
3029
|
-
}
|
|
3030
|
-
function withoutJoinedMembers(nodes, joined) {
|
|
3031
|
-
if (joined.length === 0) return [...nodes];
|
|
3032
|
-
return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
|
|
3049
|
+
var MIN_NAME_LEN = 6;
|
|
3050
|
+
var MAX_NAME_LEN = 200;
|
|
3051
|
+
var MAX_DEPTH = 12;
|
|
3052
|
+
var SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
3053
|
+
"status",
|
|
3054
|
+
"annotations",
|
|
3055
|
+
"labels",
|
|
3056
|
+
"matchLabels",
|
|
3057
|
+
"managedFields",
|
|
3058
|
+
"ownerReferences",
|
|
3059
|
+
"creationTimestamp",
|
|
3060
|
+
"resourceVersion",
|
|
3061
|
+
"uid",
|
|
3062
|
+
"selfLink",
|
|
3063
|
+
"generation",
|
|
3064
|
+
"finalizers"
|
|
3065
|
+
]);
|
|
3066
|
+
function joinable(v) {
|
|
3067
|
+
return v.length >= MIN_NAME_LEN && v.length <= MAX_NAME_LEN && !/\s/.test(v);
|
|
3033
3068
|
}
|
|
3034
|
-
|
|
3035
|
-
const
|
|
3036
|
-
const
|
|
3037
|
-
const
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
|
|
3052
|
-
const src = await memberIr(dir, srcOpts);
|
|
3053
|
-
for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
|
|
3054
|
-
stacks[i] = { name, ir: src };
|
|
3055
|
-
unobserved.push({ name, reason });
|
|
3056
|
-
} catch (err2) {
|
|
3057
|
-
dropped.push({ name, reason: firstLine2(err2) });
|
|
3069
|
+
function stringLeaves(attrs) {
|
|
3070
|
+
const out = [];
|
|
3071
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
3072
|
+
const step = (v, key, path, depth) => {
|
|
3073
|
+
if (typeof v === "string") {
|
|
3074
|
+
out.push({ path, key, value: v });
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
if (depth >= MAX_DEPTH || !v || typeof v !== "object") return;
|
|
3078
|
+
if (onPath.has(v)) return;
|
|
3079
|
+
onPath.add(v);
|
|
3080
|
+
if (Array.isArray(v)) {
|
|
3081
|
+
for (const item of v) step(item, key, path, depth + 1);
|
|
3082
|
+
} else {
|
|
3083
|
+
for (const [k, val] of Object.entries(v)) {
|
|
3084
|
+
if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
|
|
3085
|
+
step(val, k, path ? `${path}.${k}` : k, depth + 1);
|
|
3058
3086
|
}
|
|
3059
3087
|
}
|
|
3060
|
-
|
|
3061
|
-
const present = stacks.filter((s) => !!s);
|
|
3062
|
-
return {
|
|
3063
|
-
ir: composeStacks(present),
|
|
3064
|
-
observed: present.length - unobserved.length,
|
|
3065
|
-
total: projectDirs.length,
|
|
3066
|
-
unobserved,
|
|
3067
|
-
dropped,
|
|
3068
|
-
// Concurrent reads finish in whatever order the clusters answer; the note
|
|
3069
|
-
// this feeds should read the same twice.
|
|
3070
|
-
joined: joined.sort((a, b) => a.name.localeCompare(b.name))
|
|
3071
|
-
};
|
|
3072
|
-
}
|
|
3073
|
-
|
|
3074
|
-
// src/helm-drift.ts
|
|
3075
|
-
function countChanges(docs) {
|
|
3076
|
-
return docs.reduce((n, d) => n + d.changes.length, 0);
|
|
3077
|
-
}
|
|
3078
|
-
function provenanceOf(live) {
|
|
3079
|
-
const m = live.manifest;
|
|
3080
|
-
return {
|
|
3081
|
-
contentDigest: m.contentDigest,
|
|
3082
|
-
inputDigest: m.inputDigest,
|
|
3083
|
-
valuesDigest: m.valuesDigest,
|
|
3084
|
-
chart: m.chart,
|
|
3085
|
-
chartVersion: m.chartVersion,
|
|
3086
|
-
repo: m.repo,
|
|
3087
|
-
releaseName: m.releaseName,
|
|
3088
|
-
namespace: m.namespace,
|
|
3089
|
-
profile: m.capabilityProfile?.cluster ?? "",
|
|
3090
|
-
kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
|
|
3091
|
-
renderedAt: m.renderedAt,
|
|
3092
|
-
helmVersion: m.helmVersion,
|
|
3093
|
-
chantVersion: m.chantVersion,
|
|
3094
|
-
sourceRef: m.sourceRef
|
|
3095
|
-
};
|
|
3096
|
-
}
|
|
3097
|
-
function helmRenderReport(record, live) {
|
|
3098
|
-
const base = { render: record.name, chart: record.chart };
|
|
3099
|
-
if (!live) {
|
|
3100
|
-
return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
|
|
3101
|
-
}
|
|
3102
|
-
const d = live.diff;
|
|
3103
|
-
const counts = {
|
|
3104
|
-
drifted: d.drifted.length,
|
|
3105
|
-
changes: countChanges(d.drifted),
|
|
3106
|
-
unchanged: d.unchanged.length,
|
|
3107
|
-
unobserved: d.unobserved.length,
|
|
3108
|
-
undeclared: d.undeclaredEntities.length
|
|
3088
|
+
onPath.delete(v);
|
|
3109
3089
|
};
|
|
3110
|
-
const
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
}
|
|
3114
|
-
if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
|
|
3115
|
-
return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
|
|
3116
|
-
}
|
|
3117
|
-
if (d.unobserved.length > 0) {
|
|
3118
|
-
return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
|
|
3090
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
3091
|
+
if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
|
|
3092
|
+
step(v, k, k, 0);
|
|
3119
3093
|
}
|
|
3120
|
-
return
|
|
3121
|
-
}
|
|
3122
|
-
async function readHelmRenderDrift(projectDir, env, opts = {}) {
|
|
3123
|
-
const report = await helmRenders(projectDir, opts).catch(() => void 0);
|
|
3124
|
-
const records = report?.records ?? [];
|
|
3125
|
-
if (records.length === 0) return [];
|
|
3126
|
-
return mapPool(records, estateReadPool(records.length), async (record) => {
|
|
3127
|
-
if (!record.contentDigest) return helmRenderReport(record, void 0);
|
|
3128
|
-
const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
|
|
3129
|
-
return helmRenderReport(record, live);
|
|
3130
|
-
});
|
|
3131
|
-
}
|
|
3132
|
-
function chartNameOf2(node) {
|
|
3133
|
-
const name = node.attrs?.name;
|
|
3134
|
-
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
3094
|
+
return out;
|
|
3135
3095
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
const
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3096
|
+
function addValueMatchEdges(ir) {
|
|
3097
|
+
const nodes = ir.nodes;
|
|
3098
|
+
const owner = /* @__PURE__ */ new Map();
|
|
3099
|
+
for (const n of nodes) {
|
|
3100
|
+
for (const { path, key, value } of stringLeaves(n.attrs)) {
|
|
3101
|
+
if (path !== key && path !== `metadata.${key}`) continue;
|
|
3102
|
+
if (!joinable(value) || !isOwnNameAttr(n.kind, key)) continue;
|
|
3103
|
+
owner.set(value, owner.has(value) ? null : n.id);
|
|
3104
|
+
}
|
|
3144
3105
|
}
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
for (const
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
const
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
drifted++;
|
|
3106
|
+
if (owner.size === 0) return ir;
|
|
3107
|
+
const declared = /* @__PURE__ */ new Set();
|
|
3108
|
+
for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
|
|
3109
|
+
const added = /* @__PURE__ */ new Set();
|
|
3110
|
+
for (const a of nodes) {
|
|
3111
|
+
for (const { path, value } of stringLeaves(a.attrs)) {
|
|
3112
|
+
if (!joinable(value)) continue;
|
|
3113
|
+
const b = owner.get(value);
|
|
3114
|
+
if (!b || b === a.id) continue;
|
|
3115
|
+
const key = `${a.id}\0${b}`;
|
|
3116
|
+
if (declared.has(key) || added.has(key)) continue;
|
|
3117
|
+
added.add(key);
|
|
3118
|
+
ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: path, inferred: true });
|
|
3159
3119
|
}
|
|
3160
3120
|
}
|
|
3161
|
-
return
|
|
3121
|
+
return ir;
|
|
3162
3122
|
}
|
|
3163
3123
|
|
|
3164
|
-
// src/
|
|
3165
|
-
var
|
|
3166
|
-
"
|
|
3167
|
-
"
|
|
3168
|
-
"
|
|
3169
|
-
"
|
|
3124
|
+
// src/k8s-edges.ts
|
|
3125
|
+
var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
|
|
3126
|
+
"K8s::Apps::Deployment",
|
|
3127
|
+
"K8s::Apps::StatefulSet",
|
|
3128
|
+
"K8s::Apps::DaemonSet",
|
|
3129
|
+
"K8s::Apps::ReplicaSet"
|
|
3170
3130
|
]);
|
|
3171
|
-
var
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3131
|
+
var SELECTOR_CARRIERS = [
|
|
3132
|
+
{ kind: "K8s::Core::Service", read: (spec) => spec?.selector, viaAttr: "selector" },
|
|
3133
|
+
{ kind: "K8s::Policy::PodDisruptionBudget", read: (spec) => labelSelector(spec?.selector), viaAttr: "selector" },
|
|
3134
|
+
{ kind: "K8s::Networking::NetworkPolicy", read: (spec) => labelSelector(spec?.podSelector), viaAttr: "podSelector" }
|
|
3135
|
+
];
|
|
3136
|
+
function rec2(v) {
|
|
3137
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
3175
3138
|
}
|
|
3176
|
-
function
|
|
3177
|
-
const v = metadata(node)?.[key];
|
|
3139
|
+
function str2(v) {
|
|
3178
3140
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3179
3141
|
}
|
|
3180
|
-
function
|
|
3181
|
-
return
|
|
3142
|
+
function namespaceOf(n) {
|
|
3143
|
+
return str2(rec2(n.attrs?.metadata)?.namespace) ?? "default";
|
|
3182
3144
|
}
|
|
3183
|
-
function
|
|
3184
|
-
return
|
|
3145
|
+
function nameOf(n) {
|
|
3146
|
+
return str2(rec2(n.attrs?.metadata)?.name);
|
|
3185
3147
|
}
|
|
3186
|
-
function
|
|
3187
|
-
const
|
|
3188
|
-
if (
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
if (typeof
|
|
3148
|
+
function labelRecord(v) {
|
|
3149
|
+
const r = rec2(v);
|
|
3150
|
+
if (!r) return void 0;
|
|
3151
|
+
const out = {};
|
|
3152
|
+
for (const [k, val] of Object.entries(r)) {
|
|
3153
|
+
if (typeof val !== "string") return void 0;
|
|
3154
|
+
out[k] = val;
|
|
3192
3155
|
}
|
|
3193
|
-
return
|
|
3194
|
-
}
|
|
3195
|
-
function boundManagedCluster(nodes, boundContext) {
|
|
3196
|
-
const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
|
|
3197
|
-
if (clusters.length === 1) return clusters[0];
|
|
3198
|
-
if (clusters.length === 0 || !boundContext) return void 0;
|
|
3199
|
-
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
|
|
3200
|
-
return bound.length === 1 ? bound[0] : void 0;
|
|
3156
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
3201
3157
|
}
|
|
3202
|
-
function
|
|
3203
|
-
const
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
const namespaceByName = /* @__PURE__ */ new Map();
|
|
3209
|
-
for (const n of k8sNodes) {
|
|
3210
|
-
if (!isNamespace(n)) continue;
|
|
3211
|
-
const name = metaString(n, "name");
|
|
3212
|
-
if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
|
|
3213
|
-
}
|
|
3214
|
-
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3215
|
-
const add = (from, to) => {
|
|
3216
|
-
if (from === to) return;
|
|
3217
|
-
const key = `${from}\0${to}`;
|
|
3218
|
-
if (existing.has(key)) return;
|
|
3219
|
-
existing.add(key);
|
|
3220
|
-
ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
|
|
3221
|
-
};
|
|
3222
|
-
for (const n of k8sNodes) {
|
|
3223
|
-
if (isNamespace(n)) {
|
|
3224
|
-
add(cluster.id, n.id);
|
|
3225
|
-
continue;
|
|
3226
|
-
}
|
|
3227
|
-
const ns = metaString(n, "namespace");
|
|
3228
|
-
const nsNode = ns ? namespaceByName.get(ns) : void 0;
|
|
3229
|
-
add(nsNode ?? cluster.id, n.id);
|
|
3230
|
-
}
|
|
3231
|
-
return ir;
|
|
3158
|
+
function labelSelector(v) {
|
|
3159
|
+
const sel = rec2(v);
|
|
3160
|
+
if (!sel) return void 0;
|
|
3161
|
+
const expressions = sel.matchExpressions;
|
|
3162
|
+
if (Array.isArray(expressions) && expressions.length > 0) return void 0;
|
|
3163
|
+
return sel.matchLabels;
|
|
3232
3164
|
}
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
3236
|
-
// compute / workloads
|
|
3237
|
-
"Microsoft.Compute/virtualMachines",
|
|
3238
|
-
"Microsoft.Compute/virtualMachineScaleSets",
|
|
3239
|
-
"Microsoft.ContainerService/managedClusters",
|
|
3240
|
-
"Microsoft.ContainerInstance/containerGroups",
|
|
3241
|
-
"Microsoft.Web/sites",
|
|
3242
|
-
// network edge
|
|
3243
|
-
"Microsoft.Network/publicIPAddresses",
|
|
3244
|
-
"Microsoft.Network/loadBalancers",
|
|
3245
|
-
"Microsoft.Network/applicationGateways",
|
|
3246
|
-
"Microsoft.Network/natGateways",
|
|
3247
|
-
"Microsoft.Network/dnsZones",
|
|
3248
|
-
// data stores
|
|
3249
|
-
"Microsoft.Storage/storageAccounts",
|
|
3250
|
-
"Microsoft.Sql/servers",
|
|
3251
|
-
"Microsoft.DocumentDB/databaseAccounts",
|
|
3252
|
-
"Microsoft.Cache/redis",
|
|
3253
|
-
"Microsoft.DBforPostgreSQL/flexibleServers",
|
|
3254
|
-
// registry / identity edge
|
|
3255
|
-
"Microsoft.ContainerRegistry/registries"
|
|
3256
|
-
]);
|
|
3257
|
-
var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
3258
|
-
"Microsoft.Network/virtualNetworks",
|
|
3259
|
-
"Microsoft.Network/virtualNetworks_subnets",
|
|
3260
|
-
"Microsoft.Network/networkSecurityGroups",
|
|
3261
|
-
"Microsoft.Network/routeTables",
|
|
3262
|
-
"Microsoft.Network/networkInterfaces",
|
|
3263
|
-
// identity/authorization hubs — a role assignment or managed identity is
|
|
3264
|
-
// referenced by half the estate, so contracting through one invents edges
|
|
3265
|
-
// between unrelated resources.
|
|
3266
|
-
"Microsoft.Authorization/roleAssignments",
|
|
3267
|
-
"Microsoft.ManagedIdentity/userAssignedIdentities",
|
|
3268
|
-
"Microsoft.KeyVault/vaults"
|
|
3269
|
-
]);
|
|
3270
|
-
var VNET_KIND = "Microsoft.Network/virtualNetworks";
|
|
3271
|
-
var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
|
|
3272
|
-
var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
|
|
3273
|
-
var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
|
|
3274
|
-
var QUOTED_RE = /'([^']*)'/g;
|
|
3275
|
-
function resourceIdNames(value) {
|
|
3276
|
-
if (typeof value !== "string" || !value.includes("resourceId(")) return [];
|
|
3277
|
-
const out = [];
|
|
3278
|
-
for (const call of value.matchAll(RESOURCE_ID_RE)) {
|
|
3279
|
-
const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
|
|
3280
|
-
if (args.length >= 2) out.push(args[args.length - 1]);
|
|
3281
|
-
}
|
|
3282
|
-
return out;
|
|
3165
|
+
function selects(selector, labels) {
|
|
3166
|
+
return Object.entries(selector).every(([k, v]) => labels[k] === v);
|
|
3283
3167
|
}
|
|
3284
|
-
function
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
return;
|
|
3288
|
-
}
|
|
3289
|
-
const ref = value.$ref;
|
|
3290
|
-
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
3291
|
-
for (const v of Object.values(value)) collectAzureRefs(v, out);
|
|
3168
|
+
function templateLabels(n) {
|
|
3169
|
+
const template = rec2(rec2(n.attrs?.spec)?.template);
|
|
3170
|
+
return labelRecord(rec2(template?.metadata)?.labels);
|
|
3292
3171
|
}
|
|
3293
|
-
function
|
|
3294
|
-
const
|
|
3295
|
-
return
|
|
3172
|
+
function shortKind(kind) {
|
|
3173
|
+
const parts = kind.split("::");
|
|
3174
|
+
return parts[parts.length - 1] ?? kind;
|
|
3296
3175
|
}
|
|
3297
|
-
|
|
3298
|
-
|
|
3176
|
+
var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
|
|
3177
|
+
"K8s::Flux::GitRepository",
|
|
3178
|
+
"K8s::Flux::OCIRepository",
|
|
3179
|
+
"K8s::Flux::HelmRepository",
|
|
3180
|
+
"K8s::Flux::HelmChart",
|
|
3181
|
+
"K8s::Flux::Bucket"
|
|
3182
|
+
]);
|
|
3183
|
+
function sourceRefOf(refObj, referrer, defaultKind) {
|
|
3184
|
+
const ref = rec2(refObj);
|
|
3185
|
+
const kind = str2(ref?.kind) ?? defaultKind;
|
|
3186
|
+
const name = str2(ref?.name);
|
|
3187
|
+
if (!kind || !name) return void 0;
|
|
3188
|
+
return { kind, name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
|
|
3299
3189
|
}
|
|
3300
|
-
function
|
|
3301
|
-
const
|
|
3302
|
-
|
|
3303
|
-
|
|
3190
|
+
function namedRefOf(refObj, referrer) {
|
|
3191
|
+
const ref = rec2(refObj);
|
|
3192
|
+
const name = str2(ref?.name);
|
|
3193
|
+
if (!name) return void 0;
|
|
3194
|
+
return { name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
|
|
3304
3195
|
}
|
|
3305
|
-
function
|
|
3306
|
-
|
|
3307
|
-
return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
|
|
3196
|
+
function list(v) {
|
|
3197
|
+
return Array.isArray(v) ? v : [];
|
|
3308
3198
|
}
|
|
3309
|
-
function
|
|
3310
|
-
|
|
3311
|
-
const prefixes = space?.addressPrefixes;
|
|
3312
|
-
return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
|
|
3199
|
+
function helmReleaseSourceRef(n) {
|
|
3200
|
+
return rec2(rec2(rec2(n.attrs?.spec)?.chart)?.spec)?.sourceRef;
|
|
3313
3201
|
}
|
|
3314
|
-
function
|
|
3315
|
-
const
|
|
3316
|
-
|
|
3317
|
-
const
|
|
3318
|
-
const
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
|
|
3327
|
-
const refOut = /* @__PURE__ */ new Map();
|
|
3328
|
-
for (const n of azure) {
|
|
3329
|
-
const names = /* @__PURE__ */ new Set();
|
|
3330
|
-
collectAzureRefs(n.attrs, names);
|
|
3331
|
-
const targets = /* @__PURE__ */ new Set();
|
|
3332
|
-
for (const name of names) {
|
|
3333
|
-
const target = byName.get(name) ?? byId.get(name);
|
|
3334
|
-
if (target && target.id !== n.id) targets.add(target.id);
|
|
3202
|
+
function ingressBackendServices(n) {
|
|
3203
|
+
const spec = rec2(n.attrs?.spec);
|
|
3204
|
+
const out = [];
|
|
3205
|
+
const backendName = (b) => str2(rec2(rec2(b)?.service)?.name);
|
|
3206
|
+
const fromDefault = backendName(spec?.defaultBackend);
|
|
3207
|
+
if (fromDefault) out.push(fromDefault);
|
|
3208
|
+
const rules = Array.isArray(spec?.rules) ? spec.rules : [];
|
|
3209
|
+
for (const rule of rules) {
|
|
3210
|
+
const paths = rec2(rec2(rule)?.http)?.paths;
|
|
3211
|
+
for (const p of Array.isArray(paths) ? paths : []) {
|
|
3212
|
+
const name = backendName(rec2(p)?.backend);
|
|
3213
|
+
if (name) out.push(name);
|
|
3335
3214
|
}
|
|
3336
|
-
if (targets.size) refOut.set(n.id, targets);
|
|
3337
|
-
}
|
|
3338
|
-
for (const e of ir.edges) {
|
|
3339
|
-
if (!byId.has(e.from) || !byId.has(e.to)) continue;
|
|
3340
|
-
(refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
|
|
3341
3215
|
}
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
const
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3216
|
+
return out;
|
|
3217
|
+
}
|
|
3218
|
+
function deriveK8sEdges(nodes) {
|
|
3219
|
+
const k8s = nodes.filter((n) => n.lexicon === "k8s");
|
|
3220
|
+
if (k8s.length === 0) return [];
|
|
3221
|
+
const out = [];
|
|
3222
|
+
const add = (from, to, viaAttr) => {
|
|
3223
|
+
if (from !== to) out.push({ from, to, kind: "ref", viaAttr, inferred: true });
|
|
3224
|
+
};
|
|
3225
|
+
const workloads = k8s.filter((n) => WORKLOAD_KINDS.has(n.kind));
|
|
3226
|
+
const byKindNsName = /* @__PURE__ */ new Map();
|
|
3227
|
+
const appProjectsByName = /* @__PURE__ */ new Map();
|
|
3228
|
+
for (const n of k8s) {
|
|
3229
|
+
const name = nameOf(n);
|
|
3230
|
+
if (!name) continue;
|
|
3231
|
+
byKindNsName.set(`${n.kind}\0${namespaceOf(n)}\0${name}`, n);
|
|
3232
|
+
if (n.kind === "K8s::Argo::AppProject") {
|
|
3233
|
+
const same = appProjectsByName.get(name) ?? [];
|
|
3234
|
+
same.push(n);
|
|
3235
|
+
appProjectsByName.set(name, same);
|
|
3351
3236
|
}
|
|
3352
3237
|
}
|
|
3353
|
-
const
|
|
3354
|
-
const
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
const
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
for (const id of frontier) {
|
|
3362
|
-
if (seen.has(id)) continue;
|
|
3363
|
-
seen.add(id);
|
|
3364
|
-
if (want.has(id)) return id;
|
|
3365
|
-
for (const r of refOut.get(id) ?? []) if (!seen.has(r)) next.push(r);
|
|
3366
|
-
}
|
|
3367
|
-
frontier = next;
|
|
3368
|
-
}
|
|
3369
|
-
return void 0;
|
|
3238
|
+
const lookup = (kind, namespace, name) => byKindNsName.get(`${kind}\0${namespace}\0${name}`);
|
|
3239
|
+
const addFluxRef = (n, refObj, viaAttr, allowed, defaultKind) => {
|
|
3240
|
+
const ref = sourceRefOf(refObj, n, defaultKind);
|
|
3241
|
+
if (!ref || ref.name === "*") return;
|
|
3242
|
+
const kind = `K8s::Flux::${ref.kind}`;
|
|
3243
|
+
if (allowed && !allowed.has(kind)) return;
|
|
3244
|
+
const target = lookup(kind, ref.namespace, ref.name);
|
|
3245
|
+
if (target) add(n.id, target.id, viaAttr);
|
|
3370
3246
|
};
|
|
3371
|
-
const
|
|
3372
|
-
const
|
|
3373
|
-
const
|
|
3374
|
-
if (!
|
|
3247
|
+
const addSourceRef = (n, refObj, viaAttr, defaultKind) => addFluxRef(n, refObj, viaAttr, FLUX_SOURCE_KINDS, defaultKind);
|
|
3248
|
+
const addNamedRef = (n, refObj, kind, viaAttr) => {
|
|
3249
|
+
const ref = namedRefOf(refObj, n);
|
|
3250
|
+
if (!ref) return;
|
|
3251
|
+
const target = lookup(kind, ref.namespace, ref.name);
|
|
3252
|
+
if (target) add(n.id, target.id, viaAttr);
|
|
3375
3253
|
};
|
|
3376
|
-
|
|
3377
|
-
|
|
3254
|
+
const addProject = (n, project, viaAttr) => {
|
|
3255
|
+
const candidates = project ? appProjectsByName.get(project) ?? [] : [];
|
|
3256
|
+
const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
|
|
3257
|
+
if (target) add(n.id, target.id, viaAttr);
|
|
3258
|
+
};
|
|
3259
|
+
const addSelector = (n, raw, viaAttr) => {
|
|
3260
|
+
const selector = labelRecord(raw);
|
|
3261
|
+
if (!selector) return;
|
|
3262
|
+
for (const w of workloads) {
|
|
3263
|
+
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
3264
|
+
const labels = templateLabels(w);
|
|
3265
|
+
if (labels && selects(selector, labels)) add(n.id, w.id, viaAttr);
|
|
3266
|
+
}
|
|
3267
|
+
};
|
|
3268
|
+
for (const n of k8s) {
|
|
3269
|
+
const carrier = SELECTOR_CARRIERS.find((c) => c.kind === n.kind);
|
|
3270
|
+
if (carrier) {
|
|
3271
|
+
addSelector(n, carrier.read(rec2(n.attrs?.spec)), carrier.viaAttr);
|
|
3272
|
+
continue;
|
|
3273
|
+
}
|
|
3274
|
+
if (n.kind === "K8s::Networking::Ingress") {
|
|
3275
|
+
for (const svcName of ingressBackendServices(n)) {
|
|
3276
|
+
const svc = lookup("K8s::Core::Service", namespaceOf(n), svcName);
|
|
3277
|
+
if (svc) add(n.id, svc.id, "ingress backend");
|
|
3278
|
+
}
|
|
3279
|
+
continue;
|
|
3280
|
+
}
|
|
3281
|
+
if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
|
|
3282
|
+
const ref = rec2(rec2(n.attrs?.spec)?.scaleTargetRef);
|
|
3283
|
+
const refKind = str2(ref?.kind);
|
|
3284
|
+
const refName = str2(ref?.name);
|
|
3285
|
+
if (!refKind || !refName) continue;
|
|
3286
|
+
for (const w of workloads) {
|
|
3287
|
+
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
3288
|
+
if (shortKind(w.kind) === refKind && nameOf(w) === refName) add(n.id, w.id, "scaleTargetRef");
|
|
3289
|
+
}
|
|
3290
|
+
continue;
|
|
3291
|
+
}
|
|
3292
|
+
if (n.kind === "K8s::Flux::Kustomization") {
|
|
3293
|
+
addSourceRef(n, rec2(n.attrs?.spec)?.sourceRef, "sourceRef");
|
|
3294
|
+
for (const dep of list(rec2(n.attrs?.spec)?.dependsOn)) {
|
|
3295
|
+
addNamedRef(n, dep, "K8s::Flux::Kustomization", "dependsOn");
|
|
3296
|
+
}
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
if (n.kind === "K8s::Flux::HelmRelease") {
|
|
3300
|
+
addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
|
|
3301
|
+
continue;
|
|
3302
|
+
}
|
|
3303
|
+
if (n.kind === "K8s::Flux::ImagePolicy") {
|
|
3304
|
+
addNamedRef(n, rec2(n.attrs?.spec)?.imageRepositoryRef, "K8s::Flux::ImageRepository", "imageRepositoryRef");
|
|
3305
|
+
continue;
|
|
3306
|
+
}
|
|
3307
|
+
if (n.kind === "K8s::Flux::ImageUpdateAutomation") {
|
|
3308
|
+
addSourceRef(n, rec2(n.attrs?.spec)?.sourceRef, "sourceRef", "GitRepository");
|
|
3309
|
+
continue;
|
|
3310
|
+
}
|
|
3311
|
+
if (n.kind === "K8s::Flux::Alert") {
|
|
3312
|
+
addNamedRef(n, rec2(n.attrs?.spec)?.providerRef, "K8s::Flux::Provider", "providerRef");
|
|
3313
|
+
for (const src of list(rec2(n.attrs?.spec)?.eventSources)) {
|
|
3314
|
+
addFluxRef(n, src, "eventSource");
|
|
3315
|
+
}
|
|
3316
|
+
continue;
|
|
3317
|
+
}
|
|
3318
|
+
if (n.kind === "K8s::Argo::Application") {
|
|
3319
|
+
addProject(n, str2(rec2(n.attrs?.spec)?.project), "project");
|
|
3320
|
+
continue;
|
|
3321
|
+
}
|
|
3322
|
+
if (n.kind === "K8s::Argo::ApplicationSet") {
|
|
3323
|
+
addProject(n, str2(rec2(rec2(rec2(n.attrs?.spec)?.template)?.spec)?.project), "template project");
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
return out;
|
|
3327
|
+
}
|
|
3328
|
+
function addK8sDeclaredEdges(ir) {
|
|
3329
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3330
|
+
for (const e of deriveK8sEdges(ir.nodes)) {
|
|
3331
|
+
const key = `${e.from}\0${e.to}`;
|
|
3332
|
+
if (existing.has(key)) continue;
|
|
3333
|
+
existing.add(key);
|
|
3334
|
+
ir.edges.push(e);
|
|
3335
|
+
}
|
|
3336
|
+
return ir;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
// src/helm-artifacts.ts
|
|
3340
|
+
function chartNameOf(node) {
|
|
3341
|
+
const name = node.attrs?.name;
|
|
3342
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
3343
|
+
}
|
|
3344
|
+
function releaseMatchesChart(observed, chartName) {
|
|
3345
|
+
const chart = observed.attributes?.chart;
|
|
3346
|
+
if (typeof chart !== "string") return false;
|
|
3347
|
+
return chart === chartName || chart.startsWith(`${chartName}-`);
|
|
3348
|
+
}
|
|
3349
|
+
function applyHelmArtifacts(ir, observed) {
|
|
3350
|
+
let charts = 0;
|
|
3351
|
+
let installed = 0;
|
|
3352
|
+
for (const node of ir.nodes) {
|
|
3353
|
+
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
3354
|
+
charts++;
|
|
3355
|
+
const attrs = node.attrs ??= {};
|
|
3356
|
+
if (observed === void 0) {
|
|
3357
|
+
attrs._status = "neutral";
|
|
3358
|
+
attrs._unobserved = "artifact-observation-unavailable";
|
|
3359
|
+
continue;
|
|
3360
|
+
}
|
|
3361
|
+
const chartName = chartNameOf(node);
|
|
3362
|
+
const entry = chartName ? Object.entries(observed).find(([key2, o2]) => key2.startsWith("release/") && releaseMatchesChart(o2, chartName)) : void 0;
|
|
3363
|
+
if (!entry) {
|
|
3364
|
+
attrs._status = "accent";
|
|
3365
|
+
continue;
|
|
3366
|
+
}
|
|
3367
|
+
const [key, o] = entry;
|
|
3368
|
+
const match = {
|
|
3369
|
+
release: key.replace(/^release\//, ""),
|
|
3370
|
+
...o.status ? { status: o.status } : {},
|
|
3371
|
+
...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
|
|
3372
|
+
...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
|
|
3373
|
+
};
|
|
3374
|
+
attrs._artifact = match;
|
|
3375
|
+
attrs._status = o.status === "deployed" ? "good" : "warn";
|
|
3376
|
+
if (o.status === "deployed") installed++;
|
|
3377
|
+
}
|
|
3378
|
+
return { charts, installed };
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
// src/estate.ts
|
|
3382
|
+
import { statSync as statSync3 } from "node:fs";
|
|
3383
|
+
import { availableParallelism } from "node:os";
|
|
3384
|
+
import { join as join7, resolve as resolve3, sep } from "node:path";
|
|
3385
|
+
import { composeStacks, shortStackNames } from "@intentius/pinhole";
|
|
3386
|
+
|
|
3387
|
+
// src/member-ir.ts
|
|
3388
|
+
import { createHash } from "node:crypto";
|
|
3389
|
+
import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
3390
|
+
import { join as join6, relative, resolve as resolve2 } from "node:path";
|
|
3391
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
|
|
3392
|
+
function memberIrCacheSize(env = process.env) {
|
|
3393
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
|
|
3394
|
+
return Number.isInteger(override) && override >= 0 ? override : 64;
|
|
3395
|
+
}
|
|
3396
|
+
function memberSourceStamp(dir) {
|
|
3397
|
+
const root = resolve2(dir);
|
|
3398
|
+
const h = createHash("sha1");
|
|
3399
|
+
let any = false;
|
|
3400
|
+
const walk = (at) => {
|
|
3401
|
+
const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
3402
|
+
for (const e of entries) {
|
|
3403
|
+
if (SKIP.has(e.name)) continue;
|
|
3404
|
+
const path = join6(at, e.name);
|
|
3405
|
+
if (e.isDirectory()) {
|
|
3406
|
+
walk(path);
|
|
3407
|
+
continue;
|
|
3408
|
+
}
|
|
3409
|
+
if (!e.isFile()) continue;
|
|
3410
|
+
const st = statSync2(path);
|
|
3411
|
+
h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
|
|
3412
|
+
`);
|
|
3413
|
+
any = true;
|
|
3414
|
+
}
|
|
3415
|
+
};
|
|
3416
|
+
try {
|
|
3417
|
+
walk(root);
|
|
3418
|
+
} catch {
|
|
3419
|
+
return void 0;
|
|
3420
|
+
}
|
|
3421
|
+
return any ? h.digest("hex") : void 0;
|
|
3422
|
+
}
|
|
3423
|
+
function cacheKey(dir, opts) {
|
|
3424
|
+
const chant = resolveChant(dir);
|
|
3425
|
+
const canonical = JSON.stringify(opts, Object.keys(opts).sort());
|
|
3426
|
+
return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
|
|
3427
|
+
}
|
|
3428
|
+
var cache = /* @__PURE__ */ new Map();
|
|
3429
|
+
var hits = 0;
|
|
3430
|
+
var misses = 0;
|
|
3431
|
+
function invalidateMember(dir) {
|
|
3432
|
+
if (dir === void 0) {
|
|
3433
|
+
cache.clear();
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
const root = resolve2(dir);
|
|
3437
|
+
for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
|
|
3438
|
+
}
|
|
3439
|
+
async function memberIr(dir, opts = {}) {
|
|
3440
|
+
const size = memberIrCacheSize();
|
|
3441
|
+
if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
|
|
3442
|
+
const stamp = memberSourceStamp(dir);
|
|
3443
|
+
const key = cacheKey(dir, opts);
|
|
3444
|
+
const hit = stamp !== void 0 ? cache.get(key) : void 0;
|
|
3445
|
+
if (hit && hit.stamp === stamp) {
|
|
3446
|
+
hits++;
|
|
3447
|
+
cache.delete(key);
|
|
3448
|
+
cache.set(key, hit);
|
|
3449
|
+
return structuredClone(hit.ir);
|
|
3450
|
+
}
|
|
3451
|
+
misses++;
|
|
3452
|
+
const ir = await graphIr(dir, opts);
|
|
3453
|
+
if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
|
|
3454
|
+
cache.delete(key);
|
|
3455
|
+
cache.set(key, { dir: resolve2(dir), stamp, ir });
|
|
3456
|
+
for (const oldest of cache.keys()) {
|
|
3457
|
+
if (cache.size <= size) break;
|
|
3458
|
+
cache.delete(oldest);
|
|
3459
|
+
}
|
|
3460
|
+
return structuredClone(ir);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
// src/zoom-notes.ts
|
|
3464
|
+
function zoomNote(zoom, ir, compositeEdgesAttached) {
|
|
3465
|
+
const empty = ir.nodes.length === 0;
|
|
3466
|
+
if (zoom === "components") {
|
|
3467
|
+
return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
|
|
3468
|
+
}
|
|
3469
|
+
if (zoom === "logical") {
|
|
3470
|
+
return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
|
|
3471
|
+
}
|
|
3472
|
+
if (zoom === "composites") {
|
|
3473
|
+
return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
|
|
3474
|
+
}
|
|
3475
|
+
if (zoom === "runtime") {
|
|
3476
|
+
const children = ir.nodes.filter((n) => n.runtimeOwner).length;
|
|
3477
|
+
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
3478
|
+
}
|
|
3479
|
+
return void 0;
|
|
3480
|
+
}
|
|
3481
|
+
function logicalKept(before, after) {
|
|
3482
|
+
if (after > 0 && after * 3 >= before) return void 0;
|
|
3483
|
+
return after === 0 ? `logical projected nothing from ${before} resources \u2014 it is a cloud-topology lens, and this estate declares none of the kinds it nests (behold#74)` : `logical kept ${after} of ${before} resources \u2014 it is a cloud-topology lens, and the rest are kinds it does not nest (behold#74)`;
|
|
3484
|
+
}
|
|
3485
|
+
function edgelessNote(zoom, ir, detail) {
|
|
3486
|
+
if (zoom === "components" || zoom === "logical") return void 0;
|
|
3487
|
+
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
3488
|
+
if (detail !== void 0 && detail < 3) {
|
|
3489
|
+
return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
|
|
3490
|
+
}
|
|
3491
|
+
return "no edges \u2014 nothing in this estate references anything else";
|
|
3492
|
+
}
|
|
3493
|
+
var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
|
|
3494
|
+
"K8s::Core::Namespace",
|
|
3495
|
+
"K8s::Rbac::ClusterRole",
|
|
3496
|
+
"K8s::Rbac::ClusterRoleBinding",
|
|
3497
|
+
"K8s::Storage::StorageClass",
|
|
3498
|
+
"K8s::Apiextensions::CustomResourceDefinition"
|
|
3499
|
+
]);
|
|
3500
|
+
function namespaceMismatchNote(nodes) {
|
|
3501
|
+
const k8s = nodes.filter((n) => n?.lexicon === "k8s");
|
|
3502
|
+
if (k8s.length === 0) return void 0;
|
|
3503
|
+
const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
3504
|
+
if (pending.length < 2) return void 0;
|
|
3505
|
+
if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
|
|
3506
|
+
if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
|
|
3507
|
+
return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
|
|
3508
|
+
}
|
|
3509
|
+
function namespaceJoinNote(joined) {
|
|
3510
|
+
if (joined.length === 0) return void 0;
|
|
3511
|
+
const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
|
|
3512
|
+
return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
|
|
3513
|
+
}
|
|
3514
|
+
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
|
|
3515
|
+
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
3516
|
+
const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
|
|
3517
|
+
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
3518
|
+
}
|
|
3519
|
+
function tierMismatchNote(ir, tiers, currentTier) {
|
|
3520
|
+
const values = tiers?.values ?? [];
|
|
3521
|
+
if (values.length < 2) return void 0;
|
|
3522
|
+
const byLexicon = /* @__PURE__ */ new Map();
|
|
3523
|
+
for (const n of ir.nodes) {
|
|
3524
|
+
const status = n.attrs?._status;
|
|
3525
|
+
if (status !== "good" && status !== "accent") continue;
|
|
3526
|
+
const lexicon = n.lexicon ?? "?";
|
|
3527
|
+
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
3528
|
+
c[status === "good" ? "good" : "accent"]++;
|
|
3529
|
+
byLexicon.set(lexicon, c);
|
|
3530
|
+
}
|
|
3531
|
+
for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
|
|
3532
|
+
if (accent < 2 || accent * 2 < good + accent) continue;
|
|
3533
|
+
const here = currentTier ? `tier "${currentTier}"` : "the default tier";
|
|
3534
|
+
const others = values.filter((v) => v !== currentTier).join(", ");
|
|
3535
|
+
return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
|
|
3536
|
+
}
|
|
3537
|
+
return void 0;
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
// src/estate.ts
|
|
3541
|
+
function estateReadPool(members, env = process.env) {
|
|
3542
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
|
|
3543
|
+
const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
|
|
3544
|
+
return Math.max(1, Math.min(cap, members));
|
|
3545
|
+
}
|
|
3546
|
+
async function mapPool(items, width, fn) {
|
|
3547
|
+
const results = new Array(items.length);
|
|
3548
|
+
let next = 0;
|
|
3549
|
+
const worker = async () => {
|
|
3550
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
3551
|
+
results[i] = await fn(items[i], i);
|
|
3552
|
+
}
|
|
3553
|
+
};
|
|
3554
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
|
|
3555
|
+
return results;
|
|
3556
|
+
}
|
|
3557
|
+
function estateMembers(projectDirs) {
|
|
3558
|
+
const names = shortStackNames([...projectDirs]);
|
|
3559
|
+
return projectDirs.map((dir, i) => ({ name: names[i], dir }));
|
|
3560
|
+
}
|
|
3561
|
+
async function composeEstate(projectDirs, opts = {}) {
|
|
3562
|
+
const names = shortStackNames(projectDirs);
|
|
3563
|
+
const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
|
|
3564
|
+
name: names[i],
|
|
3565
|
+
ir: await memberIr(dir, opts)
|
|
3566
|
+
}));
|
|
3567
|
+
return composeStacks(stacks);
|
|
3568
|
+
}
|
|
3569
|
+
var firstLine2 = (e) => {
|
|
3570
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3571
|
+
const m = msg.match(/exited \d+:\s*([\s\S]*)/);
|
|
3572
|
+
return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
|
|
3573
|
+
};
|
|
3574
|
+
function namespaceRuntimeOwners(name, ir) {
|
|
3575
|
+
const own = new Set(ir.nodes.map((n) => n.id));
|
|
3576
|
+
for (const n of ir.nodes) {
|
|
3577
|
+
if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
|
|
3578
|
+
}
|
|
3579
|
+
return ir;
|
|
3580
|
+
}
|
|
3581
|
+
var NAMESPACE_JOIN_FLOOR = "0.44.5";
|
|
3582
|
+
var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
3583
|
+
var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3584
|
+
var realIsDir = (p) => {
|
|
3585
|
+
try {
|
|
3586
|
+
return statSync3(p).isDirectory();
|
|
3587
|
+
} catch {
|
|
3588
|
+
return false;
|
|
3589
|
+
}
|
|
3590
|
+
};
|
|
3591
|
+
function declaredNamespaceBindings(ir) {
|
|
3592
|
+
const out = [];
|
|
3593
|
+
for (const n of ir.nodes) {
|
|
3594
|
+
if (n.kind !== "K8s::Flux::Kustomization") continue;
|
|
3595
|
+
const spec = rec3(n.attrs?.spec);
|
|
3596
|
+
const namespace = str3(spec?.targetNamespace);
|
|
3597
|
+
const path = str3(spec?.path);
|
|
3598
|
+
if (namespace && path) out.push({ path, namespace });
|
|
3599
|
+
}
|
|
3600
|
+
return out;
|
|
3601
|
+
}
|
|
3602
|
+
function awaitsNamespaceBinding(ir) {
|
|
3603
|
+
const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
3604
|
+
if (namespaced.length === 0) return false;
|
|
3605
|
+
return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
|
|
3606
|
+
}
|
|
3607
|
+
function declaredSegments(path) {
|
|
3608
|
+
if (path.startsWith("/")) return void 0;
|
|
3609
|
+
const segs = path.split("/").filter((s) => s !== "" && s !== ".");
|
|
3610
|
+
if (segs.length === 0 || segs.includes("..")) return void 0;
|
|
3611
|
+
return segs;
|
|
3612
|
+
}
|
|
3613
|
+
function pathAlignment(declared, dir, isDir = realIsDir) {
|
|
3614
|
+
const want = declaredSegments(declared);
|
|
3615
|
+
if (!want) return 0;
|
|
3616
|
+
const root = resolve3(dir);
|
|
3617
|
+
const have = root.split(sep).filter(Boolean);
|
|
3618
|
+
for (let k = Math.min(want.length, have.length); k >= 1; k--) {
|
|
3619
|
+
if (!have.slice(-k).every((s, i) => s === want[i])) continue;
|
|
3620
|
+
if (!isDir(join7(root, ...want.slice(k)))) continue;
|
|
3621
|
+
return k;
|
|
3622
|
+
}
|
|
3623
|
+
return 0;
|
|
3624
|
+
}
|
|
3625
|
+
function joinNamespaceBindings(members, isDir = realIsDir) {
|
|
3626
|
+
const bindings = members.flatMap(
|
|
3627
|
+
(m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
|
|
3628
|
+
);
|
|
3629
|
+
if (bindings.length === 0) return [];
|
|
3630
|
+
const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
|
|
3631
|
+
if (awaiting.length === 0) return [];
|
|
3632
|
+
const best = /* @__PURE__ */ new Map();
|
|
3633
|
+
for (const b of bindings) {
|
|
3634
|
+
let winner;
|
|
3635
|
+
let tied = false;
|
|
3636
|
+
for (const m of awaiting) {
|
|
3637
|
+
if (m.dir === b.declaredBy) continue;
|
|
3638
|
+
const score = pathAlignment(b.path, m.dir, isDir);
|
|
3639
|
+
if (score === 0) continue;
|
|
3640
|
+
if (!winner || score > winner.score) {
|
|
3641
|
+
winner = { dir: m.dir, score };
|
|
3642
|
+
tied = false;
|
|
3643
|
+
} else if (score === winner.score) {
|
|
3644
|
+
tied = true;
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
if (!winner || tied) continue;
|
|
3648
|
+
const prev = best.get(winner.dir);
|
|
3649
|
+
if (prev === "ambiguous") continue;
|
|
3650
|
+
const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
|
|
3651
|
+
if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3652
|
+
else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
|
|
3653
|
+
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3654
|
+
}
|
|
3655
|
+
return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
|
|
3656
|
+
}
|
|
3657
|
+
async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
|
|
3658
|
+
if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
|
|
3659
|
+
const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
|
|
3660
|
+
const irs = await mapPool(
|
|
3661
|
+
projectDirs,
|
|
3662
|
+
estateReadPool(projectDirs.length),
|
|
3663
|
+
(dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
|
|
3664
|
+
);
|
|
3665
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
3666
|
+
for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
|
|
3667
|
+
if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
|
|
3668
|
+
scopes.set(j.dir, j.namespace);
|
|
3669
|
+
}
|
|
3670
|
+
return scopes;
|
|
3671
|
+
}
|
|
3672
|
+
function withoutJoinedMembers(nodes, joined) {
|
|
3673
|
+
if (joined.length === 0) return [...nodes];
|
|
3674
|
+
return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
|
|
3675
|
+
}
|
|
3676
|
+
async function composeEstateOverlay(projectDirs, opts, classify) {
|
|
3677
|
+
const names = shortStackNames(projectDirs);
|
|
3678
|
+
const unobserved = [];
|
|
3679
|
+
const dropped = [];
|
|
3680
|
+
const joined = [];
|
|
3681
|
+
const stacks = new Array(projectDirs.length);
|
|
3682
|
+
const scopes = await estateNamespaceScopes(projectDirs, opts);
|
|
3683
|
+
await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
|
|
3684
|
+
const name = names[i];
|
|
3685
|
+
const namespace = scopes.get(dir);
|
|
3686
|
+
try {
|
|
3687
|
+
const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
|
|
3688
|
+
stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
|
|
3689
|
+
if (namespace) joined.push({ name, namespace });
|
|
3690
|
+
} catch (err) {
|
|
3691
|
+
const reason = firstLine2(err);
|
|
3692
|
+
try {
|
|
3693
|
+
const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
|
|
3694
|
+
const src = await memberIr(dir, srcOpts);
|
|
3695
|
+
for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
|
|
3696
|
+
stacks[i] = { name, ir: src };
|
|
3697
|
+
unobserved.push({ name, reason });
|
|
3698
|
+
} catch (err2) {
|
|
3699
|
+
dropped.push({ name, reason: firstLine2(err2) });
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
});
|
|
3703
|
+
const present = stacks.filter((s) => !!s);
|
|
3704
|
+
return {
|
|
3705
|
+
ir: composeStacks(present),
|
|
3706
|
+
observed: present.length - unobserved.length,
|
|
3707
|
+
total: projectDirs.length,
|
|
3708
|
+
unobserved,
|
|
3709
|
+
dropped,
|
|
3710
|
+
// Concurrent reads finish in whatever order the clusters answer; the note
|
|
3711
|
+
// this feeds should read the same twice.
|
|
3712
|
+
joined: joined.sort((a, b) => a.name.localeCompare(b.name))
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
// src/helm-drift.ts
|
|
3717
|
+
function countChanges(docs) {
|
|
3718
|
+
return docs.reduce((n, d) => n + d.changes.length, 0);
|
|
3719
|
+
}
|
|
3720
|
+
function provenanceOf(live) {
|
|
3721
|
+
const m = live.manifest;
|
|
3722
|
+
return {
|
|
3723
|
+
contentDigest: m.contentDigest,
|
|
3724
|
+
inputDigest: m.inputDigest,
|
|
3725
|
+
valuesDigest: m.valuesDigest,
|
|
3726
|
+
chart: m.chart,
|
|
3727
|
+
chartVersion: m.chartVersion,
|
|
3728
|
+
repo: m.repo,
|
|
3729
|
+
releaseName: m.releaseName,
|
|
3730
|
+
namespace: m.namespace,
|
|
3731
|
+
profile: m.capabilityProfile?.cluster ?? "",
|
|
3732
|
+
kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
|
|
3733
|
+
renderedAt: m.renderedAt,
|
|
3734
|
+
helmVersion: m.helmVersion,
|
|
3735
|
+
chantVersion: m.chantVersion,
|
|
3736
|
+
sourceRef: m.sourceRef
|
|
3737
|
+
};
|
|
3738
|
+
}
|
|
3739
|
+
function helmRenderReport(record, live) {
|
|
3740
|
+
const base = { render: record.name, chart: record.chart };
|
|
3741
|
+
if (!live) {
|
|
3742
|
+
return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
|
|
3743
|
+
}
|
|
3744
|
+
const d = live.diff;
|
|
3745
|
+
const counts = {
|
|
3746
|
+
drifted: d.drifted.length,
|
|
3747
|
+
changes: countChanges(d.drifted),
|
|
3748
|
+
unchanged: d.unchanged.length,
|
|
3749
|
+
unobserved: d.unobserved.length,
|
|
3750
|
+
undeclared: d.undeclaredEntities.length
|
|
3751
|
+
};
|
|
3752
|
+
const provenance = provenanceOf(live);
|
|
3753
|
+
if (d.drifted.length > 0) {
|
|
3754
|
+
return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
|
|
3755
|
+
}
|
|
3756
|
+
if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
|
|
3757
|
+
return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
|
|
3758
|
+
}
|
|
3759
|
+
if (d.unobserved.length > 0) {
|
|
3760
|
+
return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
|
|
3761
|
+
}
|
|
3762
|
+
return { ...base, verdict: "in-sync", counts, provenance };
|
|
3763
|
+
}
|
|
3764
|
+
async function readHelmRenderDrift(projectDir, env, opts = {}) {
|
|
3765
|
+
const report = await helmRenders(projectDir, opts).catch(() => void 0);
|
|
3766
|
+
const records = report?.records ?? [];
|
|
3767
|
+
if (records.length === 0) return [];
|
|
3768
|
+
return mapPool(records, estateReadPool(records.length), async (record) => {
|
|
3769
|
+
if (!record.contentDigest) return helmRenderReport(record, void 0);
|
|
3770
|
+
const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
|
|
3771
|
+
return helmRenderReport(record, live);
|
|
3772
|
+
});
|
|
3773
|
+
}
|
|
3774
|
+
function chartNameOf2(node) {
|
|
3775
|
+
const name = node.attrs?.name;
|
|
3776
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
3777
|
+
}
|
|
3778
|
+
var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
|
|
3779
|
+
function applyHelmRenderDrift(ir, reports) {
|
|
3780
|
+
if (reports.length === 0) return { charts: 0, drifted: 0 };
|
|
3781
|
+
const byChart = /* @__PURE__ */ new Map();
|
|
3782
|
+
for (const r of reports) {
|
|
3783
|
+
const list3 = byChart.get(r.chart);
|
|
3784
|
+
if (list3) list3.push(r);
|
|
3785
|
+
else byChart.set(r.chart, [r]);
|
|
3786
|
+
}
|
|
3787
|
+
let charts = 0;
|
|
3788
|
+
let drifted = 0;
|
|
3789
|
+
for (const node of ir.nodes) {
|
|
3790
|
+
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
3791
|
+
const chartName = chartNameOf2(node);
|
|
3792
|
+
const matched = chartName ? byChart.get(chartName) : void 0;
|
|
3793
|
+
if (!matched || matched.length === 0) continue;
|
|
3794
|
+
const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
|
|
3795
|
+
const attrs = node.attrs ??= {};
|
|
3796
|
+
attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
|
|
3797
|
+
charts++;
|
|
3798
|
+
if (worst.verdict === "drifted") {
|
|
3799
|
+
attrs._status = "warn";
|
|
3800
|
+
drifted++;
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
return { charts, drifted };
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
// src/cluster-anchor.ts
|
|
3807
|
+
var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
|
|
3808
|
+
"AWS::EKS::Cluster",
|
|
3809
|
+
"GCP::Container::Cluster",
|
|
3810
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3811
|
+
"K3d::Cluster"
|
|
3812
|
+
]);
|
|
3813
|
+
var ANCHOR_VIA = "runs-on";
|
|
3814
|
+
function metadata(node) {
|
|
3815
|
+
const m = node.attrs?.metadata;
|
|
3816
|
+
return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
|
|
3817
|
+
}
|
|
3818
|
+
function metaString(node, key) {
|
|
3819
|
+
const v = metadata(node)?.[key];
|
|
3820
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3821
|
+
}
|
|
3822
|
+
function isK8s(node) {
|
|
3823
|
+
return node.lexicon === "k8s";
|
|
3824
|
+
}
|
|
3825
|
+
function isNamespace(node) {
|
|
3826
|
+
return node.kind === "K8s::Core::Namespace";
|
|
3827
|
+
}
|
|
3828
|
+
function managedClusterName(node) {
|
|
3829
|
+
const metaName = metaString(node, "name");
|
|
3830
|
+
if (metaName) return metaName;
|
|
3831
|
+
for (const key of ["name", "clusterName"]) {
|
|
3832
|
+
const v = node.attrs?.[key];
|
|
3833
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
3834
|
+
}
|
|
3835
|
+
return node.id;
|
|
3836
|
+
}
|
|
3837
|
+
function boundManagedCluster(nodes, boundContext) {
|
|
3838
|
+
const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
|
|
3839
|
+
if (clusters.length === 1) return clusters[0];
|
|
3840
|
+
if (clusters.length === 0 || !boundContext) return void 0;
|
|
3841
|
+
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
|
|
3842
|
+
return bound.length === 1 ? bound[0] : void 0;
|
|
3843
|
+
}
|
|
3844
|
+
function addClusterAnchorEdges(ir, boundContext) {
|
|
3845
|
+
const nodes = ir.nodes;
|
|
3846
|
+
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
3847
|
+
if (!cluster) return ir;
|
|
3848
|
+
const k8sNodes = nodes.filter(isK8s);
|
|
3849
|
+
if (k8sNodes.length === 0) return ir;
|
|
3850
|
+
const namespaceByName = /* @__PURE__ */ new Map();
|
|
3851
|
+
for (const n of k8sNodes) {
|
|
3852
|
+
if (!isNamespace(n)) continue;
|
|
3853
|
+
const name = metaString(n, "name");
|
|
3854
|
+
if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
|
|
3855
|
+
}
|
|
3856
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3857
|
+
const add = (from, to) => {
|
|
3858
|
+
if (from === to) return;
|
|
3859
|
+
const key = `${from}\0${to}`;
|
|
3860
|
+
if (existing.has(key)) return;
|
|
3861
|
+
existing.add(key);
|
|
3862
|
+
ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
|
|
3863
|
+
};
|
|
3864
|
+
for (const n of k8sNodes) {
|
|
3865
|
+
if (isNamespace(n)) {
|
|
3866
|
+
add(cluster.id, n.id);
|
|
3867
|
+
continue;
|
|
3868
|
+
}
|
|
3869
|
+
const ns = metaString(n, "namespace");
|
|
3870
|
+
const nsNode = ns ? namespaceByName.get(ns) : void 0;
|
|
3871
|
+
add(nsNode ?? cluster.id, n.id);
|
|
3872
|
+
}
|
|
3873
|
+
return ir;
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
// src/logical-azure.ts
|
|
3877
|
+
var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
3878
|
+
// compute / workloads
|
|
3879
|
+
"Microsoft.Compute/virtualMachines",
|
|
3880
|
+
"Microsoft.Compute/virtualMachineScaleSets",
|
|
3881
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3882
|
+
"Microsoft.ContainerInstance/containerGroups",
|
|
3883
|
+
"Microsoft.Web/sites",
|
|
3884
|
+
// network edge
|
|
3885
|
+
"Microsoft.Network/publicIPAddresses",
|
|
3886
|
+
"Microsoft.Network/loadBalancers",
|
|
3887
|
+
"Microsoft.Network/applicationGateways",
|
|
3888
|
+
"Microsoft.Network/natGateways",
|
|
3889
|
+
"Microsoft.Network/dnsZones",
|
|
3890
|
+
// data stores
|
|
3891
|
+
"Microsoft.Storage/storageAccounts",
|
|
3892
|
+
"Microsoft.Sql/servers",
|
|
3893
|
+
"Microsoft.DocumentDB/databaseAccounts",
|
|
3894
|
+
"Microsoft.Cache/redis",
|
|
3895
|
+
"Microsoft.DBforPostgreSQL/flexibleServers",
|
|
3896
|
+
// registry / identity edge
|
|
3897
|
+
"Microsoft.ContainerRegistry/registries"
|
|
3898
|
+
]);
|
|
3899
|
+
var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
3900
|
+
"Microsoft.Network/virtualNetworks",
|
|
3901
|
+
"Microsoft.Network/virtualNetworks_subnets",
|
|
3902
|
+
"Microsoft.Network/networkSecurityGroups",
|
|
3903
|
+
"Microsoft.Network/routeTables",
|
|
3904
|
+
"Microsoft.Network/networkInterfaces",
|
|
3905
|
+
// identity/authorization hubs — a role assignment or managed identity is
|
|
3906
|
+
// referenced by half the estate, so contracting through one invents edges
|
|
3907
|
+
// between unrelated resources.
|
|
3908
|
+
"Microsoft.Authorization/roleAssignments",
|
|
3909
|
+
"Microsoft.ManagedIdentity/userAssignedIdentities",
|
|
3910
|
+
"Microsoft.KeyVault/vaults"
|
|
3911
|
+
]);
|
|
3912
|
+
var VNET_KIND = "Microsoft.Network/virtualNetworks";
|
|
3913
|
+
var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
|
|
3914
|
+
var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
|
|
3915
|
+
var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
|
|
3916
|
+
var QUOTED_RE = /'([^']*)'/g;
|
|
3917
|
+
function resourceIdNames(value) {
|
|
3918
|
+
if (typeof value !== "string" || !value.includes("resourceId(")) return [];
|
|
3919
|
+
const out = [];
|
|
3920
|
+
for (const call of value.matchAll(RESOURCE_ID_RE)) {
|
|
3921
|
+
const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
|
|
3922
|
+
if (args.length >= 2) out.push(args[args.length - 1]);
|
|
3923
|
+
}
|
|
3924
|
+
return out;
|
|
3925
|
+
}
|
|
3926
|
+
function collectAzureRefs(value, out) {
|
|
3927
|
+
if (value === null || typeof value !== "object") {
|
|
3928
|
+
for (const name of resourceIdNames(value)) out.add(name);
|
|
3929
|
+
return;
|
|
3930
|
+
}
|
|
3931
|
+
const ref = value.$ref;
|
|
3932
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
3933
|
+
for (const v of Object.values(value)) collectAzureRefs(v, out);
|
|
3934
|
+
}
|
|
3935
|
+
function strAttr(node, key) {
|
|
3936
|
+
const v = node.attrs?.[key];
|
|
3937
|
+
return typeof v === "string" ? v : void 0;
|
|
3938
|
+
}
|
|
3939
|
+
function resourceName(node) {
|
|
3940
|
+
return strAttr(node, "name");
|
|
3941
|
+
}
|
|
3942
|
+
function subnetVnetName(node) {
|
|
3943
|
+
const name = resourceName(node);
|
|
3944
|
+
if (!name || !name.includes("/")) return void 0;
|
|
3945
|
+
return name.split("/")[0];
|
|
3946
|
+
}
|
|
3947
|
+
function subnetShortName(node) {
|
|
3948
|
+
const name = resourceName(node);
|
|
3949
|
+
return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
|
|
3950
|
+
}
|
|
3951
|
+
function vnetCidr(node) {
|
|
3952
|
+
const space = node.attrs?.addressSpace;
|
|
3953
|
+
const prefixes = space?.addressPrefixes;
|
|
3954
|
+
return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
|
|
3955
|
+
}
|
|
3956
|
+
function projectAzureLogical(ir, env) {
|
|
3957
|
+
const azure = ir.nodes.filter((n) => n.lexicon === "azure");
|
|
3958
|
+
if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
3959
|
+
const componentOf = componentNamer(azure);
|
|
3960
|
+
const byId = new Map(azure.map((n) => [n.id, n]));
|
|
3961
|
+
const byName = /* @__PURE__ */ new Map();
|
|
3962
|
+
for (const n of azure) {
|
|
3963
|
+
const name = resourceName(n);
|
|
3964
|
+
if (name && !byName.has(name)) byName.set(name, n);
|
|
3965
|
+
}
|
|
3966
|
+
const vnets = azure.filter((n) => n.kind === VNET_KIND);
|
|
3967
|
+
const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
|
|
3968
|
+
const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
|
|
3969
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
3970
|
+
for (const n of azure) {
|
|
3971
|
+
const names = /* @__PURE__ */ new Set();
|
|
3972
|
+
collectAzureRefs(n.attrs, names);
|
|
3973
|
+
const targets = /* @__PURE__ */ new Set();
|
|
3974
|
+
for (const name of names) {
|
|
3975
|
+
const target = byName.get(name) ?? byId.get(name);
|
|
3976
|
+
if (target && target.id !== n.id) targets.add(target.id);
|
|
3977
|
+
}
|
|
3978
|
+
if (targets.size) refOut.set(n.id, targets);
|
|
3979
|
+
}
|
|
3980
|
+
for (const e of ir.edges) {
|
|
3981
|
+
if (!byId.has(e.from) || !byId.has(e.to)) continue;
|
|
3982
|
+
(refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
|
|
3983
|
+
}
|
|
3984
|
+
const RG_TITLE = env ? `resource group ${env}` : "resource group";
|
|
3985
|
+
const vnetTitle = (v) => `VNet ${vnetCidr(v) ?? resourceName(v) ?? v.id}`;
|
|
3986
|
+
const subnetTitle = (s) => `subnet ${strAttr(s, "addressPrefix") ?? subnetShortName(s) ?? s.id}`;
|
|
3987
|
+
const guardedBy = /* @__PURE__ */ new Map();
|
|
3988
|
+
for (const s of subnets) {
|
|
3989
|
+
const names = /* @__PURE__ */ new Set();
|
|
3990
|
+
collectAzureRefs(s.attrs?.networkSecurityGroup, names);
|
|
3991
|
+
for (const name of names) {
|
|
3992
|
+
if (byName.get(name)?.kind === NSG_KIND) guardedBy.set(s.id, name);
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
const headline = azure.filter((n) => AZURE_HEADLINE_KINDS.has(n.kind));
|
|
3996
|
+
const subnetIds = new Set(subnets.map((s) => s.id));
|
|
3997
|
+
const vnetIds = new Set(vnets.map((v) => v.id));
|
|
3998
|
+
const nearest = (start, want, maxDepth = 6) => {
|
|
3999
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
4000
|
+
let frontier = [...refOut.get(start) ?? []];
|
|
4001
|
+
for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
|
|
4002
|
+
const next = [];
|
|
4003
|
+
for (const id of frontier) {
|
|
4004
|
+
if (seen.has(id)) continue;
|
|
4005
|
+
seen.add(id);
|
|
4006
|
+
if (want.has(id)) return id;
|
|
4007
|
+
for (const r of refOut.get(id) ?? []) if (!seen.has(r)) next.push(r);
|
|
4008
|
+
}
|
|
4009
|
+
frontier = next;
|
|
4010
|
+
}
|
|
4011
|
+
return void 0;
|
|
4012
|
+
};
|
|
4013
|
+
const byContainer = {};
|
|
4014
|
+
const child = (parent, c) => {
|
|
4015
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
4016
|
+
if (!arr.includes(c)) arr.push(c);
|
|
4017
|
+
};
|
|
4018
|
+
for (const v of vnets) child(RG_TITLE, vnetTitle(v));
|
|
4019
|
+
for (const s of subnets) {
|
|
3378
4020
|
const parentVnet = subnetVnetName(s);
|
|
3379
4021
|
const v = parentVnet ? vnetByName.get(parentVnet) : void 0;
|
|
3380
4022
|
child(v ? vnetTitle(v) : RG_TITLE, subnetTitle(s));
|
|
@@ -4534,9 +5176,9 @@ function helmIconFor(kind) {
|
|
|
4534
5176
|
var SUPPORTED_MAJOR = 1;
|
|
4535
5177
|
var refuse = (error, remedy) => ({ ok: false, refusal: { error, code: "carve-report", remedy } });
|
|
4536
5178
|
var HOW_TO_GET_ONE = "Generate one with `chant carve advise --from <terraform-dir> --report report.json` (chant's read-only Terraform peelability advisor), then `behold carve report.json`.";
|
|
4537
|
-
var
|
|
5179
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4538
5180
|
function parseCarveReport(value) {
|
|
4539
|
-
if (!
|
|
5181
|
+
if (!isRecord2(value)) {
|
|
4540
5182
|
return refuse("That file isn't a carve report \u2014 its top level isn't a JSON object.", HOW_TO_GET_ONE);
|
|
4541
5183
|
}
|
|
4542
5184
|
if (!Array.isArray(value.resources)) {
|
|
@@ -4546,7 +5188,7 @@ function parseCarveReport(value) {
|
|
|
4546
5188
|
);
|
|
4547
5189
|
}
|
|
4548
5190
|
const bad = value.resources.findIndex(
|
|
4549
|
-
(r) => !
|
|
5191
|
+
(r) => !isRecord2(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string"
|
|
4550
5192
|
);
|
|
4551
5193
|
if (bad >= 0) {
|
|
4552
5194
|
return refuse(
|
|
@@ -4612,7 +5254,7 @@ function boundaryEdgesOf(r) {
|
|
|
4612
5254
|
const b = r.boundary;
|
|
4613
5255
|
if (!b) return [];
|
|
4614
5256
|
const list3 = Array.isArray(b) ? b : [...b.inbound ?? [], ...b.outbound ?? []];
|
|
4615
|
-
return list3.filter((e) =>
|
|
5257
|
+
return list3.filter((e) => isRecord2(e) && typeof e.survivor === "string" && typeof e.carved === "string");
|
|
4616
5258
|
}
|
|
4617
5259
|
function scoreArithmetic(r) {
|
|
4618
5260
|
const b = r.breakdown;
|
|
@@ -4728,10 +5370,10 @@ import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as
|
|
|
4728
5370
|
import { dirname as dirname4, join as join10 } from "node:path";
|
|
4729
5371
|
var CARVE_MANIFEST_SUFFIX = ".carve.json";
|
|
4730
5372
|
var APPLY_IS_HUMAN = "`chant carve apply` graduates ownership \u2014 it resolves the marker that makes chant the owner of a live resource. behold shows what the manifest records and echoes the command; it has no apply endpoint and no apply button, by design. Read the graduation plan, then run it yourself.";
|
|
4731
|
-
var
|
|
5373
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4732
5374
|
var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
4733
5375
|
function parseCarveManifest(value) {
|
|
4734
|
-
if (!
|
|
5376
|
+
if (!isRecord3(value)) return null;
|
|
4735
5377
|
if (value.version !== 1) return null;
|
|
4736
5378
|
if (typeof value.target !== "string" || !value.target) return null;
|
|
4737
5379
|
return value;
|
|
@@ -4975,9 +5617,9 @@ function carveStateNote(progress, states) {
|
|
|
4975
5617
|
var SUPPORTED_MAJOR2 = 1;
|
|
4976
5618
|
var refuse2 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
|
|
4977
5619
|
var HOW_TO_EMIT = "Build the project with a chant >= 0.50 (`chant build --lexicon temporal`), which writes `dist/ops/<name>/op.json` beside each Op's generated workflow.";
|
|
4978
|
-
var
|
|
5620
|
+
var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4979
5621
|
function parseOpIr(value) {
|
|
4980
|
-
if (!
|
|
5622
|
+
if (!isRecord4(value)) {
|
|
4981
5623
|
return refuse2("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
|
|
4982
5624
|
}
|
|
4983
5625
|
if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
|
|
@@ -4986,7 +5628,7 @@ function parseOpIr(value) {
|
|
|
4986
5628
|
HOW_TO_EMIT
|
|
4987
5629
|
);
|
|
4988
5630
|
}
|
|
4989
|
-
const bad = value.phases.findIndex((p) => !
|
|
5631
|
+
const bad = value.phases.findIndex((p) => !isRecord4(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
|
|
4990
5632
|
if (bad >= 0) {
|
|
4991
5633
|
return refuse2(
|
|
4992
5634
|
`That JSON has a \`phases\` array, but entry ${bad} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
|
|
@@ -5083,14 +5725,14 @@ function opPlacedSteps(op) {
|
|
|
5083
5725
|
}
|
|
5084
5726
|
var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
|
|
5085
5727
|
var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
|
|
5086
|
-
var isPredicate = (v) =>
|
|
5728
|
+
var isPredicate = (v) => isRecord4(v) && typeof v.kind === "string";
|
|
5087
5729
|
function isConvergeRule(v) {
|
|
5088
|
-
if (!
|
|
5730
|
+
if (!isRecord4(v)) return false;
|
|
5089
5731
|
if (typeof v.id !== "string" || !v.id) return false;
|
|
5090
5732
|
if (typeof v.why !== "string" || !v.why) return false;
|
|
5091
5733
|
if (!isPredicate(v.when)) return false;
|
|
5092
5734
|
const then = v.then;
|
|
5093
|
-
if (!
|
|
5735
|
+
if (!isRecord4(then)) return false;
|
|
5094
5736
|
return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
|
|
5095
5737
|
}
|
|
5096
5738
|
function convergeTable(op) {
|
|
@@ -5999,6 +6641,34 @@ function reanchorEdges(svg, moved) {
|
|
|
5999
6641
|
import { spawn as spawn3 } from "node:child_process";
|
|
6000
6642
|
import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
|
|
6001
6643
|
import { join as join12 } from "node:path";
|
|
6644
|
+
|
|
6645
|
+
// src/scratch.ts
|
|
6646
|
+
var PROTECTED_INFRA = [
|
|
6647
|
+
/^floci(-|$)/,
|
|
6648
|
+
/^chant-floci(-|$)/,
|
|
6649
|
+
/^k3d-kubemicrovm-local$/,
|
|
6650
|
+
/^k3d-fountain-local$/,
|
|
6651
|
+
/^kubemicrovm-local$/,
|
|
6652
|
+
/^fountain-local$/
|
|
6653
|
+
];
|
|
6654
|
+
var PROTECTED_PORTS = [4566, 4577, 4588];
|
|
6655
|
+
var SCRATCH_PREFIX = "behold-";
|
|
6656
|
+
function scratchNameProblem(name) {
|
|
6657
|
+
if (!name.startsWith(SCRATCH_PREFIX)) return `scratch infra must be named ${SCRATCH_PREFIX}*, got ${JSON.stringify(name)}`;
|
|
6658
|
+
const hit = PROTECTED_INFRA.find((re) => re.test(name));
|
|
6659
|
+
if (hit) return `${JSON.stringify(name)} is protected infrastructure (${hit}) \u2014 behold never touches it`;
|
|
6660
|
+
return void 0;
|
|
6661
|
+
}
|
|
6662
|
+
function scratchPortProblem(port) {
|
|
6663
|
+
if (PROTECTED_PORTS.includes(port)) return `port ${port} belongs to a shared emulator \u2014 a scratch substrate gets its own`;
|
|
6664
|
+
return void 0;
|
|
6665
|
+
}
|
|
6666
|
+
function assertScratch(name, port) {
|
|
6667
|
+
const p = scratchNameProblem(name) ?? (port === void 0 ? void 0 : scratchPortProblem(port));
|
|
6668
|
+
if (p) throw new Error(`scratch discipline: ${p}`);
|
|
6669
|
+
}
|
|
6670
|
+
|
|
6671
|
+
// src/carve-live.ts
|
|
6002
6672
|
var LIVE_CONTAINER = "behold-carve-floci";
|
|
6003
6673
|
var LIVE_PORT = 4602;
|
|
6004
6674
|
var LIVE_IMAGE = "floci/floci:1.5.34";
|
|
@@ -6029,973 +6699,692 @@ function capture(cmd, args, cwd) {
|
|
|
6029
6699
|
child.on("error", () => res({ code: -1, stdout: out }));
|
|
6030
6700
|
child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
|
|
6031
6701
|
});
|
|
6032
|
-
}
|
|
6033
|
-
async function bootScratchFloci() {
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
if (ps.
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
|
|
6048
|
-
if (health.ok && (await health.text()).includes("cloudformation")) return void 0;
|
|
6049
|
-
} catch {
|
|
6050
|
-
}
|
|
6051
|
-
await new Promise((r) => setTimeout(r, 1e3));
|
|
6052
|
-
}
|
|
6053
|
-
await teardownScratchFloci();
|
|
6054
|
-
return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
|
|
6055
|
-
}
|
|
6056
|
-
async function teardownScratchFloci() {
|
|
6057
|
-
await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
|
|
6058
|
-
}
|
|
6059
|
-
async function applyIntoFloci(fromDir, step) {
|
|
6060
|
-
const template = join12(fromDir, "floci-override.tf.disabled");
|
|
6061
|
-
if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
|
|
6062
|
-
writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
|
|
6063
|
-
const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
|
|
6064
|
-
if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
|
|
6065
|
-
const apply = await step(
|
|
6066
|
-
"terraform",
|
|
6067
|
-
["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
|
|
6068
|
-
fromDir
|
|
6069
|
-
);
|
|
6070
|
-
if (apply !== 0) return `terraform apply exited ${apply}`;
|
|
6071
|
-
return void 0;
|
|
6072
|
-
}
|
|
6073
|
-
async function runLivePlan(fromDir) {
|
|
6074
|
-
const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
|
|
6075
|
-
if (r.code !== 0 && r.code !== 2) {
|
|
6076
|
-
const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
|
|
6077
|
-
return { error: `terraform plan failed (exit ${r.code}):
|
|
6078
|
-
${tail}`, exitCode: r.code };
|
|
6079
|
-
}
|
|
6080
|
-
return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
|
|
6081
|
-
}
|
|
6082
|
-
|
|
6083
|
-
// src/carve-actions.ts
|
|
6084
|
-
async function runCarvePlan(demo) {
|
|
6085
|
-
if (!demo?.live) {
|
|
6086
|
-
return refuse3(
|
|
6087
|
-
"carve-action",
|
|
6088
|
-
"terraform plan is the live tier's beat, and this server isn't serving one",
|
|
6089
|
-
"run `behold demo carve --live` (needs docker + terraform)"
|
|
6090
|
-
);
|
|
6091
|
-
}
|
|
6092
|
-
const r = await runLivePlan(demo.from);
|
|
6093
|
-
if ("error" in r) {
|
|
6094
|
-
return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
|
|
6095
|
-
}
|
|
6096
|
-
return {
|
|
6097
|
-
ok: true,
|
|
6098
|
-
command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
|
|
6099
|
-
planLine: r.planLine,
|
|
6100
|
-
changes: r.changes,
|
|
6101
|
-
noDestroy: r.noDestroy
|
|
6102
|
-
};
|
|
6103
|
-
}
|
|
6104
|
-
async function runCarveObserve(demo, select) {
|
|
6105
|
-
if (!demo.live) {
|
|
6106
|
-
return refuse3(
|
|
6107
|
-
"carve-action",
|
|
6108
|
-
"the observe beat reads the scratch Floci, and this server isn't serving one",
|
|
6109
|
-
"run `behold demo carve --live` (needs docker + terraform)"
|
|
6110
|
-
);
|
|
6111
|
-
}
|
|
6112
|
-
if (!existsSync8(join13(demo.out, "src"))) {
|
|
6113
|
-
return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
|
|
6114
|
-
}
|
|
6115
|
-
const args = ["lifecycle", "diff", "prod", "--live", "--json"];
|
|
6116
|
-
const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
|
|
6117
|
-
code: 127,
|
|
6118
|
-
stdout: "",
|
|
6119
|
-
stderr: err instanceof Error ? err.message : String(err)
|
|
6120
|
-
}));
|
|
6121
|
-
if (run3.code !== 0) {
|
|
6122
|
-
return refuse3(
|
|
6123
|
-
"carve-action",
|
|
6124
|
-
`chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
|
|
6125
|
-
"Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
|
|
6126
|
-
);
|
|
6127
|
-
}
|
|
6128
|
-
let parsed;
|
|
6129
|
-
try {
|
|
6130
|
-
parsed = JSON.parse(run3.stdout);
|
|
6131
|
-
} catch {
|
|
6132
|
-
return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
|
|
6133
|
-
}
|
|
6134
|
-
if (parsed.stacks && !parsed.lexicons) {
|
|
6135
|
-
return refuse3(
|
|
6136
|
-
"carve-action",
|
|
6137
|
-
"chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
|
|
6138
|
-
"point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
|
|
6139
|
-
);
|
|
6140
|
-
}
|
|
6141
|
-
const aws = parsed.lexicons?.aws ?? {};
|
|
6142
|
-
const entity = select.split(".").pop() ?? select;
|
|
6143
|
-
const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
|
|
6144
|
-
const queried = aws.resources?.queried?.[entity];
|
|
6145
|
-
const meta = aws.observed?.[entity];
|
|
6146
|
-
if (meta) {
|
|
6147
|
-
return {
|
|
6148
|
-
ok: true,
|
|
6149
|
-
select,
|
|
6150
|
-
command,
|
|
6151
|
-
entity,
|
|
6152
|
-
verdict: "observed",
|
|
6153
|
-
...meta.status ? { status: meta.status } : {},
|
|
6154
|
-
...meta.ownership ? { ownership: meta.ownership } : {},
|
|
6155
|
-
...meta.physicalId ? { physicalId: meta.physicalId } : {},
|
|
6156
|
-
...queried ? { queried } : {}
|
|
6157
|
-
};
|
|
6158
|
-
}
|
|
6159
|
-
const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
|
|
6160
|
-
if (hole) {
|
|
6161
|
-
return {
|
|
6162
|
-
ok: true,
|
|
6163
|
-
select,
|
|
6164
|
-
command,
|
|
6165
|
-
entity,
|
|
6166
|
-
verdict: "unobserved",
|
|
6167
|
-
...hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {},
|
|
6168
|
-
...queried ? { queried } : {}
|
|
6169
|
-
};
|
|
6170
|
-
}
|
|
6171
|
-
return { ok: true, select, command, entity, verdict: "missing", ...queried ? { queried } : {} };
|
|
6172
|
-
}
|
|
6173
|
-
var MAX_ARTIFACT_BYTES = 64 * 1024;
|
|
6174
|
-
var MAX_ARTIFACTS = 24;
|
|
6175
|
-
var MAX_OUTPUT_BYTES = 32 * 1024;
|
|
6176
|
-
var refuse3 = (code, error, remedy) => ({
|
|
6177
|
-
ok: false,
|
|
6178
|
-
refusal: { error, code, remedy }
|
|
6179
|
-
});
|
|
6180
|
-
var BUILD_CAVEAT = "The gate shown is `chant lint`, not `chant build`. chant#1637's fold is applied now: the bucket's versioning and public-access-block sub-resources come out as native props, not just folded-in mentions. `chant build` still fails, but on one rule only \u2014 WAW042, a TLS-deny bucket policy the source Terraform never declared. That's real drift the estate inherited, not a gap in the carve. example-carve/README.md, \"Why build still fails\", has the full statement.";
|
|
6181
|
-
function insideDemo(root, p) {
|
|
6182
|
-
const rel = relative2(resolve4(root), resolve4(p));
|
|
6183
|
-
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
6184
|
-
}
|
|
6185
|
-
function selectFromReport(report, raw) {
|
|
6186
|
-
if (typeof raw !== "string" || !raw || raw.length > 512) return null;
|
|
6187
|
-
return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null;
|
|
6188
|
-
}
|
|
6189
|
-
function carveWriteBlock(demo) {
|
|
6190
|
-
if (!demo) {
|
|
6191
|
-
return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
|
|
6192
|
-
}
|
|
6193
|
-
if (!existsSync8(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
|
|
6194
|
-
if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
|
|
6195
|
-
return unwritableReason(demo.root);
|
|
6196
|
-
}
|
|
6197
|
-
function readArtifacts(demo, dir, filter) {
|
|
6198
|
-
const out = [];
|
|
6199
|
-
const walk = (d, depth) => {
|
|
6200
|
-
if (out.length >= MAX_ARTIFACTS || depth > 2) return;
|
|
6201
|
-
let entries;
|
|
6702
|
+
}
|
|
6703
|
+
async function bootScratchFloci() {
|
|
6704
|
+
assertScratch(LIVE_CONTAINER, LIVE_PORT);
|
|
6705
|
+
const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
|
|
6706
|
+
if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
|
|
6707
|
+
if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
|
|
6708
|
+
return `container ${LIVE_CONTAINER} already exists \u2014 a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
|
|
6709
|
+
}
|
|
6710
|
+
const run3 = await capture(
|
|
6711
|
+
"docker",
|
|
6712
|
+
["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
|
|
6713
|
+
process.cwd()
|
|
6714
|
+
);
|
|
6715
|
+
if (run3.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run3.code})`;
|
|
6716
|
+
for (let i = 0; i < 30; i++) {
|
|
6202
6717
|
try {
|
|
6203
|
-
|
|
6718
|
+
const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
|
|
6719
|
+
if (health.ok && (await health.text()).includes("cloudformation")) return void 0;
|
|
6204
6720
|
} catch {
|
|
6205
|
-
return;
|
|
6206
|
-
}
|
|
6207
|
-
for (const name of entries) {
|
|
6208
|
-
if (out.length >= MAX_ARTIFACTS) return;
|
|
6209
|
-
if (name === "node_modules" || name.startsWith(".")) continue;
|
|
6210
|
-
const full = join13(d, name);
|
|
6211
|
-
let st;
|
|
6212
|
-
try {
|
|
6213
|
-
st = statSync5(full);
|
|
6214
|
-
} catch {
|
|
6215
|
-
continue;
|
|
6216
|
-
}
|
|
6217
|
-
if (st.isDirectory()) {
|
|
6218
|
-
walk(full, depth + 1);
|
|
6219
|
-
continue;
|
|
6220
|
-
}
|
|
6221
|
-
const rel = relative2(demo.root, full).split(sep2).join("/");
|
|
6222
|
-
if (!filter(rel)) continue;
|
|
6223
|
-
let text = "";
|
|
6224
|
-
try {
|
|
6225
|
-
text = readFileSync10(full, "utf8");
|
|
6226
|
-
} catch {
|
|
6227
|
-
continue;
|
|
6228
|
-
}
|
|
6229
|
-
const truncated = text.length > MAX_ARTIFACT_BYTES;
|
|
6230
|
-
out.push({
|
|
6231
|
-
path: rel,
|
|
6232
|
-
kind: (name.split(".").pop() ?? "").toLowerCase(),
|
|
6233
|
-
bytes: st.size,
|
|
6234
|
-
text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text,
|
|
6235
|
-
truncated
|
|
6236
|
-
});
|
|
6237
6721
|
}
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6722
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
6723
|
+
}
|
|
6724
|
+
await teardownScratchFloci();
|
|
6725
|
+
return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
|
|
6241
6726
|
}
|
|
6242
|
-
function
|
|
6243
|
-
|
|
6727
|
+
async function teardownScratchFloci() {
|
|
6728
|
+
await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
|
|
6244
6729
|
}
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6730
|
+
async function applyIntoFloci(fromDir, step) {
|
|
6731
|
+
const template = join12(fromDir, "floci-override.tf.disabled");
|
|
6732
|
+
if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
|
|
6733
|
+
writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
|
|
6734
|
+
const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
|
|
6735
|
+
if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
|
|
6736
|
+
const apply = await step(
|
|
6737
|
+
"terraform",
|
|
6738
|
+
["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
|
|
6739
|
+
fromDir
|
|
6740
|
+
);
|
|
6741
|
+
if (apply !== 0) return `terraform apply exited ${apply}`;
|
|
6742
|
+
return void 0;
|
|
6248
6743
|
}
|
|
6249
|
-
function
|
|
6250
|
-
const
|
|
6251
|
-
|
|
6744
|
+
async function runLivePlan(fromDir) {
|
|
6745
|
+
const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
|
|
6746
|
+
if (r.code !== 0 && r.code !== 2) {
|
|
6747
|
+
const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
|
|
6748
|
+
return { error: `terraform plan failed (exit ${r.code}):
|
|
6749
|
+
${tail}`, exitCode: r.code };
|
|
6750
|
+
}
|
|
6751
|
+
return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
|
|
6252
6752
|
}
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
|
|
6258
|
-
const args = [
|
|
6259
|
-
"carve",
|
|
6260
|
-
"emit",
|
|
6261
|
-
"--from",
|
|
6262
|
-
demo.from,
|
|
6263
|
-
...demo.state ? ["--state", demo.state] : [],
|
|
6264
|
-
"--select",
|
|
6265
|
-
select,
|
|
6266
|
-
"--output",
|
|
6267
|
-
demo.out,
|
|
6268
|
-
"--report",
|
|
6269
|
-
reportFile
|
|
6270
|
-
];
|
|
6271
|
-
const run3 = await runChantRaw(args, demo.project).catch((err) => ({
|
|
6272
|
-
code: 127,
|
|
6273
|
-
stdout: "",
|
|
6274
|
-
stderr: err instanceof Error ? err.message : String(err)
|
|
6275
|
-
}));
|
|
6276
|
-
const output = merge(run3, demo.root);
|
|
6277
|
-
if (run3.code !== 0) {
|
|
6753
|
+
|
|
6754
|
+
// src/carve-actions.ts
|
|
6755
|
+
async function runCarvePlan(demo) {
|
|
6756
|
+
if (!demo?.live) {
|
|
6278
6757
|
return refuse3(
|
|
6279
6758
|
"carve-action",
|
|
6280
|
-
|
|
6281
|
-
"
|
|
6759
|
+
"terraform plan is the live tier's beat, and this server isn't serving one",
|
|
6760
|
+
"run `behold demo carve --live` (needs docker + terraform)"
|
|
6282
6761
|
);
|
|
6283
6762
|
}
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
} catch {
|
|
6763
|
+
const r = await runLivePlan(demo.from);
|
|
6764
|
+
if ("error" in r) {
|
|
6765
|
+
return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
|
|
6288
6766
|
}
|
|
6289
|
-
const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
|
|
6290
|
-
const lintArgs = ["lint", lintPath];
|
|
6291
|
-
const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
|
|
6292
|
-
code: 127,
|
|
6293
|
-
stdout: "",
|
|
6294
|
-
stderr: err instanceof Error ? err.message : String(err)
|
|
6295
|
-
}));
|
|
6296
6767
|
return {
|
|
6297
6768
|
ok: true,
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
// step is about, and a package.json sorting above it buries the answer.
|
|
6303
|
-
artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort(
|
|
6304
|
-
(a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path))
|
|
6305
|
-
),
|
|
6306
|
-
boundary,
|
|
6307
|
-
lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) },
|
|
6308
|
-
buildCaveat: BUILD_CAVEAT
|
|
6769
|
+
command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
|
|
6770
|
+
planLine: r.planLine,
|
|
6771
|
+
changes: r.changes,
|
|
6772
|
+
noDestroy: r.noDestroy
|
|
6309
6773
|
};
|
|
6310
6774
|
}
|
|
6311
|
-
async function
|
|
6312
|
-
|
|
6313
|
-
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
6314
|
-
mkdirSync3(demo.out, { recursive: true });
|
|
6315
|
-
const args = [
|
|
6316
|
-
"carve",
|
|
6317
|
-
"bridge",
|
|
6318
|
-
"--from",
|
|
6319
|
-
demo.from,
|
|
6320
|
-
...demo.state ? ["--state", demo.state] : [],
|
|
6321
|
-
"--select",
|
|
6322
|
-
select,
|
|
6323
|
-
"--output",
|
|
6324
|
-
demo.out
|
|
6325
|
-
];
|
|
6326
|
-
const run3 = await runChantRaw(args, demo.project).catch((err) => ({
|
|
6327
|
-
code: 127,
|
|
6328
|
-
stdout: "",
|
|
6329
|
-
stderr: err instanceof Error ? err.message : String(err)
|
|
6330
|
-
}));
|
|
6331
|
-
const output = merge(run3, demo.root);
|
|
6332
|
-
if (run3.code !== 0) {
|
|
6775
|
+
async function runCarveObserve(demo, select) {
|
|
6776
|
+
if (!demo.live) {
|
|
6333
6777
|
return refuse3(
|
|
6334
6778
|
"carve-action",
|
|
6335
|
-
|
|
6336
|
-
"
|
|
6779
|
+
"the observe beat reads the scratch Floci, and this server isn't serving one",
|
|
6780
|
+
"run `behold demo carve --live` (needs docker + terraform)"
|
|
6337
6781
|
);
|
|
6338
6782
|
}
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
var APPLY_TARGET_LEXICON = {
|
|
6355
|
-
cloudformation: "aws",
|
|
6356
|
-
kubectl: "k8s",
|
|
6357
|
-
// kustomize renders then applies through the same k8s pipeline
|
|
6358
|
-
// (chant#1548), so it scopes to the same lexicon. Note the auto-sync
|
|
6359
|
-
// wrinkle this creates on purpose: a kustomize Op AND a kubectl Op in one
|
|
6360
|
-
// project both exact-match `k8s`, and two exact matches decline — two Ops
|
|
6361
|
-
// claiming the k8s half genuinely is ambiguous.
|
|
6362
|
-
kustomize: "k8s",
|
|
6363
|
-
arm: "azure"
|
|
6364
|
-
};
|
|
6365
|
-
function kindOf(content) {
|
|
6366
|
-
if (/\bApplyOp\b/.test(content)) return "apply";
|
|
6367
|
-
if (/\bReconcileOp\b/.test(content)) return "reconcile";
|
|
6368
|
-
if (/AuditOp\b/.test(content)) return "audit";
|
|
6369
|
-
return "op";
|
|
6370
|
-
}
|
|
6371
|
-
function discoverOps(projectDir) {
|
|
6372
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6373
|
-
const out = [];
|
|
6374
|
-
for (const sub of ["ops", "src", "."]) {
|
|
6375
|
-
const dir = join14(projectDir, sub);
|
|
6376
|
-
if (!existsSync9(dir)) continue;
|
|
6377
|
-
for (const f of readdirSync6(dir)) {
|
|
6378
|
-
if (!f.endsWith(".op.ts")) continue;
|
|
6379
|
-
const content = readFileSync11(join14(dir, f), "utf8");
|
|
6380
|
-
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6381
|
-
if (!name || seen.has(name)) continue;
|
|
6382
|
-
seen.add(name);
|
|
6383
|
-
const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6384
|
-
const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6385
|
-
const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6386
|
-
const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
|
|
6387
|
-
const irPath = opIrPath(projectDir, name);
|
|
6388
|
-
out.push({
|
|
6389
|
-
name,
|
|
6390
|
-
kind: kindOf(content),
|
|
6391
|
-
dir: projectDir,
|
|
6392
|
-
...gate ? { gate } : {},
|
|
6393
|
-
...env ? { env } : {},
|
|
6394
|
-
...substrate ? { substrate } : {},
|
|
6395
|
-
...existsSync9(irPath) ? { irPath } : {}
|
|
6396
|
-
});
|
|
6397
|
-
}
|
|
6398
|
-
}
|
|
6399
|
-
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6400
|
-
}
|
|
6401
|
-
function opIrPath(projectDir, name) {
|
|
6402
|
-
return join14(projectDir, "dist", "ops", name, "op.json");
|
|
6403
|
-
}
|
|
6404
|
-
function discoverOpIrs(projectDirs) {
|
|
6405
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6406
|
-
const out = [];
|
|
6407
|
-
for (const dir of projectDirs) {
|
|
6408
|
-
const root = join14(dir, "dist", "ops");
|
|
6409
|
-
if (!existsSync9(root)) continue;
|
|
6410
|
-
for (const name of readdirSync6(root)) {
|
|
6411
|
-
if (seen.has(name)) continue;
|
|
6412
|
-
const path = opIrPath(dir, name);
|
|
6413
|
-
if (!existsSync9(path)) continue;
|
|
6414
|
-
seen.add(name);
|
|
6415
|
-
out.push({ name, dir, path });
|
|
6416
|
-
}
|
|
6417
|
-
}
|
|
6418
|
-
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6419
|
-
}
|
|
6420
|
-
function discoverEstateOps(projectDirs) {
|
|
6421
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6422
|
-
const out = [];
|
|
6423
|
-
for (const dir of projectDirs) {
|
|
6424
|
-
for (const op of discoverOps(dir)) {
|
|
6425
|
-
if (seen.has(op.name)) continue;
|
|
6426
|
-
seen.add(op.name);
|
|
6427
|
-
out.push(op);
|
|
6428
|
-
}
|
|
6783
|
+
if (!existsSync8(join13(demo.out, "src"))) {
|
|
6784
|
+
return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
|
|
6785
|
+
}
|
|
6786
|
+
const args = ["lifecycle", "diff", "prod", "--live", "--json"];
|
|
6787
|
+
const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
|
|
6788
|
+
code: 127,
|
|
6789
|
+
stdout: "",
|
|
6790
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
6791
|
+
}));
|
|
6792
|
+
if (run3.code !== 0) {
|
|
6793
|
+
return refuse3(
|
|
6794
|
+
"carve-action",
|
|
6795
|
+
`chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
|
|
6796
|
+
"Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
|
|
6797
|
+
);
|
|
6429
6798
|
}
|
|
6430
|
-
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6431
|
-
}
|
|
6432
|
-
|
|
6433
|
-
// src/run-playhead.ts
|
|
6434
|
-
var RECEIPT_READ_FN = "receiptRead";
|
|
6435
|
-
var RECEIPT_WRITE_FN = "receiptWrite";
|
|
6436
|
-
var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
|
|
6437
|
-
var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6438
|
-
function asStepRecord(value) {
|
|
6439
|
-
if (!isRecord4(value)) return null;
|
|
6440
|
-
if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
|
|
6441
|
-
if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
|
|
6442
|
-
if (typeof value.durationMs !== "number") return null;
|
|
6443
|
-
return value;
|
|
6444
|
-
}
|
|
6445
|
-
function parseRunProgressLine(line) {
|
|
6446
6799
|
let parsed;
|
|
6447
6800
|
try {
|
|
6448
|
-
parsed = JSON.parse(
|
|
6801
|
+
parsed = JSON.parse(run3.stdout);
|
|
6449
6802
|
} catch {
|
|
6450
|
-
return
|
|
6803
|
+
return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
|
|
6451
6804
|
}
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6805
|
+
if (parsed.stacks && !parsed.lexicons) {
|
|
6806
|
+
return refuse3(
|
|
6807
|
+
"carve-action",
|
|
6808
|
+
"chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
|
|
6809
|
+
"point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
|
|
6810
|
+
);
|
|
6457
6811
|
}
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
const
|
|
6462
|
-
const
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6812
|
+
const aws = parsed.lexicons?.aws ?? {};
|
|
6813
|
+
const entity = select.split(".").pop() ?? select;
|
|
6814
|
+
const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
|
|
6815
|
+
const queried = aws.resources?.queried?.[entity];
|
|
6816
|
+
const meta = aws.observed?.[entity];
|
|
6817
|
+
if (meta) {
|
|
6818
|
+
return {
|
|
6819
|
+
ok: true,
|
|
6820
|
+
select,
|
|
6821
|
+
command,
|
|
6822
|
+
entity,
|
|
6823
|
+
verdict: "observed",
|
|
6824
|
+
...meta.status ? { status: meta.status } : {},
|
|
6825
|
+
...meta.ownership ? { ownership: meta.ownership } : {},
|
|
6826
|
+
...meta.physicalId ? { physicalId: meta.physicalId } : {},
|
|
6827
|
+
...queried ? { queried } : {}
|
|
6828
|
+
};
|
|
6472
6829
|
}
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
function parseGateLine(line) {
|
|
6476
|
-
const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
|
|
6477
|
-
if (!m) return null;
|
|
6478
|
-
const signalName = m[1].trim();
|
|
6479
|
-
if (!signalName) return null;
|
|
6480
|
-
return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
|
|
6481
|
-
}
|
|
6482
|
-
function readRunStatus(op, run3) {
|
|
6483
|
-
if (run3.code !== 0) {
|
|
6484
|
-
const message = strip(run3.stderr).trim() || strip(run3.stdout).trim() || `chant run status exited ${run3.code}`;
|
|
6485
|
-
if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
|
|
6486
|
-
return {
|
|
6487
|
-
ok: false,
|
|
6488
|
-
refusal: {
|
|
6489
|
-
error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6490
|
-
code: "no-temporal",
|
|
6491
|
-
remedy: "Gate state and per-step run records come from a durable run. Configure a Temporal profile in chant.config.ts (and `npm install @temporalio/client`), then run the Op with --temporal."
|
|
6492
|
-
}
|
|
6493
|
-
};
|
|
6494
|
-
}
|
|
6495
|
-
if (/not found|NotFound/.test(message)) {
|
|
6496
|
-
return {
|
|
6497
|
-
ok: false,
|
|
6498
|
-
refusal: {
|
|
6499
|
-
error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
6500
|
-
code: "no-run",
|
|
6501
|
-
remedy: "Start the Op (Run) and the playhead fills in as each step settles."
|
|
6502
|
-
}
|
|
6503
|
-
};
|
|
6504
|
-
}
|
|
6830
|
+
const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
|
|
6831
|
+
if (hole) {
|
|
6505
6832
|
return {
|
|
6506
|
-
ok:
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
}
|
|
6833
|
+
ok: true,
|
|
6834
|
+
select,
|
|
6835
|
+
command,
|
|
6836
|
+
entity,
|
|
6837
|
+
verdict: "unobserved",
|
|
6838
|
+
...hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {},
|
|
6839
|
+
...queried ? { queried } : {}
|
|
6512
6840
|
};
|
|
6513
6841
|
}
|
|
6514
|
-
|
|
6515
|
-
const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
|
|
6516
|
-
const gateLine = f.get("Gate");
|
|
6517
|
-
return {
|
|
6518
|
-
ok: true,
|
|
6519
|
-
read: {
|
|
6520
|
-
op,
|
|
6521
|
-
...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
|
|
6522
|
-
...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
|
|
6523
|
-
...f.get("Status") ? { status: f.get("Status") } : {},
|
|
6524
|
-
...f.get("Started") ? { startedAt: f.get("Started") } : {},
|
|
6525
|
-
...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
|
|
6526
|
-
...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
|
|
6527
|
-
// No Gate line means chant printed none, which it does both when the
|
|
6528
|
-
// query says "nothing pending" and when the Op registers no handler.
|
|
6529
|
-
// Either way: no gate is waiting on a human right now.
|
|
6530
|
-
gate: gateLine ? parseGateLine(gateLine) : null
|
|
6531
|
-
}
|
|
6532
|
-
};
|
|
6533
|
-
}
|
|
6534
|
-
var initialRunState = {
|
|
6535
|
-
op: null,
|
|
6536
|
-
mode: null,
|
|
6537
|
-
status: "idle",
|
|
6538
|
-
records: [],
|
|
6539
|
-
gate: null,
|
|
6540
|
-
gateNote: null,
|
|
6541
|
-
startedAt: null,
|
|
6542
|
-
endedAt: null,
|
|
6543
|
-
lost: null
|
|
6544
|
-
};
|
|
6545
|
-
function runStarted(op, mode, at) {
|
|
6546
|
-
return { ...initialRunState, op, mode, status: "running", startedAt: at };
|
|
6842
|
+
return { ok: true, select, command, entity, verdict: "missing", ...queried ? { queried } : {} };
|
|
6547
6843
|
}
|
|
6548
|
-
|
|
6549
|
-
|
|
6844
|
+
var MAX_ARTIFACT_BYTES = 64 * 1024;
|
|
6845
|
+
var MAX_ARTIFACTS = 24;
|
|
6846
|
+
var MAX_OUTPUT_BYTES = 32 * 1024;
|
|
6847
|
+
var refuse3 = (code, error, remedy) => ({
|
|
6848
|
+
ok: false,
|
|
6849
|
+
refusal: { error, code, remedy }
|
|
6850
|
+
});
|
|
6851
|
+
var BUILD_CAVEAT = "The gate shown is `chant lint`, not `chant build`. chant#1637's fold is applied now: the bucket's versioning and public-access-block sub-resources come out as native props, not just folded-in mentions. `chant build` still fails, but on one rule only \u2014 WAW042, a TLS-deny bucket policy the source Terraform never declared. That's real drift the estate inherited, not a gap in the carve. example-carve/README.md, \"Why build still fails\", has the full statement.";
|
|
6852
|
+
function insideDemo(root, p) {
|
|
6853
|
+
const rel = relative2(resolve4(root), resolve4(p));
|
|
6854
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
6550
6855
|
}
|
|
6551
|
-
function
|
|
6552
|
-
|
|
6856
|
+
function selectFromReport(report, raw) {
|
|
6857
|
+
if (typeof raw !== "string" || !raw || raw.length > 512) return null;
|
|
6858
|
+
return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null;
|
|
6553
6859
|
}
|
|
6554
|
-
function
|
|
6555
|
-
if (
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
return {
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
endedAt: at,
|
|
6562
|
-
lost: `the progress stream ended (exit ${exitCode}) after ${state.records.length} settled step${state.records.length === 1 ? "" : "s"} without a failed step \u2014 behold does not know whether the Op failed or the stream broke, so the playhead stays at the last settled step.`
|
|
6563
|
-
};
|
|
6860
|
+
function carveWriteBlock(demo) {
|
|
6861
|
+
if (!demo) {
|
|
6862
|
+
return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
|
|
6863
|
+
}
|
|
6864
|
+
if (!existsSync8(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
|
|
6865
|
+
if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
|
|
6866
|
+
return unwritableReason(demo.root);
|
|
6564
6867
|
}
|
|
6565
|
-
function
|
|
6868
|
+
function readArtifacts(demo, dir, filter) {
|
|
6566
6869
|
const out = [];
|
|
6567
|
-
|
|
6568
|
-
if (
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6870
|
+
const walk = (d, depth) => {
|
|
6871
|
+
if (out.length >= MAX_ARTIFACTS || depth > 2) return;
|
|
6872
|
+
let entries;
|
|
6873
|
+
try {
|
|
6874
|
+
entries = readdirSync5(d).sort();
|
|
6875
|
+
} catch {
|
|
6876
|
+
return;
|
|
6877
|
+
}
|
|
6878
|
+
for (const name of entries) {
|
|
6879
|
+
if (out.length >= MAX_ARTIFACTS) return;
|
|
6880
|
+
if (name === "node_modules" || name.startsWith(".")) continue;
|
|
6881
|
+
const full = join13(d, name);
|
|
6882
|
+
let st;
|
|
6883
|
+
try {
|
|
6884
|
+
st = statSync5(full);
|
|
6885
|
+
} catch {
|
|
6886
|
+
continue;
|
|
6887
|
+
}
|
|
6888
|
+
if (st.isDirectory()) {
|
|
6889
|
+
walk(full, depth + 1);
|
|
6890
|
+
continue;
|
|
6891
|
+
}
|
|
6892
|
+
const rel = relative2(demo.root, full).split(sep2).join("/");
|
|
6893
|
+
if (!filter(rel)) continue;
|
|
6894
|
+
let text = "";
|
|
6895
|
+
try {
|
|
6896
|
+
text = readFileSync10(full, "utf8");
|
|
6897
|
+
} catch {
|
|
6898
|
+
continue;
|
|
6899
|
+
}
|
|
6900
|
+
const truncated = text.length > MAX_ARTIFACT_BYTES;
|
|
6901
|
+
out.push({
|
|
6902
|
+
path: rel,
|
|
6903
|
+
kind: (name.split(".").pop() ?? "").toLowerCase(),
|
|
6904
|
+
bytes: st.size,
|
|
6905
|
+
text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text,
|
|
6906
|
+
truncated
|
|
6907
|
+
});
|
|
6576
6908
|
}
|
|
6577
|
-
}
|
|
6578
|
-
return out;
|
|
6579
|
-
}
|
|
6580
|
-
function mergeRun(prev, record) {
|
|
6581
|
-
const rank = { fail: 3, ok: 2, skipped: 1 };
|
|
6582
|
-
const keepPrev = prev && rank[prev.status] >= rank[record.status];
|
|
6583
|
-
return {
|
|
6584
|
-
status: keepPrev ? prev.status : record.status,
|
|
6585
|
-
durationMs: (prev?.durationMs ?? 0) + record.durationMs,
|
|
6586
|
-
...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
|
|
6587
|
-
...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
|
|
6588
|
-
records: (prev?.records ?? 0) + 1
|
|
6589
6909
|
};
|
|
6910
|
+
walk(dir, 0);
|
|
6911
|
+
return out;
|
|
6590
6912
|
}
|
|
6591
|
-
function
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6913
|
+
function carveSlug(select) {
|
|
6914
|
+
return select.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
6915
|
+
}
|
|
6916
|
+
var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
|
|
6917
|
+
function shortenIn(arg, root) {
|
|
6918
|
+
return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
|
|
6919
|
+
}
|
|
6920
|
+
function merge(r, root) {
|
|
6921
|
+
const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
|
|
6922
|
+
return clip(joined.split(root + sep2).join("").split(root).join("."));
|
|
6923
|
+
}
|
|
6924
|
+
async function runCarveEmit(demo, select) {
|
|
6925
|
+
const block = carveWriteBlock(demo);
|
|
6926
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
6927
|
+
mkdirSync3(demo.out, { recursive: true });
|
|
6928
|
+
const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
|
|
6929
|
+
const args = [
|
|
6930
|
+
"carve",
|
|
6931
|
+
"emit",
|
|
6932
|
+
"--from",
|
|
6933
|
+
demo.from,
|
|
6934
|
+
...demo.state ? ["--state", demo.state] : [],
|
|
6935
|
+
"--select",
|
|
6936
|
+
select,
|
|
6937
|
+
"--output",
|
|
6938
|
+
demo.out,
|
|
6939
|
+
"--report",
|
|
6940
|
+
reportFile
|
|
6941
|
+
];
|
|
6942
|
+
const run3 = await runChantRaw(args, demo.project).catch((err) => ({
|
|
6943
|
+
code: 127,
|
|
6944
|
+
stdout: "",
|
|
6945
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
6946
|
+
}));
|
|
6947
|
+
const output = merge(run3, demo.root);
|
|
6948
|
+
if (run3.code !== 0) {
|
|
6949
|
+
return refuse3(
|
|
6950
|
+
"carve-action",
|
|
6951
|
+
`chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
|
|
6952
|
+
"The offline emit needs `@cdktf/hcl2json` in the demo copy and the demo's own chant install \u2014 `npm install` in the copy's `app/` and re-run `behold demo carve`."
|
|
6953
|
+
);
|
|
6599
6954
|
}
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
const indexOf = new Map(placed.map((p, i) => [p.id, i]));
|
|
6605
|
-
for (const record of records) {
|
|
6606
|
-
const key = `${record.phase}\0${record.fn}`;
|
|
6607
|
-
const list3 = byKey.get(key);
|
|
6608
|
-
const i = cursor.get(key) ?? 0;
|
|
6609
|
-
const slot = list3?.[i];
|
|
6610
|
-
if (!slot) {
|
|
6611
|
-
unplaced.push(record);
|
|
6612
|
-
continue;
|
|
6613
|
-
}
|
|
6614
|
-
cursor.set(key, i + 1);
|
|
6615
|
-
byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
|
|
6616
|
-
if (record.status !== "skipped") {
|
|
6617
|
-
lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
|
|
6618
|
-
}
|
|
6955
|
+
let boundary = null;
|
|
6956
|
+
try {
|
|
6957
|
+
boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
|
|
6958
|
+
} catch {
|
|
6619
6959
|
}
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6960
|
+
const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
|
|
6961
|
+
const lintArgs = ["lint", lintPath];
|
|
6962
|
+
const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
|
|
6963
|
+
code: 127,
|
|
6964
|
+
stdout: "",
|
|
6965
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
6966
|
+
}));
|
|
6967
|
+
return {
|
|
6968
|
+
ok: true,
|
|
6969
|
+
select,
|
|
6970
|
+
command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
|
|
6971
|
+
output,
|
|
6972
|
+
// Emitted source first, scaffolding after: `src/assets.ts` is the thing the
|
|
6973
|
+
// step is about, and a package.json sorting above it buries the answer.
|
|
6974
|
+
artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort(
|
|
6975
|
+
(a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path))
|
|
6976
|
+
),
|
|
6977
|
+
boundary,
|
|
6978
|
+
lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) },
|
|
6979
|
+
buildCaveat: BUILD_CAVEAT
|
|
6980
|
+
};
|
|
6981
|
+
}
|
|
6982
|
+
async function runCarveBridge(demo, select) {
|
|
6983
|
+
const block = carveWriteBlock(demo);
|
|
6984
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
6985
|
+
mkdirSync3(demo.out, { recursive: true });
|
|
6986
|
+
const args = [
|
|
6987
|
+
"carve",
|
|
6988
|
+
"bridge",
|
|
6989
|
+
"--from",
|
|
6990
|
+
demo.from,
|
|
6991
|
+
...demo.state ? ["--state", demo.state] : [],
|
|
6992
|
+
"--select",
|
|
6993
|
+
select,
|
|
6994
|
+
"--output",
|
|
6995
|
+
demo.out
|
|
6996
|
+
];
|
|
6997
|
+
const run3 = await runChantRaw(args, demo.project).catch((err) => ({
|
|
6998
|
+
code: 127,
|
|
6999
|
+
stdout: "",
|
|
7000
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
7001
|
+
}));
|
|
7002
|
+
const output = merge(run3, demo.root);
|
|
7003
|
+
if (run3.code !== 0) {
|
|
7004
|
+
return refuse3(
|
|
7005
|
+
"carve-action",
|
|
7006
|
+
`chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
|
|
7007
|
+
"Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
|
|
7008
|
+
);
|
|
6630
7009
|
}
|
|
6631
|
-
const
|
|
7010
|
+
const slug3 = carveSlug(select);
|
|
7011
|
+
const all = readArtifacts(demo, demo.out, (rel) => /\.(tf|md|patch)$/.test(rel));
|
|
6632
7012
|
return {
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
7013
|
+
ok: true,
|
|
7014
|
+
select,
|
|
7015
|
+
command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
|
|
7016
|
+
output,
|
|
7017
|
+
runbook: all.find((a) => a.path.endsWith(`${slug3}-runbook.md`)) ?? null,
|
|
7018
|
+
proposals: all.filter((a) => !a.path.endsWith(`${slug3}-runbook.md`))
|
|
6637
7019
|
};
|
|
6638
7020
|
}
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
7021
|
+
|
|
7022
|
+
// src/ops.ts
|
|
7023
|
+
import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
|
|
7024
|
+
import { join as join14 } from "node:path";
|
|
7025
|
+
var APPLY_TARGET_LEXICON = {
|
|
7026
|
+
cloudformation: "aws",
|
|
7027
|
+
kubectl: "k8s",
|
|
7028
|
+
// kustomize renders then applies through the same k8s pipeline
|
|
7029
|
+
// (chant#1548), so it scopes to the same lexicon. Note the auto-sync
|
|
7030
|
+
// wrinkle this creates on purpose: a kustomize Op AND a kubectl Op in one
|
|
7031
|
+
// project both exact-match `k8s`, and two exact matches decline — two Ops
|
|
7032
|
+
// claiming the k8s half genuinely is ambiguous.
|
|
7033
|
+
kustomize: "k8s",
|
|
7034
|
+
arm: "azure"
|
|
6643
7035
|
};
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
7036
|
+
function kindOf(content) {
|
|
7037
|
+
if (/\bApplyOp\b/.test(content)) return "apply";
|
|
7038
|
+
if (/\bReconcileOp\b/.test(content)) return "reconcile";
|
|
7039
|
+
if (/AuditOp\b/.test(content)) return "audit";
|
|
7040
|
+
return "op";
|
|
6647
7041
|
}
|
|
6648
|
-
function
|
|
6649
|
-
|
|
6650
|
-
const
|
|
6651
|
-
|
|
6652
|
-
const
|
|
6653
|
-
if (!
|
|
6654
|
-
const
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
7042
|
+
function discoverOps(projectDir) {
|
|
7043
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7044
|
+
const out = [];
|
|
7045
|
+
for (const sub of ["ops", "src", "."]) {
|
|
7046
|
+
const dir = join14(projectDir, sub);
|
|
7047
|
+
if (!existsSync9(dir)) continue;
|
|
7048
|
+
for (const f of readdirSync6(dir)) {
|
|
7049
|
+
if (!f.endsWith(".op.ts")) continue;
|
|
7050
|
+
const content = readFileSync11(join14(dir, f), "utf8");
|
|
7051
|
+
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
7052
|
+
if (!name || seen.has(name)) continue;
|
|
7053
|
+
seen.add(name);
|
|
7054
|
+
const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
7055
|
+
const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
7056
|
+
const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
7057
|
+
const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
|
|
7058
|
+
const irPath = opIrPath(projectDir, name);
|
|
7059
|
+
out.push({
|
|
7060
|
+
name,
|
|
7061
|
+
kind: kindOf(content),
|
|
7062
|
+
dir: projectDir,
|
|
7063
|
+
...gate ? { gate } : {},
|
|
7064
|
+
...env ? { env } : {},
|
|
7065
|
+
...substrate ? { substrate } : {},
|
|
7066
|
+
...existsSync9(irPath) ? { irPath } : {}
|
|
7067
|
+
});
|
|
6669
7068
|
}
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
return { ...ir, nodes };
|
|
7069
|
+
}
|
|
7070
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6673
7071
|
}
|
|
6674
|
-
function
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
path
|
|
7072
|
+
function opIrPath(projectDir, name) {
|
|
7073
|
+
return join14(projectDir, "dist", "ops", name, "op.json");
|
|
7074
|
+
}
|
|
7075
|
+
function discoverOpIrs(projectDirs) {
|
|
7076
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7077
|
+
const out = [];
|
|
7078
|
+
for (const dir of projectDirs) {
|
|
7079
|
+
const root = join14(dir, "dist", "ops");
|
|
7080
|
+
if (!existsSync9(root)) continue;
|
|
7081
|
+
for (const name of readdirSync6(root)) {
|
|
7082
|
+
if (seen.has(name)) continue;
|
|
7083
|
+
const path = opIrPath(dir, name);
|
|
7084
|
+
if (!existsSync9(path)) continue;
|
|
7085
|
+
seen.add(name);
|
|
7086
|
+
out.push({ name, dir, path });
|
|
6686
7087
|
}
|
|
6687
|
-
};
|
|
6688
|
-
}
|
|
6689
|
-
function playheadNote(state, join21) {
|
|
6690
|
-
if (!state.op) {
|
|
6691
|
-
return "No run state: nothing has been run from this behold session, so every step paints as declared.";
|
|
6692
7088
|
}
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
)
|
|
7089
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
7090
|
+
}
|
|
7091
|
+
function discoverEstateOps(projectDirs) {
|
|
7092
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7093
|
+
const out = [];
|
|
7094
|
+
for (const dir of projectDirs) {
|
|
7095
|
+
for (const op of discoverOps(dir)) {
|
|
7096
|
+
if (seen.has(op.name)) continue;
|
|
7097
|
+
seen.add(op.name);
|
|
7098
|
+
out.push(op);
|
|
7099
|
+
}
|
|
6700
7100
|
}
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
7101
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
7102
|
+
}
|
|
7103
|
+
|
|
7104
|
+
// src/run-playhead.ts
|
|
7105
|
+
var RECEIPT_READ_FN = "receiptRead";
|
|
7106
|
+
var RECEIPT_WRITE_FN = "receiptWrite";
|
|
7107
|
+
var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
|
|
7108
|
+
var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7109
|
+
function asStepRecord(value) {
|
|
7110
|
+
if (!isRecord5(value)) return null;
|
|
7111
|
+
if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
|
|
7112
|
+
if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
|
|
7113
|
+
if (typeof value.durationMs !== "number") return null;
|
|
7114
|
+
return value;
|
|
7115
|
+
}
|
|
7116
|
+
function parseRunProgressLine(line) {
|
|
7117
|
+
let parsed;
|
|
7118
|
+
try {
|
|
7119
|
+
parsed = JSON.parse(line);
|
|
7120
|
+
} catch {
|
|
7121
|
+
return null;
|
|
6708
7122
|
}
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
);
|
|
7123
|
+
const one = asStepRecord(parsed);
|
|
7124
|
+
if (one) return [one];
|
|
7125
|
+
if (isRecord5(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
|
|
7126
|
+
const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
|
|
7127
|
+
return records.length ? records : null;
|
|
6713
7128
|
}
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
7129
|
+
return null;
|
|
7130
|
+
}
|
|
7131
|
+
function recordLine(record) {
|
|
7132
|
+
const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
|
|
7133
|
+
const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
|
|
7134
|
+
return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
|
|
6717
7135
|
}
|
|
6718
|
-
|
|
6719
|
-
// src/operator.ts
|
|
6720
7136
|
var ANSI2 = /\x1b\[[0-9;]*m/g;
|
|
6721
7137
|
var strip2 = (s) => s.replace(ANSI2, "");
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
if (!Array.isArray(value.pendingGates)) return null;
|
|
6728
|
-
const gates = [];
|
|
6729
|
-
for (const g of value.pendingGates) {
|
|
6730
|
-
if (!isRecord5(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
|
|
6731
|
-
gates.push({ rule: g.rule, gate: g.gate, ...typeof g.op === "string" ? { op: g.op } : {} });
|
|
7138
|
+
function statusFields(stdout) {
|
|
7139
|
+
const out = /* @__PURE__ */ new Map();
|
|
7140
|
+
for (const raw of strip2(stdout).split("\n")) {
|
|
7141
|
+
const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
|
|
7142
|
+
if (m) out.set(m[1].trim(), m[2].trim());
|
|
6732
7143
|
}
|
|
6733
|
-
|
|
6734
|
-
const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
|
|
6735
|
-
const lease = isRecord5(value.lease) ? value.lease : void 0;
|
|
6736
|
-
return {
|
|
6737
|
-
op: value.op,
|
|
6738
|
-
env: value.env,
|
|
6739
|
-
...usable ? { lastTick: tick } : {},
|
|
6740
|
-
pendingGates: gates,
|
|
6741
|
-
...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
|
|
6742
|
-
};
|
|
7144
|
+
return out;
|
|
6743
7145
|
}
|
|
6744
|
-
function
|
|
6745
|
-
const
|
|
6746
|
-
|
|
7146
|
+
function parseGateLine(line) {
|
|
7147
|
+
const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
|
|
7148
|
+
if (!m) return null;
|
|
7149
|
+
const signalName = m[1].trim();
|
|
7150
|
+
if (!signalName) return null;
|
|
7151
|
+
return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
|
|
7152
|
+
}
|
|
7153
|
+
function readRunStatus(op, run3) {
|
|
6747
7154
|
if (run3.code !== 0) {
|
|
6748
|
-
const message =
|
|
6749
|
-
|
|
6750
|
-
if (/Unknown command|Unknown operator subcommand/i.test(message)) {
|
|
7155
|
+
const message = strip2(run3.stderr).trim() || strip2(run3.stdout).trim() || `chant run status exited ${run3.code}`;
|
|
7156
|
+
if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
|
|
6751
7157
|
return {
|
|
6752
7158
|
ok: false,
|
|
6753
7159
|
refusal: {
|
|
6754
|
-
error: `
|
|
6755
|
-
code: "no-
|
|
6756
|
-
remedy: "
|
|
7160
|
+
error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
7161
|
+
code: "no-temporal",
|
|
7162
|
+
remedy: "Gate state and per-step run records come from a durable run. Configure a Temporal profile in chant.config.ts (and `npm install @temporalio/client`), then run the Op with --temporal."
|
|
6757
7163
|
}
|
|
6758
7164
|
};
|
|
6759
7165
|
}
|
|
6760
|
-
|
|
6761
|
-
ok: false,
|
|
6762
|
-
refusal: {
|
|
6763
|
-
error: `chant operator status failed: ${first}`,
|
|
6764
|
-
code: "operator-status",
|
|
6765
|
-
remedy: "Run `chant operator status --json` in the project to see the whole error."
|
|
6766
|
-
}
|
|
6767
|
-
};
|
|
6768
|
-
}
|
|
6769
|
-
if (!out) {
|
|
6770
|
-
return {
|
|
6771
|
-
ok: false,
|
|
6772
|
-
refusal: {
|
|
6773
|
-
error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
|
|
6774
|
-
code: "no-operator",
|
|
6775
|
-
remedy: HOW_TO_DECLARE
|
|
6776
|
-
}
|
|
6777
|
-
};
|
|
6778
|
-
}
|
|
6779
|
-
let parsed;
|
|
6780
|
-
try {
|
|
6781
|
-
parsed = JSON.parse(out);
|
|
6782
|
-
} catch (e) {
|
|
6783
|
-
return {
|
|
6784
|
-
ok: false,
|
|
6785
|
-
refusal: {
|
|
6786
|
-
error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
6787
|
-
code: "operator-status",
|
|
6788
|
-
remedy: "Run `chant operator status --json` in the project and check what it printed."
|
|
6789
|
-
}
|
|
6790
|
-
};
|
|
6791
|
-
}
|
|
6792
|
-
if (!Array.isArray(parsed)) {
|
|
6793
|
-
return {
|
|
6794
|
-
ok: false,
|
|
6795
|
-
refusal: {
|
|
6796
|
-
error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
|
|
6797
|
-
code: "operator-status",
|
|
6798
|
-
remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
|
|
6799
|
-
}
|
|
6800
|
-
};
|
|
6801
|
-
}
|
|
6802
|
-
const rows = [];
|
|
6803
|
-
for (const value of parsed) {
|
|
6804
|
-
const row = readRow(value);
|
|
6805
|
-
if (!row) {
|
|
7166
|
+
if (/not found|NotFound/.test(message)) {
|
|
6806
7167
|
return {
|
|
6807
7168
|
ok: false,
|
|
6808
7169
|
refusal: {
|
|
6809
|
-
error:
|
|
6810
|
-
code: "
|
|
6811
|
-
remedy: "
|
|
7170
|
+
error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
7171
|
+
code: "no-run",
|
|
7172
|
+
remedy: "Start the Op (Run) and the playhead fills in as each step settles."
|
|
6812
7173
|
}
|
|
6813
7174
|
};
|
|
6814
7175
|
}
|
|
6815
|
-
|
|
7176
|
+
return {
|
|
7177
|
+
ok: false,
|
|
7178
|
+
refusal: {
|
|
7179
|
+
error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
7180
|
+
code: "run-status",
|
|
7181
|
+
remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
|
|
7182
|
+
}
|
|
7183
|
+
};
|
|
6816
7184
|
}
|
|
6817
|
-
|
|
7185
|
+
const f = statusFields(run3.stdout);
|
|
7186
|
+
const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
|
|
7187
|
+
const gateLine = f.get("Gate");
|
|
7188
|
+
return {
|
|
7189
|
+
ok: true,
|
|
7190
|
+
read: {
|
|
7191
|
+
op,
|
|
7192
|
+
...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
|
|
7193
|
+
...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
|
|
7194
|
+
...f.get("Status") ? { status: f.get("Status") } : {},
|
|
7195
|
+
...f.get("Started") ? { startedAt: f.get("Started") } : {},
|
|
7196
|
+
...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
|
|
7197
|
+
...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
|
|
7198
|
+
// No Gate line means chant printed none, which it does both when the
|
|
7199
|
+
// query says "nothing pending" and when the Op registers no handler.
|
|
7200
|
+
// Either way: no gate is waiting on a human right now.
|
|
7201
|
+
gate: gateLine ? parseGateLine(gateLine) : null
|
|
7202
|
+
}
|
|
7203
|
+
};
|
|
6818
7204
|
}
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
7205
|
+
var initialRunState = {
|
|
7206
|
+
op: null,
|
|
7207
|
+
mode: null,
|
|
7208
|
+
status: "idle",
|
|
7209
|
+
records: [],
|
|
7210
|
+
gate: null,
|
|
7211
|
+
gateNote: null,
|
|
7212
|
+
startedAt: null,
|
|
7213
|
+
endedAt: null,
|
|
7214
|
+
lost: null
|
|
7215
|
+
};
|
|
7216
|
+
function runStarted(op, mode, at) {
|
|
7217
|
+
return { ...initialRunState, op, mode, status: "running", startedAt: at };
|
|
6825
7218
|
}
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
if (
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
conflicted.add(ns);
|
|
6853
|
-
delete home.stack;
|
|
7219
|
+
function runRecord(state, record) {
|
|
7220
|
+
return { ...state, records: [...state.records, record] };
|
|
7221
|
+
}
|
|
7222
|
+
function runGate(state, result) {
|
|
7223
|
+
return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
|
|
7224
|
+
}
|
|
7225
|
+
function runEnded(state, exitCode, at) {
|
|
7226
|
+
if (state.status !== "running") return state;
|
|
7227
|
+
if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
|
|
7228
|
+
if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
|
|
7229
|
+
return {
|
|
7230
|
+
...state,
|
|
7231
|
+
status: "lost",
|
|
7232
|
+
endedAt: at,
|
|
7233
|
+
lost: `the progress stream ended (exit ${exitCode}) after ${state.records.length} settled step${state.records.length === 1 ? "" : "s"} without a failed step \u2014 behold does not know whether the Op failed or the stream broke, so the playhead stays at the last settled step.`
|
|
7234
|
+
};
|
|
7235
|
+
}
|
|
7236
|
+
function slotsFor(placed) {
|
|
7237
|
+
const out = [];
|
|
7238
|
+
for (const p of placed) {
|
|
7239
|
+
if (p.step.kind === "activity") {
|
|
7240
|
+
out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
|
|
7241
|
+
} else if (p.step.kind === "effect") {
|
|
7242
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
|
|
7243
|
+
for (const nested of p.step.steps ?? []) {
|
|
7244
|
+
if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
|
|
6854
7245
|
}
|
|
7246
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
|
|
6855
7247
|
}
|
|
6856
|
-
const schedule = str3(rec3(n.attrs?.spec)?.schedule);
|
|
6857
|
-
home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
|
|
6858
|
-
byNamespace.set(ns, home);
|
|
6859
|
-
}
|
|
6860
|
-
if (!byNamespace.size) return [];
|
|
6861
|
-
for (const n of ir.nodes) {
|
|
6862
|
-
if (n.kind !== NAMESPACE_KIND) continue;
|
|
6863
|
-
const name = str3(rec3(n.attrs?.metadata)?.name);
|
|
6864
|
-
const home = name ? byNamespace.get(name) : void 0;
|
|
6865
|
-
if (home && !home.node) home.node = n.id;
|
|
6866
7248
|
}
|
|
6867
|
-
|
|
6868
|
-
return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
|
|
7249
|
+
return out;
|
|
6869
7250
|
}
|
|
6870
|
-
function
|
|
6871
|
-
const
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
7251
|
+
function mergeRun(prev, record) {
|
|
7252
|
+
const rank = { fail: 3, ok: 2, skipped: 1 };
|
|
7253
|
+
const keepPrev = prev && rank[prev.status] >= rank[record.status];
|
|
7254
|
+
return {
|
|
7255
|
+
status: keepPrev ? prev.status : record.status,
|
|
7256
|
+
durationMs: (prev?.durationMs ?? 0) + record.durationMs,
|
|
7257
|
+
...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
|
|
7258
|
+
...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
|
|
7259
|
+
records: (prev?.records ?? 0) + 1
|
|
7260
|
+
};
|
|
7261
|
+
}
|
|
7262
|
+
function joinRun(op, records, gate) {
|
|
7263
|
+
const placed = opPlacedSteps(op);
|
|
7264
|
+
const slots = slotsFor(placed);
|
|
7265
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
7266
|
+
for (const s of slots) {
|
|
7267
|
+
const key = `${s.phase}\0${s.fn}`;
|
|
7268
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
7269
|
+
byKey.get(key).push({ id: s.id });
|
|
7270
|
+
}
|
|
7271
|
+
const cursor = /* @__PURE__ */ new Map();
|
|
7272
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
7273
|
+
const unplaced = [];
|
|
7274
|
+
let lastSettledIndex = -1;
|
|
7275
|
+
const indexOf = new Map(placed.map((p, i) => [p.id, i]));
|
|
7276
|
+
for (const record of records) {
|
|
7277
|
+
const key = `${record.phase}\0${record.fn}`;
|
|
7278
|
+
const list3 = byKey.get(key);
|
|
7279
|
+
const i = cursor.get(key) ?? 0;
|
|
7280
|
+
const slot = list3?.[i];
|
|
7281
|
+
if (!slot) {
|
|
7282
|
+
unplaced.push(record);
|
|
7283
|
+
continue;
|
|
6882
7284
|
}
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
7285
|
+
cursor.set(key, i + 1);
|
|
7286
|
+
byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
|
|
7287
|
+
if (record.status !== "skipped") {
|
|
7288
|
+
lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
|
|
7289
|
+
}
|
|
7290
|
+
}
|
|
7291
|
+
if (gate) {
|
|
7292
|
+
const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
|
|
7293
|
+
if (gateNode) {
|
|
7294
|
+
return {
|
|
7295
|
+
byNode,
|
|
7296
|
+
at: gateNode.id,
|
|
7297
|
+
atReason: `waiting for ${gate.signalName} since ${gate.since}`,
|
|
7298
|
+
unplaced
|
|
7299
|
+
};
|
|
6891
7300
|
}
|
|
6892
7301
|
}
|
|
7302
|
+
const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
|
|
6893
7303
|
return {
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
})
|
|
7304
|
+
byNode,
|
|
7305
|
+
at: lastSettledIndex >= 0 && next ? next.id : null,
|
|
7306
|
+
atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
|
|
7307
|
+
unplaced
|
|
6899
7308
|
};
|
|
6900
7309
|
}
|
|
6901
|
-
var
|
|
6902
|
-
|
|
7310
|
+
var TONE = {
|
|
7311
|
+
ok: "good",
|
|
7312
|
+
fail: "warn",
|
|
7313
|
+
skipped: "neutral"
|
|
6903
7314
|
};
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
const box = namespaceBoxes[home.namespace];
|
|
6908
|
-
if (box) out[box] = OPERATOR_HOME_GLYPH;
|
|
6909
|
-
}
|
|
6910
|
-
return out;
|
|
6911
|
-
}
|
|
6912
|
-
function operatorStrip(rows, now = Date.now()) {
|
|
6913
|
-
const out = rows.map((r) => {
|
|
6914
|
-
const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
|
|
6915
|
-
const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
|
|
6916
|
-
return {
|
|
6917
|
-
op: r.op,
|
|
6918
|
-
env: r.env,
|
|
6919
|
-
log: r.lastTick?.log ?? null,
|
|
6920
|
-
at: r.lastTick?.timestamp ?? null,
|
|
6921
|
-
lease,
|
|
6922
|
-
leaseHolder: r.lease?.holder ?? null,
|
|
6923
|
-
leaseExpiresAt: r.lease?.expiresAt ?? null,
|
|
6924
|
-
pendingGates: r.pendingGates.length
|
|
6925
|
-
};
|
|
6926
|
-
});
|
|
6927
|
-
return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
|
|
7315
|
+
var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
|
|
7316
|
+
function tookLabel(ms) {
|
|
7317
|
+
return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
|
|
6928
7318
|
}
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
semantics: APPROVE_SEMANTICS,
|
|
6945
|
-
approve: {
|
|
6946
|
-
method: "POST",
|
|
6947
|
-
path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
|
|
6948
|
-
command: `chant approve ${g.op} ${g.gate}`
|
|
6949
|
-
}
|
|
6950
|
-
});
|
|
7319
|
+
function paintPlayhead(ir, join21, gate) {
|
|
7320
|
+
if (!join21.byNode.size && !join21.at) return ir;
|
|
7321
|
+
const nodes = ir.nodes.map((node) => {
|
|
7322
|
+
const run3 = join21.byNode.get(node.id);
|
|
7323
|
+
const here = join21.at === node.id;
|
|
7324
|
+
if (!run3 && !here) return node;
|
|
7325
|
+
const isGate = node.attrs?._step === "gate";
|
|
7326
|
+
const attrs = { ...node.attrs };
|
|
7327
|
+
if (run3) {
|
|
7328
|
+
attrs._status = TONE[run3.status];
|
|
7329
|
+
attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
|
|
7330
|
+
attrs.ran = tookLabel(run3.durationMs);
|
|
7331
|
+
if (run3.error) attrs.error = run3.error;
|
|
7332
|
+
if (run3.outcome) attrs.outcome = run3.outcome;
|
|
7333
|
+
if (run3.records > 1) attrs.runRecords = run3.records;
|
|
6951
7334
|
}
|
|
6952
|
-
|
|
6953
|
-
|
|
7335
|
+
if (here) {
|
|
7336
|
+
attrs._playhead = true;
|
|
7337
|
+
attrs._status = isGate && gate ? "warn" : "accent";
|
|
7338
|
+
attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
|
|
7339
|
+
if (isGate && gate?.description) attrs.gateDescription = gate.description;
|
|
7340
|
+
}
|
|
7341
|
+
return { ...node, attrs };
|
|
7342
|
+
});
|
|
7343
|
+
return { ...ir, nodes };
|
|
6954
7344
|
}
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
strip: null,
|
|
6958
|
-
gates: [],
|
|
6959
|
-
note: null,
|
|
6960
|
-
code: null,
|
|
6961
|
-
readAt: null
|
|
6962
|
-
};
|
|
6963
|
-
function operatorRead(state, result, at, now = Date.now()) {
|
|
6964
|
-
if (!result.ok) {
|
|
6965
|
-
return { ...state, strip: null, gates: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
|
|
6966
|
-
}
|
|
7345
|
+
function pendingGateCard(op, gate, ir) {
|
|
7346
|
+
const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
|
|
6967
7347
|
return {
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
7348
|
+
op,
|
|
7349
|
+
signalName: gate.signalName,
|
|
7350
|
+
...gate.description ? { description: gate.description } : {},
|
|
7351
|
+
since: gate.since,
|
|
7352
|
+
...node ? { node: node.id } : {},
|
|
7353
|
+
...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
|
|
7354
|
+
approve: {
|
|
7355
|
+
method: "POST",
|
|
7356
|
+
path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
|
|
7357
|
+
}
|
|
6974
7358
|
};
|
|
6975
7359
|
}
|
|
6976
|
-
function
|
|
6977
|
-
if (!state.
|
|
6978
|
-
return "No
|
|
6979
|
-
}
|
|
6980
|
-
const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
|
|
6981
|
-
if (!state.strip) {
|
|
6982
|
-
return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
|
|
7360
|
+
function playheadNote(state, join21) {
|
|
7361
|
+
if (!state.op) {
|
|
7362
|
+
return "No run state: nothing has been run from this behold session, so every step paints as declared.";
|
|
6983
7363
|
}
|
|
7364
|
+
const settled = join21 ? join21.byNode.size : state.records.length;
|
|
6984
7365
|
const bits = [];
|
|
6985
|
-
const
|
|
6986
|
-
|
|
6987
|
-
ticked ? `last tick only \u2014 chant's status surface exposes one tick per loop, not the history (chant#2029), so this is a strip and not a timeline` : `no tick recorded yet on any loop`
|
|
6988
|
-
);
|
|
6989
|
-
const held = state.strip.rows.filter((r) => r.lease === "held").length;
|
|
6990
|
-
if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
|
|
6991
|
-
const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
|
|
6992
|
-
if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
|
|
6993
|
-
if (state.strip.pendingGates) {
|
|
7366
|
+
const head = state.status === "idle" ? `Run state for ${state.op}: no per-step records \u2014 this behold session didn't start the run, so only the gate query answers` : `Run: ${state.op} (${state.mode === "temporal" ? "durable" : "local executor"}) \u2014 ${settled} step${settled === 1 ? "" : "s"} settled`;
|
|
7367
|
+
if (state.status === "running") {
|
|
6994
7368
|
bits.push(
|
|
6995
|
-
|
|
7369
|
+
state.mode === "local" ? "still running; the local executor reports every step at once when it ends, so the track fills in on completion" : "still running; only settled steps are painted"
|
|
6996
7370
|
);
|
|
6997
7371
|
}
|
|
6998
|
-
|
|
7372
|
+
if (state.status === "ok") bits.push("the run exited cleanly");
|
|
7373
|
+
if (state.status === "failed") bits.push("a step failed");
|
|
7374
|
+
if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
|
|
7375
|
+
if (state.gate) {
|
|
7376
|
+
bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
|
|
7377
|
+
} else if (state.gateNote) {
|
|
7378
|
+
bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
|
|
7379
|
+
}
|
|
7380
|
+
if (join21?.unplaced.length) {
|
|
7381
|
+
bits.push(
|
|
7382
|
+
`${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
|
|
7383
|
+
);
|
|
7384
|
+
}
|
|
7385
|
+
if (!bits.length) return `${head}.`;
|
|
7386
|
+
const rest = bits.join("; ");
|
|
7387
|
+
return `${head} \u2014 ${rest}.`;
|
|
6999
7388
|
}
|
|
7000
7389
|
|
|
7001
7390
|
// src/adopt.ts
|
|
@@ -8847,6 +9236,51 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
8847
9236
|
const state = runner.noteOperator({ ok: true, rows });
|
|
8848
9237
|
return c.json({ ...state, operator: state });
|
|
8849
9238
|
});
|
|
9239
|
+
app.get("/api/operator/log", async (c) => {
|
|
9240
|
+
const { dirs } = convergeDirs();
|
|
9241
|
+
if (!dirs.length) {
|
|
9242
|
+
return c.json(
|
|
9243
|
+
{
|
|
9244
|
+
error: "This project declares no operating loop \u2014 no emitted Op carries chant's `Converge` search attribute.",
|
|
9245
|
+
code: "no-operator",
|
|
9246
|
+
remedy: HOW_TO_DECLARE,
|
|
9247
|
+
history: null
|
|
9248
|
+
},
|
|
9249
|
+
200
|
|
9250
|
+
);
|
|
9251
|
+
}
|
|
9252
|
+
const window = operatorLogWindow({ limit: c.req.query("limit") ?? null, since: c.req.query("since") ?? null });
|
|
9253
|
+
if ("error" in window) {
|
|
9254
|
+
return c.json({ error: window.error, code: "operator-log", remedy: "Ask for a bounded window: `?limit=50&since=<iso>`.", history: null }, 400);
|
|
9255
|
+
}
|
|
9256
|
+
const answers = [];
|
|
9257
|
+
for (const dir of dirs) {
|
|
9258
|
+
const version = resolveChant(dir).version;
|
|
9259
|
+
if (!meetsFloor(version, OPERATOR_LOG_FLOOR)) {
|
|
9260
|
+
return c.json(
|
|
9261
|
+
{
|
|
9262
|
+
error: `This chant has no \`operator log\` \u2014 ${version ? `the project resolves chant ${version}` : "the project's chant"}, and the tick history landed in ${OPERATOR_LOG_FLOOR}.`,
|
|
9263
|
+
code: "no-operator-log-cli",
|
|
9264
|
+
remedy: `Upgrade the project's chant to ${OPERATOR_LOG_FLOOR} or newer (chant#2029). The strip itself needs only 0.52, so it keeps reading.`,
|
|
9265
|
+
history: null
|
|
9266
|
+
},
|
|
9267
|
+
422
|
|
9268
|
+
);
|
|
9269
|
+
}
|
|
9270
|
+
const result = readOperatorLog(await runChantRaw(operatorLogArgs(window), dir));
|
|
9271
|
+
if (!result.ok) return c.json({ ...result.refusal, history: null }, result.refusal.code === "no-operator" ? 200 : 422);
|
|
9272
|
+
answers.push({ entries: result.entries, malformed: result.malformed });
|
|
9273
|
+
}
|
|
9274
|
+
const merged = mergeOperatorLogs(answers, window.limit);
|
|
9275
|
+
const history = {
|
|
9276
|
+
entries: operatorTimeline(merged.entries),
|
|
9277
|
+
malformed: merged.malformed,
|
|
9278
|
+
window: { limit: window.limit, since: window.since ?? null },
|
|
9279
|
+
more: merged.entries.length >= window.limit,
|
|
9280
|
+
readAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
9281
|
+
};
|
|
9282
|
+
return c.json({ history });
|
|
9283
|
+
});
|
|
8850
9284
|
app.post("/api/operator/approve/:op/:gate", async (c) => {
|
|
8851
9285
|
const { op, gate } = c.req.param();
|
|
8852
9286
|
const info = estateOps().find((o) => o.name === op);
|
|
@@ -9148,6 +9582,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
9148
9582
|
{ method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress + run playhead" },
|
|
9149
9583
|
{ method: "GET", path: "/api/ops/:name/status", desc: "an Op's durable run status + pending gate (chant run status)" },
|
|
9150
9584
|
{ method: "GET", path: "/api/operator/status", desc: "the operating loop's strip: last tick per ConvergeOp + lease + pending converge gates" },
|
|
9585
|
+
{ method: "GET", path: "/api/operator/log", desc: "the operating loop's timeline: converge ticks + gate resolutions, newest ?limit=n (max 200) from ?since=<iso>" },
|
|
9151
9586
|
{ method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
|
|
9152
9587
|
{ method: "GET", path: "/api/frames", desc: "captured lanes frames" },
|
|
9153
9588
|
{ method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
|
|
@@ -9259,6 +9694,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
9259
9694
|
if (env) {
|
|
9260
9695
|
const rows = await componentStatus(cfg.projectDir, env, opts);
|
|
9261
9696
|
ir = joinComponentStatus(ir, rows);
|
|
9697
|
+
ir = joinTickVerdicts(ir, verdictsForEnv(runner.operatorState.verdicts, env));
|
|
9262
9698
|
mode = "component-status";
|
|
9263
9699
|
metaEnv = env;
|
|
9264
9700
|
}
|