v0.95.0: enrollment wizards use the raw-device scan /disks/candidates (Impl-2b)

Both wizards now source candidates from the agent's Impl-2a raw-device scan
(GET /disks/candidates, proxied) instead of the Observe-based /api/disks — so a
brand-new non-PVE-storage drive is finally discoverable + enrollable end-to-end.
agentapi.ListCandidates + a passthrough proxy (no controller-side filtering; the
agent's unclaimed filter is authoritative). storage_init renders `initialize`,
storage_attach renders `attach`; the enroll flow + Impl-1 guarded mkfs unchanged.
Tests + go build/vet/test clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:04:49 +02:00
parent 6ce61e862a
commit feab92ccfc
7 changed files with 174 additions and 33 deletions
+25
View File
@@ -1,5 +1,30 @@
## Changelog
### v0.95.0 — enrollment wizards use the raw-device scan `/disks/candidates` (Impl-2b) (2026-07-01)
Final drive-enrollment piece: both enrollment wizards now source candidates from the agent's Impl-2a
raw-device scan instead of the `Observe()`-based `/api/disks` list — so a brand-new (non-PVE-storage)
drive is finally visible + enrollable end-to-end. The enroll flow (`runStorageInit`/`runStorageAttach`)
and the Impl-1 guarded `mkfs` are UNCHANGED; the wizards just get the right candidate list.
- **`internal/agentapi/client.go`:** `ListCandidates(ctx) (CandidatesResult, error)` → agent
`GET /disks/candidates`; types `CandidatesResult{Initialize,Attach []DiskCandidate}` +
`DiskCandidate{Device,SizeBytes,Model,FSType,DataBearing,Mountable,MountSource,DurableID}` mirroring
the agent's `candidates.go`.
- **`internal/web/agent_disk_handlers.go`:** `GET /api/disks/candidates` proxy
(`agentDiskCandidatesHandler`, copy of `agentDisksListHandler`) — passthrough, NO controller-side
filtering (the agent's unclaimed-disk filter is authoritative + fail-safe).
- **`templates/storage_init.html`:** fetch `/api/disks/candidates` → render the `initialize` list
(model/size/current-FS + a data-bearing marker); dropped the client-side "already-managed" filter
(the server list already excludes OS/enrolled/claimed disks). Data-bearing → the existing wipe-confirm.
- **`templates/storage_attach.html`:** fetch `/api/disks/candidates` → render the `attach` list
(mountable-FS disks); selecting posts the FS-bearing node + its fstype to the existing
`/api/storage/attach` (mount + bind, NO format).
- **TOCTOU:** the wizard trusts the agent's Impl-1 `Format` guard as the backstop (re-checks unclaimed at
format time), not the list's freshness — a device claimed between scan and enroll is refused.
- Tests: `agentapi` `TestListCandidates` + `_Error`. `go build/vet/test ./...` clean. Live end-to-end
raw enrollment of `/dev/sdd` validated through the real UI (see REPORT).
### v0.94.0 — pull-based config-refresh (re-pull controller.yaml + self-restart on a config change) (2026-06-30)
Config delivery is now pull-based, riding the report ACK exactly like the Phase 2 version floor — the hub
+11
View File
@@ -852,6 +852,17 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
The storage subsystem handles the full lifecycle of external storage: detection, initialization, path registration, and data migration.
> **CURRENT (post-de-privileging + Impl-2b, v0.95.0):** the in-guest storage code below
> (`internal/storage/scan.go`, `format.go`, `attach.go`) is **retired** — all disk ops are delegated to
> the host agent via `internal/agentapi`. The two enrollment wizards (`/settings/storage/init`,
> `/settings/storage/attach`) now populate candidates from the agent's **raw-device scan**
> (`GET /api/disks/candidates` → agent Impl-2a, proxied by `agentDiskCandidatesHandler`): `initialize` =
> every unclaimed disk (blank or data-bearing), `attach` = the mountable-FS subset. The agent's
> unclaimed-disk filter (Impl-1 `claim.go`) is authoritative + fail-safe (never offers OS/enrolled/claimed
> disks), so the controller does NO client- or server-side filtering. Enrollment posts to the unchanged
> `/api/storage/init` (format via the agent's Impl-1 guarded `mkfs` → mount → bind → intent) or
> `/api/storage/attach` (mount + bind, no format). The legacy text below is kept for historical context.
#### Disk Scanning (`internal/storage/scan.go`)
- `ScanDisks()` uses `lsblk -J -b` for block device enumeration
+34
View File
@@ -347,6 +347,40 @@ func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
return out, nil
}
// DiskCandidate mirrors one entry from the agent's GET /disks/candidates (Impl-2a candidates.go) —
// a host disk the agent's unclaimed-disk filter proved is FREE for Felhom to enroll.
type DiskCandidate struct {
Device string `json:"device"`
SizeBytes int64 `json:"size_bytes"`
Model string `json:"model,omitempty"`
FSType string `json:"fstype,omitempty"`
DataBearing bool `json:"data_bearing"`
Mountable bool `json:"mountable"`
MountSource string `json:"mount_source,omitempty"`
DurableID string `json:"durable_id,omitempty"`
}
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
// unclaimed) and attach (the mountable-FS subset).
type CandidatesResult struct {
VMID int `json:"vmid"`
Initialize []DiskCandidate `json:"initialize"`
Attach []DiskCandidate `json:"attach"`
}
// ListCandidates fetches the host disks free for Felhom to enroll (Impl-2b wizard source).
func (c *Client) ListCandidates(ctx context.Context) (CandidatesResult, error) {
var out CandidatesResult
body, err := c.get(ctx, "/disks/candidates")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/candidates: %w", err)
}
return out, nil
}
// AssignDisk attaches a drive (by fs-UUID) as a host mount (benign, self-serve).
func (c *Client) AssignDisk(ctx context.Context, uuid, where, fstype, options string) error {
_, err := c.post(ctx, "/disks/assign", map[string]string{
@@ -15,6 +15,12 @@ func diskStub(t *testing.T) (*httptest.Server, string) {
mux.HandleFunc("GET /disks", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,"disks":[{"name":"bulk","data_bearing":true,"data_reason":"has ext4"}]}}`))
})
mux.HandleFunc("GET /disks/candidates", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,` +
`"initialize":[{"device":"/dev/sdd","size_bytes":64000000000,"model":"USB","fstype":"ext4","data_bearing":true,"mountable":true,"mount_source":"/dev/sdd","durable_id":"uuid:abc"},` +
`{"device":"/dev/sde","size_bytes":1000,"data_bearing":false,"mountable":false}],` +
`"attach":[{"device":"/dev/sdd","fstype":"ext4","mountable":true,"mount_source":"/dev/sdd","durable_id":"uuid:abc"}]}}`))
})
mux.HandleFunc("POST /disks/assign", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"assigned":"/mnt/data"}}`))
})
@@ -71,6 +77,40 @@ func TestDisks_List(t *testing.T) {
}
}
func TestListCandidates(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
c := clientFor(t, s, ep)
res, err := c.ListCandidates(context.Background())
if err != nil {
t.Fatal(err)
}
if len(res.Initialize) != 2 {
t.Fatalf("want 2 initialize candidates, got %+v", res.Initialize)
}
if len(res.Attach) != 1 || res.Attach[0].Device != "/dev/sdd" || res.Attach[0].FSType != "ext4" {
t.Fatalf("attach candidate wrong: %+v", res.Attach)
}
if res.Initialize[0].DurableID != "uuid:abc" || !res.Initialize[0].DataBearing {
t.Fatalf("initialize[0] fields wrong: %+v", res.Initialize[0])
}
}
func TestListCandidates_Error(t *testing.T) {
// A non-2xx / malformed agent response surfaces as an error, not a silent empty list.
mux := http.NewServeMux()
mux.HandleFunc("GET /disks/candidates", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"ok":false,"error":"agent unreachable"}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
if _, err := c.ListCandidates(context.Background()); err == nil {
t.Fatal("expected an error from a 502 candidates response")
}
}
func TestFormat_BlankOK(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
@@ -28,6 +28,8 @@ func (s *Server) ServeDiskAPI(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/disks" && r.Method == http.MethodGet:
s.agentDisksListHandler(w, r)
case r.URL.Path == "/api/disks/candidates" && r.Method == http.MethodGet:
s.agentDiskCandidatesHandler(w, r)
case r.URL.Path == "/api/disks/assign" && r.Method == http.MethodPost:
s.agentDiskAssignHandler(w, r)
case r.URL.Path == "/api/disks/eject" && r.Method == http.MethodPost:
@@ -110,6 +112,25 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b):
// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter
// already excludes claimed/OS/enrolled disks (fail-safe), so the controller passes the list through
// untouched — no controller-side filtering.
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
client, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
resp, err := client.ListCandidates(r.Context())
if err != nil {
s.logger.Printf("[ERROR] [web] disk candidates via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
// sortDisksForView orders the agent's disk list deterministically (user-data → system → backup →
// unrecognized; alphabetical by storage name within each tier). A stable Go-side contract beats
// relying on map iteration order or template JS alone.
@@ -51,27 +51,34 @@
<script>
var selDevice = "", selFSType = "";
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function classBadge(d){
if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
return '';
function fmtSize(b){
if(!b) return '';
var g=b/1073741824;
return g>=1 ? g.toFixed(g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB';
}
async function loadDisks(){
try{
var r = await fetch('/api/disks'); var j = await r.json();
// Impl-2b: the raw-device scan (agent GET /disks/candidates). `attach` = unclaimed disks already
// carrying a mountable ext4/xfs FS (mounted + bound, NO format — data preserved). The agent's filter
// excludes OS/enrolled/claimed disks, so we trust the list as-is.
var r = await fetch('/api/disks/candidates'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[];
// Attachable: a user-data drive with a backing device, an fs-UUID identity, not mounted yet.
var attachable = disks.filter(function(d){ return d.backing_device!=="" && (d.durable_id||"").indexOf("uuid:")===0 && !d.mount_path && d.role==='user-data'; });
if(attachable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható (fájlrendszerrel rendelkező, még nem csatolt) felhasználói adatmeghajtó.</p>'; return; }
var cands = (j.data&&j.data.attach)||[];
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható meghajtó — a csatoláshoz olyan (még nem használt) meghajtó kell, amelyen már van fájlrendszer. Új, üres meghajtóhoz használja az „Inicializálás” lehetőséget.</p>'; return; }
var html='<div class="drive-list">';
attachable.forEach(function(d,i){
var sub = esc(d.type)+' · '+esc(d.backing_device);
cands.forEach(function(d,i){
var title = d.model ? esc(d.model) : esc(d.device);
var parts = [esc(d.device)];
if(d.size_bytes) parts.push(fmtSize(d.size_bytes));
if(d.fstype) parts.push(esc(d.fstype));
var sub = parts.join(' · ');
// Attach the FS-bearing node (mount_source, e.g. /dev/sdd1); the agent resolves its UUID.
var dev = d.mount_source || d.device;
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.backing_device)+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges"><span class="badge badge-ok">Felhasználói adat</span>'+classBadge(d)+'</div></div></label>';
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-fs="'+esc(d.fstype)+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges"><span class="badge badge-ok">Fájlrendszer: '+esc(d.fstype)+'</span></div></div></label>';
});
html+='</div>';
document.getElementById('disk-list').innerHTML=html;
@@ -79,7 +86,7 @@ async function loadDisks(){
}
function pickDisk(radio){
selDevice=radio.value;
selDevice=radio.value; selFSType=radio.getAttribute('data-fs')||"";
document.getElementById('sel-device').textContent=selDevice;
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
@@ -92,7 +99,7 @@ async function submitAttach(ev){
var btn=document.getElementById('attach-btn'); var out=document.getElementById('attach-result');
btn.disabled=true; out.innerHTML='<p class="form-hint">Csatlakoztatás folyamatban…</p>';
try{
var body={device:selDevice, fstype:"", mount_name:document.getElementById('mount-name').value,
var body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
label:document.getElementById('storage-label').value, set_default:document.getElementById('set-default').checked};
var r=await fetch('/api/storage/attach',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
var j=await r.json();
@@ -66,32 +66,35 @@ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){retur
function dataBadge(d){
return d.data_bearing
? '<span class="badge badge-error" title="'+esc(d.data_reason)+'">Adatot tartalmaz</span>'
? '<span class="badge badge-error">Adatot tartalmaz — a formázás törli</span>'
: '<span class="badge badge-ok">Üres — formázható</span>';
}
function classBadge(d){
if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
return '';
function fmtSize(b){
if(!b) return '';
var g=b/1073741824;
return g>=1 ? g.toFixed(g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB';
}
async function loadDisks(){
try{
var r = await fetch('/api/disks'); var j = await r.json();
// Impl-2b: the raw-device scan (agent GET /disks/candidates via the controller proxy). The agent's
// unclaimed-disk filter already excludes OS/enrolled/claimed disks (fail-safe), so we trust the list
// as-is — no client-side filtering. `initialize` = every unclaimed disk (blank or data-bearing).
var r = await fetch('/api/disks/candidates'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[];
// Only USER-DATA drives with a block device that are NOT already mounted are valid init (format)
// targets — a mounted drive must be ejected (Leválasztás) first, like the attach wizard. This
// keeps an already-managed drive (e.g. felhom-usb) from showing up as an "initialize" candidate.
var formattable = disks.filter(function(d){ return d.backing_device!=="" && d.role==='user-data' && !d.mount_path; });
if(formattable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs formázható felhasználói adatmeghajtó. (A csatlakoztatott meghajtókat előbb le kell választani; a rendszer- és biztonsági-mentés meghajtók védettek.)</p>'; return; }
var cands = (j.data&&j.data.initialize)||[];
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs elérhető meghajtó az inicializáláshoz. Csatlakoztasson egy új adathordozót — a rendszer-, a biztonsági mentés- és a már használatban lévő meghajtók itt nem jelennek meg.</p>'; return; }
var html='<div class="drive-list">';
formattable.forEach(function(d,i){
var sub = esc(d.type)+' · '+esc(d.backing_device)+(d.mount_path?' · '+esc(d.mount_path):'');
cands.forEach(function(d,i){
var title = d.model ? esc(d.model) : esc(d.device);
var parts = [esc(d.device)];
if(d.size_bytes) parts.push(fmtSize(d.size_bytes));
if(d.fstype) parts.push('jelenlegi: '+esc(d.fstype));
var sub = parts.join(' · ');
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.backing_device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges"><span class="badge badge-ok">Felhasználói adat</span>'+classBadge(d)+dataBadge(d)+'</div></div></label>';
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges">'+dataBadge(d)+'</div></div></label>';
});
html+='</div>';
document.getElementById('disk-list').innerHTML=html;