mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
feat: Compose Network Inspector and exposure intent guard (#1360)
* 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.
This commit is contained in:
@@ -221,3 +221,32 @@ describe('node deletion cleanup', () => {
|
||||
expect(db().getPreflightFindings('run-x')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exposure state feeds the exposure rules end to end', () => {
|
||||
const ruleIds = (stack: string) => doctor().getLatest(nodeId, stack).findings.map(f => f.ruleId);
|
||||
|
||||
afterEach(() => {
|
||||
db().deleteStackExposureIntents(nodeId, 'expe2e');
|
||||
db().deleteStackDossier(nodeId, 'expe2e');
|
||||
fs.rmSync(path.join(process.env.COMPOSE_DIR as string, 'expe2e'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fires exposure-internal-published from a stored stack intent', async () => {
|
||||
writeStack('expe2e');
|
||||
db().setStackExposureIntent(nodeId, 'expe2e', '', 'internal', 'tester');
|
||||
stubDocker({ name: 'expe2e', services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} });
|
||||
await doctor().runPreflight(nodeId, 'expe2e', 'tester');
|
||||
expect(ruleIds('expe2e')).toContain('exposure-internal-published');
|
||||
});
|
||||
|
||||
it('fires exposure-port-vs-dossier from the dossier access URLs', async () => {
|
||||
writeStack('expe2e');
|
||||
db().upsertStackDossier(nodeId, 'expe2e', {
|
||||
purpose: '', owner: '', access_urls: 'https://app.example.com:443', static_ip: '', vlan: '',
|
||||
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
|
||||
});
|
||||
stubDocker({ name: 'expe2e', services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} });
|
||||
await doctor().runPreflight(nodeId, 'expe2e', 'tester');
|
||||
expect(ruleIds('expe2e')).toContain('exposure-port-vs-dossier');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* The Compose Network Inspector: the normalized-model adapters (rendered vs raw
|
||||
* declared produce the same shape), the pure facts assembler, and the
|
||||
* runtime-vs-Compose drift comparison (system/default/external networks and
|
||||
* stopped containers are not flagged).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel';
|
||||
import type { DeclaredCompose } from '../helpers/composeDependencyParse';
|
||||
import type { DependencySnapshot, DependencyContainer, DependencyNetwork } from '../services/DockerController';
|
||||
import {
|
||||
fromEffectiveModel, fromDeclaredCompose, compareStackNetworks, runtimeResourceName, parseAccessUrlPorts,
|
||||
} from '../services/network/normalize';
|
||||
import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
|
||||
function effSvc(over: Partial<EffService> = {}): EffService {
|
||||
return {
|
||||
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
|
||||
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [], ...over,
|
||||
};
|
||||
}
|
||||
|
||||
function container(over: Partial<DependencyContainer> = {}): DependencyContainer {
|
||||
return {
|
||||
id: 'c1', name: 'web1', service: 'web', composeProject: 'myapp', stack: 'myapp',
|
||||
state: 'running', image: 'nginx:1.27', networks: [], volumes: [], ports: [], ...over,
|
||||
};
|
||||
}
|
||||
|
||||
function depNet(over: Partial<DependencyNetwork> = {}): DependencyNetwork {
|
||||
return { id: 'n', name: 'myapp_backend', driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'myapp', stack: 'myapp', ...over };
|
||||
}
|
||||
|
||||
describe('normalized-model adapters', () => {
|
||||
it('rendered and raw declared models normalize to the same shape', () => {
|
||||
const eff: EffectiveModel = {
|
||||
projectName: 'myapp',
|
||||
services: [effSvc({ name: 'web', networks: [{ key: 'backend', aliases: [] }, { key: 'shared', aliases: [] }] })],
|
||||
networks: {
|
||||
backend: { name: 'myapp_backend', external: false, internal: false },
|
||||
shared: { name: 'shared_net', external: true, internal: false },
|
||||
custom: { name: 'custom_name', external: false, internal: false },
|
||||
},
|
||||
volumes: {},
|
||||
};
|
||||
const declared: DeclaredCompose = {
|
||||
services: [{ name: 'web', dependsOn: [], networks: ['backend', 'shared'], volumes: [], ports: [] }],
|
||||
networks: {
|
||||
backend: { external: false },
|
||||
shared: { external: true, name: 'shared_net' },
|
||||
custom: { external: false, name: 'custom_name' },
|
||||
},
|
||||
volumes: {},
|
||||
};
|
||||
expect(fromDeclaredCompose(declared, 'myapp')).toEqual(fromEffectiveModel(eff));
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleStackNetworkFacts', () => {
|
||||
const model: EffectiveModel = {
|
||||
projectName: 'myapp',
|
||||
services: [effSvc({
|
||||
name: 'web',
|
||||
networks: [{ key: 'backend', aliases: ['www'] }],
|
||||
extraHosts: ['host.docker.internal:host-gateway'],
|
||||
ports: [
|
||||
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
|
||||
{ startPort: 9000, endPort: 9000, hostIp: '127.0.0.1', protocol: 'tcp' },
|
||||
],
|
||||
})],
|
||||
networks: {
|
||||
default: { name: 'myapp_default', external: false, internal: false },
|
||||
backend: { name: 'myapp_backend', external: false, internal: true },
|
||||
shared: { name: 'shared_net', external: true, internal: false },
|
||||
},
|
||||
volumes: {},
|
||||
};
|
||||
|
||||
it('reports networks with external/internal/createdByStack flags', () => {
|
||||
const facts = assembleStackNetworkFacts('myapp', model, null, null);
|
||||
expect(facts.renderable).toBe(true);
|
||||
expect(facts.networks).toEqual([
|
||||
{ key: 'default', name: 'myapp_default', external: false, internal: false, createdByStack: false },
|
||||
{ key: 'backend', name: 'myapp_backend', external: false, internal: true, createdByStack: true },
|
||||
{ key: 'shared', name: 'shared_net', external: true, internal: false, createdByStack: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports service membership, aliases, extra_hosts, and port binding flags', () => {
|
||||
const svc = assembleStackNetworkFacts('myapp', model, null, null).services[0];
|
||||
expect(svc.networks).toEqual([{ key: 'backend', aliases: ['www'] }]);
|
||||
expect(svc.extraHosts).toEqual(['host.docker.internal:host-gateway']);
|
||||
expect(svc.publishedPorts[0]).toMatchObject({ startPort: 8080, allInterfaces: true, loopbackOnly: false });
|
||||
expect(svc.publishedPorts[1]).toMatchObject({ startPort: 9000, allInterfaces: false, loopbackOnly: true });
|
||||
});
|
||||
|
||||
it('marks the runtime unavailable and leaves drift empty when there is no snapshot', () => {
|
||||
const facts = assembleStackNetworkFacts('myapp', model, null, null);
|
||||
expect(facts.runtime).toBe('unavailable');
|
||||
expect(facts.drift.runtimeOnlyAttachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a non-renderable facts payload when the model is null', () => {
|
||||
const facts = assembleStackNetworkFacts('myapp', null, 'render failed', null);
|
||||
expect(facts.renderable).toBe(false);
|
||||
expect(facts.renderError).toBe('render failed');
|
||||
expect(facts.networks).toEqual([]);
|
||||
});
|
||||
|
||||
it('computes real drift through to the payload when a snapshot is present', () => {
|
||||
const snapshot: DependencySnapshot = {
|
||||
containers: [container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_rogue', id: 'b', ip: '' }] })],
|
||||
networks: [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_rogue' })],
|
||||
volumes: [],
|
||||
};
|
||||
const facts = assembleStackNetworkFacts('myapp', model, null, snapshot);
|
||||
expect(facts.runtime).toBe('available');
|
||||
expect(facts.drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_rogue' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtimeResourceName', () => {
|
||||
it('uses a name override, else the project prefix', () => {
|
||||
expect(runtimeResourceName('myapp', 'backend', undefined)).toBe('myapp_backend');
|
||||
expect(runtimeResourceName('myapp', 'backend', 'backend')).toBe('myapp_backend'); // name == key is not an override
|
||||
expect(runtimeResourceName('myapp', 'shared', 'shared_net')).toBe('shared_net');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAccessUrlPorts', () => {
|
||||
it('extracts host ports from access-URL text', () => {
|
||||
expect([...parseAccessUrlPorts('http://host:8080/path and https://host:443')].sort((a, b) => a - b)).toEqual([443, 8080]);
|
||||
});
|
||||
it('finds no port when the URL has none (implicit scheme port)', () => {
|
||||
expect([...parseAccessUrlPorts('https://app.example.com/dashboard')]).toEqual([]);
|
||||
});
|
||||
it('rejects out-of-range numbers and returns an empty set for empty input', () => {
|
||||
expect([...parseAccessUrlPorts('http://host:99999')]).toEqual([]);
|
||||
expect([...parseAccessUrlPorts('')]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStackNetworks', () => {
|
||||
const declared = fromEffectiveModel({
|
||||
projectName: 'myapp',
|
||||
services: [],
|
||||
networks: {
|
||||
backend: { name: 'myapp_backend', external: false, internal: false },
|
||||
shared: { name: 'shared_net', external: true, internal: false },
|
||||
},
|
||||
volumes: {},
|
||||
});
|
||||
|
||||
function snapshot(containers: DependencyContainer[], networks: DependencyNetwork[]): DependencySnapshot {
|
||||
return { containers, networks, volumes: [] };
|
||||
}
|
||||
|
||||
it('flags a runtime-only attachment to a stack-owned undeclared network', () => {
|
||||
const snap = snapshot(
|
||||
[container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_extra', id: 'b', ip: '' }] })],
|
||||
[depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_extra' }), depNet({ name: 'shared_net', stack: null })],
|
||||
);
|
||||
const drift = compareStackNetworks(declared, snap, 'myapp');
|
||||
expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_extra' }]);
|
||||
});
|
||||
|
||||
it('flags a foreign network owned by another stack', () => {
|
||||
const snap = snapshot(
|
||||
[container({ networks: [{ name: 'other_net', id: 'x', ip: '' }] })],
|
||||
[depNet({ name: 'other_net', stack: 'other', composeProject: 'other' })],
|
||||
);
|
||||
const drift = compareStackNetworks(declared, snap, 'myapp');
|
||||
expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'other_net' }]);
|
||||
});
|
||||
|
||||
it('treats a stack-owned network with no project prefix as runtime-only (ownership via snapshot.stack)', () => {
|
||||
const snap = snapshot(
|
||||
[container({ networks: [{ name: 'odd-named-net', id: 'x', ip: '' }] })],
|
||||
[depNet({ name: 'odd-named-net', stack: 'myapp', composeProject: 'myapp' })],
|
||||
);
|
||||
const drift = compareStackNetworks(declared, snap, 'myapp');
|
||||
expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'odd-named-net' }]);
|
||||
expect(drift.foreignNetworkAttachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats an attachment to a network absent from the snapshot as foreign', () => {
|
||||
const snap = snapshot(
|
||||
[container({ networks: [{ name: 'ghost-net', id: 'x', ip: '' }] })],
|
||||
[], // ghost-net is not in the snapshot network list
|
||||
);
|
||||
const drift = compareStackNetworks(declared, snap, 'myapp');
|
||||
expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'ghost-net' }]);
|
||||
});
|
||||
|
||||
it('drives the declared-compose adapter through the comparison (name override resolves)', () => {
|
||||
const declaredFromCompose = fromDeclaredCompose({
|
||||
services: [{ name: 'web', dependsOn: [], networks: ['edge'], volumes: [], ports: [] }],
|
||||
networks: { edge: { external: false, name: 'edge_override' } },
|
||||
volumes: {},
|
||||
}, 'myapp');
|
||||
const snap = snapshot(
|
||||
[container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })],
|
||||
[depNet({ name: 'myapp_default' })],
|
||||
);
|
||||
// edge_override is declared but missing from the runtime.
|
||||
expect(compareStackNetworks(declaredFromCompose, snap, 'myapp').missingFromRuntime).toEqual(['edge_override']);
|
||||
});
|
||||
|
||||
it('ignores system networks, the default network, and external networks', () => {
|
||||
const snap = snapshot(
|
||||
[container({ networks: [
|
||||
{ name: 'bridge', id: 's', ip: '' },
|
||||
{ name: 'myapp_default', id: 'd', ip: '' },
|
||||
{ name: 'shared_net', id: 'e', ip: '' },
|
||||
] })],
|
||||
[depNet({ name: 'bridge', isSystem: true, stack: null }), depNet({ name: 'myapp_default' }), depNet({ name: 'shared_net', stack: null })],
|
||||
);
|
||||
const drift = compareStackNetworks(declared, snap, 'myapp');
|
||||
expect(drift.runtimeOnlyAttachments).toEqual([]);
|
||||
expect(drift.foreignNetworkAttachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not flag attachments from stopped containers', () => {
|
||||
const snap = snapshot(
|
||||
[container({ state: 'exited', networks: [{ name: 'myapp_extra', id: 'b', ip: '' }] })],
|
||||
[depNet({ name: 'myapp_extra' })],
|
||||
);
|
||||
expect(compareStackNetworks(declared, snap, 'myapp').runtimeOnlyAttachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a declared network no running service uses', () => {
|
||||
const snap = snapshot(
|
||||
[container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })],
|
||||
[depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_default' })],
|
||||
);
|
||||
expect(compareStackNetworks(declared, snap, 'myapp').declaredButUnused).toEqual(['backend']);
|
||||
});
|
||||
|
||||
it('reports a declared network missing from the runtime', () => {
|
||||
const snap = snapshot(
|
||||
[container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })],
|
||||
[depNet({ name: 'myapp_default' })],
|
||||
);
|
||||
expect(compareStackNetworks(declared, snap, 'myapp').missingFromRuntime).toEqual(['myapp_backend']);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
buildStackDriftReport,
|
||||
} from '../services/DriftDetectionService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import type { DependencyContainer, DependencySnapshot } from '../services/DockerController';
|
||||
import type { DependencyContainer, DependencyNetwork, DependencySnapshot } from '../services/DockerController';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import type { DeclaredCompose, DeclaredService, DeclaredPort } from '../helpers/composeDependencyParse';
|
||||
|
||||
@@ -250,6 +250,107 @@ describe('assembleStackDrift - findings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── assembleStackDrift: network drift ─────────────────────────────────────
|
||||
|
||||
function depNet(name: string, p: Partial<DependencyNetwork> = {}): DependencyNetwork {
|
||||
return { id: name, name, driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'app', stack: 'app', ...p };
|
||||
}
|
||||
|
||||
describe('assembleStackDrift - network drift', () => {
|
||||
it('flags a runtime-only attachment to a stack-owned undeclared network', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([service({ name: 'web' })]),
|
||||
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_default', id: 'd', ip: '' }, { name: 'app_extra', id: 'e', ip: '' }] })],
|
||||
networks: [depNet('app_default'), depNet('app_extra')],
|
||||
});
|
||||
const f = report.findings.find(x => x.kind === 'network-undeclared');
|
||||
expect(f).toMatchObject({ service: 'web', actual: 'app_extra' });
|
||||
expect(f?.detail).not.toContain('app_default'); // the implicit default is declared
|
||||
});
|
||||
|
||||
it('maps a foreign network attachment back to its service (not the container name)', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([service({ name: 'web' })]),
|
||||
// Distinct container name vs service proves the service-map lookup ran, not the name fallback.
|
||||
containers: [container({ id: 'c1', name: 'app-web-1', service: 'web', networks: [{ name: 'other_net', id: 'o', ip: '' }] })],
|
||||
networks: [depNet('other_net', { stack: 'other', composeProject: 'other' })],
|
||||
});
|
||||
expect(report.findings.find(x => x.kind === 'network-undeclared')).toMatchObject({ service: 'web', actual: 'other_net' });
|
||||
});
|
||||
|
||||
it('aggregates multiple undeclared networks on one service into a single finding', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([service({ name: 'web' })]),
|
||||
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_extra1', id: '1', ip: '' }, { name: 'app_extra2', id: '2', ip: '' }] })],
|
||||
networks: [depNet('app_extra1'), depNet('app_extra2')],
|
||||
});
|
||||
const f = report.findings.filter(x => x.kind === 'network-undeclared');
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0]).toMatchObject({ service: 'web', actual: 'app_extra1, app_extra2' });
|
||||
expect(f[0].detail).toContain('networks not declared'); // plural wording
|
||||
});
|
||||
|
||||
it('flags a declared network that no running service uses, by its runtime name', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: { services: [service({ name: 'web', networks: ['frontend'] })], networks: { frontend: { external: false }, backend: { external: false } }, volumes: {} },
|
||||
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_frontend', id: 'f', ip: '' }] })],
|
||||
networks: [depNet('app_frontend'), depNet('app_backend')],
|
||||
});
|
||||
expect(report.findings.find(x => x.kind === 'network-missing')).toMatchObject({ service: '', expected: 'app_backend' });
|
||||
});
|
||||
|
||||
it('reports unused and absent declared networks together in one consistent namespace', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: { services: [service({ name: 'web', networks: ['frontend'] })], networks: { frontend: { external: false }, backend: { external: false }, gamma: { external: false } }, volumes: {} },
|
||||
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_frontend', id: 'f', ip: '' }] })],
|
||||
// app_backend exists but is unused; app_gamma is absent from the runtime.
|
||||
networks: [depNet('app_frontend'), depNet('app_backend')],
|
||||
});
|
||||
const f = report.findings.filter(x => x.kind === 'network-missing');
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].expected).toBe('app_backend, app_gamma'); // both as runtime names, not mixed keys
|
||||
});
|
||||
|
||||
it('ignores system and default networks', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([service({ name: 'web' })]),
|
||||
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'bridge', id: 'b', ip: '' }, { name: 'app_default', id: 'd', ip: '' }] })],
|
||||
networks: [depNet('bridge', { isSystem: true, stack: null }), depNet('app_default')],
|
||||
});
|
||||
expect(report.findings.filter(x => x.kind.startsWith('network-'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports no network drift for a stopped stack', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([service({ name: 'web' })]),
|
||||
containers: [container({ id: 'c1', service: 'web', state: 'exited', networks: [{ name: 'app_extra', id: 'e', ip: '' }] })],
|
||||
networks: [depNet('app_extra')],
|
||||
});
|
||||
expect(report.status).toBe('missing-runtime');
|
||||
expect(report.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves runtime network names via the compose top-level name (no false drift)', () => {
|
||||
// Stack dir is "app" but the compose declares `name: acme`, so Docker names
|
||||
// the network acme_backend. With the project name carried through, that
|
||||
// matches and produces no network-undeclared / network-missing.
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: { services: [service({ name: 'web', networks: ['backend'] })], networks: { backend: { external: false } }, volumes: {}, projectName: 'acme' },
|
||||
containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'acme_default', id: 'd', ip: '' }, { name: 'acme_backend', id: 'b', ip: '' }] })],
|
||||
networks: [depNet('acme_default'), depNet('acme_backend')],
|
||||
});
|
||||
expect(report.findings.filter(f => f.kind.startsWith('network-'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── normalizeImageRef ─────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeImageRef', () => {
|
||||
@@ -321,4 +422,23 @@ describe('buildStackDriftReport - boundaries', () => {
|
||||
expect(findingKinds(report)).toEqual(['image-mismatch']);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('threads the snapshot networks through into a network-undeclared finding', async () => {
|
||||
const snapshot: DependencySnapshot = {
|
||||
containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.25', networks: [{ name: 'app_rogue', id: 'r', ip: '' }] })],
|
||||
networks: [depNet('app_rogue')],
|
||||
volumes: [],
|
||||
};
|
||||
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
|
||||
getStackContent: vi.fn().mockResolvedValue('services:\n web:\n image: nginx:1.25\n'),
|
||||
getStacks: vi.fn().mockResolvedValue(['app']),
|
||||
} as unknown as FileSystemService);
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue(snapshot),
|
||||
} as unknown as DockerController);
|
||||
|
||||
const report = await buildStackDriftReport(0, 'app');
|
||||
expect(findingKinds(report)).toContain('network-undeclared');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* GET/PUT /api/stacks/:stackName/exposure: read and write the per-stack and
|
||||
* per-service exposure classification. PUT requires write access, validates the
|
||||
* intent value, and supports clearing a row (intent null) so a service inherits
|
||||
* the stack intent again.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let readOnlyToken: string;
|
||||
|
||||
const STACK = 'exproute';
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
readOnlyToken = generateApiToken();
|
||||
db.addApiToken({
|
||||
token_hash: crypto.createHash('sha256').update(readOnlyToken).digest('hex'),
|
||||
name: 'exposure-readonly', scope: 'read-only',
|
||||
user_id: db.getUserByUsername(TEST_USERNAME)!.id, created_at: Date.now(), expires_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('exposure intent routes', () => {
|
||||
let stackDir: string;
|
||||
beforeEach(() => {
|
||||
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
|
||||
});
|
||||
afterEach(() => {
|
||||
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const put = (body: object) => request(app).put(`/api/stacks/${STACK}/exposure`).set('Authorization', authHeader).send(body);
|
||||
const get = () => request(app).get(`/api/stacks/${STACK}/exposure`).set('Authorization', authHeader);
|
||||
|
||||
it('starts with no intents', async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.intents).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets a stack-level and a per-service intent and records the author', async () => {
|
||||
expect((await put({ intent: 'internal' })).status).toBe(200);
|
||||
const res = await put({ service: 'api', intent: 'public' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.intents).toEqual([
|
||||
expect.objectContaining({ service: '', intent: 'internal' }),
|
||||
expect.objectContaining({ service: 'api', intent: 'public' }),
|
||||
]);
|
||||
const stackRow = res.body.intents.find((i: { service: string }) => i.service === '');
|
||||
expect(stackRow.updatedBy).toBe(TEST_USERNAME);
|
||||
expect(typeof stackRow.updatedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('clears a service row (intent null) so it inherits the stack intent', async () => {
|
||||
await put({ intent: 'internal' });
|
||||
await put({ service: 'api', intent: 'public' });
|
||||
expect((await put({ service: 'api', intent: null })).status).toBe(200);
|
||||
const res = await get();
|
||||
expect(res.body.intents).toEqual([expect.objectContaining({ service: '', intent: 'internal' })]);
|
||||
});
|
||||
|
||||
it('clears the stack row (intent null) so the stack is unclassified again', async () => {
|
||||
await put({ intent: 'internal' });
|
||||
await put({ intent: null });
|
||||
expect((await get()).body.intents).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an invalid intent value', async () => {
|
||||
expect((await put({ intent: 'bogus' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('blocks a write from a read-only token', async () => {
|
||||
const res = await request(app).put(`/api/stacks/${STACK}/exposure`)
|
||||
.set('Authorization', `Bearer ${readOnlyToken}`).send({ intent: 'internal' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated request', async () => {
|
||||
expect((await request(app).get(`/api/stacks/${STACK}/exposure`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 404 for a stack that does not exist', async () => {
|
||||
expect((await request(app).get('/api/stacks/nope-not-here/exposure').set('Authorization', authHeader)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* GET /api/stacks/:stackName/networking: returns facts, requires stack:read,
|
||||
* 404s a missing stack, surfaces a structural (never raw) error on render
|
||||
* failure, and never leaks an env value or a label value into the response.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
const STACK = 'netroute';
|
||||
const ENV_SECRET = 'env-secret-44ad-value';
|
||||
const LABEL_SECRET = 'label-secret-90fe-value';
|
||||
|
||||
function stubRender(rendered: string | null, stderr = '') {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }),
|
||||
} as unknown as ComposeService);
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
|
||||
} as unknown as DockerController);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('networking route', () => {
|
||||
let stackDir: string;
|
||||
beforeEach(() => {
|
||||
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns networking facts for a renderable stack', async () => {
|
||||
stubRender(JSON.stringify({
|
||||
name: STACK,
|
||||
services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }], networks: { backend: null } } },
|
||||
networks: { backend: { name: `${STACK}_backend` } },
|
||||
volumes: {},
|
||||
}));
|
||||
const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.renderable).toBe(true);
|
||||
expect(res.body.runtime).toBe('available');
|
||||
expect(res.body.services[0].networks).toEqual([{ key: 'backend', aliases: [] }]);
|
||||
});
|
||||
|
||||
it('surfaces a structural error and never raw stderr on render failure', async () => {
|
||||
stubRender(null, `error: the "${ENV_SECRET}" variable is not set\nservices.web.image: ${ENV_SECRET}`);
|
||||
const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.renderable).toBe(false);
|
||||
expect(JSON.stringify(res.body)).not.toContain(ENV_SECRET);
|
||||
});
|
||||
|
||||
it('never leaks env or label values into the facts', async () => {
|
||||
stubRender(JSON.stringify({
|
||||
name: STACK,
|
||||
services: { web: { image: 'nginx:latest', environment: { TOKEN: ENV_SECRET }, labels: { 'x.secret': LABEL_SECRET } } },
|
||||
networks: {},
|
||||
volumes: {},
|
||||
}));
|
||||
const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain(ENV_SECRET);
|
||||
expect(body).not.toContain(LABEL_SECRET);
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated request', async () => {
|
||||
const res = await request(app).get(`/api/stacks/${STACK}/networking`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 404 for a stack that does not exist', async () => {
|
||||
stubRender(JSON.stringify({ name: 'x', services: {}, networks: {}, volumes: {} }));
|
||||
const res = await request(app).get('/api/stacks/nope-not-here/networking').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* The per-node networking summary and its routes: a stack that publishes a
|
||||
* non-loopback port counts as exposed and, with no intent set, as
|
||||
* unknown-exposure; the node-local route and the proxy-exempt fleet aggregate
|
||||
* both return it.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
const STACK = 'netsummary';
|
||||
|
||||
function stubSnapshot() {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
|
||||
} as unknown as DockerController);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('networking summary', () => {
|
||||
let stackDir: string;
|
||||
beforeEach(() => {
|
||||
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "0.0.0.0:8080:80"\n');
|
||||
stubSnapshot();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('node-local summary marks a published stack exposed and unknown-exposure', async () => {
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.exposed.stacks).toContain(STACK);
|
||||
expect(res.body.unknownExposure.stacks).toContain(STACK);
|
||||
expect(res.body.networkDrift.stacks).toEqual([]); // empty snapshot, no running containers
|
||||
expect(res.body.exposed.count).toBe(res.body.exposed.stacks.length);
|
||||
});
|
||||
|
||||
it('flags a stack with an undeclared runtime network as network drift', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({
|
||||
containers: [{ id: 'c1', name: 'web1', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'nginx', networks: [{ name: `${STACK}_default`, id: 'd', ip: '' }, { name: `${STACK}_rogue`, id: 'r', ip: '' }], volumes: [], ports: [] }],
|
||||
networks: [
|
||||
{ id: 'd', name: `${STACK}_default`, driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK },
|
||||
{ id: 'r', name: `${STACK}_rogue`, driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK },
|
||||
],
|
||||
volumes: [],
|
||||
}),
|
||||
} as unknown as DockerController);
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
|
||||
expect(res.body.networkDrift.stacks).toContain(STACK);
|
||||
});
|
||||
|
||||
it('still reports declared signals when the snapshot is unavailable (drift skipped)', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')),
|
||||
} as unknown as DockerController);
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.exposed.stacks).toContain(STACK);
|
||||
expect(res.body.networkDrift.stacks).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops the stack from unknown-exposure once an intent is set', async () => {
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'public', 'admin');
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
|
||||
expect(res.body.exposed.stacks).toContain(STACK);
|
||||
expect(res.body.unknownExposure.stacks).not.toContain(STACK);
|
||||
});
|
||||
|
||||
it('a service-level intent on the only publishing service drops it from unknown-exposure', async () => {
|
||||
// No stack-level row; classifying the publishing service is enough.
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, 'web', 'public', 'admin');
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
|
||||
expect(res.body.unknownExposure.stacks).not.toContain(STACK);
|
||||
});
|
||||
|
||||
it('keeps the stack unknown when only some publishing services are classified', async () => {
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'),
|
||||
'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n api:\n image: nginx:latest\n ports:\n - "9090:90"\n');
|
||||
// web classified, api still unset, so the stack remains effectively unknown.
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, 'web', 'public', 'admin');
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader);
|
||||
expect(res.body.unknownExposure.stacks).toContain(STACK);
|
||||
});
|
||||
|
||||
it('the fleet aggregate returns a per-node summary for the hub', async () => {
|
||||
const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.nodes)).toBe(true);
|
||||
const local = res.body.nodes.find((n: { status: string; summary: { exposed: { stacks: string[] } } | null }) => n.summary?.exposed.stacks.includes(STACK));
|
||||
expect(local).toBeDefined();
|
||||
expect(local.status).toBe('ok');
|
||||
expect(local.summary.unknownExposure).toBeDefined();
|
||||
expect(local.summary.networkDrift).toBeDefined();
|
||||
});
|
||||
|
||||
it('degrades a remote that errors to a node-error while keeping the hub', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-degrade', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
const hub = res.body.nodes.find((n: { summary: unknown }) => n.summary !== null);
|
||||
const remote = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === remoteId);
|
||||
expect(hub.status).toBe('ok');
|
||||
expect(remote.status).toBe('error');
|
||||
expect(remote.summary).toBeNull();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated request to the node-local summary', async () => {
|
||||
expect((await request(app).get('/api/networking/summary')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { parseEffectiveModel } from '../services/preflight/effectiveModel';
|
||||
|
||||
const SECRET = 'topsecret-9f3a-value';
|
||||
const LABEL_SECRET = 'label-secret-7b21-value';
|
||||
|
||||
function render() {
|
||||
return {
|
||||
@@ -31,10 +32,14 @@ function render() {
|
||||
container_name: 'web1',
|
||||
user: '1000:1000',
|
||||
environment: { DB_PASSWORD: SECRET, PUID: '1000' },
|
||||
networks: { backend: { aliases: ['www', 'web'] }, shared: null },
|
||||
extra_hosts: ['host.docker.internal:host-gateway'],
|
||||
labels: { 'traefik.enable': 'true', 'secret.label': LABEL_SECRET },
|
||||
},
|
||||
},
|
||||
networks: {
|
||||
default: { name: 'myapp_default' },
|
||||
backend: { name: 'myapp_backend', internal: true },
|
||||
shared: { name: 'shared_net', external: true },
|
||||
},
|
||||
volumes: {
|
||||
@@ -71,12 +76,49 @@ describe('parseEffectiveModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves top-level networks and volumes with external flags', () => {
|
||||
it('resolves top-level networks and volumes with external and internal flags', () => {
|
||||
const m = parseEffectiveModel(render(), 'fallback');
|
||||
expect(m.networks.shared).toEqual({ name: 'shared_net', external: true });
|
||||
expect(m.networks.default).toEqual({ name: 'myapp_default', external: false });
|
||||
expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true });
|
||||
expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false });
|
||||
expect(m.networks.shared).toEqual({ name: 'shared_net', external: true, internal: false });
|
||||
expect(m.networks.default).toEqual({ name: 'myapp_default', external: false, internal: false });
|
||||
expect(m.networks.backend).toEqual({ name: 'myapp_backend', external: false, internal: true });
|
||||
expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true, internal: false });
|
||||
expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false });
|
||||
});
|
||||
|
||||
it('parses service network membership (key-space) with aliases', () => {
|
||||
const web = parseEffectiveModel(render(), 'fallback').services[0];
|
||||
expect(web.networks).toEqual([
|
||||
{ key: 'backend', aliases: ['www', 'web'] },
|
||||
{ key: 'shared', aliases: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses service network membership from the list form', () => {
|
||||
const m = parseEffectiveModel({ services: { s: { networks: ['frontend', 'backend'] } } }, 'p');
|
||||
expect(m.services[0].networks).toEqual([
|
||||
{ key: 'frontend', aliases: [] },
|
||||
{ key: 'backend', aliases: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses extra_hosts in list and map form', () => {
|
||||
const web = parseEffectiveModel(render(), 'fallback').services[0];
|
||||
expect(web.extraHosts).toEqual(['host.docker.internal:host-gateway']);
|
||||
const mapForm = parseEffectiveModel({ services: { s: { extra_hosts: { 'db.local': '10.0.0.5' } } } }, 'p');
|
||||
expect(mapForm.services[0].extraHosts).toEqual(['db.local:10.0.0.5']);
|
||||
});
|
||||
|
||||
it('reads label KEY names only, never label values', () => {
|
||||
const web = parseEffectiveModel(render(), 'fallback').services[0];
|
||||
expect(web.labelKeys).toEqual(['traefik.enable', 'secret.label']);
|
||||
// A label value can carry a secret; it must not survive into the model.
|
||||
expect(JSON.stringify(parseEffectiveModel(render(), 'fallback'))).not.toContain(LABEL_SECRET);
|
||||
});
|
||||
|
||||
it('reads label key names from the list form without keeping the value', () => {
|
||||
const m = parseEffectiveModel({ services: { s: { labels: [`secret.label=${LABEL_SECRET}`, 'plain'] } } }, 'p');
|
||||
expect(m.services[0].labelKeys).toEqual(['secret.label', 'plain']);
|
||||
expect(JSON.stringify(m)).not.toContain(LABEL_SECRET);
|
||||
});
|
||||
|
||||
it('reads environment KEY names only, never values', () => {
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { PreflightContext, PreflightFinding } from '../services/preflight/t
|
||||
function svc(over: Partial<EffService> = {}): EffService {
|
||||
return {
|
||||
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
|
||||
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], ...over,
|
||||
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [], ...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +28,8 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
||||
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
|
||||
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
|
||||
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
|
||||
existingContainers: [], bindChecks: [], ...over,
|
||||
existingContainers: [], bindChecks: [],
|
||||
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,18 +180,18 @@ describe('hygiene rules', () => {
|
||||
|
||||
describe('network / volume rules', () => {
|
||||
it('blocks a missing external network and volume', () => {
|
||||
const m = model([svc()], { networks: { ext: { name: 'shared', external: true } }, volumes: { v: { name: 'data', external: true } } });
|
||||
const m = model([svc()], { networks: { ext: { name: 'shared', external: true, internal: false } }, volumes: { v: { name: 'data', external: true, internal: false } } });
|
||||
const f = runRules(ctx({ model: m }));
|
||||
expect(ids(f, 'external-network-missing')).toHaveLength(1);
|
||||
expect(ids(f, 'external-volume-missing')).toHaveLength(1);
|
||||
});
|
||||
it('does not block an external resource that exists', () => {
|
||||
const m = model([svc()], { networks: { ext: { name: 'shared', external: true } } });
|
||||
const m = model([svc()], { networks: { ext: { name: 'shared', external: true, internal: false } } });
|
||||
const f = runRules(ctx({ model: m, existingNetworkNames: new Set(['shared']) }));
|
||||
expect(ids(f, 'external-network-missing')).toHaveLength(0);
|
||||
});
|
||||
it('reports a new network/volume as info when absent on the node', () => {
|
||||
const m = model([svc()], { networks: { backend: { name: 'backend', external: false } }, volumes: { data: { name: 'data', external: false } } });
|
||||
const m = model([svc()], { networks: { backend: { name: 'backend', external: false, internal: false } }, volumes: { data: { name: 'data', external: false, internal: false } } });
|
||||
const f = runRules(ctx({ model: m }));
|
||||
expect(ids(f, 'new-network')[0].severity).toBe('info');
|
||||
expect(ids(f, 'new-volume')[0].message).toContain('proj_data');
|
||||
@@ -231,6 +233,93 @@ describe('effective-model-expanded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('exposure-intent rules', () => {
|
||||
const withPort = (hostIp = '0.0.0.0', over: Partial<EffService> = {}) =>
|
||||
model([svc({ name: 'web', ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: 'tcp' }], ...over })]);
|
||||
|
||||
it('flags a service classified internal that publishes a host port', () => {
|
||||
const f = runRules(ctx({ model: withPort(), stackIntent: 'internal' }));
|
||||
expect(ids(f, 'exposure-internal-published')).toHaveLength(1);
|
||||
expect(ids(f, 'exposure-internal-published')[0].severity).toBe('high');
|
||||
});
|
||||
it('lets same-node tolerate a loopback bind but not a broad one', () => {
|
||||
expect(ids(runRules(ctx({ model: withPort('127.0.0.1'), stackIntent: 'same-node' })), 'exposure-internal-published')).toHaveLength(0);
|
||||
expect(ids(runRules(ctx({ model: withPort('0.0.0.0'), stackIntent: 'same-node' })), 'exposure-internal-published')).toHaveLength(1);
|
||||
});
|
||||
it('lets a per-service intent override the stack intent', () => {
|
||||
// Stack is internal, but the service is reclassified public, so no finding.
|
||||
const f = runRules(ctx({ model: withPort(), stackIntent: 'internal', serviceIntents: { web: 'public' } }));
|
||||
expect(ids(f, 'exposure-internal-published')).toHaveLength(0);
|
||||
});
|
||||
it('same-node lists only the broad port when a service binds both loopback and broad', () => {
|
||||
const m = model([svc({ name: 'web', ports: [
|
||||
{ startPort: 9000, endPort: 9000, hostIp: '127.0.0.1', protocol: 'tcp' },
|
||||
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
|
||||
] })]);
|
||||
const f = ids(runRules(ctx({ model: m, stackIntent: 'same-node' })), 'exposure-internal-published');
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].message).toContain('8080');
|
||||
expect(f[0].message).not.toContain('9000');
|
||||
});
|
||||
it('warns when a port-publishing stack has no exposure intent', () => {
|
||||
expect(ids(runRules(ctx({ model: withPort(), stackIntent: null })), 'exposure-unclassified')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'unknown' })), 'exposure-unclassified')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'lan' })), 'exposure-unclassified')).toHaveLength(0);
|
||||
});
|
||||
it('lets a service-level intent suppress the unclassified warning even when the stack is unset', () => {
|
||||
// web is the only publishing service; classifying it removes the gap.
|
||||
expect(ids(runRules(ctx({ model: withPort(), stackIntent: null, serviceIntents: { web: 'public' } })), 'exposure-unclassified')).toHaveLength(0);
|
||||
});
|
||||
it('still warns when a publishing service is explicitly unknown over a classified stack', () => {
|
||||
expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'public', serviceIntents: { web: 'unknown' } })), 'exposure-unclassified')).toHaveLength(1);
|
||||
});
|
||||
it('does not warn unclassified when no port is published', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc()]), stackIntent: null })), 'exposure-unclassified')).toHaveLength(0);
|
||||
});
|
||||
it('flags a published port absent from the documented access URLs', () => {
|
||||
const f = runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([443]) }));
|
||||
expect(ids(f, 'exposure-port-vs-dossier')).toHaveLength(1);
|
||||
// No finding once the port is documented.
|
||||
expect(ids(runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([8080]) })), 'exposure-port-vs-dossier')).toHaveLength(0);
|
||||
// Gated off when the dossier records no access URL.
|
||||
expect(ids(runRules(ctx({ model: withPort(), hasAccessUrls: false })), 'exposure-port-vs-dossier')).toHaveLength(0);
|
||||
});
|
||||
it('flags a published port absent from the documented access URLs, listing all undocumented ports', () => {
|
||||
const m = model([svc({ name: 'web', ports: [
|
||||
{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' },
|
||||
{ startPort: 9090, endPort: 9090, hostIp: '0.0.0.0', protocol: 'tcp' },
|
||||
] })]);
|
||||
const f = ids(runRules(ctx({ model: m, hasAccessUrls: true, accessUrlPorts: new Set([8080]) })), 'exposure-port-vs-dossier');
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].message).toContain('9090');
|
||||
expect(f[0].message).not.toContain('8080');
|
||||
});
|
||||
it('flags reverse-proxy labels with no documented URL or intent', () => {
|
||||
const m = model([svc({ name: 'web', labelKeys: ['traefik.enable', 'traefik.http.routers.web.rule'] })]);
|
||||
expect(ids(runRules(ctx({ model: m })), 'reverse-proxy-undocumented')).toHaveLength(1);
|
||||
// A caddy-docker-proxy label also trips it.
|
||||
const caddy = model([svc({ name: 'web', labelKeys: ['caddy', 'caddy.reverse_proxy'] })]);
|
||||
expect(ids(runRules(ctx({ model: caddy })), 'reverse-proxy-undocumented')).toHaveLength(1);
|
||||
// An unrelated vendor label that merely contains "caddy" does not.
|
||||
const vendor = model([svc({ name: 'web', labelKeys: ['com.caddyserver.unrelated'] })]);
|
||||
expect(ids(runRules(ctx({ model: vendor })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
||||
// Silenced once documented, stack-intent reverse-proxy, or service-intent reverse-proxy.
|
||||
expect(ids(runRules(ctx({ model: m, hasAccessUrls: true })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
||||
expect(ids(runRules(ctx({ model: m, stackIntent: 'reverse-proxy' })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
||||
expect(ids(runRules(ctx({ model: m, serviceIntents: { web: 'reverse-proxy' } })), 'reverse-proxy-undocumented')).toHaveLength(0);
|
||||
});
|
||||
it('flags a sensitive image exposed on all interfaces', () => {
|
||||
const m = model([svc({ name: 'db', image: 'postgres:16', ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
|
||||
expect(ids(runRules(ctx({ model: m })), 'sensitive-service-broad-exposure')[0].severity).toBe('high');
|
||||
// A loopback bind of the same image does not flag.
|
||||
const loop = model([svc({ name: 'db', image: 'postgres:16', ports: [{ startPort: 5432, endPort: 5432, hostIp: '127.0.0.1', protocol: 'tcp' }] })]);
|
||||
expect(ids(runRules(ctx({ model: loop })), 'sensitive-service-broad-exposure')).toHaveLength(0);
|
||||
// A build-only service with no image is not matched, even on a broad bind.
|
||||
const build = model([svc({ name: 'postgres-ish', image: undefined, ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
|
||||
expect(ids(runRules(ctx({ model: build })), 'sensitive-service-broad-exposure')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rule registry completeness', () => {
|
||||
// The canonical rule set. Adding or removing a rule must update this list,
|
||||
// which forces a deliberate pass over the docs and the frontend severity map.
|
||||
@@ -239,7 +328,9 @@ describe('rule registry completeness', () => {
|
||||
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
|
||||
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
|
||||
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume',
|
||||
'container-name-internal-dup', 'container-name-collision', 'effective-model-expanded',
|
||||
'container-name-internal-dup', 'container-name-collision',
|
||||
'exposure-internal-published', 'sensitive-service-broad-exposure', 'exposure-unclassified',
|
||||
'exposure-port-vs-dossier', 'reverse-proxy-undocumented', 'effective-model-expanded',
|
||||
];
|
||||
it('the registry contains exactly the expected rules', () => {
|
||||
expect([...RULE_IDS].sort()).toEqual([...EXPECTED_RULE_IDS].sort());
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Stack exposure-intent DAO: stack-level ('') and per-service rows, upsert,
|
||||
* single-row clear, clear-all, the CHECK constraint, and node-delete cleanup.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
db = DatabaseService.getInstance();
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('stack exposure intent DAO', () => {
|
||||
it('stores and reads stack-level and per-service intents', () => {
|
||||
db.setStackExposureIntent(1, 'web', '', 'internal', 'admin');
|
||||
db.setStackExposureIntent(1, 'web', 'api', 'public', 'admin');
|
||||
const rows = db.getStackExposureIntents(1, 'web');
|
||||
expect(rows.map(r => [r.service, r.intent])).toEqual([['', 'internal'], ['api', 'public']]);
|
||||
expect(rows[0].updated_by).toBe('admin');
|
||||
});
|
||||
|
||||
it('upserts an existing row in place', () => {
|
||||
db.setStackExposureIntent(1, 'web', '', 'lan', 'admin2');
|
||||
const stack = db.getStackExposureIntents(1, 'web').find(r => r.service === '');
|
||||
expect(stack?.intent).toBe('lan');
|
||||
expect(stack?.updated_by).toBe('admin2');
|
||||
});
|
||||
|
||||
it('clears one row and clears all rows for a stack', () => {
|
||||
db.deleteStackExposureIntent(1, 'web', 'api');
|
||||
expect(db.getStackExposureIntents(1, 'web').map(r => r.service)).toEqual(['']);
|
||||
db.deleteStackExposureIntents(1, 'web');
|
||||
expect(db.getStackExposureIntents(1, 'web')).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an out-of-range intent via the CHECK constraint', () => {
|
||||
const raw = (db as unknown as { db: import('better-sqlite3').Database }).db;
|
||||
expect(() => raw
|
||||
.prepare("INSERT INTO stack_exposure_intent (node_id, stack_name, service, intent, updated_at) VALUES (1, 'x', '', 'bogus', 1)")
|
||||
.run()).toThrow(/CHECK constraint failed/);
|
||||
});
|
||||
|
||||
it('removes intent rows when the owning node is deleted', () => {
|
||||
const nodeId = db.addNode({ name: 'expnode', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://x', api_token: 't' });
|
||||
db.setStackExposureIntent(nodeId, 'svc', '', 'public', null);
|
||||
expect(db.getStackExposureIntents(nodeId, 'svc')).toHaveLength(1);
|
||||
db.deleteNode(nodeId);
|
||||
expect(db.getStackExposureIntents(nodeId, 'svc')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,9 @@ export interface DeclaredCompose {
|
||||
services: DeclaredService[];
|
||||
networks: Record<string, DeclaredResource>;
|
||||
volumes: Record<string, DeclaredResource>;
|
||||
/** Top-level `name:` (the Compose project name), when set; it determines the
|
||||
* `<project>_<resource>` runtime names, overriding the stack directory name. */
|
||||
projectName?: string;
|
||||
/** Set when the file could not be parsed; the other fields are then empty. */
|
||||
parseError?: string;
|
||||
}
|
||||
@@ -198,5 +201,6 @@ export function parseComposeDependencies(content: string): DeclaredCompose {
|
||||
services,
|
||||
networks: collectResources(root.networks),
|
||||
volumes: collectResources(root.volumes),
|
||||
projectName: asString(root.name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { stackActivityMetricsRouter } from './routes/stackActivityMetrics';
|
||||
import { secretsRouter } from './routes/secrets';
|
||||
import { diagnosticsRouter } from './routes/diagnostics';
|
||||
import { dependencyMapRouter } from './routes/dependencyMap';
|
||||
import { networkingRouter } from './routes/networking';
|
||||
|
||||
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
||||
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
||||
@@ -145,6 +146,7 @@ app.use('/api/ports', portsRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/diagnostics', diagnosticsRouter);
|
||||
app.use('/api/dependency-map', dependencyMapRouter);
|
||||
app.use('/api/networking', networkingRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/stacks', stackActivityRouter);
|
||||
app.use('/api/stacks', stacksRouter);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } fr
|
||||
import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants';
|
||||
import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPDATE_TIMEOUT_MS, UPDATE_TIMEOUT_MSG, TERMINAL_TTL_MS } from '../services/FleetUpdateTrackerService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
@@ -702,6 +703,75 @@ fleetRouter.get('/dependency-map', authMiddleware, async (_req: Request, res: Re
|
||||
}
|
||||
});
|
||||
|
||||
interface FleetNetworkingSummaryNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
status: 'ok' | 'error';
|
||||
summary: NodeNetworkingSummary | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function isNodeNetworkingSummary(v: unknown): v is NodeNetworkingSummary {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return (['exposed', 'unknownExposure', 'networkDrift'] as const).every(k => {
|
||||
const b = o[k] as { count?: unknown; stacks?: unknown } | undefined;
|
||||
return !!b && typeof b.count === 'number' && Array.isArray(b.stacks);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet-wide networking summary for the overview filter. Auth-only (read-only,
|
||||
* Community). Hub-exempt under /api/fleet, so it is never proxied: it builds the
|
||||
* hub's summary in-process and reaches each remote through its node-local
|
||||
* /api/networking/summary route. A remote on an older version (no route) returns
|
||||
* 404 and degrades to a skip, so one unreachable or unsupported node never fails
|
||||
* the filter for the rest.
|
||||
*/
|
||||
fleetRouter.get('/networking-summary', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node: Node): Promise<FleetNetworkingSummaryNode> => {
|
||||
if (node.type === 'local') {
|
||||
const summary = await computeNodeNetworkingSummary(node.id);
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'ok', summary, error: null };
|
||||
}
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target) {
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: formatNoTargetError(node) };
|
||||
}
|
||||
const resp = await fetch(
|
||||
`${target.apiUrl.replace(/\/$/, '')}/api/networking/summary`,
|
||||
{ headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, signal: AbortSignal.timeout(15000) },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: `Remote returned ${resp.status}` };
|
||||
}
|
||||
const summary = await resp.json().catch(() => null);
|
||||
if (!isNodeNetworkingSummary(summary)) {
|
||||
console.error(`[Fleet] Networking summary: node ${sanitizeForLog(node.name)} returned an unexpected payload (status ${resp.status})`);
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: 'Remote returned an unexpected summary payload' };
|
||||
}
|
||||
return { nodeId: node.id, nodeName: node.name, status: 'ok', summary, error: null };
|
||||
}),
|
||||
);
|
||||
|
||||
const perNode: FleetNetworkingSummaryNode[] = results.map((result, i) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
console.error(`[Fleet] Networking summary fetch failed for node ${nodes[i].name}:`, result.reason);
|
||||
return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', summary: null, error: getErrorMessage(result.reason, 'Failed to reach node') };
|
||||
});
|
||||
|
||||
res.json({ nodes: perNode });
|
||||
} catch (error) {
|
||||
console.error('[Fleet] Networking summary error:', error);
|
||||
res.status(500).json({ error: 'Failed to build fleet networking summary' });
|
||||
}
|
||||
});
|
||||
|
||||
fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { computeNodeNetworkingSummary } from '../services/network/networkingSummary';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export const networkingRouter = Router();
|
||||
|
||||
// Node-local networking summary for the Fleet view filter. Auth-only and
|
||||
// read-only (Community). The fleet aggregate computes the hub's summary by
|
||||
// calling the underlying service in-process and reaches each remote through
|
||||
// this route, so a remote is summarized on the node that owns its stacks.
|
||||
networkingRouter.get('/summary', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
res.json(await computeNodeNetworkingSummary(req.nodeId));
|
||||
} catch (error) {
|
||||
console.error('[Networking] Failed to build node summary:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to build networking summary' });
|
||||
}
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
|
||||
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
|
||||
import { UpdateGuardService } from '../services/UpdateGuardService';
|
||||
import { HealthGateService } from '../services/HealthGateService';
|
||||
import { classifyFailure } from '../services/updateGuard/failureClassifier';
|
||||
@@ -950,6 +952,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
|
||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||
DatabaseService.getInstance().deleteStackDossier(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteStackDriftFindings(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName);
|
||||
if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName });
|
||||
} catch (dbErr) {
|
||||
console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr);
|
||||
@@ -1127,6 +1130,82 @@ stacksRouter.post('/:stackName/preflight/run', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
// Compose Network Inspector: per-stack networking facts (network map, service
|
||||
// membership, published ports/bindings, network_mode, extra_hosts, runtime
|
||||
// drift) derived from the authored effective model + live snapshot. Read-only
|
||||
// and advisory; auto-proxies to the active node. Never returns raw render
|
||||
// stderr, env values, or label values.
|
||||
stacksRouter.get('/:stackName/networking', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
res.json(await buildStackNetworkFacts(req.nodeId, stackName));
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to build networking facts for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to build networking facts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Exposure intent: the user's per-stack (service '') and per-service exposure
|
||||
// classification, stored separately from generated facts so mismatches stay
|
||||
// detectable. Rows are stored independently; precedence (a service row taking
|
||||
// priority over the stack row, an absent service row inheriting the stack
|
||||
// intent) is applied by the consumers that read these rows, not enforced here.
|
||||
// Clearing a row (intent null) deletes it, returning that scope to unset.
|
||||
const ExposurePutSchema = z.object({
|
||||
service: z.string().max(255).optional().default(''),
|
||||
intent: z.enum(EXPOSURE_INTENTS).nullable(),
|
||||
});
|
||||
|
||||
function exposurePayload(nodeId: number, stackName: string): {
|
||||
intents: { service: string; intent: ExposureIntent; updatedAt: number; updatedBy: string | null }[];
|
||||
} {
|
||||
return {
|
||||
intents: DatabaseService.getInstance().getStackExposureIntents(nodeId, stackName).map(r => ({
|
||||
service: r.service, intent: r.intent, updatedAt: r.updated_at, updatedBy: r.updated_by,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
stacksRouter.get('/:stackName/exposure', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
res.json(exposurePayload(req.nodeId, stackName));
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to read exposure intent for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to read exposure intent' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.put('/:stackName/exposure', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
const parsed = ExposurePutSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid exposure intent' });
|
||||
return;
|
||||
}
|
||||
const { service, intent } = parsed.data;
|
||||
try {
|
||||
if (intent === null) {
|
||||
DatabaseService.getInstance().deleteStackExposureIntent(req.nodeId, stackName, service);
|
||||
} else {
|
||||
DatabaseService.getInstance().setStackExposureIntent(req.nodeId, stackName, service, intent, req.user?.username ?? null);
|
||||
}
|
||||
res.json(exposurePayload(req.nodeId, stackName));
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to save exposure intent for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to save exposure intent' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update guard: readiness reports computed on demand from existing stores
|
||||
// (preflight runs, drift findings, backup slot, update preview, live Docker
|
||||
// state). Node-scoped like preflight: a remote stack is evaluated on the node
|
||||
|
||||
@@ -37,6 +37,7 @@ export const CAPABILITIES = [
|
||||
'vulnerability-scanning',
|
||||
'compose-doctor',
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
@@ -9,6 +9,8 @@ import { DatabaseService } from './DatabaseService';
|
||||
import { computeStackHashes } from './DriftLedgerService';
|
||||
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
|
||||
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
|
||||
import { parseAccessUrlPorts } from './network/normalize';
|
||||
import type { ExposureIntent } from './network/types';
|
||||
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
|
||||
import type {
|
||||
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus,
|
||||
@@ -196,6 +198,7 @@ export class ComposeDoctorService {
|
||||
|
||||
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers } = await this.nodeState(nodeId, fsSvc, stackName);
|
||||
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
|
||||
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
|
||||
|
||||
return {
|
||||
stackName,
|
||||
@@ -211,9 +214,46 @@ export class ComposeDoctorService {
|
||||
existingVolumeNames,
|
||||
existingContainers,
|
||||
bindChecks,
|
||||
stackIntent,
|
||||
serviceIntents,
|
||||
accessUrlPorts,
|
||||
hasAccessUrls,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's stored exposure intent (resolved into stack-level + per-service)
|
||||
* and the dossier's documented access-URL ports, for the exposure rules.
|
||||
* Fail-soft: a read error defaults to unset/empty so the rules simply do not
|
||||
* fire rather than the whole preflight failing.
|
||||
*/
|
||||
private exposureState(nodeId: number, stackName: string): {
|
||||
stackIntent: ExposureIntent | null;
|
||||
serviceIntents: Record<string, ExposureIntent>;
|
||||
accessUrlPorts: Set<number>;
|
||||
hasAccessUrls: boolean;
|
||||
} {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const rows = db.getStackExposureIntents(nodeId, stackName);
|
||||
const stackIntent = rows.find(r => r.service === '')?.intent ?? null;
|
||||
const serviceIntents: Record<string, ExposureIntent> = {};
|
||||
for (const r of rows) if (r.service !== '') serviceIntents[r.service] = r.intent;
|
||||
|
||||
const accessUrls = db.getStackDossier(nodeId, stackName)?.access_urls ?? '';
|
||||
return {
|
||||
stackIntent,
|
||||
serviceIntents,
|
||||
accessUrlPorts: parseAccessUrlPorts(accessUrls),
|
||||
hasAccessUrls: accessUrls.trim().length > 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[ComposeDoctor] Exposure state unavailable for %s; exposure rules skipped:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */
|
||||
private async nodeState(nodeId: number, fsSvc: FileSystemService, stackName: string): Promise<{
|
||||
nodePorts: NodePortBinding[];
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'fs';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { isSeverityAtLeast } from '../utils/severity';
|
||||
import type { AuditStatsInput } from './AuditAnomalyService';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
|
||||
|
||||
function isPilotMode(): boolean {
|
||||
return process.env.SENCHO_MODE === 'pilot';
|
||||
@@ -152,6 +153,17 @@ export interface StackDriftFindingRow {
|
||||
resolved_at: number | null;
|
||||
}
|
||||
|
||||
export interface StackExposureIntentRow {
|
||||
id: number;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
/** '' = the stack-level classification; otherwise a service name. */
|
||||
service: string;
|
||||
intent: ExposureIntent;
|
||||
updated_at: number;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -1261,6 +1273,19 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_stack_drift_findings_open
|
||||
ON stack_drift_findings(node_id, stack_name, resolved_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_exposure_intent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
service TEXT NOT NULL DEFAULT '',
|
||||
intent TEXT NOT NULL CHECK(intent IN (${EXPOSURE_INTENTS.map(i => `'${i}'`).join(', ')})),
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT,
|
||||
UNIQUE(node_id, stack_name, service)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stack_exposure_intent_stack
|
||||
ON stack_exposure_intent(node_id, stack_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS preflight_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id INTEGER NOT NULL,
|
||||
@@ -2298,6 +2323,37 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Stack Exposure Intent (per-stack and per-service exposure classification) ---
|
||||
|
||||
/** All intent rows for a stack: the stack-level row (service '') and any per-service rows. */
|
||||
public getStackExposureIntents(nodeId: number, stackName: string): StackExposureIntentRow[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? ORDER BY service ASC'
|
||||
).all(nodeId, stackName) as StackExposureIntentRow[];
|
||||
}
|
||||
|
||||
/** Upsert one intent row. `service` is '' for the stack-level classification. */
|
||||
public setStackExposureIntent(nodeId: number, stackName: string, service: string, intent: ExposureIntent, updatedBy: string | null): void {
|
||||
this.db.prepare(
|
||||
`INSERT INTO stack_exposure_intent (node_id, stack_name, service, intent, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id, stack_name, service) DO UPDATE SET
|
||||
intent = excluded.intent,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by`
|
||||
).run(nodeId, stackName, service, intent, Date.now(), updatedBy);
|
||||
}
|
||||
|
||||
/** Clear one intent row, leaving that scope unset; consumers treat a service with no row as inheriting the stack intent. */
|
||||
public deleteStackExposureIntent(nodeId: number, stackName: string, service: string): void {
|
||||
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? AND service = ?').run(nodeId, stackName, service);
|
||||
}
|
||||
|
||||
/** Clear every intent row for a stack (used when the stack is deleted). */
|
||||
public deleteStackExposureIntents(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Compose Doctor / Preflight ---
|
||||
|
||||
/** Store a run and its findings, replacing any prior run for this (node, stack). */
|
||||
@@ -2730,6 +2786,7 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import DockerController from './DockerController';
|
||||
import type { DependencyContainer } from './DockerController';
|
||||
import type { DependencyContainer, DependencyNetwork } from './DockerController';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
|
||||
import type { DeclaredCompose, DeclaredService } from '../helpers/composeDependencyParse';
|
||||
import { compareStackNetworks, fromDeclaredCompose } from './network/normalize';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
@@ -25,7 +26,9 @@ export type DriftFindingKind =
|
||||
| 'service-missing'
|
||||
| 'service-undeclared'
|
||||
| 'image-mismatch'
|
||||
| 'ports-mismatch';
|
||||
| 'ports-mismatch'
|
||||
| 'network-undeclared'
|
||||
| 'network-missing';
|
||||
|
||||
export interface StackDriftFinding {
|
||||
kind: DriftFindingKind;
|
||||
@@ -101,10 +104,72 @@ export interface AssembleStackDriftInput {
|
||||
declared: DeclaredCompose;
|
||||
/** All runtime containers belonging to this stack (any state). */
|
||||
containers: DependencyContainer[];
|
||||
/** Every network on the node (for resolving foreign vs stack-owned attachments). */
|
||||
networks?: DependencyNetwork[];
|
||||
/** Set when the compose file could not be parsed. */
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
/** Add a network to a service's accumulated undeclared-attachment set. */
|
||||
function addNetwork(map: Map<string, Set<string>>, service: string, network: string): void {
|
||||
let set = map.get(service);
|
||||
if (!set) { set = new Set(); map.set(service, set); }
|
||||
set.add(network);
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-level drift: a service attached to a network not declared in compose
|
||||
* (stack-owned-undeclared or owned by another stack) is one finding per service;
|
||||
* declared networks that no running service uses or that are absent from the
|
||||
* runtime are one stack-level finding. Reuses the same comparison the Network
|
||||
* Inspector uses, so the two surfaces never disagree.
|
||||
*/
|
||||
function networkDriftFindings(
|
||||
stack: string,
|
||||
declared: DeclaredCompose,
|
||||
containers: DependencyContainer[],
|
||||
networks: DependencyNetwork[],
|
||||
): StackDriftFinding[] {
|
||||
// Runtime resource names use the Compose project (top-level `name:` when set),
|
||||
// not the stack directory, so a stack with `name:` resolves its networks the
|
||||
// same way Docker does. Containers are still attributed to the stack directory.
|
||||
const normalized = fromDeclaredCompose(declared, declared.projectName ?? stack);
|
||||
const facts = compareStackNetworks(normalized, { containers, networks, volumes: [] }, stack);
|
||||
const findings: StackDriftFinding[] = [];
|
||||
|
||||
const serviceByContainer = new Map(containers.map(c => [c.name, c.service ?? c.name]));
|
||||
const undeclaredByService = new Map<string, Set<string>>();
|
||||
for (const a of facts.runtimeOnlyAttachments) addNetwork(undeclaredByService, a.service ?? a.container, a.network);
|
||||
for (const a of facts.foreignNetworkAttachments) addNetwork(undeclaredByService, serviceByContainer.get(a.container) ?? a.container, a.network);
|
||||
|
||||
for (const [service, nets] of undeclaredByService) {
|
||||
const list = [...nets].sort();
|
||||
const joined = list.join(', ');
|
||||
findings.push({
|
||||
kind: 'network-undeclared',
|
||||
service,
|
||||
detail: `Service "${service}" is attached to ${list.length > 1 ? 'networks' : 'a network'} not declared in compose: ${joined}.`,
|
||||
actual: joined,
|
||||
});
|
||||
}
|
||||
|
||||
// declaredButUnused holds compose keys; resolve them to runtime names so the
|
||||
// finding lists every missing network in one consistent namespace.
|
||||
const unusedNames = facts.declaredButUnused.map(key => normalized.networks[key]?.runtimeName ?? key);
|
||||
const missing = [...new Set([...unusedNames, ...facts.missingFromRuntime])].sort();
|
||||
if (missing.length > 0) {
|
||||
const joined = missing.join(', ');
|
||||
findings.push({
|
||||
kind: 'network-missing',
|
||||
service: '',
|
||||
detail: `Declared ${missing.length > 1 ? 'networks are' : 'network is'} unused by running services or absent from the runtime: ${joined}.`,
|
||||
expected: joined,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure diff step (no Docker / FS access) so it is directly unit-testable. Only
|
||||
* running containers are compared, since a stopped container publishes no ports
|
||||
@@ -115,6 +180,7 @@ export interface AssembleStackDriftInput {
|
||||
*/
|
||||
export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftReport {
|
||||
const { stack, declared, containers, parseError } = input;
|
||||
const networks = input.networks ?? [];
|
||||
const hasContainers = containers.some((c) => RUNNING_STATES.has(c.state));
|
||||
|
||||
// A parse failure means the declared model is untrustworthy: report drift
|
||||
@@ -204,6 +270,8 @@ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftRe
|
||||
}
|
||||
}
|
||||
|
||||
findings.push(...networkDriftFindings(stack, declared, containers, networks));
|
||||
|
||||
const status: StackDriftStatus = findings.length > 0 ? 'drifted' : 'in-sync';
|
||||
return { stack, status, hasComposeFile: true, hasContainers, findings };
|
||||
}
|
||||
@@ -235,6 +303,7 @@ export async function buildStackDriftReport(nodeId: number, stackName: string):
|
||||
const declared = parseComposeDependencies(content);
|
||||
|
||||
let containers: DependencyContainer[];
|
||||
let networks: DependencyNetwork[] = [];
|
||||
try {
|
||||
// The snapshot needs the full known-stacks set to resolve each container to
|
||||
// its stack; we then filter to this one. Do not narrow to [stackName] or
|
||||
@@ -242,6 +311,7 @@ export async function buildStackDriftReport(nodeId: number, stackName: string):
|
||||
const stacks = await fs.getStacks();
|
||||
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
|
||||
containers = snapshot.containers.filter((c) => c.stack === stackName);
|
||||
networks = snapshot.networks;
|
||||
} catch (error) {
|
||||
// Docker is unreachable, so runtime drift cannot be assessed. The headline
|
||||
// failure is reachability; a separate parse error (if any) surfaces as
|
||||
@@ -256,5 +326,5 @@ export async function buildStackDriftReport(nodeId: number, stackName: string):
|
||||
};
|
||||
}
|
||||
|
||||
return assembleStackDrift({ stack: stackName, declared, containers, parseError: declared.parseError });
|
||||
return assembleStackDrift({ stack: stackName, declared, containers, networks, parseError: declared.parseError });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Compose Network Inspector: renders the authored effective model and pairs it
|
||||
* with the live Docker snapshot to produce the per-stack networking facts a
|
||||
* Community user reads (network map, membership, published ports/bindings,
|
||||
* network_mode, extra_hosts, and runtime drift). Advisory and read-only; it
|
||||
* renders the AUTHORED model only (no Mesh overrides) and never returns or logs
|
||||
* raw docker stderr, env values, or label values.
|
||||
*/
|
||||
import DockerController, { type DependencySnapshot } from '../DockerController';
|
||||
import { ComposeService } from '../ComposeService';
|
||||
import { FileSystemService } from '../FileSystemService';
|
||||
import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel';
|
||||
import { parseMissingRequiredVars } from '../ComposeDoctorService';
|
||||
import {
|
||||
compareStackNetworks, fromEffectiveModel, isAllInterfaces, isLoopback,
|
||||
} from './normalize';
|
||||
import type {
|
||||
NetworkDriftFacts, NetworkFactNetwork, NetworkFactService, NetworkRuntimeState, StackNetworkFacts,
|
||||
} from './types';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog';
|
||||
|
||||
const MAX_RENDER_ERROR = 600;
|
||||
const EMPTY_DRIFT: NetworkDriftFacts = {
|
||||
runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure assembler: turns a rendered model plus an optional runtime snapshot into
|
||||
* the facts payload. A null model means the render failed (renderError carries a
|
||||
* redacted reason); a null snapshot means the runtime is unavailable, so drift
|
||||
* is left empty rather than computed against an empty snapshot.
|
||||
*/
|
||||
export function assembleStackNetworkFacts(
|
||||
stackName: string,
|
||||
model: EffectiveModel | null,
|
||||
renderError: string | null,
|
||||
snapshot: DependencySnapshot | null,
|
||||
): StackNetworkFacts {
|
||||
const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable';
|
||||
|
||||
if (!model) {
|
||||
return { stack: stackName, renderable: false, renderError, runtime, networks: [], services: [], drift: EMPTY_DRIFT };
|
||||
}
|
||||
|
||||
const networks: NetworkFactNetwork[] = Object.entries(model.networks).map(([key, res]) => ({
|
||||
key,
|
||||
name: res.name,
|
||||
external: res.external,
|
||||
internal: res.internal,
|
||||
createdByStack: !res.external && key !== 'default',
|
||||
}));
|
||||
|
||||
const services: NetworkFactService[] = model.services.map(s => ({
|
||||
name: s.name,
|
||||
networks: s.networks.map(n => ({ key: n.key, aliases: n.aliases })),
|
||||
publishedPorts: s.ports.map(p => ({
|
||||
hostIp: p.hostIp,
|
||||
startPort: p.startPort,
|
||||
endPort: p.endPort,
|
||||
protocol: p.protocol,
|
||||
allInterfaces: isAllInterfaces(p.hostIp),
|
||||
loopbackOnly: isLoopback(p.hostIp),
|
||||
})),
|
||||
networkMode: s.networkMode,
|
||||
extraHosts: s.extraHosts,
|
||||
}));
|
||||
|
||||
const drift = snapshot ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName) : EMPTY_DRIFT;
|
||||
|
||||
return { stack: stackName, renderable: true, renderError: null, runtime, networks, services, drift };
|
||||
}
|
||||
|
||||
/** Render the effective model and snapshot the node, then assemble the facts. */
|
||||
export async function buildStackNetworkFacts(nodeId: number, stackName: string): Promise<StackNetworkFacts> {
|
||||
const fsSvc = FileSystemService.getInstance(nodeId);
|
||||
|
||||
let model: EffectiveModel | null = null;
|
||||
let renderError: string | null = null;
|
||||
try {
|
||||
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (result.rendered !== null) {
|
||||
try {
|
||||
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
||||
} catch (parseErr) {
|
||||
// JSON.parse errors carry no file content, so the message is safe to log.
|
||||
console.warn('[NetworkInspector] Effective model parse failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
|
||||
renderError = 'Sencho could not parse the rendered Compose model.';
|
||||
}
|
||||
} else {
|
||||
// Raw stderr can echo file content/secrets and is never surfaced; only the
|
||||
// names of any missing required variables, otherwise a generic nudge.
|
||||
const missing = parseMissingRequiredVars(result.stderr);
|
||||
renderError = missing.length
|
||||
? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
|
||||
: 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.';
|
||||
}
|
||||
} catch (err) {
|
||||
// Spawn failure (docker unavailable). Redact defensively.
|
||||
renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|
||||
|| 'Sencho could not run docker compose on this node.';
|
||||
}
|
||||
|
||||
// A null snapshot means the runtime is unavailable (drift is then left empty),
|
||||
// never confused with a real empty snapshot.
|
||||
let snapshot: DependencySnapshot | null = null;
|
||||
try {
|
||||
const knownStacks = await fsSvc.getStacks();
|
||||
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
|
||||
} catch (error) {
|
||||
console.warn('[NetworkInspector] Node snapshot unavailable for %s; runtime facts skipped:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
}
|
||||
|
||||
return assembleStackNetworkFacts(stackName, model, renderError, snapshot);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* A cheap per-node networking summary for the Fleet view filter: which stacks
|
||||
* are exposed beyond loopback, which publish ports without an exposure intent,
|
||||
* and which have network drift. It reads each stack's compose with the light
|
||||
* dependency parser (no `docker compose config` render) and one Docker snapshot,
|
||||
* so it stays inexpensive across a node's full stack set.
|
||||
*/
|
||||
import DockerController, { type DependencySnapshot } from '../DockerController';
|
||||
import { FileSystemService } from '../FileSystemService';
|
||||
import { DatabaseService } from '../DatabaseService';
|
||||
import { parseComposeDependencies } from '../../helpers/composeDependencyParse';
|
||||
import { assembleStackDrift } from '../DriftDetectionService';
|
||||
import { isLoopback } from './normalize';
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { sanitizeForLog } from '../../utils/safeLog';
|
||||
|
||||
/** One signal bucket: how many stacks match, and which. */
|
||||
export interface NetworkingSummaryBucket {
|
||||
count: number;
|
||||
stacks: string[];
|
||||
}
|
||||
|
||||
export interface NodeNetworkingSummary {
|
||||
/** Stacks that publish a host port on a non-loopback interface. */
|
||||
exposed: NetworkingSummaryBucket;
|
||||
/** Stacks that publish ports but have no stack-level exposure intent set. */
|
||||
unknownExposure: NetworkingSummaryBucket;
|
||||
/** Stacks whose running networking disagrees with the Compose file. */
|
||||
networkDrift: NetworkingSummaryBucket;
|
||||
}
|
||||
|
||||
const bucket = (stacks: string[]): NetworkingSummaryBucket => ({ count: stacks.length, stacks });
|
||||
|
||||
export async function computeNodeNetworkingSummary(nodeId: number): Promise<NodeNetworkingSummary> {
|
||||
const fsSvc = FileSystemService.getInstance(nodeId);
|
||||
const db = DatabaseService.getInstance();
|
||||
const stacks = await fsSvc.getStacks();
|
||||
|
||||
// One snapshot for the whole node; absent when Docker is unreachable, in which
|
||||
// case drift is simply not computed (the declared signals still work).
|
||||
let snapshot: DependencySnapshot | null = null;
|
||||
try {
|
||||
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
|
||||
} catch (error) {
|
||||
console.warn('[NetworkingSummary] Snapshot unavailable on node %d; drift skipped:',
|
||||
nodeId, sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
}
|
||||
|
||||
const exposed: string[] = [];
|
||||
const unknownExposure: string[] = [];
|
||||
const networkDrift: string[] = [];
|
||||
|
||||
for (const stack of stacks) {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsSvc.getStackContent(stack);
|
||||
} catch {
|
||||
continue; // unreadable compose: nothing to summarize for this stack
|
||||
}
|
||||
const declared = parseComposeDependencies(content);
|
||||
if (declared.parseError) continue;
|
||||
|
||||
const publishesPort = declared.services.some(s => s.ports.length > 0);
|
||||
if (declared.services.some(s => s.ports.some(p => !isLoopback(p.hostIp)))) exposed.push(stack);
|
||||
|
||||
if (publishesPort) {
|
||||
// Unknown only when a publishing service is effectively unclassified: a
|
||||
// service-level intent overrides the stack-level row for that service.
|
||||
const intents = db.getStackExposureIntents(nodeId, stack);
|
||||
const stackIntent = intents.find(i => i.service === '')?.intent ?? null;
|
||||
const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent]));
|
||||
const anyUnclassified = declared.services
|
||||
.filter(s => s.ports.length > 0)
|
||||
.some(s => {
|
||||
const intent = byService.get(s.name) ?? stackIntent;
|
||||
return intent === null || intent === 'unknown';
|
||||
});
|
||||
if (anyUnclassified) unknownExposure.push(stack);
|
||||
}
|
||||
|
||||
if (snapshot) {
|
||||
// declared.parseError is already excluded above, so the drift report is authoritative.
|
||||
const containers = snapshot.containers.filter(c => c.stack === stack);
|
||||
const report = assembleStackDrift({ stack, declared, containers, networks: snapshot.networks });
|
||||
if (report.findings.some(f => f.kind === 'network-undeclared' || f.kind === 'network-missing')) networkDrift.push(stack);
|
||||
}
|
||||
}
|
||||
|
||||
return { exposed: bucket(exposed), unknownExposure: bucket(unknownExposure), networkDrift: bucket(networkDrift) };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Backend-local networking helpers and the normalized network model that lets
|
||||
* the Inspector (rendered EffectiveModel) and Drift (raw DeclaredCompose) feed
|
||||
* one comparison. The rendered model already resolves resource names; the raw
|
||||
* declared model does not, so each shape gets its own adapter and both emit the
|
||||
* same key-space NormalizedNetworkModel. Do not import the frontend's access-url
|
||||
* parser here; this is the backend copy.
|
||||
*/
|
||||
import type { EffectiveModel } from '../preflight/effectiveModel';
|
||||
import type { DeclaredCompose } from '../../helpers/composeDependencyParse';
|
||||
import type { DependencySnapshot } from '../DockerController';
|
||||
import type { NetworkDriftFacts } from './types';
|
||||
|
||||
/** Container states that count as "deployed" for drift, matching DriftDetectionService. */
|
||||
const RUNNING_STATES = new Set(['running', 'restarting']);
|
||||
const SYSTEM_NETWORK_NAMES = new Set(['bridge', 'host', 'none']);
|
||||
|
||||
export function isAllInterfaces(ip: string): boolean {
|
||||
return ip === '' || ip === '0.0.0.0' || ip === '::' || ip === '[::]';
|
||||
}
|
||||
|
||||
export function isLoopback(ip: string): boolean {
|
||||
return ip === '127.0.0.1' || ip === '::1' || ip === '[::1]';
|
||||
}
|
||||
|
||||
/** Resolved runtime name of a top-level network/volume: a `name:` override wins,
|
||||
* otherwise compose prefixes the project (`<project>_<key>`). */
|
||||
export function runtimeResourceName(projectName: string, key: string, declaredName: string | undefined): string {
|
||||
return declaredName && declaredName !== key ? declaredName : `${projectName}_${key}`;
|
||||
}
|
||||
|
||||
/** Extract host port numbers referenced by free-text access URLs, for the
|
||||
* port-vs-documented finding. Heuristic: matches `:PORT` boundaries. */
|
||||
export function parseAccessUrlPorts(text: string): Set<number> {
|
||||
const ports = new Set<number>();
|
||||
const re = /:(\d{1,5})(?=[/\s)\];]|$)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const p = parseInt(m[1], 10);
|
||||
if (p > 0 && p <= 65535) ports.add(p);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/** Key-space network model both adapters emit, so the comparison is shape-agnostic. */
|
||||
export interface NormalizedNetworkModel {
|
||||
projectName: string;
|
||||
/** By network key → resolved runtime name + external flag. */
|
||||
networks: Record<string, { runtimeName: string; external: boolean }>;
|
||||
services: { name: string; networkKeys: string[]; networkMode?: string }[];
|
||||
}
|
||||
|
||||
/** Rendered model: resource names are already resolved by `docker compose config`. */
|
||||
export function fromEffectiveModel(m: EffectiveModel): NormalizedNetworkModel {
|
||||
const networks: NormalizedNetworkModel['networks'] = {};
|
||||
for (const [key, res] of Object.entries(m.networks)) {
|
||||
networks[key] = { runtimeName: res.name, external: res.external };
|
||||
}
|
||||
return {
|
||||
projectName: m.projectName,
|
||||
networks,
|
||||
services: m.services.map(s => ({ name: s.name, networkKeys: s.networks.map(n => n.key), networkMode: s.networkMode })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Raw declared model: resolve runtime names here (project prefix / `name:` override). */
|
||||
export function fromDeclaredCompose(m: DeclaredCompose, projectName: string): NormalizedNetworkModel {
|
||||
const networks: NormalizedNetworkModel['networks'] = {};
|
||||
for (const [key, res] of Object.entries(m.networks)) {
|
||||
networks[key] = { runtimeName: runtimeResourceName(projectName, key, res.name), external: res.external };
|
||||
}
|
||||
return {
|
||||
projectName,
|
||||
networks,
|
||||
services: m.services.map(s => ({ name: s.name, networkKeys: s.networks })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare declared networks against the live snapshot. Only running/restarting
|
||||
* containers of this stack count; system networks (bridge/host/none), the
|
||||
* implicit default network, and external (shared) networks are not flagged.
|
||||
*/
|
||||
export function compareStackNetworks(
|
||||
declared: NormalizedNetworkModel,
|
||||
snapshot: DependencySnapshot,
|
||||
stackName: string,
|
||||
): NetworkDriftFacts {
|
||||
const runtimeOnlyAttachments: NetworkDriftFacts['runtimeOnlyAttachments'] = [];
|
||||
const foreignNetworkAttachments: NetworkDriftFacts['foreignNetworkAttachments'] = [];
|
||||
|
||||
// Every declared network (external included) resolves into this set, so an
|
||||
// attachment to a declared external/shared network is treated as declared
|
||||
// below, not as foreign.
|
||||
const declaredRuntimeNames = new Set<string>();
|
||||
for (const net of Object.values(declared.networks)) declaredRuntimeNames.add(net.runtimeName);
|
||||
// The implicit default network compose always provisions counts as declared.
|
||||
declaredRuntimeNames.add(`${declared.projectName}_default`);
|
||||
|
||||
const networkByName = new Map(snapshot.networks.map(n => [n.name, n]));
|
||||
const stackContainers = snapshot.containers.filter(c => c.stack === stackName && RUNNING_STATES.has(c.state));
|
||||
const usedRuntimeNames = new Set<string>();
|
||||
|
||||
for (const c of stackContainers) {
|
||||
for (const attached of c.networks) {
|
||||
const net = networkByName.get(attached.name);
|
||||
if (SYSTEM_NETWORK_NAMES.has(attached.name) || net?.isSystem) continue;
|
||||
if (declaredRuntimeNames.has(attached.name)) { usedRuntimeNames.add(attached.name); continue; }
|
||||
if (net?.stack === stackName || attached.name.startsWith(`${declared.projectName}_`)) {
|
||||
runtimeOnlyAttachments.push({ container: c.name, service: c.service, network: attached.name });
|
||||
} else {
|
||||
foreignNetworkAttachments.push({ container: c.name, network: attached.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeNetworkNames = new Set(snapshot.networks.map(n => n.name));
|
||||
const declaredButUnused: string[] = [];
|
||||
const missingFromRuntime: string[] = [];
|
||||
for (const [key, net] of Object.entries(declared.networks)) {
|
||||
if (net.external || key === 'default') continue;
|
||||
if (!runtimeNetworkNames.has(net.runtimeName)) { missingFromRuntime.push(net.runtimeName); continue; }
|
||||
if (!usedRuntimeNames.has(net.runtimeName)) declaredButUnused.push(key);
|
||||
}
|
||||
|
||||
return { runtimeOnlyAttachments, declaredButUnused, missingFromRuntime, foreignNetworkAttachments };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Types for the Compose Network Inspector: the structural networking facts a
|
||||
* Community user can read for a stack, derived from the rendered effective model
|
||||
* and the live Docker snapshot. Like the preflight model, these facts never
|
||||
* carry an environment value or a label value.
|
||||
*/
|
||||
|
||||
/** Valid exposure-intent values (stored separately from these facts). The
|
||||
* union is derived from this array so the two cannot drift; the frontend
|
||||
* mirrors the same set. */
|
||||
export const EXPOSURE_INTENTS = [
|
||||
'internal', 'same-node', 'lan', 'reverse-proxy', 'public', 'temporary', 'unknown',
|
||||
] as const;
|
||||
|
||||
export type ExposureIntent = typeof EXPOSURE_INTENTS[number];
|
||||
|
||||
/** A top-level network declared by the stack's effective model. */
|
||||
export interface NetworkFactNetwork {
|
||||
/** Compose network key. */
|
||||
key: string;
|
||||
/** Resolved docker network name. */
|
||||
name: string;
|
||||
external: boolean;
|
||||
internal: boolean;
|
||||
/** True when deploying this stack creates the network (not external, not the implicit default). */
|
||||
createdByStack: boolean;
|
||||
}
|
||||
|
||||
/** A host-published port of a service. */
|
||||
export interface NetworkFactPort {
|
||||
/** '' / '0.0.0.0' / '::' means all interfaces. */
|
||||
hostIp: string;
|
||||
startPort: number;
|
||||
endPort: number;
|
||||
protocol: string;
|
||||
/** Bound on every interface (broad exposure). */
|
||||
allInterfaces: boolean;
|
||||
/** Bound only to loopback (127.0.0.1 / ::1). */
|
||||
loopbackOnly: boolean;
|
||||
}
|
||||
|
||||
/** One service's networking facts. */
|
||||
export interface NetworkFactService {
|
||||
name: string;
|
||||
/** Network membership by network key, with aliases. */
|
||||
networks: { key: string; aliases: string[] }[];
|
||||
publishedPorts: NetworkFactPort[];
|
||||
networkMode?: string;
|
||||
extraHosts: string[];
|
||||
}
|
||||
|
||||
/** Runtime-vs-Compose disagreements, computed only when the runtime is available. */
|
||||
export interface NetworkDriftFacts {
|
||||
/** Running container attached to a stack-owned network not declared in Compose. */
|
||||
runtimeOnlyAttachments: { container: string; service: string | null; network: string }[];
|
||||
/** Declared network (non-external, non-default) that no running service uses. */
|
||||
declaredButUnused: string[];
|
||||
/** Declared network whose runtime network does not exist. */
|
||||
missingFromRuntime: string[];
|
||||
/** Running container attached to a network owned by another stack or unmanaged. */
|
||||
foreignNetworkAttachments: { container: string; network: string }[];
|
||||
}
|
||||
|
||||
export type NetworkRuntimeState = 'available' | 'unavailable';
|
||||
|
||||
/**
|
||||
* The full per-stack networking facts payload returned by GET /:stackName/networking.
|
||||
* Kept a flat DTO (the frontend mirrors it) rather than a discriminated union;
|
||||
* the pairing invariants are enforced by the single producer
|
||||
* (`assembleStackNetworkFacts`), not by the type: when `renderable` is false,
|
||||
* `renderError` is set and `networks`/`services`/`drift` are empty; when
|
||||
* `runtime` is `'unavailable'`, `drift` is empty (never computed against an
|
||||
* absent snapshot).
|
||||
*/
|
||||
export interface StackNetworkFacts {
|
||||
stack: string;
|
||||
/** True when the effective model rendered; false carries only a redacted, structural error. */
|
||||
renderable: boolean;
|
||||
/** Redacted render error when not renderable; never raw docker stderr. */
|
||||
renderError: string | null;
|
||||
/** Whether the live Docker snapshot was available; drift is computed only when 'available'. */
|
||||
runtime: NetworkRuntimeState;
|
||||
networks: NetworkFactNetwork[];
|
||||
services: NetworkFactService[];
|
||||
drift: NetworkDriftFacts;
|
||||
}
|
||||
@@ -21,6 +21,14 @@ export interface EffBind {
|
||||
target: string;
|
||||
}
|
||||
|
||||
/** A service's membership in one top-level network, keyed by the network KEY
|
||||
* (not the resolved docker name) so it lines up with the `networks` map and
|
||||
* with the authored `DeclaredService.networks`. */
|
||||
export interface EffServiceNetwork {
|
||||
key: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export interface EffService {
|
||||
name: string;
|
||||
image?: string;
|
||||
@@ -37,12 +45,20 @@ export interface EffService {
|
||||
user?: string;
|
||||
/** Environment KEY names only. Values are never extracted. */
|
||||
envKeys: string[];
|
||||
/** Network membership by network key, with any aliases. */
|
||||
networks: EffServiceNetwork[];
|
||||
/** `extra_hosts` entries as `host:value` strings (host names / static IPs, never secrets). */
|
||||
extraHosts: string[];
|
||||
/** Label KEY names only. Values are never extracted (a label value can carry a secret). */
|
||||
labelKeys: string[];
|
||||
}
|
||||
|
||||
export interface EffResource {
|
||||
/** Resolved docker name (compose config fills this in). */
|
||||
name: string;
|
||||
external: boolean;
|
||||
/** Top-level `internal: true` (no outbound/host connectivity for the network). */
|
||||
internal: boolean;
|
||||
}
|
||||
|
||||
export interface EffectiveModel {
|
||||
@@ -132,12 +148,56 @@ function envKeysOf(env: unknown): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Label KEY names only. A label VALUE can carry a secret, so it is never read. */
|
||||
function labelKeysOf(labels: unknown): string[] {
|
||||
if (Array.isArray(labels)) {
|
||||
return labels
|
||||
.map(e => str(e))
|
||||
.filter((s): s is string => s !== undefined)
|
||||
.map(s => s.split('=')[0])
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (labels && typeof labels === 'object') return Object.keys(labels as Record<string, unknown>);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Service network membership (list or map form), keyed by network key, with aliases. */
|
||||
function parseServiceNetworks(networks: unknown): EffServiceNetwork[] {
|
||||
if (Array.isArray(networks)) {
|
||||
return networks
|
||||
.map(n => str(n))
|
||||
.filter((s): s is string => s !== undefined)
|
||||
.map(key => ({ key, aliases: [] as string[] }));
|
||||
}
|
||||
if (networks && typeof networks === 'object') {
|
||||
return Object.entries(networks as Record<string, unknown>).map(([key, cfg]) => {
|
||||
const aliasesRaw = (cfg && typeof cfg === 'object') ? (cfg as Record<string, unknown>).aliases : undefined;
|
||||
const aliases = Array.isArray(aliasesRaw)
|
||||
? aliasesRaw.map(a => str(a)).filter((a): a is string => a !== undefined)
|
||||
: [];
|
||||
return { key, aliases };
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** `extra_hosts` (list `host:ip` or map `{host: ip}`) → `host:value` strings. Infra facts, not secrets. */
|
||||
function parseExtraHosts(extraHosts: unknown): string[] {
|
||||
if (Array.isArray(extraHosts)) {
|
||||
return extraHosts.map(e => str(e)).filter((s): s is string => s !== undefined);
|
||||
}
|
||||
if (extraHosts && typeof extraHosts === 'object') {
|
||||
return Object.entries(extraHosts as Record<string, unknown>).map(([host, ip]) => `${host}:${str(ip) ?? ''}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseResources(value: unknown): Record<string, EffResource> {
|
||||
const out: Record<string, EffResource> = {};
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
const o = (entry ?? {}) as Record<string, unknown>;
|
||||
out[key] = { name: str(o.name) ?? key, external: o.external === true };
|
||||
out[key] = { name: str(o.name) ?? key, external: o.external === true, internal: o.internal === true };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -177,6 +237,9 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string
|
||||
containerName: str(svc.container_name),
|
||||
user: str(svc.user),
|
||||
envKeys: envKeysOf(svc.environment),
|
||||
networks: parseServiceNetworks(svc.networks),
|
||||
extraHosts: parseExtraHosts(svc.extra_hosts),
|
||||
labelKeys: labelKeysOf(svc.labels),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBinding } from './types';
|
||||
import type { EffService, EffPortSpec } from './effectiveModel';
|
||||
import type { ExposureIntent } from '../network/types';
|
||||
import { isLoopback } from '../network/normalize';
|
||||
|
||||
/** Higher number = more severe. Used to derive a run's overall status. */
|
||||
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };
|
||||
@@ -521,6 +523,150 @@ const effectiveModelExpanded: PreflightRule = {
|
||||
},
|
||||
};
|
||||
|
||||
// ----- exposure-intent rules ------------------------------------------------
|
||||
// These read the user's stored exposure classification (resolved per service)
|
||||
// and the dossier's documented access URLs from the context, plus a sensitivity
|
||||
// heuristic on the image name for the broad-exposure rule.
|
||||
|
||||
/** Image-name hints for a database or admin service that should rarely be broadly exposed. */
|
||||
const SENSITIVE_IMAGE_HINTS = [
|
||||
'postgres', 'mysql', 'mariadb', 'mongo', 'redis', 'memcached', 'elasticsearch',
|
||||
'adminer', 'phpmyadmin', 'portainer', 'docker-socket-proxy',
|
||||
];
|
||||
/** Reverse-proxy label-key base names; matched as the key itself or a `base.` prefix (case-insensitive). */
|
||||
const REVERSE_PROXY_LABEL_HINTS = ['traefik', 'caddy', 'virtual.host'];
|
||||
|
||||
/** A service's effective intent: its own override, else the stack-level intent. */
|
||||
function effectiveIntent(ctx: PreflightContext, service: string): ExposureIntent | null {
|
||||
return ctx.serviceIntents[service] ?? ctx.stackIntent;
|
||||
}
|
||||
|
||||
const exposureInternalPublished: PreflightRule = {
|
||||
id: 'exposure-internal-published',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
const findings: PreflightFinding[] = [];
|
||||
for (const svc of ctx.model.services) {
|
||||
const intent = effectiveIntent(ctx, svc.name);
|
||||
if (intent !== 'internal' && intent !== 'same-node') continue;
|
||||
// same-node tolerates a loopback binding; internal tolerates no host port.
|
||||
const offending = svc.ports.filter(p => intent === 'internal' || !isLoopback(p.hostIp));
|
||||
if (offending.length === 0) continue;
|
||||
findings.push({
|
||||
ruleId: 'exposure-internal-published',
|
||||
severity: 'high',
|
||||
title: `"${svc.name}" is classified ${intent} but publishes a host port`,
|
||||
message: `Service "${svc.name}" is classified as ${intent} exposure, but it publishes ${offending.map(specLabel).join(', ')} to the host, which contradicts that intent.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: intent === 'same-node'
|
||||
? 'Bind the port to loopback (127.0.0.1), remove it, or reclassify the exposure intent.'
|
||||
: 'Remove the published port or reclassify the exposure intent.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
|
||||
const exposureUnclassified: PreflightRule = {
|
||||
id: 'exposure-unclassified',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
const publishing = ctx.model.services.filter(s => s.ports.length > 0);
|
||||
if (publishing.length === 0) return [];
|
||||
// Fire only when a publishing service is still effectively unclassified: a
|
||||
// service-level intent suppresses the warning for that service even when the
|
||||
// stack itself is unset.
|
||||
const unclassified = publishing.some(s => {
|
||||
const intent = effectiveIntent(ctx, s.name);
|
||||
return intent === null || intent === 'unknown';
|
||||
});
|
||||
if (!unclassified) return [];
|
||||
return [{
|
||||
ruleId: 'exposure-unclassified',
|
||||
severity: 'warning',
|
||||
title: 'Stack publishes ports without an exposure intent',
|
||||
message: 'This stack publishes one or more host ports but has no exposure intent set. Classifying it (internal, LAN, reverse proxy, public) lets Sencho flag mismatches later.',
|
||||
remediation: 'Set the stack exposure intent in the Networking tab.',
|
||||
}];
|
||||
},
|
||||
};
|
||||
|
||||
const exposurePortVsDossier: PreflightRule = {
|
||||
id: 'exposure-port-vs-dossier',
|
||||
run(ctx) {
|
||||
if (!ctx.model || !ctx.hasAccessUrls) return [];
|
||||
const findings: PreflightFinding[] = [];
|
||||
for (const svc of ctx.model.services) {
|
||||
const undocumented = [...new Set(svc.ports.flatMap(portsOf).filter(p => !ctx.accessUrlPorts.has(p)))];
|
||||
if (undocumented.length === 0) continue;
|
||||
findings.push({
|
||||
ruleId: 'exposure-port-vs-dossier',
|
||||
severity: 'warning',
|
||||
title: 'Published port is not in the documented access URLs',
|
||||
message: `Service "${svc.name}" publishes ${undocumented.join(', ')}, which ${undocumented.length > 1 ? 'are' : 'is'} not referenced by the dossier's documented access URLs. The documentation may be stale.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Update the access URLs in the Stack Dossier, or change the published port.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
|
||||
const reverseProxyUndocumented: PreflightRule = {
|
||||
id: 'reverse-proxy-undocumented',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
// Already documented or intentionally reverse-proxied at the stack level.
|
||||
if (ctx.hasAccessUrls || ctx.stackIntent === 'reverse-proxy') return [];
|
||||
const findings: PreflightFinding[] = [];
|
||||
for (const svc of ctx.model.services) {
|
||||
if (effectiveIntent(ctx, svc.name) === 'reverse-proxy') continue;
|
||||
const hasRpLabel = svc.labelKeys.some(k => {
|
||||
const lk = k.toLowerCase();
|
||||
return REVERSE_PROXY_LABEL_HINTS.some(h => lk === h || lk.startsWith(`${h}.`));
|
||||
});
|
||||
if (!hasRpLabel) continue;
|
||||
findings.push({
|
||||
ruleId: 'reverse-proxy-undocumented',
|
||||
severity: 'warning',
|
||||
title: `"${svc.name}" has reverse-proxy labels but no documented URL`,
|
||||
message: `Service "${svc.name}" carries reverse-proxy labels, but the stack has no documented access URL or reverse-proxy intent, so how it is reached is unclear.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Document the access URL in the Stack Dossier or set the exposure intent to reverse proxy.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
|
||||
const sensitiveServiceBroadExposure: PreflightRule = {
|
||||
id: 'sensitive-service-broad-exposure',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
const findings: PreflightFinding[] = [];
|
||||
for (const svc of ctx.model.services) {
|
||||
if (svc.image === undefined) continue;
|
||||
const image = svc.image.toLowerCase();
|
||||
if (!SENSITIVE_IMAGE_HINTS.some(h => image.includes(h))) continue;
|
||||
const broad = svc.ports.filter(p => isAllInterfaces(p.hostIp));
|
||||
if (broad.length === 0) continue;
|
||||
findings.push({
|
||||
ruleId: 'sensitive-service-broad-exposure',
|
||||
severity: 'high',
|
||||
title: `Sensitive service "${svc.name}" is exposed on all interfaces`,
|
||||
message: `Service "${svc.name}" looks like a database or admin service (${svc.image}) and publishes ${broad.map(specLabel).join(', ')} on all interfaces. Broadly exposing it is a common source of compromise.`,
|
||||
sourcePath: svc.name,
|
||||
service: svc.name,
|
||||
remediation: 'Bind it to a specific interface such as 127.0.0.1, or keep it on an internal network only.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
};
|
||||
|
||||
/** The ordered registry. Order is the display order within a severity group. */
|
||||
export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
renderFailed,
|
||||
@@ -544,6 +690,11 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
newVolume,
|
||||
containerNameInternalDup,
|
||||
containerNameCollision,
|
||||
exposureInternalPublished,
|
||||
sensitiveServiceBroadExposure,
|
||||
exposureUnclassified,
|
||||
exposurePortVsDossier,
|
||||
reverseProxyUndocumented,
|
||||
effectiveModelExpanded,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EffectiveModel } from './effectiveModel';
|
||||
import type { ExposureIntent } from '../network/types';
|
||||
|
||||
/** Graded severity of a single preflight finding. */
|
||||
export type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
@@ -92,4 +93,12 @@ export interface PreflightContext {
|
||||
existingVolumeNames: Set<string>;
|
||||
existingContainers: { name: string; stack: string | null }[];
|
||||
bindChecks: BindCheck[];
|
||||
/** Stack-level exposure classification, or null when unset. */
|
||||
stackIntent: ExposureIntent | null;
|
||||
/** Per-service exposure overrides (a service falls back to stackIntent when absent). */
|
||||
serviceIntents: Record<string, ExposureIntent>;
|
||||
/** Host ports referenced by the dossier's documented access URLs. */
|
||||
accessUrlPorts: Set<number>;
|
||||
/** Whether the dossier records any access URL (gates the port-vs-documented rule). */
|
||||
hasAccessUrls: boolean;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"features/stack-dossier",
|
||||
"features/stack-drift",
|
||||
"features/compose-doctor",
|
||||
"features/compose-networking",
|
||||
"features/stack-labels",
|
||||
"features/sidebar"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Compose Networking
|
||||
description: Inspect how a stack is networked and exposed, classify what its exposure should be, and see where the running containers disagree with the Compose file, all without opening a terminal.
|
||||
---
|
||||
|
||||
The **Networking** tab in the right-hand **Anatomy** panel answers a Compose-first question: *is this stack networked and exposed the way the Compose file says it is, and are the dangerous or confusing parts visible before I deploy?* It renders the effective Compose model, the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied, pairs it with the live Docker state when that node is reachable, and shows the result as plain facts.
|
||||
|
||||
The view is read-only with respect to the stack: it never changes a deployment. The one thing you can edit here is the stack's *exposure intent*, which is stored separately so Sencho can flag mismatches over time.
|
||||
|
||||
Compose Networking never shows a secret value. It reads the structure of the model, network names, service-to-network membership, published ports, and the *names* of environment variables and labels, but never their values, so nothing sensitive appears in the view or the logs.
|
||||
|
||||
## Networks
|
||||
|
||||
The top of the tab lists the stack's networks with the facts that matter:
|
||||
|
||||
- The resolved Docker network name, and the Compose key it came from.
|
||||
- **external** when the network is one the stack expects to already exist on the node.
|
||||
- **internal** when the network has no outbound or host connectivity.
|
||||
- **created by stack** when deploying the stack will create the network.
|
||||
|
||||
## Published ports and bindings
|
||||
|
||||
Each service lists its published ports with the interface they bind to:
|
||||
|
||||
- **all interfaces** marks a port published on `0.0.0.0`, reachable from every network the host is attached to.
|
||||
- **loopback** marks a port bound only to `127.0.0.1`, reachable only from the host itself.
|
||||
- A specific address is shown as-is.
|
||||
|
||||
The tab also surfaces each service's network membership and aliases, `network_mode` (`host`, `none`, `service:`, and `container:` modes are called out), and `extra_hosts` entries.
|
||||
|
||||
## Exposure intent
|
||||
|
||||
Exposure intent is how you tell Sencho what a stack *should* be reachable from, so it can warn you when the Compose file says otherwise. Set it for the whole stack, or override it per service:
|
||||
|
||||
| Intent | Meaning |
|
||||
|--------|---------|
|
||||
| **internal** | Not published to the host at all. |
|
||||
| **same-node** | Reachable only from the host (loopback bindings). |
|
||||
| **LAN** | Published for the local network. |
|
||||
| **reverse proxy** | Reached through a reverse proxy, not a direct host port. |
|
||||
| **public** | Intentionally reachable from the internet. |
|
||||
| **temporary** | A short-lived exposure (a classification label only). |
|
||||
| **unknown** | Not yet classified. |
|
||||
|
||||
A service with no intent of its own inherits the stack's. Clearing a service returns it to **inherit**; clearing the stack returns it to unclassified. Sencho stores the intent apart from the generated facts, so when the two drift apart the **Doctor** tab can point it out.
|
||||
|
||||
## Findings
|
||||
|
||||
Risk findings live in the **Doctor** tab, which reads the same model. On top of its existing deploy and security checks (host-port conflicts, missing external networks, host networking, broad exposure, and a mounted Docker socket), it adds exposure-aware findings:
|
||||
|
||||
- A service classified **internal** or **same-node** that publishes a host port.
|
||||
- A database or admin image published on every interface.
|
||||
- A stack that publishes ports but has no exposure intent set.
|
||||
- A published port that the Stack Dossier's documented access URLs do not mention.
|
||||
- Reverse-proxy labels with no documented URL or reverse-proxy intent.
|
||||
|
||||
## Runtime drift
|
||||
|
||||
When the node is reachable, the tab compares the running containers to the Compose file and reports where they disagree: a container on a network the file does not declare, a container on a network owned by another stack, and a declared network that no running service uses or that is missing from the runtime. These also appear on the **Drift** tab, where their history is tracked over time. When the node is not reachable, the tab shows the declared model only and says so.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="The Networking tab is not there">
|
||||
The tab appears once the node advertises support for it. A node running an older Sencho version hides the tab until it is updated.
|
||||
</Accordion>
|
||||
<Accordion title="The view says the runtime is unavailable">
|
||||
Sencho could not reach Docker on that node, so it shows the declared Compose model only and skips runtime drift. The networks, ports, and exposure facts are still accurate; the live comparison resumes once the node is reachable.
|
||||
</Accordion>
|
||||
<Accordion title="It says the model cannot render">
|
||||
`docker compose config` could not produce an effective model, usually a YAML error, an unresolved include or merge, or a required variable with no value. Fix the reported problem and reopen the tab.
|
||||
</Accordion>
|
||||
<Accordion title="I cannot change the exposure intent">
|
||||
Editing the intent needs stack edit access. With read access you can see the current classification but not change it.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
GlobalCommandPaletteProvider,
|
||||
GlobalCommandPaletteTrigger,
|
||||
} from './GlobalCommandPalette';
|
||||
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
||||
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
||||
import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events';
|
||||
import type { SenchoOpenLogsDetail, SenchoOpenStackDetail } from '@/lib/events';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
@@ -337,6 +337,21 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Open a stack from another surface (e.g. a Resources network card). Reuses
|
||||
// the Fleet navigation, which loads the stack on its node (switching nodes if
|
||||
// needed) and flips to the editor view. A latest-ref keeps the window handler
|
||||
// current without re-subscribing every render.
|
||||
const openStackFromEventRef = useRef(handleFleetNavigateToNode);
|
||||
useEffect(() => { openStackFromEventRef.current = handleFleetNavigateToNode; });
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<SenchoOpenStackDetail>).detail;
|
||||
if (detail) openStackFromEventRef.current(detail.nodeId, detail.stackName);
|
||||
};
|
||||
window.addEventListener(SENCHO_OPEN_STACK_EVENT, handler);
|
||||
return () => window.removeEventListener(SENCHO_OPEN_STACK_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
// "Inspect" a node from the mobile Fleet screen: switch to it and land on its
|
||||
// stack list.
|
||||
const handleInspectNode = (nodeId: number) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||
import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control';
|
||||
import { LabelDot } from '../LabelPill';
|
||||
import type { LabelColor } from '../label-types';
|
||||
import type { ViewMode, SortField, FilterStatus, FilterType, FleetPreferences, FleetPaletteEntry } from './types';
|
||||
import type { ViewMode, SortField, FilterStatus, FilterType, FilterNetworking, FleetPreferences, FleetPaletteEntry } from './types';
|
||||
|
||||
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
|
||||
@@ -70,6 +70,7 @@ export function OverviewToolbar({
|
||||
(prefs.filterStatus !== 'all' ? 1 : 0) +
|
||||
(prefs.filterType !== 'all' ? 1 : 0) +
|
||||
(prefs.filterCritical ? 1 : 0) +
|
||||
(prefs.filterNetworking !== 'all' ? 1 : 0) +
|
||||
(labelFilters.size > 0 ? 1 : 0);
|
||||
|
||||
const paletteOptions = useMemo(
|
||||
@@ -199,6 +200,22 @@ export function OverviewToolbar({
|
||||
Critical Only
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className={FILTER_SECTION_LABEL_CLASS}>Networking</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{([['all', 'All'], ['exposed', 'Exposed'], ['unknown', 'Unknown'], ['drift', 'Drift']] as [FilterNetworking, string][]).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={prefs.filterNetworking === value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => onPrefsChange({ filterNetworking: value })}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{fleetPalette.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className={FILTER_SECTION_LABEL_CLASS}>Tags</label>
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock('../../fleet/FleetTopology', () => ({ FleetTopology: () => <div data-tes
|
||||
import { OverviewTab } from '../OverviewTab';
|
||||
import type { FleetNode, FleetPreferences } from '../types';
|
||||
|
||||
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
||||
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' };
|
||||
|
||||
function node(id: number, name: string): FleetNode {
|
||||
return { id, name, type: 'remote', status: 'online', stats: null, systemStats: null, stacks: null, cordoned: false, cordoned_at: null, cordoned_reason: null };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { OverviewToolbar } from '../OverviewToolbar';
|
||||
import type { FleetPaletteEntry, FleetPreferences } from '../types';
|
||||
|
||||
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
||||
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' };
|
||||
|
||||
function props(overrides: Partial<React.ComponentProps<typeof OverviewToolbar>> = {}) {
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,7 @@ const NODES: FleetNode[] = [
|
||||
{ id: 3, name: 'Charlie', type: 'remote', status: 'offline', stats: null, systemStats: null, stacks: null, cordoned: false, cordoned_at: null, cordoned_reason: null },
|
||||
];
|
||||
|
||||
const DEFAULT_PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
||||
const DEFAULT_PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' };
|
||||
|
||||
function setup(prefs: Partial<FleetPreferences> = {}) {
|
||||
const updatePrefs = vi.fn();
|
||||
@@ -47,6 +47,13 @@ beforeEach(() => {
|
||||
fetchForNodeMock.mockReset();
|
||||
apiFetchMock.mockImplementation((path: string) => {
|
||||
if (path === '/fleet/overview') return Promise.resolve(okJson(NODES));
|
||||
if (path === '/fleet/networking-summary') return Promise.resolve(okJson({
|
||||
nodes: [
|
||||
// Alpha (1) is exposed only; Bravo (2) has a summary but is unknown + drift, not exposed.
|
||||
{ nodeId: 1, summary: { exposed: { count: 1, stacks: ['web'] }, unknownExposure: { count: 0, stacks: [] }, networkDrift: { count: 0, stacks: [] } } },
|
||||
{ nodeId: 2, summary: { exposed: { count: 0, stacks: [] }, unknownExposure: { count: 1, stacks: ['db'] }, networkDrift: { count: 1, stacks: ['db'] } } },
|
||||
],
|
||||
}));
|
||||
if (path === '/node-labels') return Promise.resolve(okJson({}));
|
||||
return Promise.resolve(okJson({}));
|
||||
});
|
||||
@@ -108,6 +115,37 @@ describe('useFleetOverview', () => {
|
||||
const { result, updatePrefs } = setup({ filterStatus: 'online' });
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
act(() => result.current.clearFilters());
|
||||
expect(updatePrefs).toHaveBeenCalledWith({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
expect(updatePrefs).toHaveBeenCalledWith({ filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' });
|
||||
});
|
||||
|
||||
it('narrows to nodes with an exposed stack when the networking filter is set', async () => {
|
||||
const { result } = setup({ filterNetworking: 'exposed' });
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
// Bravo has a summary but exposed.count is 0, so it is excluded, proving the
|
||||
// filter checks the signal rather than just presence of a summary.
|
||||
await waitFor(() => expect(result.current.processedNodes.map(n => n.name)).toEqual(['Alpha']));
|
||||
});
|
||||
|
||||
it('narrows by the unknown-exposure and network-drift signals', async () => {
|
||||
const unknown = setup({ filterNetworking: 'unknown' });
|
||||
await act(async () => { await unknown.result.current.fetchOverview(); });
|
||||
await waitFor(() => expect(unknown.result.current.processedNodes.map(n => n.name)).toEqual(['Bravo']));
|
||||
|
||||
const drift = setup({ filterNetworking: 'drift' });
|
||||
await act(async () => { await drift.result.current.fetchOverview(); });
|
||||
await waitFor(() => expect(drift.result.current.processedNodes.map(n => n.name)).toEqual(['Bravo']));
|
||||
});
|
||||
|
||||
it('keeps the overview loaded when the networking summary fetch fails', async () => {
|
||||
apiFetchMock.mockImplementation((path: string) => {
|
||||
if (path === '/fleet/overview') return Promise.resolve(okJson(NODES));
|
||||
if (path === '/fleet/networking-summary') return Promise.reject(new Error('summary down'));
|
||||
return Promise.resolve(okJson({}));
|
||||
});
|
||||
// With no networking filter active, the overview must render all nodes even
|
||||
// though the summary fetch threw (fail-soft, detached from the load path).
|
||||
const { result } = setup({ filterNetworking: 'all' });
|
||||
await act(async () => { await result.current.fetchOverview(); });
|
||||
expect(result.current.processedNodes.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('useFleetPreferences', () => {
|
||||
it('starts from defaults when nothing is stored', () => {
|
||||
const { result } = renderHook(() => useFleetPreferences());
|
||||
expect(result.current.prefs).toEqual({
|
||||
sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false,
|
||||
sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
type FleetDossierStack,
|
||||
} from '@/lib/fleetDossier';
|
||||
import { EMPTY_DOSSIER_FIELDS, type StackDossierFields } from '@/lib/dossierMarkdown';
|
||||
import {
|
||||
buildNetworkExposureSummary,
|
||||
type NetworkExposureSummary,
|
||||
type NetworkFactsInput,
|
||||
type ExposureIntentInput,
|
||||
} from '@/lib/networkExposureSummary';
|
||||
import { downloadBlob } from '@/lib/download';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
@@ -86,8 +92,23 @@ async function loadDossier(stackName: string, nodeId: number): Promise<StackDoss
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNetworking(stackName: string, nodeId: number): Promise<NetworkExposureSummary | null> {
|
||||
try {
|
||||
const [facts, exposure] = await Promise.all([
|
||||
getJson<NetworkFactsInput>(`/stacks/${encodeURIComponent(stackName)}/networking`, nodeId),
|
||||
getJson<{ intents?: ExposureIntentInput[] }>(`/stacks/${encodeURIComponent(stackName)}/exposure`, nodeId),
|
||||
]);
|
||||
return buildNetworkExposureSummary(facts, exposure.intents ?? []);
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) throw err;
|
||||
console.warn(`[FleetDossier] networking load failed for "${stackName}" on node ${nodeId}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectStack(stackName: string, nodeId: number): Promise<FleetDossierStack> {
|
||||
const dossier = await loadDossier(stackName, nodeId);
|
||||
const networking = await loadNetworking(stackName, nodeId);
|
||||
let content: string;
|
||||
try {
|
||||
content = await getText(`/stacks/${encodeURIComponent(stackName)}`, nodeId);
|
||||
@@ -95,7 +116,7 @@ async function collectStack(stackName: string, nodeId: number): Promise<FleetDos
|
||||
if (isUnauthorized(err)) throw err;
|
||||
// Compose unreadable: emit a stub page from the operator notes alone.
|
||||
console.warn(`[FleetDossier] compose read failed for "${stackName}" on node ${nodeId}:`, err);
|
||||
return { stackName, anatomy: null, dossier };
|
||||
return { stackName, anatomy: null, dossier, networking };
|
||||
}
|
||||
|
||||
let envContent = '';
|
||||
@@ -114,7 +135,7 @@ async function collectStack(stackName: string, nodeId: number): Promise<FleetDos
|
||||
|
||||
const gitSource = await loadGitSource(stackName, nodeId);
|
||||
const anatomy = assembleAnatomyInput({ stackName, content, envContent, selectedEnvFile: firstEnvFile, gitSource });
|
||||
return { stackName, anatomy, dossier };
|
||||
return { stackName, anatomy, dossier, networking };
|
||||
}
|
||||
|
||||
async function collectNode(node: OverviewNode): Promise<FleetDossierNode> {
|
||||
|
||||
@@ -31,11 +31,32 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
|
||||
// Per-node networking signals (which nodes have an exposed / unknown-exposure
|
||||
// / network-drift stack), for the networking filter. Loaded fail-soft.
|
||||
const [networkingByNode, setNetworkingByNode] = useState<Map<number, { exposed: boolean; unknown: boolean; drift: boolean }>>(new Map());
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ nodes });
|
||||
const { labelsByNodeId, distinctLabels } = useNodeLabels({ nodes });
|
||||
|
||||
// The networking summary fans out to every remote (each with its own
|
||||
// timeout), so it is loaded detached: it must never gate the overview's
|
||||
// loading state, and a failure just leaves the networking filter empty.
|
||||
const loadNetworkingSummary = useCallback(async (signal: AbortSignal) => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/networking-summary', { localOnly: true, signal });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as { nodes?: { nodeId: number; summary: { exposed: { count: number }; unknownExposure: { count: number }; networkDrift: { count: number } } | null }[] };
|
||||
const map = new Map<number, { exposed: boolean; unknown: boolean; drift: boolean }>();
|
||||
for (const n of data.nodes ?? []) {
|
||||
if (n.summary) map.set(n.nodeId, { exposed: n.summary.exposed.count > 0, unknown: n.summary.unknownExposure.count > 0, drift: n.summary.networkDrift.count > 0 });
|
||||
}
|
||||
setNetworkingByNode(map);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) console.warn('Failed to fetch fleet networking summary:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchOverview = useCallback(async (showRefresh = false) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
@@ -48,6 +69,8 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
|
||||
setNodes(await res.json());
|
||||
setLastSyncAt(Date.now());
|
||||
}
|
||||
// Detached: it must never gate the loading state cleared in `finally`.
|
||||
void loadNetworkingSummary(controller.signal);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
console.error('Failed to fetch fleet overview:', error);
|
||||
@@ -55,7 +78,7 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [loadNetworkingSummary]);
|
||||
|
||||
const onlineNodes = useMemo(() => nodes.filter(n => n.status === 'online'), [nodes]);
|
||||
|
||||
@@ -110,6 +133,19 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
|
||||
if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type !== 'local');
|
||||
if (prefs.filterCritical) filtered = filtered.filter(isCritical);
|
||||
|
||||
if (prefs.filterNetworking !== 'all') {
|
||||
const signal = prefs.filterNetworking;
|
||||
filtered = filtered.filter(n => {
|
||||
const s = networkingByNode.get(n.id);
|
||||
if (!s) return false;
|
||||
switch (signal) {
|
||||
case 'exposed': return s.exposed;
|
||||
case 'unknown': return s.unknown;
|
||||
case 'drift': return s.drift;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (labelFilters.size > 0) {
|
||||
filtered = filtered.filter(n => {
|
||||
const nodeStackLabels = fleetStackLabelMap[n.id] ?? {};
|
||||
@@ -143,7 +179,7 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [nodes, searchQuery, prefs, labelFilters, fleetStackLabelMap]);
|
||||
}, [nodes, searchQuery, prefs, labelFilters, fleetStackLabelMap, networkingByNode]);
|
||||
|
||||
const localNode = useMemo(
|
||||
() => processedNodes.find(n => n.type === 'local') ?? null,
|
||||
@@ -184,12 +220,13 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee
|
||||
if (prefs.filterStatus !== 'all') count++;
|
||||
if (prefs.filterType !== 'all') count++;
|
||||
if (prefs.filterCritical) count++;
|
||||
if (prefs.filterNetworking !== 'all') count++;
|
||||
count += labelFilters.size;
|
||||
return count;
|
||||
}, [prefs, labelFilters]);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' });
|
||||
setLabelFilters(new Set());
|
||||
}, [updatePrefs]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FleetPreferences } from '../types';
|
||||
const PREFS_KEY = 'sencho-fleet-preferences';
|
||||
|
||||
const DEFAULT_PREFS: FleetPreferences = {
|
||||
sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false,
|
||||
sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all',
|
||||
};
|
||||
|
||||
function loadPreferences(): FleetPreferences {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status';
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
export type FilterStatus = 'all' | 'online' | 'offline';
|
||||
export type FilterType = 'all' | 'local' | 'remote';
|
||||
export type FilterNetworking = 'all' | 'exposed' | 'unknown' | 'drift';
|
||||
|
||||
export interface FleetPreferences {
|
||||
sortBy: SortField;
|
||||
@@ -54,6 +55,8 @@ export interface FleetPreferences {
|
||||
filterStatus: FilterStatus;
|
||||
filterType: FilterType;
|
||||
filterCritical: boolean;
|
||||
/** Narrow to nodes that have an exposed / unknown-exposure / network-drift stack. */
|
||||
filterNetworking: FilterNetworking;
|
||||
}
|
||||
|
||||
export interface FleetPaletteEntry {
|
||||
|
||||
@@ -28,7 +28,8 @@ import { CapabilityGate } from './CapabilityGate';
|
||||
import LazyBoundary from './LazyBoundary';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
||||
import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events';
|
||||
import type { SenchoOpenStackDetail } from '@/lib/events';
|
||||
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { ReclaimHero } from './resources/ReclaimHero';
|
||||
@@ -173,17 +174,23 @@ function FilterToggle({ value, onChange, counts }: FilterToggleProps) {
|
||||
|
||||
// ── Managed Status Badge ───────────────────────────────────────────────────────
|
||||
|
||||
function ManagedBadge({ status, managedBy }: {
|
||||
function ManagedBadge({ status, managedBy, onOpenStack }: {
|
||||
status: 'managed' | 'unmanaged' | 'unused' | 'system';
|
||||
managedBy: string | null;
|
||||
/** When provided on a managed resource, the owning-stack badge becomes a link to that stack. */
|
||||
onOpenStack?: (stack: string) => void;
|
||||
}) {
|
||||
if (status === 'managed') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success shrink-0" />
|
||||
{managedBy}
|
||||
</span>
|
||||
);
|
||||
const cls = "inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium";
|
||||
const inner = (<><span className="w-1.5 h-1.5 rounded-full bg-success shrink-0" />{managedBy}</>);
|
||||
if (onOpenStack && managedBy) {
|
||||
return (
|
||||
<button type="button" className={`${cls} hover:bg-success/15 transition-colors`} title={`Open stack ${managedBy}`} onClick={() => onOpenStack(managedBy)}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span className={cls}>{inner}</span>;
|
||||
}
|
||||
if (status === 'unmanaged') {
|
||||
return (
|
||||
@@ -1209,7 +1216,13 @@ export default function ResourcesView() {
|
||||
<TableCell><Badge variant="outline" className="text-[10px] h-5">{net.Scope}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<ManagedBadge status={net.managedStatus} managedBy={net.managedBy} />
|
||||
<ManagedBadge
|
||||
status={net.managedStatus}
|
||||
managedBy={net.managedBy}
|
||||
onOpenStack={activeNode ? (stack) => window.dispatchEvent(
|
||||
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }),
|
||||
) : undefined}
|
||||
/>
|
||||
{net.isSencho && <SenchoBadge />}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -70,4 +70,10 @@ describe('StackAnatomyPanel Doctor tab (capability on)', () => {
|
||||
await waitFor(() => expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/preflight'))).toBe(true));
|
||||
expect(screen.queryByTestId('doctor-tab-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Networking tab when the capability is present', async () => {
|
||||
badgeSeverity = 'warning';
|
||||
render(panel());
|
||||
expect(await screen.findByTestId('networking-tab')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,3 +337,13 @@ describe('StackAnatomyPanel exposed footer', () => {
|
||||
expect(screen.queryByRole('link', { name: /:8989/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackAnatomyPanel capability gating (capability off)', () => {
|
||||
it('hides the Networking and Doctor tabs when the capabilities are absent', async () => {
|
||||
render(panel(false));
|
||||
// The always-on Anatomy tab confirms the panel mounted.
|
||||
expect(await screen.findByRole('tab', { name: 'Anatomy' })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('networking-tab')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('doctor-tab')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||
import StackDossierPanel from './stack/StackDossierPanel';
|
||||
import DriftPanel from './stack/DriftPanel';
|
||||
import PreflightPanel from './stack/PreflightPanel';
|
||||
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
@@ -80,6 +81,7 @@ export default function StackAnatomyPanel({
|
||||
|
||||
const { hasCapability, activeNode } = useNodes();
|
||||
const doctorEnabled = hasCapability('compose-doctor');
|
||||
const networkingEnabled = hasCapability('compose-networking');
|
||||
|
||||
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo } | null>(null);
|
||||
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
|
||||
@@ -319,6 +321,9 @@ export default function StackAnatomyPanel({
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
{networkingEnabled && (
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -579,6 +584,11 @@ export default function StackAnatomyPanel({
|
||||
<TabsContent value="drift" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<DriftPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
{networkingEnabled && (
|
||||
<TabsContent value="networking" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StackNetworkingPanel stackName={stackName} canEdit={canEdit} doctorEnabled={doctorEnabled} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PreflightPanel stackName={stackName} />
|
||||
|
||||
@@ -11,7 +11,9 @@ import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable';
|
||||
type DriftFindingKind = 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch';
|
||||
type DriftFindingKind =
|
||||
| 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch'
|
||||
| 'network-undeclared' | 'network-missing';
|
||||
|
||||
interface StackDriftFinding {
|
||||
kind: DriftFindingKind;
|
||||
@@ -84,6 +86,8 @@ const FINDING_LABEL: Record<DriftFindingKind, string> = {
|
||||
'service-undeclared': 'undeclared',
|
||||
'image-mismatch': 'image',
|
||||
'ports-mismatch': 'ports',
|
||||
'network-undeclared': 'network',
|
||||
'network-missing': 'network missing',
|
||||
};
|
||||
|
||||
/** The temporal overlay: how the on-disk compose compares to the last deploy baseline. */
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('StackDossierPanel', () => {
|
||||
expect(vi.mocked(copyToClipboard).mock.calls[0][0]).toContain('# web');
|
||||
|
||||
fireEvent.click(screen.getByTestId('dossier-download-btn'));
|
||||
expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web'));
|
||||
await waitFor(() => expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web')));
|
||||
});
|
||||
|
||||
// Anatomy that publishes a single TCP host port, for documentation-drift tests.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type StackDossierFields,
|
||||
} from '@/lib/dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
|
||||
import { buildNetworkExposureSummary, type NetworkExposureSummary } from '@/lib/networkExposureSummary';
|
||||
import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift';
|
||||
import { RollbackReadinessSection } from './RollbackReadinessSection';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
@@ -201,6 +202,23 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
// Fetched only when the user exports, so opening the panel costs nothing.
|
||||
// Fail-soft: if it is unavailable the export simply omits the section.
|
||||
const loadNetworkingSummary = async (): Promise<NetworkExposureSummary | null> => {
|
||||
try {
|
||||
const [factsRes, exposureRes] = await Promise.all([
|
||||
apiFetch(`/stacks/${stackName}/networking`),
|
||||
apiFetch(`/stacks/${stackName}/exposure`),
|
||||
]);
|
||||
const facts = factsRes.ok ? await factsRes.json() : null;
|
||||
const intents = exposureRes.ok ? (await exposureRes.json()).intents ?? [] : [];
|
||||
return buildNetworkExposureSummary(facts, intents);
|
||||
} catch (err) {
|
||||
console.warn(`[Dossier] networking summary load failed for "${stackName}":`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = useMemo(
|
||||
() => FIELD_KEYS.some(k => fields[k] !== serverFields[k]),
|
||||
[fields, serverFields],
|
||||
@@ -247,20 +265,22 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
const handleCopy = async () => {
|
||||
if (!anatomy) return;
|
||||
try {
|
||||
await copyToClipboard(buildStackDossierMarkdown(anatomy, fields));
|
||||
const networking = await loadNetworkingSummary();
|
||||
await copyToClipboard(buildStackDossierMarkdown(anatomy, fields, networking));
|
||||
toast.success('Stack dossier copied as Markdown.');
|
||||
} catch {
|
||||
toast.error('Failed to copy to clipboard.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleDownload = async () => {
|
||||
if (!anatomy) return;
|
||||
try {
|
||||
const networking = await loadNetworkingSummary();
|
||||
// Stack names are already constrained, but sanitize defensively so the
|
||||
// file always has a coherent, safe name ending in .md.
|
||||
const base = stackName.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'stack';
|
||||
downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields));
|
||||
downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields, networking));
|
||||
} catch {
|
||||
toast.error('Failed to download the dossier.');
|
||||
}
|
||||
@@ -274,7 +294,7 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
<button type="button" data-testid="dossier-copy-btn" onClick={() => { void handleCopy(); }} disabled={!anatomy || loadError} className={ACTION_CLASS}>
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} /> copy md
|
||||
</button>
|
||||
<button type="button" data-testid="dossier-download-btn" onClick={handleDownload} disabled={!anatomy || loadError} className={ACTION_CLASS}>
|
||||
<button type="button" data-testid="dossier-download-btn" onClick={() => { void handleDownload(); }} disabled={!anatomy || loadError} className={ACTION_CLASS}>
|
||||
<Download className="h-3 w-3" strokeWidth={1.5} /> download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Covers the Networking panel: rendering facts (networks, service membership,
|
||||
* port binding badges), setting an exposure intent (PUT), the read-only state
|
||||
* when the user cannot edit, the runtime-unavailable note, and the unrenderable
|
||||
* banner.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import StackNetworkingPanel from './StackNetworkingPanel';
|
||||
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
function facts(partial: Record<string, unknown> = {}) {
|
||||
return {
|
||||
stack: 'web', renderable: true, renderError: null, runtime: 'available',
|
||||
networks: [{ key: 'backend', name: 'web_backend', external: false, internal: true, createdByStack: true }],
|
||||
services: [{
|
||||
name: 'api',
|
||||
networks: [{ key: 'backend', aliases: ['db'] }],
|
||||
publishedPorts: [
|
||||
{ hostIp: '0.0.0.0', startPort: 8080, endPort: 8080, protocol: 'tcp', allInterfaces: true, loopbackOnly: false },
|
||||
{ hostIp: '127.0.0.1', startPort: 9000, endPort: 9000, protocol: 'tcp', allInterfaces: false, loopbackOnly: true },
|
||||
],
|
||||
extraHosts: [],
|
||||
}],
|
||||
drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] },
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/** Route apiFetch by URL + method; a PUT echoes its request into the response. */
|
||||
function mockApi(factsBody: Record<string, unknown>, intents: unknown[] = [], factsOk = true) {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url.endsWith('/networking')) return Promise.resolve(jsonRes(factsBody, factsOk));
|
||||
if (url.endsWith('/exposure') && opts?.method === 'PUT') {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
return Promise.resolve(jsonRes({ intents: body.intent === null ? [] : [{ service: body.service, intent: body.intent }] }));
|
||||
}
|
||||
if (url.endsWith('/exposure')) return Promise.resolve(jsonRes({ intents }));
|
||||
return Promise.resolve(jsonRes({}));
|
||||
});
|
||||
}
|
||||
|
||||
const putBodies = () => vi.mocked(apiFetch).mock.calls.filter(c => c[1]?.method === 'PUT').map(c => JSON.parse(c[1]!.body as string));
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
describe('StackNetworkingPanel', () => {
|
||||
it('renders networks, service membership, and both binding badges', async () => {
|
||||
mockApi(facts());
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
expect(await screen.findByText('web_backend')).toBeInTheDocument();
|
||||
expect(screen.getByText(/\(db\)/)).toBeInTheDocument(); // network alias in parens
|
||||
expect(screen.getByText('all interfaces')).toBeInTheDocument();
|
||||
expect(screen.getByText('loopback')).toBeInTheDocument();
|
||||
expect(screen.getByText(/runtime matches compose/i)).toBeInTheDocument(); // no-drift success card
|
||||
});
|
||||
|
||||
it('saves a stack-level exposure intent on click', async () => {
|
||||
mockApi(facts());
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
await screen.findByText('web_backend');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'internal' })[0]);
|
||||
await waitFor(() => expect(putBodies()).toContainEqual({ service: '', intent: 'internal' }));
|
||||
});
|
||||
|
||||
it('saves a per-service intent under the service name', async () => {
|
||||
mockApi(facts());
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
await screen.findByText('web_backend');
|
||||
// [0] is the stack row, [1] is the 'api' service row.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'internal' })[1]);
|
||||
await waitFor(() => expect(putBodies()).toContainEqual({ service: 'api', intent: 'internal' }));
|
||||
});
|
||||
|
||||
it('clears a service intent (inherit) by sending intent null', async () => {
|
||||
mockApi(facts());
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
await screen.findByText('web_backend');
|
||||
// The stack row shows "unset"; the service row shows "inherit".
|
||||
expect(screen.getByRole('button', { name: 'unset' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'inherit' }));
|
||||
await waitFor(() => expect(putBodies()).toContainEqual({ service: 'api', intent: null }));
|
||||
});
|
||||
|
||||
it('disables the intent controls when the user cannot edit', async () => {
|
||||
mockApi(facts());
|
||||
render(<StackNetworkingPanel stackName="web" canEdit={false} doctorEnabled />);
|
||||
await screen.findByText('web_backend');
|
||||
const internalPills = screen.getAllByRole('button', { name: 'internal' });
|
||||
expect(internalPills[0]).toBeDisabled();
|
||||
fireEvent.click(internalPills[0]);
|
||||
expect(vi.mocked(apiFetch).mock.calls.some(c => c[1]?.method === 'PUT')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a runtime-unavailable note instead of computing drift', async () => {
|
||||
mockApi(facts({ runtime: 'unavailable' }));
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
expect(await screen.findByText(/runtime unavailable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unrenderable banner when the model cannot render', async () => {
|
||||
mockApi(facts({ renderable: false, renderError: 'bad compose', networks: [], services: [] }));
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
expect(await screen.findByText(/cannot render/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('bad compose')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders runtime drift rows', async () => {
|
||||
mockApi(facts({
|
||||
drift: {
|
||||
runtimeOnlyAttachments: [{ container: 'api-1', service: 'api', network: 'web_rogue' }],
|
||||
foreignNetworkAttachments: [{ container: 'api-1', network: 'other_net' }],
|
||||
declaredButUnused: ['web_idle'],
|
||||
missingFromRuntime: ['web_gone'],
|
||||
},
|
||||
}));
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
await screen.findByText('web_backend');
|
||||
expect(screen.getByText('web_rogue')).toBeInTheDocument();
|
||||
expect(screen.getByText('other_net')).toBeInTheDocument();
|
||||
expect(screen.getByText('web_idle')).toBeInTheDocument();
|
||||
expect(screen.getByText('web_gone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error with retry when the facts fetch fails, and retry refetches', async () => {
|
||||
mockApi(facts(), [], false);
|
||||
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
expect(await screen.findByText(/Could not load the networking view/i)).toBeInTheDocument();
|
||||
const before = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).endsWith('/networking')).length;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
|
||||
await waitFor(() => {
|
||||
const after = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).endsWith('/networking')).length;
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Network, Globe, Lock, ShieldQuestion, RefreshCw, ArrowRight } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
// Mirrors the backend networking payload shapes (the frontend never imports
|
||||
// backend). IntentEntry intentionally keeps only the fields this panel reads.
|
||||
type ExposureIntent = 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown';
|
||||
const INTENTS: readonly ExposureIntent[] = ['internal', 'same-node', 'lan', 'reverse-proxy', 'public', 'temporary', 'unknown'];
|
||||
|
||||
interface NetworkFactNetwork { key: string; name: string; external: boolean; internal: boolean; createdByStack: boolean }
|
||||
interface NetworkFactPort { hostIp: string; startPort: number; endPort: number; protocol: string; allInterfaces: boolean; loopbackOnly: boolean }
|
||||
interface NetworkFactService {
|
||||
name: string;
|
||||
networks: { key: string; aliases: string[] }[];
|
||||
publishedPorts: NetworkFactPort[];
|
||||
networkMode?: string;
|
||||
extraHosts: string[];
|
||||
}
|
||||
interface NetworkDriftFacts {
|
||||
runtimeOnlyAttachments: { container: string; service: string | null; network: string }[];
|
||||
declaredButUnused: string[];
|
||||
missingFromRuntime: string[];
|
||||
foreignNetworkAttachments: { container: string; network: string }[];
|
||||
}
|
||||
interface StackNetworkFacts {
|
||||
stack: string;
|
||||
renderable: boolean;
|
||||
renderError: string | null;
|
||||
runtime: 'available' | 'unavailable';
|
||||
networks: NetworkFactNetwork[];
|
||||
services: NetworkFactService[];
|
||||
drift: NetworkDriftFacts;
|
||||
}
|
||||
interface IntentEntry { service: string; intent: ExposureIntent }
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const ACTION_CLASS = 'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
|
||||
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
|
||||
|
||||
function portLabel(p: NetworkFactPort): string {
|
||||
const range = p.startPort === p.endPort ? `${p.startPort}` : `${p.startPort}-${p.endPort}`;
|
||||
return `${range}/${p.protocol}`;
|
||||
}
|
||||
|
||||
/** Defensively read the intents array from an exposure response body. */
|
||||
function asIntents(body: unknown): IntentEntry[] {
|
||||
const list = (body as { intents?: unknown })?.intents;
|
||||
return Array.isArray(list) ? (list as IntentEntry[]) : [];
|
||||
}
|
||||
|
||||
/** A small chip that states the binding scope of a published port. */
|
||||
function BindingBadge({ port }: { port: NetworkFactPort }) {
|
||||
if (port.allInterfaces) {
|
||||
return <span className="rounded border border-warning/40 bg-warning/[0.08] px-1 py-0.5 font-mono text-[10px] text-warning">all interfaces</span>;
|
||||
}
|
||||
if (port.loopbackOnly) {
|
||||
return <span className="rounded border border-success/30 bg-success/[0.06] px-1 py-0.5 font-mono text-[10px] text-success">loopback</span>;
|
||||
}
|
||||
return <span className="rounded border border-muted bg-card/40 px-1 py-0.5 font-mono text-[10px] text-stat-subtitle">{port.hostIp}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposure-intent picker: a row of pills plus a clear option. `value` null means
|
||||
* the scope is cleared. The clear option reads "unset" on the stack row and
|
||||
* "inherit" on a per-service row, where the service then falls back to the stack
|
||||
* intent. Disabled and read-only when the user cannot edit the stack.
|
||||
*/
|
||||
function IntentControl({ value, inherited, canEdit, onChange }: {
|
||||
value: ExposureIntent | null;
|
||||
inherited?: ExposureIntent | null;
|
||||
canEdit: boolean;
|
||||
onChange: (intent: ExposureIntent | null) => void;
|
||||
}) {
|
||||
const pill = (active: boolean) => cn(
|
||||
'rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide border transition-colors',
|
||||
active ? 'border-brand/50 bg-brand/15 text-brand' : 'border-muted bg-card/40 text-stat-subtitle',
|
||||
canEdit ? 'hover:border-brand/40' : 'cursor-default opacity-90',
|
||||
);
|
||||
const clearLabel = inherited !== undefined ? 'inherit' : 'unset';
|
||||
const cleared = value === null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{INTENTS.map(opt => (
|
||||
<button key={opt} type="button" disabled={!canEdit} className={pill(value === opt)} onClick={() => canEdit && onChange(opt)}>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" disabled={!canEdit} className={pill(cleared)} onClick={() => canEdit && onChange(null)}>
|
||||
{clearLabel}
|
||||
</button>
|
||||
{cleared && inherited && (
|
||||
<span className="font-mono text-[10px] text-stat-subtitle">→ {inherited}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled }: {
|
||||
stackName: string;
|
||||
canEdit: boolean;
|
||||
doctorEnabled: boolean;
|
||||
}) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const [facts, setFacts] = useState<StackNetworkFacts | null>(null);
|
||||
const [intents, setIntents] = useState<IntentEntry[]>([]);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setLoadError(false);
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [factsRes, exposureRes] = await Promise.all([
|
||||
apiFetch(`/stacks/${stackName}/networking`),
|
||||
apiFetch(`/stacks/${stackName}/exposure`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (!factsRes.ok) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the networking view.');
|
||||
return;
|
||||
}
|
||||
setFacts((await factsRes.json()) as StackNetworkFacts);
|
||||
// The exposure overlay is secondary: a bad exposure body must not tear
|
||||
// down a working facts view, so its parse is tolerated on its own.
|
||||
if (exposureRes.ok) {
|
||||
try { setIntents(asIntents(await exposureRes.json())); } catch { /* keep intents unset */ }
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the networking view.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setRefreshing(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
const stackIntent = intents.find(i => i.service === '')?.intent ?? null;
|
||||
const intentFor = (service: string): ExposureIntent | null => intents.find(i => i.service === service)?.intent ?? null;
|
||||
|
||||
const saveIntent = useCallback(async (service: string, intent: ExposureIntent | null) => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/exposure`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ service, intent }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to save the exposure intent.');
|
||||
return;
|
||||
}
|
||||
setIntents(asIntents(await res.json()));
|
||||
} catch {
|
||||
toast.error('Failed to save the exposure intent.');
|
||||
}
|
||||
}, [stackName]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
|
||||
<span className="font-mono text-[11px] text-destructive">Could not load the networking view.</span>
|
||||
<button type="button" onClick={() => setReloadKey(k => k + 1)} className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline">retry</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!facts) {
|
||||
return <div className="flex-1 min-h-0 px-3 py-3 font-mono text-[11px] text-stat-subtitle">Loading networking…</div>;
|
||||
}
|
||||
if (!facts.renderable) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
|
||||
<div className={cn(CARD_CLASS, 'border-destructive/40 bg-destructive/[0.06]')}>
|
||||
<div className="flex items-center gap-2 text-destructive"><Network className="h-4 w-4" strokeWidth={1.5} /><span className="font-mono text-[11px] uppercase tracking-wide">cannot render</span></div>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-foreground/80">{facts.renderError ?? 'Sencho could not render the effective Compose model.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const drift = facts.drift;
|
||||
const hasDrift = drift.runtimeOnlyAttachments.length > 0 || drift.foreignNetworkAttachments.length > 0
|
||||
|| drift.declaredButUnused.length > 0 || drift.missingFromRuntime.length > 0;
|
||||
|
||||
return (
|
||||
<div data-testid="networking-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={LABEL_CLASS}>networking</span>
|
||||
<button type="button" onClick={() => setReloadKey(k => k + 1)} disabled={refreshing} className={ACTION_CLASS}>
|
||||
<RefreshCw className={cn('h-3 w-3', refreshing && 'animate-spin')} strokeWidth={1.5} /> refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Exposure intent */}
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className={LABEL_CLASS}>exposure intent</div>
|
||||
<div className={cn(CARD_CLASS, 'border-muted bg-card/40 flex flex-col gap-2')}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[11px] text-foreground/80">stack</span>
|
||||
<IntentControl value={stackIntent} canEdit={canEdit} onChange={intent => saveIntent('', intent)} />
|
||||
</div>
|
||||
{facts.services.map(svc => (
|
||||
<div key={svc.name} className="flex flex-col gap-1 border-t border-muted pt-2">
|
||||
<span className="font-mono text-[11px] text-foreground/80">{svc.name}</span>
|
||||
<IntentControl value={intentFor(svc.name)} inherited={stackIntent} canEdit={canEdit} onChange={intent => saveIntent(svc.name, intent)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Networks */}
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className={LABEL_CLASS}>networks</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40">
|
||||
{facts.networks.length === 0 && <div className="px-3 py-2 font-mono text-[11px] text-stat-subtitle">default network only</div>}
|
||||
{facts.networks.map(net => (
|
||||
<div key={net.key} className="flex flex-wrap items-center gap-2 border-t border-muted px-3 py-2 first:border-t-0">
|
||||
<span className="font-mono text-[12px] text-foreground/90">{net.name}</span>
|
||||
{net.key !== net.name && <span className="font-mono text-[10px] text-stat-subtitle">({net.key})</span>}
|
||||
{net.external && <span className="rounded border border-info/40 bg-info/[0.06] px-1 py-0.5 font-mono text-[10px] text-info">external</span>}
|
||||
{net.internal && <span className="rounded border border-muted px-1 py-0.5 font-mono text-[10px] text-stat-subtitle"><Lock className="inline h-2.5 w-2.5" /> internal</span>}
|
||||
{net.createdByStack && <span className="rounded border border-muted px-1 py-0.5 font-mono text-[10px] text-stat-subtitle">created by stack</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services */}
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className={LABEL_CLASS}>services</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{facts.services.map(svc => (
|
||||
<div key={svc.name} className={cn(CARD_CLASS, 'border-muted bg-card/40 flex flex-col gap-1.5')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] text-foreground/90">{svc.name}</span>
|
||||
{svc.networkMode && <span className="rounded border border-warning/40 bg-warning/[0.08] px-1 py-0.5 font-mono text-[10px] text-warning">network_mode: {svc.networkMode}</span>}
|
||||
</div>
|
||||
{svc.networks.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{svc.networks.map(n => (
|
||||
<span key={n.key} className="font-mono text-[11px] text-foreground/80">
|
||||
{n.key}{n.aliases.length > 0 && <span className="text-stat-subtitle"> ({n.aliases.join(', ')})</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{svc.publishedPorts.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{svc.publishedPorts.map((p, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] text-foreground/80">{portLabel(p)}</span>
|
||||
<BindingBadge port={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{svc.extraHosts.length > 0 && (
|
||||
<div className="font-mono text-[10px] text-stat-subtitle">extra_hosts: {svc.extraHosts.join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Runtime drift */}
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className={LABEL_CLASS}>runtime drift</div>
|
||||
{facts.runtime === 'unavailable' ? (
|
||||
<div className={cn(CARD_CLASS, 'border-muted bg-card/40 flex items-center gap-2 text-stat-subtitle')}>
|
||||
<ShieldQuestion className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px]">runtime unavailable, showing the declared model only</span>
|
||||
</div>
|
||||
) : !hasDrift ? (
|
||||
<div className={cn(CARD_CLASS, 'border-success/30 bg-success/[0.06] flex items-center gap-2 text-success')}>
|
||||
<Globe className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">runtime matches compose</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-muted bg-card/40 flex flex-col">
|
||||
{drift.runtimeOnlyAttachments.map((d, i) => (
|
||||
<div key={`ro-${i}`} className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">
|
||||
<span className="rounded bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{d.service ?? d.container}</span> attached to undeclared network <span className="font-mono">{d.network}</span>
|
||||
</div>
|
||||
))}
|
||||
{drift.foreignNetworkAttachments.map((d, i) => (
|
||||
<div key={`fn-${i}`} className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">
|
||||
<span className="font-mono">{d.container}</span> attached to a network owned by another stack: <span className="font-mono">{d.network}</span>
|
||||
</div>
|
||||
))}
|
||||
{drift.declaredButUnused.length > 0 && (
|
||||
<div className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">declared but unused by any running service: <span className="font-mono">{drift.declaredButUnused.join(', ')}</span></div>
|
||||
)}
|
||||
{drift.missingFromRuntime.length > 0 && (
|
||||
<div className="border-t border-muted px-3 py-2 first:border-t-0 text-[12px] text-foreground/80">declared but missing from the runtime: <span className="font-mono">{drift.missingFromRuntime.join(', ')}</span></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{doctorEnabled && (
|
||||
<div className="flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
|
||||
<ArrowRight className="h-3 w-3" strokeWidth={1.5} /> deploy and security findings are in the Doctor tab
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const CAPABILITIES = [
|
||||
'vulnerability-scanning',
|
||||
'compose-doctor',
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
@@ -23,6 +23,18 @@ describe('buildStackDossierMarkdown', () => {
|
||||
expect(md).toContain('# plex');
|
||||
expect(md).toContain('## Services');
|
||||
expect(md).not.toContain('## Operator notes');
|
||||
expect(md).not.toContain('## Network exposure');
|
||||
});
|
||||
|
||||
it('appends the network exposure section when a summary is provided', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields(), {
|
||||
stackIntent: 'internal',
|
||||
networks: [{ name: 'plex_default', external: false, internal: false }],
|
||||
services: [{ name: 'plex', intent: null, ports: ['32400/tcp (all interfaces)'] }],
|
||||
});
|
||||
expect(md).toContain('## Network exposure');
|
||||
expect(md).toContain('**Stack intent:** internal');
|
||||
expect(md).toContain('32400/tcp (all interfaces)');
|
||||
});
|
||||
|
||||
it('appends an Operator notes section with the filled fields', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { buildStackAnatomyMarkdown, type AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||
import { networkExposureSection, type NetworkExposureSummary } from './networkExposureSummary';
|
||||
|
||||
/**
|
||||
* Operator-authored dossier fields. Mirrors the backend `StackDossierFields`
|
||||
@@ -84,8 +85,12 @@ export function operatorNotesSection(d: StackDossierFields): string | null {
|
||||
export function buildStackDossierMarkdown(
|
||||
anatomy: AnatomyMarkdownInput,
|
||||
dossier: StackDossierFields,
|
||||
networking?: NetworkExposureSummary | null,
|
||||
): string {
|
||||
const anatomyMarkdown = buildStackAnatomyMarkdown(anatomy);
|
||||
const notes = operatorNotesSection(dossier);
|
||||
return notes ? `${anatomyMarkdown}\n\n${notes}` : anatomyMarkdown;
|
||||
const sections = [
|
||||
buildStackAnatomyMarkdown(anatomy),
|
||||
networkExposureSection(networking ?? null),
|
||||
operatorNotesSection(dossier),
|
||||
].filter((s): s is string => s !== null && s !== '');
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
@@ -14,3 +14,11 @@ export interface SenchoSettingsChangedDetail {
|
||||
}
|
||||
|
||||
export const SENCHO_LABELS_CHANGED = 'sencho-labels-changed';
|
||||
|
||||
/** Open a stack on a given node from elsewhere in the app (e.g. a Resources network card). */
|
||||
export const SENCHO_OPEN_STACK_EVENT = 'sencho-open-stack';
|
||||
|
||||
export interface SenchoOpenStackDetail {
|
||||
nodeId: number;
|
||||
stackName: string;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
|
||||
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 {
|
||||
@@ -103,7 +106,7 @@ function stackSlugs(names: string[]): Map<string, string> {
|
||||
|
||||
function stackPageMarkdown(stack: FleetDossierStack): string {
|
||||
if (stack.anatomy) {
|
||||
return `${buildStackDossierMarkdown(stack.anatomy, stack.dossier)}\n`;
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The redacted networking + exposure summary for the dossier export: it carries
|
||||
* only names, intents, port numbers, and binding scope, never env or label
|
||||
* values, and renders nothing when there is nothing to document.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildNetworkExposureSummary, networkExposureSection } from './networkExposureSummary';
|
||||
|
||||
const facts = (over: Record<string, unknown> = {}) => ({
|
||||
renderable: true,
|
||||
networks: [{ name: 'app_backend', external: false, internal: true }],
|
||||
services: [{
|
||||
name: 'web',
|
||||
publishedPorts: [
|
||||
{ startPort: 8080, endPort: 8080, protocol: 'tcp', allInterfaces: true, loopbackOnly: false },
|
||||
{ startPort: 9000, endPort: 9000, protocol: 'tcp', allInterfaces: false, loopbackOnly: true },
|
||||
],
|
||||
}],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('buildNetworkExposureSummary', () => {
|
||||
it('returns null when the model is not renderable', () => {
|
||||
expect(buildNetworkExposureSummary({ renderable: false }, [])).toBeNull();
|
||||
});
|
||||
it('returns null when there is nothing worth documenting', () => {
|
||||
expect(buildNetworkExposureSummary({ renderable: true, networks: [], services: [{ name: 'web', publishedPorts: [] }] }, [])).toBeNull();
|
||||
});
|
||||
it('summarizes networks, intents, and ports with their binding scope', () => {
|
||||
const s = buildNetworkExposureSummary(facts(), [{ service: '', intent: 'internal' }, { service: 'web', intent: 'public' }]);
|
||||
expect(s).toEqual({
|
||||
stackIntent: 'internal',
|
||||
networks: [{ name: 'app_backend', external: false, internal: true }],
|
||||
services: [{ name: 'web', intent: 'public', ports: ['8080/tcp (all interfaces)', '9000/tcp (loopback)'] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('networkExposureSection', () => {
|
||||
it('renders a section with the redacted facts', () => {
|
||||
const md = networkExposureSection(buildNetworkExposureSummary(facts(), [{ service: '', intent: 'public' }]));
|
||||
expect(md).toContain('## Network exposure');
|
||||
expect(md).toContain('**Stack intent:** public');
|
||||
expect(md).toContain('app_backend (internal)');
|
||||
expect(md).toContain('8080/tcp (all interfaces)');
|
||||
});
|
||||
it('returns null for a null summary', () => {
|
||||
expect(networkExposureSection(null)).toBeNull();
|
||||
});
|
||||
it('never includes a value that lives in an ignored field (no env or label leak)', () => {
|
||||
// A secret planted in fields the builder does not read must not surface.
|
||||
const leaky = facts({
|
||||
services: [{ name: 'web', publishedPorts: [], env: { TOKEN: 'SECRET-9f3a' }, labels: { x: 'LABEL-SECRET' } }],
|
||||
networks: [{ name: 'app_backend', external: false, internal: false, driver: 'SECRET-DRIVER' }],
|
||||
});
|
||||
const md = networkExposureSection(buildNetworkExposureSummary(leaky, [{ service: '', intent: 'internal' }])) ?? '';
|
||||
expect(md).not.toContain('SECRET-9f3a');
|
||||
expect(md).not.toContain('LABEL-SECRET');
|
||||
expect(md).not.toContain('SECRET-DRIVER');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Redacted networking + exposure summary for the Stack Dossier export, derived
|
||||
* from the /networking facts and /exposure intents. It carries only network
|
||||
* names, exposure intents, published port numbers, and binding scope; never an
|
||||
* env value or a label value, so nothing sensitive reaches the exported text.
|
||||
* Pure and side-effect free.
|
||||
*/
|
||||
|
||||
export interface NetworkExposureSummary {
|
||||
stackIntent: string | null;
|
||||
networks: { name: string; external: boolean; internal: boolean }[];
|
||||
services: { name: string; intent: string | null; ports: string[] }[];
|
||||
}
|
||||
|
||||
// Loose input shapes: the builder reads the raw parsed /networking and
|
||||
// /exposure JSON, so it stays decoupled from the panel's local interfaces.
|
||||
interface FactsPort { startPort: number; endPort: number; protocol: string; allInterfaces: boolean; loopbackOnly: boolean }
|
||||
interface FactsService { name: string; publishedPorts?: FactsPort[] }
|
||||
interface FactsNetwork { name: string; external: boolean; internal: boolean }
|
||||
export interface NetworkFactsInput { renderable?: boolean; networks?: FactsNetwork[]; services?: FactsService[] }
|
||||
export interface ExposureIntentInput { service: string; intent: string }
|
||||
|
||||
function portLabel(p: FactsPort): string {
|
||||
const range = p.startPort === p.endPort ? `${p.startPort}` : `${p.startPort}-${p.endPort}`;
|
||||
const scope = p.allInterfaces ? ' (all interfaces)' : p.loopbackOnly ? ' (loopback)' : '';
|
||||
return `${range}/${p.protocol}${scope}`;
|
||||
}
|
||||
|
||||
/** Assemble the summary, or null when there is nothing worth documenting. */
|
||||
export function buildNetworkExposureSummary(facts: NetworkFactsInput | null, intents: ExposureIntentInput[]): NetworkExposureSummary | null {
|
||||
if (!facts || facts.renderable === false) return null;
|
||||
const stackIntent = intents.find(i => i.service === '')?.intent ?? null;
|
||||
const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent]));
|
||||
const networks = (facts.networks ?? []).map(n => ({ name: n.name, external: n.external, internal: n.internal }));
|
||||
const services = (facts.services ?? []).map(s => ({
|
||||
name: s.name,
|
||||
intent: byService.get(s.name) ?? null,
|
||||
ports: (s.publishedPorts ?? []).map(portLabel),
|
||||
}));
|
||||
const empty = networks.length === 0 && stackIntent === null
|
||||
&& services.every(s => s.ports.length === 0 && s.intent === null);
|
||||
return empty ? null : { stackIntent, networks, services };
|
||||
}
|
||||
|
||||
/** Render the summary as a Markdown section, or null when there is nothing to show. */
|
||||
export function networkExposureSection(summary: NetworkExposureSummary | null): string | null {
|
||||
if (!summary) return null;
|
||||
const parts = ['## Network exposure'];
|
||||
if (summary.stackIntent) parts.push(`- **Stack intent:** ${summary.stackIntent}`);
|
||||
if (summary.networks.length > 0) {
|
||||
parts.push('### Networks', summary.networks.map(n => {
|
||||
const flags = [n.external && 'external', n.internal && 'internal'].filter(Boolean).join(', ');
|
||||
return `- ${n.name}${flags ? ` (${flags})` : ''}`;
|
||||
}).join('\n'));
|
||||
}
|
||||
const services = summary.services.filter(s => s.ports.length > 0 || s.intent);
|
||||
if (services.length > 0) {
|
||||
parts.push('### Services', services.map(s => {
|
||||
const bits: string[] = [];
|
||||
if (s.intent) bits.push(`intent ${s.intent}`);
|
||||
if (s.ports.length > 0) bits.push(`ports ${s.ports.join(', ')}`);
|
||||
return `- **${s.name}:** ${bits.join('; ')}`;
|
||||
}).join('\n'));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
Reference in New Issue
Block a user