// Package pi implements host-side lifecycle operations for the Pi bundled in core. package pi import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "time" ) const stateFileVersion = 1 // Phase describes the durable point reached by a Pi update. type Phase string const ( PhasePreflight Phase = "preflight" PhaseBuilding Phase = "building" PhaseRecreated Phase = "recreated" 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"` Volumes []string `json:"volumes"` ConfigurationSHA string `json:"configuration_sha256,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"` Phase Phase `json:"phase"` UpdatedAt time.Time `json:"updated_at"` Target Target `json:"target,omitempty"` Previous Image `json:"previous"` Candidate Image `json:"candidate,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 == "" { return State{}, errors.New("update recovery state is incomplete") } return state, nil } func writeState(path string, state State) error { if state.Previous.ID == "" || state.Previous.Reference == "" { 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') directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0o700); err != nil { return errors.New("could not create update recovery directory") } temporary, err := os.CreateTemp(directory, ".update-state-*.tmp") if err != nil { return errors.New("could not write update recovery state") } temporaryName := temporary.Name() defer os.Remove(temporaryName) if err := temporary.Chmod(0o600); err != nil { temporary.Close() return errors.New("could not protect update recovery state") } if _, err := temporary.Write(contents); err != nil { temporary.Close() return errors.New("could not write update recovery state") } if err := temporary.Close(); err != nil { return errors.New("could not write update recovery state") } if err := os.Rename(temporaryName, path); err != nil { return errors.New("could not finalize update recovery state") } return nil }