Skip to main content

netget/client/docker/
actions.rs

1use crate::llm::actions::{
2    client_trait::{Client, ClientActionResult},
3    protocol_trait::Protocol,
4    ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::{log_template::LogTemplate, ConnectContext, EventType};
7use crate::state::AppState;
8use anyhow::{bail, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11#[derive(Default)]
12pub struct DockerClientProtocol;
13impl DockerClientProtocol {
14    pub fn new() -> Self {
15        Self
16    }
17}
18fn p(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
19    Parameter {
20        name: name.into(),
21        type_hint: kind.into(),
22        description: description.into(),
23        required,
24    }
25}
26fn request() -> ActionDefinition {
27    ActionDefinition{name:"docker_request".into(),description:"Read one selected Docker Engine API resource using the negotiated API version. No arbitrary URLs, HTTP methods, request bodies or mutations.".into(),parameters:vec![
28    p("operation","string","ping, version, info, containers, container, images, networks, or volumes",true),
29    p("container_id","string","Required ID or name for container inspect; no slashes",false),
30    p("all","boolean","containers/images: include stopped containers or intermediate images; daemon default false",false),
31    p("limit","integer","containers: most recent 1..1000 including stopped",false),
32    p("size","boolean","containers/container: request filesystem sizes",false),
33    p("digests","boolean","images: include repository digests",false),
34    p("filters","object","containers/images/networks/volumes: Docker filter name to string arrays, JSON encoded by client",false)
35],example:json!({"type":"docker_request","operation":"containers","all":true,"filters":{"label":["netget.fixture=1"]}}),log_template:Some(LogTemplate::new().with_info("Read Docker {operation}"))}
36}
37fn disconnect() -> ActionDefinition {
38    ActionDefinition {
39        name: "disconnect".into(),
40        description: "Cancel pending I/O and close this logical client".into(),
41        parameters: vec![],
42        example: json!({"type":"disconnect"}),
43        log_template: Some(LogTemplate::new().with_info("Disconnect Docker client")),
44    }
45}
46fn actions() -> Vec<ActionDefinition> {
47    vec![request(), disconnect()]
48}
49fn event(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
50    EventType::new(id, description, request().example)
51        .with_parameters(params)
52        .with_actions(actions())
53}
54pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
55    event("docker_connected","Daemon HEAD /_ping completed; request a read operation",vec![p("endpoint","string","HTTP origin or native Unix socket URI",true),p("api_version","string","Selected min(daemon maximum, preferred client ceiling)",true),p("daemon","object","Ping API maximum, optional os_type and experimental headers; ping does not advertise minimum API",true)])
56});
57pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
58    event("docker_response","Complete typed response; unknown extension fields are omitted",vec![p("request","object","Originating typed action",true),p("operation","string","Selected Docker Engine read operation",true),p("api_version","string","Negotiated request version",true),p("status","integer","Successful Docker Engine HTTP response status (200)",true),p("data","object|array","Snake case selected native response fields. Lists for containers/images/networks; container inspect has state/config/host_config/network_settings objects; volumes object has nullable volumes/warnings. Container/image created timestamps are Unix seconds; inspect/network/volume dates remain RFC3339 strings. Native nulls remain null; image shared_size/containers and volume usage may use -1 for unknown. Ports use protocol and optional public_port/ip. No protocol database.",true)])
59});
60pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
61    event(
62        "docker_request_error",
63        "Request failed; no partial response is accepted, client remains available",
64        vec![
65            p("request", "object", "Originating action", true),
66            p("api_version", "string", "Negotiated version", true),
67            p(
68                "status",
69                "integer|null",
70                "HTTP status when response received",
71                true,
72            ),
73            p("category", "string", "http, schema, or transport", true),
74            p(
75                "error",
76                "string",
77                "Docker JSON message or validation/transport/deadline failure",
78                true,
79            ),
80        ],
81    )
82});
83impl Protocol for DockerClientProtocol {
84    fn protocol_name(&self) -> &'static str {
85        "Docker"
86    }
87    fn stack_name(&self) -> &'static str {
88        "ETH>IP>TCP>HTTP>Docker"
89    }
90    fn keywords(&self) -> Vec<&'static str> {
91        vec!["docker", "engine", "containers"]
92    }
93    fn description(&self) -> &'static str {
94        "Read typed Docker Engine API resources"
95    }
96    fn group_name(&self) -> &'static str {
97        "AI & API"
98    }
99    fn example_prompt(&self) -> &'static str {
100        "List containers from Docker at localhost:2375"
101    }
102    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
103        actions()
104    }
105    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
106        vec![]
107    }
108    fn get_event_types(&self) -> Vec<EventType> {
109        vec![
110            CONNECTED_EVENT.clone(),
111            RESPONSE_EVENT.clone(),
112            ERROR_EVENT.clone(),
113        ]
114    }
115    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
116        vec![
117        ParameterDefinition{name:"api_version".into(),type_hint:"string".into(),description:"Preferred API ceiling1.24..1.47; negotiate down using daemon HEAD /_ping API-Version. A newer daemon minimum can still refuse the selected version with a typed HTTP error.".into(),required:false,example:json!("1.47"),default:Some(json!(super::DEFAULT_API_VERSION))},
118        ParameterDefinition{name:"request_timeout_secs".into(),type_hint:"integer".into(),description:"Whole connect/head/body deadline1..30 seconds for negotiation and each request".into(),required:false,example:json!(10),default:Some(json!(super::DEFAULT_TIMEOUT_SECS))}
119    ]
120    }
121    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
122        use crate::protocol::metadata::*;
123        ProtocolMetadataV2::builder().state(DevelopmentState::Experimental).privilege_requirement(PrivilegeRequirement::None).well_known_port(2375).implementation("Negotiated read-only Engine API over HTTP TCP and native Unix sockets, typed bounded JSON schemas").llm_control("Selected read operations, native Docker filters, typed responses/errors and generic memory").e2e_testing("tests/client/docker: independent Docker daemon over Unix/TCP relay, NetGet pair, handler paths, schema/bounds/cancellation; existing server real Docker CLI tests").notes("Read-only selected API1.24..1.47 fields; newer wire fields omitted. No lifecycle mutations, image pulls, logs/attach/exec streams, Docker contexts, credential files, proxy, redirects, TLS/HTTPS or Windows named pipes. Browser HTTP TCP only. Native unix:///absolute/socket returns unspecified socket metadata0.0.0.0:0; actual endpoint remains in connected event. Body4MiB, arrays4096, object fields256, text16KiB, nesting32/nodes65536; one request, queues8,4 followups. API ping advertises maximum only, minimum incompatibility is a typed daemon error. Existing server read-only501 behavior retained.").max_inbound_bytes(super::schema::MAX_BODY).build()
124    }
125    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
126        let llm = json!({"type":"open_client","base_stack":"docker","protocol":"docker","remote_addr":"127.0.0.1:2375","instruction":"List containers and summarize their states"});
127        let mut fixed = llm.clone();
128        fixed["event_handlers"] = json!([{"event_pattern":"docker_connected","handler":{"type":"static","actions":[{"type":"docker_request","operation":"containers","all":true}]}},{"event_pattern":"*","handler":{"type":"static","actions":[]}}]);
129        let mut script = fixed.clone();
130        script["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json, sys\njson.dump({'actions':[{'type':'docker_request','operation':'containers','all':True}]},sys.stdout)"});
131        crate::llm::actions::StartupExamples::new(llm, script, fixed)
132    }
133}
134impl Client for DockerClientProtocol {
135    fn connect(
136        &self,
137        ctx: ConnectContext,
138    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
139    {
140        Box::pin(super::connect(ctx))
141    }
142    fn execute_action(&self, value: Value) -> Result<ClientActionResult> {
143        match value["type"].as_str() {
144            Some("docker_request") => {
145                super::request(&value, super::DEFAULT_API_VERSION)?;
146                Ok(ClientActionResult::Custom {
147                    name: "docker_request".into(),
148                    data: value,
149                })
150            }
151            Some("disconnect") => Ok(ClientActionResult::Disconnect),
152            _ => bail!("unknown Docker client action"),
153        }
154    }
155}