// 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 "" }