Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+71 -13
View File
@@ -1,7 +1,8 @@
// 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, Portzuordnungen gestoppter Container lesen und
// Container-Events verfolgen. Bewusst
// 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
@@ -18,6 +19,7 @@ import (
"slices"
"strconv"
"strings"
"time"
)
// Container ist ein Eintrag aus GET /containers/json.
@@ -27,6 +29,10 @@ type Container struct {
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.
@@ -106,19 +112,36 @@ func (c *Client) List(ctx context.Context) ([]Container, error) {
var validID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// PortBindings liefert die konfigurierten Portzuordnungen eines Containers
// (HostConfig.PortBindings) – auch wenn er gestoppt ist. Ohne festen
// Host-Port ist PublicPort 0. Sortiert nach "<port>/<proto>".
func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
// 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 nil, fmt.Errorf("docker: ungültige Container-ID %q", id)
return Details{}, fmt.Errorf("docker: ungültige Container-ID %q", id)
}
resp, err := c.get(ctx, "/containers/"+id+"/json")
if err != nil {
return nil, err
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"`
@@ -127,14 +150,20 @@ func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
} `json:"HostConfig"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("docker: Container %s: %w", id, err)
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)
var out []Port
for _, k := range keys {
port, proto, _ := strings.Cut(k, "/")
private, err := strconv.ParseUint(port, 10, 16)
@@ -146,15 +175,44 @@ func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
}
bindings := info.HostConfig.PortBindings[k]
if len(bindings) == 0 {
out = append(out, Port{PrivatePort: uint16(private), Type: proto})
d.Ports = append(d.Ports, Port{PrivatePort: uint16(private), Type: proto})
continue
}
for _, b := range bindings {
public, _ := strconv.ParseUint(b.HostPort, 10, 16) // "" → 0
out = append(out, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
d.Ports = append(d.Ports, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
}
}
return out, nil
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.
+61
View File
@@ -38,6 +38,7 @@ func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Req
handler(w, r)
})
mux.HandleFunc("/containers/{id}/json", handler)
mux.HandleFunc("/info", handler)
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
f.lastQ <- r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
@@ -199,3 +200,63 @@ func TestEvents_StreamEnds(t *testing.T) {
t.Error("erwartet Fehler bei beendetem Stream")
}
}
// Inspect liefert Ports (auch gestoppter Container), Startzeit und
// Health-Status in einem Aufruf.
func TestInspect(t *testing.T) {
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
switch r.PathValue("id") {
case "abc":
w.Write([]byte(`{"Id":"abc","State":{"Status":"running","StartedAt":"2026-10-16T08:15:30.123456789Z",
"Health":{"Status":"healthy"}},
"HostConfig":{"PortBindings":{"80/tcp":[{"HostIp":"","HostPort":"9080"}]}}}`))
case "nie":
// Nie gestartet, kein Healthcheck: Docker liefert die Nullzeit.
w.Write([]byte(`{"Id":"nie","State":{"Status":"created","StartedAt":"0001-01-01T00:00:00Z"},"HostConfig":{}}`))
default:
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
}
})
c := &Client{Socket: f.path}
got, err := c.Inspect(context.Background(), "abc")
if err != nil {
t.Fatalf("Inspect: %v", err)
}
want := Details{
Ports: []Port{{PrivatePort: 80, PublicPort: 9080, Type: "tcp"}},
StartedAt: time.Date(2026, 10, 16, 8, 15, 30, 123456789, time.UTC),
Health: "healthy",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Inspect =\n%+v\nerwartet\n%+v", got, want)
}
got, err = c.Inspect(context.Background(), "nie")
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if !got.StartedAt.IsZero() || got.Health != "" || len(got.Ports) != 0 {
t.Errorf("nie gestartet: %+v", got)
}
if _, err := c.Inspect(context.Background(), "weg"); err == nil || !strings.Contains(err.Error(), "404") {
t.Errorf("unbekannter Container: %v", err)
}
if _, err := c.Inspect(context.Background(), "../x"); err == nil {
t.Error("ungültige ID akzeptiert")
}
}
// Info liefert das Betriebssystem des Hosts (nicht des Agent-Containers) und
// die Docker-Version.
func TestInfo(t *testing.T) {
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2","NCPU":4}`))
})
got, err := (&Client{Socket: f.path}).Info(context.Background())
if err != nil {
t.Fatalf("Info: %v", err)
}
want := Info{Name: "webdocker11", OperatingSystem: "Debian GNU/Linux 13 (trixie)", ServerVersion: "29.8.2"}
if got != want {
t.Errorf("Info = %+v, erwartet %+v", got, want)
}
}