Skip to main content

start_command/
isolation_log.rs

1//! Logging and utility functions for isolation runners
2
3use std::env;
4use std::fs::{self, File, OpenOptions};
5use std::io::{Read, Seek, SeekFrom, Write};
6use std::path::PathBuf;
7
8/// Generate timestamp for logging
9pub fn get_timestamp() -> String {
10    chrono::Utc::now()
11        .format("%Y-%m-%d %H:%M:%S%.3f")
12        .to_string()
13}
14
15/// Generate unique log filename
16pub fn generate_log_filename(environment: &str) -> String {
17    let timestamp = std::time::SystemTime::now()
18        .duration_since(std::time::UNIX_EPOCH)
19        .unwrap()
20        .as_millis();
21    let random: String = (0..6)
22        .map(|_| {
23            let idx = (std::time::SystemTime::now()
24                .duration_since(std::time::UNIX_EPOCH)
25                .unwrap()
26                .as_nanos()
27                % 36) as u8;
28            if idx < 10 {
29                (b'0' + idx) as char
30            } else {
31                (b'a' + idx - 10) as char
32            }
33        })
34        .collect();
35    format!("start-command-{}-{}-{}.log", environment, timestamp, random)
36}
37
38/// Root directory for start-command temporary files.
39pub fn get_temp_root() -> PathBuf {
40    env::var("START_TEMP_ROOT")
41        .map(PathBuf::from)
42        .unwrap_or_else(|_| env::temp_dir().join("start-command"))
43}
44
45/// Log header parameters
46#[derive(Debug)]
47pub struct LogHeaderParams {
48    pub command: String,
49    pub environment: String,
50    pub mode: String,
51    pub session_name: String,
52    pub image: Option<String>,
53    pub user: Option<String>,
54    pub start_time: String,
55}
56
57/// Create log content header
58pub fn create_log_header(params: &LogHeaderParams) -> String {
59    let mut content = String::new();
60    content.push_str("=== Start Command Log ===\n");
61    content.push_str(&format!("Timestamp: {}\n", params.start_time));
62    content.push_str(&format!("Command: {}\n", params.command));
63    content.push_str(&format!("Environment: {}\n", params.environment));
64    content.push_str(&format!("Mode: {}\n", params.mode));
65    content.push_str(&format!("Session: {}\n", params.session_name));
66    if let Some(ref image) = params.image {
67        content.push_str(&format!("Image: {}\n", image));
68    }
69    if let Some(ref user) = params.user {
70        content.push_str(&format!("User: {}\n", user));
71    }
72    content.push_str(&format!("Platform: {}\n", std::env::consts::OS));
73    content.push_str(&format!(
74        "Working Directory: {}\n",
75        env::current_dir().unwrap_or_default().display()
76    ));
77    content.push_str(&format!("{}\n\n", "=".repeat(50)));
78    content
79}
80
81/// Create log content footer
82pub fn create_log_footer(end_time: &str, exit_code: i32) -> String {
83    let mut content = String::new();
84    content.push_str(&format!("\n{}\n", "=".repeat(50)));
85    content.push_str(&format!("Finished: {}\n", end_time));
86    content.push_str(&format!("Exit Code: {}\n", exit_code));
87    content
88}
89
90/// Write log file
91pub fn write_log_file(log_path: &PathBuf, content: &str) -> bool {
92    if let Some(parent) = log_path.parent() {
93        if let Err(e) = fs::create_dir_all(parent) {
94            eprintln!("\nWarning: Could not create log directory: {}", e);
95            return false;
96        }
97    }
98    match File::create(log_path) {
99        Ok(mut file) => file.write_all(content.as_bytes()).is_ok(),
100        Err(e) => {
101            eprintln!("\nWarning: Could not save log file: {}", e);
102            false
103        }
104    }
105}
106
107/// Append to a log file, creating its parent directory when needed.
108pub fn append_log_file(log_path: &PathBuf, content: &str) -> bool {
109    if let Some(parent) = log_path.parent() {
110        if let Err(e) = fs::create_dir_all(parent) {
111            eprintln!("\nWarning: Could not create log directory: {}", e);
112            return false;
113        }
114    }
115    match OpenOptions::new().create(true).append(true).open(log_path) {
116        Ok(mut file) => file.write_all(content.as_bytes()).is_ok(),
117        Err(e) => {
118            eprintln!("\nWarning: Could not append log file: {}", e);
119            false
120        }
121    }
122}
123
124/// Get log directory from environment or use system temp
125pub fn get_log_dir() -> PathBuf {
126    env::var("START_LOG_DIR")
127        .map(PathBuf::from)
128        .unwrap_or_else(|_| get_temp_root().join("logs"))
129}
130
131/// Get a start-command temporary directory for sidecar files.
132pub fn get_temp_dir(segments: &[&str]) -> PathBuf {
133    let mut dir = get_temp_root().join("tmp");
134    for segment in segments {
135        dir.push(segment);
136    }
137    let _ = fs::create_dir_all(&dir);
138    dir
139}
140
141/// Create log file path
142pub fn create_log_path(environment: &str) -> PathBuf {
143    let log_dir = get_log_dir();
144    let log_filename = generate_log_filename(environment);
145    if environment == "direct" {
146        log_dir.join("direct").join(log_filename)
147    } else {
148        log_dir
149            .join("isolation")
150            .join(environment)
151            .join(log_filename)
152    }
153}
154
155/// Create stable log file path for a specific execution UUID/session ID.
156pub fn create_log_path_for_execution(environment: &str, execution_id: &str) -> PathBuf {
157    if environment == "direct" {
158        get_log_dir()
159            .join("direct")
160            .join(format!("{}.log", execution_id))
161    } else {
162        get_log_dir()
163            .join("isolation")
164            .join(environment)
165            .join(format!("{}.log", execution_id))
166    }
167}
168
169pub fn shell_quote(value: &str) -> String {
170    format!("'{}'", value.replace('\'', "'\\''"))
171}
172
173pub fn create_shell_log_footer_snippet() -> String {
174    // UTC, to match `create_log_footer()` (which formats an ISO timestamp). Two
175    // writers of the same `Finished:` line disagreeing on the timezone makes the
176    // footer useless as a source for `end_time` (issue #170.2).
177    let date_command =
178        "date -u '+%Y-%m-%d %H:%M:%S.%3N' 2>/dev/null || date -u '+%Y-%m-%d %H:%M:%S'";
179    format!(
180        "printf '\\n==================================================\\nFinished: %s\\nExit Code: %s\\n' \"$({})\" \"$__start_command_exit\"",
181        date_command
182    )
183}
184
185pub fn wrap_command_with_log_footer(command: &str, shell: &str, keep_alive: bool) -> String {
186    let after_footer = if keep_alive {
187        format!("exec {}", shell_quote(shell))
188    } else {
189        "exit \"$__start_command_exit\"".to_string()
190    };
191    format!(
192        "({}); __start_command_exit=$?; {}; {}",
193        command,
194        create_shell_log_footer_snippet(),
195        after_footer
196    )
197}
198
199/// Get the default Docker image based on the host operating system
200/// Returns an image that matches the current OS as closely as possible:
201/// - macOS: Uses alpine (since macOS cannot run in Docker)
202/// - Ubuntu/Debian: Uses ubuntu:latest
203/// - Arch Linux: Uses archlinux:latest
204/// - Other Linux: Uses the detected distro or alpine as fallback
205/// - Windows: Uses alpine (Windows containers have limited support)
206pub fn get_default_docker_image() -> String {
207    #[cfg(target_os = "macos")]
208    {
209        // macOS cannot run in Docker containers, use alpine as lightweight alternative
210        return "alpine:latest".to_string();
211    }
212
213    #[cfg(target_os = "windows")]
214    {
215        // Windows containers have limited support, use alpine for Linux containers
216        return "alpine:latest".to_string();
217    }
218
219    #[cfg(target_os = "linux")]
220    {
221        use std::fs;
222        // Try to detect the Linux distribution
223        if let Ok(os_release) = fs::read_to_string("/etc/os-release") {
224            // Check for Ubuntu
225            if os_release.contains("ID=ubuntu")
226                || os_release.contains("ID_LIKE=ubuntu")
227                || os_release.contains("ID_LIKE=debian ubuntu")
228            {
229                return "ubuntu:latest".to_string();
230            }
231
232            // Check for Debian
233            if os_release.contains("ID=debian") || os_release.contains("ID_LIKE=debian") {
234                return "debian:latest".to_string();
235            }
236
237            // Check for Arch Linux
238            if os_release.contains("ID=arch") || os_release.contains("ID_LIKE=arch") {
239                return "archlinux:latest".to_string();
240            }
241
242            // Check for Fedora
243            if os_release.contains("ID=fedora") {
244                return "fedora:latest".to_string();
245            }
246
247            // Check for CentOS/RHEL
248            if os_release.contains("ID=centos")
249                || os_release.contains("ID=rhel")
250                || os_release.contains("ID_LIKE=rhel")
251            {
252                return "centos:latest".to_string();
253            }
254
255            // Check for Alpine
256            if os_release.contains("ID=alpine") {
257                return "alpine:latest".to_string();
258            }
259        }
260
261        // Default fallback: use alpine as a lightweight, universal option
262        "alpine:latest".to_string()
263    }
264
265    // Fallback for other platforms
266    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
267    {
268        "alpine:latest".to_string()
269    }
270}
271
272/// Number of trailing bytes scanned for the terminal log footer. The footer is
273/// always appended at the very end of the log, so there is no reason to read
274/// (potentially megabytes of) command output on every `--status` call.
275pub const LOG_TAIL_BYTES: u64 = 16 * 1024;
276
277/// Number of trailing bytes scanned for a fatal marker. Wider than the footer
278/// window: V8 prints a long native stack trace after `FATAL ERROR: Reached heap
279/// limit ...`, which can push the marker well past 16 KiB from the end of the
280/// log (issue #165). The footer matcher stays anchored, so a wider window is
281/// safe for it too.
282pub const FATAL_MARKER_TAIL_BYTES: u64 = 64 * 1024;
283
284/// Read the last `bytes` bytes of a file as (lossy) UTF-8 text.
285///
286/// A partial first line is dropped: the slice can start in the middle of a
287/// line, and that fragment must not be treated as the beginning of a line by
288/// the anchored footer matcher.
289pub fn read_log_tail(log_path: &str, bytes: u64) -> Option<String> {
290    let mut file = fs::File::open(log_path).ok()?;
291    let size = file.metadata().ok()?.len();
292    let length = size.min(bytes);
293    file.seek(SeekFrom::Start(size - length)).ok()?;
294    let mut buffer = Vec::with_capacity(length as usize);
295    file.take(length).read_to_end(&mut buffer).ok()?;
296    let tail = String::from_utf8_lossy(&buffer).into_owned();
297    if size <= bytes {
298        return Some(tail);
299    }
300    Some(match tail.find('\n') {
301        Some(index) => tail[index + 1..].to_string(),
302        None => String::new(),
303    })
304}