diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 2c79dd6..75a6f8a 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -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. diff --git a/controller/internal/web/r25_fsuuid_mount_test.go b/controller/internal/web/r25_fsuuid_mount_test.go new file mode 100644 index 0000000..81dcc50 --- /dev/null +++ b/controller/internal/web/r25_fsuuid_mount_test.go @@ -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) + } + }) +} diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 02c08e3..e3debbb 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -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":