Skip to content

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:

{ path = "/etc/myapp/app.conf"
  content = "literal contents here\n"
}

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:

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)
punix service deploy MyStack --file pkgs/

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"]
}
punix service deploy MyStack --file pkgs/ --scenario Prod

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 to service 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 modulepackage references a module that has no recipe field.

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.