// Package pi implements host-side lifecycle operations for the Pi bundled in core. package pi import ( "crypto/sha256" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/gofrs/flock" ) const stateFileVersion = 4 // Phase describes the durable point reached by a Pi update. type Phase string const ( PhasePreflight Phase = "preflight" PhaseBuilding Phase = "building" PhaseRecreated Phase = "recreated" PhasePromoting Phase = "promoting" PhaseVerified Phase = "verified" PhaseRolledBack Phase = "rolled_back" PhaseFailed Phase = "failed" PhaseNoop Phase = "noop" ) // Image is the non-secret recovery identity of a core image and its mounted volume names. type Image struct { ID string `json:"id"` Reference string `json:"reference"` Mounts []Mount `json:"mounts"` MountFingerprint string `json:"mount_fingerprint"` ConfigurationSHA string `json:"configuration_sha256,omitempty"` } // Mount is the complete persistence identity relevant to safe core recreation. type Mount struct { Type string `json:"type"` Name string `json:"name,omitempty"` SourceSHA256 string `json:"source_sha256"` Destination string `json:"destination"` RW bool `json:"rw"` Options string `json:"options,omitempty"` } // Target records the immutable input selected by the operator. Source is either build or a // digest-pinned image reference; it intentionally never contains credentials. type Target struct { Version string `json:"version"` Source string `json:"source"` } // State is recovery metadata stored below the installation project. It never stores environment // values, secret paths, credentials, or command output. type State struct { Version int `json:"version"` Transaction string `json:"transaction"` Phase Phase `json:"phase"` UpdatedAt time.Time `json:"updated_at"` Target Target `json:"target,omitempty"` Previous Image `json:"previous"` Candidate Image `json:"candidate,omitempty"` MutationStarted bool `json:"mutation_started,omitempty"` Error string `json:"error,omitempty"` } func readState(path string) (State, error) { contents, err := os.ReadFile(path) if err != nil { return State{}, err } var state State if err := json.Unmarshal(contents, &state); err != nil { return State{}, errors.New("update recovery state is invalid") } if state.Version != stateFileVersion || state.Previous.ID == "" || state.Previous.Reference == "" || state.Previous.MountFingerprint == "" { return State{}, errors.New("update recovery state is incomplete") } if mountFingerprint(state.Previous.Mounts) != state.Previous.MountFingerprint || (state.Candidate.ID != "" && mountFingerprint(state.Candidate.Mounts) != state.Candidate.MountFingerprint) { return State{}, errors.New("update recovery state mount fingerprint is invalid") } return state, nil } func writeState(path string, state State) error { if state.Previous.ID == "" || state.Previous.Reference == "" || state.Previous.MountFingerprint == "" { return errors.New("refusing to write incomplete update recovery state") } state.Version = stateFileVersion state.UpdatedAt = time.Now().UTC() contents, err := json.MarshalIndent(state, "", " ") if err != nil { return fmt.Errorf("encode update recovery state: %w", err) } contents = append(contents, '\n') if err := writeFileDurably(path, ".update-state-", contents); err != nil { return fmt.Errorf("could not durably write update recovery state: %w", err) } return nil } func writeFileDurably(path, prefix string, contents []byte) error { directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0o700); err != nil { return err } temporary, err := os.CreateTemp(directory, prefix+"*.tmp") if err != nil { return err } temporaryName := temporary.Name() defer os.Remove(temporaryName) if err := temporary.Chmod(0o600); err != nil { temporary.Close() return err } if _, err := temporary.Write(contents); err != nil { temporary.Close() return err } if err := temporary.Sync(); err != nil { temporary.Close() return err } if err := temporary.Close(); err != nil { return err } return durableReplace(temporaryName, path, directory) } func mountSourceHash(source string) string { sum := sha256.Sum256([]byte(source)) return fmt.Sprintf("%x", sum[:]) } func mountFingerprint(mounts []Mount) string { values := make([]string, len(mounts)) for i, mount := range mounts { values[i] = strings.Join([]string{mount.Type, mount.Name, mount.SourceSHA256, mount.Destination, fmt.Sprint(mount.RW), mount.Options}, "\x00") } sort.Strings(values) sum := sha256.Sum256([]byte(strings.Join(values, "\n"))) return fmt.Sprintf("%x", sum[:]) } type lockOwner struct { PID int `json:"pid"` Host string `json:"host"` StartedAt time.Time `json:"started_at"` Transaction string `json:"transaction"` } type updateLock struct { file *flock.Flock metadata string } var ErrLockHeld = errors.New("another Pi update or rollback is already in progress") func acquireLock(statePath string) (*updateLock, error) { if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil { return nil, errors.New("could not create Pi update recovery directory") } path := statePath + ".lock" file := flock.New(path, flock.SetPermissions(0o600)) locked, err := file.TryLock() if err != nil { return nil, errors.New("could not acquire Pi update lock") } if !locked { return nil, ErrLockHeld } host, err := os.Hostname() if err != nil { _ = file.Unlock() return nil, errors.New("could not identify Pi update lock owner") } owner := lockOwner{PID: os.Getpid(), Host: host, StartedAt: time.Now().UTC(), Transaction: fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano())} contents, err := json.Marshal(owner) if err != nil { _ = file.Unlock() return nil, errors.New("could not record Pi update lock owner") } metadata := path + ".owner.json" if err := writeFileDurably(metadata, ".lock-owner-", append(contents, '\n')); err != nil { _ = file.Unlock() return nil, errors.New("could not record Pi update lock owner") } return &updateLock{file: file, metadata: metadata}, nil } func (l *updateLock) Release() { _ = durableRemove(l.metadata) _ = l.file.Unlock() }