R-25 (controller half): drive init mounts the filesystem UUID the agent verified

agentapi FormatResult / FormatStatusResult gain fs_uuid (agent R-25 half). runStorageInit mounts
it — from the synchronous answer or the polled status — instead of re-resolving the UUID from the
/dev path, so a node that moved between format and mount cannot redirect the mount. An agent that
sends no field keeps the old resolve, logged as a WARN each time (the window is open then).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:52:26 +02:00
parent 0b349e2415
commit 1355391c78
3 changed files with 77 additions and 2 deletions
+6
View File
@@ -395,6 +395,10 @@ type FormatResult struct {
DurableID string `json:"durable_id,omitempty"`
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
PendingOp *PendingOp `json:"pending_op,omitempty"`
// FSUUID (R-25, agent >= the R-25 agent half) is the UUID of the filesystem the agent just made,
// sent only while the format's durable id still names the formatted device. "" = not verified, or
// an agent too old to send it. The init flow mounts THIS rather than re-resolving from the /dev path.
FSUUID string `json:"fs_uuid,omitempty"`
}
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
@@ -808,6 +812,8 @@ type FormatStatusResult struct {
Device string `json:"device"`
FSType string `json:"fstype"`
Error string `json:"error"`
// FSUUID (R-25): see FormatResult.FSUUID — "" until the job is done and verified.
FSUUID string `json:"fs_uuid,omitempty"`
}
// FormatStatus fetches the agent's most-recent format-job record.
@@ -0,0 +1,58 @@
package web
import (
"context"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-25: the init flow mounts the filesystem UUID the AGENT verified it made, not one re-resolved from
// the /dev path. The fixture makes the two DISAGREE — the device path now resolves to another
// filesystem (the node moved) — so only the agent's value is right.
func TestR25_InitMountsTheAgentsFSUUID(t *testing.T) {
moved := agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "other", BackingDevice: "/dev/sdb1", DurableID: "uuid:SOMEONE-ELSES"},
}}
t.Run("synchronous format answer", func(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, FSUUID: "AGENT-VERIFIED"},
disks: moved,
}
if _, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", false, false, "", nil); err != nil {
t.Fatal(err)
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "AGENT-VERIFIED" {
t.Fatalf("R-25: mounted %+v, want the agent-verified UUID", agent.assignCalls)
}
})
t.Run("detached format, polled status", func(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: false},
formatErr: context.DeadlineExceeded,
formatStatus: agentapi.FormatStatusResult{Phase: "done", Device: "/dev/sdb1", FSUUID: "AGENT-VERIFIED"},
disks: moved,
}
if _, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", false, false, "", nil); err != nil {
t.Fatal(err)
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "AGENT-VERIFIED" {
t.Fatalf("R-25: the polled path mounted %+v, want the agent-verified UUID", agent.assignCalls)
}
})
t.Run("older agent sends no field: path fallback", func(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true},
disks: moved,
}
if _, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", false, false, "", nil); err != nil {
t.Fatal(err)
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "SOMEONE-ELSES" {
t.Fatalf("an agent with no fs_uuid must keep the old path resolve, mounted %+v", agent.assignCalls)
}
})
}
+13 -2
View File
@@ -150,7 +150,18 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
// 2. Resolve the NEW fs UUID. A freshly-formatted RAW device isn't in /disks (not enrolled yet), so
// resolve via the raw-device scan too (Impl-2b) — the device now appears there with its new durable_id.
setP(storageInitPhaseMounting)
uuid := resolveEnrollUUID(ctx, agent, device)
// R-25: mount the filesystem the AGENT says it made — it sends fs_uuid only while the format's
// durable id still names the formatted device, so a /dev node that moved between the format and
// the mount cannot redirect the mount. The path-based resolve stays as the fallback for an agent
// that sends no field (older than the R-25 agent half); that fallback re-opens the narrow window,
// so it is logged loudly every time it is taken. Pinned by TestR25_InitMountsTheAgentsFSUUID.
uuid := fr.FSUUID
if uuid == "" {
uuid = resolveEnrollUUID(ctx, agent, device)
s.logger.Printf("[WARN] [web] enroll %s: the agent sent no fs_uuid with the format (an agent older than the R-25 half, or it could not verify the device still matches) — resolved the UUID from the device path instead (%q); the /dev re-enumeration window is OPEN for this enroll", device, uuid)
} else {
s.logger.Printf("[INFO] [web] enroll %s: mounting the agent-verified new filesystem %s", device, uuid)
}
if uuid == "" {
return storageInitResult{}, util.MsgError("err.web.formazas_kesz_de_az_uj_fajlrendszer")
}
@@ -238,7 +249,7 @@ func (s *Server) awaitAgentFormat(ctx context.Context, agent diskAgent, device s
consecutiveErrs = 0
switch st.Phase {
case "done": // the agent's format-job terminal phases (internal/localapi/formatjob.go)
return agentapi.FormatResult{Device: device, Formatted: true}, nil
return agentapi.FormatResult{Device: device, Formatted: true, FSUUID: st.FSUUID}, nil
case "failed":
return agentapi.FormatResult{}, util.MsgError("err.web.formazas_sikertelen_2", st.Error)
case "idle":