Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@intentius/behold",
"version": "0.2.0",
"type": "module",
"description": "behold \u2014 a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
"description": "behold a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
"bin": {
"behold": "./bin/behold.js"
},
Expand All @@ -28,7 +28,7 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@intentius/chant": "^0.38.0",
"@intentius/pinhole": "^0.2.4",
"@intentius/pinhole": "^0.2.5",
"hono": "^4.6.0"
},
"devDependencies": {
Expand Down
32 changes: 32 additions & 0 deletions src/chant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,38 @@ export async function graphIr(projectDir: string, opts: GraphOptions = {}): Prom
return runChantJson<GraphIR>(graphArgs(src, "ir", opts, false), projectDir, envOverridesFor(opts));
}

/**
* The graph IR of a project's `cluster/` build root, or undefined when the
* project has none (or chant can't graph it).
*
* A k3d/floci-backed estate declares its local cluster as chant source — but
* deliberately OUTSIDE `sourceDir`, as its own build root (`chant build
* cluster`), so whole-project discovery never walks it (fountain-ops and
* kubemicrovm-ops both follow this shape). That kept the cluster out of every
* behold view: the k8s half rendered inside a synthetic `cluster <env>` box
* while the estate's actual `K3d::Cluster` declaration existed two directories
* away. This reads that root the same way the estates' own justfiles do.
*
* Failure is an ordinary state, not an error: most projects have no cluster
* root, and a chant predating the k3d lexicon can't graph one. Both yield
* undefined and the caller renders exactly what it rendered before.
*/
export async function clusterRootGraphIr(projectDir: string, opts: GraphOptions = {}): Promise<GraphIR | undefined> {
if (!existsSync(join(projectDir, "cluster"))) return undefined;
// Source graph only, always. `--live` ignores the path positional (verified
// on kubemicrovm-ops: `graph cluster --live --overlay` returns the WHOLE
// estate's live overlay, k3d node absent), so a live read here would
// duplicate the main graph instead of scoping to the root. Status for the
// declared cluster is painted by the caller from the k3d probe instead
// (src/cluster-root.ts).
const { live: _live, overlay: _overlay, env: _env, ...sourceOnly } = opts;
try {
return await runChantJson<GraphIR>(graphArgs("cluster", "ir", sourceOnly, false), projectDir, envOverridesFor(sourceOnly));
} catch {
return undefined;
}
}

