Skip to content

Commit f5256e5

Browse files
committed
nixos: implement rollbacks
1 parent 7c91389 commit f5256e5

File tree

3 files changed

+322
-10
lines changed

3 files changed

+322
-10
lines changed

src/generations.rs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::process;
66
use chrono::{DateTime, Local, TimeZone, Utc};
77
use tracing::debug;
88

9-
#[derive(Debug)]
9+
#[derive(Debug, Clone)]
1010
pub struct GenerationInfo {
1111
/// Number of a generation
1212
pub number: String,
@@ -122,12 +122,44 @@ pub fn describe(generation_dir: &Path, current_profile: &Path) -> Option<Generat
122122
}
123123
};
124124

125-
let canonical_gen_dir = generation_dir.canonicalize().ok()?;
126-
let current = current_profile
127-
.canonicalize()
125+
// Check if this generation is the current one
126+
let run_current_target = match fs::read_link("/run/current-system")
127+
.ok()
128+
.and_then(|p| fs::canonicalize(p).ok())
129+
{
130+
Some(path) => path,
131+
None => {
132+
return Some(GenerationInfo {
133+
number: generation_number.to_string(),
134+
date: build_date,
135+
nixos_version,
136+
kernel_version,
137+
configuration_revision,
138+
specialisations,
139+
current: false,
140+
})
141+
}
142+
};
143+
144+
let gen_store_path = match fs::read_link(generation_dir)
128145
.ok()
129-
.map(|canonical_current| canonical_gen_dir == canonical_current)
130-
.unwrap_or(false);
146+
.and_then(|p| fs::canonicalize(p).ok())
147+
{
148+
Some(path) => path,
149+
None => {
150+
return Some(GenerationInfo {
151+
number: generation_number.to_string(),
152+
date: build_date,
153+
nixos_version,
154+
kernel_version,
155+
configuration_revision,
156+
specialisations,
157+
current: false,
158+
})
159+
}
160+
};
161+
162+
let current = run_current_target == gen_store_path;
131163

