3403c87c80
gates / gates (push) Successful in 34s
The guest's controller asks; the agent refuses in the first 10 min after boot, a second time in one boot (persisted, keyed by /proc/stat btime), and while a backup, restore test, OS step, controller update, drive format, NAS verify, escrow ceremony or vzdump runs (each a named 409 reason). On acceptance: the record first, a host report carrying deliberate_off accepted by the hub (bounded 60 s), then pct shutdown --timeout 120, then systemctl poweroff. Sudoers: two exact vectors (pct ^shutdown N --timeout N$, systemctl poweroff) in FELHOM_POWEROFF; nine injection cases. New TestEveryAliasIsGranted: a defined alias missing from the grant line passed the coverage check before. Every new test red-proofed.
118 lines
3.3 KiB
Go
118 lines
3.3 KiB
Go
// Package poweroff keeps the box's record of a household-requested power-off (R-998, doc 14).
|
|
//
|
|
// One small JSON file in the agent's state dir. A record belongs to the boot it was made in (the
|
|
// kernel's btime from /proc/stat), so:
|
|
// - "at most one accepted request per boot": Mark refuses when this boot already has a record;
|
|
// - the host report carries the record only while the box is still in that boot (Current), and
|
|
// the next boot's reports carry nothing — that absence is how the hub learns the box is back.
|
|
//
|
|
// Pinned by state_test.go.
|
|
package poweroff
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ErrAlreadyThisBoot is Mark's refusal: a power-off was already accepted in the current boot.
|
|
var ErrAlreadyThisBoot = errors.New("a power-off was already accepted in this boot")
|
|
|
|
// ErrBootUnknown is Mark's refusal when the boot cannot be identified — fail closed: without it the
|
|
// per-boot limit could not hold.
|
|
var ErrBootUnknown = errors.New("cannot read the host's boot time")
|
|
|
|
// Record is one accepted request.
|
|
type Record struct {
|
|
RequestedAt time.Time `json:"requested_at"`
|
|
BootBtime string `json:"boot_btime"` // /proc/stat btime of the boot it was accepted in
|
|
VMID int `json:"vmid"` // the guest whose controller asked
|
|
}
|
|
|
|
// State is the persisted record.
|
|
type State struct {
|
|
path string
|
|
btime func() string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// New returns the state at path. btime returns the current boot's /proc/stat btime ("" = unknown).
|
|
func New(path string, btime func() string) *State {
|
|
if btime == nil {
|
|
btime = ProcBtime
|
|
}
|
|
return &State{path: path, btime: btime}
|
|
}
|
|
|
|
// Current is the record made in THIS boot, or nil (none, another boot's, unreadable).
|
|
func (s *State) Current() *Record {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r := s.load()
|
|
b := s.btime()
|
|
if r == nil || b == "" || r.BootBtime != b {
|
|
return nil
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Mark records an accepted request for this boot. It refuses a second one in the same boot.
|
|
func (s *State) Mark(now time.Time, vmid int) (*Record, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
b := s.btime()
|
|
if b == "" {
|
|
return nil, ErrBootUnknown
|
|
}
|
|
if r := s.load(); r != nil && r.BootBtime == b {
|
|
return nil, ErrAlreadyThisBoot
|
|
}
|
|
r := &Record{RequestedAt: now.UTC().Truncate(time.Second), BootBtime: b, VMID: vmid}
|
|
data, _ := json.Marshal(r)
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
|
|
return nil, fmt.Errorf("poweroff: state dir: %w", err)
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return nil, fmt.Errorf("poweroff: write: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, s.path); err != nil {
|
|
return nil, fmt.Errorf("poweroff: rename: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (s *State) load() *Record {
|
|
data, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var r Record
|
|
if json.Unmarshal(data, &r) != nil || r.BootBtime == "" {
|
|
return nil
|
|
}
|
|
return &r
|
|
}
|
|
|
|
// ProcBtime reads the boot time (seconds since the epoch, as text) from /proc/stat; "" when unreadable.
|
|
func ProcBtime() string {
|
|
data, err := os.ReadFile("/proc/stat")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if strings.HasPrefix(line, "btime ") {
|
|
return strings.TrimSpace(strings.TrimPrefix(line, "btime "))
|
|
}
|
|
}
|
|
return ""
|
|
}
|