use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::State,
http::StatusCode,
middleware,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::daemon::{auth, AppState};
use crate::install_state;
use crate::update::{
self, ApplyMode, ApplyResult, ApplyStage, CheckResult, UpdateStatusKind, UpdateStatusSnapshot,
};
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct AdminUpdateRequest {
pub force_cargo_install: bool,
}
#[derive(Debug, Serialize)]
pub struct AdminUpdateResponse {
pub started: bool,
pub from: String,
pub to: String,
pub stream_url: &'static str,
}
#[derive(Debug)]
pub enum AdminError {
CargoInstall,
AlreadyUpToDate { current: String },
CheckFailed(String),
PreconditionFailed {
latest: String,
min_supported: String,
},
AlreadyInProgress,
}
impl IntoResponse for AdminError {
fn into_response(self) -> axum::response::Response {
let (status, body) = match &self {
Self::CargoInstall => (
StatusCode::CONFLICT,
serde_json::json!({
"error": {
"code": crate::error::ERR_UPDATE_REFUSED_CARGO_INSTALL,
"message": "this daemon was installed via `cargo install` — auto-update would not take effect",
"suggestion": "Run: cargo install --force --locked openlatch-client"
}
}),
),
Self::AlreadyUpToDate { current } => (
StatusCode::CONFLICT,
serde_json::json!({
"idempotent": true,
"current": current,
"message": "already on the latest version"
}),
),
Self::CheckFailed(reason) => (
StatusCode::BAD_GATEWAY,
serde_json::json!({
"error": {
"code": crate::error::ERR_CLOUD_UNREACHABLE,
"message": format!("update check failed: {reason}")
}
}),
),
Self::PreconditionFailed {
latest,
min_supported,
} => (
StatusCode::PRECONDITION_FAILED,
serde_json::json!({
"error": {
"code": crate::error::ERR_UPDATE_VERIFY_FAILED,
"message": format!(
"release {latest} requires client >= {min_supported}; manual `npm install -g @openlatch/client@{latest}` required"
),
"latest": latest,
"min_supported": min_supported,
}
}),
),
Self::AlreadyInProgress => (
StatusCode::SERVICE_UNAVAILABLE,
serde_json::json!({
"error": {
"code": crate::error::ERR_DAEMON_START_FAILED,
"message": "another auto-update is already in progress"
}
}),
),
};
(status, Json(body)).into_response()
}
}
pub fn router(state: Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()
.route("/admin/update", post(handle_admin_update))
.route("/admin/update/status", get(handle_admin_update_status))
.route("/admin/inventory/status", get(handle_inventory_status))
.route("/admin/inventory/rescan", post(handle_inventory_rescan))
.route(
"/admin/inventory/inspect/{source_id}",
get(handle_inventory_inspect),
)
.route("/admin/inventory/projects", get(handle_inventory_projects))
.route("/admin/inventory/ack", post(handle_inventory_ack))
.route("/admin/auth/refresh", post(handle_admin_auth_refresh))
.route_layer(middleware::from_fn_with_state(state, auth::bearer_auth))
}
async fn handle_admin_auth_refresh(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let was_auth_error = state
.cloud_state
.as_ref()
.is_some_and(|cs| cs.clear_auth_error());
if was_auth_error {
let dir = crate::config::openlatch_dir();
if let Err(e) = crate::cloud::worker::persist_cloud_state(&dir, false) {
tracing::warn!(error = %e, "admin auth/refresh: failed to persist cloud_state.json");
}
tracing::info!("admin auth/refresh: cleared auth_error after CLI login");
}
Json(serde_json::json!({
"auth_error": false,
"cleared": was_auth_error,
}))
}
#[derive(Debug, Serialize)]
pub struct InventoryStatusResponse {
pub enabled: bool,
pub cache_size: usize,
pub manifest_loaded: bool,
pub pending_alerts: usize,
}
async fn handle_inventory_status(
State(state): State<Arc<AppState>>,
) -> Json<InventoryStatusResponse> {
Json(InventoryStatusResponse {
enabled: state.config.inventory_monitor.enabled,
cache_size: state.content_hash_cache.len(),
manifest_loaded: state.config_monitor_request_tx.is_some(),
pending_alerts: state.pending_alerts.pending_count(),
})
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct InventoryRescanRequest {
pub path: Option<std::path::PathBuf>,
}
async fn handle_inventory_rescan(
State(state): State<Arc<AppState>>,
body: Option<Json<InventoryRescanRequest>>,
) -> StatusCode {
let path_filter = body.and_then(|Json(b)| b.path);
if let Some(tx) = &state.config_monitor_request_tx {
if tx
.send(crate::daemon::config_monitor::ConfigChangeRequest::ManualRescan { path_filter })
.await
.is_err()
{
return StatusCode::SERVICE_UNAVAILABLE;
}
StatusCode::ACCEPTED
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
async fn handle_admin_update(
State(state): State<Arc<AppState>>,
body: Option<Json<AdminUpdateRequest>>,
) -> Result<axum::response::Response, AdminError> {
let req = body.map(|Json(b)| b).unwrap_or_default();
let current_version = env!("CARGO_PKG_VERSION").to_string();
if !req.force_cargo_install
&& matches!(
install_state::detect_install_method(),
install_state::InstallMethod::CargoInstall
)
{
return Err(AdminError::CargoInstall);
}
let registry_origin = state.config.update.registry_origin.clone();
let download_timeout = Duration::from_secs(state.config.update.download_timeout_secs.max(1));
let check = update::check(¤t_version, ®istry_origin).await;
let (latest, severity, min_supported, _tarball_url, _tarball_integrity) = match check {
CheckResult::UpToDate { current } => {
return Err(AdminError::AlreadyUpToDate { current });
}
CheckResult::Failed { reason } => return Err(AdminError::CheckFailed(reason)),
CheckResult::Available {
latest,
severity,
min_supported,
tarball_url,
tarball_integrity,
..
} => (
latest,
severity,
min_supported,
tarball_url,
tarball_integrity,
),
};
if let Some(ref min) = min_supported {
if !update::version_at_least(¤t_version, min) {
crate::telemetry::capture_global(
crate::telemetry::Event::update_blocked_by_min_supported(
¤t_version,
&latest,
min,
),
);
return Err(AdminError::PreconditionFailed {
latest,
min_supported: min.clone(),
});
}
}
if state
.update_in_progress
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err(AdminError::AlreadyInProgress);
}
{
let mut snap = state.update_status.lock().expect("status mutex poisoned");
*snap = UpdateStatusSnapshot {
status: UpdateStatusKind::InProgress,
stage: Some(ApplyStage::Check),
from: Some(current_version.clone()),
to: Some(latest.clone()),
started_at: Some(install_state::now_rfc3339()),
ended_at: None,
error: None,
};
}
let state_for_task = state.clone();
let from = current_version.clone();
let to = latest.clone();
let response_to = to.clone();
tokio::spawn(async move {
let opts = update::ApplyOptions {
current_version: from.clone(),
registry_origin,
download_timeout,
force_cargo_install: req.force_cargo_install,
mode: ApplyMode::Rpc,
};
run_apply_in_daemon(state_for_task, opts, severity).await;
});
Ok((
StatusCode::ACCEPTED,
Json(AdminUpdateResponse {
started: true,
from: current_version,
to: response_to,
stream_url: "/admin/update/status",
}),
)
.into_response())
}
async fn handle_admin_update_status(
State(state): State<Arc<AppState>>,
) -> Json<UpdateStatusSnapshot> {
let snap = state
.update_status
.lock()
.expect("status mutex poisoned")
.clone();
Json(snap)
}
pub(crate) async fn run_apply_in_daemon(
state: Arc<AppState>,
opts: update::ApplyOptions,
severity: update::Severity,
) {
let started = std::time::Instant::now();
let started_at = install_state::now_rfc3339();
let stamp_stage = |stage: ApplyStage| {
let mut snap = state.update_status.lock().expect("status mutex poisoned");
snap.stage = Some(stage);
};
let mark_failed = |stage: ApplyStage, reason: String| {
let mut snap = state.update_status.lock().expect("status mutex poisoned");
snap.status = UpdateStatusKind::Failed;
snap.stage = Some(stage);
snap.error = Some(reason);
snap.ended_at = Some(install_state::now_rfc3339());
};
let mark_completed = |duration_ms: u64| {
let mut snap = state.update_status.lock().expect("status mutex poisoned");
snap.status = UpdateStatusKind::Completed;
snap.stage = None;
snap.ended_at = Some(install_state::now_rfc3339());
snap.error = None;
let _ = duration_ms;
};
stamp_stage(ApplyStage::Check);
let artefacts = match update::prepare_swap_artefacts(&opts).await {
Ok(a) => a,
Err(ApplyResult::UpToDate { current }) => {
tracing::info!(target: "update", current = %current, "concurrent check found us up-to-date — releasing lock");
mark_completed(started.elapsed().as_millis() as u64);
release_lock(&state);
return;
}
Err(ApplyResult::RefusedCargoInstall { suggestion }) => {
mark_failed(ApplyStage::Check, suggestion);
release_lock(&state);
return;
}
Err(ApplyResult::Failed { stage, reason }) => {
mark_failed(stage, reason);
release_lock(&state);
return;
}
Err(ApplyResult::Applied { .. }) => unreachable!("prepare can't return Applied"),
};
stamp_stage(ApplyStage::Swap);
let hook_path = match update::locate_hook_binary() {
Ok(p) => p,
Err(e) => {
mark_failed(ApplyStage::Swap, format!("locate hook: {e}"));
release_lock(&state);
return;
}
};
let _swap_handle =
match update::perform_swap(&artefacts.staging_exe, &artefacts.staging_hook, &hook_path) {
Ok(h) => h,
Err(e) => {
mark_failed(ApplyStage::Swap, e.to_string());
release_lock(&state);
return;
}
};
let sentinel = update::UpdateSentinel {
from: artefacts.from.clone(),
to: artefacts.to.clone(),
applied_at: started_at.clone(),
};
if let Err(e) = update::write_sentinel(&sentinel) {
let rollback = update::rollback_from_bak();
tracing::error!(
target: "update",
error = %e,
rollback_error = ?rollback.as_ref().err(),
"sentinel write failed post-swap; rolled back the swap to keep the safety net intact",
);
mark_failed(
ApplyStage::Swap,
format!("sentinel write failed post-swap: {e}"),
);
release_lock(&state);
return;
}
stamp_stage(ApplyStage::Drain);
tracing::info!(target: "update", "draining axum prior to restart");
state.admin_shutdown_request.notify_waiters();
let drain_deadline = tokio::time::Instant::now() + Duration::from_secs(5);
tokio::time::sleep_until(drain_deadline).await;
tokio::time::sleep(Duration::from_secs(1)).await;
let duration_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
crate::telemetry::capture_global(crate::telemetry::Event::update_completed(
&artefacts.from,
&artefacts.to,
severity.as_str(),
opts.mode.as_str(),
true,
Some(duration_ms),
false,
));
mark_completed(duration_ms);
stamp_stage(ApplyStage::Restart);
if let Err(e) = update::restart_into_new_binary() {
mark_failed(ApplyStage::Restart, e);
release_lock(&state);
}
}
fn release_lock(state: &AppState) {
state.update_in_progress.store(false, Ordering::Release);
}
#[derive(Debug, Serialize)]
pub struct InventoryInspectResponse {
pub source_id: String,
pub cache_entry: Option<InventoryInspectEntry>,
pub alerts: Vec<crate::daemon::config_monitor::PendingAlert>,
}
#[derive(Debug, Serialize)]
pub struct InventoryInspectEntry {
pub agent: String,
pub kind: String,
pub content_hash: String,
pub path_hash: String,
pub path: String,
}
async fn handle_inventory_inspect(
State(state): State<Arc<AppState>>,
axum::extract::Path(source_id): axum::extract::Path<String>,
) -> Json<InventoryInspectResponse> {
let cache_entry = state
.content_hash_cache
.snapshot()
.into_iter()
.find_map(|e| {
let path_hash_hex = hex::encode(e.path_hash);
let id = format!("{}:{}:{}", e.agent, e.kind, path_hash_hex);
if id == source_id {
Some(InventoryInspectEntry {
agent: e.agent,
kind: e.kind,
content_hash: hex::encode(e.content_hash),
path_hash: path_hash_hex,
path: e.path.display().to_string(),
})
} else {
None
}
});
let alerts: Vec<_> = state
.pending_alerts
.snapshot()
.into_iter()
.filter(|a| a.source_id == source_id)
.collect();
Json(InventoryInspectResponse {
source_id,
cache_entry,
alerts,
})
}
#[derive(Debug, Serialize)]
pub struct InventoryProjectsResponse {
pub projects: Vec<String>,
}
async fn handle_inventory_projects(
State(state): State<Arc<AppState>>,
) -> Json<InventoryProjectsResponse> {
let mut roots: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for e in state.content_hash_cache.snapshot() {
if let Some(parent) = e.path.parent() {
roots.insert(parent.display().to_string());
}
}
Json(InventoryProjectsResponse {
projects: roots.into_iter().collect(),
})
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct InventoryAckRequest {
pub alert_id: Option<String>,
}
async fn handle_inventory_ack(
State(state): State<Arc<AppState>>,
body: Option<Json<InventoryAckRequest>>,
) -> Json<serde_json::Value> {
let req = body.map(|Json(b)| b).unwrap_or_default();
let removed = state.pending_alerts.ack(req.alert_id.as_deref());
Json(serde_json::json!({
"acknowledged": removed,
"alert_id": req.alert_id,
}))
}