agent v0.85.0 WIP: F12/F11/F10/F9/F2/F1 boot-recovery plane + appliance self-heal (pre-build)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
This commit is contained in:
2026-07-12 07:48:49 +02:00
parent bec4bac076
commit bc4eda926b
18 changed files with 1249 additions and 104 deletions
+196 -4
View File
@@ -2,6 +2,8 @@ package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"os"
@@ -246,8 +248,12 @@ func renderNetworkMountUnit(s NetworkMountSpec) string {
b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n")
b.WriteString("[Unit]\n")
fmt.Fprintf(&b, "Description=Felhom network storage %s (%s)\n", sanitizeDesc(s.Name), s.Protocol)
b.WriteString("After=network-online.target\n")
b.WriteString("Wants=network-online.target\n")
// F12 (CAMPAIGN-3, CRITICAL): NO network-online.target ordering here. `_netdev` in Options is the
// correct + sufficient network ordering for the REAL mount — systemd classes a _netdev mount under
// remote-fs.target and orders it after the network without a hand-written After/Wants. A literal
// `After=network-online.target` on this unit (which the .automount pulls in via local-fs) closed the
// boot ordering cycle networking→local-fs→automount→network-online→networking; systemd broke it by
// DELETING an arbitrary job (one boot lost networking entirely, the next lost the automount).
b.WriteString("\n[Mount]\n")
fmt.Fprintf(&b, "What=%s\n", s.mountSource())
fmt.Fprintf(&b, "Where=%s\n", s.Where())
@@ -263,8 +269,12 @@ func renderNetworkAutomountUnit(s NetworkMountSpec) string {
b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n")
b.WriteString("[Unit]\n")
fmt.Fprintf(&b, "Description=Felhom network storage automount %s (%s)\n", sanitizeDesc(s.Name), s.Protocol)
b.WriteString("After=network-online.target\n")
b.WriteString("Wants=network-online.target\n")
// F12 (CAMPAIGN-3, CRITICAL): the automount unit gets NO network relation of ANY kind. A trigger
// needs no network — it just watches the mountpoint and fires the .mount on first access (the
// .mount's `_netdev` then orders the real mount after the network). An automount is implicitly
// ordered Before=local-fs.target; adding After/Wants=network-online.target here created the boot
// ordering cycle that cost the host its network on one boot and its NAS on the next. Keep this unit
// orderable before local-fs WITHOUT dragging the network into that transaction.
b.WriteString("\n[Automount]\n")
fmt.Fprintf(&b, "Where=%s\n", s.Where())
fmt.Fprintf(&b, "TimeoutIdleSec=%d\n", s.idleTimeout())
@@ -347,6 +357,10 @@ func (h *SudoHostOps) EnsureNetworkMount(ctx context.Context, spec NetworkMountS
if err := ValidateNetworkMountSpec(spec); err != nil {
return err
}
// Template-drift reconcile: bring any already-installed units up to the current template before we
// touch the unit dir (F12 — a pre-0.85 unit still carrying the network-online ordering gets rewritten
// here even if the daemon-startup migration hasn't run in this process). Best-effort; never blocks add.
h.MigrateNetworkUnits(ctx)
// Defense in depth: never realise a network mount outside the user-data namespace.
if NetworkMountRole(spec.Where()) != RoleUserData {
return fmt.Errorf("netmount: refusing to mount outside the user-data namespace: %s", spec.Where())
@@ -427,6 +441,10 @@ func (h *SudoHostOps) RemoveNetworkMount(ctx context.Context, name string) error
h.logger.Debug("netmount: remove step", "verb", step[0], "unit", step[len(step)-1],
"ok", err == nil) // a "not loaded" failure here is expected + tolerated
}
// F2 (CAMPAIGN-3): clear any failed/start-limit runtime state on the pair BEFORE the files go, or
// systemd keeps them as `not-found failed` residue after daemon-reload. reset-failed while the units
// are still loaded; tolerate the not-failed case (nothing to reset).
h.resetNetworkUnitsIfFailed(ctx, automountUnit, mountUnit)
destAuto := filepath.Join(h.unitDir, automountUnit)
destMount := filepath.Join(h.unitDir, mountUnit)
@@ -441,10 +459,184 @@ func (h *SudoHostOps) RemoveNetworkMount(ctx context.Context, name string) error
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
return fmt.Errorf("netmount: daemon-reload: %w", err)
}
// F1 (CAMPAIGN-3): remove the now-empty mountpoint directory (the campaign accumulated 10 stub-shaped
// leftovers). rmdir ONLY — a non-empty dir (unexpected data present) is left in place with a WARN, the
// fail-safe; `rm -rf` is forbidden here. The host removal propagates into running guests through the
// shared bind; a fresh guest re-binds cleanly on next start.
h.rmdirMountpoint(ctx, where)
h.logger.Info("netmount: removed network mount", "name", name, "where", where)
return nil
}
// resetNetworkUnitsIfFailed reset-failed's any of the given units that is in the failed state (F2 —
// leave no `not-found failed`/start-limit residue behind a remove or a rolled-back add). Unprivileged
// is-failed read + the FELHOM_NETMOUNT reset-failed grant; every step tolerated.
func (h *SudoHostOps) resetNetworkUnitsIfFailed(ctx context.Context, units ...string) {
for _, unit := range units {
if h.unitFailed == nil || !h.unitFailed(ctx, unit) {
continue
}
if err := h.run(ctx, h.bins.Systemctl, "reset-failed", "--", unit); err != nil {
h.logger.Warn("netmount: reset-failed tolerated failure", "unit", unit, "err", err)
}
}
}
// rmdirMountpoint removes an empty network mountpoint dir under NetworkMountRoot. rmdir refuses a
// non-empty dir (the fail-safe): unexpected data is preserved and flagged, never rm -rf'd. Best-effort.
func (h *SudoHostOps) rmdirMountpoint(ctx context.Context, where string) {
if !strings.HasPrefix(where, NetworkMountRoot+"/") {
return // defense in depth: only ever under the bind root
}
if err := h.run(ctx, "/usr/bin/rmdir", where); err != nil {
// rmdir fails on a non-empty dir — leave it (fail-safe) and flag it for the operator.
h.logger.Warn("netmount: mountpoint dir not removed (non-empty or busy — left in place, fail-safe)",
"where", where, "err", err)
}
}
// MigrateNetworkUnits reconciles every marker-owned network-storage unit file on disk against a fresh
// render of its own reconstructed spec — a general template-drift reconcile (the git-sync pattern:
// content-hash compare, rewrite on drift, batched daemon-reload). It exists because a template change
// must reach ALREADY-INSTALLED units, not only future adds: the F12 fix (CAMPAIGN-3) removed the
// network-online ordering that turned every host boot with an enrolled share into a coin flip, and the
// units installed before 0.85 still carry the ordering cycle until they are rewritten. Runs at agent
// startup (before the reassert sweep) and at the head of EnsureNetworkMount. Idempotent: a unit already
// byte-identical to its fresh render is left untouched (second pass rewrites nothing). Best-effort per
// unit; one INFO line per migrated unit. Returns the count migrated.
func (h *SudoHostOps) MigrateNetworkUnits(ctx context.Context) int {
entries, err := os.ReadDir(h.unitDir)
if err != nil {
h.logger.Warn("netmigrate: reading unit dir failed", "err", err)
return 0
}
migrated := 0
changed := false
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".mount") {
continue // the .mount carries What/Type; the paired .automount mirrors Where
}
mountPath := filepath.Join(h.unitDir, e.Name())
mountData, rerr := os.ReadFile(mountPath)
if rerr != nil || !strings.Contains(string(mountData), netUnitMarker) {
continue // unreadable or not one of ours
}
automountName := strings.TrimSuffix(e.Name(), ".mount") + ".automount"
automountData, aerr := os.ReadFile(filepath.Join(h.unitDir, automountName))
if aerr != nil {
continue // a .mount with no paired .automount is malformed — not ours to guess
}
spec, ok := specFromNetworkUnits(string(mountData), string(automountData))
if !ok {
continue
}
freshMount := renderNetworkMountUnit(spec)
freshAuto := renderNetworkAutomountUnit(spec)
if contentHash(string(mountData)) == contentHash(freshMount) &&
contentHash(string(automountData)) == contentHash(freshAuto) {
continue // already current — the idempotent no-op
}
if err := h.installUnit(ctx, e.Name(), freshMount); err != nil {
h.logger.Warn("netmigrate: rewriting mount unit failed", "unit", e.Name(), "err", err)
continue
}
if err := h.installUnit(ctx, automountName, freshAuto); err != nil {
h.logger.Warn("netmigrate: rewriting automount unit failed", "unit", automountName, "err", err)
continue
}
changed = true
migrated++
h.logger.Info("netmigrate: migrated network-storage unit to the current template (F12: dropped the boot ordering cycle)",
"name", spec.Name, "where", spec.Where())
}
if changed {
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
h.logger.Warn("netmigrate: daemon-reload after migration failed", "err", err)
}
}
return migrated
}
// contentHash is the SHA-256 hex of a unit file's content — the drift comparator (git-sync pattern).
func contentHash(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// specFromNetworkUnits reconstructs the NetworkMountSpec that renders EXACTLY the given installed unit
// pair — the fresh-render input for the drift reconcile. Round-trips by construction: every field the
// render templates read is recovered (proto/server/export/where from the .mount; the SMB uid/gid+creds
// from its Options; the idle window from the .automount). A NFS spec's mapped uid never appears in a
// rendered unit, so it is irrelevant to the render and left at the container default. ok=false for a
// non-marker or unparseable pair.
func specFromNetworkUnits(mountContent, automountContent string) (NetworkMountSpec, bool) {
proto, server, export, where, ok := parseNetworkMountUnit(mountContent)
if !ok {
return NetworkMountSpec{}, false
}
spec := NetworkMountSpec{
Name: strings.TrimPrefix(where, NetworkMountRoot+"/"),
Protocol: NetworkProtocol(proto),
Server: server,
Export: export,
MappedUID: 1000, // container default; unused by the NFS render, overwritten below for SMB
MappedGID: 1000,
}
if spec.Protocol == ProtocolSMB {
opts := unitLineValue(mountContent, "Options=")
if uid, ok := csvIntField(opts, "uid="); ok {
spec.MappedUID = uid - lxcUIDOffset
}
if gid, ok := csvIntField(opts, "gid="); ok {
spec.MappedGID = gid - lxcUIDOffset
}
spec.CredsRef = csvField(opts, "credentials=")
}
if idle, ok := csvIntField(unitLineValue(automountContent, "TimeoutIdleSec="), ""); ok && idle > 0 {
spec.IdleTimeoutSec = idle
}
return spec, true
}
// unitLineValue returns the value after the first line beginning with prefix (e.g. "Options="), trimmed.
func unitLineValue(content, prefix string) string {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimPrefix(line, prefix)
}
}
return ""
}
// csvField finds the comma-separated token with the given key prefix (e.g. "credentials=") and returns
// its value. "" if absent.
func csvField(csv, key string) string {
for _, tok := range strings.Split(csv, ",") {
if strings.HasPrefix(tok, key) {
return strings.TrimPrefix(tok, key)
}
}
return ""
}
// csvIntField parses the int value of a comma-separated key (e.g. "uid=") — or, when key is "", parses
// the whole string as an int (for a bare value like TimeoutIdleSec's already-extracted number).
func csvIntField(csv, key string) (int, bool) {
val := csv
if key != "" {
val = csvField(csv, key)
}
if val == "" {
return 0, false
}
n, err := strconv.Atoi(strings.TrimSpace(val))
if err != nil {
return 0, false
}
return n, true
}
// ListNetworkMounts enumerates the installed network-storage units and reports per-share liveness. It
// reads the (world-readable) unit dir + /proc/mounts and TCP-probes each NAS endpoint with a short
// timeout — it NEVER stat()s the (possibly EIO/D-state) mountpoint, so a black-holed NAS cannot wedge