gates / gates (push) Successful in 42s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
189 lines
6.5 KiB
Go
189 lines
6.5 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// A cut write of agent.json must never leave a blind box (R-1380, 2026-11-15).
|
|
//
|
|
// agent.json is the agent's own file inside a ROOT-owned folder, so the agent cannot write a new file and rename it
|
|
// over the old one: every writer truncates the file and writes it again. A power cut between the truncate and the
|
|
// sync leaves an empty or half file, the agent does not start, and nothing can repair a box whose agent does not
|
|
// start from the hub. So the three writers (the trust move, the PBS seed, the backup-target move) go through
|
|
// ReplaceInPlace: the OLD bytes are first made durable in a journal file in the agent's own state folder (a new file
|
|
// and a rename, which the agent CAN do there), then the config is written, synced and read back, then the journal is
|
|
// removed. RecoverCutWrite runs at every start, before the config is read: a journal beside a config that does not
|
|
// parse means the write was cut, and the old bytes go back. A journal beside a config that parses means the cut fell
|
|
// before the truncate or after the sync — the file is whole, old or new, and only the journal is removed.
|
|
//
|
|
// Pinned by TestReplaceInPlace_* and TestRecoverCutWrite_* (this package) and by TestMainRecoversACutConfigWrite
|
|
// (cmd/felhom-agent).
|
|
|
|
// CutWriteDir is where the journal lives. It is NOT read from the config: the recovery runs before the config can be
|
|
// read. /var/lib/felhom-agent is the unit's StateDirectory. A test points it at a temp folder.
|
|
var CutWriteDir = "/var/lib/felhom-agent"
|
|
|
|
// cutWriteJournal is the journal's file name: the bytes agent.json held before the write that is in flight.
|
|
const cutWriteJournal = "agent.json.write-in-flight"
|
|
|
|
// cutPoint lets a test stop a write where a power cut would (the names are the steps of ReplaceInPlace).
|
|
var cutPoint = func(step string) error { return nil }
|
|
|
|
// ReplaceInPlace writes next over the config at path, whose present content is prev. The caller holds FileMu.
|
|
// With no journal folder there is no write: an unprotected write is the defect this exists to remove.
|
|
func ReplaceInPlace(path string, prev, next []byte) error {
|
|
if !json.Valid(prev) || !json.Valid(next) {
|
|
return fmt.Errorf("config: refusing to write %s: the old or the new content is not JSON", path)
|
|
}
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(CutWriteDir, 0o700); err != nil {
|
|
return fmt.Errorf("config: the journal folder: %w", err)
|
|
}
|
|
journal := filepath.Join(CutWriteDir, cutWriteJournal)
|
|
if err := writeDurable(journal, prev); err != nil {
|
|
return fmt.Errorf("config: the journal of the old content: %w", err)
|
|
}
|
|
if err := cutPoint("journal-written"); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, st.Mode().Perm())
|
|
if err != nil {
|
|
_ = removeDurable(journal)
|
|
return err
|
|
}
|
|
if err := cutPoint("truncated"); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
half := len(next) / 2
|
|
_, werr := f.Write(next[:half])
|
|
if werr == nil {
|
|
if err := cutPoint("half-written"); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
_, werr = f.Write(next[half:])
|
|
}
|
|
if werr == nil {
|
|
werr = f.Sync()
|
|
}
|
|
if cerr := f.Close(); werr == nil {
|
|
werr = cerr
|
|
}
|
|
if werr == nil {
|
|
var back []byte
|
|
if back, werr = os.ReadFile(path); werr == nil && !bytes.Equal(back, next) {
|
|
werr = fmt.Errorf("the file did not read back as written")
|
|
}
|
|
}
|
|
if werr != nil {
|
|
// The write failed with the process alive: put the old bytes back now. If that fails too, the journal stays
|
|
// and the next start repairs the file.
|
|
if rerr := restoreInPlace(path, prev); rerr != nil {
|
|
return fmt.Errorf("config: writing %s failed (%v) and the old content could not be put back (%v) — the next start repairs it from the journal", path, werr, rerr)
|
|
}
|
|
_ = removeDurable(journal)
|
|
return fmt.Errorf("config: writing %s failed, the old content was put back: %w", path, werr)
|
|
}
|
|
if err := cutPoint("synced"); err != nil {
|
|
return err
|
|
}
|
|
return removeDurable(journal)
|
|
}
|
|
|
|
// RecoverCutWrite repairs a config whose last write was cut. It returns what it did, for the start's log line:
|
|
// "" (no journal), "journal removed — the config is whole", or "the old config was put back".
|
|
func RecoverCutWrite(path string) (string, error) {
|
|
journal := filepath.Join(CutWriteDir, cutWriteJournal)
|
|
prev, err := os.ReadFile(journal)
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("config: reading the write journal: %w", err)
|
|
}
|
|
cur, cerr := os.ReadFile(path)
|
|
if cerr == nil && isJSONObject(cur) {
|
|
return "journal removed — the config is whole", removeDurable(journal)
|
|
}
|
|
if !isJSONObject(prev) {
|
|
// A journal that is itself broken cannot have been followed by a truncate (it is made durable first), so
|
|
// this is not a cut write. Leave both for a person.
|
|
return "", fmt.Errorf("config: %s does not parse and the write journal does not parse either", path)
|
|
}
|
|
if err := restoreInPlace(path, prev); err != nil {
|
|
return "", fmt.Errorf("config: putting the old content back into %s: %w", path, err)
|
|
}
|
|
return "the old config was put back", removeDurable(journal)
|
|
}
|
|
|
|
func isJSONObject(b []byte) bool {
|
|
var doc map[string]json.RawMessage
|
|
return json.Unmarshal(b, &doc) == nil && doc != nil
|
|
}
|
|
|
|
// restoreInPlace writes content into the EXISTING file (no create: the agent cannot make a file in the config's
|
|
// folder), so the file keeps its owner and mode.
|
|
func restoreInPlace(path string, content []byte) error {
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(content); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// writeDurable makes path hold content, whole, across a power cut: a new file, synced, renamed, the folder synced.
|
|
func writeDurable(path string, content []byte) error {
|
|
tmp := path + ".tmp"
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(content); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return err
|
|
}
|
|
return syncDir(filepath.Dir(path))
|
|
}
|
|
|
|
func removeDurable(path string) error {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return syncDir(filepath.Dir(path))
|
|
}
|
|
|
|
func syncDir(dir string) error {
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
return d.Sync()
|
|
}
|