Public: anyone can read this; the crew can change it.
A `compose.yaml` or `docker-compose.yml` opens as its services: image or build, ports, volumes, depends_on and environment names #11279
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11211-a-compose-yaml-or-docker-compose-yml-ope → main 4 commits fork 3 threads · 3 unresolved
5 files
changed +226
−0
docs/features/file-compose.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # A compose file opens as its services | |
| 2 | ||
| 3 | `compose.yaml`, `compose.yml`, `docker-compose.yaml` and `docker-compose.yml` open as a table instead of raw YAML. Source is one tap away (`?view=source`), and Raw is beside it. | |
| 4 | ||
| 5 | - **Rows:** one per service: its image (else `build` and the context), ports, volumes, `depends_on` and the **names** of its environment variables. Values are never shown. | |
| 6 | - **Fallback:** a file that is not valid YAML, holds several documents, is not a map or has no `services` shows as YAML with one line saying why, and no error banner. | |
| 7 | - **Cap:** at most 100 services and 50 entries per column, then a `more services not shown` line. Files over 1 MB keep Source. Values are cut at 300 characters. | |
| 8 | - **Phone:** the table scrolls sideways inside its own region. | |
| 9 | - **Terminal:** not drawn there; the terminal shows a compose file as source, as it does the other kinds it has no table for. | |
| 10 | - Every value is escaped, drawn on the server; no script, no new CSS, no dependency. | |
| 11 | ||
| 12 | Code: `src/web/render/compose.ts`, `src/web/render/kinds.ts`. Test: `test/render-compose.test.ts`. |
src/web/render/compose.ts added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | /** | |
| 2 | * A `compose.yaml` / `docker-compose.yml` as its services: one row per | |
| 3 | * service with its image or build context, ports, volumes, `depends_on` and | |
| 4 | * the NAMES of its environment variables, never their values. A file that is | |
| 5 | * not YAML or has no `services` map is not drawn: the source shows with the | |
| 6 | * reason, no error banner. Every string is escaped and cut to `TEXT_CAP`; at | |
| 7 | * most `SERVICE_CAP` services are drawn and `LIST_CAP` entries per column. | |
| 8 | */ | |
| 9 | ||
| 10 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 11 | import { fileSize } from "../ui.ts"; | |
| 12 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 13 | import { parseAllDocuments } from "./vendor/yaml.mjs"; | |
| 14 | ||
| 15 | export const SERVICE_CAP = 100; | |
| 16 | export const LIST_CAP = 50; | |
| 17 | const TEXT_CAP = 300; | |
| 18 | ||
| 19 | export interface Service { | |
| 20 | readonly name: string; | |
| 21 | /** `image: x`, else `build: context`, else empty. */ | |
| 22 | readonly source: string; | |
| 23 | readonly built: boolean; | |
| 24 | readonly ports: string[]; | |
| 25 | readonly volumes: string[]; | |
| 26 | readonly dependsOn: string[]; | |
| 27 | /** Variable names only. */ | |
| 28 | readonly env: string[]; | |
| 29 | } | |
| 30 | export type Compose = { readonly services: Service[]; readonly total: number } | { readonly error: string }; | |
| 31 | ||
| 32 | const cut = (s: string): string => s.slice(0, TEXT_CAP); | |
| 33 | const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v); | |
| 34 | const scalar = (v: unknown): string => (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? cut(String(v)) : ""); | |
| 35 | ||
| 36 | /** A short or long form entry (`"80:80"` or `{target, published}`) as one string. */ | |
| 37 | function entry(v: unknown): string { | |
| 38 | if (!isObj(v)) return scalar(v); | |
| 39 | const a = scalar(v["published"] ?? v["source"]); | |
| 40 | const b = scalar(v["target"]); | |
| 41 | return cut(a !== "" && b !== "" ? `${a}:${b}` : a + b); | |
| 42 | } | |
| 43 | const list = (v: unknown): string[] => (Array.isArray(v) ? v : []).map(entry).filter((s) => s !== "").slice(0, LIST_CAP); | |
| 44 | ||
| 45 | /** `environment` is a map or a list of `NAME=value` / `NAME`; only the name is kept. */ | |
| 46 | function envNames(v: unknown): string[] { | |
| 47 | const names = isObj(v) | |
| 48 | ? Object.keys(v) | |
| 49 | : Array.isArray(v) | |
| 50 | ? v.map((x) => (typeof x === "string" ? x.split("=")[0]!.trim() : "")) | |
| 51 | : []; | |
| 52 | return names.filter((n) => n !== "").map(cut).slice(0, LIST_CAP); | |
| 53 | } | |
| 54 | ||
| 55 | /** `depends_on` is a list or a map keyed by service. */ | |
| 56 | const dependsOn = (v: unknown): string[] => (isObj(v) ? Object.keys(v).map(cut).slice(0, LIST_CAP) : list(v)); | |
| 57 | ||
| 58 | function buildOf(v: unknown): string { | |
| 59 | if (typeof v === "string") return cut(v); | |
| 60 | return isObj(v) ? scalar(v["context"]) || "." : ""; | |
| 61 | } | |
| 62 | ||
| 63 | export function parseCompose(source: string): Compose { | |
| 64 | const docs = parseAllDocuments(source, { maxAliasCount: 100 }); | |
| 65 | const doc = docs[0]; | |
| 66 | if (doc === undefined || docs.length > 1) return { error: "it is not one YAML document" }; | |
| 67 | if (doc.errors.length > 0) return { error: "it is not valid YAML" }; | |
| 68 | let top: unknown; | |
| 69 | try { | |
| 70 | top = doc.toJS({ maxAliasCount: 100 }); | |
| 71 | } catch { | |
| 72 | return { error: "it is not valid YAML" }; | |
| 73 | } | |
| 74 | if (!isObj(top) || !isObj(top["services"])) return { error: "no services" }; | |
| 75 | const names = Object.keys(top["services"]); | |
| 76 | const services: Service[] = []; | |
| 77 | for (const name of names.slice(0, SERVICE_CAP)) { | |
| 78 | const s = isObj(top["services"][name]) ? (top["services"][name] as Record<string, unknown>) : {}; | |
| 79 | const image = scalar(s["image"]); | |
| 80 | const build = image === "" ? buildOf(s["build"]) : ""; | |
| 81 | services.push({ | |
| 82 | name: cut(name), | |
| 83 | source: image || build, | |
| 84 | built: image === "" && build !== "", | |
| 85 | ports: list(s["ports"]), | |
| 86 | volumes: list(s["volumes"]), | |
| 87 | dependsOn: dependsOn(s["depends_on"]), | |
| 88 | env: envNames(s["environment"]), | |
| 89 | }); | |
| 90 | } | |
| 91 | return { services, total: names.length }; | |
| 92 | } | |
| 93 | ||
| 94 | const cell = (items: string[]): string => items.map((s) => `<code>${escapeHtml(s)}</code>`).join(" "); | |
| 95 | ||
| 96 | export function compose(input: RenderInput): Rendered { | |
| 97 | const doc = parseCompose(new TextDecoder().decode(input.body)); | |
| 98 | if ("error" in doc) return { content: html``, lines: 0, fallback: doc.error === "no services" ? "This file has no services, so it is shown as YAML." : `This file is shown as YAML because ${doc.error}.` }; | |
| 99 | let rows = ""; | |
| 100 | for (const s of doc.services) { | |
| 101 | const src = s.source === "" ? "" : `${s.built ? "build " : ""}<code>${escapeHtml(s.source)}</code>`; | |
| 102 | rows += `<tr><th scope="row">${escapeHtml(s.name)}</th><td>${src}</td><td>${cell(s.ports)}</td><td>${cell(s.volumes)}</td><td>${cell(s.dependsOn)}</td><td>${cell(s.env)}</td></tr>`; | |
| 103 | } | |
| 104 | const more = doc.total > doc.services.length ? `<p class="muted small">${(doc.total - doc.services.length).toLocaleString()} more services not shown</p>` : ""; | |
| 105 | const table = `<table class="table" data-dense aria-label="Services"><thead><tr><th scope="col">Service</th><th scope="col">Image or build</th><th scope="col">Ports</th><th scope="col">Volumes</th><th scope="col">Depends on</th><th scope="col">Environment names</th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 106 | return { | |
Principle 3/7 and phone use: six columns in a plain div overflow at 360px. Wrap the table in |
||
| 107 | content: html`<div class="table-scroll" role="region" tabindex="0" aria-label="Services">${raw(table)}</div>${raw(more)}`, | |
| 108 | lines: 0, | |
| 109 | summary: `${doc.total} ${doc.total === 1 ? "service" : "services"} · ${fileSize(input.size)}`, | |
| 110 | }; | |
| 111 | } |
src/web/render/kinds.ts +7
| @@ -45,6 +45,7 @@ export type FileKind = | ||
| 45 | 45 | | "makefile" |
| 46 | 46 | | "dockerfile" |
| 47 | 47 | | "workflow" |
| 48 | | "compose" | |
| 48 | 49 | | "sql" |
| 49 | 50 | | "text" |
| 50 | 51 | | "binary"; |
| @@ -89,6 +90,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 89 | 90 | makefile: 1 * MB, |
| 90 | 91 | dockerfile: 1 * MB, |
| 91 | 92 | workflow: 1 * MB, |
| 93 | compose: 1 * MB, | |
| 92 | 94 | sql: 1 * MB, |
| 93 | 95 | text: 1 * MB, |
| 94 | 96 | binary: 0, |
| @@ -231,6 +233,9 @@ const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Container | ||
| 231 | 233 | /** A GitHub Actions workflow: a `.yml` or `.yaml` directly under `.github/workflows/`. */ |
| 232 | 234 | const isWorkflowName = (path: string): boolean => /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(path); |
| 233 | 235 | |
| 236 | /** `compose.yaml|yml` and `docker-compose.yaml|yml`. */ | |
| 237 | const isComposeName = (path: string): boolean => /(^|\/)(docker-)?compose\.ya?ml$/i.test(path); | |
| 238 | ||
| 234 | 239 | /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */ |
| 235 | 240 | const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path); |
| 236 | 241 | |
| @@ -306,6 +311,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 306 | 311 | if (isMakefileName(path)) return "makefile"; |
| 307 | 312 | if (isDockerfileName(path)) return "dockerfile"; |
| 308 | 313 | if (isWorkflowName(path)) return "workflow"; |
| 314 | if (isComposeName(path)) return "compose"; | |
| 309 | 315 | return TEXTUAL[extension(path)] ?? "text"; |
| 310 | 316 | } |
| 311 | 317 | |
| @@ -325,6 +331,7 @@ export function kindByName(path: string): FileKind { | ||
| 325 | 331 | if (isMakefileName(path)) return "makefile"; |
| 326 | 332 | if (isDockerfileName(path)) return "dockerfile"; |
| 327 | 333 | if (isWorkflowName(path)) return "workflow"; |
| 334 | if (isComposeName(path)) return "compose"; | |
| 328 | 335 | return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text"; |
| 329 | 336 | } |
| 330 | 337 |
src/web/render/renderers.ts +2
| @@ -33,6 +33,7 @@ import { patch } from "./patch.ts"; | ||
| 33 | 33 | import { pdf } from "./pdf.ts"; |
| 34 | 34 | import { dockerfile } from "./dockerfile.ts"; |
| 35 | 35 | import { workflow } from "./workflow.ts"; |
| 36 | import { compose } from "./compose.ts"; | |
| 36 | 37 | import { openapi } from "./openapi.ts"; |
| 37 | 38 | import { lcov } from "./lcov.ts"; |
| 38 | 39 | import { sarif } from "./sarif.ts"; |
| @@ -109,6 +110,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 109 | 110 | makefile, |
| 110 | 111 | dockerfile, |
| 111 | 112 | workflow, |
| 113 | compose, | |
| 112 | 114 | mail: eml, |
| 113 | 115 | archive, |
| 114 | 116 | font, |
test/render-compose.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** A compose file as its services: depends_on, build, env names only, escaping, fallback, the registry. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { compose, LIST_CAP, parseCompose, SERVICE_CAP } from "../src/web/render/compose.ts"; | |
| 4 | import { fileKind, kindByName } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string) => compose({ path: "compose.yaml", size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 9 | const out = (r: ReturnType<typeof draw>): string => String(r.content); | |
| 10 | ||
| 11 | const FILE = `services: | |
| 12 | web: | |
| 13 | image: nginx:1 | |
| 14 | ports: ["8080:80", { target: 443, published: 8443 }] | |
| 15 | volumes: [./site:/usr/share/nginx/html] | |
| 16 | depends_on: [api, db] | |
| 17 | environment: | |
| 18 | SECRET_TOKEN: hunter2 | |
| 19 | api: | |
| 20 | build: ./api | |
| 21 | depends_on: | |
| 22 | db: | |
| 23 | condition: service_healthy | |
| 24 | environment: | |
| 25 | - DB_PASSWORD=s3cret | |
| 26 | - PLAIN | |
| 27 | db: | |
| 28 | build: | |
| 29 | context: ./db | |
| 30 | environment: | |
| 31 | X: "<script>alert(1)</script>" | |
| 32 | `; | |
| 33 | ||
| 34 | describe("compose", () => { | |
| 35 | test("two services with depends_on, a build service, names only", () => { | |
| 36 | const c = parseCompose(FILE); | |
| 37 | if ("error" in c) throw new Error(c.error); | |
| 38 | expect(c.services.map((s) => s.name)).toEqual(["web", "api", "db"]); | |
| 39 | expect(c.services[0]!.dependsOn).toEqual(["api", "db"]); | |
| 40 | expect(c.services[0]!.ports).toEqual(["8080:80", "8443:443"]); | |
| 41 | expect(c.services[1]!.dependsOn).toEqual(["db"]); | |
| 42 | expect(c.services[1]!.built).toBe(true); | |
| 43 | expect(c.services[1]!.source).toBe("./api"); | |
| 44 | expect(c.services[2]!.source).toBe("./db"); | |
| 45 | expect(c.services[1]!.env).toEqual(["DB_PASSWORD", "PLAIN"]); | |
| 46 | const html = out(draw(FILE)); | |
| 47 | expect(html).toContain("SECRET_TOKEN"); | |
| 48 | for (const v of ["hunter2", "s3cret", "alert(1)"]) expect(html).not.toContain(v); | |
| 49 | }); | |
| 50 | test("escapes every value", () => { | |
| 51 | const html = out(draw(`services:\n "<script>x</script>":\n image: "<script>y</script>"\n ports: ["<script>z</script>"]\n environment: ["<script>e</script>=1"]\n`)); | |
| 52 | expect(html).not.toContain("<script>"); | |
| 53 | expect(html).toContain("<script>"); | |
| 54 | }); | |
| 55 | test("not compose falls back to source, no banner of error", () => { | |
| 56 | for (const bad of ["a: [", "just: text", "- a\n- b", "---\na: 1\n---\nb: 2"]) { | |
| 57 | const r = draw(bad); | |
| 58 | expect(r.fallback).toContain("shown as YAML"); | |
| 59 | } | |
| 60 | }); | |
| 61 | test("registered by name and drawn", () => { | |
| 62 | for (const n of ["compose.yaml", "compose.yml", "docker-compose.yaml", "a/Docker-Compose.yml"]) { | |
| 63 | expect(fileKind(n, enc("services: {}"))).toBe("compose"); | |
| 64 | expect(kindByName(n)).toBe("compose"); | |
| 65 | } | |
| 66 | expect(fileKind("my-compose.yaml", enc("a: 1"))).not.toBe("compose"); | |
| 67 | expect(RENDERERS.compose).toBe(compose); | |
| 68 | }); | |
| 69 | }); | |
Add tests for SERVICE_CAP, LIST_CAP and the 'more services not shown' line: those branches are untested. |
||
| 70 | ||
| 71 | describe("compose caps", () => { | |
| 72 | test("SERVICE_CAP and the more-services line", () => { | |
| 73 | const n = SERVICE_CAP + 3; | |
| 74 | const text = "services:\n" + Array.from({ length: n }, (_, i) => ` s${i}:\n image: x\n`).join(""); | |
| 75 | const c = parseCompose(text); | |
| 76 | if ("error" in c) throw new Error(c.error); | |
| 77 | expect(c.services.length).toBe(SERVICE_CAP); | |
| 78 | expect(c.total).toBe(n); | |
| 79 | const html = out(draw(text)); | |
| 80 | expect(html).toContain("3 more services not shown"); | |
| 81 | expect(html).not.toContain(`s${SERVICE_CAP}<`); | |
| 82 | }); | |
| 83 | test("LIST_CAP per column", () => { | |
| 84 | const n = LIST_CAP + 5; | |
| 85 | const ports = Array.from({ length: n }, (_, i) => `"${1000 + i}:80"`).join(", "); | |
| 86 | const c = parseCompose(`services:\n a:\n image: x\n ports: [${ports}]\n`); | |
| 87 | if ("error" in c) throw new Error(c.error); | |
| 88 | expect(c.services[0]!.ports.length).toBe(LIST_CAP); | |
| 89 | }); | |
| 90 | test("the table scrolls on a phone and says why it fell back", () => { | |
| 91 | expect(out(draw(FILE))).toContain('class="table-scroll"'); | |
| 92 | expect(draw("a: 1").fallback).toBe("This file has no services, so it is shown as YAML."); | |
| 93 | }); | |
| 94 | }); |
Principle 8: 'it is not drawn as a compose file: it has no services' reads as machine speak. Say 'This file has no services, so it is shown as YAML.'