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"`
|
DurableID string `json:"durable_id,omitempty"`
|
||||||
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
|
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
|
||||||
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
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
|
// 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"`
|
Device string `json:"device"`
|
||||||
FSType string `json:"fstype"`
|
FSType string `json:"fstype"`
|
||||||
Error string `json:"error"`
|
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.
|
// 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
|
// 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.
|
// resolve via the raw-device scan too (Impl-2b) — the device now appears there with its new durable_id.
|
||||||
setP(storageInitPhaseMounting)
|
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 == "" {
|
if uuid == "" {
|
||||||
return storageInitResult{}, util.MsgError("err.web.formazas_kesz_de_az_uj_fajlrendszer")
|
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
|
consecutiveErrs = 0
|
||||||
switch st.Phase {
|
switch st.Phase {
|
||||||
case "done": // the agent's format-job terminal phases (internal/localapi/formatjob.go)
|
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":
|
case "failed":
|
||||||
return agentapi.FormatResult{}, util.MsgError("err.web.formazas_sikertelen_2", st.Error)
|
return agentapi.FormatResult{}, util.MsgError("err.web.formazas_sikertelen_2", st.Error)
|
||||||
case "idle":
|
case "idle":
|
||||||
|
|||||||
Reference in New Issue
Block a user