Prototype atomic install recovery (T-103)
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
transactionFileName = "install-transaction.json"
|
||||
transactionBackupFileName = "install-transaction.json.backup"
|
||||
transactionSchemaVersion = 1
|
||||
)
|
||||
|
||||
type transactionPhase string
|
||||
|
||||
const (
|
||||
phasePrepared transactionPhase = "prepared"
|
||||
phaseCurrentBackedUp transactionPhase = "current_backed_up"
|
||||
phaseStagingActivated transactionPhase = "staging_activated"
|
||||
phaseRollbackRequired transactionPhase = "rollback_required"
|
||||
phaseCommitted transactionPhase = "committed"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTransactionCorrupt = errors.New("install transaction is corrupt")
|
||||
ErrRecoveryRequired = errors.New("install recovery is required")
|
||||
)
|
||||
|
||||
type transactionRecord struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Phase transactionPhase `json:"phase"`
|
||||
HadCurrent bool `json:"had_current"`
|
||||
}
|
||||
|
||||
func newTransaction(phase transactionPhase, hadCurrent bool) transactionRecord {
|
||||
return transactionRecord{
|
||||
SchemaVersion: transactionSchemaVersion,
|
||||
Phase: phase,
|
||||
HadCurrent: hadCurrent,
|
||||
}
|
||||
}
|
||||
|
||||
func (record transactionRecord) validate() error {
|
||||
if record.SchemaVersion != transactionSchemaVersion {
|
||||
return fmt.Errorf(
|
||||
"%w: schema_version=%d",
|
||||
ErrTransactionCorrupt,
|
||||
record.SchemaVersion,
|
||||
)
|
||||
}
|
||||
switch record.Phase {
|
||||
case phasePrepared,
|
||||
phaseCurrentBackedUp,
|
||||
phaseStagingActivated,
|
||||
phaseRollbackRequired,
|
||||
phaseCommitted:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTransaction(layout appLayout, record transactionRecord) error {
|
||||
if err := record.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode install transaction: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := replaceFileWithBackup(
|
||||
layout.root,
|
||||
layout.transaction,
|
||||
layout.transactionBackup,
|
||||
data,
|
||||
); err != nil {
|
||||
return fmt.Errorf("write install transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTransaction(layout appLayout) (transactionRecord, bool, error) {
|
||||
data, err := readTransactionFile(layout.transaction)
|
||||
if os.IsNotExist(err) {
|
||||
data, err = readTransactionFile(layout.transactionBackup)
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return transactionRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return transactionRecord{}, false, fmt.Errorf("read install transaction: %w", err)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
var record transactionRecord
|
||||
if err := decoder.Decode(&record); err != nil {
|
||||
return transactionRecord{}, false, fmt.Errorf("%w: %v", ErrTransactionCorrupt, err)
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
return transactionRecord{}, false, err
|
||||
}
|
||||
if err := record.validate(); err != nil {
|
||||
return transactionRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func readTransactionFile(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%w: transaction is not a regular file", ErrUnsafeInstallLayout)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%w: trailing JSON value", ErrTransactionCorrupt)
|
||||
}
|
||||
return fmt.Errorf("%w: trailing data: %v", ErrTransactionCorrupt, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeTransaction(layout appLayout) error {
|
||||
if err := os.Remove(layout.transaction); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove install transaction: %w", err)
|
||||
}
|
||||
if err := os.Remove(layout.transactionBackup); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove install transaction backup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceFileWithBackup(
|
||||
directory string,
|
||||
target string,
|
||||
backup string,
|
||||
data []byte,
|
||||
) error {
|
||||
temporary, err := os.CreateTemp(directory, ".install-transaction-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
movedTarget := false
|
||||
if info, err := os.Lstat(target); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%w: transaction target is not a regular file", ErrUnsafeInstallLayout)
|
||||
}
|
||||
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
movedTarget = true
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(temporaryPath, target); err != nil {
|
||||
if movedTarget {
|
||||
_ = os.Rename(backup, target)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if movedTarget {
|
||||
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func transactionPaths(root string) (string, string) {
|
||||
return filepath.Join(root, transactionFileName),
|
||||
filepath.Join(root, transactionBackupFileName)
|
||||
}
|
||||
Reference in New Issue
Block a user