Skip to main content

start_command/
isolation_metadata.rs

1//! Metadata helpers for isolated executions.
2//!
3//! Builds the human-readable `[Isolation]` status lines and the execution
4//! record options map that describe how an isolated command was launched,
5//! including the configurable Docker runtime options (volumes, mounts,
6//! environment variables, privileged mode). Kept separate from `isolation`
7//! so the runtime backends and the metadata representation can evolve
8//! independently.
9
10use crate::args_parser::WrapperOptions;
11use crate::recovery_delay::effective_on_kill_resume_delay;
12use std::collections::HashMap;
13
14/// Container labels are immutable, so resume counters describe container creation.
15/// Snapshot containers get new attribution; in-place restarts retain their labels.
16pub fn docker_attribution_labels(
17    options: &crate::isolation::IsolationOptions,
18    session: &str,
19) -> Vec<String> {
20    let mut labels = options.labels.clone();
21    let mut inferred_root = session;
22    if options.resume_count > 0 {
23        while let Some((prefix, count)) = inferred_root.rsplit_once("-resume-") {
24            if count.is_empty() || !count.bytes().all(|byte| byte.is_ascii_digit()) {
25                break;
26            }
27            inferred_root = prefix;
28        }
29    }
30    let root = options.root_session.as_deref().unwrap_or(inferred_root);
31    let uuid = options
32        .uuid
33        .clone()
34        .or_else(|| options.execution_id.clone())
35        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
36    labels.extend([
37        format!("start-command.session={}", session),
38        format!("start-command.root-session={}", root),
39        format!("start-command.uuid={}", uuid),
40        format!("start-command.resume-count={}", options.resume_count),
41    ]);
42    labels
43}
44
45/// Build the human-readable `[Isolation]` status lines for docker runtime
46/// options (volumes, mounts, env, privileged). Used for the start block and
47/// log header; empty collections contribute no lines.
48pub fn docker_runtime_status_lines(
49    volumes: &[String],
50    mounts: &[String],
51    env: &[String],
52    privileged: bool,
53    network: Option<&str>,
54    networks: &[String],
55    network_aliases: &[String],
56) -> Vec<String> {
57    let mut lines = Vec::new();
58    if !volumes.is_empty() {
59        lines.push(format!("[Isolation] Volumes: {}", volumes.join(", ")));
60    }
61    if !mounts.is_empty() {
62        lines.push(format!("[Isolation] Mounts: {}", mounts.join(", ")));
63    }
64    if !env.is_empty() {
65        lines.push(format!("[Isolation] Env: {}", env.join(", ")));
66    }
67    if privileged {
68        lines.push("[Isolation] Privileged: true".to_string());
69    }
70    let resolved_networks = if networks.is_empty() {
71        network.into_iter().collect::<Vec<_>>()
72    } else {
73        networks.iter().map(String::as_str).collect::<Vec<_>>()
74    };
75    if let Some(first_network) = resolved_networks.first() {
76        lines.push(format!("[Isolation] Network: {}", first_network));
77    }
78    if resolved_networks.len() > 1 {
79        lines.push(format!(
80            "[Isolation] Networks: {}",
81            resolved_networks.join(", ")
82        ));
83    }
84    if !network_aliases.is_empty() {
85        lines.push(format!(
86            "[Isolation] Network aliases: {}",
87            network_aliases.join(", ")
88        ));
89    }
90    lines
91}
92
93/// Build Docker runtime status lines directly from parsed wrapper options.
94pub fn docker_runtime_status_lines_for_options(options: &WrapperOptions) -> Vec<String> {
95    let mut lines = docker_runtime_status_lines(
96        &options.volumes,
97        &options.mounts,
98        &options.env,
99        options.privileged,
100        options.network.as_deref(),
101        &options.networks,
102        &options.network_aliases,
103    );
104    if options.init == Some(true) {
105        // Right after Privileged, before the network lines (issue #244).
106        let at = lines
107            .iter()
108            .position(|line| line.starts_with("[Isolation] Network"))
109            .unwrap_or(lines.len());
110        lines.insert(at, "[Isolation] Init: true".to_string());
111    }
112    if !options.labels.is_empty() {
113        lines.push(format!("[Isolation] Labels: {}", options.labels.join(", ")));
114    }
115    if let Some(line) = crate::docker_resource_options::log_line(
116        &crate::docker_resource_options::specs(options),
117        &options.resolved_limits,
118    ) {
119        lines.push(line);
120    }
121    lines.extend(recovery_status_lines_with_delay(
122        options.on_kill_resume,
123        options.recovery_command.as_deref(),
124        options.on_kill_resume_delay.as_deref(),
125    ));
126    lines
127}
128
129/// `[Isolation]` line announcing launch-time kill recovery (issue #176).
130/// A recovery command on its own implies one attempt.
131pub fn recovery_status_lines(
132    on_kill_resume: Option<u32>,
133    recovery_command: Option<&str>,
134) -> Vec<String> {
135    recovery_status_lines_with_delay(on_kill_resume, recovery_command, None)
136}
137
138/// [`recovery_status_lines`] that also names the random delay before each
139/// resume (issue #181).
140pub fn recovery_status_lines_with_delay(
141    on_kill_resume: Option<u32>,
142    recovery_command: Option<&str>,
143    on_kill_resume_delay: Option<&str>,
144) -> Vec<String> {
145    let Some(count) = effective_on_kill_resume(on_kill_resume, recovery_command) else {
146        return Vec::new();
147    };
148    let after = effective_on_kill_resume_delay(on_kill_resume_delay)
149        .map(|delay| format!(" after a random {}s delay", delay))
150        .unwrap_or_default();
151    vec![format!(
152        "[Isolation] On kill: resume up to {} time(s){} with {}",
153        count,
154        after,
155        recovery_command.unwrap_or("the original command")
156    )]
157}
158
159fn effective_on_kill_resume(
160    on_kill_resume: Option<u32>,
161    recovery_command: Option<&str>,
162) -> Option<u32> {
163    on_kill_resume
164        .filter(|count| *count > 0)
165        .or_else(|| recovery_command.map(|_| 1))
166}
167
168/// Execution-record metadata for launch-time kill recovery (issue #176).
169pub fn recovery_metadata(
170    on_kill_resume: Option<u32>,
171    recovery_command: Option<&str>,
172) -> Vec<(String, serde_json::Value)> {
173    recovery_metadata_with_delay(on_kill_resume, recovery_command, None)
174}
175
176/// [`recovery_metadata`] plus the normalized `onKillResumeDelay` range
177/// (issue #181), stored only when it is not zero.
178pub fn recovery_metadata_with_delay(
179    on_kill_resume: Option<u32>,
180    recovery_command: Option<&str>,
181    on_kill_resume_delay: Option<&str>,
182) -> Vec<(String, serde_json::Value)> {
183    let mut entries = Vec::new();
184    let Some(count) = effective_on_kill_resume(on_kill_resume, recovery_command) else {
185        return entries;
186    };
187    entries.push(("onKillResume".to_string(), serde_json::json!(count)));
188    if let Some(command) = recovery_command {
189        entries.push(("recoveryCommand".to_string(), serde_json::json!(command)));
190    }
191    if let Some(delay) = effective_on_kill_resume_delay(on_kill_resume_delay) {
192        entries.push(("onKillResumeDelay".to_string(), serde_json::json!(delay)));
193    }
194    entries
195}
196
197/// Build the execution-record metadata entries for docker runtime options.
198/// Returns `(key, value)` pairs to merge into the options map; empty
199/// collections and a false `privileged` flag contribute no entries.
200pub fn docker_runtime_metadata(
201    volumes: &[String],
202    mounts: &[String],
203    env: &[String],
204    privileged: bool,
205    network: Option<&str>,
206    networks: &[String],
207    network_aliases: &[String],
208) -> Vec<(String, serde_json::Value)> {
209    let arr = |items: &[String]| {
210        serde_json::Value::Array(
211            items
212                .iter()
213                .map(|s| serde_json::Value::String(s.clone()))
214                .collect(),
215        )
216    };
217    let mut entries = Vec::new();
218    if !volumes.is_empty() {
219        entries.push(("volumes".to_string(), arr(volumes)));
220    }
221    if !mounts.is_empty() {
222        entries.push(("mounts".to_string(), arr(mounts)));
223    }
224    if !env.is_empty() {
225        entries.push(("env".to_string(), arr(env)));
226    }
227    if privileged {
228        entries.push(("privileged".to_string(), serde_json::Value::Bool(true)));
229    }
230    let resolved_networks = if networks.is_empty() {
231        network.into_iter().collect::<Vec<_>>()
232    } else {
233        networks.iter().map(String::as_str).collect::<Vec<_>>()
234    };
235    if let Some(network) = resolved_networks.first() {
236        entries.push((
237            "network".to_string(),
238            serde_json::Value::String((*network).to_string()),
239        ));
240        entries.push((
241            "networks".to_string(),
242            serde_json::Value::Array(
243                resolved_networks
244                    .iter()
245                    .map(|value| serde_json::Value::String((*value).to_string()))
246                    .collect(),
247            ),
248        ));
249    }
250    if !network_aliases.is_empty() {
251        entries.push(("networkAliases".to_string(), arr(network_aliases)));
252    }
253    entries
254}
255
256/// Build the execution-record options map describing how an isolated command
257/// was launched (environment, mode, session, image, docker runtime options,
258/// endpoint, user, keep-alive). Used to persist the execution record so it can
259/// be surfaced via `--status`/`--list`.
260pub fn build_isolation_options_map(
261    environment: Option<&str>,
262    mode: &str,
263    session_name: &str,
264    effective_image: Option<&str>,
265    options: &WrapperOptions,
266    created_user: Option<&str>,
267) -> HashMap<String, serde_json::Value> {
268    let str_val = |s: &str| serde_json::Value::String(s.to_string());
269    let mut opts_map = HashMap::new();
270    if !options.labels.is_empty() {
271        opts_map.insert("labels".to_string(), serde_json::json!(options.labels));
272    }
273    if environment == Some("docker") && mode == "detached" {
274        opts_map.insert("commandHandoff".to_string(), serde_json::json!(true));
275    }
276    if let Some(env) = environment {
277        opts_map.insert("isolated".to_string(), str_val(env));
278    }
279    opts_map.insert("isolationMode".to_string(), str_val(mode));
280    opts_map.insert("sessionName".to_string(), str_val(session_name));
281    if let Some(v) = effective_image {
282        opts_map.insert("image".to_string(), str_val(v));
283    }
284    for (k, v) in docker_runtime_metadata(
285        &options.volumes,
286        &options.mounts,
287        &options.env,
288        options.privileged,
289        options.network.as_deref(),
290        &options.networks,
291        &options.network_aliases,
292    )
293    .into_iter()
294    .chain(recovery_metadata_with_delay(
295        options.on_kill_resume,
296        options.recovery_command.as_deref(),
297        options.on_kill_resume_delay.as_deref(),
298    )) {
299        opts_map.insert(k, v);
300    }
301    if options.init == Some(true) {
302        opts_map.insert("init".into(), serde_json::Value::Bool(true));
303    }
304    if let Some(config) = &options.cpu_penalty_config {
305        opts_map.insert("cpuPenaltyConfig".into(), serde_json::json!(config));
306        opts_map.insert(
307            "baseResourceLimits".into(),
308            serde_json::json!(options.resource_limits),
309        );
310    }
311    opts_map.insert(
312        "resourceLimits".into(),
313        serde_json::json!(options.resource_limits),
314    );
315    opts_map.insert("resolvedLimits".into(), options.resolved_limits.clone());
316    opts_map.insert(
317        "resourceLimitSpecs".into(),
318        crate::docker_resource_options::specs(options),
319    );
320    if let Some(value) = &options.on_kill_resume_memory {
321        opts_map.insert("onKillResumeMemory".into(), serde_json::json!(value));
322    }
323    if let Some(v) = &options.endpoint {
324        opts_map.insert("endpoint".to_string(), str_val(v));
325    }
326    if let Some(v) = created_user {
327        opts_map.insert("user".to_string(), str_val(v));
328    }
329    opts_map.insert(
330        "keepAlive".to_string(),
331        serde_json::Value::Bool(options.keep_alive),
332    );
333    opts_map.insert(
334        "autoRemoveDockerContainer".to_string(),
335        serde_json::Value::Bool(options.auto_remove_docker_container),
336    );
337    opts_map.insert(
338        "alwaysCleanupContainer".to_string(),
339        serde_json::Value::Bool(options.always_cleanup_container),
340    );
341    opts_map.insert(
342        "keepContainer".to_string(),
343        serde_json::Value::Bool(options.keep_container),
344    );
345    opts_map.insert(
346        "keepContainerOnFail".to_string(),
347        serde_json::Value::Bool(options.keep_container_on_fail),
348    );
349    opts_map
350}
351
352#[cfg(test)]
353#[path = "isolation_metadata_cases.rs"]
354mod tests;