From 739bbf990e97fd67d4c61192561c043c44f36fff Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 10 Jun 2026 13:59:10 -0400 Subject: [PATCH] feat: flag documentation drift in the stack dossier (#1349) * feat: flag documentation drift in the stack dossier Warn in the Dossier tab when a port written into a stack's access_urls is not published by the stack's compose, so operator documentation stays aligned with what Sencho can observe. The check is deterministic, read-only, and frontend-only: it compares ports parsed from access_urls against the published ports the Anatomy panel already shows, never interprets prose, and stays quiet for port-less URLs, scheme-default ports (:80/:443), and ports published through a variable, to avoid false positives. Community tier, no gating. * test: pin doc-drift handling of bare hosts and mixed variable ports Make two deterministic-drift behaviors intentional and regression-proof after review: a scheme-less single-label host (plex:32400) is not checked, since it cannot be told apart from a plain note (add a scheme to opt in), and a stack mixing a variable-published port with fixed ports suppresses the whole check. Adds tests for both, a clarifying code comment, and a docs note with the http:// workaround. No behavior change. --- docs/features/stack-dossier.mdx | 9 ++ e2e/dossier-doc-drift.spec.ts | 114 +++++++++++++ .../stack/StackDossierPanel.test.tsx | 63 ++++++++ .../components/stack/StackDossierPanel.tsx | 51 +++++- frontend/src/lib/__tests__/docDrift.test.ts | 150 ++++++++++++++++++ frontend/src/lib/docDrift.ts | 147 +++++++++++++++++ 6 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 e2e/dossier-doc-drift.spec.ts create mode 100644 frontend/src/lib/__tests__/docDrift.test.ts create mode 100644 frontend/src/lib/docDrift.ts diff --git a/docs/features/stack-dossier.mdx b/docs/features/stack-dossier.mdx index 9e505078..a9514f75 100644 --- a/docs/features/stack-dossier.mdx +++ b/docs/features/stack-dossier.mdx @@ -43,6 +43,12 @@ Below the generated facts is a form for the details Sencho cannot derive. Every Notes are saved per stack and per node, so the same stack name on two different nodes keeps its own dossier. Anyone can read a dossier; saving changes requires stack edit permission, so read-only users see the notes but not a Save button. +## Documentation drift + +Documentation should describe reality, so Sencho watches the one place it most often slips: a port you wrote into **Access URLs** that the stack no longer publishes. When Compose moves a service from `:32400` to `:32401`, or an access URL points at a port nothing serves, a warning appears in the Dossier tab naming the port to review. + +The check is deterministic and advisory. It reads the ports in your Access URLs and compares them against the published ports in the generated facts above, the same ports the Anatomy tab shows. It never edits your notes or your Compose file, and it never guesses: a URL with no explicit port, or on the scheme default (`:80` for `http`, `:443` for `https`), is left alone, since those usually reach the stack through a reverse proxy rather than a published port. When a stack publishes a port through a variable (such as `${PLEX_PORT}:32400`), the real value is unknown, so the check stays quiet rather than risk a false warning. A bare single-label host with a port (such as `plex:32400`) is also left alone, since it cannot be told apart from a plain note; write it as a full URL (`http://plex:32400`) to have its port checked. Fix either side, the access URL or the stack's ports, and the warning clears on its own. + ## Markdown export Two actions in the tab header produce a single Markdown document combining the generated facts and your notes: @@ -75,4 +81,7 @@ To export every stack across every node at once, use the [Fleet Dossier](/featur The Access URLs field treats each line as a separate URL. Put one URL per line in the form and they are preserved as separate lines in the exported Markdown. + + The warning means an access URL names a port no service in the stack publishes. That is expected when a reverse proxy serves the stack on a port the stack itself does not publish. The warning is advisory and changes nothing: either record the reverse-proxied URL without the internal port, or publish the port in Compose if the URL should reach it directly. + diff --git a/e2e/dossier-doc-drift.spec.ts b/e2e/dossier-doc-drift.spec.ts new file mode 100644 index 00000000..3d8b83d7 --- /dev/null +++ b/e2e/dossier-doc-drift.spec.ts @@ -0,0 +1,114 @@ +/** + * Documentation drift in the Stack Dossier. + * + * The Dossier tab warns when a port written into access_urls is not published by + * the stack's compose. The visual-regression project does not cover the + * stack-detail surface, so this functional flow does. It seeds its own stack and + * compose with a known published port and cleans them up, and does not need a + * running Docker daemon: documentation drift compares the dossier against the + * compose-derived anatomy, not the runtime. + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +const STACK = 'e2e-doc-drift-test'; +const COMPOSE = `services: + web: + image: nginx:alpine + ports: + - "8080:80" +`; + +async function createStackViaApi(page: Page, name: string, content: string): Promise { + await page.evaluate( + async ({ stackName, body }: { stackName: string; body: string }) => { + const createRes = await fetch('/api/stacks', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stackName }), + }); + if (!createRes.ok && createRes.status !== 409) throw new Error(`create failed: ${createRes.status}`); + const writeRes = await fetch(`/api/stacks/${stackName}`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: body }), + }); + if (!writeRes.ok) throw new Error(`write failed: ${writeRes.status}`); + }, + { stackName: name, body: content }, + ); +} + +async function seedDossierAccessUrls(page: Page, name: string, accessUrls: string): Promise { + await page.evaluate( + async ({ stackName, urls }: { stackName: string; urls: string }) => { + const res = await fetch(`/api/stacks/${stackName}/dossier`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ access_urls: urls }), + }); + if (!res.ok) throw new Error(`dossier seed failed: ${res.status}`); + }, + { stackName: name, urls: accessUrls }, + ); +} + +async function deleteStackViaApi(page: Page, name: string): Promise { + await page.evaluate(async (stackName: string) => { + await fetch(`/api/stacks/${stackName}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, name); +} + +async function openStack(page: Page, name: string): Promise { + await page.reload(); + await waitForStacksLoaded(page); + await Promise.all([ + page.waitForResponse( + (r) => r.url().endsWith(`/api/stacks/${name}`) && r.request().method() === 'GET' && r.ok(), + { timeout: 10_000 }, + ), + page.getByText(name, { exact: true }).first().click(), + ]); +} + +test.describe('Dossier documentation drift', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + await deleteStackViaApi(page, STACK); + }); + test.afterEach(async ({ page }) => { + await deleteStackViaApi(page, STACK); + }); + + test('warns for an unpublished access-URL port and clears when it matches', async ({ page }) => { + await createStackViaApi(page, STACK, COMPOSE); + await openStack(page, STACK); + await page.getByRole('tab', { name: 'Dossier' }).click(); + await expect(page.getByTestId('dossier-panel')).toBeVisible(); + + const field = page.getByTestId('dossier-field-access_urls'); + await field.fill('http://localhost:9000'); + const warning = page.getByTestId('dossier-doc-drift'); + await expect(warning).toBeVisible(); + await expect(warning).toContainText(':9000'); + + // Point the URL at the published port: the warning must clear. + await field.fill('http://localhost:8080'); + await expect(page.getByTestId('dossier-doc-drift')).toHaveCount(0); + }); + + test('shows the warning on the read-only mobile dossier', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await createStackViaApi(page, STACK, COMPOSE); + await seedDossierAccessUrls(page, STACK, 'http://localhost:9000'); + await openStack(page, STACK); + await page.getByRole('tab', { name: 'Compose' }).click(); + await page.getByRole('tab', { name: 'Dossier' }).click(); + const warning = page.getByTestId('dossier-doc-drift'); + await expect(warning).toBeVisible(); + await expect(warning).toContainText(':9000'); + }); +}); diff --git a/frontend/src/components/stack/StackDossierPanel.test.tsx b/frontend/src/components/stack/StackDossierPanel.test.tsx index 1fcd2e62..43de3178 100644 --- a/frontend/src/components/stack/StackDossierPanel.test.tsx +++ b/frontend/src/components/stack/StackDossierPanel.test.tsx @@ -126,4 +126,67 @@ describe('StackDossierPanel', () => { fireEvent.click(screen.getByTestId('dossier-download-btn')); expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web')); }); + + // Anatomy that publishes a single TCP host port, for documentation-drift tests. + const anatomyPublishing = (host: string): AnatomyMarkdownInput => ({ + ...anatomy, + ports: { web: [{ host, container: '80', proto: 'tcp' }] }, + }); + + it('warns when an access URL names a port the stack does not publish', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' })); + render(); + expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':32400'); + }); + + it('does not warn when the access URL port is published', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' })); + render(); + await screen.findByTestId('dossier-panel'); + expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument(); + }); + + it('shows the drift warning to read-only viewers', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' })); + render(); + expect(await screen.findByTestId('dossier-doc-drift')).toBeInTheDocument(); + }); + + it('updates the warning live as access_urls is edited', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS })); + render(); + await screen.findByTestId('dossier-panel'); + expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument(); + fireEvent.change(screen.getByTestId('dossier-field-access_urls'), { target: { value: 'http://host:9000' } }); + expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':9000'); + }); + + it('clears the warning when the URL is edited to a published port', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' })); + render(); + await screen.findByTestId('dossier-doc-drift'); + fireEvent.change(screen.getByTestId('dossier-field-access_urls'), { target: { value: 'http://host:8080' } }); + await waitFor(() => expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument()); + }); + + it('renders one row per unpublished port', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000\nhttp://host:9001' })); + render(); + const section = await screen.findByTestId('dossier-doc-drift'); + expect(section).toHaveTextContent(':9000'); + expect(section).toHaveTextContent(':9001'); + }); + + it('suppresses the warning when a reload fails, never showing the previous stack stale', async () => { + vi.mocked(apiFetch) + .mockResolvedValueOnce(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' })) + .mockResolvedValueOnce(jsonRes({ error: 'down' }, false)); + const { rerender } = render(); + expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':9000'); + // Switch stacks: the reload fails, so the prior stack's drifting fields stay + // resident but must not keep a warning on screen. + rerender(); + await screen.findByTestId('dossier-retry-btn'); + expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/stack/StackDossierPanel.tsx b/frontend/src/components/stack/StackDossierPanel.tsx index e94ac194..c213d365 100644 --- a/frontend/src/components/stack/StackDossierPanel.tsx +++ b/frontend/src/components/stack/StackDossierPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Copy, Download, Save } from 'lucide-react'; +import { Copy, Download, Save, TriangleAlert } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { copyToClipboard } from '@/lib/clipboard'; @@ -12,6 +12,7 @@ import { type StackDossierFields, } from '@/lib/dossierMarkdown'; import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown'; +import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift'; import { useNodes } from '@/context/NodeContext'; interface StackDossierPanelProps { @@ -124,9 +125,37 @@ function GeneratedFacts({ anatomy }: { anatomy: AnatomyMarkdownInput }) { ); } +// Documentation drift: ports the operator documented in access_urls that the +// stack does not publish. Read-only and advisory; it never edits notes or +// compose. Visual language matches the Drift tab's warning findings. +function DocDriftWarnings({ findings }: { findings: DocDriftFinding[] }) { + return ( +
+
documentation drift
+
+ {findings.map(f => ( +
+
+ + :{f.port} + not published +
+
{f.detail}
+
{f.source}
+
+ ))} +
+
+ ); +} + export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) { const { activeNode } = useNodes(); const nodeId = activeNode?.id; + // Identifies the dossier currently in view. Doc-drift renders only once the + // load for *this* key has succeeded (see loadedKey), so a switch-in-flight or + // a failed load never diffs new anatomy against the prior stack's fields. + const currentKey = `${nodeId ?? ''}::${stackName}`; const [fields, setFields] = useState(EMPTY_DOSSIER_FIELDS); // The last-saved values, kept in state so dirty-tracking compares against them // without reading a ref during render. @@ -134,6 +163,10 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack const [saving, setSaving] = useState(false); const [loadError, setLoadError] = useState(false); const [reloadKey, setReloadKey] = useState(0); + // The (node, stack) whose dossier has successfully loaded. Set only on a + // successful fetch, so it lags during a switch and stays behind on a failed + // load, which is exactly when doc-drift must stay hidden. + const [loadedKey, setLoadedKey] = useState(null); // Reload when the stack OR the active node changes: the same stack name can // exist on two nodes with independent dossiers, and apiFetch scopes by the @@ -151,10 +184,11 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack toast.error('Failed to load the stack dossier.'); return; } - const loaded = pickFields(await res.json()); - setServerFields(loaded); - setFields(loaded); + const next = pickFields(await res.json()); + setServerFields(next); + setFields(next); setLoadError(false); + setLoadedKey(`${nodeId ?? ''}::${stackName}`); } catch { if (!cancelled) { setLoadError(true); @@ -171,6 +205,13 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack [fields, serverFields], ); + // Recomputes live as the operator edits access_urls; depends on that single + // field so unrelated edits do not re-run the comparison. + const docDrift = useMemo( + () => computeDocDrift(anatomy, fields.access_urls), + [anatomy, fields.access_urls], + ); + const setField = (key: keyof StackDossierFields, value: string) => setFields(prev => ({ ...prev, [key]: value })); @@ -249,6 +290,8 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack )} + {loadedKey === currentKey && docDrift.length > 0 && } +
operator notes
{loadError ? ( diff --git a/frontend/src/lib/__tests__/docDrift.test.ts b/frontend/src/lib/__tests__/docDrift.test.ts new file mode 100644 index 00000000..528032b0 --- /dev/null +++ b/frontend/src/lib/__tests__/docDrift.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { computeDocDrift, extractExplicitAccessPort } from '../docDrift'; +import type { AnatomyMarkdownInput, PortRow } from '../anatomyMarkdown'; + +function anatomyWith(ports: PortRow[]): AnatomyMarkdownInput { + return { + stackName: 'web', + services: ['web'], + ports: ports.length ? { web: ports } : {}, + volumes: {}, + restart: null, + envFile: null, + envVarCount: 0, + missingVars: [], + networkName: 'web_default', + gitSource: null, + }; +} +const tcp = (host: string): PortRow => ({ host, container: '80', proto: 'tcp' }); +const udp = (host: string): PortRow => ({ host, container: '80', proto: 'udp' }); +/** Ports flagged for the given access_urls against the given published rows. */ +const flagged = (urls: string, published: PortRow[] = []): number[] => + computeDocDrift(anatomyWith(published), urls).map((f) => f.port); + +describe('extractExplicitAccessPort', () => { + it('reads an explicit non-default port from an absolute URL', () => { + expect(extractExplicitAccessPort('http://host:8080')).toBe(8080); + expect(extractExplicitAccessPort('https://host:32400/web')).toBe(32400); + }); + + it('skips scheme-default ports (http :80, https :443) and port-less URLs', () => { + expect(extractExplicitAccessPort('http://host:80')).toBeNull(); + expect(extractExplicitAccessPort('https://host:443')).toBeNull(); + expect(extractExplicitAccessPort('https://host')).toBeNull(); + }); + + it('isolates the port from userinfo and never reads a password', () => { + expect(extractExplicitAccessPort('http://user:pass@host:8080')).toBe(8080); + expect(extractExplicitAccessPort('http://user:pass@host')).toBeNull(); + }); + + it('handles IPv6 in absolute and bare forms', () => { + expect(extractExplicitAccessPort('http://[::1]:8080')).toBe(8080); + expect(extractExplicitAccessPort('[::1]:8080')).toBe(8080); + }); + + it('parses bare host:port for host-ish authorities (the corrected fallback)', () => { + expect(extractExplicitAccessPort('plex.local:32400')).toBe(32400); + expect(extractExplicitAccessPort('plex.local:32400/web')).toBe(32400); + expect(extractExplicitAccessPort('localhost:8080')).toBe(8080); + expect(extractExplicitAccessPort('192.168.1.5:32400')).toBe(32400); + }); + + it('rejects prose that merely looks like host:port', () => { + expect(extractExplicitAccessPort('note:8080')).toBeNull(); // single-label host, not host-ish + expect(extractExplicitAccessPort('ratio 16:9')).toBeNull(); // space -> invalid URL + expect(extractExplicitAccessPort('see wiki')).toBeNull(); + expect(extractExplicitAccessPort('')).toBeNull(); + expect(extractExplicitAccessPort(' ')).toBeNull(); + }); + + it('rejects out-of-range and non-numeric ports', () => { + expect(extractExplicitAccessPort('http://host:0')).toBeNull(); + expect(extractExplicitAccessPort('http://host:99999')).toBeNull(); + expect(extractExplicitAccessPort('http://host:abc')).toBeNull(); + }); + + it('intentionally does not check a scheme-less single-label host, but does with a scheme', () => { + // `plex:32400` is indistinguishable from prose like `note:8080`, so the bare + // form is skipped to avoid false positives. Adding a scheme opts it in. + expect(extractExplicitAccessPort('plex:32400')).toBeNull(); + expect(extractExplicitAccessPort('nas:8096')).toBeNull(); + expect(extractExplicitAccessPort('http://plex:32400')).toBe(32400); + }); +}); + +describe('computeDocDrift', () => { + it('flags a documented port that nothing publishes', () => { + expect(flagged('http://host:9000', [tcp('8080')])).toEqual([9000]); + }); + + it('does not flag a documented port that is published', () => { + expect(flagged('http://host:8080', [tcp('8080')])).toEqual([]); + }); + + it('does not flag a port-less or scheme-default URL', () => { + expect(flagged('http://host', [tcp('8080')])).toEqual([]); + expect(flagged('http://host:80', [tcp('8080')])).toEqual([]); + }); + + it('does not let a UDP-only publish satisfy an http access URL', () => { + expect(flagged('http://host:51820', [udp('51820')])).toEqual([51820]); + }); + + it('matches a documented port inside a published range', () => { + expect(flagged('http://host:8001', [tcp('8000-8002')])).toEqual([]); + expect(flagged('http://host:8003', [tcp('8000-8002')])).toEqual([8003]); + }); + + it('recognizes a one-part published port (Anatomy UI semantics)', () => { + expect(flagged('http://host:8096', [tcp('8096')])).toEqual([]); + }); + + it('dedupes repeated ports and sorts findings by port', () => { + expect(flagged('http://host:9000\nhttp://host:9000')).toEqual([9000]); + expect(flagged('http://host:9000\nhttp://host:8000', [tcp('1234')])).toEqual([8000, 9000]); + }); + + it('checks only URL-shaped lines, ignoring prose', () => { + expect(flagged('http://host:8080\nhttp://host:9000\nnote:8080\nsee wiki', [tcp('8080')])).toEqual([9000]); + }); + + it('flags only the unpublished line when published and unpublished URLs are mixed', () => { + expect(flagged('http://host:8080\nhttp://host:9000', [tcp('8080')])).toEqual([9000]); + }); + + it('treats an uppercase UDP proto as non-TCP', () => { + expect(flagged('http://host:51820', [{ host: '51820', container: '51820', proto: 'UDP' }])).toEqual([51820]); + }); + + it('stays quiet when a port is published through an unresolved variable', () => { + // ${PLEX_PORT}:32400 parses to a non-numeric host; its real value is unknown, + // so a documented :32400 must not be flagged as a false positive. + expect(flagged('http://host:32400', [{ host: '${PLEX_PORT}', container: '32400', proto: 'tcp' }])).toEqual([]); + }); + + it('suppresses the whole stack when a variable port is mixed with fixed ports', () => { + // One indeterminate (variable) port makes the published set unknowable, so + // even a port that is clearly unpublished stays unflagged. Pins the global + // suppression so it cannot regress to a partial check. + const published = [tcp('8080'), { host: '${PLEX_PORT}', container: '32400', proto: 'tcp' }]; + expect(flagged('http://host:9999', published)).toEqual([]); + }); + + it('keeps the first source line when a port is deduped across lines', () => { + const [finding] = computeDocDrift(anatomyWith([]), 'http://a:9000\nhttps://b:9000/x'); + expect(finding.source).toBe('http://a:9000'); + }); + + it('returns nothing when anatomy is unavailable or no URLs are documented', () => { + expect(computeDocDrift(null, 'http://host:9000')).toEqual([]); + expect(flagged('', [tcp('8080')])).toEqual([]); + }); + + it('produces an actionable finding shape', () => { + const [finding] = computeDocDrift(anatomyWith([tcp('8080')]), ' http://host:9000 '); + expect(finding).toMatchObject({ kind: 'access-url-port-unpublished', port: 9000, source: 'http://host:9000' }); + expect(finding.detail).toContain('9000'); + }); +}); diff --git a/frontend/src/lib/docDrift.ts b/frontend/src/lib/docDrift.ts new file mode 100644 index 00000000..e2b86360 --- /dev/null +++ b/frontend/src/lib/docDrift.ts @@ -0,0 +1,147 @@ +/** + * Documentation drift for the Stack Dossier: warns when operator-authored + * documentation no longer matches the facts Sencho can observe. This first + * version is deterministic and read-only, and checks one thing: a port written + * into the dossier `access_urls` that no service actually publishes (the Plex + * "moved from :32400 to :32401" case, and "an access URL with no matching + * published port"). No prose is interpreted and no AI is involved. + * + * It compares against exactly the published ports the Anatomy panel shows (the + * `anatomy.ports` in "generated facts", rendered directly above these warnings), + * so a warning can never contradict the visible facts in the same tab. That + * means it inherits the frontend parser's one-part convention: `ports: ["80"]` + * reads as host `80` here, matching the panel, even though a bare one-part + * mapping actually publishes to an ephemeral host port. Reconciling that is a + * separate, panel-wide concern and out of scope for this check. + */ +import type { AnatomyMarkdownInput } from './anatomyMarkdown'; + +export interface DocDriftFinding { + /** Discriminant mirroring the backend `StackDriftFinding`, so a later check can extend this into a union. */ + kind: 'access-url-port-unpublished'; + /** The port written into an access URL that nothing publishes. */ + port: number; + /** The dossier `access_urls` line the port came from. */ + source: string; + /** Specific, actionable description of what to review. */ + detail: string; +} + +/** A published port: a single port or an inclusive `[low, high]` range. */ +type PortSpec = number | [number, number]; + +function validPort(raw: string): number | null { + if (!/^\d+$/.test(raw)) return null; + const n = Number(raw); + return n >= 1 && n <= 65535 ? n : null; +} + +/** + * The explicit host port an access-URL line points at, or null when there is + * nothing to check. Scheme-default ports (`:80` on http, `:443` on https) are + * intentionally skipped: the URL API normalizes them to '' so a documented + * default port never produces a warning. + */ +export function extractExplicitAccessPort(line: string): number | null { + const s = line.trim(); + if (!s) return null; + + // Absolute URL (a real `scheme://`): parse directly. `url.port` is '' for an + // omitted or scheme-default port, and the API isolates the port from any + // `user:pass@` userinfo and from IPv6 brackets. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) { + try { + const { port } = new URL(s); + return port ? validPort(port) : null; + } catch { + return null; + } + } + + // Bare `host:port` (no scheme). `new URL('host:port')` would misread it as + // `scheme:opaque` with no port, so prefix a scheme and require a host-ish + // authority. Accepts `192.168.1.5:32400`, `localhost:8080`, `[::1]:8080`; + // rejects prose like `note:8080` (host `note`) or `ratio 16:9` (throws). + // A bare single-label host (`plex:32400`) is indistinguishable from prose and + // is intentionally not checked here; writing it with a scheme (`http://plex:32400`) + // routes it through the absolute branch above, where it is checked. + try { + const url = new URL(`http://${s}`); + if (!url.port) return null; + const host = url.hostname; + const hostish = host.includes('.') || host === 'localhost' || host.startsWith('['); + return hostish ? validPort(url.port) : null; + } catch { + return null; + } +} + +function parsePortSpec(host: string): PortSpec | null { + const h = host.trim(); + const range = h.match(/^(\d+)-(\d+)$/); + if (range) { + const lo = validPort(range[1]); + const hi = validPort(range[2]); + return lo !== null && hi !== null && lo <= hi ? [lo, hi] : null; + } + return validPort(h); +} + +/** + * The published TCP ports the panel shows, as single/range specs (an http(s) + * access URL cannot be served by a UDP publish). An empty `host` is a + * container-only port and is skipped. `indeterminate` is set when a TCP port is + * published through an unresolved variable (e.g. `${PLEX_PORT}:32400`): its real + * value is unknown, so any documented port might match it and the caller must + * not flag, to avoid a false positive. + */ +function publishedPortModel(anatomy: AnatomyMarkdownInput): { specs: PortSpec[]; indeterminate: boolean } { + const specs: PortSpec[] = []; + let indeterminate = false; + for (const rows of Object.values(anatomy.ports)) { + for (const row of rows) { + if (row.proto && row.proto.toLowerCase() !== 'tcp') continue; + const host = row.host.trim(); + if (!host) continue; + const spec = parsePortSpec(host); + if (spec === null) indeterminate = true; + else specs.push(spec); + } + } + return { specs, indeterminate }; +} + +function isPublished(port: number, specs: PortSpec[]): boolean { + return specs.some((s) => (typeof s === 'number' ? s === port : port >= s[0] && port <= s[1])); +} + +/** + * Compares ports written into the dossier `access_urls` against the stack's + * published ports and returns one finding per documented port that nothing + * publishes. Pure and deterministic: findings are deduped per port and sorted + * by port then source. Returns [] when anatomy is unavailable (compose could + * not be parsed) or when a published port resolves through a variable, since in + * both cases the published set cannot be compared without risking a false flag. + */ +export function computeDocDrift( + anatomy: AnatomyMarkdownInput | null, + accessUrls: string, +): DocDriftFinding[] { + if (!anatomy) return []; + const { specs, indeterminate } = publishedPortModel(anatomy); + if (indeterminate) return []; + const seen = new Set(); + const findings: DocDriftFinding[] = []; + for (const line of accessUrls.split('\n')) { + const port = extractExplicitAccessPort(line); + if (port === null || isPublished(port, specs) || seen.has(port)) continue; + seen.add(port); + findings.push({ + kind: 'access-url-port-unpublished', + port, + source: line.trim(), + detail: `An access URL points to port ${port}, but no service in this stack publishes it. Review the access URL or the stack's published ports.`, + }); + } + return findings.sort((a, b) => a.port - b.port || a.source.localeCompare(b.source)); +}