fix(networking): treat host-network services as host-exposed in summaries (#1430)

The exposure summaries derived a stack's exposure solely from the declared
published-port list, so a service running with network_mode: host (which
publishes every container port on the host but declares no ports:) was
under-reported as less exposed than it actually is.

Capture network_mode in the lightweight dependency parser, add an
isHostNetwork predicate, and treat a host-network service as exposed and
publishing across the Fleet networking summary, the Stack Dossier export, and
the Networking panel, matching how the Compose Doctor already flags host
networking.
This commit is contained in:
Anso
2026-06-24 19:50:45 -04:00
committed by GitHub
parent 2ed01641c8
commit 2eafee3594
11 changed files with 111 additions and 10 deletions
@@ -64,6 +64,16 @@ describe('StackNetworkingPanel', () => {
expect(screen.getByText(/runtime matches compose/i)).toBeInTheDocument(); // no-drift success card
});
it('marks a host-network service as host-exposed', async () => {
mockApi(facts({
services: [{ name: 'app', networks: [], publishedPorts: [], networkMode: 'host', extraHosts: [] }],
}));
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
await screen.findByText('web_backend');
expect(screen.getByText('host-exposed')).toBeInTheDocument();
expect(screen.getByText('all container ports')).toBeInTheDocument();
});
it('saves a stack-level exposure intent on click', async () => {
mockApi(facts());
render(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
@@ -255,6 +255,14 @@ export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled
))}
</div>
)}
{svc.networkMode === 'host' && (
// Host networking publishes every container port on the host, so
// the service is host-exposed even with no published-port rows.
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] text-foreground/80">all container ports</span>
<span className="rounded border border-warning/40 bg-warning/[0.08] px-1 py-0.5 font-mono text-[10px] text-warning">host-exposed</span>
</div>
)}
{svc.publishedPorts.length > 0 && (
<div className="flex flex-col gap-1">
{svc.publishedPorts.map((p, i) => (
+1 -1
View File
@@ -30,7 +30,7 @@ describe('buildStackDossierMarkdown', () => {
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)'] }],
services: [{ name: 'plex', intent: null, ports: ['32400/tcp (all interfaces)'], hostNetwork: false }],
});
expect(md).toContain('## Network exposure');
expect(md).toContain('**Stack intent:** internal');
@@ -31,7 +31,15 @@ describe('buildNetworkExposureSummary', () => {
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)'] }],
services: [{ name: 'web', intent: 'public', ports: ['8080/tcp (all interfaces)', '9000/tcp (loopback)'], hostNetwork: false }],
});
});
it('flags a host-network service as host-exposed even with no published ports', () => {
const s = buildNetworkExposureSummary({ renderable: true, networks: [], services: [{ name: 'app', publishedPorts: [], networkMode: 'host' }] }, []);
expect(s).toEqual({
stackIntent: null,
networks: [],
services: [{ name: 'app', intent: null, ports: [], hostNetwork: true }],
});
});
});
@@ -47,6 +55,10 @@ describe('networkExposureSection', () => {
it('returns null for a null summary', () => {
expect(networkExposureSection(null)).toBeNull();
});
it('renders the host-network phrase for a host-mode service', () => {
const md = networkExposureSection(buildNetworkExposureSummary({ renderable: true, networks: [], services: [{ name: 'app', publishedPorts: [], networkMode: 'host' }] }, [])) ?? '';
expect(md).toContain('host network (all ports exposed on host)');
});
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({
+8 -4
View File
@@ -9,13 +9,13 @@
export interface NetworkExposureSummary {
stackIntent: string | null;
networks: { name: string; external: boolean; internal: boolean }[];
services: { name: string; intent: string | null; ports: string[] }[];
services: { name: string; intent: string | null; ports: string[]; hostNetwork: boolean }[];
}
// 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 FactsService { name: string; publishedPorts?: FactsPort[]; networkMode?: string }
interface FactsNetwork { name: string; external: boolean; internal: boolean }
export interface NetworkFactsInput { renderable?: boolean; networks?: FactsNetwork[]; services?: FactsService[] }
export interface ExposureIntentInput { service: string; intent: string }
@@ -36,9 +36,12 @@ export function buildNetworkExposureSummary(facts: NetworkFactsInput | null, int
name: s.name,
intent: byService.get(s.name) ?? null,
ports: (s.publishedPorts ?? []).map(portLabel),
// network_mode: host publishes every container port on the host, so it is
// exposure-relevant even with no declared ports.
hostNetwork: s.networkMode === 'host',
}));
const empty = networks.length === 0 && stackIntent === null
&& services.every(s => s.ports.length === 0 && s.intent === null);
&& services.every(s => s.ports.length === 0 && s.intent === null && !s.hostNetwork);
return empty ? null : { stackIntent, networks, services };
}
@@ -53,11 +56,12 @@ export function networkExposureSection(summary: NetworkExposureSummary | null):
return `- ${n.name}${flags ? ` (${flags})` : ''}`;
}).join('\n'));
}
const services = summary.services.filter(s => s.ports.length > 0 || s.intent);
const services = summary.services.filter(s => s.ports.length > 0 || s.intent || s.hostNetwork);
if (services.length > 0) {
parts.push('### Services', services.map(s => {
const bits: string[] = [];
if (s.intent) bits.push(`intent ${s.intent}`);
if (s.hostNetwork) bits.push('host network (all ports exposed on host)');
if (s.ports.length > 0) bits.push(`ports ${s.ports.join(', ')}`);
return `- **${s.name}:** ${bits.join('; ')}`;
}).join('\n'));