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,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
|
||||
}
|
||||
Reference in new issue
Block a user