/** Node positions for a project (`chant graph --format layout`, dagre — no native dep). */
export async function graphLayout(projectDir: string, opts: GraphOptions = {}): Promise<Layout> {
const src = await graphPath(projectDir, opts);
Expand Down
36 changes: 35 additions & 1 deletion src/cluster-anchor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { GraphIR } from "@intentius/chant";
import { addClusterAnchorEdges, soleManagedCluster, ANCHOR_VIA, MANAGED_CLUSTER_KINDS } from "./cluster-anchor.ts";
import { addClusterAnchorEdges, soleManagedCluster, boundManagedCluster, managedClusterName, ANCHOR_VIA, MANAGED_CLUSTER_KINDS } from "./cluster-anchor.ts";

const cloud = (id: string, kind: string, lexicon: string) => ({ id, kind, lexicon, attrs: {} });
const k8s = (id: string, kind: string, meta: Record<string, unknown>) => ({
Expand Down Expand Up @@ -88,6 +88,40 @@ describe("addClusterAnchorEdges (#103)", () => {
expect(soleManagedCluster(ir.nodes)).toBeUndefined();
});

it("a bound kube context breaks the two-cluster tie (the fountain-ops shape)", () => {
const k3dCluster = (id: string, name: string) => ({
id,
kind: "K3d::Cluster",
lexicon: "k3d",
attrs: { metadata: { name } },
});
const nodes = [k3dCluster("local", "fountain-local"), k3dCluster("standIn", "fountain-k8s-stand-in")] as never[];
expect(boundManagedCluster(nodes, "k3d-fountain-local")?.id).toBe("local");
expect(boundManagedCluster(nodes, "k3d-fountain-k8s-stand-in")?.id).toBe("standIn");
// No context, or a context binding neither, still declines.
expect(boundManagedCluster(nodes)).toBeUndefined();
expect(boundManagedCluster(nodes, "k3d-something-else")).toBeUndefined();
});

it("anchors a k3d estate: the declared K3d::Cluster is the thing the k8s half runs on", () => {
expect(MANAGED_CLUSTER_KINDS.has("K3d::Cluster")).toBe(true);
const ir = {
nodes: [
{ id: "localCluster", kind: "K3d::Cluster", lexicon: "k3d", attrs: { metadata: { name: "kubemicrovm-local" } } },
k8s("m80Deploy", "K8s::Apps::Deployment", { name: "m80", namespace: "kube-microvm" }),
],
edges: [],
groups: {},
} as unknown as GraphIR;
expect(parentOf(addClusterAnchorEdges(ir), "m80Deploy")).toBe("localCluster");
});

it("managedClusterName reads metadata.name (k3d), a scalar name attr (cloud), then the id", () => {
expect(managedClusterName({ id: "x", kind: "K3d::Cluster", attrs: { metadata: { name: "fountain-local" } } })).toBe("fountain-local");
expect(managedClusterName({ id: "x", kind: "AWS::EKS::Cluster", attrs: { name: "cc-eks" } })).toBe("cc-eks");
expect(managedClusterName({ id: "clusterCluster", kind: "AWS::EKS::Cluster", attrs: {} })).toBe("clusterCluster");
});

it("leaves a cloud-only or k8s-only project untouched", () => {
const cloudOnly = { nodes: [cloud("cluster", "AWS::EKS::Cluster", "aws")], edges: [], groups: {} } as unknown as GraphIR;
expect(addClusterAnchorEdges(cloudOnly).edges).toHaveLength(0);
Expand Down
48 changes: 46 additions & 2 deletions src/cluster-anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,25 @@
* A project with no k8s nodes, or no managed cluster, is untouched.
*/
import type { GraphIR, IRNode } from "@intentius/chant";
import { contextBindsCluster } from "./k8s-target.ts";

/**
* Managed-cluster kinds across the three cloud lexicons — the node a k8s
* workload runs *on*. Deliberately the control-plane resource only: a node
* group / node pool is capacity, not the thing a Service is scheduled onto.
*
* `K3d::Cluster` joins them: a declared local k3d cluster is exactly as much
* "the thing the k8s half runs on" as an EKS control plane — the
* fountain-ops/kubemicrovm-ops estates declare one in their `cluster/` build
* root (merged by src/cluster-root.ts), and before this the k8s half rendered
* inside a synthetic `cluster <env>` box while the declared cluster floated
* beside it as an unrelated card.
*/
export const MANAGED_CLUSTER_KINDS = new Set([
"AWS::EKS::Cluster",
"GCP::Container::Cluster",
"Microsoft.ContainerService/managedClusters",
"K3d::Cluster",
]);

/** Edge tag carried by every anchor this module adds, so a viewer can style
Expand Down Expand Up @@ -111,14 +120,49 @@ export function soleManagedCluster(nodes: readonly IRNode[]): IRNode | undefined
return clusters.length === 1 ? clusters[0] : undefined;
}

/** The cluster name a managed-cluster node declares: `metadata.name` for the
* k8s-shaped kinds (k3d), a scalar `name`/`clusterName` attr for the cloud
* control planes, else the node id. */
export function managedClusterName(node: AnchorNode): string {
const metaName = metaString(node, "name");
if (metaName) return metaName;
for (const key of ["name", "clusterName"]) {
const v = node.attrs?.[key];
if (typeof v === "string" && v.length > 0) return v;
}
return node.id;
}

/**
* The managed cluster the k8s half runs on, with the kube context as the
* tiebreak (#106's `contextBindsCluster`, finally wired).
*
* One declared cluster answers by itself — the pre-existing rule. Two or more
* used to be an unconditional decline, which is honest for the cloud kinds but
* wrong for the local-substrate estates: fountain-ops declares TWO k3d
* clusters (`fountain-local` plus a deliberately-foreign stand-in), yet the
* bound kubeconfig context names exactly which one the reads land on. So with
* a context to compare, the candidate it binds wins — and only when it binds
* exactly one. No context, or an ambiguous match, still declines.
*/
export function boundManagedCluster(nodes: readonly IRNode[], boundContext?: string): IRNode | undefined {
const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
if (clusters.length === 1) return clusters[0];
if (clusters.length === 0 || !boundContext) return undefined;
const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
return bound.length === 1 ? bound[0] : undefined;
}

/**
* Add `runs-on` anchor edges: cluster → namespace → namespaced resource, and
* cluster → cluster-scoped resource. Mutates + returns `ir`, matching
* `addValueMatchEdges`'s contract so the two compose in the same pipeline.
* `boundContext` (the kube context chant binds for the served env) breaks a
* multi-cluster tie — see `boundManagedCluster`.
*/
export function addClusterAnchorEdges(ir: GraphIR): GraphIR {
export function addClusterAnchorEdges(ir: GraphIR, boundContext?: string): GraphIR {
const nodes = ir.nodes as AnchorNode[];
const cluster = soleManagedCluster(ir.nodes);
const cluster = boundManagedCluster(ir.nodes, boundContext);
if (!cluster) return ir;
const k8sNodes = nodes.filter(isK8s);
if (k8sNodes.length === 0) return ir;
Expand Down
81 changes: 81 additions & 0 deletions src/cluster-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import type { GraphIR } from "@intentius/chant";
import { mergeClusterRoot, runningK3dClusters, k3dClusterName } from "./cluster-root.ts";

const clusterIr = (): GraphIR => ({
nodes: [
{
id: "localCluster",
kind: "K3d::Cluster",
lexicon: "k3d",
attrs: { metadata: { name: "kubemicrovm-local" }, servers: 1, agents: 1 },
},
],
edges: [],
groups: {},
});

const mainIr = (): GraphIR => ({
nodes: [{ id: "m80Deploy", kind: "K8s::Apps::Deployment", lexicon: "k8s", attrs: {} }],
edges: [],
groups: {},
});

describe("mergeClusterRoot", () => {
it("appends the cluster root's nodes and edges", () => {
const ir = mergeClusterRoot(mainIr(), clusterIr());
expect(ir.nodes.map((n) => n.id)).toEqual(["m80Deploy", "localCluster"]);
});

it("no cluster root is a no-op", () => {
const ir = mainIr();
expect(mergeClusterRoot(ir, undefined)).toBe(ir);
expect(ir.nodes).toHaveLength(1);
});

it("skips a node the main graph already carries (the main graph wins)", () => {
const ir = mainIr();
ir.nodes.push({ id: "localCluster", kind: "K3d::Cluster", lexicon: "k3d", attrs: { _status: "good" } });
mergeClusterRoot(ir, clusterIr());
expect(ir.nodes.filter((n) => n.id === "localCluster")).toHaveLength(1);
expect(ir.nodes.find((n) => n.id === "localCluster")!.attrs?._status).toBe("good");
});

it("paints a running declared cluster good, an absent one accent", () => {
const running = new Map([["kubemicrovm-local", true]]);
const up = mergeClusterRoot(mainIr(), clusterIr(), running);
expect(up.nodes.find((n) => n.id === "localCluster")!.attrs?._status).toBe("good");

const down = mergeClusterRoot(mainIr(), clusterIr(), new Map());
expect(down.nodes.find((n) => n.id === "localCluster")!.attrs?._status).toBe("accent");
});

it("leaves the cluster unpainted on a source-only view (no probe result)", () => {
const ir = mergeClusterRoot(mainIr(), clusterIr(), undefined);
expect(ir.nodes.find((n) => n.id === "localCluster")!.attrs?._status).toBeUndefined();
});
});

describe("runningK3dClusters", () => {
it("parses names and running servers from `k3d cluster list --no-headers`", async () => {
const out = "kubemicrovm-local 1/1 1/1 true\nstopped-one 0/1 0/0 true\n";
const clusters = await runningK3dClusters(async () => out);
expect(clusters?.get("kubemicrovm-local")).toBe(true);
expect(clusters?.get("stopped-one")).toBe(false);
});

it("k3d unavailable yields undefined — no opinion, never absence", async () => {
expect(
await runningK3dClusters(async () => {
throw new Error("not installed");
}),
).toBeUndefined();
});
});

describe("k3dClusterName", () => {
it("reads metadata.name, falling back to the node id", () => {
expect(k3dClusterName({ id: "x", attrs: { metadata: { name: "fountain-local" } } })).toBe("fountain-local");
expect(k3dClusterName({ id: "x", attrs: {} })).toBe("x");
});
});
89 changes: 89 additions & 0 deletions src/cluster-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* The cluster build root, merged into the served view.
*
* A k3d/floci-backed estate declares its local cluster as chant source in its
* own build root (`cluster/`, outside `sourceDir` — the fountain-ops /
* kubemicrovm-ops shape), so no behold view ever saw the `K3d::Cluster`
* declaration: the k8s half rendered inside a synthetic `cluster <env>` box
* while the actual cluster node sat ungraphed. `chant.ts`'s
* `clusterRootGraphIr` reads that root; this module merges it into the main
* IR and paints the merged cluster nodes from the one live signal behold
* already has for k3d — `k3d cluster list`.
*
* The paint is deliberately coarse: a declared cluster that is running reads
* `good`, one that is not reads `accent` (declared, not created — the same
* word the entity overlay uses for pending). No painting on source-only views
* (`running === undefined`), where nothing else carries status either.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { GraphIR } from "@intentius/chant";

const run = promisify(execFile);

/** `k3d cluster list --no-headers` reduced to name → has a running server. */
export async function runningK3dClusters(
exec: (cmd: string, args: string[]) => Promise<string> = defaultExec,
): Promise<Map<string, boolean> | undefined> {
try {
const out = await exec("k3d", ["cluster", "list", "--no-headers"]);
const clusters = new Map<string, boolean>();
for (const line of out.split(/\r?\n/)) {
const cols = line.trim().split(/\s+/);
if (!cols[0]) continue;
// SERVERS reads `1/1` (running) or `0/1` (stopped). A row without the
// column (older k3d) counts as running — the cluster exists.
const servers = cols[1] ?? "";
const m = /^(\d+)\/(\d+)$/.exec(servers);
clusters.set(cols[0], m ? Number(m[1]) > 0 : true);
}
return clusters;
} catch {
// k3d not installed / docker down — no opinion, never "absent".
return undefined;
}
}

async function defaultExec(cmd: string, args: string[]): Promise<string> {
const { stdout } = await run(cmd, args, { encoding: "utf8", timeout: 10_000 });
return stdout;
}

/** The name a `K3d::Cluster` node declares (`metadata.name`), else its id. */
export function k3dClusterName(node: { id: string; attrs?: Record<string, unknown> }): string {
const meta = node.attrs?.metadata as Record<string, unknown> | undefined;
const name = meta?.name;
return typeof name === "string" && name.length > 0 ? name : node.id;
}

/**
* Merge the cluster root's nodes/edges into `ir`, in place, and paint each
* merged `K3d::Cluster` from `running` when the caller has it. Id collisions
* are skipped (the main graph wins — it may carry live attrs). Returns `ir`.
*/
export function mergeClusterRoot(
ir: GraphIR,
clusterIr: GraphIR | undefined,
running?: Map<string, boolean>,
): GraphIR {
if (!clusterIr) return ir;
const have = new Set(ir.nodes.map((n) => n.id));
for (const node of clusterIr.nodes) {
if (have.has(node.id)) continue;
if (node.kind === "K3d::Cluster" && running !== undefined) {
const attrs = (node.attrs ??= {});
const up = running.get(k3dClusterName(node));
attrs._status = up ? "good" : "accent";
}
ir.nodes.push(node);
have.add(node.id);
}
const haveEdges = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
for (const edge of clusterIr.edges) {
const key = `${edge.from}\0${edge.to}`;
if (haveEdges.has(key)) continue;
haveEdges.add(key);
ir.edges.push(edge);
}
return ir;
}
Loading
Loading