Files

257 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 "<port>/<proto>".
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
}