1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! Corrige deployments marcados Live que já não estão no ar (`fix_stale_live`),
//! sem tocar em serviço com deploy em andamento.
/// Reconciles deployments marked as "Live" in the DB against the actual Docker
/// container state, correcting stale entries.
///
/// Rules:
/// 1. For each service, only the most recent deployment can be Live.
/// All older Live deployments for the same service → Pruning.
/// 2. For the most recent Live deployment per service, verify its container
/// is actually running. If not → Stopped.
use crate::{api::AppState, docker};
use chrono::Utc;
use shared::{DeployState, Deployment, Event, ServiceStatus};
use std::collections::HashSet;
use tracing::warn;
/// Passa para Pruning/Stopped os deployments marcados Live que já não estão no
/// ar (só o mais recente de cada serviço pode ser Live), sem tocar em serviço
/// com deploy em curso.
pub async fn fix_stale_live(state: &AppState, deployments: Vec<Deployment>) -> Vec<Deployment> {
// Collect the ID of the most-recent Live deployment per service.
// The list is already ordered by started_at DESC, so the first Live entry
// per service_id is the candidate; subsequent ones are stale.
let mut seen_live: HashSet<String> = HashSet::new();
let mut out = Vec::with_capacity(deployments.len());
for mut dep in deployments {
if dep.state != DeployState::Live {
out.push(dep);
continue;
}
// Deploy em andamento? Não toca em NADA desse serviço. O executor é a
// fonte da verdade enquanto roda: ele mesmo promove o novo Live, poda o
// antigo e escreve o status final (Running/Error).
//
// Sem esse guard o reconcile corrompia o estado durante todo deploy: ele
// roda a cada snapshot (2s, via recent_deployments) e também no
// deploy_history da aba Deployments, via o `is_container_running` abaixo
// — que retorna false enquanto o container Live antigo está parado ou o
// serviço ainda não subiu. Resultado: o Live anterior virava Stopped e o
// status do serviço era reescrito para Stopped POR CIMA do Deploying que
// o deploy_start tinha acabado de gravar. Na GUI: card "Stopped" com um
// deployment "Building" logo abaixo.
if service_is_deploying(state, &dep.service_id).await {
out.push(dep);
continue;
}
if seen_live.contains(&dep.service_id) {
// A newer Live deployment was already accepted for this service.
// This one is stale → Pruning.
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;
}
// First Live for this service: verify the container is actually running.
if !is_container_running(state, &dep.service_id).await {
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,
"container not running at read time",
)
.await;
let _ = crate::db::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
}
/// True enquanto um deploy do serviço está em curso ou esperando na fila
/// (`ServiceStatus::Deploying`/`Queued`). `Queued` é escrito por `deploy_start`
/// ao enfileirar; `Deploying` pelo worker ao pegar o deploy — ambos limpos no
/// desfecho. Reconciliar por cima disso zeraria o status (o container ainda não
/// existe), então pulamos.
async fn service_is_deploying(state: &AppState, service_id: &str) -> bool {
matches!(
crate::db::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::db::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;
}
// Um deploy recém-promovido pode ter seu container ainda propagando o estado
// "running" no Docker Engine no instante exato em que este reconcile roda
// (ex.: GUI busca a lista de deployments quase junto com o evento DeployStateChanged
// de Live). Sem essa margem, esse read prematuro derruba um deploy que na
// prática subiu certo — visto em produção com um serviço Compose.
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::db::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 docker::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
}