// Package docker ist ein minimaler Client für die Docker Engine API über // den Unix-Socket – genau das, was der Agent braucht: Container mit Labels // und Ports auflisten, Details (Portzuordnungen auch gestoppter Container, // Startzeit, Health) lesen, Host-Infos abfragen und Container-Events // verfolgen. Bewusst // ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte). package docker import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "regexp" "slices" "strconv" "strings" "time" ) // Container ist ein Eintrag aus GET /containers/json. type Container struct { ID string `json:"Id"` Names []string `json:"Names"` State string `json:"State"` Labels map[string]string `json:"Labels"` Ports []Port `json:"Ports"` // Aus Inspect ergänzt (nicht Teil von /containers/json). StartedAt time.Time `json:"-"` Health string `json:"-"` } // Name liefert den Containernamen ohne führenden Schrägstrich. func (c Container) Name() string { if len(c.Names) == 0 { return c.ID } return strings.TrimPrefix(c.Names[0], "/") } // Port ist eine Portzuordnung; PublicPort 0 = nicht veröffentlicht. type Port struct { IP string `json:"IP,omitempty"` PrivatePort uint16 `json:"PrivatePort"` PublicPort uint16 `json:"PublicPort,omitempty"` Type string `json:"Type"` } // Event ist ein Eintrag aus GET /events. type Event struct { Type string `json:"Type"` Action string `json:"Action"` Actor Actor `json:"Actor"` } // Actor beschreibt das betroffene Objekt eines Events. type Actor struct { ID string `json:"ID"` Attributes map[string]string `json:"Attributes,omitempty"` } // Client spricht die Engine API über Socket (z. B. /var/run/docker.sock). type Client struct { Socket string } func (c *Client) http() *http.Client { return &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", c.Socket) }, }} } func (c *Client) get(ctx context.Context, path string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker"+path, nil) if err != nil { return nil, err } resp, err := c.http().Do(req) if err != nil { return nil, fmt.Errorf("docker: %s: %w", path, err) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) resp.Body.Close() return nil, fmt.Errorf("docker: %s: Status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body))) } return resp, nil } // List liefert alle Container, auch gestoppte (State != "running"); bei // diesen ist Ports leer, siehe PortBindings. func (c *Client) List(ctx context.Context) ([]Container, error) { resp, err := c.get(ctx, "/containers/json?all=1") if err != nil { return nil, err } defer resp.Body.Close() var out []Container if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, fmt.Errorf("docker: Containerliste: %w", err) } return out, nil } var validID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) // Details sind die Angaben aus GET /containers/{id}/json, die der Agent // braucht. type Details struct { // Ports: konfigurierte Zuordnungen (HostConfig.PortBindings) – auch bei // gestoppten Containern. Ohne festen Host-Port ist PublicPort 0. // Sortiert nach "/". Ports []Port // StartedAt: letzter Start; Nullzeit, wenn nie gestartet. StartedAt time.Time // Health: healthy, unhealthy, starting; leer ohne Healthcheck. Health string } // Inspect liefert die Details eines Containers. func (c *Client) Inspect(ctx context.Context, id string) (Details, error) { if !validID.MatchString(id) { return Details{}, fmt.Errorf("docker: ungültige Container-ID %q", id) } resp, err := c.get(ctx, "/containers/"+id+"/json") if err != nil { return Details{}, err } defer resp.Body.Close() var info struct { State struct { StartedAt time.Time `json:"StartedAt"` Health *struct { Status string `json:"Status"` } `json:"Health"` } `json:"State"` HostConfig struct { PortBindings map[string][]struct { HostIP string `json:"HostIp"` HostPort string `json:"HostPort"` } `json:"PortBindings"` } `json:"HostConfig"` } if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return Details{}, fmt.Errorf("docker: Container %s: %w", id, err) } var d Details if !info.State.StartedAt.IsZero() { d.StartedAt = info.State.StartedAt.UTC() } if info.State.Health != nil { d.Health = info.State.Health.Status } keys := make([]string, 0, len(info.HostConfig.PortBindings)) for k := range info.HostConfig.PortBindings { keys = append(keys, k) } slices.Sort(keys) for _, k := range keys { port, proto, _ := strings.Cut(k, "/") private, err := strconv.ParseUint(port, 10, 16) if err != nil { continue } if proto == "" { proto = "tcp" } bindings := info.HostConfig.PortBindings[k] if len(bindings) == 0 { d.Ports = append(d.Ports, Port{PrivatePort: uint16(private), Type: proto}) continue } for _, b := range bindings { public, _ := strconv.ParseUint(b.HostPort, 10, 16) // "" → 0 d.Ports = append(d.Ports, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto}) } } return d, nil } // PortBindings liefert die konfigurierten Portzuordnungen eines Containers // (siehe Details.Ports) – auch wenn er gestoppt ist. func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) { d, err := c.Inspect(ctx, id) return d.Ports, err } // Info sind die Host-Angaben aus GET /info. OperatingSystem ist das des // Hosts – auch wenn der Agent selbst in einem Container läuft. type Info struct { Name string `json:"Name"` OperatingSystem string `json:"OperatingSystem"` ServerVersion string `json:"ServerVersion"` } // Info fragt die Host-Angaben ab. func (c *Client) Info(ctx context.Context) (Info, error) { resp, err := c.get(ctx, "/info") if err != nil { return Info{}, err } defer resp.Body.Close() var info Info if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return Info{}, fmt.Errorf("docker: Info: %w", err) } return info, nil } // Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht. // Der Event-Kanal wird danach geschlossen; errc liefert genau einen Wert // (ctx.Err() bei Abbruch, sonst den Grund). func (c *Client) Events(ctx context.Context) (<-chan Event, <-chan error) { events := make(chan Event) errc := make(chan error, 1) go func() { defer close(events) filters := url.QueryEscape(`{"type":["container"]}`) resp, err := c.get(ctx, "/events?filters="+filters) if err != nil { errc <- err return } defer resp.Body.Close() dec := json.NewDecoder(resp.Body) for { var ev Event if err := dec.Decode(&ev); err != nil { switch { case ctx.Err() != nil: errc <- ctx.Err() case errors.Is(err, io.EOF): errc <- errors.New("docker: Event-Stream beendet") default: errc <- fmt.Errorf("docker: Event-Stream: %w", err) } return } select { case events <- ev: case <-ctx.Done(): errc <- ctx.Err() return } } }() return events, errc }