software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// Package docker ist ein minimaler Client für die Docker Engine API über
|
||||
// den Unix-Socket – genau das, was der Agent braucht: laufende Container
|
||||
// mit Labels und Ports auflisten 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"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 die laufenden Container.
|
||||
func (c *Client) List(ctx context.Context) ([]Container, error) {
|
||||
resp, err := c.get(ctx, "/containers/json")
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeEngine bedient die Docker-API über einen Unix-Socket.
|
||||
type fakeEngine struct {
|
||||
path string
|
||||
events chan Event // was der Server über /events streamt
|
||||
lastQ chan string
|
||||
}
|
||||
|
||||
func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *fakeEngine {
|
||||
t.Helper()
|
||||
// Unix-Socket-Pfade sind kurz begrenzt; TempDir kann zu lang sein.
|
||||
dir, err := os.MkdirTemp("", "dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeEngine{path: filepath.Join(dir, "d.sock"), events: make(chan Event, 8), lastQ: make(chan string, 8)}
|
||||
ln, err := net.Listen("unix", f.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/containers/json", handler)
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastQ <- r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.(http.Flusher).Flush()
|
||||
enc := json.NewEncoder(w)
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case ev, ok := <-f.events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
enc.Encode(ev)
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
srv := &http.Server{Handler: mux}
|
||||
go srv.Serve(ln)
|
||||
t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) })
|
||||
return f
|
||||
}
|
||||
|
||||
const containersJSON = `[
|
||||
{"Id":"abc","Names":["/wordpress"],"State":"running",
|
||||
"Labels":{"traefik.enable":"true","traefik.http.routers.wp.rule":"Host(` + "`kunde.de`" + `)"},
|
||||
"Ports":[{"IP":"0.0.0.0","PrivatePort":80,"PublicPort":8081,"Type":"tcp"},{"PrivatePort":443,"Type":"tcp"}]},
|
||||
{"Id":"def","Names":["/db"],"State":"running","Labels":{},"Ports":[]}
|
||||
]`
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(containersJSON))
|
||||
})
|
||||
got, err := (&Client{Socket: f.path}).List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
want := []Container{
|
||||
{
|
||||
ID: "abc", Names: []string{"/wordpress"}, State: "running",
|
||||
Labels: map[string]string{"traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)"},
|
||||
Ports: []Port{{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}, {PrivatePort: 443, Type: "tcp"}},
|
||||
},
|
||||
{ID: "def", Names: []string{"/db"}, State: "running", Labels: map[string]string{}, Ports: []Port{}},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("List =\n%+v\nerwartet\n%+v", got, want)
|
||||
}
|
||||
if got[0].Name() != "wordpress" {
|
||||
t.Errorf("Name() = %q", got[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Errors(t *testing.T) {
|
||||
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"message":"kaputt"}`, http.StatusInternalServerError)
|
||||
})
|
||||
if _, err := (&Client{Socket: f.path}).List(context.Background()); err == nil || !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("Status 500: %v", err)
|
||||
}
|
||||
if _, err := (&Client{Socket: "/gibt/es/nicht.sock"}).List(context.Background()); err == nil {
|
||||
t.Error("fehlender Socket: erwartet Fehler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvents(t *testing.T) {
|
||||
f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
events, errc := (&Client{Socket: f.path}).Events(ctx)
|
||||
|
||||
q := <-f.lastQ
|
||||
if !strings.Contains(q, "filters=") || !strings.Contains(q, "container") {
|
||||
t.Errorf("Events ohne Container-Filter: %q", q)
|
||||
}
|
||||
f.events <- Event{Type: "container", Action: "start", Actor: Actor{ID: "abc", Attributes: map[string]string{"name": "wordpress"}}}
|
||||
f.events <- Event{Type: "container", Action: "die", Actor: Actor{ID: "abc"}}
|
||||
|
||||
for _, want := range []string{"start", "die"} {
|
||||
select {
|
||||
case ev := <-events:
|
||||
if ev.Action != want || ev.Actor.ID != "abc" {
|
||||
t.Errorf("Event = %+v, erwartet %s", ev, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Event %s nicht empfangen", want)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case _, ok := <-events:
|
||||
if ok {
|
||||
t.Error("Kanal nach Abbruch nicht geschlossen")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Events endet nicht nach Abbruch")
|
||||
}
|
||||
if err := <-errc; err != nil && err != context.Canceled {
|
||||
t.Errorf("Fehler nach Abbruch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Endet der Event-Stream serverseitig, meldet Events das über errc.
|
||||
func TestEvents_StreamEnds(t *testing.T) {
|
||||
f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {})
|
||||
events, errc := (&Client{Socket: f.path}).Events(context.Background())
|
||||
<-f.lastQ
|
||||
close(f.events)
|
||||
for range events {
|
||||
}
|
||||
if err := <-errc; err == nil {
|
||||
t.Error("erwartet Fehler bei beendetem Stream")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user