Composing a stack¶
A stack is a PCL module with a backend field. Its job: name the packages to pin, declare the services, and write any free-form config files. The deploy CLI composes it (compose_stack), the backend generator (e.g. systemd.render) renders unit files, the Manifest layer writes a generation and flips current.
The skeleton¶
module Api { pname = "api"; version = "1.0"; recipe = "std.shell"; … }
module Worker { pname = "worker"; version = "1.0"; recipe = "std.shell"; … }
module MyStack {
backend = "systemd"
storeModules = ["Api", "Worker"]
services = [
{ name = "api"
package = "Api"
binary = "api"
args = ["--bind", "0.0.0.0:8080"]
dependsOn = []
environment = {
DB_URL = { from_env = "DB_URL" }
LOG_LEVEL = "info"
}
},
{ name = "worker"
package = "Worker"
binary = "worker"
args = []
dependsOn = ["api"]
}
]
configFiles = [
{ path = "/etc/myapp/app.conf"
content = "verbose = true\nworkers = 4\n"
}
]
}
Fields¶
backend (required, Str)¶
Names the backend generator. v0.6 ships "systemd". Stage 8 will add "launchd", "supervisord", "docker-compose". Picking an unsupported backend is exit 1 with a listing of supported ones.
storeModules (List, default [])¶
[])¶Module names (strings) whose store paths must be in this generation's pinned closure. compose_stack resolves each to a path via Evaluator.store_path(name).
Pinning here matters for GC — generation_roots walks every gen and unions the store_paths. If a package isn't in storeModules (or referenced via a service's package), GC may collect it.
services (List, default [])¶
[])¶A Service record has:
| Field | Type | Notes |
|---|---|---|
name |
Str |
The systemd unit name (no .service suffix; the renderer adds it). |
package |
Str |
Module name to use for the binary (PCL doesn't expose .out as a member yet — Stage 6+). |
binary |
Str |
Executable name inside <package_path>/bin/. |
args |
List<Str> |
Command-line args. |
dependsOn |
List<Str> |
Other services' names. Lowers to After= and Requires= in the systemd unit. |
environment |
Rec<Str: Str \| SecretRef> (default {}) |
KEY=VALUE entries; values are literals or secrets. |
serviceConfig |
Rec (default {}) |
Typed [Service] directive passthrough. |
enable |
Bool (default true) |
false omits the service entirely. |
tmpfiles |
List<Str> (default []) |
tmpfiles.d rules. |
timer |
Rec (default {}) |
[Timer] directives → a sibling .timer unit. |
The exec_path for a service is <package_path>/bin/<binary> — computed at compose time, since PCL has no string concatenation yet.
A services element may also be a module-name string instead of an inline record — required when services have differing serviceConfig shapes (PCL lists are homogeneous). The full service surface (serviceConfig, enable, tmpfiles, timers, the module form) is documented in The systemd service surface.
configFiles (List, default [])¶
[])¶Stack-declared files to write in addition to the backend's generated units. Each:
Paths are absolute. The deploy writes them via Transport.write (atomic per-file). Their bytes' SHA-256 lands in gen-NNN.json so rollback can detect drift.
What the backend adds¶
The systemd generator (Stage 4d, Stage 6c) automatically renders one .service unit per service:
[Unit]
Description=Punix stack MyStack: api
After=
Requires=
[Service]
ExecStart=/store/<hash>-api-1.0/bin/api --bind 0.0.0.0:8080
Type=simple
Restart=on-failure
Environment=DB_URL=postgresql://prod # resolved at deploy
Environment=LOG_LEVEL=info
[Install]
WantedBy=multi-user.target
These land at /etc/systemd/system/<name>.service. The path is hardcoded in v0.6 (Stage 8 will add --unit-dir for user-mode systemd at ~/.config/systemd/user/).
Beyond the basics¶
A stack can declare much more than services and free-form files. Each capability has its own page:
- The systemd service surface —
serviceConfig,enable,tmpfiles,systemUsers/systemGroups, timers, the module form. - Web & config generators —
staticSites,reverseProxies,streamProxies,redirects,prometheus,litestream. - TLS certificates —
tls = { acme, email, domains }and--tls-certs. - Stateful services —
databases(Postgres provisioning) and the data/rollback boundary. - Activation —
--activateto actually (re)start the units. - Deploying a fleet — many hosts from one command.
Composing across multiple PCL files¶
Like any PCL program, a stack module can be in any file under the directory passed to punix service deploy --file:
pkgs/
├── api.pcl # module Api
├── worker.pcl # module Worker
└── stack.pcl # module MyStack (references Api and Worker)
compose_stack(ev, "MyStack") reads the bindings under MyStack__* from the composed root — file boundaries don't matter.
Scenarios¶
A stack can be parameterised by scenario (see Modules):
scenario Dev {
Api.binary = "api-debug"
MyStack.services[0].environment.LOG_LEVEL = "debug"
}
scenario Prod {
MyStack.services[0].args = ["--bind", "0.0.0.0:443"]
}
The scenario is part of the canonical derivation — different scenarios yield different generation manifests (and possibly different store paths, if the scenario changed a recipe input).
Common errors¶
error: 'MyStack' is not a stack module (no 'backend' field)— you passed a regular package module toservice deploy.error: backend 'launchd' not supported (Stage 4 ships only: systemd)— pick a different backend or wait for Stage 8.error: services[0].package = 'Caddy' is not a package module—packagereferences a module that has norecipefield.
Where in the code¶
src/punix/deploy/stack.py::compose_stack(ev, module_name).src/punix/deploy/generators/systemd.py::render(stack).tests/c_e2e/test_stack_compose.py— 18 end-to-end tests covering composition + systemd rendering + the secrets surface.