controller v0.48.0: slice 10 P2C — enroll passes the drive into the guest

agentapi GuestAttach(where) → POST /disks/guest-attach; runStorageInit/Attach +
handleStorageRegister call attachIntoGuest after register (best-effort, P3 heals).
Closes Branch A: enrolled drives become usable in the guest, banner clears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:42:52 +02:00
parent 04bacbddfd
commit ee5b6304a7
4 changed files with 53 additions and 0 deletions
+17
View File
@@ -1,5 +1,22 @@
## Changelog ## 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/<name>`), 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) ### 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 The backups page previously showed only the app-data (DB-dump) tier and had **zero** view of the
+8
View File
@@ -323,6 +323,14 @@ func (c *Client) AssignDisk(ctx context.Context, uuid, where, fstype, options st
return err 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). // EjectResult mirrors POST /disks/eject (the dependent-guest warning).
type EjectResult struct { type EjectResult struct {
VMID int `json:"vmid"` VMID int `json:"vmid"`
@@ -28,6 +28,7 @@ type diskAgent interface {
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error) EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
GuestAttach(ctx context.Context, where string) error
} }
// mountNameRe is the safe `/mnt/<name>` component (DNS-ish: letters, digits, _ , -). // mountNameRe is the safe `/mnt/<name>` 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 { if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err return storageInitResult{}, err
} }
s.attachIntoGuest(ctx, agent, where)
return storageInitResult{Registered: true, Where: where}, nil 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) // runStorageAttach mounts an existing-filesystem device (non-destructive — never touches the gate)
// and registers it. The UUID is resolved server-side from the device. // 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) { 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 { if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err return storageInitResult{}, err
} }
s.attachIntoGuest(ctx, agent, where)
return storageInitResult{Registered: true, Where: where}, nil return storageInitResult{Registered: true, Where: where}, nil
} }
@@ -353,6 +367,11 @@ func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s", req.Where) 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}) writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": req.Where})
} }
@@ -32,6 +32,7 @@ type mockAgent struct {
assignCalls []assignCall assignCalls []assignCall
disksCalls int disksCalls int
formatCalls []formatCall formatCalls []formatCall
guestAttachCalls []string
} }
type assignCall struct{ uuid, where, fstype 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) { func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
return agentapi.EjectResult{Ejected: where}, nil 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 { func testServer(t *testing.T) *Server {
t.Helper() 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 { 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) 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). // Attach is non-destructive: resolve UUID → assign → register (no format).