210 lines
5.2 KiB
Go
210 lines
5.2 KiB
Go
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)
|
|
}
|