132164
Some(GenerationInfo {
133165
number: generation_number.to_string(),
@@ -174,9 +206,8 @@ pub fn print_info(mut generations: Vec<GenerationInfo>) {
174206

175207
// Sort generations by numeric value of the generation number
176208
generations.sort_by_key(|gen| gen.number.parse::<u64>().unwrap_or(0));
177-
let current_generation = generations
178-
.iter()
179-
.max_by_key(|gen| gen.number.parse::<u64>().unwrap_or(0));
209+
210+
let current_generation = generations.iter().find(|gen| gen.current);
180211
debug!(?current_generation);
181212

182213
if let Some(current) = current_generation {

src/interface.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ pub enum OsSubcommand {
103103

104104
/// List available generations from profile path
105105
Info(OsGenerationsArgs),
106+
107+
/// Rollback to a previous generation
108+
Rollback(OsRollbackArgs),
106109
}
107110

108111
#[derive(Debug, Args)]
@@ -134,6 +137,33 @@ pub struct OsRebuildArgs {
134137
pub bypass_root_check: bool,
135138
}
136139

140+
#[derive(Debug, Args)]
141+
pub struct OsRollbackArgs {
142+
/// Only print actions, without performing them
143+
#[arg(long, short = 'n')]
144+
pub dry: bool,
145+
146+
/// Ask for confirmation
147+
#[arg(long, short)]
148+
pub ask: bool,
149+
150+
/// Explicitly select some specialisation
151+
#[arg(long, short)]
152+
pub specialisation: Option<String>,
153+
154+
/// Ignore specialisations
155+
#[arg(long, short = 'S')]
156+
pub no_specialisation: bool,
157+
158+
/// Rollback to a specific generation number (defaults to previous generation)
159+
#[arg(long, short)]
160+
pub to: Option<u64>,
161+
162+
/// Don't panic if calling nh as root
163+
#[arg(short = 'R', long, env = "NH_BYPASS_ROOT_CHECK")]
164+
pub bypass_root_check: bool,
165+
}
166+
137167
#[derive(Debug, Args)]
138168
pub struct CommonRebuildArgs {
139169
/// Only print actions, without performing them

src/nixos.rs

Lines changed: 252 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::env;
22
use std::fs;
3+
use std::os::unix::fs::symlink;
34
use std::path::{Path, PathBuf};
45

56
use color_eyre::eyre::{bail, Context};
@@ -11,7 +12,7 @@ use crate::commands::Command;
1112
use crate::generations;
1213
use crate::installable::Installable;
1314
use crate::interface::OsSubcommand::{self};
14-
use crate::interface::{self, OsGenerationsArgs, OsRebuildArgs, OsReplArgs};
15+
use crate::interface::{self, OsGenerationsArgs, OsRebuildArgs, OsReplArgs, OsRollbackArgs};
1516
use crate::update::update;
1617
use crate::util::get_hostname;
1718

@@ -35,6 +36,7 @@ impl interface::OsArgs {
3536
}
3637
OsSubcommand::Repl(args) => args.run(),
3738
OsSubcommand::Info(args) => args.info(),
39+
OsSubcommand::Rollback(args) => args.rollback(),
3840
}
3941
}
4042
}
@@ -187,6 +189,255 @@ impl OsRebuildArgs {
187189
}
188190
}
189191

192+
impl OsRollbackArgs {
193+
fn rollback(&self) -> Result<()> {
194+
let elevate = if self.bypass_root_check {
195+
warn!("Bypassing root check, now running nix as root");
196+
false
197+
} else {
198+
if nix::unistd::Uid::effective().is_root() {
199+
bail!("Don't run nh os as root. I will call sudo internally as needed");
200+
}
201+
true
202+
};
203+
204+
// Find previous generation or specific generation
205+
let target_generation = if let Some(gen_number) = self.to {
206+
find_generation_by_number(gen_number)?
207+
} else {
208+
find_previous_generation()?
209+
};
210+
211+
info!("Rolling back to generation {}", target_generation.number);
212+
213+
// Construct path to the generation
214+
let profile_dir = Path::new(SYSTEM_PROFILE)
215+
.parent()
216+
.unwrap_or(Path::new("/nix/var/nix/profiles"));
217+
let generation_link = profile_dir.join(format!("system-{}-link", target_generation.number));
218+
219+
// Handle specialisations
220+
let current_specialisation = fs::read_to_string(SPEC_LOCATION).ok();
221+
222+
let target_specialisation = if self.no_specialisation {
223+
None
224+
} else {
225+
self.specialisation.clone().or(current_specialisation)
226+
};
227+
228+
debug!("target_specialisation: {target_specialisation:?}");
229+
230+
// Compare changes between current and target generation
231+
Command::new("nvd")
232+
.arg("diff")
233+
.arg(CURRENT_PROFILE)
234+
.arg(&generation_link)
235+
.message("Comparing changes")
236+
.run()?;
237+
238+
if self.dry {
239+
info!(
240+
"Dry run: would roll back to generation {}",
241+
target_generation.number
242+
);
243+
return Ok(());
244+
}
245+
246+
if self.ask {
247+
info!("Roll back to generation {}?", target_generation.number);
248+
let confirmation = dialoguer::Confirm::new().default(false).interact()?;
249+
250+
if !confirmation {
251+
bail!("User rejected the rollback");
252+
}
253+
}
254+
255+
// Get current generation number for potential rollback
256+
let current_gen_number = match get_current_generation_number() {
257+
Ok(num) => num,
258+
Err(e) => {
259+
warn!("Failed to get current generation number: {}", e);
260+
0
261+
}
262+
};
263+
264+
// Set the system profile
265+
info!("Setting system profile...");
266+
267+
// Instead of direct symlink operations, use a command with proper elevation
268+
Command::new("ln")
269+
.arg("-sfn") // force, symbolic link
270+
.arg(&generation_link)
271+
.arg(SYSTEM_PROFILE)
272+
.elevate(elevate)
273+
.message("Setting system profile")
274+
.run()?;
275+
276+
// Set up rollback protection flag
277+
let mut rollback_profile = false;
278+
279+
// Determine the correct profile to use with specialisations
280+
let final_profile = match &target_specialisation {
281+
None => generation_link,
282+
Some(spec) => {
283+
let spec_path = generation_link.join("specialisation").join(spec);
284+
if !spec_path.exists() {
285+
warn!(
286+
"Specialisation '{}' does not exist in generation {}",
287+
spec, target_generation.number
288+
);
289+
warn!("Using base configuration without specialisations");
290+
generation_link
291+
} else {
292+
spec_path
293+
}
294+
}
295+
};
296+
297+
// Activate the configuration
298+
info!("Activating...");
299+
300+
let switch_to_configuration = final_profile.join("bin").join("switch-to-configuration");
301+
302+
match Command::new(&switch_to_configuration)
303+
.arg("switch")
304+
.elevate(elevate)
305+
.run()
306+
{
307+
Ok(_) => {
308+
info!(
309+
"Successfully rolled back to generation {}",
310+
target_generation.number
311+
);
312+
}
313+
Err(e) => {
314+
rollback_profile = true;
315+
316+
// If activation fails, rollback the profile
317+
if rollback_profile && current_gen_number > 0 {
318+
let current_gen_link =
319+
profile_dir.join(format!("system-{}-link", current_gen_number));
320+
321+
Command::new("ln")
322+
.arg("-sfn") // Force, symbolic link
323+
.arg(&current_gen_link)
324+
.arg(SYSTEM_PROFILE)
325+
.elevate(elevate)
326+
.message("Rolling back system profile")
327+
.run()?;
328+
}
329+
330+
return Err(e).context("Failed to activate configuration");
331+
}
332+
}
333+
334+
Ok(())
335+
}
336+
}
337+
338+
fn find_previous_generation() -> Result<generations::GenerationInfo> {
339+
let profile_path = PathBuf::from(SYSTEM_PROFILE);
340+
341+
let mut generations: Vec<generations::GenerationInfo> = fs::read_dir(
342+
profile_path
343+
.parent()
344+
.unwrap_or(Path::new("/nix/var/nix/profiles")),
345+
)?
346+
.filter_map(|entry| {
347+
entry.ok().and_then(|e| {
348+
let path = e.path();
349+
if let Some(filename) = path.file_name() {
350+
if let Some(name) = filename.to_str() {
351+
if name.starts_with("system-") && name.ends_with("-link") {
352+
return generations::describe(&path, &profile_path);
353+
}
354+
}
355+
}
356+
None
357+
})
358+
})
359+
.collect();
360+
361+
if generations.is_empty() {
362+
bail!("No generations found");
363+
}
364+
365+
generations.sort_by(|a, b| {
366+
a.number
367+
.parse::<u64>()
368+
.unwrap_or(0)
369+
.cmp(&b.number.parse::<u64>().unwrap_or(0))
370+
});
371+
372+
let current_idx = generations
373+
.iter()
374+
.position(|g| g.current)
375+
.ok_or_else(|| eyre!("Current generation not found"))?;
376+
377+
if current_idx == 0 {
378+
bail!("No generation older than the current one exists");
379+
}
380+
381+
Ok(generations[current_idx - 1].clone())
382+
}
383+
384+
fn find_generation_by_number(number: u64) -> Result<generations::GenerationInfo> {
385+
let profile_path = PathBuf::from(SYSTEM_PROFILE);
386+
387+
let generations: Vec<generations::GenerationInfo> = fs::read_dir(
388+
profile_path
389+
.parent()
390+
.unwrap_or(Path::new("/nix/var/nix/profiles")),
391+
)?
392+
.filter_map(|entry| {
393+
entry.ok().and_then(|e| {
394+
let path = e.path();
395+
if let Some(filename) = path.file_name() {
396+
if let Some(name) = filename.to_str() {
397+
if name.starts_with("system-") && name.ends_with("-link") {
398+
return generations::describe(&path, &profile_path);
399+
}
400+
}
401+
}
402+
None
403+
})
404+
})
405+
.filter(|gen| gen.number == number.to_string())
406+
.collect();
407+
408+
if generations.is_empty() {
409+
bail!("Generation {} not found", number);
410+
}
411+
412+
Ok(generations[0].clone())
413+
}
414+
415+
fn get_current_generation_number() -> Result<u64> {
416+
let profile_path = PathBuf::from(SYSTEM_PROFILE);
417+
418+
let generations: Vec<generations::GenerationInfo> = fs::read_dir(
419+
profile_path
420+
.parent()
421+
.unwrap_or(Path::new("/nix/var/nix/profiles")),
422+
)?
423+
.filter_map(|entry| {
424+
entry
425+
.ok()
426+
.and_then(|e| generations::describe(&e.path(), &profile_path))
427+
})
428+
.collect();
429+
430+
let current_gen = generations
431+
.iter()
432+
.find(|g| g.current)
433+
.ok_or_else(|| eyre!("Current generation not found"))?;
434+
435+
current_gen
436+
.number
437+
.parse::<u64>()
438+
.map_err(|_| eyre!("Invalid generation number"))
439+
}
440+
190441
pub fn toplevel_for<S: AsRef<str>>(hostname: S, installable: Installable) -> Installable {
191442
let mut res = installable;
192443
let hostname = hostname.as_ref().to_owned();

0 commit comments

Comments
 (0)