Skip to content

Commit 3133e51

Browse files
say-paulclaude
andcommitted
feat: replace rollback_trigger with next-deployment-id for GRUB fallback detection
Replace the boolean `greenboot_rollback_trigger` GRUB variable with `greenboot_next_deployment_id`, which stores the staged deployment's ID (imageDigest for bootc, checksum for rpm-ostree). This serves a dual purpose: it acts as the rollback trigger and enables GRUB-level kernel fallback detection by comparing the stored ID against the actually booted deployment. Key changes: - grub.rs: replace set/get/unset_rollback_trigger with set/get/unset_next_deployment_id using new string-valued GRUB variable helpers (set_grub_str_var, get_grub_str_var) - handler.rs: add get_booted_deployment_id and get_staged_deployment_id to query deployment IDs from bootc or rpm-ostree; add force flag to handle_rollback to bypass boot_counter checks when making a GRUB fallback permanent - main.rs: add detect_grub_fallback() which compares stored vs booted deployment ID; on mismatch, make fallback permanent via forced rollback and clear next_deployment_id to prevent rollback loops; SetRollbackTrigger now queries the staged deployment ID and sets both next_deployment_id and fallback independently Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6abd866 commit 3133e51

3 files changed

Lines changed: 300 additions & 127 deletions

File tree

src/lib/grub.rs

Lines changed: 112 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -82,25 +82,6 @@ fn unset_boot_counter_at(grub_path: &str) -> Result<()> {
8282
unset_grub_var("boot_counter", grub_path)
8383
}
8484

