diff --git a/LifeOS/install/LIFEOS/ATLAS/Atlas.ts b/LifeOS/install/LIFEOS/ATLAS/Atlas.ts index 871d2b34a8..f95442a87e 100644 --- a/LifeOS/install/LIFEOS/ATLAS/Atlas.ts +++ b/LifeOS/install/LIFEOS/ATLAS/Atlas.ts @@ -29,6 +29,7 @@ import { launchd } from "./collectors/Launchd"; import { systemd } from "./collectors/Systemd"; import { gear } from "./collectors/Gear"; import { secrets } from "./collectors/Secrets"; +import { docker } from "./collectors/Docker"; // The two service collectors are platform-exclusive: exactly one is ever registered, // so a macOS install's collector set is byte-for-byte what it was before systemd @@ -38,7 +39,7 @@ import { secrets } from "./collectors/Secrets"; const SERVICE_COLLECTOR = process.platform === "linux" ? systemd : launchd; const COLLECTORS: Record = Object.fromEntries( - [cloudflare, github, projects, infraInventory, SERVICE_COLLECTOR, gear, secrets].map((c) => [c.name, c]), + [cloudflare, github, projects, infraInventory, SERVICE_COLLECTOR, gear, secrets, docker].map((c) => [c.name, c]), ); const FULL_SYNC_INTERVAL_MS = 60 * 60 * 1000; diff --git a/LifeOS/install/LIFEOS/ATLAS/collectors/Docker.ts b/LifeOS/install/LIFEOS/ATLAS/collectors/Docker.ts new file mode 100644 index 0000000000..632267fff4 --- /dev/null +++ b/LifeOS/install/LIFEOS/ATLAS/collectors/Docker.ts @@ -0,0 +1,113 @@ +/** + * Docker collector — containers on this machine's Docker daemon. + * + * Two calls: `docker ps -aq` enumerates ALL container IDs (running or not), + * then one `docker inspect` over the batch returns canonical JSON — no + * `--format` templates, whose field names drift across Docker versions. + * Containers are keyed by NAME, not ID: a recreate (compose up, image + * upgrade) rotates the ID but keeps the name, and keying by ID would churn + * a fresh asset per recreate — same reason Launchd.ts keys on Label. + */ + +import { hostname } from "node:os"; +import type { AssetObs, CollectResult, Collector, EdgeObs } from "../Store"; + +type RawContainer = { + Id: string; + Name: string; + Created: string; + State: { Status: string; Running: boolean; StartedAt: string }; + HostConfig: { RestartPolicy?: { Name?: string } }; + Config: { Image: string; Labels?: Record | null }; + NetworkSettings: { + Ports?: Record | null> | null; + Networks?: Record | null; + }; + Mounts?: Array<{ Type?: string; Destination?: string }>; +}; + +// Absent source ⇒ degrade, never throw (ratchet gate 3, same class as a +// missing `gh` in Github.ts). Docker not installed, or installed with the +// daemon stopped, are both normal states on most machines — throwing would +// make `atlas sync` exit non-zero forever on every install without Docker. +const DEGRADED: CollectResult = { complete: false, assets: [], edges: [] }; + +async function run(args: string[]): Promise<{ code: number; out: string } | null> { + try { + const proc = Bun.spawn(["docker", ...args], { stdout: "pipe", stderr: "pipe" }); + const out = await new Response(proc.stdout).text(); + const code = await proc.exited; + return { code, out }; + } catch { + return null; // ENOENT — docker CLI not installed + } +} + +/** "5432/tcp" → ["0.0.0.0:5432->5432/tcp", ...]; exposed-but-unpublished ports stay as "5432/tcp". */ +function portStrings(ports: RawContainer["NetworkSettings"]["Ports"]): string[] { + const result: string[] = []; + for (const [containerPort, bindings] of Object.entries(ports ?? {})) { + if (!bindings || bindings.length === 0) result.push(containerPort); + else for (const b of bindings) result.push(`${b.HostIp}:${b.HostPort}->${containerPort}`); + } + return result; +} + +export const docker: Collector = { + name: "docker", + async collect(): Promise { + const ls = await run(["ps", "-aq", "--no-trunc"]); + // null = CLI absent; non-zero = daemon not running/reachable. Both normal. + if (ls === null || ls.code !== 0) return DEGRADED; + const ids = ls.out.split("\n").map((l) => l.trim()).filter(Boolean); + + const host = hostname(); + const machineKey = `machine:${host}`; + const assets: AssetObs[] = [{ kind: "machine", key: machineKey, name: host }]; + const edges: EdgeObs[] = []; + if (ids.length === 0) return { complete: true, assets, edges }; + + const inspect = await run(["inspect", ...ids]); + if (inspect === null) return DEGRADED; + let raw: RawContainer[]; + try { + raw = JSON.parse(inspect.out) as RawContainer[]; + } catch (error) { + if (inspect.code !== 0) return DEGRADED; // daemon died mid-run — no usable output + // Zero exit with unparseable output is a schema regression, not an + // absent source — throw so it surfaces instead of silently under-counting. + throw new Error(`docker inspect exited 0 but returned unparseable output (contract regression?): ${(error as Error).message}`); + } + // Non-zero inspect exit with parseable output = some containers vanished + // between ps and inspect (normal churn). Keep what came back, never sweep. + const complete = inspect.code === 0; + + for (const c of raw) { + const name = c.Name.replace(/^\//, ""); + const key = `docker:container:${name}`; + assets.push({ + kind: "container", + key, + name, + attrs: { + id: c.Id.slice(0, 12), + image: c.Config.Image, + state: c.State.Status, + running: c.State.Running, + created_at: c.Created, + // Docker stamps never-started containers with a year-1 sentinel. + started_at: c.State.StartedAt?.startsWith("0001-") ? null : c.State.StartedAt, + restart_policy: c.HostConfig.RestartPolicy?.Name || "no", + ports: portStrings(c.NetworkSettings.Ports), + networks: Object.keys(c.NetworkSettings.Networks ?? {}), + // Destinations only — bind-mount sources are host paths and don't + // belong in the exported snapshot. + mounts: (c.Mounts ?? []).map((m) => `${m.Type ?? "?"}:${m.Destination ?? "?"}`), + compose_project: c.Config.Labels?.["com.docker.compose.project"] ?? null, + }, + }); + edges.push({ kind: "RUNS_ON", srcKey: key, dstKey: machineKey, srcKind: "container", dstKind: "machine" }); + } + return { complete, assets, edges }; + }, +}; diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md index c0f7766c77..95a88a07ad 100644 --- a/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md @@ -1,8 +1,8 @@ --- -last_updated: 2026-07-22T04:00:00Z +last_updated: 2026-09-01T19:30:00Z last_updated_by: da convention: pai-freshness-v1 -version: 1.1.4 +version: 1.1.5 --- # Atlas — the LifeOS Asset Graph @@ -52,6 +52,7 @@ Modeled on CNCF Cartography's design (sync-and-expire collectors, per-source obs | `systemd` | `~/.config/systemd/user/com.{lifeos,pai}.*.{service,timer}` (Linux sibling of `launchd` — unit files parsed directly, no subprocess) | services, this machine; RUNS_ON edges | | `gear` | `USER/GEAR.md` tables | devices with category/role | | `secrets` | the incident-response credential registry (`GenerateRegistry.ts --json`) + tier shapes (`DetectCriticalKeys.ts --format json`) | credentials (priority/cadence/vendor/dependencies attrs), orphaned credentials; HOLDS edges from this machine and from the config repo when the env file is tracked in it | +| `docker` | local Docker daemon (`docker ps -aq` + one batched `docker inspect`; degrades when the CLI or daemon is absent) | containers — all states, not just running — with image/state/ports/networks/mounts/restart-policy/compose-project attrs; RUNS_ON edges to this machine | `launchd` and `systemd` are platform-exclusive: `Atlas.ts` registers exactly one of them by `process.platform`, so a macOS install's collector set is unchanged and `atlas sync systemd` there fails as an unknown collector rather than reporting a permanently incomplete run.