Files
soft_quay/core/installer/transaction.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)
}