Files
felhom-agent/internal/pbsdr/manager.go
T
admin b2ca63ee9f v0.91.0 — the DR tier can no longer be applied and dead at the same time (R-39 + R-50b(a))
Closes the agent half of R-39's fleet fix. Requires hub >=0.68.0 for the re-arm signal;
that hub is safe for 0.90.0 agents (unknown key dropped), so it deploys first.

Three compounding defects let a box report `applied` while every PBS request 401'd:

1. The re-key was INVISIBLE. An ep0 re-issue rotates the secret of an existing token, so
   token_id/fingerprint/datastore/namespace come back byte-identical and the descriptor
   content hash never moved — the converged agent short-circuited and never consumed the
   fresh secret. WirePBSDR.SecretGeneration (field-exact with the hub) is what moves the
   hash now, because descriptorHash marshals this struct.

2. The agent could not READ its own credential. It writes /etc/pve/priv/storage/<id>.pw
   through the root wrapper, but that dir is 0700 root:www-data and the wrapper had no
   read verb — so the target resolver got "permission denied" every cycle, warned, and
   skipped. The one loop that could have caught the 401 was blind BY CONSTRUCTION. Adds a
   narrow `read` verb (+ exactly one sudoers line, + a pbsdr-read capability row): one
   secret to stdout, no network, no mutation, never in argv (sudo logs argv), traversal
   refused by the id grammar, the dir allowlist AND a resolved-path prefix assertion.

3. Nothing probed AUTHENTICATION. pbs.ProbeAuth (GET /version + an ErrUnauthorized
   sentinel) runs on the 15-minute collect path and its verdict becomes a loud
   `auth_failed` the hub escalates to a fresh mint. /version needs no datastore, namespace
   or privilege, so a 401 means the CREDENTIAL is bad; 403 is deliberately NOT treated as
   unauthorized, since re-keying a too-narrow token would mint forever without fixing
   anything. A transport error is UNKNOWN, never a rejection — otherwise every network
   blip burns a credential. Recovery self-clears.

R-50b(a): the report now carries the installed wrapper's sha256 so drift against the
vouched manifest value is answerable. Empty = unknown, never drift.

Three red-proofs, all at the assertion level. Removing SecretGeneration fails the re-arm
test with "consume calls=1, want 2". Swallowing the probe result leaves State:applied
AuthFailed:false — the July-18 shape exactly. Notably, deleting the wrapper's id charset
guard alone does NOT open a traversal hole (readlink + the prefix assertion still catch
it), so the isolating red-proof removes BOTH and shows the out-of-tree secret printed —
the layering is real, and a single-guard red-proof would have passed vacuously.
2026-07-21 10:12:31 +02:00

538 lines
22 KiB
Go

