diff --git a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs index 8711734e1ea3..277db89d25bd 100644 --- a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs +++ b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs @@ -148,6 +148,7 @@ impl From<&Action> for &'static str { #[derive(Debug, Clone, Copy, PartialEq)] enum UnitScope { System, + User, } impl UnitScope { @@ -156,6 +157,7 @@ impl UnitScope { fn etc_dir(&self) -> &'static str { match self { UnitScope::System => "etc/systemd/system", + UnitScope::User => "etc/systemd/user", } } @@ -163,14 +165,25 @@ impl UnitScope { fn current_dir(&self) -> &'static Path { Path::new(match self { UnitScope::System => "/etc/systemd/system", + UnitScope::User => "/etc/systemd/user", }) } /// Directory where unit action lists are persisted for - /// resume-after-interrupt. + /// resume-after-interrupt. The user scope uses XDG_RUNTIME_DIR so the + /// unprivileged child can write to it. fn list_dir(&self) -> PathBuf { match self { UnitScope::System => PathBuf::from("/run/nixos"), + UnitScope::User => { + // The parent always sets XDG_RUNTIME_DIR when spawning the + // user-scope child. + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .ok() + .filter(|d| !d.is_empty()) + .expect("XDG_RUNTIME_DIR must be set and non-empty for the user scope"); + PathBuf::from(runtime_dir).join("nixos") + } } } @@ -1167,10 +1180,37 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { die(); } + let toplevel = PathBuf::from(required_env("TOPLEVEL")?); + let old_toplevel = PathBuf::from(required_env("OLD_TOPLEVEL")?); + let action = Action::from_str(&required_env("NIXOS_ACTION")?)?; + let action = ACTION.get_or_init(|| action); + let dry_run = *action == Action::DryActivate; + + let scope = UnitScope::User; + let list_dir = scope.list_dir(); + std::fs::create_dir_all(&list_dir) + .with_context(|| format!("Failed to create {}", list_dir.display()))?; + let perms = std::fs::Permissions::from_mode(0o700); + std::fs::set_permissions(&list_dir, perms) + .with_context(|| format!("Failed to set permissions on {}", list_dir.display()))?; + let start_list = scope.start_list_file(); + let restart_list = scope.restart_list_file(); + let reload_list = scope.reload_list_file(); + let dbus_conn = LocalConnection::new_session().context("Failed to open dbus connection")?; let systemd = systemd1_proxy(&dbus_conn); + systemd + .subscribe() + .context("Failed to subscribe to systemd dbus messages")?; + + let submitted_jobs = Rc::new(RefCell::new(HashMap::new())); + let finished_jobs: Rc>> = + Rc::new(RefCell::new(HashMap::new())); let nixos_activation_done = Rc::new(RefCell::new(false)); + + let _submitted_jobs = submitted_jobs.clone(); + let _finished_jobs = finished_jobs.clone(); let _nixos_activation_done = nixos_activation_done.clone(); let jobs_token = systemd .match_signal( @@ -1180,32 +1220,190 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { if signal.unit.as_str() == "nixos-activation.service" { *_nixos_activation_done.borrow_mut() = true; } - + if let Some(old) = _submitted_jobs.borrow_mut().remove(&signal.job) { + _finished_jobs + .borrow_mut() + .insert(signal.job, (signal.unit, old, signal.result)); + } true }, ) .context("Failed to add signal match for systemd removed jobs")?; + // Plan the user unit changes before touching anything. By the time this + // child runs, /etc (and /run/current-system) have already been switched to + // the new configuration. The parent captured the old toplevel path before + // activation and passed it to us so we can still compare old vs new. + let mut units_to_stop = HashMap::new(); + let mut units_to_skip = HashMap::new(); + let mut units_to_filter = HashMap::new(); + + // Seed from any previous interrupted run so that we continue where we left + // off, like the system scope does. + let mut units_to_start = map_from_list_file(&start_list); + let mut units_to_restart = map_from_list_file(&restart_list); + let mut units_to_reload = map_from_list_file(&reload_list); + + let current_active_units = get_active_units(&systemd)?; + + collect_unit_changes( + &toplevel, + scope, + &old_toplevel.join(scope.etc_dir()), + &toplevel.join(scope.etc_dir()), + ¤t_active_units, + &mut units_to_stop, + &mut units_to_start, + &mut units_to_reload, + &mut units_to_restart, + &mut units_to_skip, + &mut units_to_filter, + )?; + + let print_units = |verb: &str, units: &HashMap| { + if units.is_empty() { + return; + } + let mut names: Vec<&str> = units.keys().map(String::as_str).collect(); + names.sort_by_key(|n| n.to_lowercase()); + eprintln!("{verb} the following user units: {}", names.join(", ")); + }; + + if dry_run { + print_units("would stop", &units_to_stop); + if !units_to_skip.is_empty() { + print_units("would NOT restart", &units_to_skip); + } + print_units("would reload", &units_to_reload); + print_units("would restart", &units_to_restart); + print_units( + "would start", + &filter_units(&units_to_filter, &units_to_start), + ); + return Ok(()); + } + + let mut exit_code = 0; + + // Stop units before reexec so that ExecStop runs with the old definition. + print_units("stopping", &units_to_stop); + for unit in units_to_stop.keys() { + if let Ok(job_path) = systemd.stop_unit(unit, "replace") { + submitted_jobs.borrow_mut().insert(job_path, Job::Stop); + } + } + block_on_jobs(&dbus_conn, &submitted_jobs); + + if !units_to_skip.is_empty() { + print_units("NOT restarting", &units_to_skip); + } + // The systemd user session seems to not send a Reloaded signal, so we don't have anything to // wait on here. _ = systemd.reexecute(); - systemd - .restart_unit("nixos-activation.service", "replace") - .context("Failed to restart nixos-activation.service")?; + // Reset failed and reload so that subsequent starts use the new unit files. + _ = systemd.reset_failed(); + _ = systemd.reload(); - log::debug!("waiting for nixos activation to finish"); - while !*nixos_activation_done.borrow() { - _ = dbus_conn - .process(DBUS_PROCESS_TIME) - .context("Failed to process dbus messages")?; + print_units("reloading", &units_to_reload); + for unit in units_to_reload.keys() { + match systemd.reload_unit(unit, "replace") { + Ok(job_path) => { + submitted_jobs.borrow_mut().insert(job_path, Job::Reload); + } + Err(err) => { + eprintln!("Failed to reload user unit {unit}: {err}"); + exit_code = 4; + } + } + } + block_on_jobs(&dbus_conn, &submitted_jobs); + remove_file_if_exists(&reload_list) + .with_context(|| format!("Failed to remove {}", reload_list.display()))?; + + print_units("restarting", &units_to_restart); + for unit in units_to_restart.keys() { + match systemd.restart_unit(unit, "replace") { + Ok(job_path) => { + submitted_jobs.borrow_mut().insert(job_path, Job::Restart); + } + Err(err) => { + eprintln!("Failed to restart user unit {unit}: {err}"); + exit_code = 4; + } + } + } + block_on_jobs(&dbus_conn, &submitted_jobs); + remove_file_if_exists(&restart_list) + .with_context(|| format!("Failed to remove {}", restart_list.display()))?; + + let start_filtered = filter_units(&units_to_filter, &units_to_start); + print_units("starting", &start_filtered); + for unit in units_to_start.keys() { + match systemd.start_unit(unit, "replace") { + Ok(job_path) => { + submitted_jobs.borrow_mut().insert(job_path, Job::Start); + } + Err(err) => { + eprintln!("Failed to start user unit {unit}: {err}"); + exit_code = 4; + } + } + } + block_on_jobs(&dbus_conn, &submitted_jobs); + remove_file_if_exists(&start_list) + .with_context(|| format!("Failed to remove {}", start_list.display()))?; + + // Run per-user activation (home-manager etc.) after NixOS-level user units + // have been brought up to date. This matches the system → user layering. + // Toplevels with system.activatable = false do not ship this unit; mirror + // the system scope's tolerance for a missing activate script. + if toplevel + .join(scope.etc_dir()) + .join("nixos-activation.service") + .exists() + { + match systemd.restart_unit("nixos-activation.service", "replace") { + Ok(_) => { + log::debug!("waiting for nixos activation to finish"); + while !*nixos_activation_done.borrow() { + _ = dbus_conn + .process(DBUS_PROCESS_TIME) + .context("Failed to process dbus messages")?; + } + } + Err(err) => { + eprintln!("Failed to restart nixos-activation.service: {err}"); + exit_code = 4; + } + } + } + + let finished = finished_jobs.borrow(); + let mut failed_units = Vec::new(); + for (unit, job, result) in finished.values() { + if matches!(result.as_str(), "timeout" | "failed" | "dependency") { + eprintln!("Failed to {job} user unit {unit}"); + failed_units.push(unit.as_str()); + exit_code = 4; + } + } + if !failed_units.is_empty() { + failed_units.sort_by_key(|name| name.to_lowercase()); + eprintln!( + "warning: the following user units failed: {}\n\ + See `systemctl --user status {}` for details.", + failed_units.join(", "), + failed_units.join(" "), + ); } dbus_conn .remove_match(jobs_token) .context("Failed to remove jobs token")?; - Ok(()) + std::process::exit(exit_code); } fn usage(argv0: &str) -> ! { @@ -1227,6 +1425,12 @@ fn do_system_switch(action: Action) -> anyhow::Result<()> { let out = PathBuf::from(required_env("OUT")?); let toplevel = PathBuf::from(required_env("TOPLEVEL")?); + // Capture the old toplevel before the activation script updates + // /run/current-system. We pass this to the per-user switch child so it can + // compare old vs new user unit files after /etc has already been switched. + let old_toplevel = Path::new("/run/current-system") + .canonicalize() + .unwrap_or_else(|_| PathBuf::from("/run/current-system")); let distro_id = required_env("DISTRO_ID")?; let pre_switch_check = required_env("PRE_SWITCH_CHECK")?; let install_bootloader = required_env("INSTALL_BOOTLOADER")?; @@ -1870,16 +2074,23 @@ won't take effect until you reboot the system. .context("Failed to get full path to /proc/self/exe")?; log::debug!("Performing user switch for {name}"); - std::process::Command::new(&myself) + let status = std::process::Command::new(&myself) .uid(uid) .gid(gid) .env_clear() .env("XDG_RUNTIME_DIR", runtime_path) .env("__NIXOS_SWITCH_TO_CONFIGURATION_PARENT_EXE", &myself) + .env("TOPLEVEL", &toplevel) + .env("OLD_TOPLEVEL", &old_toplevel) + .env("NIXOS_ACTION", Into::<&'static str>::into(action)) .spawn() .with_context(|| format!("Failed to spawn user activation for {name}"))? .wait() .with_context(|| format!("Failed to run user activation for {name}"))?; + if !status.success() { + eprintln!("warning: user activation for {name} failed"); + exit_code = 4; + } } } }