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::container_removal::{
24 mark_container_removed, CONTAINER_REMOVED_ENV, CONTAINER_STATE_REMOVED,
25};
26use crate::docker_post_mortem::{normalize_docker_timestamp, shell_vars};
27use crate::execution_attempt::{append_lifecycle, read_attempt_activity, read_attempt_log_tail};
28use crate::execution_store::{
29 ExecutionRecord, ExecutionStatus, ExecutionStore, ExecutionStoreOptions,
30};
31use crate::exit_reason::{describe_exit_code_str, resolve_exit_reason};
32use crate::isolation::isolation_log::{shell_quote, FATAL_MARKER_TAIL_BYTES};
33
34pub const INTERNAL_FINALIZE_FLAG: &str = "--internal-finalize-detached-docker";
38
39pub const END_TIME_SOURCE_DOCKER_FINISHED_AT: &str = "docker-finished-at";
42pub const END_TIME_SOURCE_LOG_FOOTER: &str = "log-footer";
44pub const END_TIME_SOURCE_OBSERVED_AT: &str = "observed-at";
46
47pub const WATCHER_LOST_CONTAINER: &str = "watcher-lost-container";
51
52#[derive(Debug, Clone, Default)]
54pub struct DetachedFinalizeFacts {
55 pub exit_code: String,
56 pub oom_killed: String,
57 pub started_at: String,
58 pub finished_at: String,
59 pub container_error: String,
60 pub running: String,
62 pub cgroup_memory: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct FinalizeOutcome {
71 pub updated: bool,
72 pub reason: String,
73}
74
75pub(crate) fn normalize_bool(value: &str) -> Option<bool> {
76 match value.trim() {
77 "true" => Some(true),
78 "false" => Some(false),
79 _ => None,
80 }
81}
82
83pub(crate) fn normalize_container_error(value: &str) -> Option<String> {
84 let trimmed = value.trim();
85 if trimmed.is_empty() || trimmed == "<no value>" || trimmed == "(none)" {
86 None
87 } else {
88 Some(trimmed.to_string())
89 }
90}
91
92pub fn finalize_detached_execution(
98 store: &ExecutionStore,
99 execution_id: &str,
100 facts: &DetachedFinalizeFacts,
101) -> FinalizeOutcome {
102 finalize_detached_execution_with_attempt(store, execution_id, facts, None)
103}
104
105pub fn finalize_detached_execution_with_attempt(
106 store: &ExecutionStore,
107 execution_id: &str,
108 facts: &DetachedFinalizeFacts,
109 attempt_number: Option<u64>,
110) -> FinalizeOutcome {
111 finalize_detached_execution_with_removal(store, execution_id, facts, attempt_number, false)
112}
113
114pub fn finalize_detached_execution_with_removal(
117 store: &ExecutionStore,
118 execution_id: &str,
119 facts: &DetachedFinalizeFacts,
120 attempt_number: Option<u64>,
121 container_removed: bool,
122) -> FinalizeOutcome {
123 if execution_id.is_empty() {
124 return FinalizeOutcome {
125 updated: false,
126 reason: "missing-arguments".to_string(),
127 };
128 }
129 if normalize_bool(&facts.running) == Some(true) {
130 return FinalizeOutcome {
133 updated: false,
134 reason: "still-running".to_string(),
135 };
136 }
137 let mut record = match store.get(execution_id) {
138 Some(record) => record,
139 None => {
140 return FinalizeOutcome {
141 updated: false,
142 reason: "record-not-found".to_string(),
143 }
144 }
145 };
146 if let Some(attempt) = &record.attempt {
147 let started = normalize_docker_timestamp(Some(&facts.started_at));
148 let older = started
149 .as_deref()
150 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
151 .zip(chrono::DateTime::parse_from_rfc3339(&attempt.started_at).ok())
152 .is_some_and(|(started, since)| started < since);
153 if attempt_number.is_some_and(|number| number != attempt.number)
154 || older
155 || (attempt_number.is_none() && started.is_none())
156 {
157 return FinalizeOutcome {
158 updated: false,
159 reason: "stale-attempt".into(),
160 };
161 }
162 }
163 if record.status == ExecutionStatus::Executed && record.end_time.is_some() {
164 if container_removed
167 && record.container_state.as_deref() != Some(CONTAINER_STATE_REMOVED)
168 && matches!(
169 store.mark_container_removed(&record.uuid, &Utc::now().to_rfc3339()),
170 Ok(Some(_))
171 )
172 {
173 return FinalizeOutcome {
174 updated: true,
175 reason: "container-removed".to_string(),
176 };
177 }
178 return FinalizeOutcome {
179 updated: false,
180 reason: "already-final".to_string(),
181 };
182 }
183
184 let finished_at = normalize_docker_timestamp(Some(&facts.finished_at));
185 let described = describe_exit_code_str(&facts.exit_code);
186 let exit_code = match described.code {
189 Some(0) if finished_at.is_none() => None,
190 code => code,
191 };
192 record.status = ExecutionStatus::Executed;
193 match exit_code {
194 Some(code) => record.exit_code = Some(code),
195 None => {
196 if record.exit_code.is_none() || finished_at.is_none() {
197 record.exit_code = Some(-1);
198 }
199 }
200 }
201 let lost = finished_at.is_none();
202
203 match finished_at {
204 Some(finished_at) => {
205 record.end_time = Some(finished_at);
206 record.end_time_source = Some(END_TIME_SOURCE_DOCKER_FINISHED_AT.to_string());
207 }
208 None => {
209 let now = Utc::now().to_rfc3339();
214 record.end_time = Some(now.clone());
215 record.end_time_source = Some(END_TIME_SOURCE_OBSERVED_AT.to_string());
216 record.observed_at = Some(now);
217 }
218 }
219
220 if let Some(started_at) = normalize_docker_timestamp(Some(&facts.started_at)) {
221 record.container_started_at = Some(started_at);
222 }
223 if let Some(oom_killed) = normalize_bool(&facts.oom_killed) {
224 record.oom_killed = Some(oom_killed);
225 }
226 if let Some(cgroup_memory) = parse_cgroup_memory_sample(&facts.cgroup_memory) {
228 record.cgroup_memory = Some(cgroup_memory);
229 }
230 let tail = read_attempt_log_tail(&record, FATAL_MARKER_TAIL_BYTES);
231 let (logged_main, daemon) = crate::exit_evidence::from_log(tail.as_deref());
232 let main = !daemon
233 && (logged_main
234 || (record.oom_killed == Some(true)
235 && record.exit_code.is_some_and(|code| code == 137 || code < 0)
236 && crate::exit_evidence::recent_oom_delta(
237 &facts.cgroup_memory,
238 &facts.finished_at,
239 )));
240 record.options.insert(
241 "exitEvidence".into(),
242 crate::exit_evidence::evidence_json(tail.as_deref(), main),
243 );
244 let reason =
245 resolve_reason(&record).or_else(|| lost.then(|| WATCHER_LOST_CONTAINER.to_string()));
246 if let Some(reason) = reason {
247 record.exit_reason = Some(reason);
248 }
249 if let Some(error) = normalize_container_error(&facts.container_error) {
250 record
251 .options
252 .insert("containerError".to_string(), Value::String(error));
253 }
254 if container_removed {
255 mark_container_removed(&mut record, None);
258 }
259
260 read_attempt_activity(&mut record);
261 match store.save(&record) {
262 Ok(()) => {
263 append_lifecycle(
264 &record,
265 "terminal",
266 serde_json::json!({ "status": record.status, "exitCode": record.exit_code,
267 "endTime": record.end_time, "exitReason": record.exit_reason, "oomKilled": record.oom_killed, "cgroupMemory": record.cgroup_memory }),
268 );
269 FinalizeOutcome {
270 updated: true,
271 reason: "finalized".to_string(),
272 }
273 }
274 Err(error) => FinalizeOutcome {
275 updated: false,
276 reason: format!("save-failed: {}", error),
277 },
278 }
279}
280
281fn resolve_reason(record: &ExecutionRecord) -> Option<String> {
283 let tail = if record.log_path.is_empty() {
284 None
285 } else {
286 read_attempt_log_tail(record, FATAL_MARKER_TAIL_BYTES)
287 };
288 let tail = crate::exit_evidence::record_tail(record, tail);
289 resolve_exit_reason(
290 record.exit_code,
291 tail.as_deref(),
292 record.oom_killed,
293 record.cgroup_memory.and_then(|memory| memory.oom_kills),
294 )
295}
296
297pub fn reconcile_finalized_record(
306 store: &ExecutionStore,
307 record: &ExecutionRecord,
308) -> ExecutionRecord {
309 let stored = match store.get(&record.uuid) {
310 Some(stored) => stored,
311 None => return record.clone(),
312 };
313 if stored.status != ExecutionStatus::Executed || stored.end_time.is_none() {
314 return record.clone();
315 }
316 let mut merged = stored;
317 for (key, value) in &record.options {
318 merged.options.insert(key.clone(), value.clone());
319 }
320 merged
321}
322
323pub fn build_detached_finalize_snippet(execution_id: &str) -> String {
329 build_detached_finalize_snippet_with_attempt(execution_id, None)
330}
331
332pub fn build_detached_finalize_snippet_with_attempt(
333 execution_id: &str,
334 attempt_number: Option<u64>,
335) -> String {
336 let executable = std::env::current_exe()
337 .map(|path| path.to_string_lossy().to_string())
338 .unwrap_or_else(|_| "start".to_string());
339 format!(
340 "{}=\"${}\" {} {} {} \"${}\" \"${}\" \"${}\" \"${}\" \"${}\" \"${}\" \"${}\"{} >/dev/null 2>&1 || true",
341 CONTAINER_REMOVED_ENV,
342 shell_vars::REMOVED,
343 shell_quote(&executable),
344 INTERNAL_FINALIZE_FLAG,
345 shell_quote(execution_id),
346 shell_vars::EXIT,
347 shell_vars::OOM,
348 shell_vars::STARTED,
349 shell_vars::FINISHED,
350 shell_vars::ERROR,
351 shell_vars::RUNNING,
352 cgroup_shell_vars::SAMPLE,
353 attempt_number.map(|number| format!(" {}", shell_quote(&number.to_string()))).unwrap_or_default(),
354 )
355}
356
357pub fn run_internal_finalize(args: &[String]) {
362 let execution_id = match args.first() {
363 Some(value) if !value.is_empty() => value.clone(),
364 _ => return,
365 };
366 if std::env::var("START_DISABLE_TRACKING").as_deref() == Ok("true") {
367 return;
368 }
369 let field = |index: usize| args.get(index).cloned().unwrap_or_default();
370 let store = ExecutionStore::with_options(ExecutionStoreOptions {
371 app_folder: std::env::var("START_APP_FOLDER").ok().map(PathBuf::from),
372 ..ExecutionStoreOptions::default()
373 });
374 finalize_detached_execution_with_removal(
375 &store,
376 &execution_id,
377 &DetachedFinalizeFacts {
378 exit_code: field(1),
379 oom_killed: field(2),
380 started_at: field(3),
381 finished_at: field(4),
382 container_error: field(5),
383 running: field(6),
384 cgroup_memory: field(7),
385 },
386 field(8).parse().ok(),
387 std::env::var(CONTAINER_REMOVED_ENV)
388 .is_ok_and(|value| normalize_bool(&value) == Some(true)),
389 );
390}