Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/toad/internal/enroll/enroll.go

Raw
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
}