Skip to main content

netget/server/docker/
actions.rs

1//! Docker Engine API actions.
2//!
3//! One event, eight actions. Each read the model answers has exactly one action that answers
4//! it (`resource` in the event says which), plus `send_docker_error` for a refusal in Docker's
5//! own `{"message": …}` shape. Every action carries structured fields; NetGet renders the JSON
6//! document the Docker CLI's Go decoder expects, filling what the model left out.
7
8use crate::llm::actions::{
9    protocol_trait::{ActionResult, Protocol, Server},
10    ActionDefinition, Parameter, ParameterDefinition,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::state::app_state::AppState;
15use anyhow::{Context, Result};
16use serde_json::{json, Value};
17use std::sync::LazyLock;
18
19use super::api;
20
21/// Any read the model decides: version, info, container list/inspect, images, networks, volumes.
22pub static DOCKER_API_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
23    EventType::new(
24        "docker_api_request",
25        "A Docker client (the docker CLI, an SDK, Portainer…) read the Docker Engine API. \
26         `resource` says what it asked for and which action answers it: version -> \
27         send_docker_version, info -> send_docker_info, containers -> send_docker_containers \
28         (query.all == \"1\" means include stopped ones), container -> send_docker_container \
29         (for `id`, which may be a name or an ID prefix), images -> send_docker_images, \
30         networks -> send_docker_networks, volumes -> send_docker_volumes. Use \
31         send_docker_error with status 404 for something that does not exist.",
32        json!({
33            "type": "send_docker_containers",
34            "containers": [example_container()]
35        }),
36    )
37    .with_actions(all_actions())
38});
39
40/// What one route's request is, in the words its event description uses.
41fn route_request(route: &api::Route) -> &'static str {
42    use api::Route as R;
43    match route {
44        R::Version => "This request is `docker version`: answer it with send_docker_version.",
45        R::Info => "This request is `docker info`: answer it with send_docker_info.",
46        R::ContainerList => {
47            "This request is `docker ps` (`docker ps -a` when query.all == \"1\", which \
48             includes stopped containers): answer it with send_docker_containers."
49        }
50        R::ContainerInspect(_) => {
51            "This request inspects one container, named by `id` (a name or an ID prefix): \
52             answer with that container's fields in send_docker_container, or send_docker_error \
53             with status 404 when there is no such container."
54        }
55        R::ImageList => "This request is `docker images`: answer it with send_docker_images.",
56        R::NetworkList => {
57            "This request is `docker network ls`: answer it with send_docker_networks."
58        }
59        R::VolumeList => "This request is `docker volume ls`: answer it with send_docker_volumes.",
60        R::Ping | R::Mutating | R::NotFound => "",
61    }
62}
63
64/// `docker_api_request` as raised for one route: the same event id, offering only the action
65/// that answers that route and `send_docker_error`.
66///
67/// Every route has exactly one answering action, and the connection loop ignores any other
68/// (`decision=fail_closed_no_action`), so offering all eight only ever offered seven wrong
69/// answers. llama3.1:8b took one: asked to inspect a container on a host with no containers, it
70/// answered `send_docker_volumes` with `[{"name": "none"}]` five runs in five, reasoning that it
71/// was answering "the resource=volumes request". With the two actions that can answer an
72/// inspect, the same prompt is answered with the 404. The id is unchanged, so handlers written
73/// for `docker_api_request` match as before; `get_event_types` still declares the one event with
74/// every action, which is what documentation and handler validation read.
75static ROUTE_EVENTS: LazyLock<Vec<(&'static str, EventType)>> = LazyLock::new(|| {
76    use api::Route as R;
77    [
78        R::Version,
79        R::Info,
80        R::ContainerList,
81        R::ContainerInspect(String::new()),
82        R::ImageList,
83        R::NetworkList,
84        R::VolumeList,
85    ]
86    .iter()
87    .filter_map(|route| {
88        let resource = route.resource()?;
89        let answering = route.answering_action()?;
90        let actions: Vec<ActionDefinition> = all_actions()
91            .into_iter()
92            .filter(|a| a.name == answering || a.name == "send_docker_error")
93            .collect();
94        let example = actions.first()?.example.clone();
95        let event = EventType::new(
96            "docker_api_request",
97            format!(
98                "A Docker client (the docker CLI, an SDK, Portainer…) read the Docker Engine \
99                 API. {} Use send_docker_error for a refusal.",
100                route_request(route)
101            ),
102            example,
103        )
104        .with_actions(actions);
105        Some((resource, event))
106    })
107    .collect()
108});
109
110/// The event to raise for a request whose route resolved to `resource`: the route's own
111/// narrowed `docker_api_request`, or the declared one with every action for anything else.
112pub fn event_for_resource(resource: &str) -> &'static EventType {
113    ROUTE_EVENTS
114        .iter()
115        .find(|(r, _)| *r == resource)
116        .map(|(_, event)| event)
117        .unwrap_or(&DOCKER_API_REQUEST_EVENT)
118}
119
120fn all_actions() -> Vec<ActionDefinition> {
121    vec![
122        send_docker_version_action(),
123        send_docker_info_action(),
124        send_docker_containers_action(),
125        send_docker_container_action(),
126        send_docker_images_action(),
127        send_docker_networks_action(),
128        send_docker_volumes_action(),
129        send_docker_error_action(),
130    ]
131}
132
133/// Docker Engine API protocol handler.
134pub struct DockerProtocol;
135
136impl DockerProtocol {
137    pub fn new() -> Self {
138        Self
139    }
140}
141
142impl Default for DockerProtocol {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148/// Defaults used when validating in the executor, which has no server configuration. Only the
149/// shape is being checked there; the running server renders with its real identity.
150fn validation_identity() -> api::EngineIdentity {
151    api::EngineIdentity {
152        engine_version: super::DEFAULT_ENGINE_VERSION.to_string(),
153        api_version: super::DEFAULT_API_VERSION.to_string(),
154    }
155}
156
157/// The action's own fields without `type` — the flat shape the single-object actions take.
158fn without_type(action: &Value) -> Value {
159    let mut v = action.clone();
160    if let Some(obj) = v.as_object_mut() {
161        obj.remove("type");
162    }
163    v
164}
165
166impl Protocol for DockerProtocol {
167    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
168        Vec::new()
169    }
170
171    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
172        all_actions()
173    }
174
175    fn protocol_name(&self) -> &'static str {
176        "Docker"
177    }
178
179    fn get_event_types(&self) -> Vec<EventType> {
180        vec![DOCKER_API_REQUEST_EVENT.clone()]
181    }
182
183    fn stack_name(&self) -> &'static str {
184        "ETH>IP>TCP>HTTP>DOCKER"
185    }
186
187    fn keywords(&self) -> Vec<&'static str> {
188        vec!["docker", "dockerd", "docker engine", "docker api", "moby"]
189    }
190
191    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
192        use crate::protocol::metadata::{
193            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
194        };
195
196        ProtocolMetadataV2::builder()
197            // Beta on evidence: tests/server/docker/real_client_test.rs drives the real docker
198            // CLI (a Go client decoding into the Engine's own types) through version, ps -a,
199            // images, inspect, network ls, volume ls and info, asserting it prints exactly the
200            // handler's values, and hard-fails when the binary is absent. Not Stable: one
201            // client, no fuzz target, no pcap-oracle test, and the API is a read-only subset.
202            .state(DevelopmentState::Beta)
203            .well_known_port(2375)
204            .privilege_requirement(PrivilegeRequirement::None)
205            .implementation(
206                "hyper HTTP/1.1, plain TCP (the unauthenticated tcp://…:2375 shape). /_ping \
207                 (HEAD and GET) and /vX.Y/ version negotiation are served deterministically; \
208                 every read raises docker_api_request and the model answers with a structured \
209                 action that NetGet renders into the document the Docker CLI decodes, filling \
210                 HostConfig/NetworkSettings/Components and the other fields nobody reads.",
211            )
212            .llm_control(
213                "Which containers, images, networks and volumes exist, their states, ports and \
214                 labels, and what /version and /info report. NetGet stores nothing between \
215                 requests.",
216            )
217            .e2e_testing(
218                "tests/server/docker/real_client_test.rs - the real docker CLI (docker -H \
219                 tcp://127.0.0.1:PORT) runs version, ps -a, images, inspect, network ls and \
220                 volume ls against a static handler and must print exactly the handler's values; \
221                 DOCKER_CONFIG is a temp dir and DOCKER_HOST/DOCKER_CONTEXT are removed so the \
222                 machine's real daemon is never contacted. HARD FAILS when docker is absent. \
223                 e2e_test.rs covers the mocked model path.",
224            )
225            .notes(
226                "Read-only subset: /_ping, /version, /info, /containers/json (all, filters \
227                 passed through as query), /containers/{id}/json, /images/json, /networks, \
228                 /volumes. Every POST/PUT/DELETE (create, start, stop, exec, pull, rm, build) is \
229                 a static 501 in Docker's error shape. Not implemented: /images/{id}/json (so \
230                 docker inspect falls back to 'No such object' for images), logs, events, \
231                 stats, attach, the swarm endpoints, TLS (2376) and authentication - the API \
232                 on 2375 has none, and neither does this.",
233            )
234            .max_inbound_bytes(super::MAX_REQUEST_BODY_BYTES)
235            // LLM failure: 503 + Retry-After (overloaded) or 500, each with Docker's
236            // {"message": ...} carrying a fixed category - the CLI prints it as the error.
237            .answers_on_failure()
238            .build()
239    }
240
241    fn description(&self) -> &'static str {
242        "Docker Engine API (read-only) whose containers and images the model invents"
243    }
244
245    fn example_prompt(&self) -> &'static str {
246        "Be a Docker daemon on port 2375 running an nginx web container and a stopped postgres \
247         container, with nginx:1.27 and postgres:16 images"
248    }
249
250    fn group_name(&self) -> &'static str {
251        "AI & API"
252    }
253
254    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
255        vec![
256            ParameterDefinition {
257                name: "api_version".to_string(),
258                type_hint: "string".to_string(),
259                description: format!(
260                    "Highest Engine API version advertised by /_ping and accepted in /vX.Y/ \
261                     prefixes (default {}). The docker CLI negotiates down to it.",
262                    super::DEFAULT_API_VERSION
263                ),
264                required: false,
265                example: json!("1.47"),
266                default: Some(serde_json::json!(super::DEFAULT_API_VERSION)),
267            },
268            ParameterDefinition {
269                name: "engine_version".to_string(),
270                type_hint: "string".to_string(),
271                description: format!(
272                    "Engine version in the Server header and the default for /version and \
273                     /info (default {}).",
274                    super::DEFAULT_ENGINE_VERSION
275                ),
276                required: false,
277                example: json!("27.5.1"),
278                default: Some(serde_json::json!(super::DEFAULT_ENGINE_VERSION)),
279            },
280        ]
281    }
282
283    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
284        use crate::llm::actions::StartupExamples;
285
286        StartupExamples::new(
287            json!({
288                "type": "open_server",
289                "port": 2375,
290                "base_stack": "docker",
291                "instruction": "Docker host for a small web app. Containers: 'web' (nginx:1.27, \
292                                running, port 8080->80/tcp) and 'db' (postgres:16, exited with \
293                                code 0 an hour ago). Images: nginx:1.27 and postgres:16. One \
294                                bridge network and one volume 'pgdata'. Say 'No such \
295                                container' (404) for anything else."
296            }),
297            json!({
298                "type": "open_server",
299                "port": 2375,
300                "base_stack": "docker",
301                "event_handlers": [{
302                    "event_pattern": "docker_api_request",
303                    "handler": {
304                        "type": "script",
305                        "language": "python",
306                        "code": "import json, sys\nevent = json.load(sys.stdin)['event']\nres = event.get('resource')\nweb = {'id': 'a1b2c3d4e5f6a7b8', 'names': ['web'], 'image': 'nginx:1.27', 'command': 'nginx -g daemon off;', 'state': 'running', 'status': 'Up 2 hours', 'ports': [{'private_port': 80, 'public_port': 8080, 'type': 'tcp'}]}\nif res == 'containers':\n    act = {'type': 'send_docker_containers', 'containers': [web]}\nelif res == 'container' and event.get('id') in ('web', 'a1b2c3d4e5f6', 'a1b2c3d4e5f6a7b8'):\n    act = dict(web, type='send_docker_container')\nelif res == 'images':\n    act = {'type': 'send_docker_images', 'images': [{'repo_tags': ['nginx:1.27'], 'size': 187000000}]}\nelif res == 'version':\n    act = {'type': 'send_docker_version'}\nelif res == 'info':\n    act = {'type': 'send_docker_info', 'containers_running': 1, 'images': 1}\nelif res == 'networks':\n    act = {'type': 'send_docker_networks', 'networks': [{'name': 'bridge'}]}\nelif res == 'volumes':\n    act = {'type': 'send_docker_volumes', 'volumes': []}\nelse:\n    act = {'type': 'send_docker_error', 'status': 404, 'message': 'No such container: ' + str(event.get('id'))}\nprint(json.dumps({'actions': [act]}))"
307                    }
308                }]
309            }),
310            json!({
311                "type": "open_server",
312                "port": 2375,
313                "base_stack": "docker",
314                "event_handlers": [{
315                    "event_pattern": "docker_api_request",
316                    "handler": {
317                        "type": "static",
318                        "actions": example_static_actions()
319                    }
320                }]
321            }),
322        )
323    }
324}
325
326impl Server for DockerProtocol {
327    fn spawn(
328        &self,
329        ctx: crate::protocol::SpawnContext,
330    ) -> std::pin::Pin<
331        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
332    > {
333        Box::pin(async move {
334            let (api_version, engine_version) = match ctx.startup_params.as_ref() {
335                Some(params) => (
336                    params
337                        .get_optional_string("api_version")?
338                        .unwrap_or_else(|| super::DEFAULT_API_VERSION.to_string()),
339                    params
340                        .get_optional_string("engine_version")?
341                        .unwrap_or_else(|| super::DEFAULT_ENGINE_VERSION.to_string()),
342                ),
343                None => (
344                    super::DEFAULT_API_VERSION.to_string(),
345                    super::DEFAULT_ENGINE_VERSION.to_string(),
346                ),
347            };
348            if api::parse_version(&api_version).is_none()
349                || api::parse_version(&api_version) < api::parse_version(api::MIN_API_VERSION)
350            {
351                return Err(anyhow::anyhow!(
352                    "api_version {api_version:?} must look like 1.47 and be at least {}",
353                    api::MIN_API_VERSION
354                ));
355            }
356            if engine_version.is_empty()
357                || !engine_version
358                    .chars()
359                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_'))
360            {
361                return Err(anyhow::anyhow!(
362                    "engine_version {engine_version:?} must be a version string like 27.5.1"
363                ));
364            }
365            super::DockerServer::spawn_with_llm_actions(
366                ctx.legacy_listen_addr(),
367                ctx.llm_client,
368                ctx.state,
369                ctx.status_tx,
370                ctx.server_id,
371                super::DockerConfig {
372                    identity: api::EngineIdentity {
373                        engine_version,
374                        api_version,
375                    },
376                },
377            )
378            .await
379        })
380    }
381
382    fn execute_action(&self, action: Value) -> Result<ActionResult> {
383        let action_type = action
384            .get("type")
385            .and_then(|v| v.as_str())
386            .context("Missing 'type' field in action")?;
387        let identity = validation_identity();
388        let refuse = |reason: String| anyhow::anyhow!("{action_type} refused: {reason}");
389
390        let data = match action_type {
391            "send_docker_version" => {
392                let flat = without_type(&action);
393                api::render_version(&flat, &identity).map_err(refuse)?;
394                flat
395            }
396            "send_docker_info" => {
397                let flat = without_type(&action);
398                api::render_info(&flat, &identity).map_err(refuse)?;
399                flat
400            }
401            "send_docker_containers" => {
402                let list = action
403                    .get("containers")
404                    .context("send_docker_containers needs a 'containers' array")?;
405                api::render_container_list(list).map_err(refuse)?;
406                json!({"containers": list})
407            }
408            "send_docker_container" => {
409                // Either flat fields or a nested `container` object.
410                let container = action
411                    .get("container")
412                    .cloned()
413                    .unwrap_or_else(|| without_type(&action));
414                api::render_container(&container).map_err(refuse)?;
415                json!({"container": container})
416            }
417            "send_docker_images" => {
418                let list = action
419                    .get("images")
420                    .context("send_docker_images needs an 'images' array")?;
421                api::render_images(list).map_err(refuse)?;
422                json!({"images": list})
423            }
424            "send_docker_networks" => {
425                let list = action
426                    .get("networks")
427                    .context("send_docker_networks needs a 'networks' array")?;
428                api::render_networks(list).map_err(refuse)?;
429                json!({"networks": list})
430            }
431            "send_docker_volumes" => {
432                let list = action
433                    .get("volumes")
434                    .context("send_docker_volumes needs a 'volumes' array")?;
435                api::render_volumes(list).map_err(refuse)?;
436                json!({"volumes": list})
437            }
438            "send_docker_error" => {
439                let status = action.get("status").and_then(|v| v.as_u64()).unwrap_or(500);
440                if !(400..600).contains(&status) {
441                    return Err(anyhow::anyhow!(
442                        "send_docker_error 'status' must be a 4xx or 5xx HTTP status, got {status}"
443                    ));
444                }
445                let message = action
446                    .get("message")
447                    .and_then(|v| v.as_str())
448                    .context("send_docker_error needs a 'message'")?;
449                json!({"status": status, "message": message})
450            }
451            other => return Err(anyhow::anyhow!("Unknown Docker action: {other}")),
452        };
453        Ok(ActionResult::Custom {
454            name: action_type.to_string(),
455            data,
456        })
457    }
458}
459
460// ---------------------------------------------------------------------------
461// Action definitions
462// ---------------------------------------------------------------------------
463
464fn example_container() -> Value {
465    json!({
466        "id": "3f4e1a2b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
467        "names": ["web"],
468        "image": "nginx:1.27",
469        "command": "nginx -g 'daemon off;'",
470        "state": "running",
471        "status": "Up 2 hours",
472        "created": "2026-09-01T10:00:00Z",
473        "ports": [{"private_port": 80, "public_port": 8080, "type": "tcp"}],
474        "labels": {"app": "web"}
475    })
476}
477
478/// One answer per route. A static handler answers every `docker_api_request` with the same
479/// action list, and the server picks the action that answers the route the request hit, so a
480/// fixed host is one static rule.
481pub fn example_static_actions() -> Value {
482    let mut container = example_container();
483    container["type"] = json!("send_docker_container");
484    json!([
485        {"type": "send_docker_version", "version": "27.5.1"},
486        {"type": "send_docker_info", "name": "netget-docker", "containers_running": 1,
487         "images": 1, "operating_system": "Ubuntu 24.04 LTS", "ncpu": 4},
488        {"type": "send_docker_containers", "containers": [example_container()]},
489        container,
490        {"type": "send_docker_images", "images": [{"repo_tags": ["nginx:1.27"],
491                                                   "size": 187654321}]},
492        {"type": "send_docker_networks", "networks": [{"name": "bridge"}]},
493        {"type": "send_docker_volumes", "volumes": []}
494    ])
495}
496
497fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
498    Parameter {
499        name: name.to_string(),
500        type_hint: type_hint.to_string(),
501        description: description.to_string(),
502        required,
503    }
504}
505
506fn send_docker_version_action() -> ActionDefinition {
507    ActionDefinition {
508        name: "send_docker_version".to_string(),
509        description: "Answer resource=version (docker version's Server section). Every field \
510                      is optional; NetGet fills the rest."
511            .to_string(),
512        parameters: vec![
513            param(
514                "version",
515                "string",
516                "Engine version, e.g. \"27.5.1\"",
517                false,
518            ),
519            param("api_version", "string", "API version, e.g. \"1.47\"", false),
520            param("os", "string", "Default \"linux\"", false),
521            param("arch", "string", "Default \"amd64\"", false),
522            param("kernel_version", "string", "Kernel version string", false),
523            param("go_version", "string", "Go version string", false),
524            param("git_commit", "string", "Short commit hash", false),
525        ],
526        example: json!({"type": "send_docker_version", "version": "27.5.1", "arch": "amd64"}),
527        log_template: Some(LogTemplate::new().with_info("-> Docker version {version}")),
528    }
529}
530
531fn send_docker_info_action() -> ActionDefinition {
532    ActionDefinition {
533        name: "send_docker_info".to_string(),
534        description: "Answer resource=info (docker info). Every field is optional; counts \
535                      default to 0 and Containers to the sum of running, paused and stopped."
536            .to_string(),
537        parameters: vec![
538            param(
539                "name",
540                "string",
541                "Host name shown as Name in docker info",
542                false,
543            ),
544            param("containers_running", "number", "Running containers", false),
545            param("containers_paused", "number", "Paused containers", false),
546            param("containers_stopped", "number", "Stopped containers", false),
547            param("images", "number", "Number of images on the host", false),
548            param(
549                "server_version",
550                "string",
551                "Engine version shown as Server Version",
552                false,
553            ),
554            param(
555                "operating_system",
556                "string",
557                "e.g. \"Ubuntu 24.04 LTS\"",
558                false,
559            ),
560            param(
561                "kernel_version",
562                "string",
563                "Kernel version string, e.g. \"6.8.0-45-generic\"",
564                false,
565            ),
566            param(
567                "architecture",
568                "string",
569                "CPU architecture, e.g. \"x86_64\" or \"aarch64\"",
570                false,
571            ),
572            param("ncpu", "number", "Number of CPUs on the host", false),
573            param("mem_total", "number", "Memory in bytes", false),
574            param("labels", "array", "Engine labels, [\"key=value\"]", false),
575        ],
576        example: json!({
577            "type": "send_docker_info",
578            "name": "build-host-1",
579            "containers_running": 1,
580            "containers_stopped": 1,
581            "images": 2,
582            "operating_system": "Ubuntu 24.04 LTS",
583            "ncpu": 8
584        }),
585        log_template: Some(LogTemplate::new().with_info("-> Docker info {name}")),
586    }
587}
588
589const CONTAINER_FIELDS: &str = "{\"id\": hex or alphanumeric (optional; derived from the name), \
590     \"names\": [\"web\"], \"image\": \"nginx:1.27\", \"command\": \"nginx -g 'daemon off;'\", \
591     \"state\": created|running|paused|restarting|removing|exited|dead, \"status\": \"Up 2 \
592     hours\" (optional, derived from state), \"exit_code\": number, \"created\": unix seconds \
593     or RFC 3339, \"ports\": [{\"private_port\": 80, \"public_port\": 8080, \"type\": \"tcp\"}], \
594     \"labels\": {}, \"env\": [\"KEY=value\"], \"ip\": \"172.17.0.2\"}";
595
596fn send_docker_containers_action() -> ActionDefinition {
597    ActionDefinition {
598        name: "send_docker_containers".to_string(),
599        description: format!(
600            "Answer resource=containers (docker ps). Without query.all=\"1\" list only running \
601             containers. Each entry: {CONTAINER_FIELDS}"
602        ),
603        parameters: vec![param(
604            "containers",
605            "array",
606            "The containers, possibly empty",
607            true,
608        )],
609        example: json!({"type": "send_docker_containers", "containers": [example_container()]}),
610        log_template: Some(LogTemplate::new().with_info("-> Docker containers")),
611    }
612}
613
614fn send_docker_container_action() -> ActionDefinition {
615    ActionDefinition {
616        name: "send_docker_container".to_string(),
617        description: format!(
618            "Answer resource=container (docker inspect) for the event's id with one container's \
619             fields at the top level of the action: {CONTAINER_FIELDS}. Use send_docker_error \
620             404 if no such container exists."
621        ),
622        parameters: vec![
623            param("names", "array", "Container name, e.g. [\"web\"]", true),
624            param("image", "string", "Image reference", true),
625            param(
626                "id",
627                "string",
628                "Container ID in hex (derived from the name if omitted)",
629                false,
630            ),
631            param(
632                "state",
633                "string",
634                "Container state (default running)",
635                false,
636            ),
637            param(
638                "command",
639                "string",
640                "Command line the container runs, e.g. \"nginx -g daemon off;\"",
641                false,
642            ),
643            param("created", "string", "RFC 3339 or unix seconds", false),
644            param("ports", "array", "Published ports", false),
645            param("env", "array", "Environment, [\"KEY=value\"]", false),
646            param(
647                "labels",
648                "object",
649                "Container labels as {\"key\": \"value\"}",
650                false,
651            ),
652        ],
653        example: {
654            let mut c = example_container();
655            c["type"] = json!("send_docker_container");
656            c
657        },
658        log_template: Some(LogTemplate::new().with_info("-> Docker container {image}")),
659    }
660}
661
662fn send_docker_images_action() -> ActionDefinition {
663    ActionDefinition {
664        name: "send_docker_images".to_string(),
665        description: "Answer resource=images (docker images). Each entry: {\"repo_tags\": \
666                      [\"nginx:1.27\"], \"id\": optional hex, \"size\": bytes, \"created\": unix \
667                      seconds or RFC 3339, \"labels\": {}}"
668            .to_string(),
669        parameters: vec![param("images", "array", "The images, possibly empty", true)],
670        example: json!({
671            "type": "send_docker_images",
672            "images": [{"repo_tags": ["nginx:1.27"], "size": 187654321,
673                        "created": "2026-08-20T00:00:00Z"}]
674        }),
675        log_template: Some(LogTemplate::new().with_info("-> Docker images")),
676    }
677}
678
679fn send_docker_networks_action() -> ActionDefinition {
680    ActionDefinition {
681        name: "send_docker_networks".to_string(),
682        description: "Answer resource=networks (docker network ls). Each entry: {\"name\": \
683                      \"bridge\", \"driver\": \"bridge\", \"scope\": \"local\", \"id\": optional}"
684            .to_string(),
685        parameters: vec![param(
686            "networks",
687            "array",
688            "The networks on the host (bridge, host, custom ones)",
689            true,
690        )],
691        example: json!({
692            "type": "send_docker_networks",
693            "networks": [{"name": "bridge", "driver": "bridge"}, {"name": "host", "driver": "host"}]
694        }),
695        log_template: Some(LogTemplate::new().with_info("-> Docker networks")),
696    }
697}
698
699fn send_docker_volumes_action() -> ActionDefinition {
700    ActionDefinition {
701        name: "send_docker_volumes".to_string(),
702        description: "Answer resource=volumes (docker volume ls). Each entry: {\"name\": \
703                      \"pgdata\", \"driver\": \"local\", \"labels\": {}}"
704            .to_string(),
705        parameters: vec![param(
706            "volumes",
707            "array",
708            "The volumes, possibly empty",
709            true,
710        )],
711        example: json!({"type": "send_docker_volumes", "volumes": [{"name": "pgdata"}]}),
712        log_template: Some(LogTemplate::new().with_info("-> Docker volumes")),
713    }
714}
715
716fn send_docker_error_action() -> ActionDefinition {
717    ActionDefinition {
718        name: "send_docker_error".to_string(),
719        description: "Refuse a request with Docker's error shape; the CLI prints the message as \
720                      'Error response from daemon: <message>'. 404 for a missing object."
721            .to_string(),
722        parameters: vec![
723            param(
724                "status",
725                "number",
726                "HTTP status, 4xx or 5xx (default 500)",
727                false,
728            ),
729            param(
730                "message",
731                "string",
732                "e.g. \"No such container: web9\"",
733                true,
734            ),
735        ],
736        example: json!({
737            "type": "send_docker_error",
738            "status": 404,
739            "message": "No such container: web9"
740        }),
741        log_template: Some(LogTemplate::new().with_info("-> Docker error {status}: {message}")),
742    }
743}