Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 hookPut it at
Decides whether the new version may go live at allactivate.enter (pre-flip, and nothing is torn down yet, so a veto is free)
Stops, drains, unmounts or flushes the outgoing versiondeactivate.enter / deactivate.exit
Starts, restarts or registers the new versionactivate.exit
Reports, notifies, marks, or checks health after the factcommit.exit (leave it where it is)
Cleans up when GC removes an old versionuninstall.enter / uninstall.exit
Brackets a whole housekeeping sweep, or opts out of itgc.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 Deactivate and dispatches nothing. There is no outgoing version to tear down.
  • A stage-only push (one that stages every artifact but flips nothing, so no --switch and no autoswitch) 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 runs activate.*.

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

  1. rg -l 'commit\.(enter|exit)' path/to/your/atc.toml. No hits means nothing to do.
  2. For each hit, open the script. Does it reach delivery/current, directly or through a service it restarts? No means leave it.
  3. commit.enter that touches the outgoing version, moves to deactivate.*.
  4. commit.exit that brings the new version into service, moves to activate.exit.
  5. Everything else stays.
  6. Push to one device and read the hook rows back with atc ota status --json before you roll it out. The sequence you should see is in What the order is now.