Your first deploy¶
Goal: take a built package, declare a service stack around it, deploy with punix service deploy, observe the atomic flip, roll back. ~10 minutes.
We'll use --dry-run throughout so nothing touches your real /etc. The exact same code path runs for a production deploy — just drop --dry-run.
A stack module¶
Continue from first-build. Add a stack module to your PCL file:
module Hello {
pname = "hello"
version = "1.0"
recipe = "std.shell"
recipeArgs = {
command = "mkdir -p $OUTPUT/bin && echo '#!/bin/sh\nwhile :; do echo tick; sleep 5; done' > $OUTPUT/bin/hi && chmod +x $OUTPUT/bin/hi"
}
source = { type = "local" path = "./src" }
}
module HelloStack {
backend = "systemd"
storeModules = ["Hello"]
services = [{
name = "hello"
package = "Hello"
binary = "hi"
args = []
dependsOn = []
}]
configFiles = [{
path = "/etc/hello.conf"
content = "verbose = true\nbind = 127.0.0.1\n"
}]
}
A stack is just another PCL module — distinguished only by having a backend field. Its job is to compose packages (already-built things in the store) into a runtime configuration:
backend = "systemd"— the only one shipping in v0.6. launchd / supervisord / docker-compose land in Stage 8.storeModules: package modules whose store paths must be pinned in the generation's closure (so GC won't collect them).services: per-service records.packagereferences a package module by name (a string, notHello.out— PCL's type checker doesn't expose.outas a member yet).binarynames an executable inside<package_path>/bin/.configFiles: arbitrary text files to write. The systemd generator also emits one/etc/systemd/system/<name>.serviceper service — those are added automatically.
Dry-run deploy¶
uv run punix service deploy HelloStack \
--file hello.pcl \
--dry-run \
--store-root /tmp/punix-tutorial/store \
--deployments-root /tmp/punix-tutorial/deploys
Expected:
deployed: stack HelloStack → gen-001 (2 config file(s), 1 store path(s) pinned)
scratch: /tmp/punix-tutorial/deploys/.dry-run/
--dry-run does the full deploy pipeline against a scratch filesystem — composes, renders config files, writes the generation manifest, flips a scratch current symlink — except it doesn't touch your real /etc or ~/.punix/deployments. The bytes it would have written land under <deployments-root>/.dry-run/. Inspect:
ls /tmp/punix-tutorial/deploys/.dry-run/etc/
# hello.conf systemd/
ls /tmp/punix-tutorial/deploys/.dry-run/etc/systemd/system/
# hello.service
cat /tmp/punix-tutorial/deploys/.dry-run/etc/systemd/system/hello.service
[Unit]
Description=Punix stack HelloStack: hello
[Service]
ExecStart=/Users/you/.punix/store/<hash>-hello-1.0/bin/hi
Type=simple
Restart=on-failure
[Install]
WantedBy=multi-user.target
The generation manifest lives at:
This file is the rollback contract: every store_path pinned, every config file's sha256, the source hash, the deploy timestamp. Rollback never re-evaluates — it reads gen-NNN.json and flips the current symlink. O(1).
Deploy twice, then roll back¶
Re-run the same command — exact same arguments:
uv run punix service deploy HelloStack \
--file hello.pcl --dry-run \
--store-root /tmp/punix-tutorial/store \
--deployments-root /tmp/punix-tutorial/deploys
Even with identical content, a new generation is recorded (the manifest records every deploy, not just content-changing ones — that's deliberate).
Now roll back:
uv run punix service rollback HelloStack \
--store-root /tmp/punix-tutorial/store \
--deployments-root /tmp/punix-tutorial/deploys \
--transport-root /tmp/punix-tutorial/deploys/.dry-run
The current symlink on the (scratch) target now points back at gen-001.json. Verify:
readlink /tmp/punix-tutorial/deploys/.dry-run/tmp/punix-tutorial/deploys/HelloStack/current
# gen-001.json
gen-002.json is still on disk — rollback is "flip the pointer", not "delete forward generations". You can roll forward by atomic_switch to 2 again (CLI command for that is Stage 8 polish).
What just happened¶
Three properties got exercised end-to-end — each is a CI release-blocker in the conformance suite:
- Atomic flip — the live state changes in exactly one
os.replaceofcurrent. Crash at any earlier point and the previous generation stays wholly live. - Rollback exact — gen-001 was restored byte-identically. The forward generation (gen-002) survived on disk; you can re-deploy to flip back.
- GC respects pins — the next
punix store gcwill see both gen-001 AND gen-002 in the keep-set; theHellostore path survives both.
→ Reference: conformance explains how each maps to the test suite.
What's next¶
- Real deploy (drop
--dry-run): you'll need write access to/etc/(i.e. root on most systems). See composing a stack for the realistic flow. - Remote deploy over SSH: add
--target ssh://user@host. See Deploy over SSH. - Secrets: add
environment = { DB_PASSWORD = { from_env = "DB_PWD" } }to a service. See Secrets at deploy. - Understand the design: Eval/realise seam walks through the one boundary the whole system is organised around.