ac8ea72025
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
85 lines
2.7 KiB
Go
85 lines
2.7 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
const stateFileName = "update-state.json"
|
|
|
|
// UpdateState tracks the last update attempt. Persisted to disk as audit log.
|
|
type UpdateState struct {
|
|
Status string `json:"status"` // "pending", "success", "failed"
|
|
PreviousVersion string `json:"previous_version"`
|
|
PreviousImage string `json:"previous_image"`
|
|
TargetVersion string `json:"target_version"`
|
|
TargetImage string `json:"target_image"`
|
|
InitiatedAt string `json:"initiated_at"` // RFC3339
|
|
InitiatedBy string `json:"initiated_by"` // "manual" or "auto"
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// LoadState reads the update state file. Returns nil, nil if file doesn't exist.
|
|
func LoadState(dataDir string) (*UpdateState, error) {
|
|
path := filepath.Join(dataDir, stateFileName)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("reading state file: %w", err)
|
|
}
|
|
|
|
var state UpdateState
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return nil, fmt.Errorf("parsing state file: %w", err)
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
// SaveState writes the state file atomically (write to .tmp, then rename).
|
|
func SaveState(dataDir string, state *UpdateState) error {
|
|
path := filepath.Join(dataDir, stateFileName)
|
|
tmpPath := path + ".tmp"
|
|
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling state: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
|
return fmt.Errorf("writing temp state file: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
return fmt.Errorf("renaming state file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearState removes the state file. Used for cleanup.
|
|
func ClearState(dataDir string, logger *log.Logger) {
|
|
path := filepath.Join(dataDir, stateFileName)
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
logger.Printf("[WARN] [selfupdate] Failed to clear update state file: %v", err)
|
|
return
|
|
}
|
|
logger.Printf("[INFO] [selfupdate] Update state cleared")
|
|
}
|
|
|
|
// RecordedPrevious is the controller image the box ran before `running`, by the swap's own record (decision 56,
|
|
// R-745): the previous_image of an update that ended on the running version. "" when the record names none — the
|
|
// last swap failed, was to another version, or there is no record (a box set up by hand). Pinned by
|
|
// TestRecordedPrevious in state_test.go.
|
|
func (s *UpdateState) RecordedPrevious(running string) string {
|
|
if s == nil || s.Status != "success" || s.TargetVersion != running || s.PreviousVersion == running {
|
|
return ""
|
|
}
|
|
return s.PreviousImage
|
|
}
|