nixfleet_agent/activation/
rollback.rs

1//! Rollback pipeline: nix-env --rollback -> discover target -> fire -> poll.
2//!
3//! FOOTGUN: bypasses `nixos-rebuild --rollback` because `nixos-rebuild-ng`
4//! evaluates `<nixpkgs/nixos>` even on rollback, which fails in the agent's
5//! NIX_PATH-less sandbox.
6
7use anyhow::{Context, Result};
8use tokio::process::Command;
9
10use super::profile::resolve_profile_target;
11use super::types::ActivationBackend;
12use super::types::RollbackOutcome;
13use super::verify_poll::{PollOutcome, VerifyPoll};
14
15pub async fn rollback_with<B: ActivationBackend>(backend: &B) -> Result<RollbackOutcome> {
16    tracing::warn!("agent: triggering local rollback (fire-and-forget via systemd-run)");
17
18    let env_status = Command::new("nix-env")
19        .arg("--profile")
20        .arg("/nix/var/nix/profiles/system")
21        .arg("--rollback")
22        .status()
23        .await
24        .with_context(|| "spawn nix-env --rollback")?;
25    if !env_status.success() {
26        tracing::error!(
27            exit_code = ?env_status.code(),
28            "agent: nix-env --rollback failed; cannot proceed",
29        );
30        return Ok(RollbackOutcome::Failed {
31            phase: "nix-env-rollback".to_string(),
32            exit_code: env_status.code(),
33        });
34    }
35
36    // Discover the rolled-back target so we poll for it specifically.
37    let target_basename = match resolve_profile_target() {
38        Ok(b) => b,
39        Err(err) => {
40            tracing::error!(
41                error = %err,
42                "agent: cannot resolve rolled-back profile target; aborting rollback",
43            );
44            return Ok(RollbackOutcome::Failed {
45                phase: "discover-target".to_string(),
46                exit_code: None,
47            });
48        }
49    };
50    tracing::info!(
51        target_basename = %target_basename,
52        "agent: rollback target discovered; firing detached switch",
53    );
54
55    if let Some(failure) = backend.fire_rollback(&target_basename).await? {
56        return Ok(failure);
57    }
58
59    // `previous_basename` stays None: the failed gen we're abandoning is not
60    // a meaningful pre-state, so any non-match collapses into the timeout branch.
61    match VerifyPoll::new(&target_basename).until_settled().await {
62        PollOutcome::Settled => {
63            tracing::info!(
64                target = %target_basename,
65                "agent: rollback fire-and-forget complete",
66            );
67            Ok(RollbackOutcome::FiredAndPolled {
68                reverted_to_closure: target_basename,
69            })
70        }
71        PollOutcome::Timeout { last_observed } => {
72            let exit_code = backend
73                .read_unit_exit_code("nixfleet-rollback.service")
74                .await;
75            tracing::error!(
76                target = %target_basename,
77                last_observed = %last_observed,
78                exit_code = ?exit_code,
79                "agent: rollback poll timed out",
80            );
81            Ok(RollbackOutcome::Failed {
82                phase: "rollback-poll-timeout".to_string(),
83                exit_code,
84            })
85        }
86        PollOutcome::FlippedToUnexpected { .. } => {
87            unreachable!(
88                "FlippedToUnexpected requires Some(previous_basename); rollback leaves it None"
89            )
90        }
91    }
92}