--- title: ezx description: Overview, features, modes, how it works. url: https://ezx.supanadit.com/index.md --- # ezx **Deterministic container entrypoint engine · Go** ezx is a deterministic, scriptable container entrypoint engine: a single Go binary driven by a declarative JavaScript script that replaces fragile bash entrypoint trees with supervision, readiness, scheduling, and secrets handling. - **Status:** v0.8.1 - **License:** Apache License 2.0 - **Repository:** ## What it does Replace fragile bash entrypoint.sh trees with a single Go binary driven by a declarative JavaScript script. Supervision, readiness, scheduling, and secrets — baked into your image, without a line of shell. ## The problem Docker images conventionally ship a bash entrypoint. It works, but it is brittle, unreadable, and unportable: config rewritten with sed -i against multiline templates, readiness approximated with sleep 5 instead of real probes, supervision via pgrep polling loops instead of a proper process tree, and secrets leak into children because env is never filtered. Every image reinvents the same fragile shell scaffolding. ## Features - **No shell boilerplate** — One Go binary + a declarative JS script. Readable, testable, and no shell dependencies. - **Dependency graph (DAG)** — Flat nodes + dependsOn edges: fan-in, shared dependencies, and diamonds — no nested tree required. - **Per-edge wait modes** — dependsOnEdges picks started, ready, or exit per dependency — wait for one dep to be ready while another only needs to start. - **Oneshot services** — oneshot: true runs a process to completion; dependents start after it exits 0. Retryable via restart. - **Per-service logging with rotation** — Route a node's stdout/stderr to its own rotating file: log.stdout: "file", filePath, maxBytes, maxBackups. - **Env-driven determinism** — Environment variables map to config files and CLI flags declaratively. Same env, same output, every time. - **Secret filtering** — filterEnv / filterEnvPattern strips sensitive vars before spawning any child. - **PID-1 init duties** — Built-in zombie reaping, signal forwarding, and graceful drain — or hand PID 1 to the app if you prefer. - **Two launch modes** — Choose exec mode (app is PID 1) or supervised mode (ezx stays PID 1). Your container, your call. - **Health & scheduling** — /readyz, /livez, /healthz, and cron-driven scheduler with readiness gates are built in. - **Runtime API** — Expose user-defined HTTP routes on the shared health server for manual triggers and runtime ops. - **Declarative builders** — Render key=value, table/matrix, INI, and deterministic YAML from structured objects. ## How it works ezx loads a JavaScript entrypoint script against its Go-hosted JS engine (goja). The script declaratively describes everything: which files to provision, how to build CLI arguments from env, which processes to spawn, how they depend on each other, and how to supervise them. The core abstraction is the dependency graph — a ProcessNode connected by explicit dependsOn edges (the flat `nodes`/`dependsOn` form). A node starts after all of its dependencies have started; per-edge wait modes let each dependency independently gate on started, ready, or exit; oneshot nodes run to completion and act as success gates; siblings / independent nodes start in parallel; and any node can carry a restart policy, a health server, a scheduler (cron), forwardSignals, a probe, and graceful shutdown config. ## Two launch modes **Mode A — exec:** ezx does the setup (env templating, file provisioning, spawning support processes), then syscall.Exec the app. The app becomes PID 1 and handles its own signals natively. Zero supervision overhead — but no restart/health-check safety net. Must be a leaf node and is mutually exclusive with health. **Mode B — supervised:** ezx stays PID 1. Each node gets restart, health/readiness HTTP, scheduler, forwardSignals, probe, shutdown, and dependency edges via dependsOn / dependsOnEdges. ezx reaps zombies and forwards signals. Declare one of those (or execDefault: false) — a lone node with none of them is auto-exec'd instead. > An alternative to tini and dumb-init that goes further — or hand PID 1 to your app with exec mode for native signals. --- title: ezx — Features description: Deep reference for each ezx capability. url: https://ezx.supanadit.com/features.md --- # ezx — Features ezx is a deterministic, scriptable container entrypoint engine: a single Go binary driven by a declarative JavaScript script that replaces fragile bash entrypoint trees with supervision, readiness, scheduling, and secrets handling. ## Feature reference 1. **No shell boilerplate** — One Go binary + a declarative JS script. Readable, testable, and no shell dependencies. 2. **Dependency graph (DAG)** — Flat nodes + dependsOn edges: fan-in, shared dependencies, and diamonds — no nested tree required. 3. **Per-edge wait modes** — dependsOnEdges picks started, ready, or exit per dependency — wait for one dep to be ready while another only needs to start. 4. **Oneshot services** — oneshot: true runs a process to completion; dependents start after it exits 0. Retryable via restart. 5. **Per-service logging with rotation** — Route a node's stdout/stderr to its own rotating file: log.stdout: "file", filePath, maxBytes, maxBackups. 6. **Env-driven determinism** — Environment variables map to config files and CLI flags declaratively. Same env, same output, every time. 7. **Secret filtering** — filterEnv / filterEnvPattern strips sensitive vars before spawning any child. 8. **PID-1 init duties** — Built-in zombie reaping, signal forwarding, and graceful drain — or hand PID 1 to the app if you prefer. 9. **Two launch modes** — Choose exec mode (app is PID 1) or supervised mode (ezx stays PID 1). Your container, your call. 10. **Health & scheduling** — /readyz, /livez, /healthz, and cron-driven scheduler with readiness gates are built in. 11. **Runtime API** — Expose user-defined HTTP routes on the shared health server for manual triggers and runtime ops. 12. **Declarative builders** — Render key=value, table/matrix, INI, and deterministic YAML from structured objects. ## How it works ezx loads a JavaScript entrypoint script against its Go-hosted JS engine (goja). The script declaratively describes everything: which files to provision, how to build CLI arguments from env, which processes to spawn, how they depend on each other, and how to supervise them. The core abstraction is the dependency graph — a ProcessNode connected by explicit dependsOn edges (the flat `nodes`/`dependsOn` form). A node starts after all of its dependencies have started; per-edge wait modes let each dependency independently gate on started, ready, or exit; oneshot nodes run to completion and act as success gates; siblings / independent nodes start in parallel; and any node can carry a restart policy, a health server, a scheduler (cron), forwardSignals, a probe, and graceful shutdown config. ## Launch modes **Mode A — exec:** ezx does the setup (env templating, file provisioning, spawning support processes), then syscall.Exec the app. The app becomes PID 1 and handles its own signals natively. Zero supervision overhead — but no restart/health-check safety net. Must be a leaf node and is mutually exclusive with health. **Mode B — supervised:** ezx stays PID 1. Each node gets restart, health/readiness HTTP, scheduler, forwardSignals, probe, shutdown, and dependency edges via dependsOn / dependsOnEdges. ezx reaps zombies and forwards signals. Declare one of those (or execDefault: false) — a lone node with none of them is auto-exec'd instead. --- title: ezx — Install & Quickstart description: Download a release binary, build from source, demo, PostgreSQL example. url: https://ezx.supanadit.com/install.md --- # ezx — Install & Quickstart Prebuilt binaries are on the GitHub releases page, or build from source. Repository: ## Download a release binary ### Linux x86_64 ```sh curl -LO https://github.com/supanadit/ezx/releases/download/0.8.1/ezx-0.8.1-linux-amd64 mv ezx-0.8.1-linux-amd64 ezx chmod +x ezx ./ezx --help ``` ### Linux arm64 ```sh curl -LO https://github.com/supanadit/ezx/releases/download/0.8.1/ezx-0.8.1-linux-arm64 mv ezx-0.8.1-linux-arm64 ezx chmod +x ezx ./ezx --help ``` ### macOS x86_64 ```sh curl -LO https://github.com/supanadit/ezx/releases/download/0.8.1/ezx-0.8.1-darwin-amd64 mv ezx-0.8.1-darwin-amd64 ezx chmod +x ezx ./ezx --help ``` ### macOS arm64 ```sh curl -LO https://github.com/supanadit/ezx/releases/download/0.8.1/ezx-0.8.1-darwin-arm64 mv ezx-0.8.1-darwin-arm64 ezx chmod +x ezx ./ezx --help ``` ## Build from source ```sh go build -o ezx ./app ``` ## Demo Read env, provision files, and run a tiny process chain — the fastest way to see ezx in action. ```sh ./ezx bootstrap examples/bootstrap/demo.js ``` ## PostgreSQL container example ```sh docker run --rm -e POSTGRES_PASSWORD=secret \ -v "$PWD/ezx:/usr/local/bin/ezx" \ -v "$PWD/examples:/examples" postgres:16 \ ezx bootstrap /examples/bootstrap/postgres.js ``` ## Status & License Status: v0.8.1 License: Apache License 2.0 — © 2026 Supan Adit Pratama. --- title: ezx — Documentation description: Full reference: host API modules, dependency graph with per-edge wait modes and oneshot services, per-service logging with rotation, probes, env-driven config and args, builders, scheduler, health/runtime API, recipes, edge cases. url: https://ezx.supanadit.com/docs.md --- # ezx — Documentation (v0.8.1) ezx documentation: the require("ezx") host API, dependency graph with per-edge wait modes and oneshot services, per-service logging with rotation, probes, env-driven config and args, scheduling, health, runtime API — plus recipes and every edge case. ## Contents 1. [Getting started](#getting-started) 2. [The dependency graph](#dependency-graph) 3. [Oneshot services & init DAG → exec main](#oneshot) 4. [Per-service logging with rotation](#logging) 5. [Probes & readiness](#probes) 6. [Env → config files (declarative provisioning)](#files) 7. [Env → CLI arguments](#args) 8. [Config & YAML builders](#builders) 9. [Imperative modules](#script-api) 10. [Cron scheduler with gates](#scheduler) 11. [Health endpoints & runtime API](#health-api) 12. [Launch modes](#modes) 13. [Use cases & recipes](#recipes) 14. [Edge cases & gotchas](#edge-cases) 15. [Status](#status) ## 1. Getting started ezx ships as a single static Go binary. Prebuilt binaries are on the GitHub releases page, or build from source: ```sh # Linux x86_64 — download v0.8.1 from GitHub releases curl -LO https://github.com/supanadit/ezx/releases/download/0.8.1/ezx-0.8.1-linux-amd64 mv ezx-0.8.1-linux-amd64 ezx chmod +x ezx # or build from the repo root go build -o ezx ./app ``` ### Your first entrypoint A script is plain JavaScript run by ezx's Go-hosted JS engine (goja). It reads env, provisions files, and declares a dependency graph: ```js // entrypoint.js const { env, chain, log } = require("ezx"); chain.run({ nodes: [ { name: "app", process: { binaryPath: "/bin/sh", arguments: ["-c", "echo 'GREETING='$GREETING"], environment: ["GREETING=Hello from ezx"], filterEnv: ["PHP_MEMORY_LIMIT"], }, }, { name: "sidecar", dependsOn: ["app"], needParentReady: true, process: { binaryPath: "/bin/sh", arguments: ["-c", "echo sidecar ready"] }, }, ], }); log.info("entrypoint complete"); ``` ```sh ./ezx bootstrap entrypoint.js ``` That is the whole model: **one binary, one declarative script, zero shell.** The rest of this page is a reference for everything the script can declare. ## 2. The dependency graph `chain.run({ nodes: [...] })` hands a flat list of `ProcessNode`s to the orchestrator, connected by explicit `dependsOn` edges, which spawns and supervises the whole graph. Each node: | Field | Type | What it does | | --- | --- | --- | | name | string | Unique identifier. Used by scheduler.trigger / scheduler.status and in logs. | | process | Process | The executable: binaryPath, arguments, env, filters, user/group, workingDir, log. | | files | FileProvision[] | Declarative file provisioning, applied before this process starts. | | dependsOn | string[] | Dependencies this node waits for before starting (the flat `nodes`/`dependsOn` form). | | dependsOnEdges | Edge[] | Per-edge wait modes: `{ name, waitFor: "started"|"ready"|"exit" }`. Each edge independently decides when a dependent may start. | | needParentReady | bool | Legacy sugar: wait until the dependency's readiness passes before starting — exactly `dependsOnEdges: [{ name, waitFor: "ready" }]` (default false). | | oneshot | bool | Run to completion; signals started + ready only on exit 0. Dependents start only after it finishes. | | readiness | Probe | Readiness probe checked after Start. Dependents with needParentReady / waitFor: "ready" wait for it. | | readinessFunc | JS function | JS callback alternative: readinessFunc: () => true. Overrides the declarative probe. Must be short and non-blocking. | | restart | RestartPolicy | Restart on failure: { mode, maxRetries, backoff }. Default: never restart. | | shutdown | ShutdownConfig | Graceful drain: { signal, timeout, forceKill }. Default: SIGTERM, 30s timeout, force-kill. | | optional | bool | Non-fatal sidecar: if it fails (and is not restarted), the error is logged but the graph and container survive. Default false (fatal). | | exec | bool | Mode A: syscall.Exec this process as PID 1. Leaf-only, mutually exclusive with health. | | scheduler | SchedulerConfig | Cron-driven node: runs process on each tick instead of once. Mutually exclusive with exec and health. | | health | HealthConfig | Drive the shared /readyz from this node: ready only while readyProbe passes. Mutually exclusive with exec. | | forwardSignals | string[] | Signals relayed from ezx (PID 1) to this process's group, e.g. ["SIGTERM", "SIGINT", "SIGHUP"]. | ### Ordering semantics - A node starts after all of its dependencies have **started**. - `dependsOnEdges` lets each edge independently wait for `started` (default), `ready`, or `exit`. The legacy `dependsOn: ["a"]` + `needParentReady: true` is sugar for `dependsOnEdges: [{ name: "a", waitFor: "ready" }]` and still works. - Sibling / independent nodes start **in parallel**; multi-root chains start concurrently. - A **oneshot** node (`oneshot: true`) runs to completion and signals success only on exit 0, so its dependents start after it finishes. - When a dependency exits permanently, its running dependents drain and its not-yet-started dependents are skipped. - When the app shuts down (SIGTERM/SIGINT), the whole graph is drained: nodes get their shutdown signal, then force-kill per config. ```js const { chain } = require("ezx"); chain.run({ nodes: [ { name: "postgres", process: { binaryPath: "postgres", arguments: ["-D", "/var/lib/postgresql/data"] } }, { name: "pgbouncer", dependsOn: ["postgres"], needParentReady: true, // waits until postgres readiness passes process: { binaryPath: "pgbouncer", arguments: ["/etc/pgbouncer/pgbouncer.ini"] }, restart: { mode: "always", backoff: 2e9 }, }, ], }); ``` ## 3. Oneshot services & init DAG → exec main A **oneshot** node (`oneshot: true`) runs its process to completion instead of supervising it as a long-running service. It is the declarative form of the imperative `process.run` one-shot, composed into the dependency graph: - **Exit 0 = success gate.** A oneshot signals `started` + `ready` only when it exits 0, so its dependents start only after it finishes successfully. A non-zero exit fails the node (fatal unless `optional`), skipping its dependents. - **Retryable.** `restart` on a oneshot retries it on failure up to `MaxRetries` (the classic run-to-completion supervisor model). - **Mutually exclusive** with `exec`, `scheduler`, `health`, and `needParentReady` (a oneshot is "ready" only when it exits 0) — validated fail-fast at chain validation. The headline pattern is **"init DAG → exec main"**: a chain of init steps (initdb, stanza-create, migrations) run to completion, then the main long-running app becomes PID 1. The exec node fires only after all its oneshot deps exit 0 and no other long-running node is still supervised: ```js const { chain } = require("ezx"); const PGDATA = "/var/lib/postgresql/data"; chain.run({ nodes: [ { name: "initdb", oneshot: true, process: { binaryPath: "/usr/local/bin/initdb", arguments: ["--username=postgres", "--pwfile=/tmp/ezx-pwfile", PGDATA], user: "postgres", group: "postgres" }, }, { name: "stanza-create", oneshot: true, dependsOn: ["initdb"], process: { binaryPath: "/usr/bin/pgbackrest", arguments: ["stanza-create", "--stanza=main", "--pg1-path=" + PGDATA] }, }, { name: "postgres", exec: true, dependsOn: ["initdb", "stanza-create"], process: { binaryPath: "/usr/local/bin/postgres", arguments: ["-D", PGDATA], user: "postgres", group: "postgres" }, }, ], }); ``` Try it: `./ezx bootstrap examples/bootstrap/oneshot-init.js`. ## 4. Per-service logging with rotation Each supervised process can route its stdout/stderr to its own **file-backed log destination with size-based rotation**. Set `log.stdout` / `log.stderr` to `"file"` and point `filePath` at the target: ```js process: { binaryPath: "/usr/bin/pgbouncer", arguments: ["/etc/pgbouncer/pgbouncer.ini"], log: { stdout: "file", // route stdout to a rotating file filePath: "/var/log/ezx/pgbouncer.log", maxBytes: 5 * 1024 * 1024, // rotate at 5 MiB (default 10 MiB) maxBackups: 2, // keep .log + .1 + .2 (default 3) stderr: "stderr", // stderr still goes to container stderr }, }, ``` - **Rotation** is size-based: when a write would push the active file past `maxBytes`, it is closed, shifted to `.1` (newest), older backups shift down, and backups beyond `maxBackups` are dropped. `maxBackups < 0` keeps backups indefinitely. There is no time-based rotation or compression. - **Append on open** — a restarted node appends to the current file; rotation is purely size-driven. - **Shared path** — when both `stdout` and `stderr` are `"file"` with the same `filePath`, they share one writer so interleaving stays coherent and rotation happens once. Different paths get independent writers. - **Validation** — a `"file"` destination requires a non-empty `filePath` (fail-fast at chain validation, naming the node). Try it: `./ezx bootstrap examples/bootstrap/log-rotation.js`. ## 5. Probes & readiness A `Probe` is the readiness/health primitive used for node `readiness`, scheduler `gate`, and per-node `health.readyProbe`. Real probes replace `sleep 5`. | Field | Type | What it does | | --- | --- | --- | | type | "exec" | "tcp" | "http" | Which check runs. Empty defaults to "exec". | | exec | string[] | Command + args. Ready when it exits 0. | | execExpect | string | Additionally require this substring on the probe's stdout (e.g. pg_is_in_recovery() = f). | | execExpectExact | bool | Trimmed stdout must equal execExpect exactly, not just contain it. | | tcp | { host, port } | Dial target for TCP probes. | | http | { url, expectStatus } | HTTP GET target; expectStatus 0 accepts any 2xx. | | interval | duration (ns) | Wait between attempts, e.g. 1e9 = 1s. | | timeout | duration (ns) | Per-attempt timeout, e.g. 5e9 = 5s. | | maxAttempts | int | Give up after this many failed attempts. | ```js readiness: { type: "exec", exec: ["pg_isready", "-h", "/run/postgresql"], interval: 1e9, timeout: 5e9, maxAttempts: 30 } readiness: { type: "tcp", tcp: { host: "127.0.0.1", port: 5432 } } readiness: { type: "http", http: { url: "http://127.0.0.1:8008/master", expectStatus: 200 } } // execExpect: exit code 0 is not enough — the output must match too. readiness: { type: "exec", exec: ["psql", "-c", "select pg_is_in_recovery()"], execExpect: "f", // only the primary is "ready" } ``` > **needParentReady without a probe** — If the parent has no readiness probe, children with needParentReady: true start as soon as the parent's Start returns — not when the app inside is actually serving. Always attach a probe when ordering matters. ## 6. Env → config files (declarative provisioning) Nodes take a `files` array of `FileProvision` rules. Each rule targets one file and runs an ordered list of operations — replacing the `sed -i` pipeline in classic entrypoints. Provisioning runs **before** the node's process starts. ```js // POSTGRESQL_CONFIG_LOG_DESTINATION=stderr // becomes log_destination = 'stderr' in postgresql.conf const { chain } = require("ezx"); chain.run({ nodes: [{ name: "postgres", files: [{ path: "/etc/postgresql/postgresql.conf", operations: [{ type: "set-property", fromEnvPattern: "^POSTGRESQL_CONFIG_(.+)$", nameTransform: "lower", pattern: "^\\s*#?\\s*${name}\\s*=.*", value: "${name} = ${value}", valueFormat: "auto", }], }], // files are provisioned before the process starts process: { binaryPath: "postgres", arguments: ["-D", "/var/lib/postgresql/data"], }, }], }); ``` ### Operation types | type | Behaviour | | --- | --- | | replace | Replace the entire file content with Value/FromEnv. Creates the file if missing. | | append | Append Value/FromEnv as new line(s) at the end. | | remove | Remove all lines matching Pattern (regex). | | ensure | Ensure a line equal to Value exists; append if not found. | | set-property | Remove lines matching Pattern, then append Value. Remove + Ensure — the most common config mutation. Idempotent. | | insert-before | Insert Value before the first line matching Pattern. Not idempotent — for run-once startup. | | insert-after | Insert Value after the first line matching Pattern. Not idempotent — for run-once startup. | | replace-block | Replace the block from Pattern to BlockEnd with Value. Idempotent. | | set-block | Remove the block if present, then insert before Marker (or append). Idempotent — the PHP-config injection op. | | copy | Copy Source to the rule's path. No content transformation. | ### Shared operation fields | Field | What it does | | --- | --- | | pattern | Regex for remove / set-property / insert-* / block ops. Interpolates ${name}, ${1}, ${2} when fromEnvPattern is set. | | blockEnd / marker | End regex and fallback insertion point for block ops. | | value | Literal with ${ENV_VAR}, ${name}, ${value}, ${1} interpolation. | | fromEnv | Use this env var's value as ${value}. | | fromEnvPattern | Enumerate env vars matching a regex; the op repeats once per match. | | nameTransform | "lower" | "upper" | "snake-to-dot" | "snake-to-camel" | "snake-to-kebab" — transforms the captured name. | | valueFormat | "auto" smart-quotes (numbers, on|off|true|false, unit-suffixed stay bare; strings get single quotes). "quote" always quotes. Default raw. | | when | { name, value } env condition gating the op. Empty value means "any non-empty". | | createOnly | Skip if the target file already exists — the `if [ ! -f ]` guard for default configs. | | permission / owner | File mode (e.g. 0640) and user:group applied after writing. | > **Filtering never affects provisioning** — filterEnv / filterEnvPattern apply at spawn time only. File-provisioning callbacks still see the full environment — consume secrets into config files first, then strip them from the child env. ## 7. Env → CLI arguments `argOperations` build CLI flags from environment variables — the argument-side mirror of file operations. They run in slice order and are appended after `arguments`. ```js // PROMETHEUS_ENABLE_WEB_LIFECYCLE=true // THANOS_QUERY_STORE_ADDRESSES=store1:10901,store2:10901 // becomes: --web.enable-lifecycle --endpoint=store1:10901 --endpoint=store2:10901 const { chain } = require("ezx"); chain.run({ nodes: [{ name: "thanos", process: { binaryPath: "thanos", argOperations: [ { when: { name: "PROMETHEUS_ENABLE_WEB_LIFECYCLE", value: "true" }, flag: "--web.enable-lifecycle", format: "bare-flag" }, { flag: "--endpoint", fromEnv: "THANOS_QUERY_STORE_ADDRESSES", split: "," }, ], }, }], }); ``` ### Operation forms | Pattern | Produces | | --- | --- | | { flag, fromEnv } | if-set value: --flag=value once, only when the var is non-empty. | | { when: {name, value}, flag, format: "bare-flag" } | if-truthy toggle: --flag only when the var equals value. | | { when: {...}, flag, value } | conditional flag+literal: --enable-feature=native-histograms. | | { flag, fromEnv, split: "," } | list split: one --endpoint= per comma-separated element. | | { flag, fromEnvPattern: "^X_(.+)$", value: '${name}="${value}"', nameTransform: "lower" } | pattern-enum: one arg per matching env var, capture group 1 as ${name}. | | { fromEnv, split: " ", format: "raw" } | raw pass-through: whitespace-split extra args, no flag prefix. | - `format` defaults to `"flag-value"` (`--flag=value`). Other formats: `"flag-space"` (two args), `"bare-flag"` (no value), `"raw"` (value only). - `${VAR:-default}` interpolation works inside `arguments` entries too, e.g. `"--web.listen-address=:${PROMETHEUS_PORT:-9090}"`. - `value` templates interpolate `${VAR}`, `${VAR:-default}`, `${value}`, `${name}`, `${1}`. ## 8. Config & YAML builders Structured objects in, deterministic text out — no template-string concatenation, byte by byte. ### config.build — key=value, tables, INI ```js const { config } = require("ezx"); // 1. Flat key=value (postgresql.conf) — auto value formatting const postgresql = config.build({ comment: "PostgreSQL configuration generated by container", separator: " = ", valueFormat: "auto", properties: [["listen_addresses", "*"], ["port", 5432], ["shared_buffers", "256MB"]], }); // 2. Table/matrix (pg_hba.conf) — column-aligned rows const pgHba = config.build({ rows: [["local", "all", "postgres", "trust"], ["host", "all", "all", "0.0.0.0/0", "md5"]], }); // 3. INI sections (pgbackrest.conf) const pgbackrest = config.build({ separator: "=", sections: { global: { "log-level-console": "info" }, default: { "pg1-port": 5432 } }, }); ``` ### config.builder — fluent & conditional ```js const b = config.builder({ separator: "=", valueFormat: "none" }); const srv = b.section("server"); srv.set("port", 8080); srv.setIf("host", env.get("HOST", "")); // skipped when empty srv.setFlag("tls", true); // tls=y (skipped when false) ``` ### yaml.build — deterministic YAML ```js const { yaml } = require("ezx"); const doc = yaml.build({ scope: "postgres-cluster", postgresql: { listen: "0.0.0.0:5432", parameters: { hot_standby: "on" } }, }); // scope: postgres-cluster // postgresql: // listen: 0.0.0.0:5432 // parameters: // hot_standby: "on" ``` > **Determinism** — Map objects are sorted for determinism; arrays keep insertion order. Same env, same output, every time — which is the point. ## 9. Imperative modules Everything under `require("ezx")`. Use the declarative `chain` where you can; drop to these modules for logic that cannot be expressed declaratively. | Module | Methods (verified against v0.8.1) | | --- | --- | | env | get(name, def?), has(name), isTruthy(name, def?), bool(name, def?), int(name, def?), list(name, sep, def?), normalizeBool(v), all() | | fs | exists, stat, mkdir, mkdirAll, ensureDir, chmod, chmodRecursive, chown, chownRecursive, readDir, glob, remove, removeAll, rename, symlink, realpath, tempFile, tempDir, umask, write, which, copy, copyTree | | editor | open(path) → upsert, replace, append, ensure, remove, insertBefore, insertAfter, replaceBlock, setBlock, read, readLines, writeLines | | process | spawn(node) → handle, run(opts), capture(opts), exec(node), shell(cmd, opts), sleep(ns) | | shell | quote(value) — single-quote-escape for safe shell embedding | | probe | tcp(host, port), http(url, expectStatus), exec(cmd...) — boolean one-shot checks | | log | debug/info/warn/error(format, ...args), enabled(level) | | chain | run({ nodes }) — the declarative supervisor (see Dependency graph). Flat nodes + dependsOn / dependsOnEdges edges. | | config | build(spec), builder(opts) — see Builders | | yaml | build(object) — deterministic YAML serialization | | scheduler | build(cfg), parse(expr), every(expr, fn, opts?), trigger(name), status(name) | | health | setReady(bool), ready() | | api | get(path, handler), post(path, handler), put/delete — routes on the shared health server | | archive | create(format, dest, sources), extract(src, dest, opts?) — native tar/tar.gz/tar.zst/zip, pure Go stdlib | | crypto | sha256(input), sha256File(path), base64Encode(input), base64Decode(input), randomHex(bytes) | | net | http(method, url, opts?), download(url, dest, opts?) — native HTTP client with timeout, redirects, optional SHA-256 verification | ### One-shot processes & handles ```js const { process, log } = require("ezx"); // One-shot with inherited output; blocks until exit, returns the code. const code = process.run({ name: "init", process: { binaryPath: "/bin/sh", arguments: ["-c", "echo hi"] } }); // Capture output; check: true throws on non-zero exit. const { stdout } = process.capture({ process: { binaryPath: "pg_ctl", arguments: ["status"] }, check: true, }); // Long-running spawn returns a handle. const h = process.spawn({ name: "app", process: { binaryPath: "/bin/app" } }); h.start([]); // start (idempotent); env array appended after inherited log.info("pid=%d", h.pid()); h.wait(); // blocks → exit code h.signal("SIGTERM"); // TERM | INT | KILL | QUIT | HUP only ``` ### Privilege drop (initdb pattern) ```js // Run initdb as the postgres user, like the official entrypoint. process.run({ name: "initdb", process: { binaryPath: "initdb", arguments: ["-D", PGDATA], user: "postgres", // name or numeric UID group: "postgres", // optional; default = user's primary group }, check: true, }); ``` ## 10. Cron scheduler with gates A node with a `scheduler` runs its process on each cron tick instead of once at startup — the cron sidecar container, gone. Ticks can be gated on a probe (e.g. only back up while the primary role is healthy) and fired manually from the runtime API. ```js const { chain, scheduler } = require("ezx"); chain.run({ nodes: [ { name: "postgres", process: { binaryPath: "postgres", arguments: ["-D", "/var/lib/postgresql/data"] } }, { name: "hourly-backup", dependsOn: ["postgres"], needParentReady: true, process: { binaryPath: "pgbackrest", arguments: ["--config=/etc/pgbackrest.conf", "backup"] }, scheduler: scheduler.build({ schedule: { expression: "0 * * * *", timezone: "UTC" }, minInterval: 60e9, // dedupe: never run twice within 60s gate: { type: "exec", exec: ["pg_isready", "-h", "/run/postgresql"] }, }), }, ], }); ``` | Option / method | What it does | | --- | --- | | schedule.expression | 5-field cron expression, e.g. "0 2 * * *". | | schedule.timezone | IANA name ("UTC", "Asia/Jakarta"). Empty = process local time. | | initialDelay | Wait before the first tick evaluation (nanoseconds). Zero = start immediately. | | minInterval | Dedupe window: a job never runs twice within it. Zero defaults to one minute. | | gate | Probe checked before each tick; a failing probe skips that tick (the loop continues). | | maxRetries | Caps consecutive per-tick restarts of the tick process; <= 0 means unlimited. | | scheduler.every(expr, fn, opts?) | JS callback on each tick instead of spawning a process. Must be short and non-blocking. | | scheduler.trigger(name) | Manual fire-and-forget tick (e.g. from an api route). | | scheduler.status(name) | Returns { exists, inflight }. | > **Trigger semantics** — scheduler.trigger is fire-and-forget: it returns true when accepted, but the tick is skipped if one is already in flight or if the last run is inside the minInterval window. minInterval defaults to 60s — an hourly schedule plus a 60s minInterval is a dedupe, not a frequency change. ## 11. Health endpoints & runtime API ezx serves `/readyz`, `/livez` and `/healthz` on `EZX_HEALTH_ADDR` (default `:8080`). The same server hosts user-defined routes via `api` — no sidecar needed. ```js const { api, scheduler, health, chain } = require("ezx"); // Flip readiness from script logic (drives /readyz and /healthz). health.setReady(true); // Register api routes BEFORE chain.run — the script blocks inside chain.run, // so anything after it never executes. Handlers return plain values (JSON). api.post("/backup/full", () => { scheduler.trigger("hourly-backup"); return { ok: true }; }); api.get("/backup/status", () => scheduler.status("hourly-backup")); // Per-node readiness: /readyz reports ready only while the probe passes. chain.run({ nodes: [{ name: "app", process: { binaryPath: "/bin/app" }, health: { readyProbe: { type: "http", http: { url: "http://127.0.0.1:3000/healthz" } } }, }], }); ``` - `/livez` — is the process alive. - `/readyz`, `/healthz` — readiness state, driven by `health.setReady()` and/or a node's `health.readyProbe` (falling back to its `readiness` probe). > **Register api routes before chain.run** — chain.run blocks the script while it supervises the tree — api.post after it never executes and the routes 404 (verified). Register routes first, with EZX_HEALTH_ADDR set, or registration fails with "ezx.api requires EZX_HEALTH_ADDR to be set". Handlers run on HTTP goroutines: return plain values, never promises or long-running work. ## 12. Launch modes Every node chooses how its main process launches — as an alternative to tini/dumb-init, or with your app as PID 1. | | Mode A — exec | Mode B — supervised | | --- | --- | --- | | PID 1 | Your app (via syscall.Exec) | ezx | | Signals | Native, handled by your app | Forwarded by ezx (forwardSignals) + reaping | | Restart / health / scheduler | None — ezx vanishes after exec | Full: restart, health, scheduler, tree | | Constraints | Leaf node only; no active supervised siblings; mutually exclusive with health | None | | Set with | exec: true — or a lone node, which is auto-exec'd (see below) | restart / health / scheduler / forwardSignals, or execDefault: false | > **A lone node is auto-exec'd** — A chain with a single node that sets no `restart`, no `scheduler` and no `health` is exec'd as PID 1 automatically — even without `exec: true` — so ezx is gone and zombie reaping / signal forwarding stop. ezx logs a warning when this implicit exec fires. Keep ezx as PID 1 by declaring supervision (`restart`, `health`, `scheduler`) or by opting out with `execDefault: false` (chain-level). ## 13. Use cases & recipes Each recipe is runnable — the full versions live in the repo under `examples/bootstrap/`. ### 1. Gated dependency graph (postgres → pgbouncer → pgpool) Flat `nodes` + `dependsOn` edges; `needParentReady` replaces the `$PARENT_PID` polling hack. Siblings (etcd, pgbouncer) start in parallel. ```js chain.run({ nodes: [ { name: "postgresql", process: { binaryPath: "/bin/sh", arguments: ["-c", "start-postgres"] } }, { name: "etcd", dependsOn: ["postgresql"], process: { binaryPath: "etcd" } }, { name: "pgbouncer", dependsOn: ["postgresql"], needParentReady: true, process: { binaryPath: "pgbouncer", arguments: ["/etc/pgbouncer/pgbouncer.ini"] } }, { name: "pgpool", dependsOn: ["pgbouncer"], needParentReady: true, process: { binaryPath: "pgpool" } }, ], }); ``` ### 2. Secrets handling for pgBackRest pgBackRest breaks if `PGBACKREST_*` vars leak into its child. Consume them into config, then strip them from the spawned env: ```js chain.run({ nodes: [{ name: "pgbackrest", process: { binaryPath: "pgbackrest", arguments: ["--config=/etc/pgbackrest.conf", "backup"], filterEnvPattern: ["^PGBACKREST_"], // strip consumed secrets before spawn environment: ["KEEP=kept"], // additive entries survive filtering }, }], }); ``` ### 3. Scheduled backup, role-gated, manually triggerable The cron + gate + API combo from the production containers project — a 1:1 port of a bash entrypoint into a few readable lines: ```js const { api, scheduler } = require("ezx"); // Only tick while this node is the primary (http gate). node.scheduler = scheduler.build({ schedule: { expression: "0 2 * * *", timezone: "UTC" }, initialDelay: 120e9, gate: { type: "http", http: { url: "http://localhost:8008/master" } }, }); // in a real script, register api routes BEFORE chain.run api.post("/backup/full", () => { scheduler.trigger("backup-full"); return { ok: true }; }); api.get("/backup/status", () => scheduler.status("backup-full")); ``` ### 4. First-run init + exec (init DAG → exec main) The official postgres entrypoint flow, declaratively: init steps run to completion as `oneshot` nodes, then the main app becomes PID 1 via `exec` only after they all exit 0: ```js const { chain } = require("ezx"); const PGDATA = "/var/lib/postgresql/data"; chain.run({ nodes: [ { name: "initdb", oneshot: true, process: { binaryPath: "/usr/local/bin/initdb", arguments: ["--username=postgres", "--pwfile=/tmp/ezx-pwfile", PGDATA], user: "postgres", group: "postgres" }, }, { name: "pg-hba", oneshot: true, dependsOn: ["initdb"], process: { binaryPath: "/bin/sh", arguments: ["-c", "echo 'local all all trust' >> " + PGDATA + "/pg_hba.conf"] }, }, { name: "postgres", exec: true, dependsOn: ["initdb", "pg-hba"], process: { binaryPath: "/usr/local/bin/postgres", arguments: ["-D", PGDATA], user: "postgres", group: "postgres" }, }, ], }); // the exec node never returns — postgres is now PID 1 with native signal handling ``` ### 5. Deterministic Patroni YAML ```js const { yaml, editor } = require("ezx"); const patroni = yaml.build({ scope: "postgres-cluster", postgresql: { listen: "0.0.0.0:5432", parameters: { hot_standby: "on" } }, }); editor.open("/etc/patroni/patroni.yml").replace(patroni); ``` ## 14. Edge cases & gotchas Everything here is verified behaviour of v0.8.1 — the stuff you would otherwise discover at 2 a.m. inside a container. > **Callback-only nodes** — Nodes can omit the `process` field entirely (or set `binaryPath: ""`), making them pure JavaScript callbacks. The node completes immediately after `onStart` (if set) returns. Use this for oneshot init tasks that are pure JS logic, or for keepalive nodes that just hold the container open for supervised children. > **Lone nodes exec automatically** — A single node with no restart/scheduler/health becomes a syscall.Exec — your app replaces ezx as PID 1, so zombie reaping and signal forwarding stop. Great for the classic "set up then vanish" entrypoint; surprising if you expected supervision. Opt out with `execDefault: false` (chain-level), or declare the supervision you want (`restart`, `health`, `scheduler`). > **Mutual exclusions are hard errors** — exec + health → "cannot have both Exec and Health set". scheduler + exec or health → "cannot combine Scheduler with Exec or Health". An exec node with still-supervised siblings → "cannot have active siblings". Oneshot nodes are mutually exclusive with `exec`, `scheduler`, `health`, and `needParentReady` (a oneshot is "ready" only when it exits 0). The orchestrator returns these as errors, not warnings. > **restart.maxRetries <= 0 means unlimited** — Default mode is "never". "on-failure" restarts only on non-zero exit; "always" restarts regardless of exit code (a clean exit 0 is also restarted). With maxRetries unset or <= 0, restarts are unlimited — set a cap unless you truly want forever. > **Shutdown timeout semantics** — Default drain: SIGTERM, 30s timeout, then force-kill. timeout: -1 means wait indefinitely (unbounded drain, no force-kill). forceKill: false never SIGKILLs even if the timeout elapses. > **Gate failure ≠ error** — A failing scheduler gate skips that tick and keeps the loop alive — designed for role-gated backups (skip while standby). The tick process itself, however, is restarted on failure up to maxRetries. > **Env filtering is spawn-time only — and additive env wins** — filterEnv/filterEnvPattern strip variables from the inherited environment at spawn time. Entries in `environment` are appended after filtering and always survive. Invalid regexes in filterEnvPattern are configuration errors that fail the spawn. > **process.signal knows five signals** — Handle.signal accepts SIGTERM/TERM/15, SIGINT/INT/2, SIGKILL/KILL/9, SIGQUIT/QUIT and SIGHUP/HUP. Any other name is a silent no-op — it does not throw. process.exec never returns on success (the image is replaced). > **env.get default applies when empty too** — env.get("X", "d") returns "d" when X is unset *or empty* — mirroring ${X:-d}, not ${X-d}. isTruthy(name, def) implements the shell default-true pattern: with a default provided, unset/empty resolves via the default. > **api routes registered after chain.run silently never exist** — Verified: a script that calls api.post after chain.run serves 404 on every route — chain.run blocks the rest of the script. Register all routes before chain.run, with EZX_HEALTH_ADDR set. > **Unsatisfiable cron expressions fire immediately** — Verified: a scheduled node with an impossible date (e.g. 0 0 31 2 * — Feb 31) runs its first tick immediately at startup, because cron.Next degenerates for schedules that can never match. ParseCron only validates syntax and ranges — pick a satisfiable expression. > **Idempotency by operation** — set-property, set-block, replace-block, ensure and remove are idempotent (safe across restarts). insert-before / insert-after are intentionally not — they are for run-once startup mutations. Use createOnly for the `if [ ! -f file ]` default-config guard. ## 15. Status ezx is pre-1.0 and in active use. The `require("ezx")` surface is additive-only: existing modules and method names are frozen, additions are allowed, and any unavoidable breaking change ships as `require("ezx/v2")` with the prior surface preserved. The supervision model draws on the init-supervisor tradition (tini, dumb-init, s6-overlay): dependency graph, per-edge wait modes, oneshot services, init DAG → exec main, per-service logging with rotation, exit-code propagation, restart, health, and scheduling — declared in JavaScript, no shell. Apache License 2.0 — © 2026 Supan Adit Pratama.