diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf9d9d..7c1f5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## Changelog +### v0.49.0 — slice 10 P2 activation: pending-drive detection + "Újraindítás most" (2026-06-12) + +A drive enrolled into a running guest activates only at the next guest boot (the host-side live inject +is blocked on unprivileged LXC — see felhom-agent v0.26.0). Per the decision: enroll persists (no forced +reboot), and the customer activates pending drives with one batched restart. + +- **Pending detection** (`pendingActivationDrives`): a registered StoragePath whose backing drive the + agent reports present+attached but which is NOT a live mount in this container → "pending activation". +- **Settings UI:** a banner ("N meghajtó aktiválásra vár") with an **"Újraindítás most (~30 mp)"** button + (one restart batches all pending drives). `POST /api/storage/activate` → `agentapi.GuestReboot` → + agent `POST /guest/reboot`. The reboot takes the controller down too, so the JS reloads after the + restart window rather than awaiting the (cut-short) response. + ### 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 diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index a02571f..c696518 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -331,6 +331,15 @@ func (c *Client) GuestAttach(ctx context.Context, where string) error { return err } +// GuestReboot reboots THIS guest to activate persisted-but-inactive drive binds (slice 10 P2 +// activation — the host-side live inject is blocked on an unprivileged guest, so a drive enrolled into +// a running guest activates only at the next boot). The agent runs the reboot detached + returns 202; +// this guest (and the controller) restarts shortly after. User-triggered ("Újraindítás most"). +func (c *Client) GuestReboot(ctx context.Context) error { + _, err := c.post(ctx, "/guest/reboot", struct{}{}) + return err +} + // EjectResult mirrors POST /disks/eject (the dependent-guest warning). type EjectResult struct { VMID int `json:"vmid"` diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 9faee58..89a4ac6 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -803,6 +803,9 @@ func (s *Server) settingsData() map[string]interface{} { storageViews = append(storageViews, view) } data["StoragePaths"] = storageViews + // Drives enrolled but not yet activated in the guest (slice 10 P2): they need the user-triggered + // "Újraindítás most" to take effect (the host-side live inject is blocked on an unprivileged guest). + data["PendingDrives"] = s.pendingActivationDrives() // Recovery info for emergency section data["RetrievalPassword"] = s.settings.GetRetrievalPassword() diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 7fe8292..9c708ba 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -13,6 +13,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin @@ -153,6 +154,44 @@ func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, return storageInitResult{Registered: true, Where: where}, nil } +// pendingActivationDrives returns registered storage paths that are NOT yet live-mounted in this +// container but whose backing drive the agent reports present+attached — enrolled drives waiting for +// the guest restart that activates their bind (slice 10 P2; the host-side live inject is blocked on an +// unprivileged guest). The customer activates them with the "Újraindítás most" button (one restart +// batches all). Best-effort: agent unreachable → none. +func (s *Server) pendingActivationDrives() []string { + paths := s.settings.GetStoragePaths() + if len(paths) == 0 { + return nil + } + agent, err := s.agentClient() + if err != nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + resp, err := agent.Disks(ctx) + if err != nil { + return nil + } + attached := map[string]bool{} + for _, d := range resp.Disks { + if d.MountPath != "" && d.State == "attached" { + attached[d.MountPath] = true + } + } + var pending []string + for _, sp := range paths { + if sp.Decommissioned { + continue + } + if attached[sp.Path] && !system.IsMountPoint(sp.Path) { + pending = append(pending, sp.Path) + } + } + return pending +} + // registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by // default) and refreshes the FileBrowser mounts so it's usable immediately. func (s *Server) registerStoragePath(where, label string, setDefault bool) error { @@ -201,6 +240,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { s.handleStorageImpact(w, r) case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost: s.handleStorageRegister(w, r) + case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost: + s.handleStorageActivate(w, r) default: http.NotFound(w, r) } @@ -341,6 +382,24 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID}) } +// handleStorageActivate reboots the guest to activate pending drive binds (slice 10 P2). The agent +// reboots detached + returns 202; this controller restarts with the guest, so the caller's response +// may be cut short — the UI handles that and reloads after the restart window. +func (s *Server) handleStorageActivate(w http.ResponseWriter, r *http.Request) { + agent, err := s.agentClient() + if err != nil { + writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + return + } + if err := agent.GuestReboot(r.Context()); err != nil { + s.logger.Printf("[ERROR] [web] guest reboot (activate pending drives) failed: %v", err) + writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + return + } + s.logger.Printf("[WARN] [web] guest restart requested to activate pending drive binds") + writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"rebooting": true}) +} + // handleStorageRegister records an ALREADY-mounted, unregistered user-data drive into the StoragePath // registry — no format, no eject. It is the natural primary action for a mounted-but-unregistered data // drive (e.g. felhom-usb): the customer's intent is to USE the existing data, not wipe it. It reuses diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 98e935b..2086cd0 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -352,6 +352,15 @@ function pollUntilBack() { 🔗 Meglévő meghajtó csatolása + {{if .PendingDrives}} +
+ {{len .PendingDrives}} meghajtó aktiválásra vár. + Az újonnan csatolt adatmeghajtók a vendég rövid (~30 mp) újraindítása után válnak elérhetővé az alkalmazások számára. +
+
+
+ {{end}} +

Meghajtók (ügynök nézet)

A host-ügynök által észlelt meghajtók élő nézete. A meghajtó szerepkörét az ügynök saját vizsgálattal állapítja meg: a rendszer- és biztonsági-mentés meghajtók védettek (csak operátori aláírással módosíthatók), a felhasználói adatmeghajtókat Ön kezeli.

@@ -481,6 +490,16 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} location.reload(); }catch(e){ alert('Hiba: '+e.message); } }; + // Activate pending drive binds by rebooting the guest (~30s). The reboot takes the controller down + // too, so the fetch may not resolve — we reload after the restart window regardless. + window.activatePendingDrives=function(){ + if(!confirm('A vendég újraindul (~30 másodperc). Eközben az alkalmazások és a vezérlőpult rövid időre nem elérhetők. Folytatja?')) return; + var btn=document.getElementById('activate-drives-btn'); var out=document.getElementById('activate-result'); + if(btn) btn.disabled=true; + if(out) out.innerHTML='Újraindítás folyamatban… az oldal automatikusan újratöltődik.'; + fetch('/api/storage/activate',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders())}).catch(function(){}); + setTimeout(function(){location.reload();}, 45000); + }; window.confirmEject=function(where){ var name=where.replace(/^\/mnt\//,''); openConfirm({title:'Meghajtó leválasztása', mount:where, mountName:name,