use std::io;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use sysknife_daemon::dispatcher::connection_handler_with_executor;
use sysknife_daemon::executor::{ActionExecutor, ExecutionOutput, ExecutorError};
use sysknife_daemon::state::{DaemonConfig, DaemonState};
use sysknife_daemon::state_collector::CommandRunner;
use sysknife_daemon::transport::{framing::FramedStream, listen::ListenTarget};
use sysknife_types::CallerRole;
use tempfile::tempdir;
use tokio::net::UnixStream;
struct MockRunner;
impl CommandRunner for MockRunner {
fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
match program {
"hostname" => Ok("rollback-test-host\n".to_string()),
"rpm-ostree" => Ok("{}".to_string()),
_ => Ok(String::new()),
}
}
}
struct FailThenRollbackExecutor;
#[async_trait]
impl ActionExecutor for FailThenRollbackExecutor {
async fn execute(
&self,
spec: &sysknife_daemon::actions::ActionSpec,
) -> Result<ExecutionOutput, ExecutorError> {
match spec.action_name {
"RollbackDeployment" => Ok(ExecutionOutput {
stdout: "Rollback successful\n".to_string(),
stderr: String::new(),
exit_code: 0,
}),
_ => Ok(ExecutionOutput {
stdout: String::new(),
stderr: "mock: unexpected action".to_string(),
exit_code: 99,
}),
}
}
}
struct FailBothExecutor;
#[async_trait]
impl ActionExecutor for FailBothExecutor {
async fn execute(
&self,
_spec: &sysknife_daemon::actions::ActionSpec,
) -> Result<ExecutionOutput, ExecutorError> {
Ok(ExecutionOutput {
stdout: String::new(),
stderr: "mock: command failed".to_string(),
exit_code: 1,
})
}
}
async fn spawn_handler_with_executor(
state: DaemonState,
executor: Arc<dyn ActionExecutor>,
) -> FramedStream<UnixStream> {
let (client, server) = UnixStream::pair().unwrap();
let runner: Arc<dyn CommandRunner + Send + Sync> = Arc::new(MockRunner);
tokio::spawn(async move {
connection_handler_with_executor(
server,
state,
runner,
executor,
sysknife_daemon::auth::CallerAttribution::from_peer_uid(1000, CallerRole::Admin),
)
.await;
});
FramedStream::new(client)
}
fn test_state(dir: &tempfile::TempDir) -> DaemonState {
let db_path = dir.path().join("rollback-test.db");
let sock_path = dir.path().join("rollback-test.sock");
let config = DaemonConfig::new(ListenTarget::Unix(sock_path), db_path);
let mut state = DaemonState::open(config).unwrap();
state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 });
state
}
async fn preview_update_system(framed: &mut FramedStream<UnixStream>) -> (String, String) {
let preview_req = json!({
"type": "preview",
"request_id": "rollback-preview",
"action_name": "UpdateSystem",
"params": {}
});
framed
.send(&serde_json::to_vec(&preview_req).unwrap())
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let resp: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(
resp["type"], "preview_response",
"expected preview_response, got: {resp}"
);
let transaction_id = resp["transaction_id"].as_str().unwrap().to_string();
let approve_req = json!({
"type": "approve",
"request_id": "rollback-approve",
"transaction_id": transaction_id,
});
framed
.send(&serde_json::to_vec(&approve_req).unwrap())
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let approval: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(approval["type"], "approval_response");
(
transaction_id,
approval["approval_receipt"].as_str().unwrap().to_string(),
)
}
async fn execute_update_system(
framed: &mut FramedStream<UnixStream>,
transaction_id: &str,
approval_receipt: &str,
) {
let exec_req = json!({
"type": "execute",
"request_id": "rollback-execute",
"transaction_id": transaction_id,
"action_name": "UpdateSystem",
"params": {},
"approval_receipt": approval_receipt
});
framed
.send(&serde_json::to_vec(&exec_req).unwrap())
.await
.unwrap();
}
async fn drain_until_completed(framed: &mut FramedStream<UnixStream>) -> (Vec<Value>, Value) {
let mut messages = Vec::new();
loop {
let raw = framed.recv().await.unwrap();
let msg: Value = serde_json::from_slice(&raw).unwrap();
let msg_type = msg["type"].as_str().unwrap().to_string();
messages.push(msg.clone());
if msg_type == "job_completed" {
return (messages, msg);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_update_system_triggers_automatic_rollback() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let executor: Arc<dyn ActionExecutor> = Arc::new(FailThenRollbackExecutor);
let mut framed = spawn_handler_with_executor(state, executor).await;
let (transaction_id, receipt) = preview_update_system(&mut framed).await;
execute_update_system(&mut framed, &transaction_id, &receipt).await;
let (messages, completed) = drain_until_completed(&mut framed).await;
assert_eq!(
messages[0]["type"], "job_started",
"first frame must be job_started, got: {:?}",
messages[0]
);
let status = completed["result"]["status"].as_str().unwrap();
assert_eq!(
status, "rolled_back",
"expected rolled_back status, got: {status}; full message: {completed}"
);
let rollback_ref = completed["result"]["rollback_ref"].as_str().unwrap();
assert_eq!(
rollback_ref, "rpm-ostree rollback",
"rollback_ref should indicate the rollback command used"
);
let progress_lines: Vec<&str> = messages
.iter()
.filter(|m| m["type"] == "job_progress")
.filter_map(|m| m["line"].as_str())
.collect();
assert!(
progress_lines
.iter()
.any(|l| l.contains("attempting automatic rollback")),
"progress should announce rollback attempt; got lines: {progress_lines:?}"
);
assert!(
progress_lines
.iter()
.any(|l| l.contains("Rollback succeeded")),
"progress should announce rollback success; got lines: {progress_lines:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rollback_updates_transaction_store_to_rolled_back() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let store = std::sync::Arc::clone(&state.audit);
let executor: Arc<dyn ActionExecutor> = Arc::new(FailThenRollbackExecutor);
let mut framed = spawn_handler_with_executor(state, executor).await;
let (transaction_id, receipt) = preview_update_system(&mut framed).await;
execute_update_system(&mut framed, &transaction_id, &receipt).await;
let (messages, _completed) = drain_until_completed(&mut framed).await;
let transaction_id = messages[0]["transaction_id"]
.as_str()
.expect("job_started must contain transaction_id")
.to_string();
let tx = store.get(&transaction_id).await.unwrap().unwrap();
assert_eq!(
tx.status,
sysknife_types::JobState::RolledBack,
"transaction store must record RolledBack; got: {:?}",
tx.status
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_rollback_leaves_status_as_failed() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let executor: Arc<dyn ActionExecutor> = Arc::new(FailBothExecutor);
let mut framed = spawn_handler_with_executor(state, executor).await;
let (transaction_id, receipt) = preview_update_system(&mut framed).await;
execute_update_system(&mut framed, &transaction_id, &receipt).await;
let (messages, completed) = drain_until_completed(&mut framed).await;
let status = completed["result"]["status"].as_str().unwrap();
assert_eq!(
status, "failed",
"when rollback also fails, status must remain 'failed'; got: {status}"
);
assert!(
completed["result"]["rollback_ref"].is_null(),
"rollback_ref must be null when rollback fails; got: {:?}",
completed["result"]["rollback_ref"]
);
let progress_lines: Vec<&str> = messages
.iter()
.filter(|m| m["type"] == "job_progress")
.filter_map(|m| m["line"].as_str())
.collect();
assert!(
progress_lines
.iter()
.any(|l| l.contains("Rollback also failed")),
"progress should announce rollback failure; got lines: {progress_lines:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn non_rollbackable_action_does_not_trigger_rollback() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let executor: Arc<dyn ActionExecutor> = Arc::new(FailBothExecutor);
let mut framed = spawn_handler_with_executor(state, executor).await;
let preview_req = json!({
"type": "preview",
"request_id": "no-rollback-preview",
"action_name": "GetSystemState",
"params": {}
});
framed
.send(&serde_json::to_vec(&preview_req).unwrap())
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let resp: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(resp["type"], "preview_response");
let transaction_id = resp["transaction_id"].as_str().unwrap().to_string();
framed
.send(
&serde_json::to_vec(&json!({
"type": "approve",
"request_id": "no-rollback-approve",
"transaction_id": transaction_id,
}))
.unwrap(),
)
.await
.unwrap();
let approval: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
let receipt = approval["approval_receipt"].as_str().unwrap();
let exec_req = json!({
"type": "execute",
"request_id": "no-rollback-execute",
"transaction_id": transaction_id,
"action_name": "GetSystemState",
"params": {},
"approval_receipt": receipt
});
framed
.send(&serde_json::to_vec(&exec_req).unwrap())
.await
.unwrap();
let (_messages, completed) = drain_until_completed(&mut framed).await;
let status = completed["result"]["status"].as_str().unwrap();
assert_ne!(
status, "rolled_back",
"non-rollbackable action must never produce rolled_back status"
);
assert!(
completed["result"]["rollback_ref"].is_null(),
"rollback_ref must be null for non-rollbackable actions"
);
}
struct NotStoppedExecutor {
rollbacks: std::sync::atomic::AtomicUsize,
}
#[async_trait]
impl ActionExecutor for NotStoppedExecutor {
async fn execute(
&self,
spec: &sysknife_daemon::actions::ActionSpec,
) -> Result<ExecutionOutput, ExecutorError> {
if spec.action_name == "RollbackDeployment" {
self.rollbacks
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return Ok(ExecutionOutput {
stdout: "Rollback successful\n".to_string(),
stderr: String::new(),
exit_code: 0,
});
}
Err(ExecutorError::ActionNotStopped {
program: "rpm-ostree".to_string(),
pgid: 4242,
timeout_secs: 7200,
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_action_that_could_not_be_stopped_is_not_rolled_back_over_end_to_end() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let counting = Arc::new(NotStoppedExecutor {
rollbacks: std::sync::atomic::AtomicUsize::new(0),
});
let executor: Arc<dyn ActionExecutor> = counting.clone();
let mut framed = spawn_handler_with_executor(state, executor).await;
let (transaction_id, receipt) = preview_update_system(&mut framed).await;
execute_update_system(&mut framed, &transaction_id, &receipt).await;
let (messages, completed) = drain_until_completed(&mut framed).await;
assert_eq!(
counting.rollbacks.load(std::sync::atomic::Ordering::SeqCst),
0,
"no rollback may run while the original action may still be in flight"
);
let status = completed["result"]["status"].as_str().unwrap();
assert_eq!(
status, "failed",
"the job stays failed, not rolled_back; got: {status}"
);
assert!(
completed["result"]["rollback_ref"].is_null(),
"no rollback happened, so there is no rollback_ref"
);
let progress: Vec<&str> = messages
.iter()
.filter(|m| m["type"] == "job_progress")
.filter_map(|m| m["line"].as_str())
.collect();
assert!(
progress
.iter()
.any(|l| l.contains("could not be confirmed stopped")
&& l.contains("Inspect the host")),
"the operator must be told the rollback was skipped and to inspect the host; got: {progress:?}"
);
}