1use crate::identity::{validate_name, ContainerIdentity, ContainerRef};
7use crate::ContainerError;
8use std::process::Command;
9use std::time::Duration;
10use strop_core::process::{
11 capture_with, stream_with, CaptureError, CapturePolicy, StdinPolicy, StreamError, StreamPolicy,
12};
13use strop_core::worker::CancelToken;
14
15const INFO_DEADLINE: Duration = Duration::from_secs(10);
17const META_DEADLINE: Duration = Duration::from_secs(15);
19pub(crate) const READ_DEADLINE: Duration = Duration::from_secs(30);
21const STDERR_LIMIT: u64 = 64 * 1024;
23const META_LIMIT: u64 = 4 * 1024 * 1024;
25pub(crate) const LIST_LIMIT: u64 = 16 * 1024 * 1024;
31
32#[derive(Debug, Clone)]
38pub struct EngineRef {
39 server_version: String,
40}
41
42impl EngineRef {
43 pub fn server_version(&self) -> &str {
45 &self.server_version
46 }
47}
48
49pub struct Captured {
52 pub code: Option<i32>,
53 pub stdout: Vec<u8>,
54 pub stderr: Vec<u8>,
55 pub stdout_dropped: u64,
56}
57
58pub(crate) fn capture(
62 args: &[&str],
63 stdout_limit: u64,
64 deadline: Duration,
65 token: &CancelToken,
66) -> Result<Captured, ContainerError> {
67 let mut command = Command::new("docker");
68 command.args(args);
69 let policy = CapturePolicy {
70 stdout_limit,
71 stderr_limit: STDERR_LIMIT,
72 stderr_tail: 0,
73 deadline,
74 stdin: StdinPolicy::Null,
75 };
76 let output = capture_with(&mut command, token, &policy).map_err(|error| match error {
77 CaptureError::Spawn(detail) => ContainerError::EngineUnavailable { detail },
78 CaptureError::Cancelled => ContainerError::Cancelled,
79 CaptureError::TimedOut(deadline) => ContainerError::Io {
80 detail: format!(
81 "docker {} timed out after {}s",
82 args.first().copied().unwrap_or("<none>"),
83 deadline.as_secs()
84 ),
85 },
86 CaptureError::Failure(failure) => ContainerError::Io {
87 detail: failure.message,
88 },
89 })?;
90 Ok(Captured {
91 code: output.status.code(),
92 stdout: output.stdout,
93 stderr: output.stderr,
94 stdout_dropped: output.stdout_dropped,
95 })
96}
97
98pub(crate) struct Streamed {
100 pub code: Option<i32>,
101 pub stderr: Vec<u8>,
102}
103
104pub(crate) fn stream(
111 args: &[&str],
112 deadline: Duration,
113 token: &CancelToken,
114 consume: impl FnMut(&[u8]) -> Result<(), ContainerError>,
115) -> Result<Streamed, ContainerError> {
116 let mut command = Command::new("docker");
117 command.args(args);
118 let policy = StreamPolicy {
119 stderr_limit: STDERR_LIMIT,
120 stderr_tail: 0,
121 deadline,
122 hold_stdin: false,
123 };
124 let output =
125 stream_with(&mut command, token, &policy, consume).map_err(|error| match error {
126 StreamError::Spawn(detail) => ContainerError::EngineUnavailable { detail },
127 StreamError::Cancelled => ContainerError::Cancelled,
128 StreamError::TimedOut(deadline) => ContainerError::Io {
129 detail: format!(
130 "docker {} timed out after {}s",
131 args.first().copied().unwrap_or("<none>"),
132 deadline.as_secs()
133 ),
134 },
135 StreamError::Failure(failure) => ContainerError::Io {
136 detail: failure.message,
137 },
138 StreamError::Consumer(error) => error,
139 })?;
140 Ok(Streamed {
141 code: output.status.code(),
142 stderr: output.stderr,
143 })
144}
145
146pub(crate) fn stderr_tail(stderr: &[u8]) -> String {
149 let text = String::from_utf8_lossy(stderr);
150 let tail: String = text.chars().rev().take(300).collect::<String>();
151 tail.chars().rev().collect::<String>().replace('\n', " ")
152}
153
154pub fn engine(token: &CancelToken) -> Result<EngineRef, ContainerError> {
158 let output = capture(
159 &["info", "--format", "{{json .ServerVersion}}"],
160 META_LIMIT,
161 INFO_DEADLINE,
162 token,
163 )?;
164 if output.code != Some(0) {
165 return Err(ContainerError::EngineUnavailable {
166 detail: stderr_tail(&output.stderr),
167 });
168 }
169 let server_version: String =
170 serde_json::from_slice(&output.stdout).map_err(|error| ContainerError::Protocol {
171 detail: format!("docker info reported no server version: {error}"),
172 })?;
173 if server_version.is_empty() {
174 return Err(ContainerError::Protocol {
175 detail: "docker info reported an empty server version".into(),
176 });
177 }
178 Ok(EngineRef { server_version })
179}
180
181pub fn list_running(
185 engine: &EngineRef,
186 token: &CancelToken,
187) -> Result<Vec<ContainerIdentity>, ContainerError> {
188 let _ = engine;
189 let output = capture(
190 &["ps", "--quiet", "--no-trunc"],
191 META_LIMIT,
192 META_DEADLINE,
193 token,
194 )?;
195 if output.code != Some(0) {
196 return Err(ContainerError::EngineUnavailable {
197 detail: stderr_tail(&output.stderr),
198 });
199 }
200 let text = String::from_utf8(output.stdout).map_err(|_| ContainerError::Protocol {
201 detail: "docker ps answered in non-UTF-8".into(),
202 })?;
203 let ids: Vec<&str> = text.lines().filter(|line| !line.is_empty()).collect();
204 for id in &ids {
205 if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
206 return Err(ContainerError::Protocol {
207 detail: format!("docker ps reported a non-canonical id {id:?}"),
208 });
209 }
210 }
211 if ids.is_empty() {
212 return Ok(Vec::new());
213 }
214 let mut args = vec!["inspect"];
215 args.extend(ids);
216 let output = capture(&args, META_LIMIT, META_DEADLINE, token)?;
217 inspect_records("docker ps ids", &output)?
218 .into_iter()
219 .filter(|record| record.state.running)
220 .map(identity)
221 .collect()
222}
223
224pub fn inspect(
229 engine: &EngineRef,
230 name_or_id: &str,
231 token: &CancelToken,
232) -> Result<ContainerIdentity, ContainerError> {
233 let _ = engine;
234 validate_name(name_or_id)?;
235 let output = capture(&["inspect", name_or_id], META_LIMIT, META_DEADLINE, token)?;
236 identity(inspect_record(name_or_id, &output)?)
237}
238
239pub fn revalidate(
244 engine: &EngineRef,
245 held: &ContainerIdentity,
246 token: &CancelToken,
247) -> Result<ContainerRef, ContainerError> {
248 let current = inspect(engine, &held.name, token)?;
249 if current.id != held.id || current.started_at != held.started_at {
250 return Err(ContainerError::StaleIdentity {
251 name: held.name.clone(),
252 expected: format!("{}@{}", held.id, held.started_at),
253 found: format!("{}@{}", current.id, current.started_at),
254 });
255 }
256 ContainerRef::of(¤t)
257}
258
259pub(crate) fn refresh(
264 engine: &EngineRef,
265 reference: &ContainerRef,
266 token: &CancelToken,
267) -> Result<(), ContainerError> {
268 let _ = engine;
269 let id = reference.id().as_str();
270 let output = capture(&["inspect", id], META_LIMIT, META_DEADLINE, token)?;
271 let record = inspect_record(id, &output)?;
272 if record.state.started_at != reference.started_at() {
273 return Err(ContainerError::StaleIdentity {
274 name: id.to_string(),
275 expected: reference.incarnation(),
276 found: format!("{id}@{}", record.state.started_at),
277 });
278 }
279 if !record.state.running {
280 return Err(ContainerError::NotRunning { id: id.to_string() });
281 }
282 Ok(())
283}
284
285#[derive(serde::Deserialize)]
287struct InspectRecord {
288 #[serde(rename = "Id")]
289 id: String,
290 #[serde(rename = "Name", default)]
291 name: String,
292 #[serde(rename = "Config", default)]
293 config: InspectConfig,
294 #[serde(rename = "State", default)]
295 state: InspectState,
296}
297
298#[derive(Default, serde::Deserialize)]
299struct InspectConfig {
300 #[serde(rename = "Image", default)]
301 image: String,
302 #[serde(rename = "User", default)]
303 user: String,
304 #[serde(rename = "WorkingDir", default)]
307 workdir: String,
308}
309
310#[derive(Default, serde::Deserialize)]
311struct InspectState {
312 #[serde(rename = "Running", default)]
313 running: bool,
314 #[serde(rename = "StartedAt", default)]
315 started_at: String,
316}
317
318fn inspect_record(name_or_id: &str, output: &Captured) -> Result<InspectRecord, ContainerError> {
320 Ok(inspect_records(name_or_id, output)?.remove(0))
321}
322
323fn inspect_records(what: &str, output: &Captured) -> Result<Vec<InspectRecord>, ContainerError> {
326 if output.code != Some(0) {
327 let tail = stderr_tail(&output.stderr);
328 if tail.to_ascii_lowercase().contains("no such object") {
330 return Err(ContainerError::NoSuchContainer {
331 name: what.to_string(),
332 });
333 }
334 return Err(ContainerError::Io {
335 detail: format!("docker inspect failed: {tail}"),
336 });
337 }
338 if output.stdout_dropped > 0 {
339 return Err(ContainerError::OutputTooLarge {
340 what: "docker inspect output".into(),
341 });
342 }
343 let records: Vec<InspectRecord> =
344 serde_json::from_slice(&output.stdout).map_err(|error| ContainerError::Protocol {
345 detail: format!("docker inspect answered malformed JSON: {error}"),
346 })?;
347 if records.is_empty() {
348 return Err(ContainerError::Protocol {
349 detail: format!("docker inspect of {what} returned no record"),
350 });
351 }
352 Ok(records)
353}
354
355fn identity(record: InspectRecord) -> Result<ContainerIdentity, ContainerError> {
359 strop_workspace::ContainerId::canonical(record.id.clone()).map_err(|_| {
360 ContainerError::Protocol {
361 detail: format!("inspect id {:?} is not the canonical 64-hex id", record.id),
362 }
363 })?;
364 Ok(ContainerIdentity {
365 id: record.id,
366 name: record.name.trim_start_matches('/').to_string(),
367 image: record.config.image,
368 started_at: record.state.started_at,
369 user: record.config.user,
370 workdir: record.config.workdir,
371 })
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn captured(code: Option<i32>, stdout: &[u8], stderr: &[u8]) -> Captured {
379 Captured {
380 code,
381 stdout: stdout.to_vec(),
382 stderr: stderr.to_vec(),
383 stdout_dropped: 0,
384 }
385 }
386
387 const INSPECT_JSON: &str = r#"[{
388 "Id": "5b04229f99d2c4b8ae3c4e38b7a887cf1c5c1ea51f2a1b2c3d4e5f60718293a4",
389 "Name": "/fixture",
390 "Config": {"Image": "busybox:latest", "User": "root"},
391 "State": {"Running": true, "StartedAt": "2026-09-10T08:00:00.123Z"}
392 }]"#;
393
394 #[test]
395 fn inspect_json_decodes_to_identity() {
396 let output = captured(Some(0), INSPECT_JSON.as_bytes(), b"");
397 let identity = identity(inspect_record("fixture", &output).unwrap()).unwrap();
398 assert_eq!(identity.id.len(), 64);
399 assert_eq!(identity.name, "fixture", "leading slash stripped");
400 assert_eq!(identity.image, "busybox:latest");
401 assert_eq!(identity.started_at, "2026-09-10T08:00:00.123Z");
402 assert_eq!(identity.user, "root");
403 }
404
405 #[test]
406 fn missing_object_and_daemon_errors_classify() {
407 let missing = captured(
408 Some(1),
409 b"[]",
410 b"Error response from daemon: No such object: ghost\n",
411 );
412 assert!(matches!(
413 inspect_record("ghost", &missing),
414 Err(ContainerError::NoSuchContainer { .. })
415 ));
416 let lowercase = captured(Some(1), b"[]", b"error: no such object: ghost\n");
418 assert!(matches!(
419 inspect_record("ghost", &lowercase),
420 Err(ContainerError::NoSuchContainer { .. })
421 ));
422 let down = captured(
423 Some(1),
424 b"",
425 b"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
426 );
427 assert!(matches!(
428 inspect_record("ghost", &down),
429 Err(ContainerError::Io { .. })
430 ));
431 let malformed = captured(Some(0), b"[{]", b"");
432 assert!(matches!(
433 inspect_record("ghost", &malformed),
434 Err(ContainerError::Protocol { .. })
435 ));
436 }
437
438 #[test]
439 fn non_canonical_ids_are_protocol_violations() {
440 let json = INSPECT_JSON.replace(
441 "5b04229f99d2c4b8ae3c4e38b7a887cf1c5c1ea51f2a1b2c3d4e5f60718293a4",
442 "5b04229f99d2",
443 );
444 let output = captured(Some(0), json.as_bytes(), b"");
445 assert!(matches!(
446 inspect_record("x", &output).and_then(identity),
447 Err(ContainerError::Protocol { .. })
448 ));
449 }
450
451 #[test]
452 fn stderr_tail_is_bounded_and_single_line() {
453 let noisy = format!("{}\nfinal line", "x".repeat(1000));
454 let tail = stderr_tail(noisy.as_bytes());
455 assert!(tail.len() <= 300);
456 assert!(tail.ends_with("final line"));
457 assert!(!tail.contains('\n'));
458 }
459}