Skip to content

Latest commit

 

History

History
222 lines (178 loc) · 8.71 KB

File metadata and controls

222 lines (178 loc) · 8.71 KB

Compose Orchestration (perry/compose)

perry/compose brings the docker compose up / down / ps / exec / logs workflow into TypeScript. The spec is a TS object literal that mirrors the Compose Specification, the engine is in-process Rust (no shell-out to a docker-compose binary), and dependency ordering / rollback / interpolation all run natively.

Bringing up a single-service stack

{{#include ../../examples/stdlib/container/snippets.ts:compose-up-simple}}

The handle returned from up() is an opaque integer (NaN-boxed with POINTER_TAG); pass it as the first argument to down / ps / logs / exec. The template-string interpolation ${stack} renders as [object Object] because of the NaN-boxing tag; coerce explicitly with String(stack) if you need to log it.

Multi-service stack with healthcheck-gated startup

{{#include ../../examples/stdlib/container/snippets.ts:compose-up-multi}}

This pattern combines several production-grade primitives:

Primitive What it does
container_name: 'app-db' Forces a stable container name so docker's embedded DNS resolves app-db to the postgres container's IP. See the DNS gotcha below.
healthcheck: { test: [...], interval, retries, start_period } Per-service liveness probe. Compose-spec § service.healthcheck shape — Perry's engine honors it for depends_on gating.
depends_on: { db: { condition: 'service_healthy' } } Holds the dependent service back until the dependency reports healthy. Three valid conditions: service_started, service_healthy, service_completed_successfully.
networks: { ..., internal: true } Marks the network as internal-only — postgres is unreachable from the host or from sibling stacks. See Networking.
restart: 'unless-stopped' The runtime restarts the container after a crash, but not after an explicit docker stop.

The full ComposeSpec shape is exported from perry/compose as ComposeSpec, with sub-types Service, ComposeNetwork, ComposeVolume, Build, and Healthcheck.

Recognised Service fields

The full set Perry's engine understands (matches compose-spec § services):

interface Service {
  image?: string;
  container_name?: string;
  ports?: string[];                                              // "host:container[:proto]"
  environment?: Record<string, string> | string[];               // map or KEY=VALUE list
  labels?: Record<string, string>;
  volumes?: string[];                                            // "host:container[:ro]" or "named:container"
  build?: Build;                                                 // { context, dockerfile, args, … }
  depends_on?: string[] | Record<string, { condition?: string }>;
  restart?: "no" | "always" | "on-failure" | "unless-stopped";
  entrypoint?: string | string[];
  command?: string | string[];
  networks?: string[];
  healthcheck?: Healthcheck;
  user?: string;
  working_dir?: string;
  read_only?: boolean;
  privileged?: boolean;
  cap_add?: string[];
  cap_drop?: string[];
}

Healthcheck shape

interface Healthcheck {
  test?: string[];           // ["CMD", "<cmd>", ...] | ["CMD-SHELL", "<line>"] | ["NONE"]
  interval?: string;         // Go duration: "5s", "2m", "1h30m"
  timeout?: string;
  retries?: number;
  start_period?: string;     // grace period before retries count
  disable?: boolean;
}

Environment variable interpolation

Compose's ${VAR} and ${VAR:-default} placeholders work in TS-side specs too — Perry expands them against process.env at the FFI boundary, before the JSON gets parsed:

{{#include ../../examples/stdlib/container/snippets.ts:env-interpolation}}

Set the env vars before invoking your binary:

NGINX_VERSION=1.27 WEB_PORT=9000 ./my-stack

Without this, the literal string "${NGINX_VERSION:-alpine}" would flow through to docker as the image tag and the pull would fail.

Cross-service DNS

Each service registers its service key (db, api, …) as a network alias automatically — Perry's engine emits --network-alias <key> per service per network on every run. So this just works:

api: {
  image: "myapp/api",
  environment: {
    // ✅ "db" resolves in DNS via the auto-registered service-key alias
    DATABASE_URL: "postgres://user:pw@db:5432/app",
  },
}

container_name is no longer required for cross-service DNS. You can still set one if you want a stable name visible to docker ps, but the service key alone is enough for in-network resolution. Pre-v0.5.372 docs described a workaround using container_name pinning — that pattern still works but is now optional.

Tearing down

{{#include ../../examples/stdlib/container/snippets.ts:compose-down}}

down(handle) removes containers and networks, and preserves named volumes by default. Pass { volumes: true } to also drop the volumes (destroys committed data — use only for "rip and replace" redeploy or test cleanup).

down option Type Default Effect
volumes boolean false Also remove named volumes after containers + networks.
removeOrphans boolean false Remove containers labelled with this stack's project but not in the current spec.

Status / logs / exec

{{#include ../../examples/stdlib/container/snippets.ts:compose-ops}}

Like perry/container.{logs, exec}, the compose logs and exec return registry-id handles for the ContainerLogs array. Treat them as opaque for now; user-side materialisation is a planned ergonomics task.

Function Signature
ps(handle) (handle) → Promise<ContainerInfo[]>
logs(handle, opts?) (handle, { service?, tail? }) → Promise<ContainerLogs>
exec(handle, service, cmd[]) (handle, service, cmd[]) → Promise<ContainerLogs>
config(handle) (handle) → Promise<string> (resolved YAML)
start(handle, services?) (handle, services?: string[]) → Promise<void>
stop(handle, services?) (handle, services?: string[]) → Promise<void>
restart(handle, services?) (handle, services?: string[]) → Promise<void>
down(handle, opts?) (handle, { volumes?, removeOrphans? }) → Promise<void>

exec targets a service by its service key (e.g. 'db', not the container name) — the engine resolves the key to its tracked container name internally.

Idempotency

up() is idempotent: if a service is already running with a matching configuration, it's left alone; if it exists but is stopped, it's started; only when it doesn't exist at all is it created from scratch. This makes "redeploy" a no-op-or-restart operation rather than a tear-down-and-recreate.

⚠️ Idempotency works at the service granularity, not field-level. If you change the spec (e.g. update an image tag), you'll want down(handle, { volumes: false }) followed by up(newSpec) so the old containers are replaced with the new image.

Waiting for readiness

up() returns as soon as the engine has started every service — not when each service is ready. To block until the stack is serving:

  1. Use the healthcheck block on the service (built-in, runtime handles it). Combined with depends_on: { svc: { condition: 'service_healthy' } }, dependent services wait for the dependency to report healthy.
  2. Run an explicit probe loop in your code. The Forgejo example does this for both postgres (pg_isready) and Forgejo (/api/healthz over HTTP), each with its own timeout budget.

Errors and rollback

If any service fails to start, the engine rolls back the entire stack: every container created during this up() call is stopped + removed, every network created is removed, and (subject to the standard session_volumes semantics) created volumes are removed too. The returned Promise rejects with a ServiceStartupFailed containing the failing service name and the underlying backend error.

try {
  const stack = await up({ /* … */ });
} catch (err: any) {
  // err.message is "Service '<name>' failed to start: <reason>"
  console.error(err);
  process.exit(1);
}

See also