lesavka/server/src/capture_power.rs

339 lines
9.5 KiB
Rust
Raw Normal View History

use lesavka_common::lesavka::CapturePowerState;
#[cfg(not(coverage))]
use {
anyhow::{Context, Result, anyhow},
std::process::Command,
std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
tokio::sync::Mutex,
tracing::{info, warn},
};
#[cfg(not(coverage))]
#[derive(Debug, Default)]
struct CapturePowerInner {
active_leases: u32,
manual_override: Option<bool>,
}
#[cfg(not(coverage))]
#[derive(Debug, Clone)]
pub struct CapturePowerManager {
unit: Arc<str>,
inner: Arc<Mutex<CapturePowerInner>>,
}
#[cfg(not(coverage))]
#[derive(Clone)]
pub struct CapturePowerLease {
manager: CapturePowerManager,
released: Arc<AtomicBool>,
}
#[cfg(not(coverage))]
#[derive(Debug)]
struct UnitSnapshot {
available: bool,
enabled: bool,
detail: String,
}
#[cfg(not(coverage))]
impl CapturePowerManager {
pub fn new() -> Self {
let unit = std::env::var("LESAVKA_CAPTURE_POWER_UNIT")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "relay.service".to_string());
Self {
unit: Arc::<str>::from(unit),
inner: Arc::new(Mutex::new(CapturePowerInner::default())),
}
}
pub async fn acquire(&self) -> CapturePowerLease {
let (desired, unit, leases, manual_override) = {
let mut inner = self.inner.lock().await;
inner.active_leases = inner.active_leases.saturating_add(1);
(
desired_state(&inner),
self.unit.to_string(),
inner.active_leases,
inner.manual_override,
)
};
if let Err(err) = sync_unit_state(unit.as_str(), desired).await {
warn!(unit = %unit, leases, desired, ?manual_override, ?err, "capture power sync failed on acquire");
}
CapturePowerLease {
manager: self.clone(),
released: Arc::new(AtomicBool::new(false)),
}
}
pub async fn set_manual(&self, enabled: bool) -> Result<CapturePowerState> {
let unit = self.unit.to_string();
{
let mut inner = self.inner.lock().await;
inner.manual_override = Some(enabled);
}
sync_unit_state(unit.as_str(), enabled).await?;
self.snapshot().await
}
pub async fn set_auto(&self) -> Result<CapturePowerState> {
let unit = self.unit.to_string();
let desired = {
let mut inner = self.inner.lock().await;
inner.manual_override = None;
desired_state(&inner)
};
sync_unit_state(unit.as_str(), desired).await?;
self.snapshot().await
}
pub async fn snapshot(&self) -> Result<CapturePowerState> {
let (active_leases, manual_override) = {
let inner = self.inner.lock().await;
(inner.active_leases, inner.manual_override)
};
let unit = self.unit.to_string();
let snapshot = inspect_unit(unit.as_str()).await?;
Ok(CapturePowerState {
available: snapshot.available,
enabled: snapshot.enabled,
unit,
detail: snapshot.detail,
active_leases,
mode: match manual_override {
Some(true) => "forced-on".to_string(),
Some(false) => "forced-off".to_string(),
None => "auto".to_string(),
},
})
}
async fn release_one(&self) {
let (desired, unit, leases, manual_override) = {
let mut inner = self.inner.lock().await;
inner.active_leases = inner.active_leases.saturating_sub(1);
(
desired_state(&inner),
self.unit.to_string(),
inner.active_leases,
inner.manual_override,
)
};
if let Err(err) = sync_unit_state(unit.as_str(), desired).await {
warn!(
unit = %unit,
leases,
desired,
?manual_override,
?err,
"capture power sync failed on release"
);
} else {
info!(
unit = %unit,
leases,
desired,
?manual_override,
"capture power synced"
);
}
}
}
#[cfg(not(coverage))]
impl Drop for CapturePowerLease {
fn drop(&mut self) {
if self.released.swap(true, Ordering::AcqRel) {
return;
}
let manager = self.manager.clone();
tokio::spawn(async move {
manager.release_one().await;
});
}
}
#[cfg(not(coverage))]
fn desired_state(inner: &CapturePowerInner) -> bool {
inner.manual_override.unwrap_or(inner.active_leases > 0)
}
#[cfg(not(coverage))]
async fn inspect_unit(unit: &str) -> Result<UnitSnapshot> {
let unit = unit.to_string();
tokio::task::spawn_blocking(move || inspect_unit_blocking(unit.as_str()))
.await
.map_err(|err| anyhow!("capture power inspect task failed: {err}"))?
}
#[cfg(not(coverage))]
fn inspect_unit_blocking(unit: &str) -> Result<UnitSnapshot> {
let output = Command::new("systemctl")
.args([
"show",
unit,
"--property=LoadState,ActiveState,SubState",
"--value",
])
.output()
.with_context(|| format!("querying systemd unit {unit}"))?;
if !output.status.success() {
return Err(anyhow!(
"systemctl show {unit} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
let load_state = lines.next().unwrap_or_default().trim().to_string();
let active_state = lines.next().unwrap_or_default().trim().to_string();
let sub_state = lines.next().unwrap_or_default().trim().to_string();
let available = !load_state.is_empty() && load_state != "not-found";
let enabled = active_state == "active";
let detail = if available {
format!("{active_state}/{sub_state}")
} else {
"unit not found".to_string()
};
Ok(UnitSnapshot {
available,
enabled,
detail,
})
}
#[cfg(not(coverage))]
async fn sync_unit_state(unit: &str, enabled: bool) -> Result<()> {
let unit = unit.to_string();
tokio::task::spawn_blocking(move || sync_unit_state_blocking(unit.as_str(), enabled))
.await
.map_err(|err| anyhow!("capture power sync task failed: {err}"))?
}
#[cfg(not(coverage))]
fn sync_unit_state_blocking(unit: &str, enabled: bool) -> Result<()> {
let action = if enabled { "start" } else { "stop" };
let status = Command::new("systemctl")
.args([action, unit])
.status()
.with_context(|| format!("running systemctl {action} {unit}"))?;
if status.success() {
Ok(())
} else {
Err(anyhow!("systemctl {action} {unit} failed with {status}"))
}
}
#[cfg(coverage)]
#[derive(Debug, Clone, Default)]
pub struct CapturePowerManager;
#[cfg(coverage)]
#[derive(Clone, Default)]
pub struct CapturePowerLease;
#[cfg(coverage)]
impl CapturePowerManager {
pub fn new() -> Self {
Self
}
pub async fn acquire(&self) -> CapturePowerLease {
CapturePowerLease
}
pub async fn set_manual(&self, enabled: bool) -> anyhow::Result<CapturePowerState> {
Ok(CapturePowerState {
available: true,
enabled,
unit: "relay.service".to_string(),
detail: if enabled {
"active/running".to_string()
} else {
"inactive/dead".to_string()
},
active_leases: 0,
mode: if enabled {
"forced-on".to_string()
} else {
"forced-off".to_string()
},
})
}
pub async fn set_auto(&self) -> anyhow::Result<CapturePowerState> {
Ok(CapturePowerState {
available: true,
enabled: false,
unit: "relay.service".to_string(),
detail: "inactive/dead".to_string(),
active_leases: 0,
mode: "auto".to_string(),
})
}
pub async fn snapshot(&self) -> anyhow::Result<CapturePowerState> {
Ok(CapturePowerState {
available: true,
enabled: false,
unit: "relay.service".to_string(),
detail: "inactive/dead".to_string(),
active_leases: 0,
mode: "auto".to_string(),
})
}
}
#[cfg(all(test, coverage))]
mod tests {
use super::*;
#[tokio::test]
async fn coverage_stub_reports_auto_snapshot() {
let state = CapturePowerManager::new()
.snapshot()
.await
.expect("snapshot");
assert!(state.available);
assert!(!state.enabled);
assert_eq!(state.mode, "auto");
}
#[tokio::test]
async fn coverage_stub_toggles_manual_modes() {
let manager = CapturePowerManager::new();
let on = manager.set_manual(true).await.expect("on");
assert!(on.enabled);
assert_eq!(on.mode, "forced-on");
let off = manager.set_manual(false).await.expect("off");
assert!(!off.enabled);
assert_eq!(off.mode, "forced-off");
}
#[tokio::test]
async fn coverage_stub_returns_to_auto_mode() {
let manager = CapturePowerManager::new();
let state = manager.set_auto().await.expect("auto");
assert!(state.available);
assert!(!state.enabled);
assert_eq!(state.mode, "auto");
}
}