1use std::path::PathBuf;
18
19use chrono::Utc;
20use serde_json::Value;
21
22use crate::cgroup_memory::{cgroup_shell_vars, parse_cgroup_memory_sample};
23use crate::docker_post_mortem::{normalize_docker_timestamp, shell_vars};
24use crate::execution_attempt::{append_lifecycle, read_attempt_activity, read_attempt_log_tail};
25use crate::execution_store::{
26 ExecutionRecord, ExecutionStatus, ExecutionStore, ExecutionStoreOptions,
27};
28use crate::exit_reason::{describe_exit_code_str, resolve_exit_reason};
29use crate::isolation::isolation_log::{shell_quote, FATAL_MARKER_TAIL_BYTES};
30
31pub const INTERNAL_FINALIZE_FLAG: &str = "--internal-finalize-detached-docker";
35
36pub const END_TIME_SOURCE_DOCKER_FINISHED_AT: &str = "docker-finished-at";
39pub const END_TIME_SOURCE_LOG_FOOTER: &str = "log-footer";
41pub const END_TIME_SOURCE_OBSERVED_AT: &str = "observed-at";
43
44pub const WATCHER_LOST_CONTAINER: &str = "watcher-lost-container";
48
49#[derive(Debug, Clone, Default)]
51pub struct DetachedFinalizeFacts {
52 pub exit_code: String,
53 pub oom_killed: String,
54 pub started_at: String,
55 pub finished_at: String,
56 pub container_error: String,
57 pub running: String,
59 pub cgroup_memory: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct FinalizeOutcome {
68 pub updated: bool,
69 pub reason: String,
70}
71
72pub(crate) fn normalize_bool(value: &str) -> Option<bool> {
73 match value.trim() {
74 "true" => Some(true),
75 "false" => Some(false),
76 _ => None,
77 }
78}
79
80pub(crate) fn normalize_container_error(value: &str) -> Option<String> {
81 let trimmed = value.trim();
82 if trimmed.is_empty() || trimmed == "<no value>" || trimmed == "(none)" {
83 None
84 } else {
85 Some(trimmed.to_string())
86 }
87}
88
89pub fn finalize_detached_execution(
95 store: &ExecutionStore,
96 execution_id: &str,
97 facts: &DetachedFinalizeFacts,
98) -> FinalizeOutcome {
99 finalize_detached_execution_with_attempt(store, execution_id, facts, None)
100}
101
102pub fn finalize_detached_execution_with_attempt(
103 store: &ExecutionStore,
104 execution_id: &str,
105 facts: &DetachedFinalizeFacts,
106 attempt_number: Option<u64>,
107) -> FinalizeOutcome {
108 if execution_id.is_empty() {
109 return FinalizeOutcome {
110 updated: false,
111 reason: "missing-arguments".to_string(),
112 };
113 }
114 if normalize_bool(&facts.running) == Some(true) {
115 return FinalizeOutcome {
118 updated: false,
119 reason: "still-running".to_string(),
120 };
121 }
122 let mut record = match store.get(execution_id) {
123 Some(record) => record,
124 None => {
125 return FinalizeOutcome {
126 updated: false,
127 reason: "record-not-found".to_string(),
128 }
129 }
130 };
131 if let Some(attempt) = &record.attempt {
132 let started = normalize_docker_timestamp(Some(&facts.started_at));
133 let older = started
134 .as_deref()
135 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
136 .zip(chrono::DateTime::parse_from_rfc3339(&attempt.started_at).ok())
137 .is_some_and(|(started, since)| started < since);
138 if attempt_number.is_some_and(|number| number != attempt.number)
139 || older
140 || (attempt_number.is_none() && started.is_none())
141 {
142 return FinalizeOutcome {
143 updated: false,
144 reason: "stale-attempt".into(),
145 };
146 }
147 }
148 if record.status == ExecutionStatus::Executed && record.end_time.is_some() {
149 return FinalizeOutcome {
152 updated: false,
153 reason: "already-final".to_string(),
154 };
155 }
156
157 let finished_at = normalize_docker_timestamp(Some(&facts.finished_at));
158 let described = describe_exit_code_str(&facts.exit_code);
159 let exit_code = match described.code {
162 Some(0) if finished_at.is_none() => None,
163 code => code,
164 };
165 record.status = ExecutionStatus::Executed;
166 match exit_code {
167 Some(code) => record.exit_code = Some(code),
168 None => {
169 if record.exit_code.is_none() || finished_at.is_none() {
170 record.exit_code = Some(-1);
171 }
172 }
173 }
174 let lost = finished_at.is_none();
175
176 match finished_at {
177 Some(finished_at) => {
178 record.end_time = Some(finished_at);
179 record.end_time_source = Some(END_TIME_SOURCE_DOCKER_FINISHED_AT.to_string());
180 }
181 None => {
182 let now = Utc::now().to_rfc3339();
187 record.end_time = Some(now.clone());
188 record.end_time_source = Some(END_TIME_SOURCE_OBSERVED_AT.to_string());
189 record.observed_at = Some(now);
190 }
191 }
192
193 if let Some(started_at) = normalize_docker_timestamp(Some(&facts.started_at)) {
194 record.container_started_at = Some(started_at);
195 }
196 if let Some(oom_killed) = normalize_bool(&facts.oom_killed) {
197 record.oom_killed = Some(oom_killed);
198 }
199 if let Some(cgroup_memory) = parse_cgroup_memory_sample(&facts.cgroup_memory) {
201 record.cgroup_memory = Some(cgroup_memory);
202 }
203 let tail = read_attempt_log_tail(&record, FATAL_MARKER_TAIL_BYTES);
204 let (logged_main, daemon) = crate::exit_evidence::from_log(tail.as_deref());
205 let main = !daemon
206 && (logged_main
207 || (record.oom_killed == Some(true)
208 && record.exit_code.is_some_and(|code| code == 137 || code < 0)
209 && crate::exit_evidence::recent_oom_delta(
210 &facts.cgroup_memory,
211 &facts.finished_at,
212 )));
213 record.options.insert(
214 "exitEvidence".into(),
215 serde_json::json!({"mainOom": main, "daemonRestart": daemon}),
216 );
217 let reason =
218 resolve_reason(&record).or_else(|| lost.then(|| WATCHER_LOST_CONTAINER.to_string()));
219 if let Some(reason) = reason {
220 record.exit_reason = Some(reason);
221 }
222 if let Some(error) = normalize_container_error(&facts.container_error) {
223 record
224 .options
225 .insert("containerError".to_string(), Value::String(error));
226 }
227
228 read_attempt_activity(&mut record);
229 match store.save(&record) {
230 Ok(()) => {
231 append_lifecycle(
232 &record,
233 "terminal",
234 serde_json::json!({ "status": record.status, "exitCode": record.exit_code,
235 "endTime": record.end_time, "exitReason": record.exit_reason, "oomKilled": record.oom_killed, "cgroupMemory": record.cgroup_memory }),
236 );
237 FinalizeOutcome {
238 updated: true,
239 reason: "finalized".to_string(),
240 }
241 }
242 Err(error) => FinalizeOutcome {
243 updated: false,
244 reason: format!("save-failed: {}", error),
245 },
246 }
247}
248
249fn resolve_reason(record: &ExecutionRecord) -> Option<String> {
251 let tail = if record.log_path.is_empty() {
252 None
253 } else {
254 read_attempt_log_tail(record, FATAL_MARKER_TAIL_BYTES)
255 };
256 let tail = crate::exit_evidence::record_tail(record, tail);
257 resolve_exit_reason(
258 record.exit_code,
259 tail.as_deref(),
260 record.oom_killed,
261 record.cgroup_memory.and_then(|memory| memory.oom_kills),
262 )
263}
264
265pub fn reconcile_finalized_record(
274 store: &ExecutionStore,
275 record: &ExecutionRecord,
276) -> ExecutionRecord {
277 let stored = match store.get(&record.uuid) {
278 Some(stored) => stored,
279 None => return record.clone(),
280 };
281 if stored.status != ExecutionStatus::Executed || stored.end_time.is_none() {
282 return record.clone();
283 }
284 let mut merged = stored;
285 for (key, value) in &record.options {
286 merged.options.insert(key.clone(), value.clone());
287 }
288 merged
289}
290
291pub fn build_detached_finalize_snippet(execution_id: &str) -> String {
297 build_detached_finalize_snippet_with_attempt(execution_id, None)
298}
299
300pub fn build_detached_finalize_snippet_with_attempt(
301 execution_id: &str,
302 attempt_number: Option<u64>,
303) -> String {
304 let executable = std::env::current_exe()
305 .map(|path| path.to_string_lossy().to_string())
306 .unwrap_or_else(|_| "start".to_string());
307 format!(
308 "{} {} {} \"${}\" \"${}\" \"${}\" \"${}\" \"${}\" \"${}\" \"${}\"{} >/dev/null 2>&1 || true",
309 shell_quote(&executable),
310 INTERNAL_FINALIZE_FLAG,
311 shell_quote(execution_id),
312 shell_vars::EXIT,
313 shell_vars::OOM,
314 shell_vars::STARTED,
315 shell_vars::FINISHED,
316 shell_vars::ERROR,
317 shell_vars::RUNNING,
318 cgroup_shell_vars::SAMPLE,
319 attempt_number.map(|number| format!(" {}", shell_quote(&number.to_string()))).unwrap_or_default(),
320 )
321}
322
323pub fn run_internal_finalize(args: &[String]) {
326 let execution_id = match args.first() {
327 Some(value) if !value.is_empty() => value.clone(),
328 _ => return,
329 };
330 if std::env::var("START_DISABLE_TRACKING").as_deref() == Ok("true") {
331 return;
332 }
333 let field = |index: usize| args.get(index).cloned().unwrap_or_default();
334 let store = ExecutionStore::with_options(ExecutionStoreOptions {
335 app_folder: std::env::var("START_APP_FOLDER").ok().map(PathBuf::from),
336 ..ExecutionStoreOptions::default()
337 });
338 finalize_detached_execution_with_attempt(
339 &store,
340 &execution_id,
341 &DetachedFinalizeFacts {
342 exit_code: field(1),
343 oom_killed: field(2),
344 started_at: field(3),
345 finished_at: field(4),
346 container_error: field(5),
347 running: field(6),
348 cgroup_memory: field(7),
349 },
350 field(8).parse().ok(),
351 );
352}