85-
/// sets greenboot_rollback_trigger=1 and fallback=1
86-
pub fn set_rollback_trigger() -> Result<()> {
87-
set_rollback_trigger_at(GRUB_PATH)
88-
}
89-
90-
fn set_rollback_trigger_at(grub_path: &str) -> Result<()> {
91-
set_grub_var("greenboot_rollback_trigger", 1, grub_path)?;
92-
set_grub_var("fallback", 1, grub_path)
93-
}
94-
95-
/// unsets greenboot_rollback_trigger
96-
pub fn unset_rollback_trigger() -> Result<()> {
97-
unset_rollback_trigger_at(GRUB_PATH)
98-
}
99-
100-
fn unset_rollback_trigger_at(grub_path: &str) -> Result<()> {
101-
unset_grub_var("greenboot_rollback_trigger", grub_path)
102-
}
103-
10485
/// sets fallback=1 for GRUB-level kernel fallback protection
10586
pub fn set_fallback() -> Result<()> {
10687
set_fallback_at(GRUB_PATH)
@@ -128,13 +109,31 @@ fn get_fallback_at(grub_path: &str) -> Result<bool> {
128109
get_grub_bool_var("fallback", grub_path)
129110
}
130111

131-
/// gets greenboot_rollback_trigger value, returns true if set to 1
132-
pub fn get_rollback_trigger() -> Result<bool> {
133-
get_rollback_trigger_at(GRUB_PATH)
112+
/// sets greenboot_next_deployment_id to the given deployment ID string
113+
pub fn set_next_deployment_id(id: &str) -> Result<()> {
114+
set_next_deployment_id_at(id, GRUB_PATH)
134115
}
135116

136-
fn get_rollback_trigger_at(grub_path: &str) -> Result<bool> {
137-
get_grub_bool_var("greenboot_rollback_trigger", grub_path)
117+
fn set_next_deployment_id_at(id: &str, grub_path: &str) -> Result<()> {
118+
set_grub_str_var("greenboot_next_deployment_id", id, grub_path)
119+
}
120+
121+
/// unsets greenboot_next_deployment_id
122+
pub fn unset_next_deployment_id() -> Result<()> {
123+
unset_next_deployment_id_at(GRUB_PATH)
124+
}
125+
126+
fn unset_next_deployment_id_at(grub_path: &str) -> Result<()> {
127+
unset_grub_var("greenboot_next_deployment_id", grub_path)
128+
}
129+
130+
/// returns the stored deployment ID, or None if not set
131+
pub fn get_next_deployment_id() -> Result<Option<String>> {
132+
get_next_deployment_id_at(GRUB_PATH)
133+
}
134+
135+
fn get_next_deployment_id_at(grub_path: &str) -> Result<Option<String>> {
136+
get_grub_str_var("greenboot_next_deployment_id", grub_path)
138137
}
139138

140139
fn get_grub_bool_var(key: &str, grub_path: &str) -> Result<bool> {
@@ -187,12 +186,45 @@ fn set_grub_var(key: &str, val: u16, grub_path: &str) -> Result<()> {
187186
Ok(())
188187
}
189188

189+
fn set_grub_str_var(key: &str, val: &str, grub_path: &str) -> Result<()> {
190+
let grub_result = Command::new("grub2-editenv")
191+
.arg(grub_path)
192+
.arg("set")
193+
.arg(format!("{key}={val}"))
194+
.status()
195+
.context("Unable to set grubenv")?;
196+
197+
if !grub_result.success() {
198+
bail!("Failed to set grubenv key: {key}");
199+
}
200+
201+
log::info!("Set grubenv: {key}={val}");
202+
Ok(())
203+
}
204+
205+
fn get_grub_str_var(key: &str, grub_path: &str) -> Result<Option<String>> {
206+
let grub_vars = Command::new("grub2-editenv")
207+
.arg(grub_path)
208+
.arg("list")
209+
.output()
210+
.context(format!("Unable to list grubenv variables for key: {key}"))?;
211+
212+
let prefix = format!("{key}=");
213+
let output = String::from_utf8_lossy(&grub_vars.stdout);
214+
for line in output.lines() {
215+
if let Some(value) = line.strip_prefix(&prefix) {
216+
return Ok(Some(value.to_string()));
217+
}
218+
}
219+
Ok(None)
220+
}
221+
190222
#[cfg(test)]
191223
mod tests {
192224
use super::{
193-
get_boot_counter_at, get_fallback_at, get_rollback_trigger_at, set_boot_counter_at,
194-
set_fallback_at, set_rollback_trigger_at, unset_boot_counter_at, unset_fallback_at,
195-
unset_rollback_trigger_at,
225+
get_boot_counter_at, get_fallback_at, get_next_deployment_id_at, set_boot_counter_at,
226+
set_fallback_at, set_next_deployment_id_at, unset_boot_counter_at, unset_fallback_at,
227+
unset_next_deployment_id_at,
196228
};
197229
use anyhow::Context;
198230
use std::fs;
@@ -266,41 +298,66 @@ mod tests {
266298
}
267299

268300
#[test]
269-
fn test_rollback_trigger_functions() {
301+
fn test_next_deployment_id_set_and_get() {
270302
let (_temp_dir, grubenv) = setup_test_paths();
271303

272-
assert!(!get_rollback_trigger_at(&grubenv).unwrap());
273-
assert!(!get_fallback_at(&grubenv).unwrap());
304+
assert_eq!(get_next_deployment_id_at(&grubenv).unwrap(), None);
274305

275-
set_rollback_trigger_at(&grubenv).unwrap();
276-
assert!(get_rollback_trigger_at(&grubenv).unwrap());
277-
assert!(get_fallback_at(&grubenv).unwrap());
306+
let id = "sha256:abc123def456";
307+
set_next_deployment_id_at(id, &grubenv).unwrap();
308+
assert_eq!(
309+
get_next_deployment_id_at(&grubenv).unwrap(),
310+
Some(id.to_string())
311+
);
312+
}
278313

279-
// unset_rollback_trigger only clears the trigger, not fallback
280-
unset_rollback_trigger_at(&grubenv).unwrap();
281-
assert!(!get_rollback_trigger_at(&grubenv).unwrap());
282-
assert!(get_fallback_at(&grubenv).unwrap());
314+
#[test]
315+
fn test_next_deployment_id_unset() {
316+
let (_temp_dir, grubenv) = setup_test_paths();
283317

284-
// fallback has its own lifecycle, unset independently
285-
unset_fallback_at(&grubenv).unwrap();
286-
assert!(!get_fallback_at(&grubenv).unwrap());
318+
let id = "sha256:abc123def456";
319+
set_next_deployment_id_at(id, &grubenv).unwrap();
320+
assert!(get_next_deployment_id_at(&grubenv).unwrap().is_some());
321+
322+
unset_next_deployment_id_at(&grubenv).unwrap();
323+
assert_eq!(get_next_deployment_id_at(&grubenv).unwrap(), None);
287324
}
288325

289326
#[test]
290-
fn test_rollback_trigger_with_other_vars() {
327+
fn test_next_deployment_id_coexists_with_boot_counter() {
291328
let (_temp_dir, grubenv) = setup_test_paths();
292329

330+
let id = "sha256:abc123def456";
293331
set_boot_counter_at(3, &grubenv).unwrap();
294-
set_rollback_trigger_at(&grubenv).unwrap();
332+
set_next_deployment_id_at(id, &grubenv).unwrap();
295333

296334
assert_eq!(get_boot_counter_at(&grubenv).unwrap(), Some(3));
297-
assert!(get_rollback_trigger_at(&grubenv).unwrap());
298-
assert!(get_fallback_at(&grubenv).unwrap());
335+
assert_eq!(
336+
get_next_deployment_id_at(&grubenv).unwrap(),
337+
Some(id.to_string())
338+
);
299339

300-
// unset_rollback_trigger leaves boot_counter and fallback intact
301-
unset_rollback_trigger_at(&grubenv).unwrap();
340+
unset_next_deployment_id_at(&grubenv).unwrap();
302341
assert_eq!(get_boot_counter_at(&grubenv).unwrap(), Some(3));
303-
assert!(!get_rollback_trigger_at(&grubenv).unwrap());
342+
assert_eq!(get_next_deployment_id_at(&grubenv).unwrap(), None);
343+
}
344+
345+
#[test]
346+
fn test_next_deployment_id_coexists_with_fallback() {
347+
let (_temp_dir, grubenv) = setup_test_paths();
348+
349+
let id = "sha256:abc123def456";
350+
set_next_deployment_id_at(id, &grubenv).unwrap();
351+
set_fallback_at(&grubenv).unwrap();
352+
353+
assert_eq!(
354+
get_next_deployment_id_at(&grubenv).unwrap(),
355+
Some(id.to_string())
356+
);
357+
assert!(get_fallback_at(&grubenv).unwrap());
358+
359+
unset_next_deployment_id_at(&grubenv).unwrap();
360+
assert_eq!(get_next_deployment_id_at(&grubenv).unwrap(), None);
304361
assert!(get_fallback_at(&grubenv).unwrap());
305362
}
306363

@@ -333,36 +390,30 @@ mod tests {
333390
assert!(!get_fallback_at(&grubenv).unwrap());
334391
}
335392

336-
#[test]
337-
fn test_fallback_set_via_rollback_trigger() {
338-
let (_temp_dir, grubenv) = setup_test_paths();
339-
340-
set_rollback_trigger_at(&grubenv).unwrap();
341-
assert!(get_fallback_at(&grubenv).unwrap());
342-
assert!(get_rollback_trigger_at(&grubenv).unwrap());
343-
}
344-
345393
#[test]
346394
fn test_fallback_coexists_with_boot_counter() {
347395
let (_temp_dir, grubenv) = setup_test_paths();
348396

349397
set_boot_counter_at(3, &grubenv).unwrap();
350-
set_rollback_trigger_at(&grubenv).unwrap();
398+
set_fallback_at(&grubenv).unwrap();
351399

352400
assert_eq!(get_boot_counter_at(&grubenv).unwrap(), Some(3));
353401
assert!(get_fallback_at(&grubenv).unwrap());
354-
assert!(get_rollback_trigger_at(&grubenv).unwrap());
355402
}
356403

357404
#[test]
358-
fn test_fallback_independent_unset() {
405+
fn test_fallback_independent_of_deployment_id() {
359406
let (_temp_dir, grubenv) = setup_test_paths();
360407

361-
set_rollback_trigger_at(&grubenv).unwrap();
408+
let id = "sha256:abc123def456";
409+
set_next_deployment_id_at(id, &grubenv).unwrap();
410+
set_fallback_at(&grubenv).unwrap();
411+
412+
unset_next_deployment_id_at(&grubenv).unwrap();
413+
assert_eq!(get_next_deployment_id_at(&grubenv).unwrap(), None);
362414
assert!(get_fallback_at(&grubenv).unwrap());
363415

364416
unset_fallback_at(&grubenv).unwrap();
365417
assert!(!get_fallback_at(&grubenv).unwrap());
366-
assert!(get_rollback_trigger_at(&grubenv).unwrap());
367418
}
368419
}

src/lib/handler.rs

Lines changed: 106 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,78 @@ pub fn detect_os_deployment() -> Option<&'static str> {
5252
}
5353
}
5454

55+
/// Returns the deployment ID of the currently booted deployment, or None
56+
/// if not on an ostree-based system or if the query fails.
57+
pub fn get_booted_deployment_id() -> Option<String> {
58+
match detect_os_deployment() {
59+
Some("bootc") => get_bootc_deployment_id("booted"),
60+
Some("rpm-ostree") => get_rpm_ostree_deployment_id("booted"),
61+
_ => None,
62+
}
63+
}
64+
65+
/// Returns the deployment ID of the staged (pending) deployment, or None
66+
/// if not on an ostree-based system or if no deployment is staged.
67+
pub fn get_staged_deployment_id() -> Option<String> {
68+
match detect_os_deployment() {
69+
Some("bootc") => get_bootc_deployment_id("staged"),
70+
Some("rpm-ostree") => get_rpm_ostree_deployment_id("staged"),
71+
_ => None,
72+
}
73+
}
74+
75+
fn get_bootc_deployment_id(key: &str) -> Option<String> {
76+
let mut args = vec!["status", "--json"];
77+
if key == "booted" {
78+
args.insert(1, "--booted");
79+
}
80+
81+
let output = Command::new("bootc").args(&args).output().ok()?;
82+
83+
if !output.status.success() {
84+
log::warn!("Error parsing bootc status");
85+
return None;
86+
}
87+
88+
let json: Value = serde_json::from_slice(&output.stdout).ok()?;
89+
90+
json.get("status")
91+
.and_then(|s| s.get(key))
92+
.and_then(|d| d.get("image"))
93+
.and_then(|i| i.get("imageDigest"))
94+
.and_then(|d| d.as_str())
95+
.map(|s| s.to_string())
96+
}
97+
98+
fn get_rpm_ostree_deployment_id(key: &str) -> Option<String> {
99+
let output = Command::new("rpm-ostree")
100+
.args(["status", "--json"])
101+
.output()
102+
.ok()?;
103+
104+
if !output.status.success() {
105+
log::warn!("Error parsing rpmostree status");
106+
return None;
107+
}
108+
109+
let json: Value = serde_json::from_slice(&output.stdout).ok()?;
110+
let deployments = json.get("deployments")?.as_array()?;
111+
112+
for deployment in deployments {
113+
if deployment
114+
.get(key)
115+
.and_then(|v| v.as_bool())
116+
.unwrap_or(false)
117+
{
118+
return deployment
119+
.get("checksum")
120+
.and_then(|c| c.as_str())
121+
.map(|s| s.to_string());
122+
}
123+
}
124+
None
125+
}
126+
55127
/// reboots the system if boot_counter is greater than 0 or can be forced too
56128
pub fn handle_reboot(force: bool) -> Result<()> {
57129
if !force {
@@ -66,39 +138,44 @@ pub fn handle_reboot(force: bool) -> Result<()> {
66138
}
67139

68140
/// Rollback to the previous deployment if the boot counter allows.
69-
pub fn handle_rollback() -> Result<()> {
70-
let boot_counter = get_boot_counter()?;
141+
/// When `force` is true, bypass the boot_counter check entirely
142+
/// (used when GRUB kernel fallback is detected and rollback must be made permanent).
143+
pub fn handle_rollback(force: bool) -> Result<()> {
144+
if !force {
145+
let boot_counter = get_boot_counter()?;
71146

72-
match boot_counter {
73-
// Exit early if boot_counter is not set
74-
None => {
75-
bail!("System is unhealthy but boot_counter is not set, manual intervention required")
76-
}
77-
// Proceed with rollback if boot_counter is <= 0
78-
Some(counter) if counter <= 0 => {
79-
log::info!("Greenboot will now attempt to rollback to a previous deployment.");
80-
if let Some(deployment_cmd) = detect_os_deployment() {
81-
log::info!("Deployment manager '{deployment_cmd}' detected, attempting rollback.");
82-
let status = Command::new(deployment_cmd)
83-
.arg("rollback")
84-
.status()
85-
.context(format!("Failed to execute '{deployment_cmd} rollback'"))?;
86-
87-
if !status.success() {
88-
bail!(
89-
"Rollback with '{}' failed with status: {}",
90-
deployment_cmd,
91-
status
92-
);
93-
}
94-
} else {
95-
bail!("Rollback only supported in bootc or rpm-ostree environment.");
147+
match boot_counter {
148+
None => {
149+
bail!(
150+
"System is unhealthy but boot_counter is not set, manual intervention required"
151+
)
152+
}
153+
Some(counter) if counter > 0 => {
154+
bail!("Rollback not initiated as boot_counter is {}", counter)
96155
}
97-
Ok(())
156+
_ => {}
98157
}
99-
// Reject if boot_counter is > 0
100-
Some(counter) => bail!("Rollback not initiated as boot_counter is {}", counter),
101158
}
159+
160+
log::info!("Greenboot will now attempt to rollback to a previous deployment.");
161+
if let Some(deployment_cmd) = detect_os_deployment() {
162+
log::info!("Deployment manager '{deployment_cmd}' detected, attempting rollback.");
163+
let status = Command::new(deployment_cmd)
164+
.arg("rollback")
165+
.status()
166+
.context(format!("Failed to execute '{deployment_cmd} rollback'"))?;
167+
168+
if !status.success() {
169+
bail!(
170+
"Rollback with '{}' failed with status: {}",
171+
deployment_cmd,
172+
status
173+
);
174+
}
175+
} else {
176+
bail!("Rollback only supported in bootc or rpm-ostree environment.");
177+
}
178+
Ok(())
102179
}
103180

104181
/// writes greenboot status to motd.d/boot-status

0 commit comments

Comments
 (0)