package infra import ( "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" ) // DockerContainerInfo holds the essential fields for a Docker container, // mapped from the Engine API /containers/json response. type DockerContainerInfo struct { ID string // short id (12 chars) Names []string // e.g. ["/my-container"] Image string // image name State string // running|exited|paused|... Status string // human label, e.g. "Up 2 hours" Ports []string // e.g. "0.0.0.0:8080->8080/tcp" Networks []string // network names Labels map[string]string // container labels } // DockerContainerListOpts controls which containers are returned and where // the Docker daemon is reached. type DockerContainerListOpts struct { All bool // if true, include stopped/exited containers (default: false = only running) Filters []string // Docker filter expressions, e.g. "label=app=agents_and_robots", "status=running" DockerHost string // default "unix:///var/run/docker.sock". Use "tcp://host:port" for remote. } // DockerContainerList lists Docker containers on the local (or remote) host // by calling the Docker Engine HTTP API directly. No docker CLI required. // // The DockerHost field selects the transport: // - empty or "unix:///var/run/docker.sock" → unix socket // - "tcp://host:port" → plain HTTP (no TLS) func DockerContainerList(opts DockerContainerListOpts) ([]DockerContainerInfo, error) { client, baseURL, err := dockerListHTTPClient(opts.DockerHost) if err != nil { return nil, fmt.Errorf("docker_container_list: build client: %w", err) } // Build query string q := url.Values{} if opts.All { q.Set("all", "1") } if len(opts.Filters) > 0 { filters := buildDockerFilters(opts.Filters) b, err := json.Marshal(filters) if err != nil { return nil, fmt.Errorf("docker_container_list: marshal filters: %w", err) } q.Set("filters", string(b)) } endpoint := baseURL + "/containers/json" if len(q) > 0 { endpoint += "?" + q.Encode() } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("docker_container_list: new request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("docker_container_list: do request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("docker_container_list: read body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("docker_container_list: daemon returned %d: %s", resp.StatusCode, string(body)) } return parseDockerContainerList(body) } // dockerListHTTPClient returns an *http.Client wired for unix socket or TCP, // and a base URL ("http://localhost" for unix, "http://host:port" for TCP). func dockerListHTTPClient(dockerHost string) (*http.Client, string, error) { if dockerHost == "" { dockerHost = "unix:///var/run/docker.sock" } if strings.HasPrefix(dockerHost, "unix://") { sockPath := strings.TrimPrefix(dockerHost, "unix://") transport := &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", sockPath) }, } return &http.Client{Transport: transport}, "http://localhost", nil } if strings.HasPrefix(dockerHost, "tcp://") { host := strings.TrimPrefix(dockerHost, "tcp://") return &http.Client{}, "http://" + host, nil } return nil, "", fmt.Errorf("unsupported docker host scheme: %q (use unix:// or tcp://)", dockerHost) } // buildDockerFilters converts []string{"label=k=v", "status=running"} into // the map[string][]string format that the Docker Engine API expects. func buildDockerFilters(filters []string) map[string][]string { m := make(map[string][]string) for _, f := range filters { idx := strings.IndexByte(f, '=') if idx < 0 { continue } key := f[:idx] val := f[idx+1:] m[key] = append(m[key], val) } return m } // parseDockerContainerList decodes the raw JSON from /containers/json. func parseDockerContainerList(body []byte) ([]DockerContainerInfo, error) { var raw []struct { ID string `json:"Id"` Names []string `json:"Names"` Image string `json:"Image"` State string `json:"State"` Status string `json:"Status"` Labels map[string]string `json:"Labels"` NetworkSettings struct { Networks map[string]json.RawMessage `json:"Networks"` } `json:"NetworkSettings"` Ports []struct { IP string `json:"IP"` PrivatePort uint16 `json:"PrivatePort"` PublicPort uint16 `json:"PublicPort"` Type string `json:"Type"` } `json:"Ports"` } if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("docker_container_list: parse response: %w", err) } result := make([]DockerContainerInfo, 0, len(raw)) for _, c := range raw { id := c.ID if len(id) > 12 { id = id[:12] } ports := make([]string, 0, len(c.Ports)) for _, p := range c.Ports { if p.IP != "" && p.PublicPort != 0 { ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.IP, p.PublicPort, p.PrivatePort, p.Type)) } else if p.PrivatePort != 0 { ports = append(ports, fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)) } } networks := make([]string, 0, len(c.NetworkSettings.Networks)) for name := range c.NetworkSettings.Networks { networks = append(networks, name) } labels := c.Labels if labels == nil { labels = map[string]string{} } result = append(result, DockerContainerInfo{ ID: id, Names: c.Names, Image: c.Image, State: c.State, Status: c.Status, Ports: ports, Networks: networks, Labels: labels, }) } return result, nil }