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