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. Punix ships "systemd"; the seam takes "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.

scope (Str, default "system")

Where this stack's files land, and under whose authority.

"system" (default) "user"
config may go under /etc, /srv, /var, /opt ~/.config, ~/.local, ~/.cache, $HOME
units go to /etc/systemd/system/ ~/.config/systemd/user/
activation uses systemctl systemctl --user
needs root yes no

Everything else is identical: same file, same generators, same generations, same one-second rollback, same secret handling. scope changes where and as whom, nothing else.

module Dots {
  backend = "systemd"
  scope   = "user"
  configFiles = [
    { path = ".config/myapp/app.conf"  content = "verbose = true\n" }
  ]
  dataFiles = ["GitAliases"]
}

Under scope = "user", a relative path is resolved against your home directory. A relative config file is portable across every machine you own: it carries neither a username nor a platform path. An absolute path still works if you want one. A dotfile gets generations and rollback like anything else, with no separate mechanism. punix service rollback Dots puts the previous contents back.

Refusals. On the rule deny what undo cannot fix:

  • ~/.ssh/…: the one mistake rollback can't repair: clobber authorized_keys on a host you reach over SSH and you've destroyed the access needed to run the rollback;
  • ~/.punix/…: the store and the generation manifests, where your provenance lives; a config file able to rewrite a generation record could rewrite its own provenance;
  • ~/.config/systemd/user/…: refused to configFiles only, because the unit directory belongs to the renderer. Declare a services entry instead.

Shell rcs (~/.profile, ~/.bashrc, ~/.zshrc, fish, nushell) are not refused. An earlier version refused them for "executing as you", which does not hold: the deploy already runs as you, so nothing is escalated, and the rule could not be stated without also denying ~/.local/bin and every shell dialect. Undo protects you there.

Local only, for now. scope = "user" resolves $HOME on the machine you run punix on, which is correct locally and wrong over --target (the home would belong to the remote account). Deploying it over SSH is refused with that explanation rather than silently writing the wrong path.

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 (or home-relative under scope = "user"). The deploy writes them via Transport.write (atomic per-file). Their bytes' SHA-256 lands in gen-NNN.json so rollback can detect drift.

An entry gives its bytes one of two ways, and must give exactly one:

content = "…" a PCL string literal
source = "./path" a file, read at deploy time, relative to the .pcl file's own directory
{ path = ".config/git/ignore"  source = "./git/ignore" }

source is what makes a dotfiles or config repo practical: the bytes stay in real files you can git diff, and the stack file just names them. The path may not climb out of the stack's directory, so a stack file cannot be turned into a read primitive for the rest of the disk; the file must be UTF-8; and compose_stack stays pure: it carries the reference, and a resolve step reads it, exactly as resolve_secrets handles a SecretValue.

A directory source takes an exclude list of glob patterns, the only thing the walk skips:

{ path = ".config/fish"  source = "./fish"
  exclude = ["fish_variables*", "*.bak"] }

Patterns match from the right, so *.bak excludes at any depth and completions/* excludes one directory's worth. Punix ships no built-in junk list and does not consult .gitignore (the walk reads the disk, and git-ignored files are still on it). The only files skipped are the ones you listed, visible in the artifact. An exclude that removes every file is an error; a typo in the pattern is the more likely cause.

The executable bit is preserved, and only that bit: a script in a config tree lands 0755 (or 0700 when the entry is secret), everything else takes the transport default. A repo's group/other bits are not carried over: the kernel refuses world- or group-writable files anyway, and a script deployed without +x fails at run time, far from the deploy that caused it.

Note that PCL lists are homogeneous, so one configFiles list uses source throughout or content throughout.

secret = true marks a file as carrying a credential:

{ path = "/etc/app/creds.toml"  source = "./creds.toml"  secret = true }

The kernel decides at the write boundary: mode forced to 0600 (a looser one is refused), the payload excluded from gen-NNN.json, and the file exempted from the secret leak scan. Only the sha256 is recorded, so drift is still detectable. Without the flag a file holding a token is written 0644 and its bytes land in the generation record as content_b64. A truthy non-boolean ("yes", 1) is refused, never coerced.

One consequence to plan around: the payload is deliberately not recorded, so rollback cannot restore a secret-bearing file's contents. At rollback time, Punix re-resolves the bytes (§20.6). The bytes must still be reachable at rollback time.

content must be a literal string; PCL has no string concatenation; so a configFiles entry cannot be computed from anything. For a config file in a structured format (YAML, JSON), use dataFiles below instead: it can be derived.

dataFiles (List, default [])

For a config file whose format is a structured one, write the data and let Punix serialize it. Each entry names a module of the form { path format data }:

module AppConf {
  path   = "/etc/myapp/config.yml"
  format = "yaml"                        # or "json"
  data   = {
    listen = "0.0.0.0:8080"
    debug  = false
    tags   = ["a", "b"]
  }
}

module MyStack {
  backend   = "systemd"
  dataFiles = ["AppConf"]
}

/etc/myapp/config.yml:

listen: "0.0.0.0:8080"
debug: false
tags:
  - "a"
  - "b"

Why this is better than a content string for any format it covers:

  • It can be derived. data is an ordinary PCL value, so it can be folded from a fleet model: the thing a literal string can never do:
data = {
  global = { scrape_interval = "15s" }
  scrape_configs = for m in Fleet.machines
                     yield { job_name = m.name  static_configs = [{ targets = [m.addr] }] }
}

Editing Fleet.machines re-renders the file. No per-host copy-paste, so no drift.

  • It cannot produce broken syntax. A serializer that quotes correctly cannot be made to break its own grammar, so no value of yours can escape into structure.

  • No type surprises. Every string is quoted, which closes the most common YAML bug class: unquoted yes/no/on/off are read as booleans, 1:30 as a number, 1.0 as a float. version = "1.0" stays the string you wrote.

Notes:

  • Why a module, not an inline record. PCL lists are homogeneous, so two inline entries with differently-shaped data would be a type error. A list of module names is a list of strings, and each module carries its own shape: the same idiom services uses.
  • Values may be strings, integers, booleans, records, and lists, nested freely. Floats and empty values are not accepted (write "1.5" if you need a decimal in a config).
  • Key order is yours: mappings serialize in the order you wrote them, never sorted. But a list produced by for … yield arrives canonically sorted, because the fold is order-independent by design; that matters only where list order is semantically significant.
  • Paths go through the same confinement and collision checks as every other config file, and a dataFiles path colliding with a generated one is a hard error.

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, or at ~/.config/systemd/user/<name>.service when the stack declares scope = "user". In that case activation is systemctl --user and nothing needs root. The unit directory is not a separate knob: it is one of the three things scope binds, alongside the allowed config roots and the activator verb. See manage your dotfiles.

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 module: package references a module that has no recipe field.

Where in the code

  • src/punix/deploy/stack/compose.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.