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.labels.is_empty() {
105        lines.push(format!("[Isolation] Labels: {}", options.labels.join(", ")));
106    }
107    if let Some(line) = crate::docker_resource_options::log_line(
108        &crate::docker_resource_options::specs(options),
109        &options.resolved_limits,
110    ) {
111        lines.push(line);
112    }
113    lines.extend(recovery_status_lines_with_delay(
114        options.on_kill_resume,
115        options.recovery_command.as_deref(),
116        options.on_kill_resume_delay.as_deref(),
117    ));
118    lines
119}
120
121/// `[Isolation]` line announcing launch-time kill recovery (issue #176).
122/// A recovery command on its own implies one attempt.
123pub fn recovery_status_lines(
124    on_kill_resume: Option<u32>,
125    recovery_command: Option<&str>,
126) -> Vec<String> {
127    recovery_status_lines_with_delay(on_kill_resume, recovery_command, None)
128}
129
130/// [`recovery_status_lines`] that also names the random delay before each
131/// resume (issue #181).
132pub fn recovery_status_lines_with_delay(
133    on_kill_resume: Option<u32>,
134    recovery_command: Option<&str>,
135    on_kill_resume_delay: Option<&str>,
136) -> Vec<String> {
137    let Some(count) = effective_on_kill_resume(on_kill_resume, recovery_command) else {
138        return Vec::new();
139    };
140    let after = effective_on_kill_resume_delay(on_kill_resume_delay)
141        .map(|delay| format!(" after a random {}s delay", delay))
142        .unwrap_or_default();
143    vec![format!(
144        "[Isolation] On kill: resume up to {} time(s){} with {}",
145        count,
146        after,
147        recovery_command.unwrap_or("the original command")
148    )]
149}
150
151fn effective_on_kill_resume(
152    on_kill_resume: Option<u32>,
153    recovery_command: Option<&str>,
154) -> Option<u32> {
155    on_kill_resume
156        .filter(|count| *count > 0)
157        .or_else(|| recovery_command.map(|_| 1))
158}
159
160/// Execution-record metadata for launch-time kill recovery (issue #176).
161pub fn recovery_metadata(
162    on_kill_resume: Option<u32>,
163    recovery_command: Option<&str>,
164) -> Vec<(String, serde_json::Value)> {
165    recovery_metadata_with_delay(on_kill_resume, recovery_command, None)
166}
167
168/// [`recovery_metadata`] plus the normalized `onKillResumeDelay` range
169/// (issue #181), stored only when it is not zero.
170pub fn recovery_metadata_with_delay(
171    on_kill_resume: Option<u32>,
172    recovery_command: Option<&str>,
173    on_kill_resume_delay: Option<&str>,
174) -> Vec<(String, serde_json::Value)> {
175    let mut entries = Vec::new();
176    let Some(count) = effective_on_kill_resume(on_kill_resume, recovery_command) else {
177        return entries;
178    };
179    entries.push(("onKillResume".to_string(), serde_json::json!(count)));
180    if let Some(command) = recovery_command {
181        entries.push(("recoveryCommand".to_string(), serde_json::json!(command)));
182    }
183    if let Some(delay) = effective_on_kill_resume_delay(on_kill_resume_delay) {
184        entries.push(("onKillResumeDelay".to_string(), serde_json::json!(delay)));
185    }
186    entries
187}
188
189/// Build the execution-record metadata entries for docker runtime options.
190/// Returns `(key, value)` pairs to merge into the options map; empty
191/// collections and a false `privileged` flag contribute no entries.
192pub fn docker_runtime_metadata(
193    volumes: &[String],
194    mounts: &[String],
195    env: &[String],
196    privileged: bool,
197    network: Option<&str>,
198    networks: &[String],
199    network_aliases: &[String],
200) -> Vec<(String, serde_json::Value)> {
201    let arr = |items: &[String]| {
202        serde_json::Value::Array(
203            items
204                .iter()
205                .map(|s| serde_json::Value::String(s.clone()))
206                .collect(),
207        )
208    };
209    let mut entries = Vec::new();
210    if !volumes.is_empty() {
211        entries.push(("volumes".to_string(), arr(volumes)));
212    }
213    if !mounts.is_empty() {
214        entries.push(("mounts".to_string(), arr(mounts)));
215    }
216    if !env.is_empty() {
217        entries.push(("env".to_string(), arr(env)));
218    }
219    if privileged {
220        entries.push(("privileged".to_string(), serde_json::Value::Bool(true)));
221    }
222    let resolved_networks = if networks.is_empty() {
223        network.into_iter().collect::<Vec<_>>()
224    } else {
225        networks.iter().map(String::as_str).collect::<Vec<_>>()
226    };
227    if let Some(network) = resolved_networks.first() {
228        entries.push((
229            "network".to_string(),
230            serde_json::Value::String((*network).to_string()),
231        ));
232        entries.push((
233            "networks".to_string(),
234            serde_json::Value::Array(
235                resolved_networks
236                    .iter()
237                    .map(|value| serde_json::Value::String((*value).to_string()))
238                    .collect(),
239            ),
240        ));
241    }
242    if !network_aliases.is_empty() {
243        entries.push(("networkAliases".to_string(), arr(network_aliases)));
244    }
245    entries
246}
247
248/// Build the execution-record options map describing how an isolated command
249/// was launched (environment, mode, session, image, docker runtime options,
250/// endpoint, user, keep-alive). Used to persist the execution record so it can
251/// be surfaced via `--status`/`--list`.
252pub fn build_isolation_options_map(
253    environment: Option<&str>,
254    mode: &str,
255    session_name: &str,
256    effective_image: Option<&str>,
257    options: &WrapperOptions,
258    created_user: Option<&str>,
259) -> HashMap<String, serde_json::Value> {
260    let str_val = |s: &str| serde_json::Value::String(s.to_string());
261    let mut opts_map = HashMap::new();
262    if !options.labels.is_empty() {
263        opts_map.insert("labels".to_string(), serde_json::json!(options.labels));
264    }
265    if environment == Some("docker") && mode == "detached" {
266        opts_map.insert("commandHandoff".to_string(), serde_json::json!(true));
267    }
268    if let Some(env) = environment {
269        opts_map.insert("isolated".to_string(), str_val(env));
270    }
271    opts_map.insert("isolationMode".to_string(), str_val(mode));
272    opts_map.insert("sessionName".to_string(), str_val(session_name));
273    if let Some(v) = effective_image {
274        opts_map.insert("image".to_string(), str_val(v));
275    }
276    for (k, v) in docker_runtime_metadata(
277        &options.volumes,
278        &options.mounts,
279        &options.env,
280        options.privileged,
281        options.network.as_deref(),
282        &options.networks,
283        &options.network_aliases,
284    )
285    .into_iter()
286    .chain(recovery_metadata_with_delay(
287        options.on_kill_resume,
288        options.recovery_command.as_deref(),
289        options.on_kill_resume_delay.as_deref(),
290    )) {
291        opts_map.insert(k, v);
292    }
293    if let Some(config) = &options.cpu_penalty_config {
294        opts_map.insert("cpuPenaltyConfig".into(), serde_json::json!(config));
295        opts_map.insert(
296            "baseResourceLimits".into(),
297            serde_json::json!(options.resource_limits),
298        );
299    }
300    opts_map.insert(
301        "resourceLimits".into(),
302        serde_json::json!(options.resource_limits),
303    );
304    opts_map.insert("resolvedLimits".into(), options.resolved_limits.clone());
305    opts_map.insert(
306        "resourceLimitSpecs".into(),
307        crate::docker_resource_options::specs(options),
308    );
309    if let Some(value) = &options.on_kill_resume_memory {
310        opts_map.insert("onKillResumeMemory".into(), serde_json::json!(value));
311    }
312    if let Some(v) = &options.endpoint {
313        opts_map.insert("endpoint".to_string(), str_val(v));
314    }
315    if let Some(v) = created_user {
316        opts_map.insert("user".to_string(), str_val(v));
317    }
318    opts_map.insert(
319        "keepAlive".to_string(),
320        serde_json::Value::Bool(options.keep_alive),
321    );
322    opts_map.insert(
323        "autoRemoveDockerContainer".to_string(),
324        serde_json::Value::Bool(options.auto_remove_docker_container),
325    );
326    opts_map.insert(
327        "alwaysCleanupContainer".to_string(),
328        serde_json::Value::Bool(options.always_cleanup_container),
329    );
330    opts_map.insert(
331        "keepContainer".to_string(),
332        serde_json::Value::Bool(options.keep_container),
333    );
334    opts_map.insert(
335        "keepContainerOnFail".to_string(),
336        serde_json::Value::Bool(options.keep_container_on_fail),
337    );
338    opts_map
339}
340
341#[cfg(test)]
342#[path = "isolation_metadata_cases.rs"]
343mod tests;