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

114 lines
3.1 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")
)
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
}
// 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
}
// 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
}