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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user