Fix systemd unloaded unit
This commit is contained in:
+142
-1
@@ -315,6 +315,9 @@ pub struct RestartDiagnostic {
|
||||
pub struct RestartReport {
|
||||
pub stage: RestartStage,
|
||||
pub failure: Option<RestartFailureKind>,
|
||||
pub service_failure: Option<service::RestartFailure>,
|
||||
pub service_phase: Option<service::RestartPhase>,
|
||||
pub systemd_unit: Option<String>,
|
||||
pub backup_path: Option<std::path::PathBuf>,
|
||||
pub restart_submitted: bool,
|
||||
pub restart_may_still_complete: bool,
|
||||
@@ -1713,6 +1716,7 @@ impl Application {
|
||||
let execution = service(&settings.systemd_unit, deadline);
|
||||
let job = execution.outcome;
|
||||
let submitted = execution.submitted;
|
||||
let service_phase = execution.phase;
|
||||
if job != RestartOutcome::Succeeded {
|
||||
self.active_restart_attempt = None;
|
||||
let may_complete = submitted
|
||||
@@ -1727,7 +1731,7 @@ impl Application {
|
||||
| service::RestartFailure::JobResultUnknown
|
||||
)
|
||||
);
|
||||
return restart_report(
|
||||
let mut report = restart_report(
|
||||
RestartStage::Service,
|
||||
Some(RestartFailureKind::ServiceFailed),
|
||||
Some(backup_path),
|
||||
@@ -1735,6 +1739,13 @@ impl Application {
|
||||
may_complete,
|
||||
submitted,
|
||||
);
|
||||
report.service_failure = match job {
|
||||
RestartOutcome::Failed(failure) => Some(failure),
|
||||
RestartOutcome::Waiting | RestartOutcome::Succeeded => None,
|
||||
};
|
||||
report.service_phase = Some(service_phase);
|
||||
report.systemd_unit = Some(settings.systemd_unit);
|
||||
return report;
|
||||
}
|
||||
let remaining = deadline.remaining_at(now());
|
||||
if remaining.is_zero() {
|
||||
@@ -2411,6 +2422,9 @@ fn restart_report(
|
||||
RestartReport {
|
||||
stage,
|
||||
failure,
|
||||
service_failure: None,
|
||||
service_phase: None,
|
||||
systemd_unit: None,
|
||||
backup_path,
|
||||
restart_submitted,
|
||||
restart_may_still_complete,
|
||||
@@ -4172,6 +4186,7 @@ restart_timeout_seconds = 30
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
|_, _| {
|
||||
@@ -4210,6 +4225,7 @@ restart_timeout_seconds = 30
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
|_, _| Err(TransportError::Transport),
|
||||
@@ -4250,6 +4266,7 @@ restart_timeout_seconds = 30
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
|interface, remaining| {
|
||||
@@ -4327,6 +4344,7 @@ restart_timeout_seconds = 30
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
|_, _| Ok(observation),
|
||||
@@ -4445,6 +4463,7 @@ restart_timeout_seconds = 30
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
},
|
||||
|_, _| Ok(incomplete),
|
||||
|store, saved, backup| store.install(saved, backup),
|
||||
@@ -4513,6 +4532,7 @@ restart_timeout_seconds = 30
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
},
|
||||
|_, _| {
|
||||
runtime_called.set(true);
|
||||
@@ -4548,6 +4568,7 @@ restart_timeout_seconds = 30
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
|_, _| {
|
||||
@@ -4595,6 +4616,7 @@ restart_timeout_seconds = 30
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
|_, remaining| {
|
||||
@@ -4660,6 +4682,7 @@ restart_timeout_seconds = 30
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
},
|
||||
|_, _| Err(error),
|
||||
|store, saved, backup| {
|
||||
@@ -4698,6 +4721,7 @@ restart_timeout_seconds = 30
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Failed(failure),
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
},
|
||||
|_, _| unreachable!("terminal service failure must stop before runtime read"),
|
||||
|_, _, _| unreachable!("terminal service failure must stop before persistence"),
|
||||
@@ -4710,6 +4734,120 @@ restart_timeout_seconds = 30
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_report_preserves_every_service_failure_and_operation_phase() {
|
||||
use crate::service::RestartPhase;
|
||||
|
||||
let cases = [
|
||||
(RestartFailure::JobFailed, RestartPhase::WaitForJob, true),
|
||||
(
|
||||
RestartFailure::JobDependency,
|
||||
RestartPhase::WaitForJob,
|
||||
true,
|
||||
),
|
||||
(RestartFailure::JobTimedOut, RestartPhase::WaitForJob, true),
|
||||
(RestartFailure::JobCanceled, RestartPhase::WaitForJob, true),
|
||||
(RestartFailure::JobSkipped, RestartPhase::WaitForJob, true),
|
||||
(
|
||||
RestartFailure::JobResultUnknown,
|
||||
RestartPhase::WaitForJob,
|
||||
true,
|
||||
),
|
||||
(
|
||||
RestartFailure::BusDisconnected,
|
||||
RestartPhase::Connect,
|
||||
false,
|
||||
),
|
||||
(
|
||||
RestartFailure::ManagerOwnerChanged,
|
||||
RestartPhase::WaitForJob,
|
||||
true,
|
||||
),
|
||||
(
|
||||
RestartFailure::ManagerOwnerChanged,
|
||||
RestartPhase::RequestRestart,
|
||||
true,
|
||||
),
|
||||
(
|
||||
RestartFailure::SubscriptionFailed,
|
||||
RestartPhase::Subscribe,
|
||||
false,
|
||||
),
|
||||
(
|
||||
RestartFailure::UnitResolutionFailed,
|
||||
RestartPhase::ResolveUnit,
|
||||
false,
|
||||
),
|
||||
(
|
||||
RestartFailure::PermissionDenied,
|
||||
RestartPhase::ResolveUnit,
|
||||
false,
|
||||
),
|
||||
(
|
||||
RestartFailure::RequestFailed,
|
||||
RestartPhase::RequestRestart,
|
||||
false,
|
||||
),
|
||||
(
|
||||
RestartFailure::WaitTimedOutMayStillComplete,
|
||||
RestartPhase::WaitForJob,
|
||||
true,
|
||||
),
|
||||
(
|
||||
RestartFailure::EarlyEventBufferFull,
|
||||
RestartPhase::WaitForJob,
|
||||
true,
|
||||
),
|
||||
(
|
||||
RestartFailure::InvalidJobPath,
|
||||
RestartPhase::WaitForJob,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (mut app, _) = prepare_restartable(root.path());
|
||||
for (failure, phase, submitted) in cases {
|
||||
let preview = app.preview_restart().expect("preview saved generation");
|
||||
let report = app.restart_with_adapters(
|
||||
preview,
|
||||
RestartChoice::RestartSavedState,
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Failed(failure),
|
||||
submitted,
|
||||
phase,
|
||||
},
|
||||
|_, _| unreachable!("service failure must stop before runtime read"),
|
||||
|_, _, _| unreachable!("service failure must stop before persistence"),
|
||||
);
|
||||
assert_eq!(report.failure, Some(RestartFailureKind::ServiceFailed));
|
||||
assert_eq!(report.service_failure, Some(failure));
|
||||
assert_eq!(report.service_phase, Some(phase));
|
||||
assert_eq!(report.systemd_unit.as_deref(), Some("wg-quick@wg0.service"));
|
||||
assert_eq!(report.restart_submitted, submitted);
|
||||
if failure == RestartFailure::ManagerOwnerChanged
|
||||
&& phase == RestartPhase::RequestRestart
|
||||
{
|
||||
assert!(report.restart_may_still_complete);
|
||||
assert!(report.manual_recovery_required);
|
||||
}
|
||||
assert_eq!(
|
||||
report.restart_may_still_complete,
|
||||
submitted
|
||||
&& !matches!(
|
||||
failure,
|
||||
RestartFailure::JobFailed
|
||||
| RestartFailure::JobDependency
|
||||
| RestartFailure::JobTimedOut
|
||||
| RestartFailure::JobCanceled
|
||||
| RestartFailure::JobSkipped
|
||||
| RestartFailure::JobResultUnknown
|
||||
)
|
||||
);
|
||||
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_mismatch_and_post_restart_write_failure_never_release_addresses() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
@@ -4724,6 +4862,7 @@ restart_timeout_seconds = 30
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
},
|
||||
|_, _| {
|
||||
Ok(ObservedRuntime {
|
||||
@@ -4744,6 +4883,7 @@ restart_timeout_seconds = 30
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
},
|
||||
|_, _| Ok(observation),
|
||||
|_, _, _| Err(PersistenceError::RecoveryRequired),
|
||||
@@ -4803,6 +4943,7 @@ restart_timeout_seconds = 30
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
|_, _| Err(TransportError::Transport),
|
||||
|
||||
@@ -1062,6 +1062,7 @@ fn run_failure_prevents_application_scenario(
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Failed(RestartFailure::RequestFailed),
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1663,6 +1664,7 @@ fn run_fixture_apply_with_cleanup(
|
||||
RestartOutcome::Failed(RestartFailure::RequestFailed)
|
||||
},
|
||||
submitted: applied,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1900,6 +1902,7 @@ mod tests {
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Failed(RestartFailure::RequestFailed),
|
||||
submitted: true,
|
||||
phase: crate::service::RestartPhase::WaitForJob,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,16 @@ pub enum RestartFailure {
|
||||
InvalidJobPath,
|
||||
}
|
||||
|
||||
/// Secret-free operation boundary where a restart attempt failed.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartPhase {
|
||||
Connect,
|
||||
Subscribe,
|
||||
ResolveUnit,
|
||||
RequestRestart,
|
||||
WaitForJob,
|
||||
}
|
||||
|
||||
/// Current state of a single restart attempt.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RestartOutcome {
|
||||
@@ -115,6 +125,7 @@ pub enum RestartOutcome {
|
||||
pub struct RestartExecution {
|
||||
pub outcome: RestartOutcome,
|
||||
pub submitted: bool,
|
||||
pub phase: RestartPhase,
|
||||
}
|
||||
|
||||
/// Pure event tracker for the primary systemd unit resolved from the configured
|
||||
|
||||
+314
-33
@@ -18,7 +18,7 @@ use zbus::{Connection, MatchRule, MessageStream, Proxy};
|
||||
|
||||
use super::{
|
||||
JobResult, RestartDeadline, RestartExecution, RestartFailure, RestartJobTracker,
|
||||
RestartOutcome, ServiceEvent,
|
||||
RestartOutcome, RestartPhase, ServiceEvent,
|
||||
};
|
||||
|
||||
const SYSTEMD_NAME: &str = "org.freedesktop.systemd1";
|
||||
@@ -70,25 +70,39 @@ fn restart_with_transport_report(
|
||||
deadline: RestartDeadline,
|
||||
) -> RestartExecution {
|
||||
if configured_unit.is_empty() || configured_unit.as_bytes().contains(&0) {
|
||||
return execution(RestartOutcome::Failed(RestartFailure::RequestFailed), false);
|
||||
return execution(
|
||||
RestartOutcome::Failed(RestartFailure::RequestFailed),
|
||||
false,
|
||||
RestartPhase::RequestRestart,
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(failure) = transport.connect(deadline) {
|
||||
return execution(RestartOutcome::Failed(failure), false);
|
||||
return execution(
|
||||
RestartOutcome::Failed(failure),
|
||||
false,
|
||||
RestartPhase::Connect,
|
||||
);
|
||||
}
|
||||
if deadline.is_expired_at(Instant::now()) {
|
||||
return execution(
|
||||
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
|
||||
false,
|
||||
RestartPhase::Connect,
|
||||
);
|
||||
}
|
||||
if let Err(failure) = transport.subscribe(deadline) {
|
||||
return execution(RestartOutcome::Failed(failure), false);
|
||||
return execution(
|
||||
RestartOutcome::Failed(failure),
|
||||
false,
|
||||
RestartPhase::Subscribe,
|
||||
);
|
||||
}
|
||||
if deadline.is_expired_at(Instant::now()) {
|
||||
return execution(
|
||||
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
|
||||
false,
|
||||
RestartPhase::Subscribe,
|
||||
);
|
||||
}
|
||||
let primary_unit = match transport.resolve_primary_unit(configured_unit, deadline) {
|
||||
@@ -97,14 +111,22 @@ fn restart_with_transport_report(
|
||||
return execution(
|
||||
RestartOutcome::Failed(RestartFailure::UnitResolutionFailed),
|
||||
false,
|
||||
RestartPhase::ResolveUnit,
|
||||
);
|
||||
}
|
||||
Err(failure) => {
|
||||
return execution(
|
||||
RestartOutcome::Failed(failure),
|
||||
false,
|
||||
RestartPhase::ResolveUnit,
|
||||
);
|
||||
}
|
||||
Err(failure) => return execution(RestartOutcome::Failed(failure), false),
|
||||
};
|
||||
if deadline.is_expired_at(Instant::now()) {
|
||||
return execution(
|
||||
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
|
||||
false,
|
||||
RestartPhase::ResolveUnit,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,9 +135,15 @@ fn restart_with_transport_report(
|
||||
Err(failure) => {
|
||||
let submitted = matches!(
|
||||
failure,
|
||||
RestartFailure::WaitTimedOutMayStillComplete | RestartFailure::BusDisconnected
|
||||
RestartFailure::WaitTimedOutMayStillComplete
|
||||
| RestartFailure::BusDisconnected
|
||||
| RestartFailure::ManagerOwnerChanged
|
||||
);
|
||||
return execution(
|
||||
RestartOutcome::Failed(failure),
|
||||
submitted,
|
||||
RestartPhase::RequestRestart,
|
||||
);
|
||||
return execution(RestartOutcome::Failed(failure), submitted);
|
||||
}
|
||||
};
|
||||
let mut tracker = RestartJobTracker::new(primary_unit, deadline);
|
||||
@@ -123,15 +151,25 @@ fn restart_with_transport_report(
|
||||
while outcome == RestartOutcome::Waiting {
|
||||
let event = match transport.next_event(deadline) {
|
||||
Ok(event) => event,
|
||||
Err(failure) => return execution(RestartOutcome::Failed(failure), true),
|
||||
Err(failure) => {
|
||||
return execution(
|
||||
RestartOutcome::Failed(failure),
|
||||
true,
|
||||
RestartPhase::WaitForJob,
|
||||
);
|
||||
}
|
||||
};
|
||||
outcome = tracker.observe(event, Instant::now());
|
||||
}
|
||||
execution(outcome, true)
|
||||
execution(outcome, true, RestartPhase::WaitForJob)
|
||||
}
|
||||
|
||||
fn execution(outcome: RestartOutcome, submitted: bool) -> RestartExecution {
|
||||
RestartExecution { outcome, submitted }
|
||||
fn execution(outcome: RestartOutcome, submitted: bool, phase: RestartPhase) -> RestartExecution {
|
||||
RestartExecution {
|
||||
outcome,
|
||||
submitted,
|
||||
phase,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -142,6 +180,100 @@ struct SystemdBus {
|
||||
owner_events: Option<zbus::MessageStream>,
|
||||
}
|
||||
|
||||
/// The small systemd unit API used by the shared resolver. Keeping method
|
||||
/// selection and canonical-ID lookup here lets synthetic tests exercise the
|
||||
/// same resolution flow as the production adapter.
|
||||
trait UnitLoadApi {
|
||||
type UnitPath;
|
||||
|
||||
fn load_unit(
|
||||
&mut self,
|
||||
configured_unit: &str,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<Self::UnitPath, RestartFailure>;
|
||||
fn load_state(
|
||||
&mut self,
|
||||
unit_path: &Self::UnitPath,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure>;
|
||||
fn canonical_id(
|
||||
&mut self,
|
||||
unit_path: &Self::UnitPath,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure>;
|
||||
}
|
||||
|
||||
fn resolve_loaded_unit(
|
||||
api: &mut impl UnitLoadApi,
|
||||
configured_unit: &str,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure> {
|
||||
let unit_path = api.load_unit(configured_unit, deadline)?;
|
||||
if !unit_is_loaded(&api.load_state(&unit_path, deadline)?) {
|
||||
return Err(RestartFailure::UnitResolutionFailed);
|
||||
}
|
||||
api.canonical_id(&unit_path, deadline)
|
||||
}
|
||||
|
||||
impl UnitLoadApi for SystemdBus {
|
||||
type UnitPath = zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
fn load_unit(
|
||||
&mut self,
|
||||
configured_unit: &str,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<Self::UnitPath, RestartFailure> {
|
||||
let manager = self
|
||||
.manager
|
||||
.as_ref()
|
||||
.ok_or(RestartFailure::UnitResolutionFailed)?;
|
||||
drive(manager.call("LoadUnit", &(configured_unit,)), deadline)?
|
||||
.map_err(map_unit_resolution_error)
|
||||
}
|
||||
|
||||
fn load_state(
|
||||
&mut self,
|
||||
unit_path: &Self::UnitPath,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure> {
|
||||
let unit = self.unit_proxy(unit_path, deadline)?;
|
||||
drive(unit.get_property::<String>("LoadState"), deadline)?
|
||||
.map_err(map_unit_resolution_error)
|
||||
}
|
||||
|
||||
fn canonical_id(
|
||||
&mut self,
|
||||
unit_path: &Self::UnitPath,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure> {
|
||||
let unit = self.unit_proxy(unit_path, deadline)?;
|
||||
drive(unit.get_property::<String>("Id"), deadline)?.map_err(map_unit_resolution_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemdBus {
|
||||
fn unit_proxy(
|
||||
&self,
|
||||
unit_path: &zbus::zvariant::OwnedObjectPath,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<Proxy<'static>, RestartFailure> {
|
||||
let connection = self
|
||||
.connection
|
||||
.as_ref()
|
||||
.ok_or(RestartFailure::BusDisconnected)?;
|
||||
drive(
|
||||
Proxy::new_owned(
|
||||
connection.clone(),
|
||||
SYSTEMD_NAME,
|
||||
unit_path.clone(),
|
||||
"org.freedesktop.systemd1.Unit",
|
||||
),
|
||||
deadline,
|
||||
)?
|
||||
.map_err(map_unit_resolution_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl RestartTransport for SystemdBus {
|
||||
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
|
||||
let connection = drive(Connection::system(), deadline)?.map_err(map_bus_error)?;
|
||||
@@ -202,28 +334,7 @@ impl RestartTransport for SystemdBus {
|
||||
configured_unit: &str,
|
||||
deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure> {
|
||||
let manager = self
|
||||
.manager
|
||||
.as_ref()
|
||||
.ok_or(RestartFailure::UnitResolutionFailed)?;
|
||||
let connection = self
|
||||
.connection
|
||||
.as_ref()
|
||||
.ok_or(RestartFailure::BusDisconnected)?;
|
||||
let unit_path: zbus::zvariant::OwnedObjectPath =
|
||||
drive(manager.call("GetUnit", &(configured_unit,)), deadline)?
|
||||
.map_err(map_unit_resolution_error)?;
|
||||
let unit = drive(
|
||||
Proxy::new_owned(
|
||||
connection.clone(),
|
||||
SYSTEMD_NAME,
|
||||
unit_path,
|
||||
"org.freedesktop.systemd1.Unit",
|
||||
),
|
||||
deadline,
|
||||
)?
|
||||
.map_err(map_unit_resolution_error)?;
|
||||
drive(unit.get_property::<String>("Id"), deadline)?.map_err(map_unit_resolution_error)
|
||||
resolve_loaded_unit(self, configured_unit, deadline)
|
||||
}
|
||||
|
||||
fn restart_unit(
|
||||
@@ -348,6 +459,10 @@ impl RestartTransport for SystemdBus {
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_is_loaded(load_state: &str) -> bool {
|
||||
load_state == "loaded"
|
||||
}
|
||||
|
||||
fn map_job_result(result: &str) -> JobResult {
|
||||
match result {
|
||||
"done" => JobResult::Done,
|
||||
@@ -624,6 +739,172 @@ mod tests {
|
||||
assert!(fake.deadlines.iter().all(|(_, passed)| *passed == deadline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemd_load_states_allow_only_loaded_units_to_reach_restart() {
|
||||
assert!(unit_is_loaded("loaded"));
|
||||
for state in ["not-found", "bad-setting", "error", "masked", "stub"] {
|
||||
assert!(!unit_is_loaded(state), "unexpected load state: {state}");
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeUnitLoadApi {
|
||||
calls: Vec<String>,
|
||||
configured_unit_seen: Option<String>,
|
||||
load_state: Result<String, RestartFailure>,
|
||||
canonical_id: Result<String, RestartFailure>,
|
||||
load_unit_result: Result<String, RestartFailure>,
|
||||
}
|
||||
|
||||
impl UnitLoadApi for FakeUnitLoadApi {
|
||||
type UnitPath = String;
|
||||
|
||||
fn load_unit(
|
||||
&mut self,
|
||||
configured_unit: &str,
|
||||
_deadline: RestartDeadline,
|
||||
) -> Result<Self::UnitPath, RestartFailure> {
|
||||
self.calls.push("LoadUnit".to_owned());
|
||||
self.configured_unit_seen = Some(configured_unit.to_owned());
|
||||
self.load_unit_result.clone()
|
||||
}
|
||||
|
||||
fn load_state(
|
||||
&mut self,
|
||||
unit_path: &Self::UnitPath,
|
||||
_deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure> {
|
||||
self.calls.push(format!("LoadState:{unit_path}"));
|
||||
self.load_state.clone()
|
||||
}
|
||||
|
||||
fn canonical_id(
|
||||
&mut self,
|
||||
unit_path: &Self::UnitPath,
|
||||
_deadline: RestartDeadline,
|
||||
) -> Result<String, RestartFailure> {
|
||||
self.calls.push(format!("Id:{unit_path}"));
|
||||
self.canonical_id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeUnitLoadApi {
|
||||
fn loaded(unit_path: &str, canonical_id: &str) -> Self {
|
||||
Self {
|
||||
calls: Vec::new(),
|
||||
configured_unit_seen: None,
|
||||
load_state: Ok("loaded".to_owned()),
|
||||
canonical_id: Ok(canonical_id.to_owned()),
|
||||
load_unit_result: Ok(unit_path.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_unit_resolver_loads_unloaded_alias_and_returns_canonical_id() {
|
||||
let mut resolver = FakeUnitLoadApi::loaded(
|
||||
"/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
|
||||
"wg-quick@wg0.service",
|
||||
);
|
||||
|
||||
let canonical = resolve_loaded_unit(&mut resolver, "wireguard-vpn.service", deadline())
|
||||
.expect("LoadUnit loads existing unloaded alias");
|
||||
|
||||
assert_eq!(
|
||||
resolver.configured_unit_seen.as_deref(),
|
||||
Some("wireguard-vpn.service")
|
||||
);
|
||||
assert_eq!(canonical, "wg-quick@wg0.service");
|
||||
assert_eq!(
|
||||
resolver.calls,
|
||||
[
|
||||
"LoadUnit",
|
||||
"LoadState:/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
|
||||
"Id:/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_unit_resolver_stops_for_missing_units_and_access_denial() {
|
||||
let mut missing = FakeUnitLoadApi::loaded("/unit/missing", "unused");
|
||||
missing.load_state = Ok("not-found".to_owned());
|
||||
assert_eq!(
|
||||
resolve_loaded_unit(&mut missing, "missing.service", deadline()),
|
||||
Err(RestartFailure::UnitResolutionFailed)
|
||||
);
|
||||
assert_eq!(missing.calls, ["LoadUnit", "LoadState:/unit/missing"]);
|
||||
|
||||
let mut load_error = FakeUnitLoadApi::loaded("/unit/unused", "unused");
|
||||
load_error.load_unit_result = Err(RestartFailure::UnitResolutionFailed);
|
||||
assert_eq!(
|
||||
resolve_loaded_unit(&mut load_error, "absent.service", deadline()),
|
||||
Err(RestartFailure::UnitResolutionFailed)
|
||||
);
|
||||
assert_eq!(load_error.calls, ["LoadUnit"]);
|
||||
|
||||
let mut denied = FakeUnitLoadApi::loaded("/unit/denied", "unused");
|
||||
denied.load_unit_result = Err(RestartFailure::PermissionDenied);
|
||||
assert_eq!(
|
||||
resolve_loaded_unit(&mut denied, "restricted.service", deadline()),
|
||||
Err(RestartFailure::PermissionDenied)
|
||||
);
|
||||
assert_eq!(denied.calls, ["LoadUnit"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_resolution_and_access_denial_stop_before_restart() {
|
||||
let mut alias = FakeBus {
|
||||
configured_unit: "wireguard-vpn.service",
|
||||
primary_unit: "wg-quick@wg0.service",
|
||||
job_path: "/job/alias",
|
||||
events: VecDeque::from([removed(
|
||||
"/job/alias",
|
||||
"wg-quick@wg0.service",
|
||||
JobResult::Done,
|
||||
)]),
|
||||
..FakeBus::default()
|
||||
};
|
||||
let execution =
|
||||
restart_with_transport_report(&mut alias, "wireguard-vpn.service", deadline());
|
||||
assert_eq!(execution.outcome, RestartOutcome::Succeeded);
|
||||
assert_eq!(execution.phase, RestartPhase::WaitForJob);
|
||||
assert_eq!(
|
||||
alias.calls[0..4],
|
||||
["connect", "subscribe", "resolve", "restart"]
|
||||
);
|
||||
|
||||
let mut denied = FakeBus {
|
||||
failure: Some(("resolve", RestartFailure::PermissionDenied)),
|
||||
..FakeBus::default()
|
||||
};
|
||||
let denied_unit = denied.configured_unit;
|
||||
let execution = restart_with_transport_report(&mut denied, denied_unit, deadline());
|
||||
assert_eq!(
|
||||
execution.outcome,
|
||||
RestartOutcome::Failed(RestartFailure::PermissionDenied)
|
||||
);
|
||||
assert_eq!(execution.phase, RestartPhase::ResolveUnit);
|
||||
assert!(!denied.calls.contains(&"restart"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_change_while_restart_reply_is_pending_is_reported_as_submitted() {
|
||||
let mut fake = FakeBus {
|
||||
failure: Some(("restart", RestartFailure::ManagerOwnerChanged)),
|
||||
..FakeBus::default()
|
||||
};
|
||||
|
||||
let execution =
|
||||
restart_with_transport_report(&mut fake, "wg-quick@wg0.service", deadline());
|
||||
|
||||
assert_eq!(
|
||||
execution.outcome,
|
||||
RestartOutcome::Failed(RestartFailure::ManagerOwnerChanged)
|
||||
);
|
||||
assert_eq!(execution.phase, RestartPhase::RequestRestart);
|
||||
assert!(execution.submitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_shared_deadline_stops_between_setup_and_submission() {
|
||||
let mut fake = FakeBus {
|
||||
|
||||
+194
-8
@@ -508,14 +508,32 @@ fn result_message(result: &CommandResult) -> String {
|
||||
CommandResult::RestartPreview(_) => {
|
||||
"Restart preview ready; confirm the exact saved-state action.".to_owned()
|
||||
}
|
||||
CommandResult::Restarted(report) => format!(
|
||||
"Restart result: {:?}; failure: {:?}; submitted: {}; may still complete: {}; manual recovery required: {}.",
|
||||
report.stage,
|
||||
report.failure,
|
||||
report.restart_submitted,
|
||||
report.restart_may_still_complete,
|
||||
report.manual_recovery_required
|
||||
),
|
||||
CommandResult::Restarted(report) => {
|
||||
let mut message = format!(
|
||||
"Restart result: {:?}; failure: {:?}; submitted: {}; may still complete: {}; manual recovery required: {}.",
|
||||
report.stage,
|
||||
report.failure,
|
||||
report.restart_submitted,
|
||||
report.restart_may_still_complete,
|
||||
report.manual_recovery_required
|
||||
);
|
||||
if let Some(failure) = report.service_failure {
|
||||
let phase = report
|
||||
.service_phase
|
||||
.map(restart_phase_label)
|
||||
.unwrap_or("service operation");
|
||||
let unit = report
|
||||
.systemd_unit
|
||||
.as_deref()
|
||||
.map(render::safe_text)
|
||||
.unwrap_or_else(|| "configured systemd unit".to_owned());
|
||||
message.push_str(&format!(
|
||||
" Systemd {phase} failed for {unit}: {}.",
|
||||
restart_failure_label(failure)
|
||||
));
|
||||
}
|
||||
message
|
||||
}
|
||||
CommandResult::DeliveryDestination(destination) => format!(
|
||||
"Prepared destination {}. Overwrite confirmation required: {}.",
|
||||
render::safe_text(&destination.path.display().to_string()),
|
||||
@@ -566,6 +584,39 @@ fn result_message(result: &CommandResult) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_phase_label(phase: crate::service::RestartPhase) -> &'static str {
|
||||
match phase {
|
||||
crate::service::RestartPhase::Connect => "bus connection",
|
||||
crate::service::RestartPhase::Subscribe => "job subscription",
|
||||
crate::service::RestartPhase::ResolveUnit => "unit resolution",
|
||||
crate::service::RestartPhase::RequestRestart => "restart request",
|
||||
crate::service::RestartPhase::WaitForJob => "job wait",
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_failure_label(failure: crate::service::RestartFailure) -> &'static str {
|
||||
use crate::service::RestartFailure;
|
||||
match failure {
|
||||
RestartFailure::JobFailed => "job failed",
|
||||
RestartFailure::JobDependency => "job dependency failed",
|
||||
RestartFailure::JobTimedOut => "job timed out",
|
||||
RestartFailure::JobCanceled => "job was canceled",
|
||||
RestartFailure::JobSkipped => "job was skipped",
|
||||
RestartFailure::JobResultUnknown => "job returned an unknown result",
|
||||
RestartFailure::BusDisconnected => "D-Bus connection was lost",
|
||||
RestartFailure::ManagerOwnerChanged => "systemd manager owner changed",
|
||||
RestartFailure::SubscriptionFailed => "job subscription failed",
|
||||
RestartFailure::UnitResolutionFailed => "unit could not be loaded",
|
||||
RestartFailure::PermissionDenied => "permission denied",
|
||||
RestartFailure::RequestFailed => "systemd request failed",
|
||||
RestartFailure::WaitTimedOutMayStillComplete => {
|
||||
"operation timed out and may still complete"
|
||||
}
|
||||
RestartFailure::EarlyEventBufferFull => "job event buffer was full",
|
||||
RestartFailure::InvalidJobPath => "systemd returned an invalid job path",
|
||||
}
|
||||
}
|
||||
|
||||
fn process_command_result(
|
||||
result: CommandResult,
|
||||
state: &mut UiState,
|
||||
@@ -1048,11 +1099,15 @@ mod tests {
|
||||
#[test]
|
||||
fn restart_results_render_cancel_success_and_uncertain_failure_honestly() {
|
||||
use crate::app::{RestartFailureKind, RestartReport, RestartStage};
|
||||
use crate::service::{RestartFailure, RestartPhase};
|
||||
|
||||
let report = |stage, failure, restart_submitted, restart_may_still_complete| {
|
||||
CommandResult::Restarted(RestartReport {
|
||||
stage,
|
||||
failure,
|
||||
service_failure: None,
|
||||
service_phase: None,
|
||||
systemd_unit: None,
|
||||
backup_path: None,
|
||||
restart_submitted,
|
||||
restart_may_still_complete,
|
||||
@@ -1076,6 +1131,137 @@ mod tests {
|
||||
assert!(uncertain.contains("ServiceFailed"));
|
||||
assert!(uncertain.contains("may still complete: true"));
|
||||
assert!(uncertain.contains("manual recovery required: true"));
|
||||
|
||||
let detailed = CommandResult::Restarted(RestartReport {
|
||||
stage: RestartStage::Service,
|
||||
failure: Some(RestartFailureKind::ServiceFailed),
|
||||
service_failure: Some(RestartFailure::PermissionDenied),
|
||||
service_phase: Some(RestartPhase::ResolveUnit),
|
||||
systemd_unit: Some("wireguard-vpn.service".to_owned()),
|
||||
backup_path: None,
|
||||
restart_submitted: false,
|
||||
restart_may_still_complete: false,
|
||||
manual_recovery_required: false,
|
||||
});
|
||||
let detailed = result_message(&detailed);
|
||||
assert!(detailed.contains("unit resolution"));
|
||||
assert!(detailed.contains("wireguard-vpn.service"));
|
||||
assert!(detailed.contains("permission denied"));
|
||||
assert!(!detailed.contains("org.freedesktop.DBus.Error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_redacted_service_failure_has_a_readable_tui_category_and_phase() {
|
||||
use crate::app::{RestartFailureKind, RestartReport, RestartStage};
|
||||
use crate::service::{RestartFailure, RestartPhase};
|
||||
|
||||
for (failure, phase, label) in [
|
||||
(
|
||||
RestartFailure::JobFailed,
|
||||
RestartPhase::WaitForJob,
|
||||
"job failed",
|
||||
),
|
||||
(
|
||||
RestartFailure::JobDependency,
|
||||
RestartPhase::WaitForJob,
|
||||
"job dependency failed",
|
||||
),
|
||||
(
|
||||
RestartFailure::JobTimedOut,
|
||||
RestartPhase::WaitForJob,
|
||||
"job timed out",
|
||||
),
|
||||
(
|
||||
RestartFailure::JobCanceled,
|
||||
RestartPhase::WaitForJob,
|
||||
"job was canceled",
|
||||
),
|
||||
(
|
||||
RestartFailure::JobSkipped,
|
||||
RestartPhase::WaitForJob,
|
||||
"job was skipped",
|
||||
),
|
||||
(
|
||||
RestartFailure::JobResultUnknown,
|
||||
RestartPhase::WaitForJob,
|
||||
"job returned an unknown result",
|
||||
),
|
||||
(
|
||||
RestartFailure::BusDisconnected,
|
||||
RestartPhase::Connect,
|
||||
"D-Bus connection was lost",
|
||||
),
|
||||
(
|
||||
RestartFailure::ManagerOwnerChanged,
|
||||
RestartPhase::WaitForJob,
|
||||
"systemd manager owner changed",
|
||||
),
|
||||
(
|
||||
RestartFailure::SubscriptionFailed,
|
||||
RestartPhase::Subscribe,
|
||||
"job subscription failed",
|
||||
),
|
||||
(
|
||||
RestartFailure::UnitResolutionFailed,
|
||||
RestartPhase::ResolveUnit,
|
||||
"unit could not be loaded",
|
||||
),
|
||||
(
|
||||
RestartFailure::PermissionDenied,
|
||||
RestartPhase::RequestRestart,
|
||||
"permission denied",
|
||||
),
|
||||
(
|
||||
RestartFailure::RequestFailed,
|
||||
RestartPhase::RequestRestart,
|
||||
"systemd request failed",
|
||||
),
|
||||
(
|
||||
RestartFailure::WaitTimedOutMayStillComplete,
|
||||
RestartPhase::WaitForJob,
|
||||
"operation timed out and may still complete",
|
||||
),
|
||||
(
|
||||
RestartFailure::EarlyEventBufferFull,
|
||||
RestartPhase::WaitForJob,
|
||||
"job event buffer was full",
|
||||
),
|
||||
(
|
||||
RestartFailure::InvalidJobPath,
|
||||
RestartPhase::WaitForJob,
|
||||
"systemd returned an invalid job path",
|
||||
),
|
||||
] {
|
||||
let report = CommandResult::Restarted(RestartReport {
|
||||
stage: RestartStage::Service,
|
||||
failure: Some(RestartFailureKind::ServiceFailed),
|
||||
service_failure: Some(failure),
|
||||
service_phase: Some(phase),
|
||||
systemd_unit: Some("wg-quick@wg0.service".to_owned()),
|
||||
backup_path: None,
|
||||
restart_submitted: true,
|
||||
restart_may_still_complete: true,
|
||||
manual_recovery_required: true,
|
||||
});
|
||||
let message = result_message(&report);
|
||||
assert!(message.contains(label), "missing {failure:?}: {message}");
|
||||
assert!(message.contains("wg-quick@wg0.service"));
|
||||
assert!(message.contains(restart_phase_label(phase)));
|
||||
assert!(!message.contains("org.freedesktop.DBus.Error"));
|
||||
}
|
||||
|
||||
let hostile_unit = CommandResult::Restarted(RestartReport {
|
||||
stage: RestartStage::Service,
|
||||
failure: Some(RestartFailureKind::ServiceFailed),
|
||||
service_failure: Some(RestartFailure::PermissionDenied),
|
||||
service_phase: Some(RestartPhase::ResolveUnit),
|
||||
systemd_unit: Some("wg0\u{1b}[31m.service".to_owned()),
|
||||
backup_path: None,
|
||||
restart_submitted: false,
|
||||
restart_may_still_complete: false,
|
||||
manual_recovery_required: false,
|
||||
});
|
||||
assert!(!result_message(&hostile_unit).contains('\u{1b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user