controller v0.49.0: slice 10 P2 activation — pending-drive detection + restart button

pendingActivationDrives() flags registered drives the agent shows attached but not
live-mounted in the container; settings banner + "Újraindítás most" button →
/api/storage/activate → agentapi.GuestReboot. Batches all pending into one restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:19:27 +02:00
parent ee5b6304a7
commit 2a353572f7
5 changed files with 103 additions and 0 deletions
+13
View File
@@ -1,5 +1,18 @@
## Changelog ## 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) ### 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 Pairs with felhom-agent v0.25.0 (`POST /disks/guest-attach`) + the golden's `/mnt:rslave` controller
+9
View File
@@ -331,6 +331,15 @@ func (c *Client) GuestAttach(ctx context.Context, where string) error {
return err 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). // EjectResult mirrors POST /disks/eject (the dependent-guest warning).
type EjectResult struct { type EjectResult struct {
VMID int `json:"vmid"` VMID int `json:"vmid"`
+3
View File
@@ -803,6 +803,9 @@ func (s *Server) settingsData() map[string]interface{} {
storageViews = append(storageViews, view) storageViews = append(storageViews, view)
} }
data["StoragePaths"] = storageViews 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 // Recovery info for emergency section
data["RetrievalPassword"] = s.settings.GetRetrievalPassword() data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
@@ -13,6 +13,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "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 // 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 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 // registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
// default) and refreshes the FileBrowser mounts so it's usable immediately. // default) and refreshes the FileBrowser mounts so it's usable immediately.
func (s *Server) registerStoragePath(where, label string, setDefault bool) error { 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) s.handleStorageImpact(w, r)
case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost: case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
s.handleStorageRegister(w, r) s.handleStorageRegister(w, r)
case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost:
s.handleStorageActivate(w, r)
default: default:
http.NotFound(w, r) 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}) 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 // 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 // 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 // drive (e.g. felhom-usb): the customer's intent is to USE the existing data, not wipe it. It reuses
@@ -352,6 +352,15 @@ function pollUntilBack() {
<a href="/settings/storage/attach" class="btn btn-sm btn-outline">🔗 Meglévő meghajtó csatolása</a> <a href="/settings/storage/attach" class="btn btn-sm btn-outline">🔗 Meglévő meghajtó csatolása</a>
</div> </div>
{{if .PendingDrives}}
<div class="alert alert-warning" style="margin-top:1rem">
<strong>{{len .PendingDrives}} meghajtó aktiválásra vár.</strong>
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.
<div style="margin-top:.6rem"><button class="btn btn-sm btn-primary" id="activate-drives-btn" onclick="activatePendingDrives()">Újraindítás most (~30 mp)</button></div>
<div id="activate-result" style="margin-top:.5rem"></div>
</div>
{{end}}
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4> <h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4>
<p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete. A meghajtó <strong>szerepkörét</strong> 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.</p> <p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete. A meghajtó <strong>szerepkörét</strong> 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.</p>
@@ -481,6 +490,16 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
location.reload(); location.reload();
}catch(e){ alert('Hiba: '+e.message); } }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='<span class="form-hint">Újraindítás folyamatban… az oldal automatikusan újratöltődik.</span>';
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){ window.confirmEject=function(where){
var name=where.replace(/^\/mnt\//,''); var name=where.replace(/^\/mnt\//,'');
openConfirm({title:'Meghajtó leválasztása', mount:where, mountName:name, openConfirm({title:'Meghajtó leválasztása', mount:where, mountName:name,