package enroll import ( "encoding/json" "fmt" "net/url" "os" "regexp" "strings" "time" ) // Service is one operator-enrolled workload. // Every field is infrastructure-owned; a deployment request can change none of them. type Service struct { Name string `json:"name"` Unit string `json:"unit"` ImageRepository string `json:"imageRepository"` UnitTemplateFile string `json:"unitTemplateFile"` ContainerName string `json:"containerName"` ReadinessURL string `json:"readinessURL"` Probation Probation `json:"probation"` unitTemplate string } type Probation struct { Deadline string `json:"deadline"` StableFor string `json:"stableFor"` RestartBudget int `json:"restartBudget"` deadline time.Duration stableFor time.Duration } func (p Probation) DeadlineDuration() time.Duration { return p.deadline } func (p Probation) StableForDuration() time.Duration { return p.stableFor } var segment = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) type Set struct { services map[string]Service } func Load(path string) (Set, error) { data, err := os.ReadFile(path) if err != nil { return Set{}, err } var list []Service decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() if err := decoder.Decode(&list); err != nil { return Set{}, fmt.Errorf("enrollment %s: %w", path, err) } set := Set{services: make(map[string]Service, len(list))} for _, service := range list { prepared, err := prepare(service) if err != nil { return Set{}, fmt.Errorf("enrollment %s: %w", path, err) } if _, exists := set.services[prepared.Name]; exists { return Set{}, fmt.Errorf("enrollment %s: duplicate service %q", path, prepared.Name) } set.services[prepared.Name] = prepared } return set, nil } func (s Set) Lookup(name string) (Service, bool) { service, ok := s.services[name] return service, ok } func (s Set) Names() []string { names := make([]string, 0, len(s.services)) for name := range s.services { names = append(names, name) } return names } // UnitFileName is the Quadlet source file installed for this service. func (s Service) UnitFileName() string { return s.Unit + ".container" } // UnitName is the generated systemd unit controlled during a rollout. func (s Service) UnitName() string { return s.Unit + ".service" } // RenderUnit produces the unit content for one pinned image reference. func (s Service) RenderUnit(reference string) string { return strings.ReplaceAll(s.unitTemplate, "${image}", reference) } // Reference builds the only image reference this service may run for a digest. func (s Service) Reference(digest string) string { return s.ImageRepository + "@" + digest } var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) func ValidDigest(digest string) bool { return digestPattern.MatchString(digest) } func prepare(service Service) (Service, error) { if !segment.MatchString(service.Name) { return Service{}, fmt.Errorf("invalid service name %q", service.Name) } if !segment.MatchString(service.Unit) { return Service{}, fmt.Errorf("service %s: invalid unit %q", service.Name, service.Unit) } if !segment.MatchString(service.ContainerName) { return Service{}, fmt.Errorf("service %s: invalid container name %q", service.Name, service.ContainerName) } if strings.ContainsAny(service.ImageRepository, " \t@:") || !strings.Contains(service.ImageRepository, "/") { return Service{}, fmt.Errorf("service %s: invalid image repository %q", service.Name, service.ImageRepository) } probe, err := url.Parse(service.ReadinessURL) if err != nil || (probe.Scheme != "http" && probe.Scheme != "https") || probe.Host == "" { return Service{}, fmt.Errorf("service %s: invalid readiness URL %q", service.Name, service.ReadinessURL) } if service.UnitTemplateFile == "" { return Service{}, fmt.Errorf("service %s: unit template file is required", service.Name) } template, err := os.ReadFile(service.UnitTemplateFile) if err != nil { return Service{}, fmt.Errorf("service %s: %w", service.Name, err) } service.unitTemplate = string(template) if !strings.Contains(service.unitTemplate, "${image}") { return Service{}, fmt.Errorf("service %s: unit template has no ${image} placeholder", service.Name) } if strings.Contains(service.unitTemplate, "AutoUpdate") { return Service{}, fmt.Errorf("service %s: enrolled unit must not use AutoUpdate", service.Name) } if service.Probation.deadline, err = time.ParseDuration(service.Probation.Deadline); err != nil || service.Probation.deadline <= 0 { return Service{}, fmt.Errorf("service %s: invalid probation deadline %q", service.Name, service.Probation.Deadline) } if service.Probation.stableFor, err = time.ParseDuration(service.Probation.StableFor); err != nil || service.Probation.stableFor <= 0 { return Service{}, fmt.Errorf("service %s: invalid probation stableFor %q", service.Name, service.Probation.StableFor) } if service.Probation.stableFor >= service.Probation.deadline { return Service{}, fmt.Errorf("service %s: probation deadline must exceed stableFor", service.Name) } if service.Probation.RestartBudget < 0 { return Service{}, fmt.Errorf("service %s: negative restart budget", service.Name) } return service, nil }