// SPDX-License-Identifier: AGPL-3.0-only package state import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/netip" "os" "path/filepath" "regexp" "time" ) const CandidateLeaseSchemaVersion = 1 var ( leaseOperationPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) leaseServicePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`) ) type CandidateLease struct { SchemaVersion int `json:"schema_version"` Service string `json:"service"` OperationID string `json:"operation_id"` Release string `json:"release"` Unit string `json:"unit"` Address string `json:"address"` StartedAt string `json:"started_at"` } func CandidateLeasePath(statePath string) string { return statePath + ".candidate-lease.json" } func (l CandidateLease) Validate(root, service string) error { if l.SchemaVersion != CandidateLeaseSchemaVersion { return errors.New("candidate lease schema version is unsupported") } if !leaseServicePattern.MatchString(service) || l.Service != service { return errors.New("candidate lease service does not match configuration") } if !leaseOperationPattern.MatchString(l.OperationID) { return errors.New("candidate lease operation ID is invalid") } if err := releaseBelow(root, l.Release); err != nil { return fmt.Errorf("candidate lease release: %w", err) } expectedUnit := service + "-tend-candidate-" + l.OperationID[:12] + ".service" if l.Unit != expectedUnit { return errors.New("candidate lease unit does not match its operation") } address, err := netip.ParseAddrPort(l.Address) if err != nil || !address.Addr().IsLoopback() || address.Port() == 0 { return errors.New("candidate lease address is invalid") } if _, err := time.Parse(time.RFC3339, l.StartedAt); err != nil { return errors.New("candidate lease timestamp is invalid") } return nil } func LoadCandidateLease(path, root, service string) (CandidateLease, error) { info, err := os.Lstat(path) if err != nil { return CandidateLease{}, err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 64<<10 { return CandidateLease{}, errors.New("candidate lease must be a bounded regular file") } body, err := os.ReadFile(path) if err != nil { return CandidateLease{}, err } decoder := json.NewDecoder(bytes.NewReader(body)) decoder.DisallowUnknownFields() var lease CandidateLease if err := decoder.Decode(&lease); err != nil { return CandidateLease{}, fmt.Errorf("decode candidate lease: %w", err) } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return CandidateLease{}, errors.New("candidate lease contains trailing data") } if err := lease.Validate(root, service); err != nil { return CandidateLease{}, err } return lease, nil } func StoreCandidateLease(path, root string, lease CandidateLease) error { if err := lease.Validate(root, lease.Service); err != nil { return err } if info, err := os.Lstat(path); err == nil && (!info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0) { return errors.New("candidate lease path must be a regular file, not a symlink") } else if err != nil && !os.IsNotExist(err) { return err } body, err := json.MarshalIndent(lease, "", " ") if err != nil { return err } body = append(body, '\n') dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return err } temporary, err := os.CreateTemp(dir, ".tend-candidate-lease-") if err != nil { return err } name := temporary.Name() complete := false defer func() { _ = temporary.Close() if !complete { _ = os.Remove(name) } }() if err := temporary.Chmod(0o644); err != nil { return err } if _, err := temporary.Write(body); err != nil { return err } if err := temporary.Sync(); err != nil { return err } if err := temporary.Close(); err != nil { return err } if err := os.Rename(name, path); err != nil { return err } complete = true return syncDir(dir) } func RemoveCandidateLease(path string) error { info, err := os.Lstat(path) if os.IsNotExist(err) { return nil } if err != nil { return err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return errors.New("candidate lease path must be a regular file, not a symlink") } if err := os.Remove(path); err != nil { return err } return syncDir(filepath.Dir(path)) }