57405c1a99
The shared front half of provision and guest-loss DR as a journaled reconcile job (internal/reconcile/bringup.go), mirroring the restore-test's crash-safety but keeping the guest on success and applying a scenario-specific identity policy. Agent-only; no hub/wire change. Grounded by the slice-7 bring-up spike (commit 3342993): F1/F3/F4. - RunBringUp: restore -> reset identity -> size -> attach mounts -> start link-up; verdict is liveness (waitRunning), success KEEPS the guest. - identity policy: provision = fresh MAC (net0 sans hwaddr -> PVE regen) + hostname, host-side; machine-id/host-keys regenerate guest-side (systemd + baked golden unit). dr_guest_loss = preserve continuity (keep hostname; keep MAC unless KeepMAC=false). - compensating rollback: mid-flight failure destroys the just-created guest (SameTxnCreated provenance, gated); new Rollback journal flag + Recover.recoverBringUp reap a half-built guest from a crash. - F4: coalesced config PUT + bounded retry on the transient PVE config-lock 500 only. - --selftest=bring-up (mode/archive/vmid/hostname/keep). - configs/build-golden.sh: validated golden recipe incl. the F3 first-boot host-key unit. - doc-03 §9 + identity-reset settled/implemented. Deferred (stated): provisioning back half -> slice 8; host-loss DR + escrow consumption and the BringUpSpec source (hub desired-state) -> slice 10. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
6.5 KiB
Go
203 lines
6.5 KiB
Go
package reconcile
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// OpState is the lifecycle state of a journaled operation.
|
|
type OpState string
|
|
|
|
const (
|
|
// OpStarted: the op was planned and dispatch began (no Proxmox task yet).
|
|
OpStarted OpState = "started"
|
|
// OpTaskRunning: the Proxmox POST returned a UPID we are/were awaiting. Recorded
|
|
// so a crash mid-op is detected on restart and the task status re-checked.
|
|
OpTaskRunning OpState = "task_running"
|
|
// OpSucceeded: the op completed (task exitstatus OK, or a clean synchronous apply).
|
|
OpSucceeded OpState = "succeeded"
|
|
// OpFailed: the op errored (POST error, task non-OK, or WaitTask error).
|
|
OpFailed OpState = "failed"
|
|
)
|
|
|
|
// terminal reports whether a state is final (no further records expected).
|
|
func (s OpState) terminal() bool { return s == OpSucceeded || s == OpFailed }
|
|
|
|
// JournalEntry is one durable record. Multiple records share an OpID across an op's
|
|
// lifecycle (started → task_running → succeeded/failed); the latest wins in the index.
|
|
//
|
|
// IdempKey is the one-shot idempotency key: set on ops that must run AT MOST ONCE
|
|
// across retries/restarts (signed jobs, slice B). Reconcile actions leave it empty —
|
|
// reconcile is convergent and SHOULD re-run on real drift, so it is never suppressed
|
|
// by the idempotency set. A non-empty IdempKey that reaches OpSucceeded marks the key
|
|
// applied (AlreadyApplied true forever after, surviving restarts).
|
|
type JournalEntry struct {
|
|
OpID string `json:"op_id"`
|
|
VMID int `json:"vmid"`
|
|
Kind string `json:"kind"`
|
|
Params map[string]string `json:"params,omitempty"`
|
|
UPID string `json:"upid,omitempty"`
|
|
State OpState `json:"state"`
|
|
IdempKey string `json:"idemp_key,omitempty"`
|
|
// Scratch marks an entry that OWNS an agent-tagged scratch guest at VMID (slice 6
|
|
// restore-test). While such an entry is in-flight, the load-bearing invariant is "the
|
|
// scratch guest may exist and MUST be destroyed" — so Recover resolves it by ensuring
|
|
// VMID is gone (a benign teardown), NOT by re-checking any sub-task UPID. The entry is
|
|
// terminal only after teardown. See recover.go.
|
|
Scratch bool `json:"scratch,omitempty"`
|
|
// Rollback marks an entry that OWNS a guest the agent is CREATING in this journaled
|
|
// bring-up transaction (slice 7, doc 03 §9). While such an entry is in-flight, the
|
|
// invariant is "VMID may be a half-built guest and MUST be destroyed" (a compensating
|
|
// rollback) — so Recover resolves it by ensuring VMID is gone, NOT by re-checking the
|
|
// restore sub-task UPID (whose OK status would otherwise leave a half-provisioned guest).
|
|
// On SUCCESS the bring-up records this entry terminal and KEEPS the guest. See recover.go.
|
|
Rollback bool `json:"rollback,omitempty"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// Journal is the durable operation log + idempotency store. It mirrors
|
|
// authz.FileNonceStore: an fsync'd append-only JSONL with an in-memory index. A
|
|
// record is on disk AND fsync'd before Append returns, so a crash never loses a
|
|
// committed lifecycle transition.
|
|
//
|
|
// Phase-A scope: it records single-task ops (Start/Stop/SetConfig) for crash
|
|
// detection and provides the idempotency-key dedupe. Full multi-step compensating
|
|
// rollback (provision/restore, slices 6/7) reuses this structure with richer replay.
|
|
type Journal struct {
|
|
mu sync.Mutex
|
|
path string
|
|
f *os.File
|
|
latest map[string]JournalEntry // op_id -> latest record
|
|
applied map[string]struct{} // idemp keys that reached OpSucceeded
|
|
}
|
|
|
|
// OpenJournal opens (or creates) the journal at path, replaying any existing log into
|
|
// the index. The parent dir must exist (the daemon ensures it, sibling to the nonce
|
|
// store).
|
|
func OpenJournal(path string) (*Journal, error) {
|
|
j := &Journal{
|
|
path: path,
|
|
latest: make(map[string]JournalEntry),
|
|
applied: make(map[string]struct{}),
|
|
}
|
|
if err := j.load(); err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
j.f = f
|
|
syncJournalDir(filepath.Dir(path))
|
|
return j, nil
|
|
}
|
|
|
|
func (j *Journal) load() error {
|
|
b, err := os.ReadFile(j.path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, line := range bytes.Split(b, []byte("\n")) {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var e JournalEntry
|
|
if json.Unmarshal(line, &e) != nil {
|
|
continue // skip a torn trailing line from a crash mid-append
|
|
}
|
|
j.index(e)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// index folds one entry into the in-memory state (latest-by-op + applied set).
|
|
func (j *Journal) index(e JournalEntry) {
|
|
j.latest[e.OpID] = e
|
|
if e.State == OpSucceeded && e.IdempKey != "" {
|
|
j.applied[e.IdempKey] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Append durably writes one lifecycle record (fsync before returning) and updates the
|
|
// index. Callers build entries via the engine's lifecycle (Begin/RecordTask/Complete
|
|
// helpers below).
|
|
func (j *Journal) Append(e JournalEntry) error {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
rec, err := json.Marshal(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec = append(rec, '\n')
|
|
if _, err := j.f.Write(rec); err != nil {
|
|
return err
|
|
}
|
|
if err := j.f.Sync(); err != nil {
|
|
return err
|
|
}
|
|
j.index(e)
|
|
return nil
|
|
}
|
|
|
|
// Latest returns the most recent record for an op id.
|
|
func (j *Journal) Latest(opID string) (JournalEntry, bool) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
e, ok := j.latest[opID]
|
|
return e, ok
|
|
}
|
|
|
|
// InFlight returns ops whose latest state is non-terminal — i.e. started or
|
|
// task_running with no succeeded/failed record. On restart the daemon re-checks each
|
|
// (resume-or-rollback). Order is unspecified.
|
|
func (j *Journal) InFlight() []JournalEntry {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
var out []JournalEntry
|
|
for _, e := range j.latest {
|
|
if !e.State.terminal() {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AlreadyApplied reports whether a one-shot op with this idempotency key has already
|
|
// succeeded (survives restarts via the replayed log). Empty key is never "applied".
|
|
func (j *Journal) AlreadyApplied(idempKey string) bool {
|
|
if idempKey == "" {
|
|
return false
|
|
}
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
_, ok := j.applied[idempKey]
|
|
return ok
|
|
}
|
|
|
|
// Close releases the file handle.
|
|
func (j *Journal) Close() error {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
if j.f != nil {
|
|
return j.f.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncJournalDir(dir string) {
|
|
if d, err := os.Open(dir); err == nil {
|
|
_ = d.Sync()
|
|
_ = d.Close()
|
|
}
|
|
}
|