Files
ThothII/tools/tht/internal/lifecycle/lock.go

180 lines
5.3 KiB
Go

// Package lifecycle coordinates installation-wide mutating operations.
package lifecycle
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
var (
ErrLocked = errors.New("another lifecycle operation is already running for this installation")
ErrOwnership = errors.New("lifecycle lock ownership changed; refusing to remove it")
ErrTransactionInactive = errors.New("lifecycle transaction capability is not active")
ErrTransactionInstallation = errors.New("lifecycle transaction capability belongs to another installation")
)
const lockFileName = "lifecycle.lock.owner.json"
type owner struct {
Token string `json:"token"`
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
}
// Lock is an exclusively-created owner file scoped to one installation descriptor.
type Lock struct {
path string
token string
mu sync.Mutex
released bool
}
// Transaction is an opaque, installation-bound capability for work that must run while a
// lifecycle lock remains owned. Its fields are deliberately private so callers can obtain one
// only through AcquireTransaction.
type Transaction struct {
lock *Lock
controlDirectory string
}
// Acquire obtains the shared lock used by backup, restore, Pi lifecycle and product updates.
func Acquire(installation config.Installation) (*Lock, error) {
directory := installation.ControlDirectory()
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, fmt.Errorf("create lifecycle control directory: %w", err)
}
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("lifecycle control directory is not a regular directory")
}
if err := os.Chmod(directory, 0o700); err != nil {
return nil, fmt.Errorf("protect lifecycle control directory: %w", err)
}
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("create lifecycle lock owner: %w", err)
}
token := hex.EncodeToString(tokenBytes)
path := filepath.Join(directory, lockFileName)
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
return nil, ErrLocked
}
if err != nil {
return nil, fmt.Errorf("acquire lifecycle lock: %w", err)
}
value := owner{Token: token, PID: os.Getpid(), CreatedAt: time.Now().UTC()}
encodeErr := json.NewEncoder(file).Encode(value)
if encodeErr == nil {
encodeErr = file.Sync()
}
closeErr := file.Close()
if encodeErr != nil || closeErr != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("persist lifecycle lock owner: %w", errors.Join(encodeErr, closeErr))
}
return &Lock{path: path, token: token}, nil
}
// AcquireTransaction obtains a lifecycle lock and returns the capability required by callers
// that perform nested work inside the same non-reentrant transaction.
func AcquireTransaction(installation config.Installation) (*Transaction, error) {
lock, err := Acquire(installation)
if err != nil {
return nil, err
}
return &Transaction{
lock: lock,
controlDirectory: filepath.Clean(installation.ControlDirectory()),
}, nil
}
// Verify refuses a nil, released, replaced, or foreign-installation capability before a nested
// lifecycle operation can begin.
func (transaction *Transaction) Verify(installation config.Installation) error {
if transaction == nil || transaction.lock == nil {
return ErrTransactionInactive
}
if transaction.controlDirectory != filepath.Clean(installation.ControlDirectory()) {
return ErrTransactionInstallation
}
return transaction.lock.verifyHeld()
}
// Release relinquishes the lifecycle lock associated with this transaction capability.
func (transaction *Transaction) Release() error {
if transaction == nil || transaction.lock == nil {
return nil
}
return transaction.lock.Release()
}
// Path returns the installation-private owner-file path for diagnostics and tests.
func (lock *Lock) Path() string {
if lock == nil {
return ""
}
return lock.path
}
// Release removes only the owner file created by this Lock.
func (lock *Lock) Release() error {
if lock == nil {
return nil
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released {
return nil
}
contents, err := os.ReadFile(lock.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrOwnership
}
return fmt.Errorf("read lifecycle lock owner: %w", err)
}
var current owner
if json.Unmarshal(contents, &current) != nil || current.Token == "" || current.Token != lock.token {
return ErrOwnership
}
if err := os.Remove(lock.path); err != nil {
return fmt.Errorf("release lifecycle lock: %w", err)
}
lock.released = true
return nil
}
func (lock *Lock) verifyHeld() error {
if lock == nil {
return ErrTransactionInactive
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released {
return ErrTransactionInactive
}
contents, err := os.ReadFile(lock.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrOwnership
}
return fmt.Errorf("read lifecycle lock owner: %w", err)
}
var current owner
if json.Unmarshal(contents, &current) != nil || current.Token == "" || current.Token != lock.token {
return ErrOwnership
}
return nil
}