Skip to content

Activation

A deploy writes unit files and flips current; but, by default, it does not run systemctl. The new generation takes effect on next boot or a manual restart. --activate is the opt-in step that actually (re)starts the deployed units, as an effect, after the flip.

--activate

punix service deploy WebStack --file pkgs/ --target ssh://deploy@host --activate

With --activate, after the atomic flip the deploy runs systemctl daemon-reload, then systemctl restart for each rendered unit; through the Transport (no subprocess outside the seam). It is ignored under --dry-run. Without it, the deploy is config-only.

It is opt-in, so a config-only deploy (and its tests) stays unchanged, and an operator can stage config now and activate later.

Post-flip, by theorem

Activation runs after the atomic flip, which covers binary + config. A failed (re)start is therefore a warning-after-success: the flip already happened and cannot be un-flipped:

deployed: stack WebStack → gen-005 (4 config file(s), 7 store path(s) pinned)
  ✓ (re)started appview.service (not enabled for boot)
  ⚠ knot.service did not (re)start: Job for knot.service failed
error: stack WebStack is deployed (current → gen-005) but 1 unit(s) did not (re)start: knot.service — investigate and re-activate; this was NOT rolled back
  • A single deploy exits 1 on any activation failure (one operator action; the exit code is the signal): but it does not roll back. Raising would imply a rollback that didn't happen.
  • A fleet apply host whose activation fails stays counted as deployed and surfaces a per-host warning; one host's post-flip restart must not abort the others.

Activation never crashes a deploy whose flip succeeded: every unit/transport failure is reported as a result; these paths never raise exceptions. A daemon-reload failure reports every unit failed (nothing can start).

What gets restarted

Units derive from the stack's services (provenance), not from config-file paths; so a stack-declared configFiles entry that merely lives under /etc/systemd/system/ is never touched. A timer-activated service restarts its .timer only (which re-arms the schedule); restarting the bound .service would run the job now. Stateful provisioning oneshots are activated too, so the DB state is applied after the engine is up.

Boot persistence: --enable

--activate makes the deploy take effect now (it restarts units). --enable additionally runs systemctl enable so the units survive a reboot: the rendered units carry [Install], so this is additive. Without --enable the success message says (not enabled for boot) so the distinction is never a surprise. examples/tangled-deploy proves boot-persistence end to end (deploy once → restart the container → units cold-start).

Deploying onto a distro that already ships the same service

A unit name is shared namespace. When Punix manages a service the distribution also packages (postfix, redis, nginx), the distro's own units may attach things to that name which are not visible in the unit file Punix writes, and which systemctl disable does not remove. Two shapes, both met on a stock Debian host:

An Alias= symlink. Debian's redis-server.service declares Alias=redis.service, so /etc/systemd/system/redis.service is already a symlink into /usr/lib/systemd/system/. A stack with a redis service is then refused:

error: refusing to write /etc/systemd/system/redis.service: resolves to
/usr/lib/systemd/system/redis-server.service, outside the allowed config roots

That is path confinement working as designed: a pre-existing symlink redirecting a Punix write outside its roots is indistinguishable from a planted one, and Punix will not follow it. Remove the symlink (rm /etc/systemd/system/redis.service) and deploy again.

A generated Wants=. Debian's postfix ships /lib/systemd/system-generators/postfix-instance-generator, which synthesises /run/systemd/generator/postfix.service.wants/postfix@-.service on every daemon-reload. Starting any unit called postfix.service, including Punix's, therefore also starts Debian's postfix, which wins the race for the queue lock and leaves the Punix unit in a restart loop on:

fatal: open lock file pid/master.pid: unable to set exclusive lock:
Resource temporarily unavailable

disable does not touch a generated drop-in, and rm cannot outlive a reload because the drop-in is generated. Mask it:

systemctl mask postfix@-.service

The general remedy, before the first deploy onto an existing host: for each service the stack declares, check systemctl cat <name> and systemctl list-dependencies <name> for units you did not write. examples/mailserver/container/Dockerfile does exactly this for a four-daemon mail host and comments each case.

Pluggable backends: the activator registry

Activation is the effect side of a backend, and it is pluggable the same way generators are. ACTIVATORS (in src/punix/deploy/activate.py) maps a backend name to an activator factory: the effect-side mirror of cli._GENERATORS:

ACTIVATORS: dict[str, Callable[..., Activator]] = {"systemd": SystemdActivator}

_deploy_one selects the activator by the stack's backend post-compose. The Activator Protocol is one method; activate(units) -> tuple[UnitResult, ...]. A backend that supplies only a generator renders config fine and fails only when --activate/--enable is requested; so "render now, activate later" is a valid state, and a new supervisor (runit, launchd, …) becomes reachable by registering an activator, not by forking the core. The activator is the irreducible host-code residue; it stays core-only by design. See Extending Punix and ADR-023 for the full extensibility ladder.

Where in the code

  • src/punix/deploy/activate.py: the Activator Protocol, SystemdActivator, units_to_activate, and the ACTIVATORS registry.