Lifecycle hook cutover: the flip moved earlier
The current delivery pointer used to flip in the middle of the Commit state,
between commit.enter and commit.exit. It now flips inside Activate, before
activate.exit. Two new states, Activate and Deactivate, sit between
Install and Commit.
Nothing you already wrote stops parsing and nothing stops running. This is a hard cutover with no compatibility shim and no deprecation window, because none is needed. Every declaration that was valid before is still valid and every hook still fires. What changed is when one of them fires relative to the moment the new version goes live, and how long two others wait for it.
This page answers one question. Does your hook need to move?
What the order is now
Before:
check.* → download.* → install.* → commit.enter → [FLIP] → commit.exit
Now:
check.* → download.* → install.*
→ activate.enter (new version asks: may I go live?)
→ deactivate.enter (old version stands down)
→ deactivate.exit
→ [FLIP]
→ activate.exit (new version confirms: I am live)
→ commit.enter
→ commit.exit
Deactivate runs nested inside Activate, so every step that can fail happens
before the one destructive step. A veto at activate.enter has torn nothing
down and owes no compensation. The transition table that decides this lives in
crates/atc-lifecycle/src/state/fsm.rs; the driver that walks it is
crates/atcd-applier/src/driver/delivery_flow.rs.
commit.enter is the one gate that swapped sides. It used to run before the
flip and now runs after it. activate.exit is a new post-flip gate, and
commit.exit was post-flip before this change and still is.
The observable symptom
Open your hook script. You need only one fact about it.
Does the script reach the live delivery pointer? Either directly, by naming
a path under the channel’s delivery/current, or indirectly, by restarting,
reloading or querying something whose own configuration resolves through it. A
systemctl restart, an atc ota status, a unit file whose ExecStart points
into the active delivery dir: all of those reach it.
If the answer is no, stop reading. Your hook is unaffected. Everything a hook
gets in ATC_HOOK_CONTEXT is stable across the flip: version.new_sha,
version.old_sha, self_root.new, self_root.old and artifacts_dir hold the
same values on both sides of it. A script built only from those fields cannot
observe the flip at all, and so cannot have been broken by moving it.
If the answer is yes, read the section for the gate you declared.
commit.enter: check this one first
commit.enter moved from before the flip to after it. It is the only gate whose
meaning changed, and it changed silently: the hook still runs, still at the same
point in the printed sequence, and still exits 0.
The difference is what it reads.
# in a commit.enter hook, on the same device, same push
atcd get-path
# before this change: the OLD delivery dir
# now: the NEW delivery dir
atcd get-path prints the active delivery directory, and atcd get-path -a <name> prints one artifact’s active payload directory. Use it rather
than building the path yourself: the directory is keyed by delivery id,
and the v5 layout already moved it once, which broke every hand-written
copy. With no flag the verb reads the current pointer. With --delivery-id
it lists the delivery directories of the default channel instead. Neither path
opens the database or needs a running daemon, so both are safe to call from
inside a hook.
If your hook must read one SPECIFIC delivery, name it. atcd get-path --delivery-id <ID> reports that delivery rather than whichever one current
points at, and --delivery-id <ID> -a <name> reports one of its members. A
delivery bookend gets the id from ATC_DELIVERY_CONTEXT. A per-artifact hook
does not receive one.
A delivery gets its directory when it is activated. On a fresh push that
happens at the flip, so delivery.pre, activate.enter, deactivate.enter
and deactivate.exit cannot look the INCOMING delivery up: it has no directory
yet. activate.exit, commit.enter, commit.exit and delivery.post run
after the flip and can. On atc ota switch the target was committed by an
earlier push, so every phase can look it up.
If your commit.enter hook drains connections, flushes a queue, snapshots
state, or stops a service belonging to the version being replaced, it is now
doing that after the replacement went live. Move it. deactivate.enter and
deactivate.exit exist for exactly that work, and they run the outgoing
version’s own declaration rather than the incoming one (see
Whose declaration runs).
If your commit.enter hook only inspects the incoming artifact on disk, or only
reads ATC_HOOK_CONTEXT, leave it alone.
commit.exit: correct, but late
A commit.exit hook was post-flip before and is post-flip now. It still sees
current pointing at the new version. It is not broken and you do not have to
touch it.
What changed is the gap in front of it. The window between the flip and
commit.exit used to hold only the daemon’s own post-flip work. It now also
holds activate.exit, the delivery-wide commit.enter stage barrier, and one
member-scoped commit.enter gate per artifact. Each of those can run an
operator script with its own timeout_secs, so the new version can be live for
a while before your commit.exit hook gets to run.
That matters if the hook is what makes the new version usable. A device whose
current points at v2 while v2’s service is still stopped is running nothing,
and the longer that gap the worse it reads in the field.
Before, and still working:
[bundle.lifecycle.hook.commit.exit]
script = "@self/scripts/restart_service.sh"
timeout_secs = 60
Better now:
# activate.exit is the FIRST gate after the flip. Bring the new version into
# service here, and the live-but-stopped window is as short as it can be.
[bundle.lifecycle.hook.activate.exit]
script = "@self/scripts/restart_service.sh"
timeout_secs = 60
Leave it at commit.exit if it reports, notifies, validates or marks. A hook
that posts to Slack, drops a marker file, or curls a health endpoint does not
care that it runs a few seconds later.
Both cells are veto-capable, so moving the script does not weaken the gate. A
non-zero exit at activate.exit unwinds the delivery and reverts the pointer
exactly as one at commit.exit does.
Where each kind of hook belongs now
| Your hook | Put it at |
|---|---|
| Decides whether the new version may go live at all | activate.enter (pre-flip, and nothing is torn down yet, so a veto is free) |
| Stops, drains, unmounts or flushes the outgoing version | deactivate.enter / deactivate.exit |
| Starts, restarts or registers the new version | activate.exit |
| Reports, notifies, marks, or checks health after the fact | commit.exit (leave it where it is) |
| Cleans up when GC removes an old version | uninstall.enter / uninstall.exit |
| Brackets a whole housekeeping sweep, or opts out of it | gc.enter / gc.exit |
The last two are new slots, not relocations. See
atc-toml.md for their scope rules.
Does your hook claim a name the device holds only once, a systemd unit name or a
TCP port? Read
A name shared across versions
first. The table above puts the start at activate.exit to shorten
the live-but-stopped window. For a shared name that row is also a correctness
rule, and the start must be a restart.
Do you move a @self hook to check.*, download.* or install.enter? There
it runs the ACTIVE version’s script, not the incoming one, and a missing script
skips the hook. See
Which version’s script @self runs.
A @self hook that you move OUT of install.enter to install.exit starts to
run the incoming version’s script.
Does the hook run an absolute script that the artifact itself installs, for
example a file a .deb puts under /usr/lib? Then a move to an earlier cell
can run it before the package exists. On the first delivery to a device that
fails the hook with ENOENT. Set allow_missing_initial = true on that hook.
Read
A hook script that the artifact installs.
Whose declaration runs
deactivate.* is the one state whose hooks come from the version being torn
down rather than the delivery arriving. A push reads them back from that
version’s own .atc/lifecycle.pb sidecar; a pulled delivery reads the signed
declaration the poller retained when it installed that version. That has a
consequence worth stating plainly, on either plane: a deactivate.* hook you
add today does not run on today’s delivery. It runs on the next one, when the
version you are shipping now becomes the outgoing version.
Two corollaries:
- A first deploy traverses
Deactivateand dispatches nothing. There is no outgoing version to tear down. - A stage-only push (one that stages every artifact but flips nothing, so no
--switchand noautoswitch) and a re-push of the already-active version also dispatch nothing there. Neither replaces a running workload, so tearing one down would leave the device running nothing and report success. Neither activates anything either, so neither runsactivate.*.
uninstall.* follows the same rule for the same reason: the sweep reads it from
the sidecar of the version it is about to delete.
atc ota switch and atcd ctl switch
Both now run six gates where they ran two: activate.enter,
deactivate.enter, deactivate.exit, the flip, activate.exit,
commit.enter, commit.exit. If you parse the streamed progress, expect three
times the frames.
deactivate.* was absent from this path until #3698. The reason was real. The
switch plane had none of the machinery Deactivate depends on. A vetoed switch
would then leave the outgoing version torn down, with nothing able to restore
it. It has that machinery now: a durable teardown record, a boot payer, and the
compensation a refusal owes. So the two cells run, from the OUTGOING version’s
declaration.
Read your deactivate.* declarations before you upgrade. A teardown reads
the outgoing version’s declaration. So the scripts that start running are the
ones already on your fleet. A refused teardown vetoes the switch and leaves the
device where it was.
Devices that crashed under the old daemon
Nothing to run. deliveries.phase is free text, and the recovery reader accepts
both the old Commit spelling and the new Activate one, so a device that
crashed mid-flip under a pre-cutover daemon recovers correctly under a
post-cutover one. There is no schema migration and no backfill step. The dual
acceptance is the upgrade path
(crates/atcd-core/src/crash_position.rs::classify_crash_position).
A crash between deactivate.exit and the flip is now a rollback case: the
daemon reactivates the outgoing version on the next boot rather than leaving the
device stranded.
A word that means two different things
atc ota ls prints a push status column whose values include activating
and committing. Those are not the lifecycle states this page is about. They
are a separate vocabulary in crates/atc-store/src/pushes.rs describing where a
push sits in the transfer pipeline, and they predate these states. If you are
matching on one, be sure which one you mean.
Checklist
rg -l 'commit\.(enter|exit)' path/to/your/atc.toml. No hits means nothing to do.- For each hit, open the script. Does it reach
delivery/current, directly or through a service it restarts? No means leave it. commit.enterthat touches the outgoing version, moves todeactivate.*.commit.exitthat brings the new version into service, moves toactivate.exit.- Everything else stays.
- Push to one device and read the hook rows back with
atc ota status --jsonbefore you roll it out. The sequence you should see is in What the order is now.