F20-BUG3: run mkfs detached (survives request deadline + agent restart); v0.31.0

The format ran mkfs under the HTTP request context, so the controller's 15s client
timeout cancelled it → SIGKILL mid-write → corrupt disk. Now mkfs runs DETACHED off
s.baseCtx (a dropped request can't kill it) via a persisted formatJob record; the handler
still waits to return the synchronous result (backward-compatible with the v0.62.0
controller) but abandoning the wait on client-disconnect leaves the mkfs running to
completion. New GET /disks/format/status surfaces the job (additive). RecoverFormatJob
runs on agent startup: a record left 'running' (agent died mid-format) is re-resolved by
durable-id (anti-retarget — absent/swapped disk NOT re-formatted) and the mkfs re-run; a
blank/path-bound interrupted format is marked failed (retry), never auto-re-run.

Tests: detached run persists running→done + binds durable-id; status endpoint; recovery
re-runs an interrupted durable-id-bound format; skips blank; skips unresolvable durable-id.
Version 0.30.0 → 0.31.0.
This commit is contained in:
2026-06-14 15:16:01 +02:00
parent 4cd1d024e9
commit 4777f8a221
5 changed files with 417 additions and 6 deletions
+13 -3
View File
@@ -43,7 +43,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.30.0"
var version = "0.31.0"
func main() {
var (
@@ -347,6 +347,13 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
logger.Warn("storage: guest-bind store unavailable — startup bind re-assert disabled (F9)", "err", gbErr)
guestBindStore = nil
}
// F20-BUG3: persisted disk-format job — lets mkfs run detached from the request and survive an agent
// restart (RecoverFormatJob re-runs an interrupted durable-id-bound format).
formatJobStore, fjErr := localapi.OpenFormatJobStore(filepath.Join(intentStateDir, "format-job.json"))
if fjErr != nil {
logger.Warn("storage: format-job store unavailable — format restart-recovery disabled (F20-BUG3)", "err", fjErr)
formatJobStore = nil
}
watchdog := storage.NewWatchdog(storage.WatchdogOptions{
Targets: storage.NewCachingKnownTargets(observer, cfg.Storage.KnownRefresh()),
Liveness: storage.NewHostLiveness(hostReader, 0),
@@ -431,7 +438,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec}, cfg.Hub.HostID, logger)
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, intentRec, guestBindStore, logger, &localTokens)
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, hostOps, gate, collector, intentRec, guestBindStore, formatJobStore, logger, &localTokens)
if localTokens != nil {
defer localTokens.Close()
}
@@ -473,6 +480,8 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
// bind that a re-provision dropped — before serving, so the drive is back in the guest config
// (activates on the guest's next reboot). On-durable-id-match; absent/swapped drives are skipped.
localSrv.ReassertGuestBinds(ctx)
// F20-BUG3: complete a disk format that an agent restart interrupted (durable-id-bound, re-resolved).
localSrv.RecoverFormatJob(ctx)
go func() { errc <- localSrv.Run(ctx) }()
}
if lanLoop != nil {
@@ -587,7 +596,7 @@ func buildRestoreTestScheduler(cfg config.Config, px *proxmox.Client, engine *re
// leaf (stable fingerprint). Any failure DISABLES the server (returns nil) WITHOUT crashing the
// daemon — the host still reports/reconciles; only the controller channel is unavailable until
// fixed. The opened token store is returned via outTokens so the caller can Close it.
func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.Store, observer *storage.Observer, hostOps storage.HostOps, gate *reconcile.Gate, collector *hub.Collector, intent localapi.IntentRecorder, guestBinds *localapi.GuestBindStore, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server {
func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.Store, observer *storage.Observer, hostOps storage.HostOps, gate *reconcile.Gate, collector *hub.Collector, intent localapi.IntentRecorder, guestBinds *localapi.GuestBindStore, formatJobs *localapi.FormatJobStore, logger *slog.Logger, outTokens **localapi.TokenStore) *localapi.Server {
if !cfg.LocalAPI.Enabled() {
return nil
}
@@ -632,6 +641,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal
GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert
FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).