// Package pbsdr is the PBS-DR-tier apply-bridge (slice 2) — the host-side twin of the
// controller's offsite apply-bridge. It consumes the hub's `pbs_dr` desired-state descriptor
// (slice 1) and converges the box: the pbs storage entry exists, K exists, the agent token can
// write to it, and the ceremony one-liner finds its storage id.
//
// The laws this package encodes (spike SPIKE-pbs-tier-provisioning-2026-07-10 + the offsite
// bridge precedent — do not "simplify" any of them):
//
// - SET-ONLY: re-apply never removes the storage entry — entry deletion destroys K
// (un-decryptable backups). The wrapper has no deletion verb; this package never asks for one.
// - ADOPTION FIRST: an existing healthy entry is adopted non-destructively — verified, granted,
// marked — with NO consume. Tenancy identity (namespace/username/datastore) is ENTRY-OWNED on
// adoption; a descriptor that names a different namespace does not repoint a live entry (the
// demo's manually-built felhom-offsite tenancy is the canonical case).
// - VERIFY-PIN-BEFORE-CONSUME: the PBS fingerprint is probed over the tunnel against the
// descriptor BEFORE the one-time secret is consumed (the offsite ordering law).
// - CONSUMED-BUT-FAILED IS LOUD: after a consume, any failure lands in a persistent alarming
// report state. The bridge never silently retries a burned secret; it recovers ONLY when the
// operator stages a fresh one (hub Re-issue) — the next consume then succeeds.
// - The secret rides the wrapper's STDIN (sudo logs argv) and is never logged.
package pbsdr
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/netip"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// WrapperPath is the pinned sudoers vector (configs/felhom-pbs-apply).
const WrapperPath = "/usr/local/sbin/felhom-pbs-apply"
// StorageReader is the PVE read seam (satisfied by *proxmox.Client; tests fake it).
type StorageReader interface {
StorageEntry(ctx context.Context, id string) (*proxmox.StorageEntryConfig, bool, error)
StorageActive(ctx context.Context, id string) (bool, error)
}
// SecretConsumer is the hub consume-once seam (satisfied by *hub.Client; tests fake it).
type SecretConsumer interface {
ConsumePBSToken(ctx context.Context) (string, error)
}
var storageIDRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,27}$`)
var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$`)
var tokenIDRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+@[A-Za-z0-9]+![A-Za-z0-9_.-]+$`)
var fingerprintRe = regexp.MustCompile(`^([A-Fa-f0-9]{2}:){31}[A-Fa-f0-9]{2}$`)
const markerName = "marker.json"
const consumedFailedName = "consumed-failed.json"
// marker is the idempotency record: the descriptor hash last converged + how.
type marker struct {
Hash string `json:"hash"`
State string `json:"state"` // "adopted" | "applied"
AppliedAt string `json:"applied_at"`
}
// consumedFailed is the LOUD persistent state: a one-time secret was burned and the apply failed.
type consumedFailed struct {
Hash string `json:"hash"`
Message string `json:"message"`
At string `json:"at"`
}
// Manager converges the box toward the pbs_dr descriptor. All deps are seams for tests.
type Manager struct {
runner proxmox.Runner
px StorageReader
hub SecretConsumer
stateDir string // <agent-state>/pbsdr
secretDir string // cfg.Backup pbs secret dir (the wrapper's copy target)
configPath string // agent.json — for the escrow.pbs_storage_id seed ("" = no seeding)
logger *slog.Logger
// probeFP is the verify-pin-before-consume seam (default pbs.ProbeFingerprint).
probeFP func(ctx context.Context, server, fingerprint string) error
now func() time.Time
mu sync.Mutex
status *hub.PBSDRStatus // latest snapshot for the report stanza
}
// NewManager builds the bridge manager.
func NewManager(runner proxmox.Runner, px StorageReader, hubc SecretConsumer, stateDir, secretDir, configPath string, logger *slog.Logger) *Manager {
if logger == nil {
logger = slog.Default()
}
return &Manager{
runner: runner, px: px, hub: hubc,
stateDir: filepath.Join(stateDir, "pbsdr"), secretDir: secretDir, configPath: configPath,
logger: logger,
probeFP: pbs.ProbeFingerprint,
now: func() time.Time { return time.Now().UTC() },
}
}
func (m *Manager) markerPath() string { return filepath.Join(m.stateDir, markerName) }
func (m *Manager) consumedFailedPath() string { return filepath.Join(m.stateDir, consumedFailedName) }
func (m *Manager) setStatus(s *hub.PBSDRStatus) {
m.mu.Lock()
m.status = s
m.mu.Unlock()
}
// Status returns the latest bridge state (the report stanza; nil before the first Apply).
func (m *Manager) Status() *hub.PBSDRStatus {
m.mu.Lock()
defer m.mu.Unlock()
return m.status
}
// DRConfigured reports whether the DR tier is configured ON for this box — the capability
// prober's GatePBSDR answer (v0.86.0). True when the last-seen descriptor was enabled (any live
// status except "disabled"), or, before the first desired-state fetch of this process, when a
// previously-converged marker exists (so an applied box never flaps to inactive across an agent
// restart). False = no descriptor ever / descriptor disabled → healthy pbsdr capabilities report
// "inactive (disabled by configuration)" instead of ok.
func (m *Manager) DRConfigured() bool {
m.mu.Lock()
st := m.status
m.mu.Unlock()
if st != nil {
return st.State != "disabled"
}
return m.loadMarker() != nil
}
// NoteAuthResult implements pbs.AuthSink (R-39 leg c): the credential probe's verdict for one
// storage, turned into the DR bridge's reported state.
//
// This is the leg that makes `applied` mean something. Until v0.91.0 the agent could not read the
// credential it had written (root-only path, no wrapper read verb), so a tier pinned to a superseded
// secret reported `applied` forever while every PBS request 401'd — and the hub, seeing `applied`,
// had no reason to re-key. Now a rejection becomes a LOUD `auth_failed` that pbsdrheal escalates to
// a fresh mint; the fresh mint advances the secret generation; the descriptor hash moves; and Apply
// finally re-consumes.
//
// Rules that keep it safe:
// - Only a REJECTION (401) sets the state. An unreachable PBS is UNKNOWN and must never re-key.
// - Only the storage this box's descriptor actually names is considered; a host may carry other
// PBS entries that are none of the DR tier's business.
// - Recovery is self-clearing: a subsequent successful probe restores the converged state from the
// marker, so the operator does not have to acknowledge a fault that fixed itself.
func (m *Manager) NoteAuthResult(storageID string, unauthorized bool, detail string) {
m.mu.Lock()
st := m.status
m.mu.Unlock()
// No descriptor seen yet, or this is not our storage → not our business.
if st == nil || st.StorageID == "" || storageID == "" || st.StorageID != storageID {
return
}
if unauthorized {
if st.State == "auth_failed" {
return // already loud; do not churn the report
}
m.logger.Error("pbsdr: the DR endpoint REJECTED this box's credential — the tier is applied and DEAD",
"storage_id", storageID, "previous_state", st.State)
m.setStatus(&hub.PBSDRStatus{
State: "auth_failed", StorageID: st.StorageID, Namespace: st.Namespace, AppliedAt: st.AppliedAt,
AuthFailed: true,
Message: detail + " — awaiting fresh credentials from the hub (automatic)",
})
return
}
// A clean probe clears a previously-loud auth failure by restoring the converged marker state.
if st.State == "auth_failed" && detail == "" {
mk := m.loadMarker()
restored := "applied"
appliedAt := st.AppliedAt
if mk != nil {
restored, appliedAt = mk.State, mk.AppliedAt
}
m.logger.Info("pbsdr: credential accepted again — clearing auth_failed", "storage_id", storageID, "state", restored)
m.setStatus(&hub.PBSDRStatus{State: restored, StorageID: st.StorageID, Namespace: st.Namespace, AppliedAt: appliedAt})
}
}
// descriptorHash is the idempotency key: sha256 of the canonical (struct-ordered) JSON.
func descriptorHash(b *hub.WirePBSDR) string {
j, _ := json.Marshal(b)
sum := sha256.Sum256(j)
return hex.EncodeToString(sum[:])
}
func (m *Manager) loadMarker() *marker {
raw, err := os.ReadFile(m.markerPath())
if err != nil {
return nil
}
var mk marker
if json.Unmarshal(raw, &mk) != nil || mk.Hash == "" {
return nil
}
return &mk
}
func (m *Manager) loadConsumedFailed() *consumedFailed {
raw, err := os.ReadFile(m.consumedFailedPath())
if err != nil {
return nil
}
var cf consumedFailed
if json.Unmarshal(raw, &cf) != nil {
return nil
}
return &cf
}
func (m *Manager) writeState(path string, v any) error {
if err := os.MkdirAll(m.stateDir, 0o700); err != nil {
return err
}
raw, err := json.Marshal(v)
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// validate checks every descriptor field BEFORE any exec (the fine gate under the coarse sudoers).
func validate(b *hub.WirePBSDR) error {
if !storageIDRe.MatchString(b.StorageID) {
return fmt.Errorf("bad storage_id %q", b.StorageID)
}
if !nameRe.MatchString(b.Datastore) {
return fmt.Errorf("bad datastore %q", b.Datastore)
}
if !nameRe.MatchString(b.Namespace) {
return fmt.Errorf("bad namespace %q", b.Namespace)
}
if !tokenIDRe.MatchString(b.TokenID) {
return fmt.Errorf("bad token_id %q", b.TokenID)
}
if !fingerprintRe.MatchString(b.Fingerprint) {
return fmt.Errorf("bad fingerprint (want 32-pair colon sha256)")
}
if _, err := netip.ParseAddr(b.PBSTunnelIP); err != nil {
return fmt.Errorf("bad pbs_tunnel_ip %q", b.PBSTunnelIP)
}
return nil
}
// Apply converges toward the descriptor. fetched=false (no desired data yet) is never a signal.
func (m *Manager) Apply(ctx context.Context, fetched bool, block *hub.WirePBSDR) {
if !fetched {
return
}
if block == nil {
// Absent block: pre-slice-1 hub or tier never enabled — a silent no-op (old-hub compat).
return
}
if !block.Enabled {
m.setStatus(&hub.PBSDRStatus{State: "disabled", StorageID: block.StorageID, Namespace: block.Namespace})
return // NO teardown in this slice — deprovision is a deliberate future op
}
if err := validate(block); err != nil {
m.logger.Error("pbsdr: descriptor invalid; refusing", "err", err)
m.setStatus(&hub.PBSDRStatus{State: "verify_failed", StorageID: block.StorageID, Namespace: block.Namespace,
Message: "descriptor invalid: " + err.Error()})
return
}
h := descriptorHash(block)
cf := m.loadConsumedFailed()
if mk := m.loadMarker(); mk != nil && mk.Hash == h && (cf == nil || cf.Hash != h) {
m.setStatus(&hub.PBSDRStatus{State: mk.State, StorageID: block.StorageID, Namespace: block.Namespace, AppliedAt: mk.AppliedAt})
return // idempotent: this exact descriptor already converged
}
entry, found, err := m.px.StorageEntry(ctx, block.StorageID)
if err != nil {
// R-22 self-grant (F4, tests/VALIDATION-n100-baremetal): on a NON-DEFAULT storage id the
// agent token holds no ACL on /storage/<id> yet, so this token-auth pre-check
// (GET /storage/<id>) 403s. Aborting here would deadlock permanently — the root-run wrapper
// `grant` that CREATES that very ACL is only reached further down (adoption / create paths).
// So on a 403 ONLY, run the grant now (root, no secret, no pre-existing entry required —
// `pveum acl modify` on a path is unconditional) and re-read once; the retry then flows the
// normal adoption/create path. Every OTHER error stays transient (retry next tick). The
// pre-check itself is KEPT: once the ACL exists the read succeeds and short-circuits the
// happy path cheaply — we only stop the 403 from being a first-contact dead-end.
var ae *proxmox.APIError
if !errors.As(err, &ae) || !ae.IsForbidden() {
m.logger.Warn("pbsdr: storage-entry read failed (transient; retrying next tick)", "err", err)
return
}
m.logger.Info("pbsdr: pre-check 403 (token has no ACL on this storage id yet) — self-granting via the root wrapper, then re-reading (R-22)",
"storage_id", block.StorageID)
if _, errOut, gerr := m.runner.Run(ctx, WrapperPath, "grant", block.StorageID); gerr != nil {
m.logger.Warn("pbsdr: self-grant failed (retrying next tick)", "err", gerr, "stderr", tail(errOut))
m.setStatus(&hub.PBSDRStatus{State: "verify_failed", StorageID: block.StorageID, Namespace: block.Namespace,
Message: "pre-check 403 and self-grant failed: " + gerr.Error()})
return
}
entry, found, err = m.px.StorageEntry(ctx, block.StorageID)
if err != nil {
// Grant succeeded but the read STILL fails → not the ACL bootstrap after all; surface it
// loudly rather than looping silently.
m.logger.Warn("pbsdr: storage-entry read still failing after self-grant (retrying next tick)", "err", err)
m.setStatus(&hub.PBSDRStatus{State: "verify_failed", StorageID: block.StorageID, Namespace: block.Namespace,
Message: "storage read failed even after self-grant: " + err.Error()})
return
}
}
if found && entry.Type != "pbs" {
msg := fmt.Sprintf("storage id %s exists with type %q (not pbs) — refusing to touch it", block.StorageID, entry.Type)
m.logger.Error("pbsdr: " + msg)
m.setStatus(&hub.PBSDRStatus{State: "verify_failed", StorageID: block.StorageID, Message: msg})
return
}
if found {
active, err := m.px.StorageActive(ctx, block.StorageID)
if err != nil {
m.logger.Warn("pbsdr: storage status probe failed (transient)", "err", err)
return
}
if active {
m.adopt(ctx, block, entry, h)
return
}
// Exists but unhealthy → the recovery path: verify → consume → reconcile-with-password.
}
// VERIFY-PIN-BEFORE-CONSUME (the ordering law): a mismatched or unreachable PBS aborts here —
// the one-time secret is untouched and the bridge simply retries next tick.
if err := m.probeFP(ctx, block.PBSTunnelIP, block.Fingerprint); err != nil {
m.logger.Warn("pbsdr: PBS fingerprint verify failed BEFORE consume (nothing consumed; retrying)", "err", err)
m.setStatus(&hub.PBSDRStatus{State: "verify_failed", StorageID: block.StorageID, Namespace: block.Namespace,
Message: "pre-consume fingerprint verify: " + err.Error()})
return
}
secret, err := m.hub.ConsumePBSToken(ctx)
if errors.Is(err, hub.ErrNoPBSSecret) {
if cf != nil && cf.Hash == h {
// The burned-secret dead-end: stay LOUD until the operator re-issues (fresh secret).
m.setStatus(&hub.PBSDRStatus{State: "consumed_failed", StorageID: block.StorageID, Namespace: block.Namespace,
ConsumedFailed: true, Message: cf.Message + " — awaiting operator re-issue (hub: Re-issue PBS credentials)"})
return
}
m.setStatus(&hub.PBSDRStatus{State: "waiting_secret", StorageID: block.StorageID, Namespace: block.Namespace,
Message: "verified; no unconsumed token secret staged on the hub"})
return
}
if err != nil {
m.logger.Warn("pbsdr: consume-token failed (transient; nothing consumed hub-side on error)", "err", err)
return
}
m.logger.Info("pbsdr: one-time token secret consumed (single-use; value withheld from logs)",
"storage_id", block.StorageID, "secret_len", len(secret))
// From here the secret is BURNED — every failure below is the loud persistent state.
if !found {
_, errOut, err := m.runner.RunStdin(ctx, strings.NewReader(secret+"\n"), WrapperPath,
"create", block.StorageID, block.PBSTunnelIP, block.Datastore, block.Namespace,
block.TokenID, block.Fingerprint, m.secretDir)
if err != nil {
m.consumedFail(h, fmt.Sprintf("wrapper create failed: %v (stderr: %s)", err, tail(errOut)), block)
return
}
} else {
_, errOut, err := m.runner.RunStdin(ctx, strings.NewReader(secret+"\n"), WrapperPath,
"reconcile", block.StorageID, block.PBSTunnelIP, block.Namespace,
block.TokenID, block.Fingerprint, m.secretDir)
if err != nil {
m.consumedFail(h, fmt.Sprintf("wrapper reconcile failed: %v (stderr: %s)", err, tail(errOut)), block)
return
}
}
if _, errOut, err := m.runner.Run(ctx, WrapperPath, "grant", block.StorageID); err != nil {
m.consumedFail(h, fmt.Sprintf("wrapper grant failed: %v (stderr: %s)", err, tail(errOut)), block)
return
}
active, err := m.px.StorageActive(ctx, block.StorageID)
if err != nil || !active {
m.consumedFail(h, fmt.Sprintf("post-apply status probe failed (active=%v err=%v)", active, err), block)
return
}
m.finishConverged(block, h, "applied")
}
// adopt is the non-destructive existing-entry path: NO consume, tenancy identity entry-owned.
func (m *Manager) adopt(ctx context.Context, block *hub.WirePBSDR, entry *proxmox.StorageEntryConfig, h string) {
if _, errOut, err := m.runner.Run(ctx, WrapperPath, "grant", block.StorageID); err != nil {
// No secret involved — a grant failure is transient, retried next tick.
m.logger.Warn("pbsdr: adoption grant failed (retrying next tick)", "err", err, "stderr", tail(errOut))
return
}
note := ""
if entry.Namespace != block.Namespace {
note = fmt.Sprintf("adopted entry keeps its own tenancy (namespace %q; descriptor says %q — entry wins, never repointed)",
entry.Namespace, block.Namespace)
m.logger.Info("pbsdr: " + note)
}
m.finishConverged(block, h, "adopted")
if note != "" {
st := m.Status()
st.Message = note
st.Namespace = entry.Namespace // report the REAL tenancy
m.setStatus(st)
}
}
// finishConverged writes the marker, clears any consumed-failed state, seeds the escrow storage
// id, and publishes the converged status.
func (m *Manager) finishConverged(block *hub.WirePBSDR, h, state string) {
at := m.now().Format(time.RFC3339)
if err := m.writeState(m.markerPath(), marker{Hash: h, State: state, AppliedAt: at}); err != nil {
m.logger.Error("pbsdr: marker write failed (converged, but will re-run next tick)", "err", err)
}
os.Remove(m.consumedFailedPath())
msg := ""
if err := m.seedEscrowStorageID(block.StorageID); err != nil {
msg = "escrow.pbs_storage_id seed failed: " + err.Error() + " (set it manually before the ceremony)"
m.logger.Warn("pbsdr: " + msg)
}
m.logger.Info("pbsdr: converged", "state", state, "storage_id", block.StorageID)
m.setStatus(&hub.PBSDRStatus{State: state, StorageID: block.StorageID, Namespace: block.Namespace,
AppliedAt: at, Message: msg})
}
// consumedFail records the LOUD persistent burned-secret state.
func (m *Manager) consumedFail(h, msg string, block *hub.WirePBSDR) {
m.logger.Error("pbsdr: CONSUMED-BUT-FAILED — the one-time secret is burned; NOT retrying silently. "+
"Operator action: Re-issue PBS credentials on the hub.", "detail", msg, "storage_id", block.StorageID)
if err := m.writeState(m.consumedFailedPath(), consumedFailed{Hash: h, Message: msg, At: m.now().Format(time.RFC3339)}); err != nil {
m.logger.Error("pbsdr: consumed-failed state write failed", "err", err)
}
m.setStatus(&hub.PBSDRStatus{State: "consumed_failed", StorageID: block.StorageID, Namespace: block.Namespace,
ConsumedFailed: true, Message: msg})
}
// seedEscrowStorageID sets escrow.pbs_storage_id in agent.json when empty/absent — the bare
// `--selftest=escrow-create` one-liner must find its storage with no flags. A different existing
// value is NEVER clobbered (warn-and-keep). Unknown config keys are preserved verbatim
// (map[string]RawMessage read-modify-write, atomic rename).
func (m *Manager) seedEscrowStorageID(storageID string) error {
if m.configPath == "" {
return nil
}
raw, err := os.ReadFile(m.configPath)
if err != nil {
return err
}
var doc map[string]json.RawMessage
if err := json.Unmarshal(raw, &doc); err != nil {
return fmt.Errorf("parse %s: %w", m.configPath, err)
}
var esc map[string]json.RawMessage
if cur, ok := doc["escrow"]; ok {
if err := json.Unmarshal(cur, &esc); err != nil {
return fmt.Errorf("parse escrow section: %w", err)
}
} else {
esc = map[string]json.RawMessage{}
}
if cur, ok := esc["pbs_storage_id"]; ok {
var existing string
_ = json.Unmarshal(cur, &existing)
if existing == storageID {
return nil // already seeded
}
if existing != "" {
m.logger.Warn("pbsdr: escrow.pbs_storage_id already set differently — keeping it",
"existing", existing, "descriptor", storageID)
return nil
}
}
idJSON, _ := json.Marshal(storageID)
esc["pbs_storage_id"] = idJSON
escJSON, err := json.Marshal(esc)
if err != nil {
return err
}
doc["escrow"] = escJSON
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
st, err := os.Stat(m.configPath)
if err != nil {
return err
}
// The config DIRECTORY (/etc/felhom-agent) is root-owned while the FILE is agent-owned —
// tmp+rename is impossible for the non-root agent (rename mutates the directory; live
// finding on the demo). So: park a recovery copy in the agent state dir, then rewrite the
// file IN PLACE (O_TRUNC). The agent is the file's only writer and the content is small;
// a torn write is recoverable from the parked copy.
if err := os.MkdirAll(m.stateDir, 0o700); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(m.stateDir, "agent.json.pre-seed"), raw, 0o600); err != nil {
return fmt.Errorf("parking the pre-seed copy: %w", err)
}
f, err := os.OpenFile(m.configPath, os.O_WRONLY|os.O_TRUNC, st.Mode().Perm())
if err != nil {
return err
}
if _, err := f.Write(out); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
m.logger.Info("pbsdr: seeded escrow.pbs_storage_id (the ceremony one-liner needs no flags)", "storage_id", storageID)
return nil
}
func tail(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 300 {
s = s[len(s)-300:]
}
return s
}