use crate::store::services::DesiredState;
use crate::{bootstrap::state::AppState, engine};
use chrono::Utc;
use shared::{DeployState, Deployment, Event, ServiceStatus};
use std::collections::HashSet;
use std::time::Duration;
use tracing::{info, warn};
const STOPPED_BY_RECONCILE: &str = "container not running at read time";
const BOOT_GRACE: Duration = Duration::from_secs(10 * 60);
fn spare_during_boot(desired: DesiredState, daemon_uptime: Duration) -> bool {
desired == DesiredState::Running && daemon_uptime < BOOT_GRACE
}
fn is_reconcile_stop(dep: &Deployment) -> bool {
dep.state == DeployState::Stopped
&& dep.states_log.last().and_then(|t| t.message.as_deref()) == Some(STOPPED_BY_RECONCILE)
}
pub async fn fix_stale_live(state: &AppState, deployments: Vec<Deployment>) -> Vec<Deployment> {
let mut seen_live: HashSet<String> = HashSet::new();
let mut seen_service: HashSet<String> = HashSet::new();
let mut out = Vec::with_capacity(deployments.len());
for mut dep in deployments {
let is_newest = seen_service.insert(dep.service_id.clone());
if dep.state != DeployState::Live {
if is_newest
&& is_reconcile_stop(&dep)
&& !service_is_deploying(state, &dep.service_id).await
&& is_container_running(state, &dep.service_id).await
{
info!(
deployment_id = %dep.id,
service_id = %dep.service_id,
"reconcile: o container voltou — deployment Stopped pelo reconcile volta a Live"
);
transition_deployment(
state,
&mut dep,
DeployState::Live,
"container running again",
)
.await;
seen_live.insert(dep.service_id.clone());
}
out.push(dep);
continue;
}
if service_is_deploying(state, &dep.service_id).await {
out.push(dep);
continue;
}
if seen_live.contains(&dep.service_id) {
warn!(
deployment_id = %dep.id,
service_id = %dep.service_id,
"reconcile: deployment Live duplicado para o mesmo serviço — corrigindo para Pruning"
);
transition_deployment(
state,
&mut dep,
DeployState::Pruning,
"superseded by newer deployment",
)
.await;
out.push(dep);
continue;
}
if !is_container_running(state, &dep.service_id).await {
if service_is_deploying(state, &dep.service_id).await {
out.push(dep);
continue;
}
let desired = crate::store::services::desired_state(&state.db, &dep.service_id)
.await
.unwrap_or(DesiredState::Stopped);
if spare_during_boot(desired, state.started_at.elapsed()) {
seen_live.insert(dep.service_id.clone());
out.push(dep);
continue;
}
warn!(
deployment_id = %dep.id,
service_id = %dep.service_id,
"reconcile: deployment Live mas container não está rodando — corrigindo para Stopped"
);
transition_deployment(state, &mut dep, DeployState::Stopped, STOPPED_BY_RECONCILE)
.await;
let _ = crate::store::services::update_status(
&state.db,
&dep.service_id,
&ServiceStatus::Stopped,
None,
)
.await;
state.bus.publish(Event::ServiceStatusChanged {
service_id: dep.service_id.clone(),
status: ServiceStatus::Stopped,
});
} else {
seen_live.insert(dep.service_id.clone());
}
out.push(dep);
}
out
}
async fn service_is_deploying(state: &AppState, service_id: &str) -> bool {
matches!(
crate::store::services::get(&state.db, service_id).await,
Ok(Some(svc)) if matches!(svc.status, ServiceStatus::Deploying | ServiceStatus::Queued)
)
}
async fn transition_deployment(state: &AppState, dep: &mut Deployment, to: DeployState, msg: &str) {
let _ = crate::store::deployments::transition(
&state.db,
&dep.id,
&dep.state,
to.clone(),
Some(msg.into()),
)
.await;
state.bus.publish(Event::DeployStateChanged {
deployment_id: dep.id.clone(),
service_id: dep.service_id.clone(),
state: to.clone(),
timestamp: Utc::now(),
message: Some(msg.into()),
});
dep.state = to;
}
const RUNNING_CHECK_RETRIES: u32 = 3;
const RUNNING_CHECK_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
async fn is_container_running(state: &AppState, service_id: &str) -> bool {
let svc = match crate::store::services::get(&state.db, service_id).await {
Ok(Some(s)) => s,
_ => return false,
};
if svc.status == ServiceStatus::Stopped {
return false;
}
let Some(container_id) = svc.live_container_id else {
return false;
};
for attempt in 0..RUNNING_CHECK_RETRIES {
let running = match engine::containers::inspect(&state.docker.inner, &container_id).await {
Ok(info) => info.state.as_ref().and_then(|s| s.running).unwrap_or(false),
Err(_) => false,
};
if running {
return true;
}
if attempt + 1 < RUNNING_CHECK_RETRIES {
tokio::time::sleep(RUNNING_CHECK_DELAY).await;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn dep(state: DeployState, last_message: Option<&str>) -> Deployment {
serde_json::from_value(serde_json::json!({
"id": "dep_1", "service_id": "svc_1", "image": "rp_x",
"state": serde_json::to_value(&state).unwrap(),
"started_at": "2026-11-07T00:00:00Z", "finished_at": null,
"states_log": last_message.map(|m| vec![serde_json::json!({
"from": "Live", "to": serde_json::to_value(&state).unwrap(),
"at": "2026-11-07T00:00:00Z", "message": m,
})]).unwrap_or_default(),
}))
.expect("Deployment de teste")
}
#[test]
fn poupa_o_live_so_com_intencao_de_rodar_e_daemon_recem_subido() {
let logo = Duration::from_secs(30);
let depois = BOOT_GRACE + Duration::from_secs(1);
assert!(spare_during_boot(DesiredState::Running, logo));
assert!(
!spare_during_boot(DesiredState::Running, depois),
"passada a janela, um container que sumiu volta a ser corrigido"
);
assert!(
!spare_during_boot(DesiredState::Stopped, logo),
"serviço parado de propósito não é poupado"
);
}
#[test]
fn so_o_stopped_do_reconcile_pode_voltar_a_live() {
assert!(is_reconcile_stop(&dep(
DeployState::Stopped,
Some(STOPPED_BY_RECONCILE)
)));
assert!(!is_reconcile_stop(&dep(
DeployState::Stopped,
Some("stopped by user")
)));
assert!(!is_reconcile_stop(&dep(DeployState::Stopped, None)));
assert!(!is_reconcile_stop(&dep(
DeployState::Failed,
Some(STOPPED_BY_RECONCILE)
)));
}
}