@intentius/behold 0.13.1 → 0.14.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 +35 -7
- package/dist/cli.js +2232 -1871
- 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 +92 -4
- package/web/index.html +24 -2
- package/web/operator.js +247 -14
- package/web/operator.test.js +188 -4
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,1110 +2310,1679 @@ 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);
|
|
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
|
|
2616
2811
|
};
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
}
|
|
2812
|
+
}
|
|
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.";
|
|
2816
|
+
}
|
|
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.`;
|
|
2820
|
+
}
|
|
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("; ")}.`;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
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
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
function joinComponentStatus(ir, rows) {
|
|
2875
|
+
const byComponent = new Map(rows.map((r) => [r.component, r]));
|
|
2876
|
+
return {
|
|
2877
|
+
...ir,
|
|
2878
|
+
nodes: ir.nodes.map((n) => {
|
|
2879
|
+
const row = byComponent.get(n.id);
|
|
2880
|
+
if (!row) return n;
|
|
2881
|
+
return {
|
|
2882
|
+
...n,
|
|
2883
|
+
attrs: {
|
|
2884
|
+
...n.attrs,
|
|
2885
|
+
_status: componentStatusColor(row),
|
|
2886
|
+
_liveStatus: {
|
|
2887
|
+
reconciliation: row.reconciliation,
|
|
2888
|
+
detail: row.detail,
|
|
2889
|
+
...row.live !== void 0 ? { live: row.live } : {},
|
|
2890
|
+
...row.stack ? { stack: row.stack } : {},
|
|
2891
|
+
...row.resources ? { resources: row.resources } : {}
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
};
|
|
2895
|
+
})
|
|
2625
2896
|
};
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2897
|
+
}
|
|
2898
|
+
function joinTickVerdicts(ir, tick, now = Date.now()) {
|
|
2899
|
+
if (!tick || !tick.verdicts.length) return ir;
|
|
2900
|
+
const { fresh } = tickFreshness(tick.at, now);
|
|
2901
|
+
const byComponent = new Map(tick.verdicts.map((v) => [v.component, v]));
|
|
2902
|
+
let joined = false;
|
|
2903
|
+
const nodes = ir.nodes.map((n) => {
|
|
2904
|
+
const v = byComponent.get(n.id);
|
|
2905
|
+
if (!v) return n;
|
|
2906
|
+
joined = true;
|
|
2907
|
+
const status = {
|
|
2908
|
+
reconciliation: v.reconciliation,
|
|
2909
|
+
detail: v.detail,
|
|
2910
|
+
...v.live !== void 0 ? { live: v.live } : {},
|
|
2911
|
+
...v.unobserved ? { unobserved: v.unobserved } : {},
|
|
2912
|
+
op: tick.op,
|
|
2913
|
+
env: tick.env,
|
|
2914
|
+
at: tick.at,
|
|
2915
|
+
...tick.tickId ? { tickId: tick.tickId } : {},
|
|
2916
|
+
stale: !fresh
|
|
2917
|
+
};
|
|
2918
|
+
const paint = fresh && n.attrs?._status === void 0;
|
|
2919
|
+
return {
|
|
2920
|
+
...n,
|
|
2921
|
+
attrs: {
|
|
2922
|
+
...n.attrs,
|
|
2923
|
+
// The reconciliation tier and nothing else — `live`/`stack`/`resources`
|
|
2924
|
+
// are deliberately not passed, so this can only ever reach
|
|
2925
|
+
// `componentStatusColor`'s last branch.
|
|
2926
|
+
...paint ? { _status: componentStatusColor({ reconciliation: v.reconciliation, detail: v.detail }) } : {},
|
|
2927
|
+
_tickStatus: status
|
|
2654
2928
|
}
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2929
|
+
};
|
|
2930
|
+
});
|
|
2931
|
+
return joined ? { ...ir, nodes } : ir;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// src/overlay.ts
|
|
2935
|
+
function pruneImports(ir) {
|
|
2936
|
+
const drop = new Set((ir.imports ?? []).map((i) => i.node));
|
|
2937
|
+
if (drop.size === 0) return ir;
|
|
2938
|
+
ir.nodes = ir.nodes.filter((n) => !drop.has(n.id));
|
|
2939
|
+
if (ir.edges) ir.edges = ir.edges.filter((e) => !drop.has(e.from) && !drop.has(e.to));
|
|
2940
|
+
return ir;
|
|
2941
|
+
}
|
|
2942
|
+
function componentDir(node) {
|
|
2943
|
+
const parts = node.sourceLoc?.file?.split("/") ?? [];
|
|
2944
|
+
if (parts[0] !== "src" || parts[1] === "examples" || parts.length < 3) return void 0;
|
|
2945
|
+
return parts[1];
|
|
2946
|
+
}
|
|
2947
|
+
function reclassifyOverlay(ir) {
|
|
2948
|
+
const deployedComponents = /* @__PURE__ */ new Set();
|
|
2949
|
+
for (const n of ir.nodes) {
|
|
2950
|
+
if (n.attrs?._status === "good") {
|
|
2951
|
+
const c = componentDir(n);
|
|
2952
|
+
if (c) deployedComponents.add(c);
|
|
2668
2953
|
}
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2954
|
+
}
|
|
2955
|
+
for (const n of ir.nodes) {
|
|
2956
|
+
const file = n.sourceLoc?.file ?? "";
|
|
2957
|
+
if (file.startsWith("src/examples/")) {
|
|
2958
|
+
n.attrs = { ...n.attrs, _byo: true };
|
|
2959
|
+
delete n.attrs._status;
|
|
2960
|
+
} else if (n.kind === "AWS::CloudFormation::Parameter") {
|
|
2961
|
+
const c = componentDir(n);
|
|
2962
|
+
if (c && deployedComponents.has(c)) {
|
|
2963
|
+
n.attrs = { ...n.attrs, _status: "good" };
|
|
2673
2964
|
}
|
|
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
2965
|
}
|
|
2683
2966
|
}
|
|
2684
|
-
return
|
|
2967
|
+
return ir;
|
|
2685
2968
|
}
|
|
2686
|
-
function
|
|
2687
|
-
const
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2969
|
+
function pruneRuntimeChildren(ir) {
|
|
2970
|
+
const runtime = new Set(ir.nodes.filter((n) => n.runtimeOwner).map((n) => n.id));
|
|
2971
|
+
if (runtime.size === 0) return ir;
|
|
2972
|
+
ir.nodes = ir.nodes.filter((n) => !runtime.has(n.id));
|
|
2973
|
+
ir.edges = ir.edges.filter((e) => !runtime.has(e.from) && !runtime.has(e.to));
|
|
2974
|
+
return ir;
|
|
2975
|
+
}
|
|
2976
|
+
function attachRuntimeContainment(ir) {
|
|
2977
|
+
const byOwner = /* @__PURE__ */ new Map();
|
|
2978
|
+
for (const n of ir.nodes) {
|
|
2979
|
+
if (!n.runtimeOwner) continue;
|
|
2980
|
+
if (!byOwner.has(n.runtimeOwner)) byOwner.set(n.runtimeOwner, /* @__PURE__ */ new Set());
|
|
2981
|
+
byOwner.get(n.runtimeOwner).add(n.id);
|
|
2982
|
+
}
|
|
2983
|
+
if (byOwner.size === 0) return ir;
|
|
2984
|
+
const byContainer = { ...ir.groups.byContainer ?? {} };
|
|
2985
|
+
for (const [owner, children] of byOwner) {
|
|
2986
|
+
byContainer[owner] = [.../* @__PURE__ */ new Set([owner, ...byContainer[owner] ?? [], ...children])].sort();
|
|
2693
2987
|
}
|
|
2988
|
+
ir.groups.byContainer = byContainer;
|
|
2694
2989
|
return ir;
|
|
2695
2990
|
}
|
|
2696
2991
|
|
|
2697
|
-
// src/
|
|
2698
|
-
function
|
|
2699
|
-
const
|
|
2700
|
-
return
|
|
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}-`);
|
|
2992
|
+
// src/value-match.ts
|
|
2993
|
+
function typeSegment(kind) {
|
|
2994
|
+
const parts = kind.split(kind.includes("::") ? "::" : "/");
|
|
2995
|
+
return parts[parts.length - 1] ?? kind;
|
|
2706
2996
|
}
|
|
2707
|
-
function
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
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++;
|
|
2735
|
-
}
|
|
2736
|
-
return { charts, installed };
|
|
2997
|
+
function isOwnNameAttr(kind, key) {
|
|
2998
|
+
if (!/name$/i.test(key)) return false;
|
|
2999
|
+
const prefix = key.slice(0, -4).toLowerCase();
|
|
3000
|
+
if (prefix === "") return true;
|
|
3001
|
+
return typeSegment(kind).toLowerCase().includes(prefix);
|
|
2737
3002
|
}
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
3003
|
+
var MIN_NAME_LEN = 6;
|
|
3004
|
+
var MAX_NAME_LEN = 200;
|
|
3005
|
+
var MAX_DEPTH = 12;
|
|
3006
|
+
var SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
3007
|
+
"status",
|
|
3008
|
+
"annotations",
|
|
3009
|
+
"labels",
|
|
3010
|
+
"matchLabels",
|
|
3011
|
+
"managedFields",
|
|
3012
|
+
"ownerReferences",
|
|
3013
|
+
"creationTimestamp",
|
|
3014
|
+
"resourceVersion",
|
|
3015
|
+
"uid",
|
|
3016
|
+
"selfLink",
|
|
3017
|
+
"generation",
|
|
3018
|
+
"finalizers"
|
|
3019
|
+
]);
|
|
3020
|
+
function joinable(v) {
|
|
3021
|
+
return v.length >= MIN_NAME_LEN && v.length <= MAX_NAME_LEN && !/\s/.test(v);
|
|
2753
3022
|
}
|
|
2754
|
-
function
|
|
2755
|
-
const
|
|
2756
|
-
const
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
3023
|
+
function stringLeaves(attrs) {
|
|
3024
|
+
const out = [];
|
|
3025
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
3026
|
+
const step = (v, key, path, depth) => {
|
|
3027
|
+
if (typeof v === "string") {
|
|
3028
|
+
out.push({ path, key, value: v });
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
if (depth >= MAX_DEPTH || !v || typeof v !== "object") return;
|
|
3032
|
+
if (onPath.has(v)) return;
|
|
3033
|
+
onPath.add(v);
|
|
3034
|
+
if (Array.isArray(v)) {
|
|
3035
|
+
for (const item of v) step(item, key, path, depth + 1);
|
|
3036
|
+
} else {
|
|
3037
|
+
for (const [k, val] of Object.entries(v)) {
|
|
3038
|
+
if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
|
|
3039
|
+
step(val, k, path ? `${path}.${k}` : k, depth + 1);
|
|
2766
3040
|
}
|
|
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
3041
|
}
|
|
3042
|
+
onPath.delete(v);
|
|
2773
3043
|
};
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
return void 0;
|
|
2778
|
-
}
|
|
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
|
-
}
|
|
2786
|
-
var cache = /* @__PURE__ */ new Map();
|
|
2787
|
-
var hits = 0;
|
|
2788
|
-
var misses = 0;
|
|
2789
|
-
function invalidateMember(dir) {
|
|
2790
|
-
if (dir === void 0) {
|
|
2791
|
-
cache.clear();
|
|
2792
|
-
return;
|
|
3044
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
3045
|
+
if (k.startsWith("_") || SKIP_KEYS.has(k)) continue;
|
|
3046
|
+
step(v, k, k, 0);
|
|
2793
3047
|
}
|
|
2794
|
-
|
|
2795
|
-
for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
|
|
3048
|
+
return out;
|
|
2796
3049
|
}
|
|
2797
|
-
|
|
2798
|
-
const
|
|
2799
|
-
|
|
2800
|
-
const
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
cache.set(key, hit);
|
|
2807
|
-
return structuredClone(hit.ir);
|
|
3050
|
+
function addValueMatchEdges(ir) {
|
|
3051
|
+
const nodes = ir.nodes;
|
|
3052
|
+
const owner = /* @__PURE__ */ new Map();
|
|
3053
|
+
for (const n of nodes) {
|
|
3054
|
+
for (const { path, key, value } of stringLeaves(n.attrs)) {
|
|
3055
|
+
if (path !== key && path !== `metadata.${key}`) continue;
|
|
3056
|
+
if (!joinable(value) || !isOwnNameAttr(n.kind, key)) continue;
|
|
3057
|
+
owner.set(value, owner.has(value) ? null : n.id);
|
|
3058
|
+
}
|
|
2808
3059
|
}
|
|
2809
|
-
|
|
2810
|
-
const
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
3060
|
+
if (owner.size === 0) return ir;
|
|
3061
|
+
const declared = /* @__PURE__ */ new Set();
|
|
3062
|
+
for (const e of ir.edges) declared.add(`${e.from}\0${e.to}`);
|
|
3063
|
+
const added = /* @__PURE__ */ new Set();
|
|
3064
|
+
for (const a of nodes) {
|
|
3065
|
+
for (const { path, value } of stringLeaves(a.attrs)) {
|
|
3066
|
+
if (!joinable(value)) continue;
|
|
3067
|
+
const b = owner.get(value);
|
|
3068
|
+
if (!b || b === a.id) continue;
|
|
3069
|
+
const key = `${a.id}\0${b}`;
|
|
3070
|
+
if (declared.has(key) || added.has(key)) continue;
|
|
3071
|
+
added.add(key);
|
|
3072
|
+
ir.edges.push({ from: a.id, to: b, kind: "ref", viaAttr: path, inferred: true });
|
|
3073
|
+
}
|
|
2817
3074
|
}
|
|
2818
|
-
return
|
|
3075
|
+
return ir;
|
|
2819
3076
|
}
|
|
2820
3077
|
|
|
2821
|
-
// src/
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
2836
|
-
}
|
|
2837
|
-
return void 0;
|
|
3078
|
+
// src/k8s-edges.ts
|
|
3079
|
+
var WORKLOAD_KINDS = /* @__PURE__ */ new Set([
|
|
3080
|
+
"K8s::Apps::Deployment",
|
|
3081
|
+
"K8s::Apps::StatefulSet",
|
|
3082
|
+
"K8s::Apps::DaemonSet",
|
|
3083
|
+
"K8s::Apps::ReplicaSet"
|
|
3084
|
+
]);
|
|
3085
|
+
var SELECTOR_CARRIERS = [
|
|
3086
|
+
{ kind: "K8s::Core::Service", read: (spec) => spec?.selector, viaAttr: "selector" },
|
|
3087
|
+
{ kind: "K8s::Policy::PodDisruptionBudget", read: (spec) => labelSelector(spec?.selector), viaAttr: "selector" },
|
|
3088
|
+
{ kind: "K8s::Networking::NetworkPolicy", read: (spec) => labelSelector(spec?.podSelector), viaAttr: "podSelector" }
|
|
3089
|
+
];
|
|
3090
|
+
function rec2(v) {
|
|
3091
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
2838
3092
|
}
|
|
2839
|
-
function
|
|
2840
|
-
|
|
2841
|
-
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)`;
|
|
3093
|
+
function str2(v) {
|
|
3094
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
2842
3095
|
}
|
|
2843
|
-
function
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
3096
|
+
function namespaceOf(n) {
|
|
3097
|
+
return str2(rec2(n.attrs?.metadata)?.namespace) ?? "default";
|
|
3098
|
+
}
|
|
3099
|
+
function nameOf(n) {
|
|
3100
|
+
return str2(rec2(n.attrs?.metadata)?.name);
|
|
3101
|
+
}
|
|
3102
|
+
function labelRecord(v) {
|
|
3103
|
+
const r = rec2(v);
|
|
3104
|
+
if (!r) return void 0;
|
|
3105
|
+
const out = {};
|
|
3106
|
+
for (const [k, val] of Object.entries(r)) {
|
|
3107
|
+
if (typeof val !== "string") return void 0;
|
|
3108
|
+
out[k] = val;
|
|
2848
3109
|
}
|
|
2849
|
-
return
|
|
3110
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
2850
3111
|
}
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
]);
|
|
2858
|
-
function namespaceMismatchNote(nodes) {
|
|
2859
|
-
const k8s = nodes.filter((n) => n?.lexicon === "k8s");
|
|
2860
|
-
if (k8s.length === 0) return void 0;
|
|
2861
|
-
const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
2862
|
-
if (pending.length < 2) return void 0;
|
|
2863
|
-
if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
|
|
2864
|
-
if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
|
|
2865
|
-
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`;
|
|
3112
|
+
function labelSelector(v) {
|
|
3113
|
+
const sel = rec2(v);
|
|
3114
|
+
if (!sel) return void 0;
|
|
3115
|
+
const expressions = sel.matchExpressions;
|
|
3116
|
+
if (Array.isArray(expressions) && expressions.length > 0) return void 0;
|
|
3117
|
+
return sel.matchLabels;
|
|
2866
3118
|
}
|
|
2867
|
-
function
|
|
2868
|
-
|
|
2869
|
-
const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
|
|
2870
|
-
return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
|
|
3119
|
+
function selects(selector, labels) {
|
|
3120
|
+
return Object.entries(selector).every(([k, v]) => labels[k] === v);
|
|
2871
3121
|
}
|
|
2872
|
-
function
|
|
2873
|
-
const
|
|
2874
|
-
|
|
2875
|
-
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
3122
|
+
function templateLabels(n) {
|
|
3123
|
+
const template = rec2(rec2(n.attrs?.spec)?.template);
|
|
3124
|
+
return labelRecord(rec2(template?.metadata)?.labels);
|
|
2876
3125
|
}
|
|
2877
|
-
function
|
|
2878
|
-
const
|
|
2879
|
-
|
|
2880
|
-
const byLexicon = /* @__PURE__ */ new Map();
|
|
2881
|
-
for (const n of ir.nodes) {
|
|
2882
|
-
const status = n.attrs?._status;
|
|
2883
|
-
if (status !== "good" && status !== "accent") continue;
|
|
2884
|
-
const lexicon = n.lexicon ?? "?";
|
|
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));
|
|
3126
|
+
function shortKind(kind) {
|
|
3127
|
+
const parts = kind.split("::");
|
|
3128
|
+
return parts[parts.length - 1] ?? kind;
|
|
2903
3129
|
}
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
3130
|
+
var FLUX_SOURCE_KINDS = /* @__PURE__ */ new Set([
|
|
3131
|
+
"K8s::Flux::GitRepository",
|
|
3132
|
+
"K8s::Flux::OCIRepository",
|
|
3133
|
+
"K8s::Flux::HelmRepository",
|
|
3134
|
+
"K8s::Flux::HelmChart",
|
|
3135
|
+
"K8s::Flux::Bucket"
|
|
3136
|
+
]);
|
|
3137
|
+
function sourceRefOf(refObj, referrer, defaultKind) {
|
|
3138
|
+
const ref = rec2(refObj);
|
|
3139
|
+
const kind = str2(ref?.kind) ?? defaultKind;
|
|
3140
|
+
const name = str2(ref?.name);
|
|
3141
|
+
if (!kind || !name) return void 0;
|
|
3142
|
+
return { kind, name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
|
|
2914
3143
|
}
|
|
2915
|
-
function
|
|
2916
|
-
const
|
|
2917
|
-
|
|
3144
|
+
function namedRefOf(refObj, referrer) {
|
|
3145
|
+
const ref = rec2(refObj);
|
|
3146
|
+
const name = str2(ref?.name);
|
|
3147
|
+
if (!name) return void 0;
|
|
3148
|
+
return { name, namespace: str2(ref?.namespace) ?? namespaceOf(referrer) };
|
|
2918
3149
|
}
|
|
2919
|
-
|
|
2920
|
-
|
|
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);
|
|
3150
|
+
function list(v) {
|
|
3151
|
+
return Array.isArray(v) ? v : [];
|
|
2926
3152
|
}
|
|
2927
|
-
|
|
2928
|
-
|
|
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));
|
|
2934
|
-
for (const n of ir.nodes) {
|
|
2935
|
-
if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
|
|
2936
|
-
}
|
|
2937
|
-
return ir;
|
|
3153
|
+
function helmReleaseSourceRef(n) {
|
|
3154
|
+
return rec2(rec2(rec2(n.attrs?.spec)?.chart)?.spec)?.sourceRef;
|
|
2938
3155
|
}
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
var str2 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
2942
|
-
var realIsDir = (p) => {
|
|
2943
|
-
try {
|
|
2944
|
-
return statSync3(p).isDirectory();
|
|
2945
|
-
} catch {
|
|
2946
|
-
return false;
|
|
2947
|
-
}
|
|
2948
|
-
};
|
|
2949
|
-
function declaredNamespaceBindings(ir) {
|
|
3156
|
+
function ingressBackendServices(n) {
|
|
3157
|
+
const spec = rec2(n.attrs?.spec);
|
|
2950
3158
|
const out = [];
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
3159
|
+
const backendName = (b) => str2(rec2(rec2(b)?.service)?.name);
|
|
3160
|
+
const fromDefault = backendName(spec?.defaultBackend);
|
|
3161
|
+
if (fromDefault) out.push(fromDefault);
|
|
3162
|
+
const rules = Array.isArray(spec?.rules) ? spec.rules : [];
|
|
3163
|
+
for (const rule of rules) {
|
|
3164
|
+
const paths = rec2(rec2(rule)?.http)?.paths;
|
|
3165
|
+
for (const p of Array.isArray(paths) ? paths : []) {
|
|
3166
|
+
const name = backendName(rec2(p)?.backend);
|
|
3167
|
+
if (name) out.push(name);
|
|
3168
|
+
}
|
|
2957
3169
|
}
|
|
2958
3170
|
return out;
|
|
2959
3171
|
}
|
|
2960
|
-
function
|
|
2961
|
-
const
|
|
2962
|
-
if (
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
const
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
return k;
|
|
3172
|
+
function deriveK8sEdges(nodes) {
|
|
3173
|
+
const k8s = nodes.filter((n) => n.lexicon === "k8s");
|
|
3174
|
+
if (k8s.length === 0) return [];
|
|
3175
|
+
const out = [];
|
|
3176
|
+
const add = (from, to, viaAttr) => {
|
|
3177
|
+
if (from !== to) out.push({ from, to, kind: "ref", viaAttr, inferred: true });
|
|
3178
|
+
};
|
|
3179
|
+
const workloads = k8s.filter((n) => WORKLOAD_KINDS.has(n.kind));
|
|
3180
|
+
const byKindNsName = /* @__PURE__ */ new Map();
|
|
3181
|
+
const appProjectsByName = /* @__PURE__ */ new Map();
|
|
3182
|
+
for (const n of k8s) {
|
|
3183
|
+
const name = nameOf(n);
|
|
3184
|
+
if (!name) continue;
|
|
3185
|
+
byKindNsName.set(`${n.kind}\0${namespaceOf(n)}\0${name}`, n);
|
|
3186
|
+
if (n.kind === "K8s::Argo::AppProject") {
|
|
3187
|
+
const same = appProjectsByName.get(name) ?? [];
|
|
3188
|
+
same.push(n);
|
|
3189
|
+
appProjectsByName.set(name, same);
|
|
3190
|
+
}
|
|
2980
3191
|
}
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
const
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3192
|
+
const lookup = (kind, namespace, name) => byKindNsName.get(`${kind}\0${namespace}\0${name}`);
|
|
3193
|
+
const addFluxRef = (n, refObj, viaAttr, allowed, defaultKind) => {
|
|
3194
|
+
const ref = sourceRefOf(refObj, n, defaultKind);
|
|
3195
|
+
if (!ref || ref.name === "*") return;
|
|
3196
|
+
const kind = `K8s::Flux::${ref.kind}`;
|
|
3197
|
+
if (allowed && !allowed.has(kind)) return;
|
|
3198
|
+
const target = lookup(kind, ref.namespace, ref.name);
|
|
3199
|
+
if (target) add(n.id, target.id, viaAttr);
|
|
3200
|
+
};
|
|
3201
|
+
const addSourceRef = (n, refObj, viaAttr, defaultKind) => addFluxRef(n, refObj, viaAttr, FLUX_SOURCE_KINDS, defaultKind);
|
|
3202
|
+
const addNamedRef = (n, refObj, kind, viaAttr) => {
|
|
3203
|
+
const ref = namedRefOf(refObj, n);
|
|
3204
|
+
if (!ref) return;
|
|
3205
|
+
const target = lookup(kind, ref.namespace, ref.name);
|
|
3206
|
+
if (target) add(n.id, target.id, viaAttr);
|
|
3207
|
+
};
|
|
3208
|
+
const addProject = (n, project, viaAttr) => {
|
|
3209
|
+
const candidates = project ? appProjectsByName.get(project) ?? [] : [];
|
|
3210
|
+
const target = candidates.length === 1 ? candidates[0] : candidates.find((p) => namespaceOf(p) === namespaceOf(n));
|
|
3211
|
+
if (target) add(n.id, target.id, viaAttr);
|
|
3212
|
+
};
|
|
3213
|
+
const addSelector = (n, raw, viaAttr) => {
|
|
3214
|
+
const selector = labelRecord(raw);
|
|
3215
|
+
if (!selector) return;
|
|
3216
|
+
for (const w of workloads) {
|
|
3217
|
+
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
3218
|
+
const labels = templateLabels(w);
|
|
3219
|
+
if (labels && selects(selector, labels)) add(n.id, w.id, viaAttr);
|
|
3220
|
+
}
|
|
3221
|
+
};
|
|
3222
|
+
for (const n of k8s) {
|
|
3223
|
+
const carrier = SELECTOR_CARRIERS.find((c) => c.kind === n.kind);
|
|
3224
|
+
if (carrier) {
|
|
3225
|
+
addSelector(n, carrier.read(rec2(n.attrs?.spec)), carrier.viaAttr);
|
|
3226
|
+
continue;
|
|
3227
|
+
}
|
|
3228
|
+
if (n.kind === "K8s::Networking::Ingress") {
|
|
3229
|
+
for (const svcName of ingressBackendServices(n)) {
|
|
3230
|
+
const svc = lookup("K8s::Core::Service", namespaceOf(n), svcName);
|
|
3231
|
+
if (svc) add(n.id, svc.id, "ingress backend");
|
|
3003
3232
|
}
|
|
3233
|
+
continue;
|
|
3234
|
+
}
|
|
3235
|
+
if (n.kind === "K8s::Autoscaling::HorizontalPodAutoscaler") {
|
|
3236
|
+
const ref = rec2(rec2(n.attrs?.spec)?.scaleTargetRef);
|
|
3237
|
+
const refKind = str2(ref?.kind);
|
|
3238
|
+
const refName = str2(ref?.name);
|
|
3239
|
+
if (!refKind || !refName) continue;
|
|
3240
|
+
for (const w of workloads) {
|
|
3241
|
+
if (namespaceOf(w) !== namespaceOf(n)) continue;
|
|
3242
|
+
if (shortKind(w.kind) === refKind && nameOf(w) === refName) add(n.id, w.id, "scaleTargetRef");
|
|
3243
|
+
}
|
|
3244
|
+
continue;
|
|
3245
|
+
}
|
|
3246
|
+
if (n.kind === "K8s::Flux::Kustomization") {
|
|
3247
|
+
addSourceRef(n, rec2(n.attrs?.spec)?.sourceRef, "sourceRef");
|
|
3248
|
+
for (const dep of list(rec2(n.attrs?.spec)?.dependsOn)) {
|
|
3249
|
+
addNamedRef(n, dep, "K8s::Flux::Kustomization", "dependsOn");
|
|
3250
|
+
}
|
|
3251
|
+
continue;
|
|
3252
|
+
}
|
|
3253
|
+
if (n.kind === "K8s::Flux::HelmRelease") {
|
|
3254
|
+
addSourceRef(n, helmReleaseSourceRef(n), "chart sourceRef");
|
|
3255
|
+
continue;
|
|
3256
|
+
}
|
|
3257
|
+
if (n.kind === "K8s::Flux::ImagePolicy") {
|
|
3258
|
+
addNamedRef(n, rec2(n.attrs?.spec)?.imageRepositoryRef, "K8s::Flux::ImageRepository", "imageRepositoryRef");
|
|
3259
|
+
continue;
|
|
3260
|
+
}
|
|
3261
|
+
if (n.kind === "K8s::Flux::ImageUpdateAutomation") {
|
|
3262
|
+
addSourceRef(n, rec2(n.attrs?.spec)?.sourceRef, "sourceRef", "GitRepository");
|
|
3263
|
+
continue;
|
|
3264
|
+
}
|
|
3265
|
+
if (n.kind === "K8s::Flux::Alert") {
|
|
3266
|
+
addNamedRef(n, rec2(n.attrs?.spec)?.providerRef, "K8s::Flux::Provider", "providerRef");
|
|
3267
|
+
for (const src of list(rec2(n.attrs?.spec)?.eventSources)) {
|
|
3268
|
+
addFluxRef(n, src, "eventSource");
|
|
3269
|
+
}
|
|
3270
|
+
continue;
|
|
3271
|
+
}
|
|
3272
|
+
if (n.kind === "K8s::Argo::Application") {
|
|
3273
|
+
addProject(n, str2(rec2(n.attrs?.spec)?.project), "project");
|
|
3274
|
+
continue;
|
|
3275
|
+
}
|
|
3276
|
+
if (n.kind === "K8s::Argo::ApplicationSet") {
|
|
3277
|
+
addProject(n, str2(rec2(rec2(rec2(n.attrs?.spec)?.template)?.spec)?.project), "template project");
|
|
3004
3278
|
}
|
|
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
3279
|
}
|
|
3013
|
-
return
|
|
3280
|
+
return out;
|
|
3014
3281
|
}
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
const
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
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);
|
|
3282
|
+
function addK8sDeclaredEdges(ir) {
|
|
3283
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3284
|
+
for (const e of deriveK8sEdges(ir.nodes)) {
|
|
3285
|
+
const key = `${e.from}\0${e.to}`;
|
|
3286
|
+
if (existing.has(key)) continue;
|
|
3287
|
+
existing.add(key);
|
|
3288
|
+
ir.edges.push(e);
|
|
3027
3289
|
}
|
|
3028
|
-
return
|
|
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}/`)));
|
|
3033
|
-
}
|
|
3034
|
-
async function composeEstateOverlay(projectDirs, opts, classify) {
|
|
3035
|
-
const names = shortStackNames(projectDirs);
|
|
3036
|
-
const unobserved = [];
|
|
3037
|
-
const dropped = [];
|
|
3038
|
-
const joined = [];
|
|
3039
|
-
const stacks = new Array(projectDirs.length);
|
|
3040
|
-
const scopes = await estateNamespaceScopes(projectDirs, opts);
|
|
3041
|
-
await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
|
|
3042
|
-
const name = names[i];
|
|
3043
|
-
const namespace = scopes.get(dir);
|
|
3044
|
-
try {
|
|
3045
|
-
const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
|
|
3046
|
-
stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
|
|
3047
|
-
if (namespace) joined.push({ name, namespace });
|
|
3048
|
-
} catch (err) {
|
|
3049
|
-
const reason = firstLine2(err);
|
|
3050
|
-
try {
|
|
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) });
|
|
3058
|
-
}
|
|
3059
|
-
}
|
|
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
|
-
};
|
|
3290
|
+
return ir;
|
|
3072
3291
|
}
|
|
3073
3292
|
|
|
3074
|
-
// src/helm-
|
|
3075
|
-
function
|
|
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
|
|
3109
|
-
};
|
|
3110
|
-
const provenance = provenanceOf(live);
|
|
3111
|
-
if (d.drifted.length > 0) {
|
|
3112
|
-
return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
|
|
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 };
|
|
3119
|
-
}
|
|
3120
|
-
return { ...base, verdict: "in-sync", counts, provenance };
|
|
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) {
|
|
3293
|
+
// src/helm-artifacts.ts
|
|
3294
|
+
function chartNameOf(node) {
|
|
3133
3295
|
const name = node.attrs?.name;
|
|
3134
3296
|
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
3135
3297
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
if (
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
if (list3) list3.push(r);
|
|
3143
|
-
else byChart.set(r.chart, [r]);
|
|
3144
|
-
}
|
|
3298
|
+
function releaseMatchesChart(observed, chartName) {
|
|
3299
|
+
const chart = observed.attributes?.chart;
|
|
3300
|
+
if (typeof chart !== "string") return false;
|
|
3301
|
+
return chart === chartName || chart.startsWith(`${chartName}-`);
|
|
3302
|
+
}
|
|
3303
|
+
function applyHelmArtifacts(ir, observed) {
|
|
3145
3304
|
let charts = 0;
|
|
3146
|
-
let
|
|
3305
|
+
let installed = 0;
|
|
3147
3306
|
for (const node of ir.nodes) {
|
|
3148
3307
|
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
3149
|
-
const chartName = chartNameOf2(node);
|
|
3150
|
-
const matched = chartName ? byChart.get(chartName) : void 0;
|
|
3151
|
-
if (!matched || matched.length === 0) continue;
|
|
3152
|
-
const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
|
|
3153
|
-
const attrs = node.attrs ??= {};
|
|
3154
|
-
attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
|
|
3155
3308
|
charts++;
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3309
|
+
const attrs = node.attrs ??= {};
|
|
3310
|
+
if (observed === void 0) {
|
|
3311
|
+
attrs._status = "neutral";
|
|
3312
|
+
attrs._unobserved = "artifact-observation-unavailable";
|
|
3313
|
+
continue;
|
|
3314
|
+
}
|
|
3315
|
+
const chartName = chartNameOf(node);
|
|
3316
|
+
const entry = chartName ? Object.entries(observed).find(([key2, o2]) => key2.startsWith("release/") && releaseMatchesChart(o2, chartName)) : void 0;
|
|
3317
|
+
if (!entry) {
|
|
3318
|
+
attrs._status = "accent";
|
|
3319
|
+
continue;
|
|
3159
3320
|
}
|
|
3321
|
+
const [key, o] = entry;
|
|
3322
|
+
const match = {
|
|
3323
|
+
release: key.replace(/^release\//, ""),
|
|
3324
|
+
...o.status ? { status: o.status } : {},
|
|
3325
|
+
...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
|
|
3326
|
+
...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
|
|
3327
|
+
};
|
|
3328
|
+
attrs._artifact = match;
|
|
3329
|
+
attrs._status = o.status === "deployed" ? "good" : "warn";
|
|
3330
|
+
if (o.status === "deployed") installed++;
|
|
3160
3331
|
}
|
|
3161
|
-
return { charts,
|
|
3332
|
+
return { charts, installed };
|
|
3162
3333
|
}
|
|
3163
3334
|
|
|
3164
|
-
// src/
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3335
|
+
// src/estate.ts
|
|
3336
|
+
import { statSync as statSync3 } from "node:fs";
|
|
3337
|
+
import { availableParallelism } from "node:os";
|
|
3338
|
+
import { join as join7, resolve as resolve3, sep } from "node:path";
|
|
3339
|
+
import { composeStacks, shortStackNames } from "@intentius/pinhole";
|
|
3340
|
+
|
|
3341
|
+
// src/member-ir.ts
|
|
3342
|
+
import { createHash } from "node:crypto";
|
|
3343
|
+
import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
3344
|
+
import { join as join6, relative, resolve as resolve2 } from "node:path";
|
|
3345
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
|
|
3346
|
+
function memberIrCacheSize(env = process.env) {
|
|
3347
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
|
|
3348
|
+
return Number.isInteger(override) && override >= 0 ? override : 64;
|
|
3175
3349
|
}
|
|
3176
|
-
function
|
|
3177
|
-
const
|
|
3178
|
-
|
|
3350
|
+
function memberSourceStamp(dir) {
|
|
3351
|
+
const root = resolve2(dir);
|
|
3352
|
+
const h = createHash("sha1");
|
|
3353
|
+
let any = false;
|
|
3354
|
+
const walk = (at) => {
|
|
3355
|
+
const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
3356
|
+
for (const e of entries) {
|
|
3357
|
+
if (SKIP.has(e.name)) continue;
|
|
3358
|
+
const path = join6(at, e.name);
|
|
3359
|
+
if (e.isDirectory()) {
|
|
3360
|
+
walk(path);
|
|
3361
|
+
continue;
|
|
3362
|
+
}
|
|
3363
|
+
if (!e.isFile()) continue;
|
|
3364
|
+
const st = statSync2(path);
|
|
3365
|
+
h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
|
|
3366
|
+
`);
|
|
3367
|
+
any = true;
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
try {
|
|
3371
|
+
walk(root);
|
|
3372
|
+
} catch {
|
|
3373
|
+
return void 0;
|
|
3374
|
+
}
|
|
3375
|
+
return any ? h.digest("hex") : void 0;
|
|
3179
3376
|
}
|
|
3180
|
-
function
|
|
3181
|
-
|
|
3377
|
+
function cacheKey(dir, opts) {
|
|
3378
|
+
const chant = resolveChant(dir);
|
|
3379
|
+
const canonical = JSON.stringify(opts, Object.keys(opts).sort());
|
|
3380
|
+
return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
|
|
3182
3381
|
}
|
|
3183
|
-
|
|
3184
|
-
|
|
3382
|
+
var cache = /* @__PURE__ */ new Map();
|
|
3383
|
+
var hits = 0;
|
|
3384
|
+
var misses = 0;
|
|
3385
|
+
function invalidateMember(dir) {
|
|
3386
|
+
if (dir === void 0) {
|
|
3387
|
+
cache.clear();
|
|
3388
|
+
return;
|
|
3389
|
+
}
|
|
3390
|
+
const root = resolve2(dir);
|
|
3391
|
+
for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
|
|
3185
3392
|
}
|
|
3186
|
-
function
|
|
3187
|
-
const
|
|
3188
|
-
if (
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3393
|
+
async function memberIr(dir, opts = {}) {
|
|
3394
|
+
const size = memberIrCacheSize();
|
|
3395
|
+
if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
|
|
3396
|
+
const stamp = memberSourceStamp(dir);
|
|
3397
|
+
const key = cacheKey(dir, opts);
|
|
3398
|
+
const hit = stamp !== void 0 ? cache.get(key) : void 0;
|
|
3399
|
+
if (hit && hit.stamp === stamp) {
|
|
3400
|
+
hits++;
|
|
3401
|
+
cache.delete(key);
|
|
3402
|
+
cache.set(key, hit);
|
|
3403
|
+
return structuredClone(hit.ir);
|
|
3192
3404
|
}
|
|
3193
|
-
|
|
3405
|
+
misses++;
|
|
3406
|
+
const ir = await graphIr(dir, opts);
|
|
3407
|
+
if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
|
|
3408
|
+
cache.delete(key);
|
|
3409
|
+
cache.set(key, { dir: resolve2(dir), stamp, ir });
|
|
3410
|
+
for (const oldest of cache.keys()) {
|
|
3411
|
+
if (cache.size <= size) break;
|
|
3412
|
+
cache.delete(oldest);
|
|
3413
|
+
}
|
|
3414
|
+
return structuredClone(ir);
|
|
3194
3415
|
}
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
}
|
|
3202
|
-
function addClusterAnchorEdges(ir, boundContext) {
|
|
3203
|
-
const nodes = ir.nodes;
|
|
3204
|
-
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
3205
|
-
if (!cluster) return ir;
|
|
3206
|
-
const k8sNodes = nodes.filter(isK8s);
|
|
3207
|
-
if (k8sNodes.length === 0) return ir;
|
|
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);
|
|
3416
|
+
|
|
3417
|
+
// src/zoom-notes.ts
|
|
3418
|
+
function zoomNote(zoom, ir, compositeEdgesAttached) {
|
|
3419
|
+
const empty = ir.nodes.length === 0;
|
|
3420
|
+
if (zoom === "components") {
|
|
3421
|
+
return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
|
|
3213
3422
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
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);
|
|
3423
|
+
if (zoom === "logical") {
|
|
3424
|
+
return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
|
|
3230
3425
|
}
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
// src/logical-azure.ts
|
|
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]);
|
|
3426
|
+
if (zoom === "composites") {
|
|
3427
|
+
return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
|
|
3281
3428
|
}
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
if (value === null || typeof value !== "object") {
|
|
3286
|
-
for (const name of resourceIdNames(value)) out.add(name);
|
|
3287
|
-
return;
|
|
3429
|
+
if (zoom === "runtime") {
|
|
3430
|
+
const children = ir.nodes.filter((n) => n.runtimeOwner).length;
|
|
3431
|
+
return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
|
|
3288
3432
|
}
|
|
3289
|
-
|
|
3290
|
-
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
3291
|
-
for (const v of Object.values(value)) collectAzureRefs(v, out);
|
|
3433
|
+
return void 0;
|
|
3292
3434
|
}
|
|
3293
|
-
function
|
|
3294
|
-
|
|
3295
|
-
return
|
|
3435
|
+
function logicalKept(before, after) {
|
|
3436
|
+
if (after > 0 && after * 3 >= before) return void 0;
|
|
3437
|
+
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)`;
|
|
3296
3438
|
}
|
|
3297
|
-
function
|
|
3298
|
-
|
|
3439
|
+
function edgelessNote(zoom, ir, detail) {
|
|
3440
|
+
if (zoom === "components" || zoom === "logical") return void 0;
|
|
3441
|
+
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
3442
|
+
if (detail !== void 0 && detail < 3) {
|
|
3443
|
+
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)";
|
|
3444
|
+
}
|
|
3445
|
+
return "no edges \u2014 nothing in this estate references anything else";
|
|
3299
3446
|
}
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3447
|
+
var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
|
|
3448
|
+
"K8s::Core::Namespace",
|
|
3449
|
+
"K8s::Rbac::ClusterRole",
|
|
3450
|
+
"K8s::Rbac::ClusterRoleBinding",
|
|
3451
|
+
"K8s::Storage::StorageClass",
|
|
3452
|
+
"K8s::Apiextensions::CustomResourceDefinition"
|
|
3453
|
+
]);
|
|
3454
|
+
function namespaceMismatchNote(nodes) {
|
|
3455
|
+
const k8s = nodes.filter((n) => n?.lexicon === "k8s");
|
|
3456
|
+
if (k8s.length === 0) return void 0;
|
|
3457
|
+
const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
3458
|
+
if (pending.length < 2) return void 0;
|
|
3459
|
+
if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
|
|
3460
|
+
if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
|
|
3461
|
+
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`;
|
|
3304
3462
|
}
|
|
3305
|
-
function
|
|
3306
|
-
|
|
3307
|
-
|
|
3463
|
+
function namespaceJoinNote(joined) {
|
|
3464
|
+
if (joined.length === 0) return void 0;
|
|
3465
|
+
const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
|
|
3466
|
+
return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
|
|
3308
3467
|
}
|
|
3309
|
-
function
|
|
3310
|
-
const
|
|
3311
|
-
const
|
|
3312
|
-
return
|
|
3468
|
+
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
|
|
3469
|
+
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
3470
|
+
const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
|
|
3471
|
+
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
3313
3472
|
}
|
|
3314
|
-
function
|
|
3315
|
-
const
|
|
3316
|
-
if (
|
|
3317
|
-
const
|
|
3318
|
-
const
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
const
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
|
|
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);
|
|
3335
|
-
}
|
|
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);
|
|
3473
|
+
function tierMismatchNote(ir, tiers, currentTier) {
|
|
3474
|
+
const values = tiers?.values ?? [];
|
|
3475
|
+
if (values.length < 2) return void 0;
|
|
3476
|
+
const byLexicon = /* @__PURE__ */ new Map();
|
|
3477
|
+
for (const n of ir.nodes) {
|
|
3478
|
+
const status = n.attrs?._status;
|
|
3479
|
+
if (status !== "good" && status !== "accent") continue;
|
|
3480
|
+
const lexicon = n.lexicon ?? "?";
|
|
3481
|
+
const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
|
|
3482
|
+
c[status === "good" ? "good" : "accent"]++;
|
|
3483
|
+
byLexicon.set(lexicon, c);
|
|
3341
3484
|
}
|
|
3342
|
-
const
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
const names = /* @__PURE__ */ new Set();
|
|
3348
|
-
collectAzureRefs(s.attrs?.networkSecurityGroup, names);
|
|
3349
|
-
for (const name of names) {
|
|
3350
|
-
if (byName.get(name)?.kind === NSG_KIND) guardedBy.set(s.id, name);
|
|
3351
|
-
}
|
|
3485
|
+
for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
|
|
3486
|
+
if (accent < 2 || accent * 2 < good + accent) continue;
|
|
3487
|
+
const here = currentTier ? `tier "${currentTier}"` : "the default tier";
|
|
3488
|
+
const others = values.filter((v) => v !== currentTier).join(", ");
|
|
3489
|
+
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)`;
|
|
3352
3490
|
}
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3491
|
+
return void 0;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
// src/estate.ts
|
|
3495
|
+
function estateReadPool(members, env = process.env) {
|
|
3496
|
+
const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
|
|
3497
|
+
const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
|
|
3498
|
+
return Math.max(1, Math.min(cap, members));
|
|
3499
|
+
}
|
|
3500
|
+
async function mapPool(items, width, fn) {
|
|
3501
|
+
const results = new Array(items.length);
|
|
3502
|
+
let next = 0;
|
|
3503
|
+
const worker = async () => {
|
|
3504
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
3505
|
+
results[i] = await fn(items[i], i);
|
|
3368
3506
|
}
|
|
3369
|
-
return void 0;
|
|
3370
|
-
};
|
|
3371
|
-
const byContainer = {};
|
|
3372
|
-
const child = (parent, c) => {
|
|
3373
|
-
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
3374
|
-
if (!arr.includes(c)) arr.push(c);
|
|
3375
3507
|
};
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
}
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3508
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
|
|
3509
|
+
return results;
|
|
3510
|
+
}
|
|
3511
|
+
function estateMembers(projectDirs) {
|
|
3512
|
+
const names = shortStackNames([...projectDirs]);
|
|
3513
|
+
return projectDirs.map((dir, i) => ({ name: names[i], dir }));
|
|
3514
|
+
}
|
|
3515
|
+
async function composeEstate(projectDirs, opts = {}) {
|
|
3516
|
+
const names = shortStackNames(projectDirs);
|
|
3517
|
+
const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
|
|
3518
|
+
name: names[i],
|
|
3519
|
+
ir: await memberIr(dir, opts)
|
|
3520
|
+
}));
|
|
3521
|
+
return composeStacks(stacks);
|
|
3522
|
+
}
|
|
3523
|
+
var firstLine2 = (e) => {
|
|
3524
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3525
|
+
const m = msg.match(/exited \d+:\s*([\s\S]*)/);
|
|
3526
|
+
return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
|
|
3527
|
+
};
|
|
3528
|
+
function namespaceRuntimeOwners(name, ir) {
|
|
3529
|
+
const own = new Set(ir.nodes.map((n) => n.id));
|
|
3530
|
+
for (const n of ir.nodes) {
|
|
3531
|
+
if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
|
|
3532
|
+
}
|
|
3533
|
+
return ir;
|
|
3534
|
+
}
|
|
3535
|
+
var NAMESPACE_JOIN_FLOOR = "0.44.5";
|
|
3536
|
+
var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
3537
|
+
var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3538
|
+
var realIsDir = (p) => {
|
|
3539
|
+
try {
|
|
3540
|
+
return statSync3(p).isDirectory();
|
|
3541
|
+
} catch {
|
|
3542
|
+
return false;
|
|
3543
|
+
}
|
|
3544
|
+
};
|
|
3545
|
+
function declaredNamespaceBindings(ir) {
|
|
3546
|
+
const out = [];
|
|
3547
|
+
for (const n of ir.nodes) {
|
|
3548
|
+
if (n.kind !== "K8s::Flux::Kustomization") continue;
|
|
3549
|
+
const spec = rec3(n.attrs?.spec);
|
|
3550
|
+
const namespace = str3(spec?.targetNamespace);
|
|
3551
|
+
const path = str3(spec?.path);
|
|
3552
|
+
if (namespace && path) out.push({ path, namespace });
|
|
3553
|
+
}
|
|
3554
|
+
return out;
|
|
3555
|
+
}
|
|
3556
|
+
function awaitsNamespaceBinding(ir) {
|
|
3557
|
+
const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
|
|
3558
|
+
if (namespaced.length === 0) return false;
|
|
3559
|
+
return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
|
|
3560
|
+
}
|
|
3561
|
+
function declaredSegments(path) {
|
|
3562
|
+
if (path.startsWith("/")) return void 0;
|
|
3563
|
+
const segs = path.split("/").filter((s) => s !== "" && s !== ".");
|
|
3564
|
+
if (segs.length === 0 || segs.includes("..")) return void 0;
|
|
3565
|
+
return segs;
|
|
3566
|
+
}
|
|
3567
|
+
function pathAlignment(declared, dir, isDir = realIsDir) {
|
|
3568
|
+
const want = declaredSegments(declared);
|
|
3569
|
+
if (!want) return 0;
|
|
3570
|
+
const root = resolve3(dir);
|
|
3571
|
+
const have = root.split(sep).filter(Boolean);
|
|
3572
|
+
for (let k = Math.min(want.length, have.length); k >= 1; k--) {
|
|
3573
|
+
if (!have.slice(-k).every((s, i) => s === want[i])) continue;
|
|
3574
|
+
if (!isDir(join7(root, ...want.slice(k)))) continue;
|
|
3575
|
+
return k;
|
|
3576
|
+
}
|
|
3577
|
+
return 0;
|
|
3578
|
+
}
|
|
3579
|
+
function joinNamespaceBindings(members, isDir = realIsDir) {
|
|
3580
|
+
const bindings = members.flatMap(
|
|
3581
|
+
(m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
|
|
3582
|
+
);
|
|
3583
|
+
if (bindings.length === 0) return [];
|
|
3584
|
+
const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
|
|
3585
|
+
if (awaiting.length === 0) return [];
|
|
3586
|
+
const best = /* @__PURE__ */ new Map();
|
|
3587
|
+
for (const b of bindings) {
|
|
3588
|
+
let winner;
|
|
3589
|
+
let tied = false;
|
|
3590
|
+
for (const m of awaiting) {
|
|
3591
|
+
if (m.dir === b.declaredBy) continue;
|
|
3592
|
+
const score = pathAlignment(b.path, m.dir, isDir);
|
|
3593
|
+
if (score === 0) continue;
|
|
3594
|
+
if (!winner || score > winner.score) {
|
|
3595
|
+
winner = { dir: m.dir, score };
|
|
3596
|
+
tied = false;
|
|
3597
|
+
} else if (score === winner.score) {
|
|
3598
|
+
tied = true;
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
if (!winner || tied) continue;
|
|
3602
|
+
const prev = best.get(winner.dir);
|
|
3603
|
+
if (prev === "ambiguous") continue;
|
|
3604
|
+
const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
|
|
3605
|
+
if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3606
|
+
else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
|
|
3607
|
+
else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
|
|
3608
|
+
}
|
|
3609
|
+
return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
|
|
3610
|
+
}
|
|
3611
|
+
async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
|
|
3612
|
+
if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
|
|
3613
|
+
const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
|
|
3614
|
+
const irs = await mapPool(
|
|
3615
|
+
projectDirs,
|
|
3616
|
+
estateReadPool(projectDirs.length),
|
|
3617
|
+
(dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
|
|
3618
|
+
);
|
|
3619
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
3620
|
+
for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
|
|
3621
|
+
if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
|
|
3622
|
+
scopes.set(j.dir, j.namespace);
|
|
3623
|
+
}
|
|
3624
|
+
return scopes;
|
|
3625
|
+
}
|
|
3626
|
+
function withoutJoinedMembers(nodes, joined) {
|
|
3627
|
+
if (joined.length === 0) return [...nodes];
|
|
3628
|
+
return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
|
|
3629
|
+
}
|
|
3630
|
+
async function composeEstateOverlay(projectDirs, opts, classify) {
|
|
3631
|
+
const names = shortStackNames(projectDirs);
|
|
3632
|
+
const unobserved = [];
|
|
3633
|
+
const dropped = [];
|
|
3634
|
+
const joined = [];
|
|
3635
|
+
const stacks = new Array(projectDirs.length);
|
|
3636
|
+
const scopes = await estateNamespaceScopes(projectDirs, opts);
|
|
3637
|
+
await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
|
|
3638
|
+
const name = names[i];
|
|
3639
|
+
const namespace = scopes.get(dir);
|
|
3640
|
+
try {
|
|
3641
|
+
const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
|
|
3642
|
+
stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
|
|
3643
|
+
if (namespace) joined.push({ name, namespace });
|
|
3644
|
+
} catch (err) {
|
|
3645
|
+
const reason = firstLine2(err);
|
|
3646
|
+
try {
|
|
3647
|
+
const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
|
|
3648
|
+
const src = await memberIr(dir, srcOpts);
|
|
3649
|
+
for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
|
|
3650
|
+
stacks[i] = { name, ir: src };
|
|
3651
|
+
unobserved.push({ name, reason });
|
|
3652
|
+
} catch (err2) {
|
|
3653
|
+
dropped.push({ name, reason: firstLine2(err2) });
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
});
|
|
3657
|
+
const present = stacks.filter((s) => !!s);
|
|
3658
|
+
return {
|
|
3659
|
+
ir: composeStacks(present),
|
|
3660
|
+
observed: present.length - unobserved.length,
|
|
3661
|
+
total: projectDirs.length,
|
|
3662
|
+
unobserved,
|
|
3663
|
+
dropped,
|
|
3664
|
+
// Concurrent reads finish in whatever order the clusters answer; the note
|
|
3665
|
+
// this feeds should read the same twice.
|
|
3666
|
+
joined: joined.sort((a, b) => a.name.localeCompare(b.name))
|
|
3667
|
+
};
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
// src/helm-drift.ts
|
|
3671
|
+
function countChanges(docs) {
|
|
3672
|
+
return docs.reduce((n, d) => n + d.changes.length, 0);
|
|
3673
|
+
}
|
|
3674
|
+
function provenanceOf(live) {
|
|
3675
|
+
const m = live.manifest;
|
|
3676
|
+
return {
|
|
3677
|
+
contentDigest: m.contentDigest,
|
|
3678
|
+
inputDigest: m.inputDigest,
|
|
3679
|
+
valuesDigest: m.valuesDigest,
|
|
3680
|
+
chart: m.chart,
|
|
3681
|
+
chartVersion: m.chartVersion,
|
|
3682
|
+
repo: m.repo,
|
|
3683
|
+
releaseName: m.releaseName,
|
|
3684
|
+
namespace: m.namespace,
|
|
3685
|
+
profile: m.capabilityProfile?.cluster ?? "",
|
|
3686
|
+
kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
|
|
3687
|
+
renderedAt: m.renderedAt,
|
|
3688
|
+
helmVersion: m.helmVersion,
|
|
3689
|
+
chantVersion: m.chantVersion,
|
|
3690
|
+
sourceRef: m.sourceRef
|
|
3691
|
+
};
|
|
3692
|
+
}
|
|
3693
|
+
function helmRenderReport(record, live) {
|
|
3694
|
+
const base = { render: record.name, chart: record.chart };
|
|
3695
|
+
if (!live) {
|
|
3696
|
+
return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
|
|
3697
|
+
}
|
|
3698
|
+
const d = live.diff;
|
|
3699
|
+
const counts = {
|
|
3700
|
+
drifted: d.drifted.length,
|
|
3701
|
+
changes: countChanges(d.drifted),
|
|
3702
|
+
unchanged: d.unchanged.length,
|
|
3703
|
+
unobserved: d.unobserved.length,
|
|
3704
|
+
undeclared: d.undeclaredEntities.length
|
|
3705
|
+
};
|
|
3706
|
+
const provenance = provenanceOf(live);
|
|
3707
|
+
if (d.drifted.length > 0) {
|
|
3708
|
+
return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
|
|
3709
|
+
}
|
|
3710
|
+
if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
|
|
3711
|
+
return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
|
|
3712
|
+
}
|
|
3713
|
+
if (d.unobserved.length > 0) {
|
|
3714
|
+
return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
|
|
3715
|
+
}
|
|
3716
|
+
return { ...base, verdict: "in-sync", counts, provenance };
|
|
3717
|
+
}
|
|
3718
|
+
async function readHelmRenderDrift(projectDir, env, opts = {}) {
|
|
3719
|
+
const report = await helmRenders(projectDir, opts).catch(() => void 0);
|
|
3720
|
+
const records = report?.records ?? [];
|
|
3721
|
+
if (records.length === 0) return [];
|
|
3722
|
+
return mapPool(records, estateReadPool(records.length), async (record) => {
|
|
3723
|
+
if (!record.contentDigest) return helmRenderReport(record, void 0);
|
|
3724
|
+
const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
|
|
3725
|
+
return helmRenderReport(record, live);
|
|
3726
|
+
});
|
|
3727
|
+
}
|
|
3728
|
+
function chartNameOf2(node) {
|
|
3729
|
+
const name = node.attrs?.name;
|
|
3730
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
3731
|
+
}
|
|
3732
|
+
var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
|
|
3733
|
+
function applyHelmRenderDrift(ir, reports) {
|
|
3734
|
+
if (reports.length === 0) return { charts: 0, drifted: 0 };
|
|
3735
|
+
const byChart = /* @__PURE__ */ new Map();
|
|
3736
|
+
for (const r of reports) {
|
|
3737
|
+
const list3 = byChart.get(r.chart);
|
|
3738
|
+
if (list3) list3.push(r);
|
|
3739
|
+
else byChart.set(r.chart, [r]);
|
|
3740
|
+
}
|
|
3741
|
+
let charts = 0;
|
|
3742
|
+
let drifted = 0;
|
|
3743
|
+
for (const node of ir.nodes) {
|
|
3744
|
+
if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
|
|
3745
|
+
const chartName = chartNameOf2(node);
|
|
3746
|
+
const matched = chartName ? byChart.get(chartName) : void 0;
|
|
3747
|
+
if (!matched || matched.length === 0) continue;
|
|
3748
|
+
const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
|
|
3749
|
+
const attrs = node.attrs ??= {};
|
|
3750
|
+
attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
|
|
3751
|
+
charts++;
|
|
3752
|
+
if (worst.verdict === "drifted") {
|
|
3753
|
+
attrs._status = "warn";
|
|
3754
|
+
drifted++;
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
return { charts, drifted };
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
// src/cluster-anchor.ts
|
|
3761
|
+
var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
|
|
3762
|
+
"AWS::EKS::Cluster",
|
|
3763
|
+
"GCP::Container::Cluster",
|
|
3764
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3765
|
+
"K3d::Cluster"
|
|
3766
|
+
]);
|
|
3767
|
+
var ANCHOR_VIA = "runs-on";
|
|
3768
|
+
function metadata(node) {
|
|
3769
|
+
const m = node.attrs?.metadata;
|
|
3770
|
+
return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
|
|
3771
|
+
}
|
|
3772
|
+
function metaString(node, key) {
|
|
3773
|
+
const v = metadata(node)?.[key];
|
|
3774
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3775
|
+
}
|
|
3776
|
+
function isK8s(node) {
|
|
3777
|
+
return node.lexicon === "k8s";
|
|
3778
|
+
}
|
|
3779
|
+
function isNamespace(node) {
|
|
3780
|
+
return node.kind === "K8s::Core::Namespace";
|
|
3781
|
+
}
|
|
3782
|
+
function managedClusterName(node) {
|
|
3783
|
+
const metaName = metaString(node, "name");
|
|
3784
|
+
if (metaName) return metaName;
|
|
3785
|
+
for (const key of ["name", "clusterName"]) {
|
|
3786
|
+
const v = node.attrs?.[key];
|
|
3787
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
3788
|
+
}
|
|
3789
|
+
return node.id;
|
|
3790
|
+
}
|
|
3791
|
+
function boundManagedCluster(nodes, boundContext) {
|
|
3792
|
+
const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
|
|
3793
|
+
if (clusters.length === 1) return clusters[0];
|
|
3794
|
+
if (clusters.length === 0 || !boundContext) return void 0;
|
|
3795
|
+
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
|
|
3796
|
+
return bound.length === 1 ? bound[0] : void 0;
|
|
3797
|
+
}
|
|
3798
|
+
function addClusterAnchorEdges(ir, boundContext) {
|
|
3799
|
+
const nodes = ir.nodes;
|
|
3800
|
+
const cluster = boundManagedCluster(ir.nodes, boundContext);
|
|
3801
|
+
if (!cluster) return ir;
|
|
3802
|
+
const k8sNodes = nodes.filter(isK8s);
|
|
3803
|
+
if (k8sNodes.length === 0) return ir;
|
|
3804
|
+
const namespaceByName = /* @__PURE__ */ new Map();
|
|
3805
|
+
for (const n of k8sNodes) {
|
|
3806
|
+
if (!isNamespace(n)) continue;
|
|
3807
|
+
const name = metaString(n, "name");
|
|
3808
|
+
if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
|
|
3809
|
+
}
|
|
3810
|
+
const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
|
|
3811
|
+
const add = (from, to) => {
|
|
3812
|
+
if (from === to) return;
|
|
3813
|
+
const key = `${from}\0${to}`;
|
|
3814
|
+
if (existing.has(key)) return;
|
|
3815
|
+
existing.add(key);
|
|
3816
|
+
ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
|
|
3817
|
+
};
|
|
3818
|
+
for (const n of k8sNodes) {
|
|
3819
|
+
if (isNamespace(n)) {
|
|
3820
|
+
add(cluster.id, n.id);
|
|
3821
|
+
continue;
|
|
3822
|
+
}
|
|
3823
|
+
const ns = metaString(n, "namespace");
|
|
3824
|
+
const nsNode = ns ? namespaceByName.get(ns) : void 0;
|
|
3825
|
+
add(nsNode ?? cluster.id, n.id);
|
|
3826
|
+
}
|
|
3827
|
+
return ir;
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
// src/logical-azure.ts
|
|
3831
|
+
var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
|
|
3832
|
+
// compute / workloads
|
|
3833
|
+
"Microsoft.Compute/virtualMachines",
|
|
3834
|
+
"Microsoft.Compute/virtualMachineScaleSets",
|
|
3835
|
+
"Microsoft.ContainerService/managedClusters",
|
|
3836
|
+
"Microsoft.ContainerInstance/containerGroups",
|
|
3837
|
+
"Microsoft.Web/sites",
|
|
3838
|
+
// network edge
|
|
3839
|
+
"Microsoft.Network/publicIPAddresses",
|
|
3840
|
+
"Microsoft.Network/loadBalancers",
|
|
3841
|
+
"Microsoft.Network/applicationGateways",
|
|
3842
|
+
"Microsoft.Network/natGateways",
|
|
3843
|
+
"Microsoft.Network/dnsZones",
|
|
3844
|
+
// data stores
|
|
3845
|
+
"Microsoft.Storage/storageAccounts",
|
|
3846
|
+
"Microsoft.Sql/servers",
|
|
3847
|
+
"Microsoft.DocumentDB/databaseAccounts",
|
|
3848
|
+
"Microsoft.Cache/redis",
|
|
3849
|
+
"Microsoft.DBforPostgreSQL/flexibleServers",
|
|
3850
|
+
// registry / identity edge
|
|
3851
|
+
"Microsoft.ContainerRegistry/registries"
|
|
3852
|
+
]);
|
|
3853
|
+
var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
|
|
3854
|
+
"Microsoft.Network/virtualNetworks",
|
|
3855
|
+
"Microsoft.Network/virtualNetworks_subnets",
|
|
3856
|
+
"Microsoft.Network/networkSecurityGroups",
|
|
3857
|
+
"Microsoft.Network/routeTables",
|
|
3858
|
+
"Microsoft.Network/networkInterfaces",
|
|
3859
|
+
// identity/authorization hubs — a role assignment or managed identity is
|
|
3860
|
+
// referenced by half the estate, so contracting through one invents edges
|
|
3861
|
+
// between unrelated resources.
|
|
3862
|
+
"Microsoft.Authorization/roleAssignments",
|
|
3863
|
+
"Microsoft.ManagedIdentity/userAssignedIdentities",
|
|
3864
|
+
"Microsoft.KeyVault/vaults"
|
|
3865
|
+
]);
|
|
3866
|
+
var VNET_KIND = "Microsoft.Network/virtualNetworks";
|
|
3867
|
+
var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
|
|
3868
|
+
var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
|
|
3869
|
+
var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
|
|
3870
|
+
var QUOTED_RE = /'([^']*)'/g;
|
|
3871
|
+
function resourceIdNames(value) {
|
|
3872
|
+
if (typeof value !== "string" || !value.includes("resourceId(")) return [];
|
|
3873
|
+
const out = [];
|
|
3874
|
+
for (const call of value.matchAll(RESOURCE_ID_RE)) {
|
|
3875
|
+
const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
|
|
3876
|
+
if (args.length >= 2) out.push(args[args.length - 1]);
|
|
3877
|
+
}
|
|
3878
|
+
return out;
|
|
3879
|
+
}
|
|
3880
|
+
function collectAzureRefs(value, out) {
|
|
3881
|
+
if (value === null || typeof value !== "object") {
|
|
3882
|
+
for (const name of resourceIdNames(value)) out.add(name);
|
|
3883
|
+
return;
|
|
3884
|
+
}
|
|
3885
|
+
const ref = value.$ref;
|
|
3886
|
+
if (typeof ref === "string") out.add(ref.split(".")[0]);
|
|
3887
|
+
for (const v of Object.values(value)) collectAzureRefs(v, out);
|
|
3888
|
+
}
|
|
3889
|
+
function strAttr(node, key) {
|
|
3890
|
+
const v = node.attrs?.[key];
|
|
3891
|
+
return typeof v === "string" ? v : void 0;
|
|
3892
|
+
}
|
|
3893
|
+
function resourceName(node) {
|
|
3894
|
+
return strAttr(node, "name");
|
|
3895
|
+
}
|
|
3896
|
+
function subnetVnetName(node) {
|
|
3897
|
+
const name = resourceName(node);
|
|
3898
|
+
if (!name || !name.includes("/")) return void 0;
|
|
3899
|
+
return name.split("/")[0];
|
|
3900
|
+
}
|
|
3901
|
+
function subnetShortName(node) {
|
|
3902
|
+
const name = resourceName(node);
|
|
3903
|
+
return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
|
|
3904
|
+
}
|
|
3905
|
+
function vnetCidr(node) {
|
|
3906
|
+
const space = node.attrs?.addressSpace;
|
|
3907
|
+
const prefixes = space?.addressPrefixes;
|
|
3908
|
+
return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
|
|
3909
|
+
}
|
|
3910
|
+
function projectAzureLogical(ir, env) {
|
|
3911
|
+
const azure = ir.nodes.filter((n) => n.lexicon === "azure");
|
|
3912
|
+
if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
|
|
3913
|
+
const componentOf = componentNamer(azure);
|
|
3914
|
+
const byId = new Map(azure.map((n) => [n.id, n]));
|
|
3915
|
+
const byName = /* @__PURE__ */ new Map();
|
|
3916
|
+
for (const n of azure) {
|
|
3917
|
+
const name = resourceName(n);
|
|
3918
|
+
if (name && !byName.has(name)) byName.set(name, n);
|
|
3919
|
+
}
|
|
3920
|
+
const vnets = azure.filter((n) => n.kind === VNET_KIND);
|
|
3921
|
+
const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
|
|
3922
|
+
const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
|
|
3923
|
+
const refOut = /* @__PURE__ */ new Map();
|
|
3924
|
+
for (const n of azure) {
|
|
3925
|
+
const names = /* @__PURE__ */ new Set();
|
|
3926
|
+
collectAzureRefs(n.attrs, names);
|
|
3927
|
+
const targets = /* @__PURE__ */ new Set();
|
|
3928
|
+
for (const name of names) {
|
|
3929
|
+
const target = byName.get(name) ?? byId.get(name);
|
|
3930
|
+
if (target && target.id !== n.id) targets.add(target.id);
|
|
3931
|
+
}
|
|
3932
|
+
if (targets.size) refOut.set(n.id, targets);
|
|
3933
|
+
}
|
|
3934
|
+
for (const e of ir.edges) {
|
|
3935
|
+
if (!byId.has(e.from) || !byId.has(e.to)) continue;
|
|
3936
|
+
(refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
|
|
3937
|
+
}
|
|
3938
|
+
const RG_TITLE = env ? `resource group ${env}` : "resource group";
|
|
3939
|
+
const vnetTitle = (v) => `VNet ${vnetCidr(v) ?? resourceName(v) ?? v.id}`;
|
|
3940
|
+
const subnetTitle = (s) => `subnet ${strAttr(s, "addressPrefix") ?? subnetShortName(s) ?? s.id}`;
|
|
3941
|
+
const guardedBy = /* @__PURE__ */ new Map();
|
|
3942
|
+
for (const s of subnets) {
|
|
3943
|
+
const names = /* @__PURE__ */ new Set();
|
|
3944
|
+
collectAzureRefs(s.attrs?.networkSecurityGroup, names);
|
|
3945
|
+
for (const name of names) {
|
|
3946
|
+
if (byName.get(name)?.kind === NSG_KIND) guardedBy.set(s.id, name);
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
const headline = azure.filter((n) => AZURE_HEADLINE_KINDS.has(n.kind));
|
|
3950
|
+
const subnetIds = new Set(subnets.map((s) => s.id));
|
|
3951
|
+
const vnetIds = new Set(vnets.map((v) => v.id));
|
|
3952
|
+
const nearest = (start, want, maxDepth = 6) => {
|
|
3953
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
3954
|
+
let frontier = [...refOut.get(start) ?? []];
|
|
3955
|
+
for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
|
|
3956
|
+
const next = [];
|
|
3957
|
+
for (const id of frontier) {
|
|
3958
|
+
if (seen.has(id)) continue;
|
|
3959
|
+
seen.add(id);
|
|
3960
|
+
if (want.has(id)) return id;
|
|
3961
|
+
for (const r of refOut.get(id) ?? []) if (!seen.has(r)) next.push(r);
|
|
3962
|
+
}
|
|
3963
|
+
frontier = next;
|
|
3964
|
+
}
|
|
3965
|
+
return void 0;
|
|
3966
|
+
};
|
|
3967
|
+
const byContainer = {};
|
|
3968
|
+
const child = (parent, c) => {
|
|
3969
|
+
const arr = byContainer[parent] ?? (byContainer[parent] = []);
|
|
3970
|
+
if (!arr.includes(c)) arr.push(c);
|
|
3971
|
+
};
|
|
3972
|
+
for (const v of vnets) child(RG_TITLE, vnetTitle(v));
|
|
3973
|
+
for (const s of subnets) {
|
|
3974
|
+
const parentVnet = subnetVnetName(s);
|
|
3975
|
+
const v = parentVnet ? vnetByName.get(parentVnet) : void 0;
|
|
3976
|
+
child(v ? vnetTitle(v) : RG_TITLE, subnetTitle(s));
|
|
3977
|
+
}
|
|
3978
|
+
const placeOf = /* @__PURE__ */ new Map();
|
|
3979
|
+
for (const n of headline) {
|
|
3980
|
+
const subnetId = nearest(n.id, subnetIds);
|
|
3981
|
+
if (subnetId) {
|
|
3982
|
+
placeOf.set(n.id, subnetTitle(byId.get(subnetId)));
|
|
3983
|
+
continue;
|
|
3984
|
+
}
|
|
3985
|
+
const vnetId = nearest(n.id, vnetIds);
|
|
3390
3986
|
placeOf.set(n.id, vnetId ? vnetTitle(byId.get(vnetId)) : RG_TITLE);
|
|
3391
3987
|
}
|
|
3392
3988
|
const byComponent = /* @__PURE__ */ new Map();
|
|
@@ -4534,9 +5130,9 @@ function helmIconFor(kind) {
|
|
|
4534
5130
|
var SUPPORTED_MAJOR = 1;
|
|
4535
5131
|
var refuse = (error, remedy) => ({ ok: false, refusal: { error, code: "carve-report", remedy } });
|
|
4536
5132
|
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
|
|
5133
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4538
5134
|
function parseCarveReport(value) {
|
|
4539
|
-
if (!
|
|
5135
|
+
if (!isRecord2(value)) {
|
|
4540
5136
|
return refuse("That file isn't a carve report \u2014 its top level isn't a JSON object.", HOW_TO_GET_ONE);
|
|
4541
5137
|
}
|
|
4542
5138
|
if (!Array.isArray(value.resources)) {
|
|
@@ -4546,7 +5142,7 @@ function parseCarveReport(value) {
|
|
|
4546
5142
|
);
|
|
4547
5143
|
}
|
|
4548
5144
|
const bad = value.resources.findIndex(
|
|
4549
|
-
(r) => !
|
|
5145
|
+
(r) => !isRecord2(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string"
|
|
4550
5146
|
);
|
|
4551
5147
|
if (bad >= 0) {
|
|
4552
5148
|
return refuse(
|
|
@@ -4612,7 +5208,7 @@ function boundaryEdgesOf(r) {
|
|
|
4612
5208
|
const b = r.boundary;
|
|
4613
5209
|
if (!b) return [];
|
|
4614
5210
|
const list3 = Array.isArray(b) ? b : [...b.inbound ?? [], ...b.outbound ?? []];
|
|
4615
|
-
return list3.filter((e) =>
|
|
5211
|
+
return list3.filter((e) => isRecord2(e) && typeof e.survivor === "string" && typeof e.carved === "string");
|
|
4616
5212
|
}
|
|
4617
5213
|
function scoreArithmetic(r) {
|
|
4618
5214
|
const b = r.breakdown;
|
|
@@ -4728,10 +5324,10 @@ import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as
|
|
|
4728
5324
|
import { dirname as dirname4, join as join10 } from "node:path";
|
|
4729
5325
|
var CARVE_MANIFEST_SUFFIX = ".carve.json";
|
|
4730
5326
|
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
|
|
5327
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4732
5328
|
var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
4733
5329
|
function parseCarveManifest(value) {
|
|
4734
|
-
if (!
|
|
5330
|
+
if (!isRecord3(value)) return null;
|
|
4735
5331
|
if (value.version !== 1) return null;
|
|
4736
5332
|
if (typeof value.target !== "string" || !value.target) return null;
|
|
4737
5333
|
return value;
|
|
@@ -4975,9 +5571,9 @@ function carveStateNote(progress, states) {
|
|
|
4975
5571
|
var SUPPORTED_MAJOR2 = 1;
|
|
4976
5572
|
var refuse2 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
|
|
4977
5573
|
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
|
|
5574
|
+
var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4979
5575
|
function parseOpIr(value) {
|
|
4980
|
-
if (!
|
|
5576
|
+
if (!isRecord4(value)) {
|
|
4981
5577
|
return refuse2("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
|
|
4982
5578
|
}
|
|
4983
5579
|
if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
|
|
@@ -4986,7 +5582,7 @@ function parseOpIr(value) {
|
|
|
4986
5582
|
HOW_TO_EMIT
|
|
4987
5583
|
);
|
|
4988
5584
|
}
|
|
4989
|
-
const bad = value.phases.findIndex((p) => !
|
|
5585
|
+
const bad = value.phases.findIndex((p) => !isRecord4(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
|
|
4990
5586
|
if (bad >= 0) {
|
|
4991
5587
|
return refuse2(
|
|
4992
5588
|
`That JSON has a \`phases\` array, but entry ${bad} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
|
|
@@ -5083,14 +5679,14 @@ function opPlacedSteps(op) {
|
|
|
5083
5679
|
}
|
|
5084
5680
|
var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
|
|
5085
5681
|
var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
|
|
5086
|
-
var isPredicate = (v) =>
|
|
5682
|
+
var isPredicate = (v) => isRecord4(v) && typeof v.kind === "string";
|
|
5087
5683
|
function isConvergeRule(v) {
|
|
5088
|
-
if (!
|
|
5684
|
+
if (!isRecord4(v)) return false;
|
|
5089
5685
|
if (typeof v.id !== "string" || !v.id) return false;
|
|
5090
5686
|
if (typeof v.why !== "string" || !v.why) return false;
|
|
5091
5687
|
if (!isPredicate(v.when)) return false;
|
|
5092
5688
|
const then = v.then;
|
|
5093
|
-
if (!
|
|
5689
|
+
if (!isRecord4(then)) return false;
|
|
5094
5690
|
return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
|
|
5095
5691
|
}
|
|
5096
5692
|
function convergeTable(op) {
|
|
@@ -6029,973 +6625,691 @@ function capture(cmd, args, cwd) {
|
|
|
6029
6625
|
child.on("error", () => res({ code: -1, stdout: out }));
|
|
6030
6626
|
child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
|
|
6031
6627
|
});
|
|
6032
|
-
}
|
|
6033
|
-
async function bootScratchFloci() {
|
|
6034
|
-
const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
|
|
6035
|
-
if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
|
|
6036
|
-
if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
|
|
6037
|
-
return `container ${LIVE_CONTAINER} already exists \u2014 a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
|
|
6038
|
-
}
|
|
6039
|
-
const run3 = await capture(
|
|
6040
|
-
"docker",
|
|
6041
|
-
["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
|
|
6042
|
-
process.cwd()
|
|
6043
|
-
);
|
|
6044
|
-
if (run3.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run3.code})`;
|
|
6045
|
-
for (let i = 0; i < 30; i++) {
|
|
6046
|
-
try {
|
|
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;
|
|
6628
|
+
}
|
|
6629
|
+
async function bootScratchFloci() {
|
|
6630
|
+
const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
|
|
6631
|
+
if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
|
|
6632
|
+
if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
|
|
6633
|
+
return `container ${LIVE_CONTAINER} already exists \u2014 a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
|
|
6634
|
+
}
|
|
6635
|
+
const run3 = await capture(
|
|
6636
|
+
"docker",
|
|
6637
|
+
["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
|
|
6638
|
+
process.cwd()
|
|
6639
|
+
);
|
|
6640
|
+
if (run3.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run3.code})`;
|
|
6641
|
+
for (let i = 0; i < 30; i++) {
|
|
6202
6642
|
try {
|
|
6203
|
-
|
|
6643
|
+
const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
|
|
6644
|
+
if (health.ok && (await health.text()).includes("cloudformation")) return void 0;
|
|
6204
6645
|
} 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
6646
|
}
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6647
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
6648
|
+
}
|
|
6649
|
+
await teardownScratchFloci();
|
|
6650
|
+
return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
|
|
6241
6651
|
}
|
|
6242
|
-
function
|
|
6243
|
-
|
|
6652
|
+
async function teardownScratchFloci() {
|
|
6653
|
+
await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
|
|
6244
6654
|
}
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6655
|
+
async function applyIntoFloci(fromDir, step) {
|
|
6656
|
+
const template = join12(fromDir, "floci-override.tf.disabled");
|
|
6657
|
+
if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
|
|
6658
|
+
writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
|
|
6659
|
+
const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
|
|
6660
|
+
if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
|
|
6661
|
+
const apply = await step(
|
|
6662
|
+
"terraform",
|
|
6663
|
+
["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
|
|
6664
|
+
fromDir
|
|
6665
|
+
);
|
|
6666
|
+
if (apply !== 0) return `terraform apply exited ${apply}`;
|
|
6667
|
+
return void 0;
|
|
6248
6668
|
}
|
|
6249
|
-
function
|
|
6250
|
-
const
|
|
6251
|
-
|
|
6669
|
+
async function runLivePlan(fromDir) {
|
|
6670
|
+
const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
|
|
6671
|
+
if (r.code !== 0 && r.code !== 2) {
|
|
6672
|
+
const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
|
|
6673
|
+
return { error: `terraform plan failed (exit ${r.code}):
|
|
6674
|
+
${tail}`, exitCode: r.code };
|
|
6675
|
+
}
|
|
6676
|
+
return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
|
|
6252
6677
|
}
|
|
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) {
|
|
6678
|
+
|
|
6679
|
+
// src/carve-actions.ts
|
|
6680
|
+
async function runCarvePlan(demo) {
|
|
6681
|
+
if (!demo?.live) {
|
|
6278
6682
|
return refuse3(
|
|
6279
6683
|
"carve-action",
|
|
6280
|
-
|
|
6281
|
-
"
|
|
6684
|
+
"terraform plan is the live tier's beat, and this server isn't serving one",
|
|
6685
|
+
"run `behold demo carve --live` (needs docker + terraform)"
|
|
6282
6686
|
);
|
|
6283
6687
|
}
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
} catch {
|
|
6688
|
+
const r = await runLivePlan(demo.from);
|
|
6689
|
+
if ("error" in r) {
|
|
6690
|
+
return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
|
|
6288
6691
|
}
|
|
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
6692
|
return {
|
|
6297
6693
|
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
|
|
6694
|
+
command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
|
|
6695
|
+
planLine: r.planLine,
|
|
6696
|
+
changes: r.changes,
|
|
6697
|
+
noDestroy: r.noDestroy
|
|
6309
6698
|
};
|
|
6310
6699
|
}
|
|
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) {
|
|
6700
|
+
async function runCarveObserve(demo, select) {
|
|
6701
|
+
if (!demo.live) {
|
|
6333
6702
|
return refuse3(
|
|
6334
6703
|
"carve-action",
|
|
6335
|
-
|
|
6336
|
-
"
|
|
6704
|
+
"the observe beat reads the scratch Floci, and this server isn't serving one",
|
|
6705
|
+
"run `behold demo carve --live` (needs docker + terraform)"
|
|
6337
6706
|
);
|
|
6338
6707
|
}
|
|
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
|
-
}
|
|
6708
|
+
if (!existsSync8(join13(demo.out, "src"))) {
|
|
6709
|
+
return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
|
|
6710
|
+
}
|
|
6711
|
+
const args = ["lifecycle", "diff", "prod", "--live", "--json"];
|
|
6712
|
+
const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
|
|
6713
|
+
code: 127,
|
|
6714
|
+
stdout: "",
|
|
6715
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
6716
|
+
}));
|
|
6717
|
+
if (run3.code !== 0) {
|
|
6718
|
+
return refuse3(
|
|
6719
|
+
"carve-action",
|
|
6720
|
+
`chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
|
|
6721
|
+
"Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
|
|
6722
|
+
);
|
|
6429
6723
|
}
|
|
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
6724
|
let parsed;
|
|
6447
6725
|
try {
|
|
6448
|
-
parsed = JSON.parse(
|
|
6726
|
+
parsed = JSON.parse(run3.stdout);
|
|
6449
6727
|
} catch {
|
|
6450
|
-
return
|
|
6728
|
+
return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
|
|
6451
6729
|
}
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6730
|
+
if (parsed.stacks && !parsed.lexicons) {
|
|
6731
|
+
return refuse3(
|
|
6732
|
+
"carve-action",
|
|
6733
|
+
"chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
|
|
6734
|
+
"point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
|
|
6735
|
+
);
|
|
6457
6736
|
}
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
const
|
|
6462
|
-
const
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6737
|
+
const aws = parsed.lexicons?.aws ?? {};
|
|
6738
|
+
const entity = select.split(".").pop() ?? select;
|
|
6739
|
+
const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
|
|
6740
|
+
const queried = aws.resources?.queried?.[entity];
|
|
6741
|
+
const meta = aws.observed?.[entity];
|
|
6742
|
+
if (meta) {
|
|
6743
|
+
return {
|
|
6744
|
+
ok: true,
|
|
6745
|
+
select,
|
|
6746
|
+
command,
|
|
6747
|
+
entity,
|
|
6748
|
+
verdict: "observed",
|
|
6749
|
+
...meta.status ? { status: meta.status } : {},
|
|
6750
|
+
...meta.ownership ? { ownership: meta.ownership } : {},
|
|
6751
|
+
...meta.physicalId ? { physicalId: meta.physicalId } : {},
|
|
6752
|
+
...queried ? { queried } : {}
|
|
6753
|
+
};
|
|
6472
6754
|
}
|
|
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
|
-
}
|
|
6755
|
+
const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
|
|
6756
|
+
if (hole) {
|
|
6505
6757
|
return {
|
|
6506
|
-
ok:
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
}
|
|
6758
|
+
ok: true,
|
|
6759
|
+
select,
|
|
6760
|
+
command,
|
|
6761
|
+
entity,
|
|
6762
|
+
verdict: "unobserved",
|
|
6763
|
+
...hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {},
|
|
6764
|
+
...queried ? { queried } : {}
|
|
6512
6765
|
};
|
|
6513
6766
|
}
|
|
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 };
|
|
6767
|
+
return { ok: true, select, command, entity, verdict: "missing", ...queried ? { queried } : {} };
|
|
6547
6768
|
}
|
|
6548
|
-
|
|
6549
|
-
|
|
6769
|
+
var MAX_ARTIFACT_BYTES = 64 * 1024;
|
|
6770
|
+
var MAX_ARTIFACTS = 24;
|
|
6771
|
+
var MAX_OUTPUT_BYTES = 32 * 1024;
|
|
6772
|
+
var refuse3 = (code, error, remedy) => ({
|
|
6773
|
+
ok: false,
|
|
6774
|
+
refusal: { error, code, remedy }
|
|
6775
|
+
});
|
|
6776
|
+
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.";
|
|
6777
|
+
function insideDemo(root, p) {
|
|
6778
|
+
const rel = relative2(resolve4(root), resolve4(p));
|
|
6779
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
6550
6780
|
}
|
|
6551
|
-
function
|
|
6552
|
-
|
|
6781
|
+
function selectFromReport(report, raw) {
|
|
6782
|
+
if (typeof raw !== "string" || !raw || raw.length > 512) return null;
|
|
6783
|
+
return (report?.resources ?? []).some((r) => r.address === raw) ? raw : null;
|
|
6553
6784
|
}
|
|
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
|
-
};
|
|
6785
|
+
function carveWriteBlock(demo) {
|
|
6786
|
+
if (!demo) {
|
|
6787
|
+
return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
|
|
6788
|
+
}
|
|
6789
|
+
if (!existsSync8(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
|
|
6790
|
+
if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
|
|
6791
|
+
return unwritableReason(demo.root);
|
|
6564
6792
|
}
|
|
6565
|
-
function
|
|
6793
|
+
function readArtifacts(demo, dir, filter) {
|
|
6566
6794
|
const out = [];
|
|
6567
|
-
|
|
6568
|
-
if (
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6795
|
+
const walk = (d, depth) => {
|
|
6796
|
+
if (out.length >= MAX_ARTIFACTS || depth > 2) return;
|
|
6797
|
+
let entries;
|
|
6798
|
+
try {
|
|
6799
|
+
entries = readdirSync5(d).sort();
|
|
6800
|
+
} catch {
|
|
6801
|
+
return;
|
|
6802
|
+
}
|
|
6803
|
+
for (const name of entries) {
|
|
6804
|
+
if (out.length >= MAX_ARTIFACTS) return;
|
|
6805
|
+
if (name === "node_modules" || name.startsWith(".")) continue;
|
|
6806
|
+
const full = join13(d, name);
|
|
6807
|
+
let st;
|
|
6808
|
+
try {
|
|
6809
|
+
st = statSync5(full);
|
|
6810
|
+
} catch {
|
|
6811
|
+
continue;
|
|
6812
|
+
}
|
|
6813
|
+
if (st.isDirectory()) {
|
|
6814
|
+
walk(full, depth + 1);
|
|
6815
|
+
continue;
|
|
6816
|
+
}
|
|
6817
|
+
const rel = relative2(demo.root, full).split(sep2).join("/");
|
|
6818
|
+
if (!filter(rel)) continue;
|
|
6819
|
+
let text = "";
|
|
6820
|
+
try {
|
|
6821
|
+
text = readFileSync10(full, "utf8");
|
|
6822
|
+
} catch {
|
|
6823
|
+
continue;
|
|
6824
|
+
}
|
|
6825
|
+
const truncated = text.length > MAX_ARTIFACT_BYTES;
|
|
6826
|
+
out.push({
|
|
6827
|
+
path: rel,
|
|
6828
|
+
kind: (name.split(".").pop() ?? "").toLowerCase(),
|
|
6829
|
+
bytes: st.size,
|
|
6830
|
+
text: truncated ? text.slice(0, MAX_ARTIFACT_BYTES) : text,
|
|
6831
|
+
truncated
|
|
6832
|
+
});
|
|
6576
6833
|
}
|
|
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
6834
|
};
|
|
6835
|
+
walk(dir, 0);
|
|
6836
|
+
return out;
|
|
6590
6837
|
}
|
|
6591
|
-
function
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6838
|
+
function carveSlug(select) {
|
|
6839
|
+
return select.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
6840
|
+
}
|
|
6841
|
+
var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
|
|
6842
|
+
function shortenIn(arg, root) {
|
|
6843
|
+
return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
|
|
6844
|
+
}
|
|
6845
|
+
function merge(r, root) {
|
|
6846
|
+
const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
|
|
6847
|
+
return clip(joined.split(root + sep2).join("").split(root).join("."));
|
|
6848
|
+
}
|
|
6849
|
+
async function runCarveEmit(demo, select) {
|
|
6850
|
+
const block = carveWriteBlock(demo);
|
|
6851
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
6852
|
+
mkdirSync3(demo.out, { recursive: true });
|
|
6853
|
+
const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
|
|
6854
|
+
const args = [
|
|
6855
|
+
"carve",
|
|
6856
|
+
"emit",
|
|
6857
|
+
"--from",
|
|
6858
|
+
demo.from,
|
|
6859
|
+
...demo.state ? ["--state", demo.state] : [],
|
|
6860
|
+
"--select",
|
|
6861
|
+
select,
|
|
6862
|
+
"--output",
|
|
6863
|
+
demo.out,
|
|
6864
|
+
"--report",
|
|
6865
|
+
reportFile
|
|
6866
|
+
];
|
|
6867
|
+
const run3 = await runChantRaw(args, demo.project).catch((err) => ({
|
|
6868
|
+
code: 127,
|
|
6869
|
+
stdout: "",
|
|
6870
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
6871
|
+
}));
|
|
6872
|
+
const output = merge(run3, demo.root);
|
|
6873
|
+
if (run3.code !== 0) {
|
|
6874
|
+
return refuse3(
|
|
6875
|
+
"carve-action",
|
|
6876
|
+
`chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
|
|
6877
|
+
"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`."
|
|
6878
|
+
);
|
|
6599
6879
|
}
|
|
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
|
-
}
|
|
6880
|
+
let boundary = null;
|
|
6881
|
+
try {
|
|
6882
|
+
boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
|
|
6883
|
+
} catch {
|
|
6619
6884
|
}
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6885
|
+
const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
|
|
6886
|
+
const lintArgs = ["lint", lintPath];
|
|
6887
|
+
const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
|
|
6888
|
+
code: 127,
|
|
6889
|
+
stdout: "",
|
|
6890
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
6891
|
+
}));
|
|
6892
|
+
return {
|
|
6893
|
+
ok: true,
|
|
6894
|
+
select,
|
|
6895
|
+
command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
|
|
6896
|
+
output,
|
|
6897
|
+
// Emitted source first, scaffolding after: `src/assets.ts` is the thing the
|
|
6898
|
+
// step is about, and a package.json sorting above it buries the answer.
|
|
6899
|
+
artifacts: readArtifacts(demo, demo.out, (rel) => /\.(ts|json)$/.test(rel) && !rel.endsWith("tsconfig.json")).sort(
|
|
6900
|
+
(a, b) => Number(!/\/src\//.test(a.path)) - Number(!/\/src\//.test(b.path))
|
|
6901
|
+
),
|
|
6902
|
+
boundary,
|
|
6903
|
+
lint: { ok: lint.code === 0, code: lint.code, command: `chant ${lintArgs.join(" ")}`, output: merge(lint, demo.root) },
|
|
6904
|
+
buildCaveat: BUILD_CAVEAT
|
|
6905
|
+
};
|
|
6906
|
+
}
|
|
6907
|
+
async function runCarveBridge(demo, select) {
|
|
6908
|
+
const block = carveWriteBlock(demo);
|
|
6909
|
+
if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
|
|
6910
|
+
mkdirSync3(demo.out, { recursive: true });
|
|
6911
|
+
const args = [
|
|
6912
|
+
"carve",
|
|
6913
|
+
"bridge",
|
|
6914
|
+
"--from",
|
|
6915
|
+
demo.from,
|
|
6916
|
+
...demo.state ? ["--state", demo.state] : [],
|
|
6917
|
+
"--select",
|
|
6918
|
+
select,
|
|
6919
|
+
"--output",
|
|
6920
|
+
demo.out
|
|
6921
|
+
];
|
|
6922
|
+
const run3 = await runChantRaw(args, demo.project).catch((err) => ({
|
|
6923
|
+
code: 127,
|
|
6924
|
+
stdout: "",
|
|
6925
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
6926
|
+
}));
|
|
6927
|
+
const output = merge(run3, demo.root);
|
|
6928
|
+
if (run3.code !== 0) {
|
|
6929
|
+
return refuse3(
|
|
6930
|
+
"carve-action",
|
|
6931
|
+
`chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
|
|
6932
|
+
"Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
|
|
6933
|
+
);
|
|
6630
6934
|
}
|
|
6631
|
-
const
|
|
6935
|
+
const slug3 = carveSlug(select);
|
|
6936
|
+
const all = readArtifacts(demo, demo.out, (rel) => /\.(tf|md|patch)$/.test(rel));
|
|
6632
6937
|
return {
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6938
|
+
ok: true,
|
|
6939
|
+
select,
|
|
6940
|
+
command: `chant ${args.map((a) => shortenIn(a, demo.root)).join(" ")}`,
|
|
6941
|
+
output,
|
|
6942
|
+
runbook: all.find((a) => a.path.endsWith(`${slug3}-runbook.md`)) ?? null,
|
|
6943
|
+
proposals: all.filter((a) => !a.path.endsWith(`${slug3}-runbook.md`))
|
|
6637
6944
|
};
|
|
6638
6945
|
}
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6946
|
+
|
|
6947
|
+
// src/ops.ts
|
|
6948
|
+
import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
|
|
6949
|
+
import { join as join14 } from "node:path";
|
|
6950
|
+
var APPLY_TARGET_LEXICON = {
|
|
6951
|
+
cloudformation: "aws",
|
|
6952
|
+
kubectl: "k8s",
|
|
6953
|
+
// kustomize renders then applies through the same k8s pipeline
|
|
6954
|
+
// (chant#1548), so it scopes to the same lexicon. Note the auto-sync
|
|
6955
|
+
// wrinkle this creates on purpose: a kustomize Op AND a kubectl Op in one
|
|
6956
|
+
// project both exact-match `k8s`, and two exact matches decline — two Ops
|
|
6957
|
+
// claiming the k8s half genuinely is ambiguous.
|
|
6958
|
+
kustomize: "k8s",
|
|
6959
|
+
arm: "azure"
|
|
6643
6960
|
};
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6961
|
+
function kindOf(content) {
|
|
6962
|
+
if (/\bApplyOp\b/.test(content)) return "apply";
|
|
6963
|
+
if (/\bReconcileOp\b/.test(content)) return "reconcile";
|
|
6964
|
+
if (/AuditOp\b/.test(content)) return "audit";
|
|
6965
|
+
return "op";
|
|
6647
6966
|
}
|
|
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
|
-
|
|
6967
|
+
function discoverOps(projectDir) {
|
|
6968
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6969
|
+
const out = [];
|
|
6970
|
+
for (const sub of ["ops", "src", "."]) {
|
|
6971
|
+
const dir = join14(projectDir, sub);
|
|
6972
|
+
if (!existsSync9(dir)) continue;
|
|
6973
|
+
for (const f of readdirSync6(dir)) {
|
|
6974
|
+
if (!f.endsWith(".op.ts")) continue;
|
|
6975
|
+
const content = readFileSync11(join14(dir, f), "utf8");
|
|
6976
|
+
const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6977
|
+
if (!name || seen.has(name)) continue;
|
|
6978
|
+
seen.add(name);
|
|
6979
|
+
const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6980
|
+
const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6981
|
+
const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
6982
|
+
const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
|
|
6983
|
+
const irPath = opIrPath(projectDir, name);
|
|
6984
|
+
out.push({
|
|
6985
|
+
name,
|
|
6986
|
+
kind: kindOf(content),
|
|
6987
|
+
dir: projectDir,
|
|
6988
|
+
...gate ? { gate } : {},
|
|
6989
|
+
...env ? { env } : {},
|
|
6990
|
+
...substrate ? { substrate } : {},
|
|
6991
|
+
...existsSync9(irPath) ? { irPath } : {}
|
|
6992
|
+
});
|
|
6669
6993
|
}
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
return { ...ir, nodes };
|
|
6994
|
+
}
|
|
6995
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
6673
6996
|
}
|
|
6674
|
-
function
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
path
|
|
6997
|
+
function opIrPath(projectDir, name) {
|
|
6998
|
+
return join14(projectDir, "dist", "ops", name, "op.json");
|
|
6999
|
+
}
|
|
7000
|
+
function discoverOpIrs(projectDirs) {
|
|
7001
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7002
|
+
const out = [];
|
|
7003
|
+
for (const dir of projectDirs) {
|
|
7004
|
+
const root = join14(dir, "dist", "ops");
|
|
7005
|
+
if (!existsSync9(root)) continue;
|
|
7006
|
+
for (const name of readdirSync6(root)) {
|
|
7007
|
+
if (seen.has(name)) continue;
|
|
7008
|
+
const path = opIrPath(dir, name);
|
|
7009
|
+
if (!existsSync9(path)) continue;
|
|
7010
|
+
seen.add(name);
|
|
7011
|
+
out.push({ name, dir, path });
|
|
6686
7012
|
}
|
|
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
7013
|
}
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
)
|
|
7014
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
7015
|
+
}
|
|
7016
|
+
function discoverEstateOps(projectDirs) {
|
|
7017
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7018
|
+
const out = [];
|
|
7019
|
+
for (const dir of projectDirs) {
|
|
7020
|
+
for (const op of discoverOps(dir)) {
|
|
7021
|
+
if (seen.has(op.name)) continue;
|
|
7022
|
+
seen.add(op.name);
|
|
7023
|
+
out.push(op);
|
|
7024
|
+
}
|
|
6700
7025
|
}
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
7026
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
7027
|
+
}
|
|
7028
|
+
|
|
7029
|
+
// src/run-playhead.ts
|
|
7030
|
+
var RECEIPT_READ_FN = "receiptRead";
|
|
7031
|
+
var RECEIPT_WRITE_FN = "receiptWrite";
|
|
7032
|
+
var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
|
|
7033
|
+
var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7034
|
+
function asStepRecord(value) {
|
|
7035
|
+
if (!isRecord5(value)) return null;
|
|
7036
|
+
if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
|
|
7037
|
+
if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
|
|
7038
|
+
if (typeof value.durationMs !== "number") return null;
|
|
7039
|
+
return value;
|
|
7040
|
+
}
|
|
7041
|
+
function parseRunProgressLine(line) {
|
|
7042
|
+
let parsed;
|
|
7043
|
+
try {
|
|
7044
|
+
parsed = JSON.parse(line);
|
|
7045
|
+
} catch {
|
|
7046
|
+
return null;
|
|
6708
7047
|
}
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
);
|
|
7048
|
+
const one = asStepRecord(parsed);
|
|
7049
|
+
if (one) return [one];
|
|
7050
|
+
if (isRecord5(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
|
|
7051
|
+
const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
|
|
7052
|
+
return records.length ? records : null;
|
|
6713
7053
|
}
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
7054
|
+
return null;
|
|
7055
|
+
}
|
|
7056
|
+
function recordLine(record) {
|
|
7057
|
+
const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
|
|
7058
|
+
const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
|
|
7059
|
+
return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
|
|
6717
7060
|
}
|
|
6718
|
-
|
|
6719
|
-
// src/operator.ts
|
|
6720
7061
|
var ANSI2 = /\x1b\[[0-9;]*m/g;
|
|
6721
7062
|
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 } : {} });
|
|
7063
|
+
function statusFields(stdout) {
|
|
7064
|
+
const out = /* @__PURE__ */ new Map();
|
|
7065
|
+
for (const raw of strip2(stdout).split("\n")) {
|
|
7066
|
+
const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
|
|
7067
|
+
if (m) out.set(m[1].trim(), m[2].trim());
|
|
6732
7068
|
}
|
|
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
|
-
};
|
|
7069
|
+
return out;
|
|
6743
7070
|
}
|
|
6744
|
-
function
|
|
6745
|
-
const
|
|
6746
|
-
|
|
7071
|
+
function parseGateLine(line) {
|
|
7072
|
+
const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
|
|
7073
|
+
if (!m) return null;
|
|
7074
|
+
const signalName = m[1].trim();
|
|
7075
|
+
if (!signalName) return null;
|
|
7076
|
+
return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
|
|
7077
|
+
}
|
|
7078
|
+
function readRunStatus(op, run3) {
|
|
6747
7079
|
if (run3.code !== 0) {
|
|
6748
|
-
const message =
|
|
6749
|
-
|
|
6750
|
-
if (/Unknown command|Unknown operator subcommand/i.test(message)) {
|
|
7080
|
+
const message = strip2(run3.stderr).trim() || strip2(run3.stdout).trim() || `chant run status exited ${run3.code}`;
|
|
7081
|
+
if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
|
|
6751
7082
|
return {
|
|
6752
7083
|
ok: false,
|
|
6753
7084
|
refusal: {
|
|
6754
|
-
error: `
|
|
6755
|
-
code: "no-
|
|
6756
|
-
remedy: "
|
|
7085
|
+
error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
7086
|
+
code: "no-temporal",
|
|
7087
|
+
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
7088
|
}
|
|
6758
7089
|
};
|
|
6759
7090
|
}
|
|
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) {
|
|
7091
|
+
if (/not found|NotFound/.test(message)) {
|
|
6806
7092
|
return {
|
|
6807
7093
|
ok: false,
|
|
6808
7094
|
refusal: {
|
|
6809
|
-
error:
|
|
6810
|
-
code: "
|
|
6811
|
-
remedy: "
|
|
7095
|
+
error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
7096
|
+
code: "no-run",
|
|
7097
|
+
remedy: "Start the Op (Run) and the playhead fills in as each step settles."
|
|
6812
7098
|
}
|
|
6813
7099
|
};
|
|
6814
7100
|
}
|
|
6815
|
-
|
|
7101
|
+
return {
|
|
7102
|
+
ok: false,
|
|
7103
|
+
refusal: {
|
|
7104
|
+
error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
|
|
7105
|
+
code: "run-status",
|
|
7106
|
+
remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
|
|
7107
|
+
}
|
|
7108
|
+
};
|
|
6816
7109
|
}
|
|
6817
|
-
|
|
7110
|
+
const f = statusFields(run3.stdout);
|
|
7111
|
+
const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
|
|
7112
|
+
const gateLine = f.get("Gate");
|
|
7113
|
+
return {
|
|
7114
|
+
ok: true,
|
|
7115
|
+
read: {
|
|
7116
|
+
op,
|
|
7117
|
+
...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
|
|
7118
|
+
...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
|
|
7119
|
+
...f.get("Status") ? { status: f.get("Status") } : {},
|
|
7120
|
+
...f.get("Started") ? { startedAt: f.get("Started") } : {},
|
|
7121
|
+
...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
|
|
7122
|
+
...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
|
|
7123
|
+
// No Gate line means chant printed none, which it does both when the
|
|
7124
|
+
// query says "nothing pending" and when the Op registers no handler.
|
|
7125
|
+
// Either way: no gate is waiting on a human right now.
|
|
7126
|
+
gate: gateLine ? parseGateLine(gateLine) : null
|
|
7127
|
+
}
|
|
7128
|
+
};
|
|
6818
7129
|
}
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
7130
|
+
var initialRunState = {
|
|
7131
|
+
op: null,
|
|
7132
|
+
mode: null,
|
|
7133
|
+
status: "idle",
|
|
7134
|
+
records: [],
|
|
7135
|
+
gate: null,
|
|
7136
|
+
gateNote: null,
|
|
7137
|
+
startedAt: null,
|
|
7138
|
+
endedAt: null,
|
|
7139
|
+
lost: null
|
|
7140
|
+
};
|
|
7141
|
+
function runStarted(op, mode, at) {
|
|
7142
|
+
return { ...initialRunState, op, mode, status: "running", startedAt: at };
|
|
6825
7143
|
}
|
|
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;
|
|
7144
|
+
function runRecord(state, record) {
|
|
7145
|
+
return { ...state, records: [...state.records, record] };
|
|
7146
|
+
}
|
|
7147
|
+
function runGate(state, result) {
|
|
7148
|
+
return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
|
|
7149
|
+
}
|
|
7150
|
+
function runEnded(state, exitCode, at) {
|
|
7151
|
+
if (state.status !== "running") return state;
|
|
7152
|
+
if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
|
|
7153
|
+
if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
|
|
7154
|
+
return {
|
|
7155
|
+
...state,
|
|
7156
|
+
status: "lost",
|
|
7157
|
+
endedAt: at,
|
|
7158
|
+
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.`
|
|
7159
|
+
};
|
|
7160
|
+
}
|
|
7161
|
+
function slotsFor(placed) {
|
|
7162
|
+
const out = [];
|
|
7163
|
+
for (const p of placed) {
|
|
7164
|
+
if (p.step.kind === "activity") {
|
|
7165
|
+
out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
|
|
7166
|
+
} else if (p.step.kind === "effect") {
|
|
7167
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
|
|
7168
|
+
for (const nested of p.step.steps ?? []) {
|
|
7169
|
+
if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
|
|
6854
7170
|
}
|
|
7171
|
+
out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
|
|
6855
7172
|
}
|
|
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
7173
|
}
|
|
6867
|
-
|
|
6868
|
-
return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
|
|
7174
|
+
return out;
|
|
6869
7175
|
}
|
|
6870
|
-
function
|
|
6871
|
-
const
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
7176
|
+
function mergeRun(prev, record) {
|
|
7177
|
+
const rank = { fail: 3, ok: 2, skipped: 1 };
|
|
7178
|
+
const keepPrev = prev && rank[prev.status] >= rank[record.status];
|
|
7179
|
+
return {
|
|
7180
|
+
status: keepPrev ? prev.status : record.status,
|
|
7181
|
+
durationMs: (prev?.durationMs ?? 0) + record.durationMs,
|
|
7182
|
+
...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
|
|
7183
|
+
...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
|
|
7184
|
+
records: (prev?.records ?? 0) + 1
|
|
7185
|
+
};
|
|
7186
|
+
}
|
|
7187
|
+
function joinRun(op, records, gate) {
|
|
7188
|
+
const placed = opPlacedSteps(op);
|
|
7189
|
+
const slots = slotsFor(placed);
|
|
7190
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
7191
|
+
for (const s of slots) {
|
|
7192
|
+
const key = `${s.phase}\0${s.fn}`;
|
|
7193
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
7194
|
+
byKey.get(key).push({ id: s.id });
|
|
7195
|
+
}
|
|
7196
|
+
const cursor = /* @__PURE__ */ new Map();
|
|
7197
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
7198
|
+
const unplaced = [];
|
|
7199
|
+
let lastSettledIndex = -1;
|
|
7200
|
+
const indexOf = new Map(placed.map((p, i) => [p.id, i]));
|
|
7201
|
+
for (const record of records) {
|
|
7202
|
+
const key = `${record.phase}\0${record.fn}`;
|
|
7203
|
+
const list3 = byKey.get(key);
|
|
7204
|
+
const i = cursor.get(key) ?? 0;
|
|
7205
|
+
const slot = list3?.[i];
|
|
7206
|
+
if (!slot) {
|
|
7207
|
+
unplaced.push(record);
|
|
7208
|
+
continue;
|
|
6882
7209
|
}
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
7210
|
+
cursor.set(key, i + 1);
|
|
7211
|
+
byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
|
|
7212
|
+
if (record.status !== "skipped") {
|
|
7213
|
+
lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
|
|
7214
|
+
}
|
|
7215
|
+
}
|
|
7216
|
+
if (gate) {
|
|
7217
|
+
const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
|
|
7218
|
+
if (gateNode) {
|
|
7219
|
+
return {
|
|
7220
|
+
byNode,
|
|
7221
|
+
at: gateNode.id,
|
|
7222
|
+
atReason: `waiting for ${gate.signalName} since ${gate.since}`,
|
|
7223
|
+
unplaced
|
|
7224
|
+
};
|
|
6891
7225
|
}
|
|
6892
7226
|
}
|
|
7227
|
+
const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
|
|
6893
7228
|
return {
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
})
|
|
7229
|
+
byNode,
|
|
7230
|
+
at: lastSettledIndex >= 0 && next ? next.id : null,
|
|
7231
|
+
atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
|
|
7232
|
+
unplaced
|
|
6899
7233
|
};
|
|
6900
7234
|
}
|
|
6901
|
-
var
|
|
6902
|
-
|
|
7235
|
+
var TONE = {
|
|
7236
|
+
ok: "good",
|
|
7237
|
+
fail: "warn",
|
|
7238
|
+
skipped: "neutral"
|
|
6903
7239
|
};
|
|
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) };
|
|
7240
|
+
var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
|
|
7241
|
+
function tookLabel(ms) {
|
|
7242
|
+
return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
|
|
6928
7243
|
}
|
|
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
|
-
});
|
|
7244
|
+
function paintPlayhead(ir, join21, gate) {
|
|
7245
|
+
if (!join21.byNode.size && !join21.at) return ir;
|
|
7246
|
+
const nodes = ir.nodes.map((node) => {
|
|
7247
|
+
const run3 = join21.byNode.get(node.id);
|
|
7248
|
+
const here = join21.at === node.id;
|
|
7249
|
+
if (!run3 && !here) return node;
|
|
7250
|
+
const isGate = node.attrs?._step === "gate";
|
|
7251
|
+
const attrs = { ...node.attrs };
|
|
7252
|
+
if (run3) {
|
|
7253
|
+
attrs._status = TONE[run3.status];
|
|
7254
|
+
attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
|
|
7255
|
+
attrs.ran = tookLabel(run3.durationMs);
|
|
7256
|
+
if (run3.error) attrs.error = run3.error;
|
|
7257
|
+
if (run3.outcome) attrs.outcome = run3.outcome;
|
|
7258
|
+
if (run3.records > 1) attrs.runRecords = run3.records;
|
|
6951
7259
|
}
|
|
6952
|
-
|
|
6953
|
-
|
|
7260
|
+
if (here) {
|
|
7261
|
+
attrs._playhead = true;
|
|
7262
|
+
attrs._status = isGate && gate ? "warn" : "accent";
|
|
7263
|
+
attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
|
|
7264
|
+
if (isGate && gate?.description) attrs.gateDescription = gate.description;
|
|
7265
|
+
}
|
|
7266
|
+
return { ...node, attrs };
|
|
7267
|
+
});
|
|
7268
|
+
return { ...ir, nodes };
|
|
6954
7269
|
}
|
|
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
|
-
}
|
|
7270
|
+
function pendingGateCard(op, gate, ir) {
|
|
7271
|
+
const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
|
|
6967
7272
|
return {
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
7273
|
+
op,
|
|
7274
|
+
signalName: gate.signalName,
|
|
7275
|
+
...gate.description ? { description: gate.description } : {},
|
|
7276
|
+
since: gate.since,
|
|
7277
|
+
...node ? { node: node.id } : {},
|
|
7278
|
+
...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
|
|
7279
|
+
approve: {
|
|
7280
|
+
method: "POST",
|
|
7281
|
+
path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
|
|
7282
|
+
}
|
|
6974
7283
|
};
|
|
6975
7284
|
}
|
|
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.`;
|
|
7285
|
+
function playheadNote(state, join21) {
|
|
7286
|
+
if (!state.op) {
|
|
7287
|
+
return "No run state: nothing has been run from this behold session, so every step paints as declared.";
|
|
6983
7288
|
}
|
|
7289
|
+
const settled = join21 ? join21.byNode.size : state.records.length;
|
|
6984
7290
|
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) {
|
|
7291
|
+
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`;
|
|
7292
|
+
if (state.status === "running") {
|
|
6994
7293
|
bits.push(
|
|
6995
|
-
|
|
7294
|
+
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
7295
|
);
|
|
6997
7296
|
}
|
|
6998
|
-
|
|
7297
|
+
if (state.status === "ok") bits.push("the run exited cleanly");
|
|
7298
|
+
if (state.status === "failed") bits.push("a step failed");
|
|
7299
|
+
if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
|
|
7300
|
+
if (state.gate) {
|
|
7301
|
+
bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
|
|
7302
|
+
} else if (state.gateNote) {
|
|
7303
|
+
bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
|
|
7304
|
+
}
|
|
7305
|
+
if (join21?.unplaced.length) {
|
|
7306
|
+
bits.push(
|
|
7307
|
+
`${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
|
|
7308
|
+
);
|
|
7309
|
+
}
|
|
7310
|
+
if (!bits.length) return `${head}.`;
|
|
7311
|
+
const rest = bits.join("; ");
|
|
7312
|
+
return `${head} \u2014 ${rest}.`;
|
|
6999
7313
|
}
|
|
7000
7314
|
|
|
7001
7315
|
// src/adopt.ts
|
|
@@ -8847,6 +9161,51 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
8847
9161
|
const state = runner.noteOperator({ ok: true, rows });
|
|
8848
9162
|
return c.json({ ...state, operator: state });
|
|
8849
9163
|
});
|
|
9164
|
+
app.get("/api/operator/log", async (c) => {
|
|
9165
|
+
const { dirs } = convergeDirs();
|
|
9166
|
+
if (!dirs.length) {
|
|
9167
|
+
return c.json(
|
|
9168
|
+
{
|
|
9169
|
+
error: "This project declares no operating loop \u2014 no emitted Op carries chant's `Converge` search attribute.",
|
|
9170
|
+
code: "no-operator",
|
|
9171
|
+
remedy: HOW_TO_DECLARE,
|
|
9172
|
+
history: null
|
|
9173
|
+
},
|
|
9174
|
+
200
|
|
9175
|
+
);
|
|
9176
|
+
}
|
|
9177
|
+
const window = operatorLogWindow({ limit: c.req.query("limit") ?? null, since: c.req.query("since") ?? null });
|
|
9178
|
+
if ("error" in window) {
|
|
9179
|
+
return c.json({ error: window.error, code: "operator-log", remedy: "Ask for a bounded window: `?limit=50&since=<iso>`.", history: null }, 400);
|
|
9180
|
+
}
|
|
9181
|
+
const answers = [];
|
|
9182
|
+
for (const dir of dirs) {
|
|
9183
|
+
const version = resolveChant(dir).version;
|
|
9184
|
+
if (!meetsFloor(version, OPERATOR_LOG_FLOOR)) {
|
|
9185
|
+
return c.json(
|
|
9186
|
+
{
|
|
9187
|
+
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}.`,
|
|
9188
|
+
code: "no-operator-log-cli",
|
|
9189
|
+
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.`,
|
|
9190
|
+
history: null
|
|
9191
|
+
},
|
|
9192
|
+
422
|
|
9193
|
+
);
|
|
9194
|
+
}
|
|
9195
|
+
const result = readOperatorLog(await runChantRaw(operatorLogArgs(window), dir));
|
|
9196
|
+
if (!result.ok) return c.json({ ...result.refusal, history: null }, result.refusal.code === "no-operator" ? 200 : 422);
|
|
9197
|
+
answers.push({ entries: result.entries, malformed: result.malformed });
|
|
9198
|
+
}
|
|
9199
|
+
const merged = mergeOperatorLogs(answers, window.limit);
|
|
9200
|
+
const history = {
|
|
9201
|
+
entries: operatorTimeline(merged.entries),
|
|
9202
|
+
malformed: merged.malformed,
|
|
9203
|
+
window: { limit: window.limit, since: window.since ?? null },
|
|
9204
|
+
more: merged.entries.length >= window.limit,
|
|
9205
|
+
readAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
9206
|
+
};
|
|
9207
|
+
return c.json({ history });
|
|
9208
|
+
});
|
|
8850
9209
|
app.post("/api/operator/approve/:op/:gate", async (c) => {
|
|
8851
9210
|
const { op, gate } = c.req.param();
|
|
8852
9211
|
const info = estateOps().find((o) => o.name === op);
|
|
@@ -9148,6 +9507,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
9148
9507
|
{ method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress + run playhead" },
|
|
9149
9508
|
{ method: "GET", path: "/api/ops/:name/status", desc: "an Op's durable run status + pending gate (chant run status)" },
|
|
9150
9509
|
{ method: "GET", path: "/api/operator/status", desc: "the operating loop's strip: last tick per ConvergeOp + lease + pending converge gates" },
|
|
9510
|
+
{ 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
9511
|
{ method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
|
|
9152
9512
|
{ method: "GET", path: "/api/frames", desc: "captured lanes frames" },
|
|
9153
9513
|
{ method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
|
|
@@ -9259,6 +9619,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
9259
9619
|
if (env) {
|
|
9260
9620
|
const rows = await componentStatus(cfg.projectDir, env, opts);
|
|
9261
9621
|
ir = joinComponentStatus(ir, rows);
|
|
9622
|
+
ir = joinTickVerdicts(ir, verdictsForEnv(runner.operatorState.verdicts, env));
|
|
9262
9623
|
mode = "component-status";
|
|
9263
9624
|
metaEnv = env;
|
|
9264
9625
|
}
|