diff --git a/CHANGELOG.md b/CHANGELOG.md index a9eaeaa..8a37a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ ## Changelog +### v0.44.0 — role-aware drive management: protected lockout + customer type-to-confirm wipe + drive-list restyle (2026-06-11) + +The controller half of the storage-authorization redesign. The drive UI is now driven by the agent's +authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own +system storage and the backup safety-net are visibly protected with NO destructive controls; the +customer manages their own data drives with informed consent instead of a support ticket. + +- **`agentapi` client** — `DiskInfo` gains `role` + capacity (`total_bytes`/`used_bytes`/ + `used_fraction`); `FormatResult` gains `role`/`needs_confirmation`/`durable_id`. `FormatDisk` now + takes `confirmed` + `durableID` and returns a new `ErrNeedsConfirmation` (user-data, awaiting the + customer's confirmation) distinct from `ErrFormatRefused` (system/backup, operator signature). +- **Role-aware overview** (`settings.html`, "Meghajtók (ügynök nézet)") — restyled from a raw + `` to **cards** in the house style: prominent storage name, mono device/mount detail, badges + for class (gyors/lassú), data ("Adatot tartalmaz"), **role** (🔒 Rendszer / 🔒 Biztonsági mentés — + védett / Felhasználói adat) and registered state, plus a **capacity bar** (the monitoring page's + green→amber→red `system-bar`). Destructive controls (Leválasztás / Törlés) render **only** for + user-data drives mounted under `/mnt`. System/backup get the lock badge and no controls. +- **Type-to-confirm + name-the-apps** — a modal that (1) lists, **by name**, the deployed apps whose + data lives on the drive (`GET /api/storage/impact` → `appsUsingPath`), and (2) requires the customer + to **type the mount name** before the destructive button enables. No reflex-clickable destructive + action. Applies to both eject and wipe. +- **Customer wipe** (`POST /api/storage/wipe`) — eject (unmount + deregister) then a server-side + two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true` + bound to it). The mount name is re-checked server-side. A system/backup device is refused by the + agent regardless of what the controller sends. +- **Init wizard** (`storage_init.html`) — the data-bearing path now uses the **customer-confirmation** + flow (type-to-confirm → re-submit confirmed) instead of the `felhom-opsign` instruction; the disk + selector is restyled to cards and lists only user-data targets. `storage_attach.html` likewise + restyled (cards, user-data only). No raw `
` remains in the storage UI. +- **Tests** — `agentapi`: blank → ok, system/backup → ErrFormatRefused (+pending op), user-data → + ErrNeedsConfirmation (+durable id), confirmed → formatted. `web`: init surfaces NeedsConfirmation and + does NO mount/register; confirmed init forwards the confirmation+durable id and proceeds; the + dependency-impact (`appsUsingPathIn`) names the right deployed apps. + +Pairs with **felhom-agent v0.23.0** (the authoritative role classifier + the tiered wipe gate). + ### v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11) After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (`/settings/storage/init`, diff --git a/REPORT.md b/REPORT.md index 1a23a75..f58be1b 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,61 +1,61 @@ -# REPORT — v0.43.0: rebuilt storage management (guided init/attach/eject on the agent disk model) +# REPORT — felhom-controller v0.44.0: role-aware drive management + customer type-to-confirm wipe -**Repo:** `felhom-controller` · **Version:** 0.43.0 · **Date:** 2026-06-11 -**Pushed commit:** `29a9dcd` · paired with `felhom-agent` v0.22.0 (`4734d4a`, exposes `durable_id`) + golden rebake. +**Repo:** `felhom-controller` · **Version:** 0.44.0 · **Date:** 2026-06-11 · pairs with **felhom-agent v0.23.0** -## What shipped +## What this implements -After the 8C de-privileging the storage UI's buttons pointed at deleted routes (all 404); only manual -"add already-mounted path" survived. The agent already owns disk execution + the data-bearing signature -gate, and the controller already had the `agentapi` client + `/api/disks/*` proxies + the `StoragePath` -registry. This is a **controller-only UI/orchestration layer** over those — the controller holds **no -destructive authority**. +The controller half of the storage-authorization redesign (CC SPEC, Part B). The drive UI is driven by +the agent's authoritative **role** (`system` | `backup` | `user-data`): system/backup are visibly +protected with no destructive controls; the customer manages their own data drives with informed +consent (type-to-confirm + named app impact). -- **Storage overview** (`settings.html`, `GET /api/disks`): the agent's live disk view — name/type/state/ - device/mount/class + the **`data_bearing` badge** + a "registered?" cross-reference. -- **Guided init** (`/settings/storage/init` + `POST /api/storage/init`): format → resolve the new fs UUID - from the re-listed disks → assign (mount) → register the `StoragePath`. **A data-bearing device is - REFUSED** by the agent; the UI surfaces the exact `felhom-opsign …` command and **stops** — no force-format. -- **Guided attach** (`/settings/storage/attach` + `POST /api/storage/attach`): non-destructive — resolve - the existing fs UUID → assign → register. -- **Eject** (`POST /api/storage/eject`): benign unmount + deregister, surfacing the agent's dependent-guest warning. -- **`agentapi`**: `DiskInfo.DurableID` + `FSUUID()` (the assign key — strip `uuid:`); `FormatResult.PendingOp` - + `OpsignCommand()`, now parsed from the agent's 403 body (the old client discarded it). -- **Honest buttons**: init/attach wired; migrate (drive + per-stack, both places) disabled "Hamarosan" — **no 404s**. -- **Phase 3 (de-priv template debt)**: removed the dead `CrossDrive*` blocks in `deploy.html` (the "2. - mentés" form + 3 JS fns) and `backups.html` (run buttons + 2 JS fns) — they referenced fields the - de-privileged handlers no longer provide. +### B0 — `agentapi` client +- `DiskInfo` += `role`, `total_bytes`, `used_bytes`, `used_fraction`. +- `FormatResult` += `role`, `needs_confirmation`, `durable_id`. +- `FormatDisk(ctx, device, fstype, confirmed, durableID)` — new `ErrNeedsConfirmation` (user-data, + awaiting the customer's confirmation) vs `ErrFormatRefused` (system/backup, operator signature). -## Security invariant — held, proven live -The UI **never** bypasses the agent's data-bearing gate; there is **no force-format**. A refusal surfaces -the `felhom-opsign` command only. Unit-tested (`runStorageInit` on a data-bearing refusal performs **zero** -assign/register) **and** proven live on 9201's real `sdb`: -`POST /api/storage/init {device:/dev/sdb1}` → **HTTP 409**, `refused:true`, `registered:false`, -`opsign: felhom-opsign -op storage_wipe -host demo-felhom-01 -durable-id byid:wwn-0x5000039ddb108568-part1`. -No format, no mount, no registration. +### B1 — Role-aware overview (no destructive controls on protected drives) +- `settings.html` "Meghajtók (ügynök nézet)" restyled from a raw `
` to **cards** (house style): + name prominent, mono device/mount sub-detail, badges for class / data / **role** (🔒 lock for + system & backup) / registered, and a **capacity bar** reusing the monitoring `system-bar` + (green→amber→red). Eject/Wipe controls render **only** for user-data drives mounted under `/mnt`. -## Live validation (guest 9201, real 1TB USB `sdb` = `felhom-usb`) -- `/api/disks` now carries `durable_id`; `felhom-usb` → `/dev/sdb1`, `data_bearing:true` ("device is - mounted"), `durable_id:uuid:277a2179-…`. Overview badge maps correctly. -- **Init on sdb (data-bearing) → 409 + opsign, gate held** (the spec's passing gate test — sdb holds data). -- Pages render (no 404/500): `/settings`, `/settings/storage/init`, `/settings/storage/attach`, - `/stacks//deploy` (deploy.html — CrossDrive removed), `/stacks`, `/monitoring`. No dead storage links. -- Tests: refusal-surfaces-opsign-and-does-NOT-mount/register; success assigns with the resolved UUID + - registers the expected `StoragePath`; UUID resolution; a **template-parse test** guards every page. +### B2 — Customer wipe/eject flow +- **Name the apps**: `GET /api/storage/impact?where=` → `appsUsingPath` → the deployed apps (by display + name) whose `HDD_PATH` is that mount. Shown in the modal before any destructive action. +- **Type-to-confirm**: a modal with a text field; the destructive button stays disabled until the typed + value equals the mount name exactly (enforced client-side AND server-side in `/api/storage/wipe`). +- **Wipe** (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side two-step + customer-confirmed format (learn the agent's durable id via the NeedsConfirmation response, then + re-submit `confirmed:true` bound to it). Deregisters the StoragePath. -## Deferred / flagged (NOT in this slice) -- **Phase 2 — migration (controller-side rsync):** intentionally its own slice (the migrate buttons are - disabled "Hamarosan", not dead). The controller still has `/mnt:/mnt:rw`, so it can rsync app-data - between mounts + update `app.yaml`'s `HDD_PATH` (stop→rsync→verify→start) — no agent endpoint needed. -- **`/backups` still 500s on PRE-EXISTING restic debt (NOT this change, NOT CrossDrive).** The page - references ~30 dead restic-tier fields (`.Backup.RepoStats`, `.SnapshotHistory`, `.ResticSchedule`, - `.Retention`, `.LastBackup`, `.NextBackup`, `.LastCheckOK`, …) that 8C removed from the backend — the - whole restic snapshot tier + repo stats + snapshot history + restic-password UI is dead. That's a - **backups-page de-priv rebuild** (a design slice: what the page shows in the app-data-only model), well - beyond the CrossDrive cleanup this spec scoped (the spec listed "backups.html (5)" = the CrossDrive refs, - which I removed). `/backups` was already 500'ing before this task. **Recommend it as the next slice.** +### B3 — Init/attach role-gated + restyled +- `storage_init.html`: data-bearing path now uses the **customer-confirmation** flow (type-to-confirm → + re-submit confirmed) instead of the `felhom-opsign` instruction; selector restyled to cards, lists + **only user-data** targets (system/backup are not offered). The opsign surface remains as a fallback + if a protected device somehow reaches it. +- `storage_attach.html`: restyled to cards, user-data only. -## Notes -- No agent disk-subsystem or gate changes; the only agent change is the read-only `durable_id` exposure - (v0.22.0) the user approved (without it the de-privileged controller can't learn the fs UUID `assign` - needs). Golden rebaked with controller 0.43.0 so fresh provisions get the rebuilt UI. +### B4 — UI polish +- New CSS appended to `style.css` (reusing existing tokens): `.drive-card` / `.drive-badges` / + `.drive-cap`, the missing `.badge-ok` / `.badge-lock` / `.badge-muted` / standalone `.mono`, and the + type-to-confirm `.confirm-overlay` / `.confirm-box`. No new design system; no raw table left. + +## Tests +- `internal/agentapi/disks_test.go` — blank → ok; system/backup → `ErrFormatRefused` (+pending op); + user-data → `ErrNeedsConfirmation` (+durable id + role); confirmed → formatted. +- `internal/web/storage_handlers_test.go` — init on a user-data data-bearing device returns + NeedsConfirmation and does NO mount/register; confirmed init forwards the confirmation+durable id and + proceeds to register; system/backup init still surfaces the opsign; `appsUsingPathIn` names the right + deployed apps (dependency-impact). Plus `TestTemplatesParse` (all templates parse). + +`go build ./... && go vet ./... && go test ./...` all green (Go 1.26, local). + +## Version +`v0.43.0 → v0.44.0` (build-time ldflags `Version`). + +## Live validation / deploy +Pending in this session: build+push the image, deploy to guest 9201, rebake the golden, and run the +live checks (overview tiers, the hand-issued `confirmed:true` refusal on a system/backup device, a +user-data confirmed wipe binding to the durable id, and the audit-log entry). diff --git a/controller/README.md b/controller/README.md index 1b52bb0..33f741f 100644 --- a/controller/README.md +++ b/controller/README.md @@ -514,23 +514,36 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an ### 4. Storage Management -> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0).** After the 8C de-privileging, the controller -> holds **no Proxmox/disk credentials and no destructive authority** — disk execution + the data-bearing -> signature gate live entirely in the **host agent**. The controller is now a thin presenter/orchestrator: -> - **Overview** (`settings.html` ← `GET /api/disks`): the agent's live disk view (name/type/state/device/ -> mount/class) + the **`data_bearing`** badge + "registered?" cross-reference. +> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0), made ROLE-AWARE in v0.44.0.** After the 8C +> de-privileging, the controller holds **no Proxmox/disk credentials and no destructive authority** — disk +> execution + the gate live entirely in the **host agent**. The drive UI is driven by the agent's +> authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own +> system storage and the backup safety-net are visibly **protected** (lock badge, NO destructive controls); +> the customer manages their own **user-data** drives with informed consent. The agent re-enforces role at +> wipe time — the UI lockout is defense-in-depth, not the gate. +> - **Overview** (`settings.html` ← `GET /api/disks`): styled **cards** (not a table) — name, mono +> device/mount, badges for class (gyors/lassú), data (`Adatot tartalmaz`), **role** (🔒 Rendszer / 🔒 +> Biztonsági mentés — védett / Felhasználói adat) and registered state, plus a **capacity bar** (the +> monitoring `system-bar`, from the agent's `total_bytes`/`used_bytes`). Eject/Wipe render **only** for +> user-data drives mounted under `/mnt`. +> - **Customer wipe/eject** — a **type-to-confirm** modal that names the deployed apps that break +> (`GET /api/storage/impact` → `appsUsingPath`) and disables the destructive button until the **mount +> name is typed exactly**. Wipe (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side +> two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true` bound +> to it). The agent refuses a protected device regardless of what the controller sends. > - **Guided init** (`/settings/storage/init`, `POST /api/storage/init`, `web/storage_handlers.go`): format -> → resolve the new fs UUID from the re-listed disks (`durable_id`, `uuid:`-stripped) → `assign` (mount) -> → register a `StoragePath`. **A data-bearing device is REFUSED by the agent** (`pending_op`); the UI -> surfaces the exact `felhom-opsign -op storage_wipe -host … -durable-id …` command and stops — **there -> is no force-format**. The agent's `data_bearing` verdict (it inspects the device) is ground truth. +> → resolve the new fs UUID → `assign` → register. The selector lists **only user-data** targets. A +> data-bearing user-data device now uses the **customer-confirmation** flow (type-to-confirm → re-submit +> `confirmed:true` + durable id), NOT the `felhom-opsign` command. The opsign surface remains a fallback +> only if a protected device somehow reaches init. > - **Guided attach** (`/settings/storage/attach`, `POST /api/storage/attach`): non-destructive — resolve -> the existing fs UUID → `assign` → register. +> the existing fs UUID → `assign` → register. Selector restyled to cards (user-data only). > - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest warning. > - **`agentapi`** (`internal/agentapi`) is the pinned client to the agent local API: `Disks`/`AssignDisk`/ -> `EjectDisk`/`FormatDisk`; `DiskInfo.FSUUID()` + `FormatResult.PendingOp.OpsignCommand()`. -> - The **`StoragePath` registry** (`settings.go`: `AddStoragePath`/default/schedulable/label) is unchanged — -> init/attach register into it; the existing per-path management handlers stay. +> `EjectDisk`/`FormatDisk(…, confirmed, durableID)`; `DiskInfo.role`+capacity; +> `FormatResult.{role,needs_confirmation,durable_id}`; `ErrNeedsConfirmation` (user-data) vs +> `ErrFormatRefused` (system/backup). `FormatResult.PendingOp.OpsignCommand()` for the operator path. +> - The **`StoragePath` registry** (`settings.go`: `AddStoragePath`/default/schedulable/label) is unchanged. > - **Migration** (drive + per-stack) is **deferred** to its own slice (buttons disabled "Hamarosan"). > > The privileged controller-side disk subsections **below are historical** (the `internal/storage/*` scan/ diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 99378de..555d7bc 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -184,8 +184,15 @@ type DiskInfo struct { BackingDevice string `json:"backing_device"` MountPath string `json:"mount_path"` Class string `json:"class"` - DataBearing bool `json:"data_bearing"` - DataReason string `json:"data_reason"` + // Role is the agent's AUTHORITATIVE protection tier: "system" | "backup" | "user-data". The UI + // is driven from it — system/backup get a lock badge and NO destructive controls; user-data is + // customer-manageable (eject/wipe with type-to-confirm). + Role string `json:"role"` + DataBearing bool `json:"data_bearing"` + DataReason string `json:"data_reason"` + TotalBytes int64 `json:"total_bytes"` + UsedBytes int64 `json:"used_bytes"` + UsedFraction float64 `json:"used_fraction"` // DurableID is the target's stable identity (e.g. "uuid:" for usb/local-dir). The // fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's the // only way the de-privileged controller learns a mount key it cannot read off the device itself. @@ -209,12 +216,19 @@ type DisksResponse struct { // FormatResult mirrors POST /disks/format (the success/refusal payload). type FormatResult struct { - VMID int `json:"vmid"` - Device string `json:"device"` - Formatted bool `json:"formatted"` - DataBearing bool `json:"data_bearing"` - Reason string `json:"reason"` - PendingOp *PendingOp `json:"pending_op,omitempty"` + VMID int `json:"vmid"` + Device string `json:"device"` + Formatted bool `json:"formatted"` + DataBearing bool `json:"data_bearing"` + Reason string `json:"reason"` + // Role is the agent's tier for this device (system | backup | user-data). + Role string `json:"role,omitempty"` + // NeedsConfirmation is set on a USER-DATA data-bearing refusal: re-submit with confirmed=true + + // DurableID after the type-to-confirm UI (NOT an operator signature). + NeedsConfirmation bool `json:"needs_confirmation,omitempty"` + DurableID string `json:"durable_id,omitempty"` + // PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op. + PendingOp *PendingOp `json:"pending_op,omitempty"` } // PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller @@ -231,9 +245,14 @@ func (p PendingOp) OpsignCommand() string { return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID) } -// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format -// (pending operator authorization — the 8C invariant). The UI surfaces this distinctly. -var ErrFormatRefused = fmt.Errorf("agentapi: format refused — device is data-bearing (operator authorization required)") +// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format on a +// SYSTEM/BACKUP device (operator signature required). The UI surfaces the pending opsign command. +var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup device (operator authorization required)") + +// ErrNeedsConfirmation is returned by FormatDisk when the agent refuses a data-bearing format on a +// USER-DATA device pending the CUSTOMER's informed-confirmation (bound to FormatResult.DurableID). +// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id. +var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device") // Disks lists the host drives the agent manages, with a data-bearing flag per drive. func (c *Client) Disks(ctx context.Context) (DisksResponse, error) { @@ -276,14 +295,22 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro return out, nil } -// FormatDisk asks the agent to format a device. The AGENT inspects the device and decides -// data-bearing-ness — a data-bearing device is refused (ErrFormatRefused), the controller's claim -// is irrelevant. Only a device the agent reads as blank is formatted. -func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatResult, error) { +// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE +// (its own classification, never the controller's claim): +// - blank device → formatted. +// - user-data, data-bearing, NOT confirmed → ErrNeedsConfirmation (out.DurableID = the id to confirm). +// - user-data, data-bearing, confirmed + matching durable id → formatted. +// - system/backup, data-bearing → ErrFormatRefused (out.PendingOp = the operator opsign command). +// +// confirmed + durableID authorize a user-data wipe (the durable id the agent gave on the prior +// ErrNeedsConfirmation); they are inert for system/backup. +func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (FormatResult, error) { var out FormatResult - // Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op) even on the - // 403 refusal, so we must read the body on non-2xx rather than discarding it. - data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype}) + // Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op / durable_id) + // even on the 403 refusal, so we must read the body on non-2xx rather than discarding it. + data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]any{ + "device": device, "fstype": fstype, "confirmed": confirmed, "durable_id": durableID, + }) if err != nil { return out, err } @@ -291,10 +318,16 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatR if len(data) > 0 { _ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body } + if out.Formatted { + return out, nil + } + if out.NeedsConfirmation { + out.DataBearing = true + return out, ErrNeedsConfirmation // user-data: surface the type-to-confirm flow + } if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) { out.DataBearing = true - out.Formatted = false - return out, ErrFormatRefused // carries PendingOp for the caller to surface the opsign command + return out, ErrFormatRefused // system/backup: surface the opsign command } return out, nil } diff --git a/controller/internal/agentapi/disks_test.go b/controller/internal/agentapi/disks_test.go index d9bfdc8..0e32a40 100644 --- a/controller/internal/agentapi/disks_test.go +++ b/controller/internal/agentapi/disks_test.go @@ -22,14 +22,21 @@ func diskStub(t *testing.T) (*httptest.Server, string) { _, _ = w.Write([]byte(`{"ok":true,"data":{"ejected":"/mnt/bulk","dependent_guests":[8200]}}`)) }) mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) { - var body struct{ Device, FSType string } - _ = decodeJSON(r, &body) - if strings.Contains(body.Device, "data") { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"ok":false,"error":"device is data-bearing — operator authorization (pending_signature)"}`)) - return + var body struct { + Device, FSType, DurableID string + Confirmed bool + } + _ = decodeJSON(r, &body) + switch { + case strings.Contains(body.Device, "protected"): // system/backup → operator signature + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"system","pending_op":{"op":"storage_wipe","host_scope":"h","durable_id":"byid:x","fstype":"ext4"}}}`)) + case strings.Contains(body.Device, "data") && !body.Confirmed: // user-data, not yet confirmed + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"user-data","needs_confirmation":true,"durable_id":"byid:wwn-1"}}`)) + default: // blank, or user-data confirmed + _, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true,"role":"user-data"}}`)) } - _, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true}}`)) }) s := httptest.NewTLSServer(mux) return s, strings.TrimPrefix(s.URL, "https://") @@ -65,20 +72,49 @@ func TestFormat_BlankOK(t *testing.T) { s, ep := diskStub(t) defer s.Close() c := clientFor(t, s, ep) - res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4") + res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4", false, "") if err != nil || !res.Formatted { t.Fatalf("blank format: %v %+v", err, res) } } -func TestFormat_DataBearingRefused(t *testing.T) { +// SYSTEM/BACKUP data-bearing → ErrFormatRefused with the operator pending op. +func TestFormat_ProtectedRefused(t *testing.T) { s, ep := diskStub(t) defer s.Close() c := clientFor(t, s, ep) - _, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4") + res, err := c.FormatDisk(context.Background(), "/dev/protected-disk", "ext4", false, "") if !errors.Is(err, ErrFormatRefused) { t.Fatalf("expected ErrFormatRefused, got %v", err) } + if res.PendingOp == nil { + t.Fatal("protected refusal should carry the operator pending op") + } +} + +// USER-DATA data-bearing, not confirmed → ErrNeedsConfirmation with the durable id to confirm against. +func TestFormat_UserDataNeedsConfirmation(t *testing.T) { + s, ep := diskStub(t) + defer s.Close() + c := clientFor(t, s, ep) + res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", false, "") + if !errors.Is(err, ErrNeedsConfirmation) { + t.Fatalf("expected ErrNeedsConfirmation, got %v", err) + } + if !res.NeedsConfirmation || res.DurableID != "byid:wwn-1" || res.Role != "user-data" { + t.Fatalf("needs-confirmation payload not surfaced: %+v", res) + } +} + +// USER-DATA data-bearing, confirmed + durable id → formatted. +func TestFormat_UserDataConfirmed(t *testing.T) { + s, ep := diskStub(t) + defer s.Close() + c := clientFor(t, s, ep) + res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", true, "byid:wwn-1") + if err != nil || !res.Formatted { + t.Fatalf("confirmed user-data format: %v %+v", err, res) + } } func TestEject_Dependents(t *testing.T) { diff --git a/controller/internal/web/agent_disk_handlers.go b/controller/internal/web/agent_disk_handlers.go index e76c17b..03e72e3 100644 --- a/controller/internal/web/agent_disk_handlers.go +++ b/controller/internal/web/agent_disk_handlers.go @@ -139,8 +139,10 @@ func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) { // can show "operator authorization required". func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) { var req struct { - Device string `json:"device"` - FSType string `json:"fstype"` + Device string `json:"device"` + FSType string `json:"fstype"` + Confirmed bool `json:"confirmed"` + DurableID string `json:"durable_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil) @@ -155,9 +157,14 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } - resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType) + resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID) + if errors.Is(err, agentapi.ErrNeedsConfirmation) { + s.logger.Printf("[INFO] [web] disk format needs customer confirmation (user-data): %s", req.Device) + writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp) + return + } if errors.Is(err, agentapi.ErrFormatRefused) { - s.logger.Printf("[WARN] [web] disk format refused by agent (data-bearing): %s", req.Device) + s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device) writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp) return } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index af221bf..395fe93 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1022,12 +1022,19 @@ func (s *Server) countAppsUsingPath(storagePath string) int { } func (s *Server) appsUsingPath(storagePath string) []string { + return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath) +} + +// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the +// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is +// the "name the apps that break" list for the type-to-confirm wipe/eject UI. +func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string { var names []string - for _, stack := range s.stackMgr.GetStacks() { + for _, stack := range allStacks { if !stack.Deployed { continue } - if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { + if appCfg := loadCfg(stack.Name); appCfg != nil { if appCfg.Env["HDD_PATH"] == storagePath { names = append(names, stack.Meta.DisplayName) } diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 1001a08..abd2355 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -25,7 +25,7 @@ import ( // without a live agent). *agentapi.Client satisfies it. type diskAgent interface { Disks(ctx context.Context) (agentapi.DisksResponse, error) - FormatDisk(ctx context.Context, device, fstype 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 EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error) } @@ -60,21 +60,33 @@ func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string { type storageInitResult struct { Registered bool `json:"registered"` Where string `json:"where,omitempty"` - // Refusal (data-bearing): the operator must sign offline. No bypass. + // NeedsConfirmation (USER-DATA data-bearing): the customer must confirm the wipe (type-to-confirm), + // then the wizard re-submits with confirmed=true. NOT an operator signature. + NeedsConfirmation bool `json:"needs_confirmation,omitempty"` + Role string `json:"role,omitempty"` + DurableID string `json:"durable_id,omitempty"` + // Refusal (system/backup data-bearing): the operator must sign offline. No bypass. Refused bool `json:"refused,omitempty"` Reason string `json:"reason,omitempty"` Opsign string `json:"opsign,omitempty"` } -// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID → -// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs -// NO further (destructive or mount) action. -func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) { +// runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new +// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation +// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In +// either refusal it performs NO further (destructive or mount) action. +func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) { if !validFSTypes[fstype] { return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype) } - // 1. Format — the AGENT inspects the device and decides. A data-bearing device is refused. - fr, err := agent.FormatDisk(ctx, device, fstype) + // 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device + // is allowed only with the customer's confirmation bound to its durable id; system/backup needs + // an operator signature. + fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID) + if errors.Is(err, agentapi.ErrNeedsConfirmation) { + // USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against. + return storageInitResult{NeedsConfirmation: true, Role: fr.Role, DurableID: fr.DurableID, Reason: fr.Reason}, nil + } if errors.Is(err, agentapi.ErrFormatRefused) { res := storageInitResult{Refused: true, Reason: fr.Reason} if fr.PendingOp != nil { @@ -169,6 +181,10 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { s.handleStorageAttach(w, r) case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost: s.handleStorageEject(w, r) + case r.URL.Path == "/api/storage/wipe" && r.Method == http.MethodPost: + s.handleStorageWipe(w, r) + case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet: + s.handleStorageImpact(w, r) default: http.NotFound(w, r) } @@ -180,6 +196,10 @@ type storageProvReq struct { MountName string `json:"mount_name"` Label string `json:"label"` SetDefault bool `json:"set_default"` + // Confirmed + DurableID: the customer's type-to-confirm authorization for a USER-DATA data-bearing + // wipe (the durable id the agent returned on the prior NeedsConfirmation response). + Confirmed bool `json:"confirmed"` + DurableID string `json:"durable_id"` } func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) { @@ -202,11 +222,15 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } - res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault) + res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID) if err != nil { writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) return } + if res.NeedsConfirmation { + writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res) + return + } if res.Refused { writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res) return @@ -214,6 +238,93 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusOK, true, "", res) } +// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount — +// the "name the apps that break" requirement for the type-to-confirm wipe/eject UI. +func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) { + where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where"))) + if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil) + return + } + apps := s.appsUsingPath(where) + if apps == nil { + apps = []string{} + } + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "apps": apps}) +} + +// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject — +// deregisters + frees the device) then formats with the customer's confirmation bound to the device's +// durable id. The agent re-classifies the role and re-resolves the durable id itself — a system/backup +// device is refused by the agent regardless of what the controller sends. The mount name must be typed +// to match (type-to-confirm) — enforced both client-side (disabled button) and here (server-side). +func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) { + var req struct { + Device string `json:"device"` + Where string `json:"where"` + MountName string `json:"mount_name"` // the typed confirmation (must equal the basename of Where) + FSType string `json:"fstype"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) + return + } + if req.Device == "" { + writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil) + return + } + req.Where = path.Clean(strings.TrimSpace(req.Where)) + if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil) + return + } + // Server-side type-to-confirm: the typed name must match the mount's basename exactly. + if strings.TrimSpace(req.MountName) != path.Base(req.Where) { + writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil) + return + } + fstype := req.FSType + if !validFSTypes[fstype] { + fstype = "ext4" + } + agent, err := s.agentClient() + if err != nil { + writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + return + } + // 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath. + if _, eerr := agent.EjectDisk(r.Context(), req.Where); eerr != nil { + s.logger.Printf("[WARN] [web] wipe: eject %s failed (continuing to format): %v", req.Where, eerr) + } else if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil { + s.logger.Printf("[WARN] [web] wipe: deregister %s failed: %v", req.Where, rerr) + } else { + go s.SyncFileBrowserMounts() + } + // 2. Two-step customer-confirmed format: learn the agent's durable id (NeedsConfirmation), then + // re-submit confirmed:true bound to it. The agent re-resolves + matches the durable id and + // re-classifies the role — a protected device is refused here even though we send confirmed:true. + probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "") + if errors.Is(perr, agentapi.ErrFormatRefused) { + writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe) + return + } + if !errors.Is(perr, agentapi.ErrNeedsConfirmation) { + if perr != nil { + writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil) + return + } + // Already blank (no confirmation needed) — the format the agent just ran is the wipe. + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": true}) + return + } + fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID) + if ferr != nil { + writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil) + return + } + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID}) +} + func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) { var req storageProvReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/controller/internal/web/storage_handlers_test.go b/controller/internal/web/storage_handlers_test.go index 5178364..cded7e8 100644 --- a/controller/internal/web/storage_handlers_test.go +++ b/controller/internal/web/storage_handlers_test.go @@ -11,6 +11,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv @@ -24,21 +25,27 @@ func TestTemplatesParse(t *testing.T) { // mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action. type mockAgent struct { - disks agentapi.DisksResponse - formatRes agentapi.FormatResult - formatErr error - assignErr error - assignCalls []assignCall - disksCalls int + disks agentapi.DisksResponse + formatRes agentapi.FormatResult + formatErr error + assignErr error + assignCalls []assignCall + disksCalls int + formatCalls []formatCall } type assignCall struct{ uuid, where, fstype string } +type formatCall struct { + device, fstype, durableID string + confirmed bool +} func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) { m.disksCalls++ return m.disks, nil } -func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string) (agentapi.FormatResult, error) { +func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error) { + m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed}) return m.formatRes, m.formatErr } func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error { @@ -59,22 +66,23 @@ func testServer(t *testing.T) *Server { return &Server{settings: sett, logger: lg, cfg: &config.Config{}} } -// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register. -func TestRunStorageInit_DataBearingRefusal(t *testing.T) { +// SECURITY: a SYSTEM/BACKUP data-bearing refusal must surface the opsign command and perform NO +// assign/register (operator signature required — confirmation cannot help). +func TestRunStorageInit_SystemBackupRefusal(t *testing.T) { s := testServer(t) agent := &mockAgent{ formatErr: agentapi.ErrFormatRefused, formatRes: agentapi.FormatResult{ - Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", + Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", Role: "system", PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"}, }, } - res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true) + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } if !res.Refused { - t.Fatal("expected Refused=true on a data-bearing device") + t.Fatal("expected Refused=true on a protected data-bearing device") } if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" { t.Errorf("opsign command not surfaced: %q", res.Opsign) @@ -87,6 +95,54 @@ func TestRunStorageInit_DataBearingRefusal(t *testing.T) { } } +// A USER-DATA data-bearing device returns NeedsConfirmation (+ the durable id to confirm against) and +// performs NO assign/register — the customer must confirm the wipe first (NOT an operator signature). +func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) { + s := testServer(t) + agent := &mockAgent{ + formatErr: agentapi.ErrNeedsConfirmation, + formatRes: agentapi.FormatResult{ + Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", + Role: "user-data", DurableID: "byid:wwn-abc", + }, + } + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.NeedsConfirmation || res.DurableID != "byid:wwn-abc" || res.Role != "user-data" { + t.Fatalf("expected NeedsConfirmation with the durable id + role: %+v", res) + } + if res.Refused || res.Opsign != "" { + t.Fatal("a user-data device must NOT surface an operator-signature path") + } + if len(agent.assignCalls) != 0 || len(s.settings.GetStoragePaths()) != 0 { + t.Fatal("NeedsConfirmation MUST NOT mount or register") + } +} + +// After the customer confirms, the wizard re-submits with confirmed=true + the durable id; the format +// then succeeds and the flow proceeds to assign + register. Assert the confirmation is forwarded. +func TestRunStorageInit_UserDataConfirmedProceeds(t *testing.T) { + s := testServer(t) + agent := &mockAgent{ + formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: true, Role: "user-data"}, + disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{ + {Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-1", Role: "user-data"}, + }}, + } + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Registered { + t.Fatalf("confirmed wipe should proceed to register: %+v", res) + } + if len(agent.formatCalls) != 1 || !agent.formatCalls[0].confirmed || agent.formatCalls[0].durableID != "byid:wwn-abc" { + t.Fatalf("the customer confirmation + durable id were not forwarded to the agent: %+v", agent.formatCalls) + } +} + // Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register. func TestRunStorageInit_Success(t *testing.T) { s := testServer(t) @@ -96,7 +152,7 @@ func TestRunStorageInit_Success(t *testing.T) { {Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"}, }}, } - res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true) + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -148,6 +204,39 @@ func TestFSUUIDForDevice(t *testing.T) { } } +// Dependency-impact: name the deployed apps whose data lives on a given mount (the type-to-confirm +// "which apps break" list). Pure helper so no live stacks.Manager is needed. +func TestAppsUsingPathIn(t *testing.T) { + all := []stacks.Stack{ + {Name: "immich", Deployed: true, Meta: stacks.Metadata{DisplayName: "Immich"}}, + {Name: "nextcloud", Deployed: true, Meta: stacks.Metadata{DisplayName: "Nextcloud"}}, + {Name: "paperless", Deployed: true, Meta: stacks.Metadata{DisplayName: "Paperless"}}, + {Name: "notdeployed", Deployed: false, Meta: stacks.Metadata{DisplayName: "Nem telepített"}}, + } + env := map[string]map[string]string{ + "immich": {"HDD_PATH": "/mnt/hdd_1"}, + "nextcloud": {"HDD_PATH": "/mnt/hdd_1"}, + "paperless": {"HDD_PATH": "/mnt/hdd_2"}, // different drive + "notdeployed": {"HDD_PATH": "/mnt/hdd_1"}, // on the drive but not deployed → excluded + } + load := func(name string) *stacks.AppConfig { + if e, ok := env[name]; ok { + return &stacks.AppConfig{Env: e} + } + return nil + } + got := appsUsingPathIn(all, load, "/mnt/hdd_1") + if len(got) != 2 || got[0] != "Immich" || got[1] != "Nextcloud" { + t.Fatalf("apps on /mnt/hdd_1: got %v, want [Immich Nextcloud]", got) + } + if other := appsUsingPathIn(all, load, "/mnt/hdd_2"); len(other) != 1 || other[0] != "Paperless" { + t.Fatalf("apps on /mnt/hdd_2: got %v, want [Paperless]", other) + } + if none := appsUsingPathIn(all, load, "/mnt/empty"); len(none) != 0 { + t.Fatalf("apps on an unused mount: got %v, want []", none) + } +} + func TestMountWhere(t *testing.T) { if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" { t.Errorf("mountWhere(hdd_1) = %q, %v", w, err) diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 10ac502..250c134 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -354,46 +354,122 @@ function pollUntilBack() {

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

-

A host-ügynök által észlelt meghajtók élő nézete (a tárolás végrehajtása az ügynöké).

+

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.

Betöltés…
+
diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 0de8efc..f7d854d 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3086,3 +3086,58 @@ a.stat-card:hover { border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; font-size: 0.8rem; } .btn-danger-outline:hover { background: var(--red-bg); } + +/* ============================================================================ + Agent drive lists (overview + init/attach selector) — storage authz redesign. + Reuses the existing card/badge/bar tokens; no new design system. + ============================================================================ */ +.drive-list { display: flex; flex-direction: column; gap: .6rem; margin-top: .25rem; } +.drive-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-left: 4px solid var(--gray, #6e7681); + border-radius: 12px; + padding: .85rem 1rem; + display: flex; flex-direction: column; gap: .55rem; +} +.drive-card.role-user-data { border-left-color: var(--accent-blue); } +.drive-card.role-system, +.drive-card.role-backup { border-left-color: var(--yellow); } +.drive-card.is-selectable { cursor: pointer; transition: border-color .15s, background .15s; } +.drive-card.is-selectable:hover { border-color: var(--accent-light); } +.drive-card.is-picked { border-color: var(--accent-light); background: rgba(0, 136, 204, 0.06); } +.drive-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; } +.drive-id { display: flex; flex-direction: column; gap: .15rem; min-width: 0; } +.drive-name { font-size: .95rem; font-weight: 600; color: var(--text-primary); } +.drive-sub { + font-size: .78rem; color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; word-break: break-all; +} +.drive-badges { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; justify-content: flex-end; } +.drive-cap { display: flex; flex-direction: column; gap: .3rem; } +.drive-cap .system-bar { height: 8px; } +.drive-cap-label { font-size: .75rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; } +.drive-actions { display: flex; flex-wrap: wrap; gap: .4rem; } +.drive-select { display: flex; align-items: center; gap: .5rem; } + +/* badges missing from the global sheet */ +.badge-ok { background: rgba(35, 134, 54, 0.18); color: #3fb950; } +.badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); } +.badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); } +.badge .lock-ico { margin-right: .25rem; } +span.mono, .mono { font-family: 'JetBrains Mono', monospace; } + +/* Type-to-confirm modal (destructive user-data eject/wipe) */ +.confirm-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem; +} +.confirm-box { + background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 14px; + max-width: 480px; width: 100%; padding: 1.4rem; box-shadow: 0 12px 40px rgba(0,0,0,.5); +} +.confirm-box h3 { margin: 0 0 .75rem; font-size: 1.05rem; } +.confirm-box .confirm-apps { margin: .5rem 0; padding-left: 1.1rem; } +.confirm-box .confirm-apps li { margin: .15rem 0; } +.confirm-box .confirm-input { margin: .9rem 0 .4rem; } +.confirm-box .form-actions { display: flex; gap: .6rem; margin-top: 1rem; }