software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+303
View File
@@ -0,0 +1,303 @@
package agent
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"gopkg.in/yaml.v3"
"sultan/pkg/license"
"sultan/pkg/registry"
)
// ServiceSpec ist ein Dienst aus /etc/sultan.d/*.yaml (Spec 4.1b) – für
// Bare-Metal-/LAMP-Hosts ohne Docker-Labels.
type ServiceSpec struct {
Name string `yaml:"name"`
URL string `yaml:"url"` // lokaler Dienst; sein Port gilt auch für den Ingress
Hosts []string `yaml:"hosts"`
HTTPS bool `yaml:"https"`
CertResolver string `yaml:"certresolver"`
EntryPoints []string `yaml:"entrypoints"` // Default websecure (https) bzw. web
RedirectHTTPToHTTPS bool `yaml:"redirect_http_to_https"`
RedirectWWW string `yaml:"redirect_www"` // none | to_www | to_non_www
Healthcheck *HealthSpec `yaml:"healthcheck"`
Maintenance bool `yaml:"maintenance"` // Dienst in Wartung (Wartungsseite)
MaintenancePublic string `yaml:"maintenance_public_reason"` // optional, ersetzt den Standardtext
Labels map[string]string `yaml:"labels"` // rohe traefik.*-Labels, überschreiben die erzeugten
}
// HealthSpec beschreibt den Healthcheck eines Dienstes.
type HealthSpec struct {
URL string `yaml:"url"` // Default: URL des Dienstes
Interval Duration `yaml:"interval"` // Default 30s
Timeout Duration `yaml:"timeout"` // Default 5s
Failures int `yaml:"failures"` // Fehlschläge in Folge bis "down", Default 2
ExpectStatus int `yaml:"expect_status"`
ExpectTitle string `yaml:"expect_title"`
ExpectContains *ContainsSpec `yaml:"expect_contains"`
}
// ContainsSpec: Ein Element <Tag> muss Text enthalten.
type ContainsSpec struct {
Tag string `yaml:"tag"`
Text string `yaml:"text"`
}
// Duration liest Go-Dauern wie "30s" aus YAML.
type Duration struct{ time.Duration }
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
var s string
if err := n.Decode(&s); err != nil {
return err
}
v, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("Dauer %q nicht lesbar (z. B. 30s, 1m)", s)
}
d.Duration = v
return nil
}
var serviceNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`)
// ParseServices liest eine Datei; unbekannte Felder sind Fehler.
func ParseServices(data []byte) ([]ServiceSpec, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var f struct {
Services []ServiceSpec `yaml:"services"`
}
if err := dec.Decode(&f); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
seen := map[string]bool{}
for i := range f.Services {
s := &f.Services[i]
if err := s.normalize(); err != nil {
return nil, fmt.Errorf("Dienst %d (%q): %w", i+1, s.Name, err)
}
if seen[s.Name] {
return nil, fmt.Errorf("Dienst %q doppelt", s.Name)
}
seen[s.Name] = true
}
return f.Services, nil
}
func (s *ServiceSpec) normalize() error {
if !serviceNameRe.MatchString(s.Name) {
return errors.New("name fehlt oder ungültig (a–z, 0–9, - und _)")
}
u, err := url.Parse(s.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return fmt.Errorf("url %q muss eine http(s)-URL sein", s.URL)
}
if len(s.Hosts) == 0 {
return errors.New("hosts fehlt")
}
for i, h := range s.Hosts {
n, err := license.NormalizeDomain(h)
if err != nil || strings.HasPrefix(n, "*.") {
return fmt.Errorf("Host %q ungültig (keine Wildcards)", h)
}
s.Hosts[i] = n
}
switch s.RedirectWWW {
case "", "none", "to_www", "to_non_www":
default:
return fmt.Errorf("redirect_www %q ungültig (none, to_www, to_non_www)", s.RedirectWWW)
}
if utf8.RuneCountInString(s.MaintenancePublic) > 300 || strings.ContainsFunc(s.MaintenancePublic, unicode.IsControl) {
return errors.New("maintenance_public_reason: einzeilig, höchstens 300 Zeichen")
}
if s.RedirectHTTPToHTTPS && !s.HTTPS {
return errors.New("redirect_http_to_https erfordert https: true")
}
if h := s.Healthcheck; h != nil {
if h.URL == "" {
h.URL = s.URL
}
if h.Interval.Duration <= 0 {
h.Interval.Duration = 30 * time.Second
}
if h.Timeout.Duration <= 0 {
h.Timeout.Duration = 5 * time.Second
}
if h.Failures <= 0 {
h.Failures = 2
}
if h.ExpectStatus == 0 {
h.ExpectStatus = 200
}
if h.ExpectStatus < 100 || h.ExpectStatus > 599 {
return fmt.Errorf("expect_status %d ungültig", h.ExpectStatus)
}
if c := h.ExpectContains; c != nil && (c.Tag == "" || c.Text == "") {
return errors.New("expect_contains braucht tag und text")
}
}
return nil
}
// LoadServiceDir liest alle *.yaml/*.yml eines Verzeichnisses (alphabetisch).
// Fehlerhafte Dateien und doppelte Dienstnamen werden mit Warnung
// übersprungen, damit eine kaputte Datei nicht alle Dienste abschaltet. Ein
// fehlendes Verzeichnis bedeutet: keine Dienste.
func LoadServiceDir(dir string) ([]ServiceSpec, []string) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, []string{err.Error()}
}
var specs []ServiceSpec
var warnings []string
origin := map[string]string{}
for _, e := range entries {
ext := filepath.Ext(e.Name())
if e.IsDir() || (ext != ".yaml" && ext != ".yml") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err))
continue
}
parsed, err := ParseServices(data)
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err))
continue
}
for _, s := range parsed {
if first, dup := origin[s.Name]; dup {
warnings = append(warnings, fmt.Sprintf("%s: Dienst %q schon in %s definiert – übersprungen", e.Name(), s.Name, first))
continue
}
origin[s.Name] = e.Name()
specs = append(specs, s)
}
}
return specs, warnings
}
// FileWorkloads übersetzt Dienste in Workloads mit Traefik-Labels; danach
// gelten dieselben Regeln (FromLabels, Policy) wie für Docker-Container.
func FileWorkloads(specs []ServiceSpec, host string) []registry.Workload {
var out []registry.Workload
for _, s := range specs {
out = append(out, registry.Workload{Name: "sultan.d/" + s.Name, Host: host, Labels: s.labels()})
}
return out
}
func (s ServiceSpec) labels() map[string]string {
u, _ := url.Parse(s.URL)
port := u.Port()
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
scheme := "http"
if s.HTTPS {
scheme = "https"
}
hosts := slices.Clone(s.Hosts)
var bases []string
if s.RedirectWWW == "to_www" || s.RedirectWWW == "to_non_www" {
for _, h := range s.Hosts {
if b := strings.TrimPrefix(h, "www."); !slices.Contains(bases, b) {
bases = append(bases, b)
}
}
// Beide Varianten müssen beim Router ankommen, sonst greift die
// Umleitung nie.
for _, b := range bases {
for _, h := range []string{b, "www." + b} {
if !slices.Contains(hosts, h) {
hosts = append(hosts, h)
}
}
}
}
var rules []string
for _, h := range hosts {
rules = append(rules, "Host(`"+h+"`)")
}
rule := strings.Join(rules, " || ")
entryPoints := s.EntryPoints
if len(entryPoints) == 0 {
entryPoints = []string{"web"}
if s.HTTPS {
entryPoints = []string{"websecure"}
}
}
n := s.Name
router := "traefik.http.routers." + n + "."
l := map[string]string{
"traefik.enable": "true",
router + "rule": rule,
router + "entrypoints": strings.Join(entryPoints, ","),
router + "service": n,
"traefik.http.services." + n + ".loadbalancer.server.port": port,
}
if u.Scheme == "https" {
l["traefik.http.services."+n+".loadbalancer.server.scheme"] = "https"
}
if s.HTTPS {
l[router+"tls"] = "true"
if s.CertResolver != "" {
l[router+"tls.certresolver"] = s.CertResolver
}
}
if len(bases) > 0 {
alts := make([]string, len(bases))
for i, b := range bases {
alts[i] = regexp.QuoteMeta(b)
}
mw := "traefik.http.middlewares." + n + "-www.redirectregex."
if s.RedirectWWW == "to_non_www" {
l[mw+"regex"] = `^https?://www\.(` + strings.Join(alts, "|") + `)(.*)`
l[mw+"replacement"] = scheme + "://${1}${2}"
} else {
l[mw+"regex"] = `^https?://(` + strings.Join(alts, "|") + `)(.*)`
l[mw+"replacement"] = scheme + "://www.${1}${2}"
}
l[mw+"permanent"] = "true"
l[router+"middlewares"] = n + "-www"
}
if s.RedirectHTTPToHTTPS {
httpRouter := "traefik.http.routers." + n + "-http."
l[httpRouter+"rule"] = rule
l[httpRouter+"entrypoints"] = "web"
l[httpRouter+"service"] = n
l[httpRouter+"middlewares"] = n + "-https"
l["traefik.http.middlewares."+n+"-https.redirectscheme.scheme"] = "https"
l["traefik.http.middlewares."+n+"-https.redirectscheme.permanent"] = "true"
}
if s.Maintenance {
l[registry.LabelMaintenance] = "true"
if s.MaintenancePublic != "" {
l[registry.LabelMaintenancePublicReason] = s.MaintenancePublic
}
}
for k, v := range s.Labels {
l[k] = v
}
return l
}