259 lines
7.6 KiB
Go
259 lines
7.6 KiB
Go
package agent
|
||
|
||
import (
|
||
"fmt"
|
||
"maps"
|
||
"net"
|
||
"regexp"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"sultan/pkg/docker"
|
||
"sultan/pkg/registry"
|
||
)
|
||
|
||
// protocols sind die Label-Ebenen mit Routern und Services (HTTP und TCP
|
||
// für TLS-Passthrough). Router- und Service-Namen sind je Ebene getrennt.
|
||
var protocols = []string{"http", "tcp"}
|
||
|
||
func routersPrefix(proto string) string { return "traefik." + proto + ".routers." }
|
||
func servicesPrefix(proto string) string { return "traefik." + proto + ".services." }
|
||
|
||
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
|
||
|
||
// Workloads übersetzt Container in Workloads für service_state.
|
||
//
|
||
// Berücksichtigt werden nur Container mit traefik.enable=true. Die Regeln
|
||
// gelten für traefik.http.* und traefik.tcp.* (TLS-Passthrough) getrennt. Weil der
|
||
// Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port
|
||
// im Container) auf den veröffentlichten Host-Port umgerechnet. Fehlt das
|
||
// Port-Label, gilt wie bei Traefiks Docker-Provider der einzige
|
||
// veröffentlichte Port; ohne Service-Labels entsteht ein Service mit dem
|
||
// Containernamen. Container, deren Ports von außen nicht erreichbar sind
|
||
// (nicht veröffentlicht, nur Loopback, nur UDP), werden mit Warnung
|
||
// weggelassen.
|
||
//
|
||
// Gestoppte Container (State != "running", Ports aus PortBindings) bleiben
|
||
// drin; ihre Services stehen in down, damit die Policy zur Wartungsseite
|
||
// umleitet statt die Route zu entfernen. Überschneiden sich ihre Router-
|
||
// oder Service-Namen mit einem laufenden Container, entfallen sie.
|
||
func Workloads(containers []docker.Container, host string) (workloads []registry.Workload, down []string, warnings []string) {
|
||
type candidate struct {
|
||
workload registry.Workload
|
||
running bool
|
||
routers []string // "<proto>:<name>"
|
||
services []string // "<proto>:<name>"
|
||
}
|
||
var candidates []candidate
|
||
taken := map[string]bool{} // Namen laufender Container, "r:"/"s:" + klein
|
||
for _, c := range containers {
|
||
if !enabled(c.Labels) {
|
||
continue
|
||
}
|
||
labels, warning := translatePorts(c)
|
||
if warning != "" {
|
||
warnings = append(warnings, warning)
|
||
continue
|
||
}
|
||
cand := candidate{
|
||
workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels, Meta: meta(c)},
|
||
running: c.State == "running",
|
||
}
|
||
for _, proto := range protocols {
|
||
for _, r := range labelNames(labels, routersPrefix(proto)) {
|
||
cand.routers = append(cand.routers, proto+":"+r)
|
||
}
|
||
for _, s := range labelNames(labels, servicesPrefix(proto)) {
|
||
cand.services = append(cand.services, proto+":"+s)
|
||
}
|
||
}
|
||
if cand.running {
|
||
for _, r := range cand.routers {
|
||
taken["r:"+strings.ToLower(r)] = true
|
||
}
|
||
for _, s := range cand.services {
|
||
taken["s:"+strings.ToLower(s)] = true
|
||
}
|
||
}
|
||
candidates = append(candidates, cand)
|
||
}
|
||
collides := func(c candidate) bool {
|
||
for _, r := range c.routers {
|
||
if taken["r:"+strings.ToLower(r)] {
|
||
return true
|
||
}
|
||
}
|
||
for _, s := range c.services {
|
||
if taken["s:"+strings.ToLower(s)] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
for _, c := range candidates {
|
||
if !c.running {
|
||
if collides(c) {
|
||
continue
|
||
}
|
||
for _, s := range c.services {
|
||
_, name, _ := strings.Cut(s, ":")
|
||
down = append(down, name)
|
||
}
|
||
}
|
||
workloads = append(workloads, c.workload)
|
||
}
|
||
slices.SortFunc(workloads, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) })
|
||
slices.Sort(down)
|
||
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 ®istry.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 {
|
||
var out []string
|
||
for k := range labels {
|
||
if len(k) <= len(prefix) || !strings.EqualFold(k[:len(prefix)], prefix) {
|
||
continue
|
||
}
|
||
if name, _, ok := strings.Cut(k[len(prefix):], "."); ok && !slices.Contains(out, name) {
|
||
out = append(out, name)
|
||
}
|
||
}
|
||
slices.Sort(out)
|
||
return out
|
||
}
|
||
|
||
func enabled(labels map[string]string) bool {
|
||
for k, v := range labels {
|
||
if strings.EqualFold(k, "traefik.enable") {
|
||
on, err := strconv.ParseBool(strings.TrimSpace(v))
|
||
return err == nil && on
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// reachablePorts: Container-Port → von außen erreichbarer Host-Port (TCP).
|
||
func reachablePorts(ports []docker.Port) map[uint16]uint16 {
|
||
out := map[uint16]uint16{}
|
||
for _, p := range ports {
|
||
if p.Type != "tcp" || p.PublicPort == 0 {
|
||
continue
|
||
}
|
||
if ip := net.ParseIP(p.IP); ip != nil && ip.IsLoopback() {
|
||
continue
|
||
}
|
||
out[p.PrivatePort] = p.PublicPort
|
||
}
|
||
return out
|
||
}
|
||
|
||
type serviceLabels struct {
|
||
name string // Schreibweise aus dem Label
|
||
portKey string // Original-Schlüssel des Port-Labels, "" wenn keins
|
||
}
|
||
|
||
func translatePorts(c docker.Container) (map[string]string, string) {
|
||
labels := maps.Clone(c.Labels)
|
||
reachable := reachablePorts(c.Ports)
|
||
for _, proto := range protocols {
|
||
if warning := translateProtoPorts(c, labels, reachable, proto); warning != "" {
|
||
return nil, warning
|
||
}
|
||
}
|
||
return labels, ""
|
||
}
|
||
|
||
// translateProtoPorts rechnet die Service-Ports einer Ebene (http/tcp) in
|
||
// labels auf veröffentlichte Host-Ports um.
|
||
func translateProtoPorts(c docker.Container, labels map[string]string, reachable map[uint16]uint16, proto string) string {
|
||
svcPrefix := servicesPrefix(proto)
|
||
single := func() (uint16, bool) {
|
||
for _, pub := range reachable {
|
||
return pub, len(reachable) == 1
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
services := map[string]*serviceLabels{}
|
||
hasRouters := false
|
||
for k := range labels {
|
||
lk := strings.ToLower(k)
|
||
if strings.HasPrefix(lk, routersPrefix(proto)) {
|
||
hasRouters = true
|
||
}
|
||
rest, ok := strings.CutPrefix(lk, svcPrefix)
|
||
if !ok {
|
||
continue
|
||
}
|
||
svc, attr, ok := strings.Cut(rest, ".")
|
||
if !ok {
|
||
continue
|
||
}
|
||
s := services[svc]
|
||
if s == nil {
|
||
name, _, _ := strings.Cut(k[len(svcPrefix):], ".")
|
||
s = &serviceLabels{name: name}
|
||
services[svc] = s
|
||
}
|
||
if attr == "loadbalancer.server.port" {
|
||
s.portKey = k
|
||
}
|
||
}
|
||
|
||
if len(services) == 0 {
|
||
if !hasRouters {
|
||
return ""
|
||
}
|
||
pub, ok := single()
|
||
if !ok {
|
||
return fmt.Sprintf("Container %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), len(reachable))
|
||
}
|
||
svc := unsafeName.ReplaceAllString(c.Name(), "-")
|
||
labels[svcPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
|
||
return ""
|
||
}
|
||
|
||
for _, s := range services {
|
||
if s.portKey == "" {
|
||
pub, ok := single()
|
||
if !ok {
|
||
return fmt.Sprintf("Container %s, Service %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), s.name, len(reachable))
|
||
}
|
||
labels[svcPrefix+s.name+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
|
||
continue
|
||
}
|
||
private, err := strconv.Atoi(strings.TrimSpace(labels[s.portKey]))
|
||
if err != nil {
|
||
return fmt.Sprintf("Container %s: Port %q nicht lesbar", c.Name(), labels[s.portKey])
|
||
}
|
||
pub, ok := reachable[uint16(private)]
|
||
if !ok {
|
||
return fmt.Sprintf("Container %s: Port %d ist von außen nicht erreichbar (nicht veröffentlicht oder nur 127.0.0.1)", c.Name(), private)
|
||
}
|
||
labels[s.portKey] = strconv.Itoa(int(pub))
|
||
}
|
||
return ""
|
||
}
|