Skip to content

Commit cd68351

Browse files
committed
docs(container): split container subsystem into a dedicated docs/src/container/ section
Seven new pages cover overview, single-container lifecycle (perry/container), compose orchestration (perry/compose), networking (incl. the container_name DNS workaround), volumes, security, and a Forgejo-deployment case study. New docs/examples/stdlib/container/snippets.ts with 11 ANCHOR blocks pulled into the markdown via {{#include}}. doc-tests --lint and --filter container both pass.
1 parent 8209b9d commit cd68351

11 files changed

Lines changed: 2100 additions & 173 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
// demonstrates: per-snippet examples for the perry/container + perry/compose
2+
// docs page (docs/src/stdlib/container.md)
3+
// docs: docs/src/stdlib/container.md
4+
// platforms: macos, linux, windows
5+
// run: false
6+
7+
// Each ANCHOR block below is the code that the container docs page renders
8+
// inline via {{#include ... :NAME}}. The file as a whole is compiled and
9+
// linked by the doc-tests harness — `run: false` because every example
10+
// touches a live OCI runtime (apple/container, docker, podman, …) which
11+
// isn't hermetic in CI. Compile + link is the contract here; the live
12+
// runtime path is exercised by example-code/forgejo-deployment which is
13+
// run by hand against Docker on the maintainer's machine.
14+
15+
// ANCHOR: backend-detect
16+
import { getBackend, detectBackend } from "perry/container";
17+
18+
async function pickBackend(): Promise<void> {
19+
// Synchronous: returns the canonical name of the active backend
20+
// (`"docker"`, `"podman"`, `"apple/container"`, `"orbstack"`,
21+
// `"colima"`, `"lima"`, `"nerdctl"`, …). When called before any
22+
// async FFI has triggered detection, getBackend() performs a
23+
// synchronous in-place probe with the same 2 s timeout per
24+
// candidate that detectBackend() uses, so the result is live.
25+
console.log(`backend: ${getBackend()}`);
26+
27+
// Async + verbose: returns a JSON array of every probed backend
28+
// with availability + version + reason for unavailable ones. Use
29+
// this when you want to surface a "diagnostics" panel to the user.
30+
const probed = await detectBackend();
31+
console.log(probed);
32+
}
33+
// ANCHOR_END: backend-detect
34+
35+
// ANCHOR: run-simple
36+
import { run, remove } from "perry/container";
37+
38+
async function runAlpine(): Promise<void> {
39+
const handle = await run({
40+
image: "alpine:3.19",
41+
cmd: ["echo", "hello from perry"],
42+
rm: false,
43+
// Production-friendly defaults: drop every Linux capability and
44+
// run as a non-root user. Add `cap_add` only for the specific
45+
// capabilities a workload actually needs.
46+
user: "nobody",
47+
cap_drop: ["ALL"],
48+
});
49+
console.log(`container handle: ${String(handle)}`);
50+
51+
// `force: true` removes the container even if still running (the
52+
// FFI calls `docker rm -f` / `podman rm -f`).
53+
await remove(handle as unknown as string, true);
54+
}
55+
// ANCHOR_END: run-simple
56+
57+
// ANCHOR: run-secure
58+
import { run as runSecure } from "perry/container";
59+
60+
// Maximum-isolation single-container run for an untrusted workload:
61+
// - read-only root filesystem
62+
// - no Linux capabilities at all
63+
// - non-root user
64+
// - working directory pinned
65+
// - default seccomp profile
66+
async function runUntrustedWorkload(): Promise<void> {
67+
await runSecure({
68+
image: "alpine:3.19",
69+
cmd: ["sh", "-c", "echo isolated && exit 0"],
70+
read_only: true,
71+
cap_drop: ["ALL"],
72+
user: "nobody",
73+
workdir: "/tmp",
74+
seccomp: "default",
75+
});
76+
}
77+
// ANCHOR_END: run-secure
78+
79+
// ANCHOR: list-inspect
80+
import {
81+
list,
82+
inspect,
83+
logs,
84+
exec,
85+
} from "perry/container";
86+
87+
async function inspectAll(): Promise<void> {
88+
const containers = await list(true); // all=true → include stopped
89+
console.log(containers);
90+
91+
const id = "my-container-id";
92+
const info = await inspect(id);
93+
console.log(info.status); // "running" | "exited" | …
94+
95+
// Tail the last 50 stdout/stderr lines.
96+
const tailed = await logs(id, { tail: 50 });
97+
console.log(tailed.stdout);
98+
99+
// Run a command inside the container; returns a ContainerLogs
100+
// handle whose stdout/stderr you can read.
101+
const r = await exec(id, ["ls", "-la"]);
102+
console.log(r.stdout);
103+
}
104+
// ANCHOR_END: list-inspect
105+
106+
// ANCHOR: image-mgmt
107+
import { pullImage, listImages, removeImage } from "perry/container";
108+
109+
async function manageImages(): Promise<void> {
110+
await pullImage("postgres:16-alpine");
111+
const images = await listImages();
112+
console.log(`${images.length} images`);
113+
await removeImage("postgres:16-alpine", false);
114+
}
115+
// ANCHOR_END: image-mgmt
116+
117+
// ANCHOR: compose-up-simple
118+
import { up } from "perry/compose";
119+
120+
async function bringUpSimpleStack(): Promise<void> {
121+
const stack = await up({
122+
version: "3.8",
123+
services: {
124+
cache: {
125+
image: "redis:7-alpine",
126+
ports: ["6379:6379"],
127+
networks: ["app-net"],
128+
healthcheck: {
129+
test: ["CMD", "redis-cli", "PING"],
130+
interval: "5s",
131+
timeout: "3s",
132+
retries: 6,
133+
},
134+
},
135+
},
136+
networks: {
137+
"app-net": { driver: "bridge" },
138+
},
139+
});
140+
// `stack` is an opaque handle (NaN-boxed integer) — pass it as
141+
// the first arg to `down` / `ps` / `logs` / `exec`.
142+
console.log(`stack handle: ${String(stack)}`);
143+
}
144+
// ANCHOR_END: compose-up-simple
145+
146+
// ANCHOR: compose-up-multi
147+
import { up as upMulti } from "perry/compose";
148+
149+
async function bringUpMultiServiceStack(): Promise<void> {
150+
// depends_on with `condition: 'service_healthy'` blocks the
151+
// dependent service until the dependency's healthcheck reports
152+
// healthy. Use the map form (not the bare-array form) to pass
153+
// the condition.
154+
await upMulti({
155+
version: "3.8",
156+
services: {
157+
db: {
158+
image: "postgres:16-alpine",
159+
container_name: "app-db", // stable DNS target for siblings
160+
environment: {
161+
POSTGRES_USER: "app",
162+
POSTGRES_PASSWORD: "${APP_DB_PASSWORD:-changeme}",
163+
POSTGRES_DB: "app",
164+
},
165+
volumes: ["app-pgdata:/var/lib/postgresql/data"],
166+
networks: ["app-db-net"],
167+
healthcheck: {
168+
test: ["CMD-SHELL", "pg_isready -U app -d app"],
169+
interval: "5s",
170+
timeout: "3s",
171+
retries: 10,
172+
start_period: "30s",
173+
},
174+
},
175+
api: {
176+
image: "myorg/api:1.0",
177+
depends_on: { db: { condition: "service_healthy" } },
178+
environment: {
179+
DATABASE_URL: "postgres://app:changeme@app-db:5432/app",
180+
},
181+
ports: ["8080:8080"],
182+
networks: ["app-db-net", "app-web-net"],
183+
restart: "unless-stopped",
184+
},
185+
},
186+
networks: {
187+
"app-db-net": { driver: "bridge", internal: true }, // db unreachable from host
188+
"app-web-net": { driver: "bridge" },
189+
},
190+
volumes: {
191+
"app-pgdata": { driver: "local" },
192+
},
193+
});
194+
}
195+
// ANCHOR_END: compose-up-multi
196+
197+
// ANCHOR: compose-down
198+
import { down } from "perry/compose";
199+
200+
async function tearDown(stack: number): Promise<void> {
201+
// Default: containers + networks removed; named volumes preserved
202+
// so a subsequent `up()` against the same spec resumes from
203+
// committed state.
204+
await down(stack);
205+
206+
// Pass `volumes: true` to also drop named volumes — DESTROYS DATA.
207+
// Useful for test teardown or for a "rip and replace" redeploy.
208+
await down(stack, { volumes: true });
209+
}
210+
// ANCHOR_END: compose-down
211+
212+
// ANCHOR: compose-ops
213+
import {
214+
ps,
215+
logs as composeLogs,
216+
exec as composeExec,
217+
config,
218+
start,
219+
stop,
220+
restart,
221+
} from "perry/compose";
222+
223+
async function manageStack(stack: number): Promise<void> {
224+
// Status of every service in the stack (returns a registry
225+
// handle to a ContainerInfo[]; user-side array materialisation
226+
// is a follow-up ergonomics task).
227+
const statusHandle = await ps(stack);
228+
console.log(statusHandle);
229+
230+
// Aggregated logs from one or all services.
231+
await composeLogs(stack, { service: "db", tail: 200 });
232+
233+
// Exec a command inside a service's container by service KEY
234+
// (not container name) — the engine resolves the service to its
235+
// running container internally.
236+
await composeExec(stack, "db", ["pg_isready"]);
237+
238+
// Resolved YAML the engine actually used (post-interpolation).
239+
const yaml = await config(stack);
240+
console.log(yaml);
241+
242+
// Stop / start / restart by service key. `services: []` (or
243+
// omitted) targets every service in the stack.
244+
await stop(stack, ["api"]);
245+
await start(stack, ["api"]);
246+
await restart(stack, []);
247+
}
248+
// ANCHOR_END: compose-ops
249+
250+
// ANCHOR: env-interpolation
251+
import { up as upEnv } from "perry/compose";
252+
253+
// Compose YAML interpolation (`${VAR}` / `${VAR:-default}`) is applied
254+
// to TS-side specs at the FFI boundary too — set `process.env` keys
255+
// before calling up() and they'll resolve in the spec values.
256+
async function envInterpolatedStack(): Promise<void> {
257+
await upEnv({
258+
version: "3.8",
259+
services: {
260+
web: {
261+
image: "nginx:${NGINX_VERSION:-alpine}",
262+
ports: ["${WEB_PORT:-8080}:80"],
263+
environment: {
264+
SERVER_NAME: "${WEB_DOMAIN:-localhost}",
265+
},
266+
},
267+
},
268+
});
269+
}
270+
// ANCHOR_END: env-interpolation
271+
272+
// ANCHOR: container-name-dns
273+
// IMPORTANT: Perry's compose engine creates each container with a
274+
// `{md5}-{random_hex}` derived name and DOES NOT (yet) register the
275+
// service KEY (`db`, `api`, …) as a network alias. So
276+
// `DATABASE_URL: 'postgres://user:pw@db:5432/app'` would fail name
277+
// resolution at runtime. Two ways to make sibling-DNS work:
278+
//
279+
// (a) Set `container_name` explicitly on each service so the
280+
// chosen name is what Docker's embedded DNS resolves. This is
281+
// the simplest pattern and is what the Forgejo example uses.
282+
//
283+
// (b) Wait for service-key network-alias support (planned).
284+
//
285+
// Until (b) lands, prefer (a):
286+
import { up as upDns } from "perry/compose";
287+
288+
async function dnsAwareStack(): Promise<void> {
289+
await upDns({
290+
version: "3.8",
291+
services: {
292+
db: {
293+
image: "postgres:16-alpine",
294+
container_name: "myapp-db", // ← stable DNS target
295+
networks: ["myapp-net"],
296+
environment: { POSTGRES_PASSWORD: "x" },
297+
},
298+
api: {
299+
image: "myapp/api",
300+
container_name: "myapp-api",
301+
networks: ["myapp-net"],
302+
environment: {
303+
// Use the container_name as the hostname:
304+
DATABASE_URL: "postgres://postgres:x@myapp-db:5432/postgres",
305+
},
306+
},
307+
},
308+
networks: { "myapp-net": { driver: "bridge" } },
309+
});
310+
}
311+
// ANCHOR_END: container-name-dns

docs/src/SUMMARY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@
7070
- [Utilities](stdlib/utilities.md)
7171
- [Other Modules](stdlib/other.md)
7272

73+
# Containers
74+
75+
- [Overview](container/overview.md)
76+
- [Single-Container Lifecycle](container/containers.md)
77+
- [Compose Orchestration](container/compose.md)
78+
- [Networking](container/networking.md)
79+
- [Volumes](container/volumes.md)
80+
- [Security](container/security.md)
81+
- [Production Patterns](container/production-patterns.md)
82+
7383
# Internationalization
7484

7585
- [Overview](i18n/overview.md)

0 commit comments

Comments
 (0)