1use 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
21pub 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
40fn 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
64static 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
110pub 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
133pub 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
148fn 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
157fn 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 .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 .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 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
460fn 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
478pub 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}