Skip to main content

netget/server/docker/
api.rs

1//! Docker Engine API routing and response rendering.
2//!
3//! Everything here is pure: a path in, a [`Route`] out; a model's structured answer in, the JSON
4//! document the Docker CLI decodes out. The model names the things that matter to a user —
5//! container names, images, states, ports — and NetGet fills the fields a Go decoder needs but
6//! nobody reads (`HostConfig`, `NetworkSettings`, `SharedSize`, the `Components` list) with valid
7//! defaults, so a model that forgets one cannot make `docker ps` fail to decode.
8//!
9//! Every renderer validates first and returns a reason on refusal: an unknown container state,
10//! a port outside 1..=65535, a name with characters Docker itself refuses. Recursion: none.
11
12use serde_json::{json, Map, Value};
13
14/// Oldest API version accepted in a `/v1.xx/` path prefix. The Docker CLI negotiates down to
15/// whatever `/_ping` advertises, and 1.24 is the floor every current daemon still honours.
16pub const MIN_API_VERSION: &str = "1.24";
17
18/// What a request path resolves to.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum Route {
21    /// `GET`/`HEAD /_ping` — served statically, it is how the client negotiates.
22    Ping,
23    /// `GET /version`
24    Version,
25    /// `GET /info`
26    Info,
27    /// `GET /containers/json`
28    ContainerList,
29    /// `GET /containers/{id}/json`
30    ContainerInspect(String),
31    /// `GET /images/json`
32    ImageList,
33    /// `GET /networks`
34    NetworkList,
35    /// `GET /volumes`
36    VolumeList,
37    /// A method that changes state (create, start, exec, pull, delete…). Refused with 501.
38    Mutating,
39    /// Anything else. 404.
40    NotFound,
41}
42
43impl Route {
44    /// The `resource` field of the event, or `None` for routes that never reach the model.
45    pub fn resource(&self) -> Option<&'static str> {
46        match self {
47            Route::Version => Some("version"),
48            Route::Info => Some("info"),
49            Route::ContainerList => Some("containers"),
50            Route::ContainerInspect(_) => Some("container"),
51            Route::ImageList => Some("images"),
52            Route::NetworkList => Some("networks"),
53            Route::VolumeList => Some("volumes"),
54            Route::Ping | Route::Mutating | Route::NotFound => None,
55        }
56    }
57
58    /// The one action that answers this route (besides `send_docker_error`).
59    pub fn answering_action(&self) -> Option<&'static str> {
60        match self {
61            Route::Version => Some("send_docker_version"),
62            Route::Info => Some("send_docker_info"),
63            Route::ContainerList => Some("send_docker_containers"),
64            Route::ContainerInspect(_) => Some("send_docker_container"),
65            Route::ImageList => Some("send_docker_images"),
66            Route::NetworkList => Some("send_docker_networks"),
67            Route::VolumeList => Some("send_docker_volumes"),
68            Route::Ping | Route::Mutating | Route::NotFound => None,
69        }
70    }
71
72    /// The event's `answer_with`: which action answers this request, and what it must say.
73    ///
74    /// The event description maps every resource to its action, but a small model answering
75    /// one request reads the request, not the table: asked to inspect a container that did not
76    /// exist, llama3.1:8b answered with `send_docker_version`. Naming the action, and the 404
77    /// for a missing container, in the request itself is what it follows.
78    ///
79    /// A list route names the array the action carries, not just the action: told only
80    /// "send_docker_containers with the running containers", the same model answered with
81    /// `{"type": "send_docker_containers"}` and nothing in it, 13 times in 15.
82    pub fn answer_with(&self, query: &Value) -> Option<String> {
83        let all = query.get("all").and_then(Value::as_str) == Some("1");
84        Some(match self {
85            Route::Version => "send_docker_version".to_string(),
86            Route::Info => "send_docker_info".to_string(),
87            Route::ContainerList if all => "send_docker_containers with a containers array \
88                 holding every container, running or not, each with its names, image and state \
89                 (a stopped container has state exited and its exit_code)"
90                .to_string(),
91            Route::ContainerList => "send_docker_containers with a containers array holding \
92                 the running containers, each with its names, image and state"
93                .to_string(),
94            Route::ContainerInspect(id) => format!(
95                "send_docker_container with the fields of the container named {id} (or whose \
96                 ID starts with {id}); if there is no such container, send_docker_error with \
97                 status 404 and message \"No such container: {id}\""
98            ),
99            Route::ImageList => "send_docker_images with an images array".to_string(),
100            Route::NetworkList => "send_docker_networks with a networks array".to_string(),
101            Route::VolumeList => "send_docker_volumes with a volumes array".to_string(),
102            Route::Ping | Route::Mutating | Route::NotFound => return None,
103        })
104    }
105}
106
107/// Split an optional `/v<major>.<minor>` prefix off a path.
108///
109/// Returns the version as written (`"1.45"`) and the rest of the path. A first segment that
110/// starts with `v` but is not a version is left alone, so it routes to a 404 rather than being
111/// mistaken for one.
112pub fn split_version(path: &str) -> (Option<String>, String) {
113    let trimmed = path.trim_start_matches('/');
114    let (first, rest) = match trimmed.split_once('/') {
115        Some((f, r)) => (f, format!("/{r}")),
116        None => (trimmed, "/".to_string()),
117    };
118    if let Some(v) = first.strip_prefix('v') {
119        if parse_version(v).is_some() {
120            return (Some(v.to_string()), rest);
121        }
122    }
123    (None, path.to_string())
124}
125
126/// `"1.45"` → `(1, 45)`.
127pub fn parse_version(v: &str) -> Option<(u32, u32)> {
128    let (major, minor) = v.split_once('.')?;
129    if major.is_empty() || minor.is_empty() || major.len() > 3 || minor.len() > 3 {
130        return None;
131    }
132    Some((major.parse().ok()?, minor.parse().ok()?))
133}
134
135/// Resolve a method and an unversioned path.
136pub fn resolve(method: &str, path: &str) -> Route {
137    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
138    let is_read = method == "GET" || method == "HEAD";
139    match segments.as_slice() {
140        ["_ping"] if is_read => Route::Ping,
141        _ if !is_read => Route::Mutating,
142        ["version"] => Route::Version,
143        ["info"] => Route::Info,
144        ["containers", "json"] => Route::ContainerList,
145        ["containers", id, "json"] if !id.is_empty() => Route::ContainerInspect(id.to_string()),
146        ["images", "json"] => Route::ImageList,
147        ["networks"] => Route::NetworkList,
148        ["volumes"] => Route::VolumeList,
149        _ => Route::NotFound,
150    }
151}
152
153/// The body of every Docker error: `{"message": "..."}`.
154pub fn error_body(message: &str) -> Value {
155    json!({ "message": message })
156}
157
158// ---------------------------------------------------------------------------
159// Field helpers
160// ---------------------------------------------------------------------------
161
162/// First present key among `names` — models write both `image` and Docker's own `Image`.
163fn field<'a>(obj: &'a Value, names: &[&str]) -> Option<&'a Value> {
164    names
165        .iter()
166        .find_map(|n| obj.get(*n))
167        .filter(|v| !v.is_null())
168}
169
170fn str_field(obj: &Value, names: &[&str]) -> Option<String> {
171    field(obj, names).and_then(|v| match v {
172        Value::String(s) => Some(s.clone()),
173        Value::Number(n) => Some(n.to_string()),
174        _ => None,
175    })
176}
177
178fn u64_field(obj: &Value, names: &[&str], what: &str) -> Result<Option<u64>, String> {
179    match field(obj, names) {
180        None => Ok(None),
181        Some(Value::Number(n)) => n
182            .as_u64()
183            .map(Some)
184            .ok_or_else(|| format!("{what} must be a non-negative integer")),
185        Some(Value::String(s)) => s
186            .trim()
187            .parse::<u64>()
188            .map(Some)
189            .map_err(|_| format!("{what} must be a non-negative integer, got {s:?}")),
190        Some(_) => Err(format!("{what} must be a number")),
191    }
192}
193
194fn string_map(obj: &Value, names: &[&str], what: &str) -> Result<Map<String, Value>, String> {
195    match field(obj, names) {
196        None => Ok(Map::new()),
197        Some(Value::Object(m)) => {
198            let mut out = Map::new();
199            for (k, v) in m {
200                let text = match v {
201                    Value::String(s) => s.clone(),
202                    Value::Null => String::new(),
203                    other => other.to_string(),
204                };
205                out.insert(k.clone(), Value::String(text));
206            }
207            Ok(out)
208        }
209        Some(_) => Err(format!("{what} must be an object of strings")),
210    }
211}
212
213fn string_list(obj: &Value, names: &[&str]) -> Vec<String> {
214    match field(obj, names) {
215        Some(Value::Array(a)) => a
216            .iter()
217            .filter_map(|v| v.as_str().map(str::to_string))
218            .collect(),
219        Some(Value::String(s)) => vec![s.clone()],
220        _ => Vec::new(),
221    }
222}
223
224/// A 64-hex identifier derived from `seed`, for objects the model named but gave no ID.
225///
226/// Deterministic, so the same container keeps the same ID across `ps` and `inspect`. Not a
227/// digest of anything, and nothing treats it as one.
228pub fn derived_id(seed: &str) -> String {
229    use std::hash::{Hash, Hasher};
230    let mut out = String::with_capacity(64);
231    for round in 0u8..4 {
232        let mut h = std::collections::hash_map::DefaultHasher::new();
233        (round, seed).hash(&mut h);
234        out.push_str(&format!("{:016x}", h.finish()));
235    }
236    out
237}
238
239fn valid_id(id: &str) -> bool {
240    !id.is_empty() && id.len() <= 128 && id.chars().all(|c| c.is_ascii_alphanumeric())
241}
242
243/// The ID a container or image is rendered with, from the `id` the model gave.
244///
245/// Letters and digits are used as given. A model very often writes the object's *name* as its
246/// ID (`"id": "eval-web"`); that is not an ID Docker would ever report, and it is not an error
247/// either, so a name-shaped value stands in for the seed a missing ID is derived from — the
248/// same 64-hex ID as omitting it, so `ps` and `inspect` still agree. Anything else (spaces,
249/// slashes, punctuation) is refused. Either way the client only ever receives letters and
250/// digits.
251fn id_from(given: &str, kind: &str, what: &str) -> Result<String, String> {
252    let bare = given.trim_start_matches("sha256:");
253    if valid_id(bare) {
254        Ok(bare.to_string())
255    } else if valid_name(bare) {
256        Ok(derived_id(&format!("{kind}:{bare}")))
257    } else {
258        Err(format!(
259            "{what}: id {given:?} must be letters and digits (or omit it and NetGet derives one)"
260        ))
261    }
262}
263
264/// Docker's own rule for container names: `[a-zA-Z0-9][a-zA-Z0-9_.-]+`.
265fn valid_name(name: &str) -> bool {
266    let mut chars = name.chars();
267    matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric())
268        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
269        && name.len() <= 255
270}
271
272fn now_unix() -> i64 {
273    crate::utils::clock::SystemTime::now()
274        .duration_since(crate::utils::clock::UNIX_EPOCH)
275        .map(|d| d.as_secs() as i64)
276        .unwrap_or(0)
277}
278
279/// A creation time given as unix seconds or RFC 3339, as unix seconds.
280fn created_unix(obj: &Value, what: &str) -> Result<i64, String> {
281    match field(obj, &["created", "Created", "created_at", "CreatedAt"]) {
282        None => Ok(now_unix()),
283        Some(Value::Number(n)) => n
284            .as_i64()
285            .ok_or_else(|| format!("{what} created must be unix seconds or RFC 3339")),
286        Some(Value::String(s)) => chrono::DateTime::parse_from_rfc3339(s.trim())
287            .map(|t| t.timestamp())
288            .or_else(|_| s.trim().parse::<i64>())
289            .map_err(|_| format!("{what} created {s:?} is neither unix seconds nor RFC 3339")),
290        Some(_) => Err(format!("{what} created must be unix seconds or RFC 3339")),
291    }
292}
293
294fn rfc3339(unix: i64) -> String {
295    chrono::DateTime::from_timestamp(unix, 0)
296        .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true))
297        .unwrap_or_else(|| "0001-01-01T00:00:00Z".to_string())
298}
299
300const STATES: &[&str] = &[
301    "created",
302    "running",
303    "paused",
304    "restarting",
305    "removing",
306    "exited",
307    "dead",
308];
309
310fn state_of(obj: &Value, what: &str) -> Result<String, String> {
311    let raw = match field(obj, &["state", "State"]) {
312        None => "running".to_string(),
313        Some(Value::String(s)) => s.to_ascii_lowercase(),
314        // An inspect-shaped State object from a model that copied `docker inspect`.
315        Some(Value::Object(o)) => o
316            .get("Status")
317            .or_else(|| o.get("status"))
318            .and_then(Value::as_str)
319            .unwrap_or("running")
320            .to_ascii_lowercase(),
321        Some(_) => return Err(format!("{what} state must be a string")),
322    };
323    // `docker stop` leaves a container `exited`; "stopped" is what people call that, and
324    // what a model writes when told a container is stopped.
325    let raw = if raw == "stopped" {
326        "exited".to_string()
327    } else {
328        raw
329    };
330    if STATES.contains(&raw.as_str()) {
331        Ok(raw)
332    } else {
333        Err(format!(
334            "{what} state {raw:?} is not one of {}",
335            STATES.join(", ")
336        ))
337    }
338}
339
340fn default_status(state: &str, exit_code: i64) -> String {
341    match state {
342        "running" => "Up".to_string(),
343        "paused" => "Up (Paused)".to_string(),
344        "restarting" => "Restarting".to_string(),
345        "exited" => format!("Exited ({exit_code})"),
346        "created" => "Created".to_string(),
347        "removing" => "Removal In Progress".to_string(),
348        _ => "Dead".to_string(),
349    }
350}
351
352/// The `Status` column. The model's own wording is kept, except that an exited container's
353/// always carries its exit code the way the daemon writes it — `Exited (0) 10 minutes ago` —
354/// because that code is the one fact the column exists to show, and a model writing
355/// "Exited 10 minutes ago" or "Stopped" leaves it out.
356fn status_of(given: Option<String>, state: &str, exit_code: i64) -> String {
357    let Some(given) = given.filter(|s| !s.trim().is_empty()) else {
358        return default_status(state, exit_code);
359    };
360    if state != "exited" || given.starts_with("Exited (") {
361        return given;
362    }
363    let lower = given.to_ascii_lowercase();
364    let ago = lower.find(" ago").and_then(|end| {
365        let words: Vec<&str> = given[..end].split_whitespace().collect();
366        // "… 10 minutes ago", "… About an hour ago": the last two words before "ago".
367        (words.len() >= 2)
368            .then(|| format!("{} {} ago", words[words.len() - 2], words[words.len() - 1]))
369    });
370    match ago {
371        Some(ago) => format!("Exited ({exit_code}) {ago}"),
372        None => default_status(state, exit_code),
373    }
374}
375
376/// `[{private_port, public_port?, type?, ip?}]` → the list shape `docker ps` renders.
377fn ports(obj: &Value, what: &str) -> Result<Vec<Value>, String> {
378    let Some(list) = field(obj, &["ports", "Ports"]) else {
379        return Ok(Vec::new());
380    };
381    let Value::Array(list) = list else {
382        return Err(format!("{what} ports must be an array"));
383    };
384    let mut out = Vec::with_capacity(list.len());
385    for p in list {
386        let private = u64_field(
387            p,
388            &["private_port", "PrivatePort", "container_port"],
389            "private_port",
390        )?
391        .ok_or_else(|| format!("{what} port entry needs a private_port"))?;
392        if !(1..=65535).contains(&private) {
393            return Err(format!("{what} private_port {private} is not a port"));
394        }
395        let kind = str_field(p, &["type", "Type", "protocol"])
396            .unwrap_or_else(|| "tcp".to_string())
397            .to_ascii_lowercase();
398        if !matches!(kind.as_str(), "tcp" | "udp" | "sctp") {
399            return Err(format!("{what} port type {kind:?} is not tcp, udp or sctp"));
400        }
401        let mut entry = json!({"PrivatePort": private, "Type": kind});
402        if let Some(public) = u64_field(
403            p,
404            &["public_port", "PublicPort", "host_port"],
405            "public_port",
406        )? {
407            if !(1..=65535).contains(&public) {
408                return Err(format!("{what} public_port {public} is not a port"));
409            }
410            entry["PublicPort"] = json!(public);
411            entry["IP"] = json!(
412                str_field(p, &["ip", "IP", "host_ip"]).unwrap_or_else(|| "0.0.0.0".to_string())
413            );
414        }
415        out.push(entry);
416    }
417    Ok(out)
418}
419
420/// The command as one string (`docker ps`) and as path + args (`docker inspect`).
421fn command_of(obj: &Value) -> (String, String, Vec<String>) {
422    match field(obj, &["command", "Command", "cmd", "Cmd"]) {
423        Some(Value::Array(parts)) => {
424            let parts: Vec<String> = parts
425                .iter()
426                .filter_map(|p| p.as_str().map(str::to_string))
427                .collect();
428            let path = parts.first().cloned().unwrap_or_default();
429            (parts.join(" "), path, parts.into_iter().skip(1).collect())
430        }
431        Some(Value::String(s)) => {
432            let mut words = s.split_whitespace().map(str::to_string);
433            let path = words.next().unwrap_or_default();
434            (s.clone(), path, words.collect())
435        }
436        _ => (String::new(), String::new(), Vec::new()),
437    }
438}
439
440struct Container {
441    id: String,
442    name: String,
443    image: String,
444    image_id: String,
445    command: String,
446    path: String,
447    args: Vec<String>,
448    state: String,
449    status: String,
450    exit_code: i64,
451    created: i64,
452    ports: Vec<Value>,
453    labels: Map<String, Value>,
454    env: Vec<String>,
455    ip: String,
456}
457
458fn container(obj: &Value, index: usize) -> Result<Container, String> {
459    if !obj.is_object() {
460        return Err(format!("container {index} must be an object"));
461    }
462    let names = string_list(obj, &["names", "Names", "name", "Name"]);
463    let name = names
464        .first()
465        .map(|n| n.trim_start_matches('/').to_string())
466        .unwrap_or_default();
467    let what = if name.is_empty() {
468        format!("container {index}")
469    } else {
470        format!("container '{name}'")
471    };
472    if name.is_empty() {
473        return Err(format!("{what} needs a name"));
474    }
475    if !valid_name(&name) {
476        return Err(format!(
477            "{what}: name must match [a-zA-Z0-9][a-zA-Z0-9_.-]* as Docker requires"
478        ));
479    }
480    let id = match str_field(obj, &["id", "Id", "ID"]) {
481        Some(id) => id_from(&id, "container", &what)?,
482        None => derived_id(&format!("container:{name}")),
483    };
484    let image = str_field(obj, &["image", "Image"]).unwrap_or_default();
485    if image.is_empty() {
486        return Err(format!("{what} needs an image"));
487    }
488    let state = state_of(obj, &what)?;
489    let exit_code = field(obj, &["exit_code", "ExitCode"])
490        .and_then(Value::as_i64)
491        .unwrap_or(0);
492    let status = status_of(str_field(obj, &["status", "Status"]), &state, exit_code);
493    let (command, path, args) = command_of(obj);
494    let env = string_list(obj, &["env", "Env"]);
495    Ok(Container {
496        image_id: format!("sha256:{}", derived_id(&format!("image:{image}"))),
497        id,
498        name,
499        image,
500        command,
501        path,
502        args,
503        state,
504        status,
505        exit_code,
506        created: created_unix(obj, &what)?,
507        ports: ports(obj, &what)?,
508        labels: string_map(obj, &["labels", "Labels"], "labels")?,
509        env,
510        ip: str_field(obj, &["ip", "ip_address", "IPAddress"]).unwrap_or_default(),
511    })
512}
513
514/// `GET /containers/json` body.
515pub fn render_container_list(containers: &Value) -> Result<Value, String> {
516    let list = containers
517        .as_array()
518        .ok_or("'containers' must be an array")?;
519    let mut out = Vec::with_capacity(list.len());
520    let mut seen_short_ids: Vec<String> = Vec::with_capacity(list.len());
521    for (i, c) in list.iter().enumerate() {
522        let mut c = container(c, i)?;
523        // Two containers never share an ID, and the CLI tells them apart by its first 12
524        // characters. A model copying one ID onto every container (llama3.1:8b copied the
525        // action example's 64-hex ID onto every container of a `ps -a`) would otherwise
526        // show two rows `docker inspect` cannot tell apart, so a repeated ID is replaced by
527        // the one derived from the container's name - what omitting it would have given.
528        let short: String = c.id.chars().take(12).collect();
529        if seen_short_ids.contains(&short) {
530            c.id = derived_id(&format!("container:{}", c.name));
531        }
532        seen_short_ids.push(c.id.chars().take(12).collect());
533        out.push(json!({
534            "Id": c.id,
535            "Names": [format!("/{}", c.name)],
536            "Image": c.image,
537            "ImageID": c.image_id,
538            "Command": c.command,
539            "Created": c.created,
540            "Ports": c.ports,
541            "Labels": c.labels,
542            "State": c.state,
543            "Status": c.status,
544            "HostConfig": {"NetworkMode": "bridge"},
545            "NetworkSettings": {"Networks": {}},
546            "Mounts": [],
547        }));
548    }
549    Ok(Value::Array(out))
550}
551
552/// `GET /containers/{id}/json` body.
553pub fn render_container(container_value: &Value) -> Result<Value, String> {
554    let c = container(container_value, 0)?;
555    let running = c.state == "running" || c.state == "paused";
556    let started = if c.state == "created" {
557        "0001-01-01T00:00:00Z".to_string()
558    } else {
559        rfc3339(c.created)
560    };
561    let mut exposed = Map::new();
562    let mut bindings = Map::new();
563    for p in &c.ports {
564        let key = format!(
565            "{}/{}",
566            p["PrivatePort"],
567            p["Type"].as_str().unwrap_or("tcp")
568        );
569        exposed.insert(key.clone(), json!({}));
570        let binding = match p.get("PublicPort") {
571            Some(public) => json!([{"HostIp": p["IP"], "HostPort": public.to_string()}]),
572            None => Value::Null,
573        };
574        bindings.insert(key, binding);
575    }
576    Ok(json!({
577        "Id": c.id,
578        "Created": rfc3339(c.created),
579        "Path": c.path,
580        "Args": c.args,
581        "State": {
582            "Status": c.state,
583            "Running": running,
584            "Paused": c.state == "paused",
585            "Restarting": c.state == "restarting",
586            "OOMKilled": false,
587            "Dead": c.state == "dead",
588            "Pid": if running { 4242 } else { 0 },
589            "ExitCode": c.exit_code,
590            "Error": "",
591            "StartedAt": started,
592            "FinishedAt": if running || c.state == "created" {
593                "0001-01-01T00:00:00Z".to_string()
594            } else {
595                rfc3339(c.created)
596            },
597        },
598        "Image": c.image_id,
599        "Name": format!("/{}", c.name),
600        "RestartCount": 0,
601        "Driver": "overlay2",
602        "Platform": "linux",
603        "MountLabel": "",
604        "ProcessLabel": "",
605        "AppArmorProfile": "",
606        "HostConfig": {"NetworkMode": "bridge", "RestartPolicy": {"Name": "no", "MaximumRetryCount": 0}},
607        "Mounts": [],
608        "Config": {
609            "Hostname": c.id.chars().take(12).collect::<String>(),
610            "Image": c.image,
611            "Cmd": std::iter::once(c.path.clone()).chain(c.args.iter().cloned())
612                .filter(|s| !s.is_empty()).collect::<Vec<_>>(),
613            "Env": c.env,
614            "Labels": c.labels,
615            "ExposedPorts": exposed,
616            "Tty": false,
617            "OpenStdin": false,
618        },
619        "NetworkSettings": {
620            "Ports": bindings,
621            "IPAddress": c.ip,
622            "Networks": {},
623        },
624    }))
625}
626
627/// `GET /images/json` body.
628pub fn render_images(images: &Value) -> Result<Value, String> {
629    let list = images.as_array().ok_or("'images' must be an array")?;
630    let mut out = Vec::with_capacity(list.len());
631    for (i, img) in list.iter().enumerate() {
632        if !img.is_object() {
633            return Err(format!("image {i} must be an object"));
634        }
635        let tags = string_list(img, &["repo_tags", "RepoTags", "tags", "tag"]);
636        for t in &tags {
637            // The characters an image reference may contain: name components, `:` before a
638            // tag, `@` before a digest, `/` between path components.
639            let reference_char = |c: char| c.is_ascii_alphanumeric() || "._-:/@".contains(c);
640            if t.is_empty() || t.len() > 255 || !t.chars().all(reference_char) {
641                return Err(format!("image {i}: tag {t:?} is not a valid reference"));
642            }
643        }
644        let seed = tags
645            .first()
646            .cloned()
647            .unwrap_or_else(|| format!("image-{i}"));
648        let id = match str_field(img, &["id", "Id", "ID"]) {
649            Some(id) => format!("sha256:{}", id_from(&id, "image", &format!("image {i}"))?),
650            None => format!("sha256:{}", derived_id(&format!("image:{seed}"))),
651        };
652        let size = u64_field(img, &["size", "Size"], "size")?.unwrap_or(0);
653        out.push(json!({
654            "Id": id,
655            "ParentId": "",
656            "RepoTags": tags,
657            "RepoDigests": string_list(img, &["repo_digests", "RepoDigests"]),
658            "Created": created_unix(img, &format!("image {i}"))?,
659            "Size": size,
660            "SharedSize": -1,
661            "Labels": string_map(img, &["labels", "Labels"], "labels")?,
662            "Containers": -1,
663        }));
664    }
665    Ok(Value::Array(out))
666}
667
668/// `GET /networks` body.
669pub fn render_networks(networks: &Value) -> Result<Value, String> {
670    let list = networks.as_array().ok_or("'networks' must be an array")?;
671    let mut out = Vec::with_capacity(list.len());
672    for (i, n) in list.iter().enumerate() {
673        let name = str_field(n, &["name", "Name"])
674            .filter(|s| valid_name(s))
675            .ok_or_else(|| format!("network {i} needs a valid name"))?;
676        let id = str_field(n, &["id", "Id", "ID"])
677            .filter(|s| valid_id(s))
678            .unwrap_or_else(|| derived_id(&format!("network:{name}")));
679        out.push(json!({
680            "Name": name,
681            "Id": id,
682            "Created": rfc3339(created_unix(n, &format!("network '{name}'"))?),
683            "Scope": str_field(n, &["scope", "Scope"]).unwrap_or_else(|| "local".to_string()),
684            "Driver": str_field(n, &["driver", "Driver"]).unwrap_or_else(|| "bridge".to_string()),
685            "EnableIPv6": false,
686            "IPAM": {"Driver": "default", "Options": null, "Config": []},
687            "Internal": field(n, &["internal", "Internal"]).and_then(Value::as_bool).unwrap_or(false),
688            "Attachable": false,
689            "Ingress": false,
690            "ConfigFrom": {"Network": ""},
691            "ConfigOnly": false,
692            "Containers": {},
693            "Options": {},
694            "Labels": string_map(n, &["labels", "Labels"], "labels")?,
695        }));
696    }
697    Ok(Value::Array(out))
698}
699
700/// `GET /volumes` body.
701pub fn render_volumes(volumes: &Value) -> Result<Value, String> {
702    let list = volumes.as_array().ok_or("'volumes' must be an array")?;
703    let mut out = Vec::with_capacity(list.len());
704    for (i, v) in list.iter().enumerate() {
705        let name = str_field(v, &["name", "Name"])
706            .filter(|s| valid_name(s))
707            .ok_or_else(|| format!("volume {i} needs a valid name"))?;
708        out.push(json!({
709            "Name": name,
710            "Driver": str_field(v, &["driver", "Driver"]).unwrap_or_else(|| "local".to_string()),
711            "Mountpoint": format!("/var/lib/docker/volumes/{name}/_data"),
712            "CreatedAt": rfc3339(created_unix(v, &format!("volume '{name}'"))?),
713            "Labels": string_map(v, &["labels", "Labels"], "labels")?,
714            "Scope": "local",
715            "Options": {},
716        }));
717    }
718    Ok(json!({"Volumes": out, "Warnings": []}))
719}
720
721/// Server identity used to fill `/version` and `/info` defaults.
722#[derive(Clone, Debug)]
723pub struct EngineIdentity {
724    pub engine_version: String,
725    pub api_version: String,
726}
727
728fn text_or(obj: &Value, names: &[&str], default: &str) -> String {
729    str_field(obj, names).unwrap_or_else(|| default.to_string())
730}
731
732/// `GET /version` body.
733pub fn render_version(data: &Value, id: &EngineIdentity) -> Result<Value, String> {
734    let version = text_or(data, &["version", "Version"], &id.engine_version);
735    let api = text_or(data, &["api_version", "ApiVersion"], &id.api_version);
736    if parse_version(&api).is_none() {
737        return Err(format!("api_version {api:?} must look like 1.47"));
738    }
739    let os = text_or(data, &["os", "Os"], "linux");
740    let arch = text_or(data, &["arch", "Arch"], "amd64");
741    let kernel = text_or(data, &["kernel_version", "KernelVersion"], "6.8.0-netget");
742    let go = text_or(data, &["go_version", "GoVersion"], "go1.22.10");
743    let commit = text_or(data, &["git_commit", "GitCommit"], "netget");
744    let built = text_or(
745        data,
746        &["build_time", "BuildTime"],
747        "2025-01-01T00:00:00.000000000+00:00",
748    );
749    Ok(json!({
750        "Platform": {"Name": text_or(data, &["platform", "Platform"], "Docker Engine - Community")},
751        "Components": [{
752            "Name": "Engine",
753            "Version": version,
754            "Details": {
755                "ApiVersion": api,
756                "Arch": arch,
757                "BuildTime": built,
758                "Experimental": "false",
759                "GitCommit": commit,
760                "GoVersion": go,
761                "KernelVersion": kernel,
762                "MinAPIVersion": MIN_API_VERSION,
763                "Os": os,
764            }
765        }],
766        "Version": version,
767        "ApiVersion": api,
768        "MinAPIVersion": MIN_API_VERSION,
769        "GitCommit": commit,
770        "GoVersion": go,
771        "Os": os,
772        "Arch": arch,
773        "KernelVersion": kernel,
774        "BuildTime": built,
775    }))
776}
777
778/// `GET /info` body.
779pub fn render_info(data: &Value, id: &EngineIdentity) -> Result<Value, String> {
780    let count = |names: &[&str], what: &str| -> Result<u64, String> {
781        Ok(u64_field(data, names, what)?.unwrap_or(0))
782    };
783    let running = count(
784        &["containers_running", "ContainersRunning"],
785        "containers_running",
786    )?;
787    let paused = count(
788        &["containers_paused", "ContainersPaused"],
789        "containers_paused",
790    )?;
791    let stopped = count(
792        &["containers_stopped", "ContainersStopped"],
793        "containers_stopped",
794    )?;
795    let total = u64_field(data, &["containers", "Containers"], "containers")?
796        .unwrap_or(running + paused + stopped);
797    let name = text_or(data, &["name", "Name"], "netget");
798    // Two literals merged, because one of this size exceeds `json!`'s macro recursion limit.
799    let mut info = json!({
800        "ID": text_or(data, &["id", "ID"], &derived_id(&format!("engine:{name}"))[..36]),
801        "Containers": total,
802        "ContainersRunning": running,
803        "ContainersPaused": paused,
804        "ContainersStopped": stopped,
805        "Images": count(&["images", "Images"], "images")?,
806        "Driver": text_or(data, &["driver", "Driver"], "overlay2"),
807        "DriverStatus": [["Backing Filesystem", "extfs"]],
808        "Plugins": {
809            "Volume": ["local"],
810            "Network": ["bridge", "host", "null"],
811            "Authorization": null,
812            "Log": ["json-file", "local"],
813        },
814        "MemoryLimit": true,
815        "SwapLimit": true,
816        "CpuCfsPeriod": true,
817        "CpuCfsQuota": true,
818        "CPUShares": true,
819        "CPUSet": true,
820        "PidsLimit": true,
821        "IPv4Forwarding": true,
822        "Debug": false,
823        "NFd": 30,
824        "OomKillDisable": false,
825        "NGoroutines": 40,
826        "SystemTime": rfc3339(now_unix()),
827        "LoggingDriver": "json-file",
828        "CgroupDriver": "systemd",
829        "CgroupVersion": "2",
830        "NEventsListener": 0,
831    });
832    let rest = json!({
833        "KernelVersion": text_or(data, &["kernel_version", "KernelVersion"], "6.8.0-netget"),
834        "OperatingSystem": text_or(data, &["operating_system", "OperatingSystem"], "NetGet Linux"),
835        "OSVersion": text_or(data, &["os_version", "OSVersion"], ""),
836        "OSType": text_or(data, &["os_type", "OSType"], "linux"),
837        "Architecture": text_or(data, &["architecture", "Architecture"], "x86_64"),
838        "IndexServerAddress": "https://index.docker.io/v1/",
839        "RegistryConfig": {
840            "IndexConfigs": {},
841            "InsecureRegistryCIDRs": ["127.0.0.0/8"],
842            "Mirrors": [],
843        },
844        "NCPU": u64_field(data, &["ncpu", "NCPU", "cpus"], "ncpu")?.unwrap_or(4),
845        "MemTotal": u64_field(data, &["mem_total", "MemTotal", "memory_bytes"], "mem_total")?
846            .unwrap_or(8 * 1024 * 1024 * 1024),
847        "DockerRootDir": "/var/lib/docker",
848        "Name": name,
849        "Labels": string_list(data, &["labels", "Labels"]),
850        "ExperimentalBuild": false,
851        "ServerVersion": text_or(data, &["server_version", "ServerVersion", "version"], &id.engine_version),
852        "Runtimes": {"runc": {"path": "runc"}},
853        "DefaultRuntime": "runc",
854        "Swarm": {"NodeID": "", "NodeAddr": "", "LocalNodeState": "inactive", "ControlAvailable": false, "Error": "", "RemoteManagers": null},
855        "LiveRestoreEnabled": false,
856        "Isolation": "",
857        "InitBinary": "docker-init",
858        "SecurityOptions": ["name=seccomp,profile=builtin", "name=cgroupns"],
859        "Warnings": [],
860    });
861    if let (Some(a), Value::Object(b)) = (info.as_object_mut(), rest) {
862        a.extend(b);
863    }
864    Ok(info)
865}