// SPDX-License-Identifier: AGPL-3.0-only package state import ( "bytes" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "time" ) const SchemaVersion = 1 type Record struct { SchemaVersion int `json:"schema_version"` Strategy string `json:"strategy"` DesiredRelease string `json:"desired_release,omitempty"` CandidateRelease string `json:"candidate_release,omitempty"` ActiveSlot string `json:"active_slot"` ActiveRelease string `json:"active_release"` PreviousSlot string `json:"previous_slot,omitempty"` PreviousRelease string `json:"previous_release,omitempty"` LastAttemptRelease string `json:"last_attempt_release,omitempty"` LastAttemptOutcome string `json:"last_attempt_outcome,omitempty"` LastAttemptAt string `json:"last_attempt_at,omitempty"` UpdatedAt string `json:"updated_at"` } func Load(path, root, strategy string) (Record, error) { b, err := os.ReadFile(path) if err != nil { return Record{}, err } dec := json.NewDecoder(bytes.NewReader(b)) dec.DisallowUnknownFields() var record Record if err := dec.Decode(&record); err != nil { return Record{}, fmt.Errorf("decode state: %w", err) } var extra any if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { return Record{}, errors.New("state contains trailing data") } if err := record.Validate(root, strategy); err != nil { return Record{}, err } return record, nil } func (r Record) Validate(root, strategy string) error { if r.SchemaVersion != SchemaVersion { return errors.New("state schema version is unsupported") } if r.Strategy != strategy { return errors.New("state strategy does not match configuration") } if strategy == "blue_green" && (r.ActiveSlot != "blue" && r.ActiveSlot != "green") { return errors.New("active slot is invalid") } if strategy == "singleton_candidate" && r.ActiveSlot != "singleton" { return errors.New("singleton state slot is invalid") } if err := releaseBelow(root, r.ActiveRelease); err != nil { return fmt.Errorf("active release: %w", err) } if r.PreviousRelease != "" { if err := releaseBelow(root, r.PreviousRelease); err != nil { return fmt.Errorf("previous release: %w", err) } } for label, release := range map[string]string{"desired release": r.DesiredRelease, "candidate release": r.CandidateRelease, "last attempt release": r.LastAttemptRelease} { if release != "" { if err := releaseBelow(root, release); err != nil { return fmt.Errorf("%s: %w", label, err) } } } if r.LastAttemptOutcome != "" { switch r.LastAttemptOutcome { case "running", "succeeded", "failed", "rolled_back": default: return errors.New("last attempt outcome is invalid") } if _, err := time.Parse(time.RFC3339, r.LastAttemptAt); err != nil { return errors.New("last attempt timestamp is invalid") } } if r.LastAttemptOutcome == "running" && r.CandidateRelease == "" { return errors.New("running attempt requires a candidate release") } if strategy == "blue_green" && r.PreviousRelease != "" && r.PreviousSlot == r.ActiveSlot { return errors.New("previous slot must differ from active slot") } if _, err := time.Parse(time.RFC3339, r.UpdatedAt); err != nil { return errors.New("state timestamp is invalid") } return nil } func Store(path, root string, record Record) error { if err := record.Validate(root, record.Strategy); err != nil { return err } if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { return errors.New("state file must not be a symlink") } else if err != nil && !os.IsNotExist(err) { return err } b, err := json.MarshalIndent(record, "", " ") if err != nil { return err } b = append(b, '\n') dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".tend-state-") if err != nil { return err } name := tmp.Name() ok := false defer func() { _ = tmp.Close() if !ok { _ = os.Remove(name) } }() if err := tmp.Chmod(0o644); err != nil { return err } if _, err := tmp.Write(b); err != nil { return err } if err := tmp.Sync(); err != nil { return err } if err := tmp.Close(); err != nil { return err } if err := os.Rename(name, path); err != nil { return err } ok = true return syncDir(dir) } func releaseBelow(root, path string) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path { return errors.New("must be a clean absolute path") } releases := filepath.Join(root, "releases") rel, err := filepath.Rel(releases, path) if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.ContainsRune(rel, filepath.Separator) { return errors.New("must be one direct child of the release directory") } return nil } func syncDir(path string) error { dir, err := os.Open(path) if err != nil { return err } defer dir.Close() return dir.Sync() }