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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11211-a-compose-yaml-or-docker-compose-yml-ope → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/11211-a-compose-yaml-or-docker-compose-yml-ope was merged into main as 5b7d243.

Review

Sign in to review this change.
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 }

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.'

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 <div class="table-scroll"> and keep the service name as a sticky lead or stack the cells. compose-view has no style; drop it.

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 =
4545 | "makefile"
4646 | "dockerfile"
4747 | "workflow"
48 | "compose"
4849 | "sql"
4950 | "text"
5051 | "binary";
@@ -89,6 +90,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
8990 makefile: 1 * MB,
9091 dockerfile: 1 * MB,
9192 workflow: 1 * MB,
93 compose: 1 * MB,
9294 sql: 1 * MB,
9395 text: 1 * MB,
9496 binary: 0,
@@ -231,6 +233,9 @@ const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Container
231233 /** A GitHub Actions workflow: a `.yml` or `.yaml` directly under `.github/workflows/`. */
232234 const isWorkflowName = (path: string): boolean => /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(path);
233235
236 /** `compose.yaml|yml` and `docker-compose.yaml|yml`. */
237 const isComposeName = (path: string): boolean => /(^|\/)(docker-)?compose\.ya?ml$/i.test(path);
238
234239 /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */
235240 const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path);
236241
@@ -306,6 +311,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
306311 if (isMakefileName(path)) return "makefile";
307312 if (isDockerfileName(path)) return "dockerfile";
308313 if (isWorkflowName(path)) return "workflow";
314 if (isComposeName(path)) return "compose";
309315 return TEXTUAL[extension(path)] ?? "text";
310316 }
311317
@@ -325,6 +331,7 @@ export function kindByName(path: string): FileKind {
325331 if (isMakefileName(path)) return "makefile";
326332 if (isDockerfileName(path)) return "dockerfile";
327333 if (isWorkflowName(path)) return "workflow";
334 if (isComposeName(path)) return "compose";
328335 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
329336 }
330337
src/web/render/renderers.ts +2
@@ -33,6 +33,7 @@ import { patch } from "./patch.ts";
3333 import { pdf } from "./pdf.ts";
3434 import { dockerfile } from "./dockerfile.ts";
3535 import { workflow } from "./workflow.ts";
36 import { compose } from "./compose.ts";
3637 import { openapi } from "./openapi.ts";
3738 import { lcov } from "./lcov.ts";
3839 import { sarif } from "./sarif.ts";
@@ -109,6 +110,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
109110 makefile,
110111 dockerfile,
111112 workflow,
113 compose,
112114 mail: eml,
113115 archive,
114116 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("&lt;script&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs