Files
felhom-agent/internal/config/cutwrite.go
T

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()
}