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

+18 -8
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
@@ -42,6 +43,7 @@ type Config struct {
Token string // Einladung, nur beim ersten Start
NodeID string // Default aus dem Hostnamen
Hostname string // Default os.Hostname()
FQDN string // Default automatisch (detectFQDN); nur Information für das Inventar
DockerSocket string // Default /var/run/docker.sock; "none" schaltet Docker ab
BackendHost string // Adresse, unter der der Ingress die Dienste erreicht
@@ -65,6 +67,7 @@ type Agent struct {
signer *auth.Signer
id Identity
serverURL string // tatsächlich genutzte Adresse (Override oder node.json)
fqdn string // in Run ermittelt
mu sync.Mutex
fileSpecs []ServiceSpec
@@ -144,6 +147,7 @@ func (a *Agent) Run(ctx context.Context) error {
if err != nil {
return err
}
a.fqdn = detectFQDN(ctx, net.DefaultResolver, a.cfg.FQDN, a.cfg.Hostname, host)
hc := a.httpClient()
a.runCtx = ctx
// Abgebrochener Schlüsselwechsel? Vor dem Verbinden abschließen.
@@ -153,7 +157,7 @@ func (a *Agent) Run(ctx context.Context) error {
}
}
a.log.Info("Agent gestartet", "node", a.id.NodeID, "server", a.serverURL, "backend", host,
"docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
"fqdn", a.fqdn, "docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
trigger := make(chan struct{}, 1)
kick := func() {
@@ -235,23 +239,28 @@ func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trig
// gesendet (sonst würden alle Container-Routen entfernt).
func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) {
state := registry.NodeState{Workloads: []registry.Workload{}}
var client *docker.Client
if a.dockerAvailable() {
client := &docker.Client{Socket: a.cfg.DockerSocket}
client = &docker.Client{Socket: a.cfg.DockerSocket}
containers, err := client.List(ctx)
if err != nil {
return state, err
}
// Gestoppte Container nennen in der Liste keine Ports.
// Startzeit und Health stehen nur in den Details; gestoppte Container
// nennen ihre Ports ebenfalls nur dort.
for i, c := range containers {
if c.State == "running" || !enabled(c.Labels) {
if !enabled(c.Labels) {
continue
}
ports, err := client.PortBindings(ctx, c.ID)
d, err := client.Inspect(ctx, c.ID)
if err != nil {
a.log.Debug("Portzuordnung nicht lesbar", "container", c.Name(), "err", err)
continue // inzwischen entfernt o. Ä. → Workloads warnt
a.log.Debug("Container-Details nicht lesbar", "container", c.Name(), "err", err)
continue // inzwischen entfernt o. Ä. → Workloads warnt ggf.
}
containers[i].StartedAt, containers[i].Health = d.StartedAt, d.Health
if c.State != "running" {
containers[i].Ports = d.Ports
}
containers[i].Ports = ports
}
ws, down, warnings := Workloads(containers, host)
for _, w := range warnings {
@@ -265,6 +274,7 @@ func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, e
a.mu.Unlock()
state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...)
state.DownServices = append(state.DownServices, a.health.down()...)
state.Host = a.hostInfo(ctx, client)
return state, nil
}
+53 -1
View File
@@ -24,6 +24,7 @@ import (
"sultan/pkg/crypto"
"sultan/pkg/docker"
"sultan/pkg/license"
"sultan/pkg/registry"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
@@ -179,11 +180,18 @@ func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
key := fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)
bindings[key] = append(bindings[key], map[string]string{"HostIp": p.IP, "HostPort": fmt.Sprint(p.PublicPort)})
}
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "HostConfig": map[string]any{"PortBindings": bindings}})
state := map[string]any{"Status": c.State, "StartedAt": c.StartedAt}
if c.Health != "" {
state["Health"] = map[string]string{"Status": c.Health}
}
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "State": state, "HostConfig": map[string]any{"PortBindings": bindings}})
return
}
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
})
mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2"}`))
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
ch := make(chan docker.Event, 8)
f.mu.Lock()
@@ -872,3 +880,47 @@ func TestServerCandidates(t *testing.T) {
t.Errorf("ohne Override: %v", got)
}
}
// Zusatzdaten für Inventar und Störfall kommen automatisch beim Server an:
// Compose-Projekt/-Verzeichnis, Startzeit, Health je Container; FQDN,
// Betriebssystem (des Hosts) und Docker-Version je Knoten. Ein Wechsel des
// Health-Status wird nachgemeldet.
func TestRun_ReportsMetadata(t *testing.T) {
s := startServer(t, "", "")
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
wp := wordpress()
wp.Labels["com.docker.compose.project"] = "kunde-a"
wp.Labels["com.docker.compose.service"] = "wordpress"
wp.Labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
wp.StartedAt = started
wp.Health = "healthy"
dock := newFakeDocker(t, wp)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
cfg.FQDN = "webdocker11.lan.r25dc.de"
runAgent(t, cfg)
var st registry.NodeState
eventually(t, "Zustand mit Zusatzdaten", func() bool {
var ok bool
st, ok = s.NodeState("node-1")
return ok && len(st.Workloads) == 1 && st.Host != nil
})
wantMeta := &registry.WorkloadMeta{
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
State: "running", StartedAt: started, Health: "healthy",
}
if !reflect.DeepEqual(st.Workloads[0].Meta, wantMeta) {
t.Errorf("Meta = %+v, erwartet %+v", st.Workloads[0].Meta, wantMeta)
}
if h := st.Host; h.FQDN != "webdocker11.lan.r25dc.de" || h.OS != "Debian GNU/Linux 13 (trixie)" || h.DockerVersion != "29.8.2" {
t.Errorf("Host = %+v", h)
}
sick := wp
sick.Health = "unhealthy"
dock.set("health_status: unhealthy", sick)
eventually(t, "Health-Wechsel nachgemeldet", func() bool {
st, ok := s.NodeState("node-1")
return ok && len(st.Workloads) == 1 && st.Workloads[0].Meta != nil && st.Workloads[0].Meta.Health == "unhealthy"
})
}
+165
View File
@@ -0,0 +1,165 @@
package agent
import (
"bufio"
"context"
"net"
"os"
"slices"
"strings"
"time"
"sultan/pkg/docker"
"sultan/pkg/registry"
)
// osReleasePath: Quelle des Betriebssystems ohne Docker (systemd-Agent).
var osReleasePath = "/etc/os-release"
// resolver ist der Teil von *net.Resolver, den detectFQDN braucht.
type resolver interface {
LookupCNAME(ctx context.Context, host string) (string, error)
LookupAddr(ctx context.Context, addr string) ([]string, error)
}
// detectFQDN ermittelt den vollständigen Namen des Knotens für Inventar und
// Störfall (Spec 5.3): explizit (--fqdn) > Hostname mit Punkt > Hostname
// über die Suchdomain aufgelöst > Reverse-Lookup der Backend-Adresse >
// kurzer Hostname. Jede Abfrage ist kurz begrenzt; scheitert alles, bleibt
// der Hostname.
func detectFQDN(ctx context.Context, res resolver, explicit, hostname, backend string) string {
if n := normalizeName(explicit); n != "" {
return n
}
short := normalizeName(hostname)
if strings.Contains(short, ".") {
return short
}
lookup := func(f func(context.Context) string) string {
lctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return f(lctx)
}
if short != "" {
if n := lookup(func(c context.Context) string {
cname, err := res.LookupCNAME(c, short)
if err != nil {
return ""
}
return normalizeName(cname)
}); strings.Contains(n, ".") {
return n
}
}
if net.ParseIP(backend) != nil {
if n := lookup(func(c context.Context) string {
names, err := res.LookupAddr(c, backend)
if err != nil {
return ""
}
for _, name := range names {
if n := normalizeName(name); strings.Contains(n, ".") {
return n
}
}
return ""
}); n != "" {
return n
}
}
return short
}
func normalizeName(s string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s), "."))
}
// cgnat ist 100.64.0.0/10 – der Bereich, aus dem NetBird Adressen vergibt.
var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)}
// netbirdIP wählt die NetBird-Adresse: IPv4 auf wt0, sonst die erste
// IPv4-Adresse aus 100.64.0.0/10 (Interfaces alphabetisch).
func netbirdIP(addrs map[string][]net.IP) string {
for _, ip := range addrs["wt0"] {
if ip4 := ip.To4(); ip4 != nil {
return ip4.String()
}
}
names := make([]string, 0, len(addrs))
for name := range addrs {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
for _, ip := range addrs[name] {
if ip4 := ip.To4(); ip4 != nil && cgnat.Contains(ip4) {
return ip4.String()
}
}
}
return ""
}
// interfaceAddrs liest die Adressen aller Interfaces.
func interfaceAddrs() map[string][]net.IP {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
out := map[string][]net.IP{}
for _, ifc := range ifaces {
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
out[ifc.Name] = append(out[ifc.Name], ipnet.IP)
}
}
}
return out
}
// osPrettyName liest PRETTY_NAME (ersatzweise NAME) aus einer os-release-Datei.
func osPrettyName(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
var name string
sc := bufio.NewScanner(f)
for sc.Scan() {
key, value, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
if !ok {
continue
}
value = strings.Trim(value, `"'`)
switch key {
case "PRETTY_NAME":
return value
case "NAME":
name = value
}
}
return name
}
// hostInfo sammelt die Angaben zum Knoten. Mit Docker kommt das
// Betriebssystem aus der Docker-API (das des Hosts, auch wenn der Agent in
// einem Container läuft); Fehler lassen Felder nur leer.
func (a *Agent) hostInfo(ctx context.Context, client *docker.Client) *registry.HostInfo {
h := &registry.HostInfo{FQDN: a.fqdn, NetBirdIP: netbirdIP(interfaceAddrs())}
if client != nil {
info, err := client.Info(ctx)
if err != nil {
a.log.Debug("Docker-Info nicht lesbar", "err", err)
}
h.OS, h.DockerVersion = info.OperatingSystem, info.ServerVersion
}
if h.OS == "" {
h.OS = osPrettyName(osReleasePath)
}
return h
}
+96
View File
@@ -0,0 +1,96 @@
package agent
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"testing"
)
// fakeResolver beantwortet CNAME- und PTR-Anfragen aus Tabellen.
type fakeResolver struct {
cname map[string]string
ptr map[string][]string
}
func (f fakeResolver) LookupCNAME(_ context.Context, host string) (string, error) {
if c, ok := f.cname[host]; ok {
return c, nil
}
return "", errors.New("no such host")
}
func (f fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
if p, ok := f.ptr[addr]; ok {
return p, nil
}
return nil, errors.New("no such host")
}
// FQDN: explizit > Hostname mit Punkt > aufgelöster Hostname (Suchdomain) >
// Reverse-Lookup der Backend-Adresse > kurzer Hostname.
func TestDetectFQDN(t *testing.T) {
res := fakeResolver{
cname: map[string]string{"webdocker11": "webdocker11.lan.r25dc.de.", "kurz": "kurz"},
ptr: map[string][]string{"10.0.0.11": {"backend11.lan.r25dc.de."}},
}
tests := []struct {
name, explicit, hostname, backend, want string
}{
{"explizit gewinnt", "web.example.de", "webdocker11", "10.0.0.11", "web.example.de"},
{"Hostname ist schon FQDN", "", "pi.home.arpa", "10.0.0.11", "pi.home.arpa"},
{"über Suchdomain aufgelöst", "", "webdocker11", "10.0.0.11", "webdocker11.lan.r25dc.de"},
{"Reverse-Lookup der Backend-Adresse", "", "kurz", "10.0.0.11", "backend11.lan.r25dc.de"},
{"nichts auflösbar: kurzer Hostname", "", "solo", "10.9.9.9", "solo"},
{"Groß/Klein und Leerraum", " Web.Example.DE ", "x", "", "web.example.de"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := detectFQDN(context.Background(), res, tt.explicit, tt.hostname, tt.backend); got != tt.want {
t.Errorf("detectFQDN = %q, erwartet %q", got, tt.want)
}
})
}
}
// NetBird: Interface wt0, sonst eine Adresse aus 100.64.0.0/10 (CGNAT, wie
// NetBird sie vergibt).
func TestNetBirdIP(t *testing.T) {
ip := net.ParseIP
tests := []struct {
name string
addrs map[string][]net.IP
want string
}{
{"wt0", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "wt0": {ip("fe80::1"), ip("100.92.10.4")}}, "100.92.10.4"},
{"CGNAT auf anderem Interface", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "nb0": {ip("100.64.3.2")}}, "100.64.3.2"},
{"kein Overlay", map[string][]net.IP{"eth0": {ip("10.0.0.11")}, "lo": {ip("127.0.0.1")}}, ""},
{"100.128.x ist kein CGNAT", map[string][]net.IP{"eth0": {ip("100.128.0.1")}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := netbirdIP(tt.addrs); got != tt.want {
t.Errorf("netbirdIP = %q, erwartet %q", got, tt.want)
}
})
}
}
// Ohne Docker (systemd-Agent) kommt das Betriebssystem aus /etc/os-release.
func TestOSPrettyName(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "os-release")
os.WriteFile(p, []byte("NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nID=debian\n"), 0o644)
if got := osPrettyName(p); got != "Debian GNU/Linux 13 (trixie)" {
t.Errorf("osPrettyName = %q", got)
}
os.WriteFile(p, []byte("NAME=Alpine\nID=alpine\n"), 0o644)
if got := osPrettyName(p); got != "Alpine" {
t.Errorf("ohne PRETTY_NAME: %q, erwartet NAME", got)
}
if got := osPrettyName(filepath.Join(dir, "fehlt")); got != "" {
t.Errorf("fehlende Datei: %q", got)
}
}
+21 -1
View File
@@ -57,7 +57,7 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
continue
}
cand := candidate{
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels},
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels, Meta: meta(c)},
running: c.State == "running",
}
for _, proto := range protocols {
@@ -108,6 +108,26 @@ func Workloads(containers []docker.Container, host string) (workloads []registry
return workloads, slices.Compact(down), warnings
}
// Labels, die Docker Compose an jeden Container setzt.
const (
labelComposeProject = "com.docker.compose.project"
labelComposeService = "com.docker.compose.service"
labelComposeDir = "com.docker.compose.project.working_dir"
)
// meta liefert die Zusatzdaten eines Containers (Spec 5.3) – nur aus dem,
// was Docker ohnehin kennt, ohne zusätzliche Labels.
func meta(c docker.Container) *registry.WorkloadMeta {
return &registry.WorkloadMeta{
ComposeProject: c.Labels[labelComposeProject],
ComposeService: c.Labels[labelComposeService],
ComposeDir: c.Labels[labelComposeDir],
State: c.State,
StartedAt: c.StartedAt,
Health: c.Health,
}
}
// labelNames liefert die Namen (Schreibweise aus dem Label) unter prefix,
// z. B. die Router aus traefik.http.routers.<name>.…
func labelNames(labels map[string]string, prefix string) []string {
+38
View File
@@ -4,6 +4,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"sultan/pkg/docker"
"sultan/pkg/registry"
@@ -38,6 +39,7 @@ func TestWorkloads_TranslatesPublishedPort(t *testing.T) {
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.server.port": "8081",
},
Meta: &registry.WorkloadMeta{State: "running"},
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("Workloads =\n%+v\nerwartet\n%+v", got, want)
@@ -220,3 +222,39 @@ func TestWorkloads_RunningWinsOverStopped(t *testing.T) {
t.Errorf("ohne Überschneidung: got=%+v down=%v", got, down)
}
}
// Compose-Projekt, -Verzeichnis, Zustand, Startzeit und Health kommen
// automatisch aus Docker – ohne zusätzliche Labels.
func TestWorkloads_Meta(t *testing.T) {
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
labels := wpLabels()
labels["com.docker.compose.project"] = "kunde-a"
labels["com.docker.compose.service"] = "wordpress"
labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
compose := ctr("kunde-a-wordpress-1", labels, pub(80, 8081))
compose.StartedAt = started
compose.Health = "healthy"
plain := ctr("solo", map[string]string{
"traefik.enable": "true",
"traefik.http.routers.so.rule": "Host(`solo.kunde.de`)",
}, pub(80, 8082))
plain.State = "exited"
got, _, warnings := Workloads([]docker.Container{compose, plain}, "100.64.0.42")
if len(warnings) != 0 || len(got) != 2 {
t.Fatalf("Workloads = %+v, Warnungen %v", got, warnings)
}
want := map[string]*registry.WorkloadMeta{
"kunde-a-wordpress-1": {
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
State: "running", StartedAt: started, Health: "healthy",
},
"solo": {State: "exited"},
}
for _, w := range got {
if !reflect.DeepEqual(w.Meta, want[w.Name]) {
t.Errorf("%s: Meta = %+v, erwartet %+v", w.Name, w.Meta, want[w.Name])
}
}
}