mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
77f1611971
* feat: add Compose Network Inspector facts engine Render a stack's authored effective model and pair it with the live Docker snapshot to derive per-stack networking facts: project networks with external and internal flags, service-to-network membership and aliases, published ports with host-binding scope, network_mode, and extra_hosts, plus runtime drift (runtime-only attachments, foreign networks, and declared-but-unused or missing networks). Extend the effective-model parser with service network membership, extra_hosts, and label keys (key names only, never values), and add a key-space normalized network model with adapters from both the rendered model and the raw declared compose so the Inspector and drift share one comparison. Expose GET /api/stacks/:stackName/networking: advisory and read-only, it renders the authored model only and never returns or logs raw stderr, env values, or label values. * feat: store and edit per-stack and per-service exposure intent Add a stack_exposure_intent table (intent values constrained by a CHECK, unique per node, stack, and service) with DAO methods to read, upsert, clear one row, and clear all rows for a stack. The classification is stored independently of the generated networking facts so a later mismatch stays detectable; service rows are kept separately from the stack-level row (service ''). Expose GET and PUT /api/stacks/:stackName/exposure: GET requires read access, PUT requires edit access and validates the intent against the allowed set. Sending intent null clears that row, returning the scope to unset so a service inherits the stack intent again. Intent rows are cleared when the stack is deleted and when the owning node is removed, so a later same-named stack never picks up stale classification. * feat: add exposure-aware Compose Doctor findings Feed the Compose Doctor's effective-model context with the stored exposure intent (resolved into a stack-level value plus per-service overrides) and the dossier's documented access-URL ports, read fail-soft so a metadata read error skips these checks rather than failing the preflight. Add five deterministic findings on top of that context: - a service classified internal or same-node that publishes a host port (same-node tolerates a loopback bind), - a sensitive database or admin image published on all interfaces, - a port-publishing stack with no exposure intent set, - a published port not reflected in the documented access URLs, - reverse-proxy labels with no documented URL or reverse-proxy intent. The rules stay pure functions over the preflight context; the registry completeness test pins the new rule set. * feat: detect compose network drift in the drift ledger Extend the spatial drift engine with two network-level findings: a running container attached to a stack-owned or foreign network that compose does not declare (one finding per service), and a declared network that no running service uses or that is absent from the runtime (one stack-level finding, every network named by its resolved runtime name). The comparison reuses the same helper the Network Inspector uses, so the two surfaces never disagree. Network drift runs only when the stack has running containers and the runtime is reachable, preserving the existing missing-runtime, parse-error, and unreachable behavior. The findings persist through the existing drift ledger and surface on the Drift tab, which now labels the two new kinds. * feat: link a Docker network back to its owning stack Add a cross-component open-stack event and make the owning-stack badge on a managed network in Resources a link: clicking it loads that stack on its node and opens the editor, reusing the existing fleet navigation. A latest-ref keeps the window listener current without re-subscribing each render. Image and volume badges are unchanged; only a managed network opts in via the new optional handler. * feat: add the Networking tab to the stack detail panel Add a capability-gated Networking tab that reads the per-stack networking facts and exposure intent. It shows the project networks (with external, internal, and created-by-stack flags), per-service network membership and aliases, published ports with their host-binding scope, network_mode and extra_hosts, and runtime drift, degrading to the declared model when the runtime is unavailable. Users can classify the stack and each service (internal, LAN, reverse proxy, public, and so on) or clear a row to inherit; the controls are read-only when the user cannot edit, and a broken exposure response never tears down the facts view. A new compose-networking capability is added to both registries so older nodes hide the tab, and the tab cross-links to the Doctor for the deploy and security findings. * docs: document the Compose Networking tab Add a feature page covering the Networking tab: the network facts, published ports and host bindings, the exposure-intent classification and inheritance, the exposure-aware Doctor findings, runtime drift, and a troubleshooting section. Register it in the docs navigation next to Compose Doctor. * feat: add a redacted network summary to the Stack Dossier export Append a network exposure section to the dossier Markdown: the stack and per-service exposure intents, the networks with their external and internal flags, and each service's published ports with their binding scope. It carries only names, intents, port numbers, and scope, never an env value or a label value. The summary is fetched only when the user exports (copy or download), so opening the panel costs nothing, and it degrades to omitting the section when the data is unavailable. The whole-fleet dossier export collects the same summary per stack, rethrowing the unauthorized sentinel like the sibling loaders. * feat: add a Fleet networking filter for exposure and drift Add a per-node networking summary that classifies a node's stacks as exposed (a host port published beyond loopback), unknown-exposure (publishes ports with no exposure intent set), or network-drift. It reads each stack's compose with the light dependency parser and one Docker snapshot, so it stays cheap across a node's full stack set, and it skips drift when the runtime is unreachable rather than inventing it. Serve it node-locally at GET /api/networking/summary, and aggregate it fleet-wide at GET /api/fleet/networking-summary: the hub computes its own summary in-process and reaches each remote through its node-local route, degrading an unreachable or older node to a skip. Because the aggregate lives under the proxy-exempt /api/fleet prefix it is never wrongly proxied. The Fleet overview gains a networking filter chip backed by that aggregate, fetched fail-soft and detached so it never gates the grid. * fix: spin the Networking refresh button while it reloads The refresh button silently refetched the same data, so a click gave no feedback. Track a refreshing state and spin the icon while the load is in flight, disabling the button, matching the Compose Doctor preflight button. * fix: apply effective per-service exposure intent to unclassified checks The "unclassified exposure" decisions only consulted the stack-level intent row, so a service classified directly (with no stack row) was still reported as unclassified, and a service explicitly marked unknown over a classified stack was missed. Both the exposure-unclassified preflight rule and the networking summary's unknown-exposure bucket now resolve the effective intent per publishing service (service row overrides stack row), matching the precedence already used by the exposure-internal-published rule. * fix: resolve drift network names via the compose top-level name When a compose file sets a top-level name:, Docker prefixes resource names with that project name instead of the stack directory. The light dependency parser dropped name:, so network-drift normalization compared runtime networks against directory-prefixed names and reported false network-undeclared / network-missing findings. Carry the parsed project name through DeclaredCompose and use it when normalizing declared networks for drift, while still filtering containers by the stack directory.
259 lines
9.9 KiB
TypeScript
259 lines
9.9 KiB
TypeScript
/**
|
|
* Deterministic Markdown builder for the whole-fleet dossier export.
|
|
*
|
|
* Fans the per-stack Stack Dossier generator across every node and stack in the
|
|
* fleet and adds fleet-level index and network/port/volume maps, producing a
|
|
* folder of Markdown files an operator can commit to Git or store alongside
|
|
* backups. Pure and side-effect free: the same input always yields the same
|
|
* file map.
|
|
*
|
|
* Like the generators it reuses, it only ever receives env variable names and
|
|
* counts, never `.env` values, and the operator-note fields carry no secrets,
|
|
* so nothing sensitive can leak into the export.
|
|
*/
|
|
|
|
import type { AnatomyMarkdownInput } from './anatomyMarkdown';
|
|
import { buildStackDossierMarkdown, operatorNotesSection, type StackDossierFields } from './dossierMarkdown';
|
|
import type { NetworkExposureSummary } from './networkExposureSummary';
|
|
|
|
export interface FleetDossierStack {
|
|
stackName: string;
|
|
/** Generated anatomy, or null when the stack's compose.yaml could not be parsed. */
|
|
anatomy: AnatomyMarkdownInput | null;
|
|
dossier: StackDossierFields;
|
|
/** Redacted networking + exposure summary, or null when unavailable. */
|
|
networking?: NetworkExposureSummary | null;
|
|
}
|
|
|
|
interface FleetDossierNodeBase {
|
|
id: number;
|
|
name: string;
|
|
type: 'local' | 'remote';
|
|
}
|
|
|
|
/**
|
|
* A node in the export: either reachable with its stacks, or skipped with a
|
|
* reason. Modelled as a discriminated union so an unreachable node can never
|
|
* carry stacks and a reachable node always has them.
|
|
*/
|
|
export type FleetDossierNode =
|
|
| (FleetDossierNodeBase & { reachable: true; stacks: FleetDossierStack[] })
|
|
| (FleetDossierNodeBase & { reachable: false; skipReason: string });
|
|
|
|
export interface FleetDossierInput {
|
|
/** ISO timestamp the export was generated. */
|
|
generatedAt: string;
|
|
senchoVersion: string;
|
|
nodes: FleetDossierNode[];
|
|
}
|
|
|
|
/** Slugify a node or stack name into a safe, lowercase filename segment. */
|
|
function slugify(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/^[-.]+|[-.]+$/g, '') || 'unnamed';
|
|
}
|
|
|
|
// Escape a value for a Markdown table cell: backslash first (so it cannot defeat
|
|
// the pipe escaping), then pipes, then collapse line breaks onto one line.
|
|
function cell(value: string): string {
|
|
return value
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/\|/g, '\\|')
|
|
.replace(/\r\n?|\n/g, ' ');
|
|
}
|
|
|
|
/** Collapse a multi-line operator field to a single line for table display. */
|
|
function inline(value: string): string {
|
|
return value.trim().replace(/\s*\r?\n\s*/g, ' · ');
|
|
}
|
|
|
|
/** Assign each node a unique slug, disambiguating collisions with the node id. */
|
|
function nodeSlugs(nodes: FleetDossierNode[]): Map<number, string> {
|
|
const used = new Set<string>();
|
|
const map = new Map<number, string>();
|
|
for (const node of nodes) {
|
|
let slug = slugify(node.name);
|
|
if (used.has(slug)) slug = `${slug}-${node.id}`;
|
|
used.add(slug);
|
|
map.set(node.id, slug);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Map each (distinct) stack name on one node to a unique slug, disambiguating
|
|
* collisions with a numeric suffix. Stack names are unique per node, but two
|
|
* names can slugify to the same value (e.g. `Web` and `web` on a case-sensitive
|
|
* host), which would otherwise overwrite a stack's page in the file map.
|
|
*/
|
|
function stackSlugs(names: string[]): Map<string, string> {
|
|
const used = new Set<string>();
|
|
const map = new Map<string, string>();
|
|
for (const name of names) {
|
|
let slug = slugify(name);
|
|
if (used.has(slug)) {
|
|
let i = 2;
|
|
while (used.has(`${slug}-${i}`)) i++;
|
|
slug = `${slug}-${i}`;
|
|
}
|
|
used.add(slug);
|
|
map.set(name, slug);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function stackPageMarkdown(stack: FleetDossierStack): string {
|
|
if (stack.anatomy) {
|
|
return `${buildStackDossierMarkdown(stack.anatomy, stack.dossier, stack.networking ?? null)}\n`;
|
|
}
|
|
// Compose could not be parsed: keep the operator's notes rather than dropping
|
|
// the stack from the export entirely.
|
|
const notes = operatorNotesSection(stack.dossier);
|
|
const body = `# ${stack.stackName}\n\n_compose.yaml could not be parsed; showing operator notes only._`;
|
|
return `${notes ? `${body}\n\n${notes}` : body}\n`;
|
|
}
|
|
|
|
function nodePageMarkdown(node: FleetDossierNode, slug: string, slugForStack: Map<string, string>): string {
|
|
const lines = [`# ${node.name}`, ''];
|
|
lines.push(`- **Type:** ${node.type}`);
|
|
lines.push(`- **Status:** ${node.reachable ? 'reachable' : 'unreachable'}`);
|
|
if (!node.reachable) {
|
|
lines.push(`- **Skipped:** ${node.skipReason}`);
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
lines.push(`- **Stacks:** ${node.stacks.length}`);
|
|
lines.push('', '## Stacks');
|
|
if (node.stacks.length === 0) {
|
|
lines.push('_none_');
|
|
} else {
|
|
for (const stack of node.stacks) {
|
|
lines.push(`- [${stack.stackName}](../stacks/${slug}--${slugForStack.get(stack.stackName)}.md)`);
|
|
}
|
|
}
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
function indexMarkdown(input: FleetDossierInput, slugs: Map<number, string>): string {
|
|
const lines = ['# Homelab Dossier', '', `_Generated ${input.generatedAt} · Sencho ${input.senchoVersion}_`, ''];
|
|
|
|
lines.push('## Nodes', '', '| Node | Type | Status | Stacks |', '| --- | --- | --- | --- |');
|
|
for (const node of input.nodes) {
|
|
const slug = slugs.get(node.id)!;
|
|
const status = node.reachable ? 'reachable' : 'unreachable';
|
|
const count = node.reachable ? String(node.stacks.length) : '-';
|
|
lines.push(`| [${cell(node.name)}](nodes/${slug}.md) | ${node.type} | ${status} | ${count} |`);
|
|
}
|
|
|
|
if (input.nodes.some(n => !n.reachable)) {
|
|
lines.push('', '## Skipped nodes', '');
|
|
for (const node of input.nodes) {
|
|
if (node.reachable) continue;
|
|
lines.push(`- **${node.name}** (${node.type}): ${node.skipReason}`);
|
|
}
|
|
}
|
|
|
|
lines.push('', '## Network maps', '', '- [Port, volume, and network maps](network.md)');
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
interface StackRef { nodeName: string; stack: FleetDossierStack; }
|
|
|
|
function reachableStacks(input: FleetDossierInput): StackRef[] {
|
|
const refs: StackRef[] = [];
|
|
for (const node of input.nodes) {
|
|
if (!node.reachable) continue;
|
|
for (const stack of node.stacks) refs.push({ nodeName: node.name, stack });
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function table(header: string[], rows: string[][]): string {
|
|
if (rows.length === 0) return '_none_';
|
|
const head = `| ${header.join(' | ')} |`;
|
|
const sep = `| ${header.map(() => '---').join(' | ')} |`;
|
|
const body = rows.map(r => `| ${r.map(cell).join(' | ')} |`);
|
|
return [head, sep, ...body].join('\n');
|
|
}
|
|
|
|
function networkMarkdown(input: FleetDossierInput): string {
|
|
const refs = reachableStacks(input);
|
|
const lines = ['# Network Maps', '', `_Generated ${input.generatedAt} · Sencho ${input.senchoVersion}_`, ''];
|
|
|
|
const portRows: string[][] = [];
|
|
const volumeRows: string[][] = [];
|
|
const networkRows: string[][] = [];
|
|
const envRows: string[][] = [];
|
|
const accessRows: string[][] = [];
|
|
const infraRows: string[][] = [];
|
|
|
|
for (const { nodeName, stack } of refs) {
|
|
const { anatomy, dossier, stackName } = stack;
|
|
if (anatomy) {
|
|
for (const [svc, list] of Object.entries(anatomy.ports)) {
|
|
for (const p of list) portRows.push([nodeName, stackName, svc, p.host, p.container, p.proto]);
|
|
}
|
|
for (const [svc, list] of Object.entries(anatomy.volumes)) {
|
|
for (const v of list) volumeRows.push([nodeName, stackName, svc, v.host, v.container]);
|
|
}
|
|
networkRows.push([nodeName, stackName, anatomy.networkName]);
|
|
envRows.push([
|
|
nodeName,
|
|
stackName,
|
|
anatomy.envFile ?? 'none',
|
|
String(anatomy.envVarCount),
|
|
anatomy.missingVars.length > 0 ? anatomy.missingVars.join(', ') : 'none',
|
|
]);
|
|
}
|
|
const accessUrls = inline(dossier.access_urls);
|
|
if (accessUrls) accessRows.push([nodeName, stackName, accessUrls]);
|
|
if (dossier.static_ip.trim() || dossier.vlan.trim() || dossier.firewall_notes.trim()) {
|
|
infraRows.push([
|
|
nodeName,
|
|
stackName,
|
|
dossier.static_ip.trim() || '-',
|
|
dossier.vlan.trim() || '-',
|
|
inline(dossier.firewall_notes) || '-',
|
|
]);
|
|
}
|
|
}
|
|
|
|
lines.push('## Port map', '', table(['Node', 'Stack', 'Service', 'Host', 'Container', 'Protocol'], portRows), '');
|
|
lines.push('## Volume map', '', table(['Node', 'Stack', 'Service', 'Host', 'Container'], volumeRows), '');
|
|
lines.push('## Network map', '', table(['Node', 'Stack', 'Network'], networkRows), '');
|
|
lines.push('## Environment checklist', '', table(['Node', 'Stack', 'Env file', 'Variables', 'Missing'], envRows), '');
|
|
lines.push('## Access URLs', '', table(['Node', 'Stack', 'URLs'], accessRows), '');
|
|
lines.push('## VLAN / static IP / firewall', '', table(['Node', 'Stack', 'Static IP', 'VLAN', 'Firewall'], infraRows));
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
/**
|
|
* Build the full fleet dossier as a map of relative file path to Markdown
|
|
* content, ready to zip. Always emits `index.md` and `network.md`; emits one
|
|
* `nodes/<slug>.md` per node and one `stacks/<node>--<stack>.md` per stack on a
|
|
* reachable node.
|
|
*/
|
|
export function buildFleetDossier(input: FleetDossierInput): Record<string, string> {
|
|
const slugs = nodeSlugs(input.nodes);
|
|
const files: Record<string, string> = {
|
|
'index.md': indexMarkdown(input, slugs),
|
|
'network.md': networkMarkdown(input),
|
|
};
|
|
|
|
for (const node of input.nodes) {
|
|
const slug = slugs.get(node.id)!;
|
|
// One stack-slug map per node, shared by the node-page links and the file
|
|
// emission below so a slug collision never points a link at the wrong page
|
|
// or silently overwrites a stack's file.
|
|
const slugForStack = node.reachable ? stackSlugs(node.stacks.map(s => s.stackName)) : new Map<string, string>();
|
|
files[`nodes/${slug}.md`] = nodePageMarkdown(node, slug, slugForStack);
|
|
if (!node.reachable) continue;
|
|
for (const stack of node.stacks) {
|
|
files[`stacks/${slug}--${slugForStack.get(stack.stackName)}.md`] = stackPageMarkdown(stack);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|