From 2af324f6ee76f0b36e3bf6616eae385afc767939 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 18 May 2026 09:09:45 -0300 Subject: [PATCH] fix(docker): ship Dashboard Docs markdown in the container image (#2348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.dockerignore` excluded everything under `docs/` except `openapi.yaml`, which broke the in-product Docs viewer at `/docs/*` with "ENOENT: no such file or directory, open '/app/docs/SETUP_GUIDE.md'" for every help page. The previous block-list was meant to keep the image small but it hid the ~5 MB English markdown tree that the viewer actually reads. Replace the block with a targeted exclude of the heavy assets that account for ~45 MB of the original ~50 MB: - docs/i18n/** (translated copies — viewer falls back to English) - docs/screenshots/** - docs/diagrams/exported/** plus raster/SVG variants Note: Go filepath.Match (Docker's matcher) treats `*` as not crossing `/`, so the existing `*.md` rule still excludes only root-level markdown — nested `docs/**/*.md` is implicitly kept without a re-include rule that would have brought i18n back. Added a regression test that parses .dockerignore and asserts the critical English docs survive the filter while the heavy paths still do not. --- .dockerignore | 24 ++- tests/unit/dockerignore-docs-coverage.test.ts | 153 ++++++++++++++++++ 2 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 tests/unit/dockerignore-docs-coverage.test.ts diff --git a/.dockerignore b/.dockerignore index fb2d2a7d3a..494187a285 100644 --- a/.dockerignore +++ b/.dockerignore @@ -37,10 +37,26 @@ test-results playwright-report blob-report -# Documentation (not needed in container) -# Keep the docs directory itself in context so openapi.yaml can be re-included. -docs/* -!docs/openapi.yaml +# Documentation +# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at +# runtime. The previous `docs/*` block hid every file except openapi.yaml, +# so the in-product help screen failed with ENOENT for every page. +# We now keep the English markdown tree but drop the bulky assets +# (translations, screenshots, raster diagrams) that account for ~45 MB +# of the ~50 MB docs directory. The Docs viewer reads the default-locale +# (English) sources at runtime, so translations are not required in the +# container image. +docs/i18n/** +docs/screenshots/** +docs/diagrams/exported/** +docs/diagrams/**/*.png +docs/diagrams/**/*.jpg +docs/diagrams/**/*.jpeg +docs/diagrams/**/*.gif +docs/diagrams/**/*.webp +docs/diagrams/**/*.svg +# Note: `*.md` matches the root only (Go filepath.Match does not cross /), +# so nested docs/**/*.md is implicitly kept without a re-include rule. *.md !README.md diff --git a/tests/unit/dockerignore-docs-coverage.test.ts b/tests/unit/dockerignore-docs-coverage.test.ts new file mode 100644 index 0000000000..4e0ca4d1b2 --- /dev/null +++ b/tests/unit/dockerignore-docs-coverage.test.ts @@ -0,0 +1,153 @@ +/** + * Issue #2348 — Regression guard for the Docker image documentation bundle. + * + * The Dashboard Docs viewer at `src/app/docs/[slug]/page.tsx` reads markdown + * from `process.cwd()/docs/` at runtime. The previous `.dockerignore` + * shipped only `docs/openapi.yaml` to the container, so every help screen + * threw ENOENT. + * + * This test parses `.dockerignore`, applies it against the working tree, + * and asserts that the critical English markdown files are still in the + * Docker build context. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../.."); +const DOCKERIGNORE = path.resolve(REPO_ROOT, ".dockerignore"); + +// Subset of the docs viewer's catalog that MUST survive the docker filter. +// Sourced from src/app/docs/lib/docs-auto-generated.ts. +const REQUIRED_DOCS = [ + "docs/README.md", + "docs/PROVIDERS.md", + "docs/AUTO-COMBO.md", + "docs/guides/SETUP_GUIDE.md", + "docs/guides/TROUBLESHOOTING.md", + "docs/reference/API_REFERENCE.md", + "docs/reference/PROVIDER_REFERENCE.md", + "docs/reference/ENVIRONMENT.md", +]; + +// Compile .dockerignore patterns into a simple matcher. +// We only need to support the directives we actually use: glob `**`, plain +// path prefixes, and negations starting with `!`. +function parseDockerignore(text: string): { excludes: string[]; includes: string[] } { + const excludes: string[] = []; + const includes: string[] = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("!")) { + includes.push(line.slice(1)); + } else { + excludes.push(line); + } + } + return { excludes, includes }; +} + +/** + * Match a Docker glob pattern against a file path without building a dynamic + * RegExp (avoid ReDoS risk on patterns sourced from .dockerignore). We walk + * the pattern token-by-token. Supported syntax (the subset our .dockerignore + * actually uses): literal segments, `*` (no slash), `**` (any depth incl. 0), + * and trailing `**`. + */ +function patternMatches(pattern: string, file: string): boolean { + const pSegs = pattern.split("/"); + const fSegs = file.split("/"); + return matchSegments(pSegs, 0, fSegs, 0); +} + +function matchSegments(p: string[], pi: number, f: string[], fi: number): boolean { + while (pi < p.length) { + const seg = p[pi]; + if (seg === "**") { + // Try consuming 0..N file segments. + if (pi === p.length - 1) return true; // trailing ** consumes everything + for (let k = fi; k <= f.length; k++) { + if (matchSegments(p, pi + 1, f, k)) return true; + } + return false; + } + if (fi >= f.length) return false; + if (!segmentMatches(seg, f[fi])) return false; + pi++; + fi++; + } + return fi === f.length; +} + +function segmentMatches(pattern: string, segment: string): boolean { + if (pattern === "*") return true; + if (!pattern.includes("*")) return pattern === segment; + // Single-segment glob with one or more `*` wildcards. Walk literal chunks. + const parts = pattern.split("*"); + let cursor = 0; + // Anchor first chunk to start. + const first = parts[0]; + if (first && !segment.startsWith(first)) return false; + cursor = first.length; + // Anchor last chunk to end. + const last = parts[parts.length - 1]; + if (last && !segment.endsWith(last)) return false; + // Each middle chunk must appear in order between cursor and end-last. + const endLimit = segment.length - last.length; + for (let i = 1; i < parts.length - 1; i++) { + const idx = segment.indexOf(parts[i], cursor); + if (idx === -1 || idx + parts[i].length > endLimit) return false; + cursor = idx + parts[i].length; + } + return true; +} + +function isIgnored(file: string, parsed: { excludes: string[]; includes: string[] }): boolean { + let ignored = false; + for (const ex of parsed.excludes) if (patternMatches(ex, file)) ignored = true; + if (ignored) { + for (const inc of parsed.includes) if (patternMatches(inc, file)) ignored = false; + } + return ignored; +} + +test("#2348 .dockerignore keeps every doc the in-product viewer needs", () => { + const parsed = parseDockerignore(fs.readFileSync(DOCKERIGNORE, "utf8")); + const missing: string[] = []; + const ignored: string[] = []; + + for (const docPath of REQUIRED_DOCS) { + const absPath = path.resolve(REPO_ROOT, docPath); + if (!fs.existsSync(absPath)) { + missing.push(docPath); + continue; + } + if (isIgnored(docPath, parsed)) { + ignored.push(docPath); + } + } + + assert.deepEqual(missing, [], `Required docs missing from repo: ${missing.join(", ")}`); + assert.deepEqual( + ignored, + [], + `Files excluded from Docker context — Dashboard Docs viewer will 404 on these:\n ${ignored.join("\n ")}` + ); +}); + +test("#2348 .dockerignore still excludes the heavy i18n + screenshots dirs", () => { + const parsed = parseDockerignore(fs.readFileSync(DOCKERIGNORE, "utf8")); + // These should NOT make it into the container — they are 45+ MB combined + // and the in-product viewer reads only the English originals at runtime. + const HEAVY_PATHS = ["docs/i18n/pt-BR/docs/AUTO-COMBO.md", "docs/screenshots/dashboard.png"]; + for (const heavy of HEAVY_PATHS) { + assert.ok( + isIgnored(heavy, parsed), + `${heavy} should be excluded from Docker context but is not — image size will balloon` + ); + } +});