diff --git a/CHANGELOG.md b/CHANGELOG.md index 4544da8..5cf9d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ ## Changelog +### v0.48.0 — slice 10 P2C: enroll passes the drive into the guest (passthrough) (2026-06-12) + +Pairs with felhom-agent v0.25.0 (`POST /disks/guest-attach`) + the golden's `/mnt:rslave` controller +bind. Closes the diagnosed Branch-A gap: enrolling an external drive now makes it actually usable in +the guest, not just mounted on the host. + +- **agentapi:** new `GuestAttach(where)` → `POST /disks/guest-attach` (idempotent on the agent side). +- **Enroll triggers attach:** `runStorageInit`, `runStorageAttach`, and `handleStorageRegister` call + `attachIntoGuest` after recording the StoragePath. Best-effort (logged, non-fatal) — the registration + is the durable intent; a transient attach failure is healed by P3 self-heal (next slice). Test: + `TestRunStorageInit_Success` now asserts the drive is guest-attached. +- Note: app data on these drives is written via `HDD_PATH` (the registered `/mnt/`), which Model A + binds to the drive's `felhom-data` namespace — so app bytes land on the external drive, and the + controller's storage probe (os.Stat + IsMountPoint) sees a real mount → the "nem elérhető" banner + clears. (The controller's own backup-path helpers' `felhom-data` level is reconciled when app-data + backup-to-drive is wired; not P2.) + ### v0.47.0 — backups page: whole-guest backup visibility + manual trigger (agent-sourced) (2026-06-12) The backups page previously showed only the app-data (DB-dump) tier and had **zero** view of the diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index fc0e0d5..a02571f 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -323,6 +323,14 @@ func (c *Client) AssignDisk(ctx context.Context, uuid, where, fstype, options st return err } +// GuestAttach binds an enrolled drive's felhom-data namespace into THIS guest (slice 10 P2, Model A). +// The drive must already be host-mounted at `where` (the enroll flow's assign did that). Idempotent on +// the agent side (returns the existing slot if already bound). Returns nil on success. +func (c *Client) GuestAttach(ctx context.Context, where string) error { + _, err := c.post(ctx, "/disks/guest-attach", map[string]string{"where": where}) + return err +} + // EjectResult mirrors POST /disks/eject (the dependent-guest warning). type EjectResult struct { VMID int `json:"vmid"` diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index e2f1a7c..7fe8292 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -28,6 +28,7 @@ type diskAgent interface { FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error) AssignDisk(ctx context.Context, uuid, where, fstype, options string) error EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error) + GuestAttach(ctx context.Context, where string) error } // mountNameRe is the safe `/mnt/` component (DNS-ish: letters, digits, _ , -). @@ -116,9 +117,21 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs if err := s.registerStoragePath(where, label, setDefault); err != nil { return storageInitResult{}, err } + s.attachIntoGuest(ctx, agent, where) return storageInitResult{Registered: true, Where: where}, nil } +// attachIntoGuest passes an enrolled drive INTO the guest (slice 10 P2) so the controller + apps can +// use it. Best-effort: the StoragePath registration is the durable intent, so a transient attach +// failure is logged (not fatal) — P3 self-heal reconcile will complete it on the next tick. +func (s *Server) attachIntoGuest(ctx context.Context, agent diskAgent, where string) { + if err := agent.GuestAttach(ctx, where); err != nil { + s.logger.Printf("[WARN] [web] enroll: guest-attach %s failed (registered; will be retried): %v", where, err) + return + } + s.logger.Printf("[INFO] [web] enroll: drive bound into guest: %s", where) +} + // runStorageAttach mounts an existing-filesystem device (non-destructive — never touches the gate) // and registers it. The UUID is resolved server-side from the device. func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) { @@ -136,6 +149,7 @@ func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, if err := s.registerStoragePath(where, label, setDefault); err != nil { return storageInitResult{}, err } + s.attachIntoGuest(ctx, agent, where) return storageInitResult{Registered: true, Where: where}, nil } @@ -353,6 +367,11 @@ func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) { return } s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s", req.Where) + // Pass the drive into the guest too (slice 10 P2) — registering a host-only mount otherwise leaves + // it guest-invisible (the exact gap that produced the "nem elérhető" banner). + if agent, aerr := s.agentClient(); aerr == nil { + s.attachIntoGuest(r.Context(), agent, req.Where) + } writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": req.Where}) } diff --git a/controller/internal/web/storage_handlers_test.go b/controller/internal/web/storage_handlers_test.go index 3ab0694..51f02cd 100644 --- a/controller/internal/web/storage_handlers_test.go +++ b/controller/internal/web/storage_handlers_test.go @@ -32,6 +32,7 @@ type mockAgent struct { assignCalls []assignCall disksCalls int formatCalls []formatCall + guestAttachCalls []string } type assignCall struct{ uuid, where, fstype string } @@ -55,6 +56,10 @@ func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) { return agentapi.EjectResult{Ejected: where}, nil } +func (m *mockAgent) GuestAttach(_ context.Context, where string) error { + m.guestAttachCalls = append(m.guestAttachCalls, where) + return nil +} func testServer(t *testing.T) *Server { t.Helper() @@ -166,6 +171,10 @@ func TestRunStorageInit_Success(t *testing.T) { if len(paths) != 1 || paths[0].Path != "/mnt/hdd1" || paths[0].Label != "Külső HDD" || !paths[0].IsDefault || !paths[0].Schedulable { t.Fatalf("StoragePath not registered as expected: %+v", paths) } + // P2C: enroll must pass the drive into the guest. + if len(agent.guestAttachCalls) != 1 || agent.guestAttachCalls[0] != "/mnt/hdd1" { + t.Fatalf("enroll did not guest-attach the drive: %+v", agent.guestAttachCalls) + } } // Attach is non-destructive: resolve UUID → assign → register (no format).