f43697c881
POST /disks/decommission mirrors eject (withGuest, user-data role gate) — no operator signature, non-destructive (never formats): sets IntentDecommissioned, prunes the GuestBindStore entry, unmounts. ReassertGuestBinds is now intent-aware (skip non-enrolled) so a decommissioned-but-present drive never auto-rebinds on agent restart — the load-bearing F9-reconnect fix. GuestBindStore.Remove added. Operator-signed DecommissionExecutor + classify untouched. Non-hollow tests incl. the intent-aware reassert companion (mutation-proven to fail on intent-blind code). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
884 lines
41 KiB
Go
884 lines
41 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
// Disk management (slice 8C, doc 03 §6). The controller's disk-management UX stays in the
|
|
// controller; EXECUTION is the agent's. The security centerpiece: the agent decides
|
|
// data-bearing-ness by INSPECTING THE ACTUAL DEVICE (agent-internal evidence), never from the
|
|
// caller's claim — a compromised controller asserting "this drive is blank" cannot wipe a
|
|
// data-bearing drive. Benign ops (list/assign/eject/format-blank) execute self-serve; a
|
|
// data-bearing format is classified destructive → the gate refuses it `pending_signature` (the
|
|
// operator-signed completion is slice 10).
|
|
|
|
// DiskOps is the privileged host-storage surface the disk endpoints need. Satisfied by
|
|
// *storage.SudoHostOps. Optional — the endpoints report "not configured" when absent.
|
|
type DiskOps interface {
|
|
EnsureMount(ctx context.Context, spec storage.MountSpec) error
|
|
Unmount(ctx context.Context, where string) error
|
|
Format(ctx context.Context, device, fstype string) error
|
|
InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, error)
|
|
}
|
|
|
|
// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
|
|
// reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an
|
|
// adapter over reconcile.Gate in main.go.
|
|
// - user-data → customer-confirmable: allowed iff the request carries the customer's confirmation
|
|
// bound to the device's durable id (no operator signature).
|
|
// - system/backup → operator-signature only (unsigned → pending_signature; Confirmed is ignored).
|
|
type StorageGate interface {
|
|
AuthorizeWipe(req WipeRequest) WipeDecision
|
|
}
|
|
|
|
// WipeRequest is the inspection-derived input to the wipe gate. Role + DeviceDurableID are
|
|
// AGENT-INTERNAL (the agent classified the role and re-resolved the durable id); Confirmed +
|
|
// ConfirmDurableID are the caller's claim, honored ONLY for user-data and ONLY on a durable-id match.
|
|
type WipeRequest struct {
|
|
Role string // "system" | "backup" | "user-data" (agent-classified)
|
|
DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable)
|
|
Confirmed bool
|
|
ConfirmDurableID string
|
|
}
|
|
|
|
// WipeDecision is the gate's verdict.
|
|
type WipeDecision struct {
|
|
Allowed bool
|
|
Tier string // "customer_confirmable" | "destructive"
|
|
Reason string // machine reason (audit/UI)
|
|
NeedsConfirmation bool // user-data, not-yet-confirmed → ask the customer (NOT a signature)
|
|
}
|
|
|
|
// GuestLister lists the host's guests (to map a mount to the guests that depend on it for the
|
|
// eject warning). Satisfied by *proxmox.Client.
|
|
type GuestLister interface {
|
|
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
|
|
}
|
|
|
|
// GuestAttacher binds an enrolled user-data drive's felhom-data namespace into a guest as an RW bind
|
|
// mount (slice 10 P2, Model A) and reboots the guest to activate persisted-but-inactive binds (the
|
|
// host-side live inject is blocked on unprivileged guests). Satisfied by *GuestBinder.
|
|
type GuestAttacher interface {
|
|
AttachBind(ctx context.Context, vmid int, mountKey, where string) error
|
|
RebootGuest(ctx context.Context, vmid int) error
|
|
}
|
|
|
|
// IntentRecorder persists drive enroll/eject/decommission INTENT (slice 10 P3 self-heal), keyed by
|
|
// durable-id, so the watchdog reconciles only enrolled drives and respects an official eject /
|
|
// permanent decommission. Get lets the startup re-assert be intent-aware (B2 — skip non-enrolled).
|
|
// Satisfied by *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal
|
|
// is ungated and the re-assert falls back to legacy bind-all behavior).
|
|
type IntentRecorder interface {
|
|
SetEnrolled(durableID string) error
|
|
SetEjected(durableID string) error
|
|
SetDecommissioned(durableID string) error
|
|
Get(durableID string) storage.DriveIntent
|
|
}
|
|
|
|
// ---- handlers ---------------------------------------------------------------------------
|
|
|
|
// DiskInfo is one host drive with its data-bearing flag (for the UI).
|
|
type DiskInfo struct {
|
|
Name string `json:"name"` // PVE storage id
|
|
Type string `json:"type"` // local-dir | usb | lvmthin | …
|
|
State string `json:"state"` // attached | disconnected
|
|
BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
|
|
MountPath string `json:"mount_path"`
|
|
Class string `json:"class"` // fast | slow | ""
|
|
// Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from
|
|
// the agent's own storage view + host topology — never from the controller. The controller drives
|
|
// the UI from it: system/backup get a lock badge and NO destructive controls; user-data is
|
|
// customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless.
|
|
Role string `json:"role"`
|
|
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
|
|
DataReason string `json:"data_reason,omitempty"`
|
|
// Capacity (from the agent's storage view) — for the controller's capacity bar. 0 when unknown.
|
|
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:<fs-uuid>" for usb/local-dir). The
|
|
// controller strips the "uuid:" prefix to get the fs UUID it passes to POST /disks/assign —
|
|
// the only way the (de-privileged) controller can learn the mount key it cannot read itself.
|
|
DurableID string `json:"durable_id,omitempty"`
|
|
// WipeDurableID is the device's WIPE-binding durable id in the SAME scheme the format gate resolves
|
|
// against (byid:<wwn>/byuuid:<uuid>). F20-BUG2: the customer must confirm a data-bearing wipe with
|
|
// THIS id, not DurableID (uuid:) — passing the uuid: id was rejected as a binding_mismatch. "" when
|
|
// the device has no durable identity (a wipe of it can't be bound anyway). Distinct from DurableID
|
|
// (uuid:, used for /disks/assign) on purpose; both are derived from the agent's own device read.
|
|
WipeDurableID string `json:"wipe_durable_id,omitempty"`
|
|
// GuestAttached reports whether THIS guest (the token's vmid) actually has the drive's felhom-data
|
|
// namespace bound into its config — i.e. the drive is usable IN the guest, not merely present on the
|
|
// host. F9: host presence (State=attached) != guest-usable; this is the missing signal that made the
|
|
// HDD look available when it wasn't bound. Only meaningful for user-data drives.
|
|
GuestAttached bool `json:"guest_attached"`
|
|
}
|
|
|
|
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
|
|
func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.disks == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
|
|
return
|
|
}
|
|
targets, err := s.storage.Observe(r.Context())
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "could not read storage view")
|
|
return
|
|
}
|
|
// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
|
|
// (the agent's own mount/topology read, never the caller's claim).
|
|
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
|
|
// F9: which host mount paths are actually BOUND into THIS guest's config (guest-usable, not just
|
|
// host-present). A bind's mp= equals the guest path, which is the drive's host mount path (`where`).
|
|
boundPaths := s.guestBoundPaths(r.Context(), vmid)
|
|
out := make([]DiskInfo, 0, len(targets))
|
|
for _, t := range targets {
|
|
di := DiskInfo{
|
|
Name: t.Name, Type: t.Type, State: t.State,
|
|
BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint,
|
|
DurableID: t.DurableID,
|
|
Role: string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)),
|
|
TotalBytes: t.TotalBytes,
|
|
UsedBytes: t.UsedBytes,
|
|
UsedFraction: t.UsedFraction,
|
|
GuestAttached: t.MountPath != "" && boundPaths[t.MountPath],
|
|
}
|
|
// Inspect the backing device for the UI's data-bearing hint (the authoritative check
|
|
// is re-run at format time on the actual device).
|
|
if t.BackingDevice != "" {
|
|
if probe, perr := s.disks.InspectDevice(r.Context(), t.BackingDevice); perr == nil {
|
|
di.DataBearing = probe.DataBearing()
|
|
di.DataReason = probe.Reason()
|
|
} else {
|
|
di.DataBearing = true // fail-safe
|
|
di.DataReason = "could not inspect device"
|
|
}
|
|
// F20-BUG2: surface the gate-scheme wipe id (byid:/byuuid:) so a customer-confirmed wipe
|
|
// binds with the id the gate accepts. Same seam the gate uses → guaranteed to match.
|
|
if wid, werr := s.deviceDurableID(t.BackingDevice); werr == nil {
|
|
di.WipeDurableID = wid
|
|
}
|
|
}
|
|
out = append(out, di)
|
|
}
|
|
writeOK(w, map[string]any{"vmid": vmid, "disks": out})
|
|
}
|
|
|
|
type assignRequest struct {
|
|
VMID int `json:"vmid"`
|
|
UUID string `json:"uuid"`
|
|
Where string `json:"where"`
|
|
FSType string `json:"fstype"`
|
|
Options string `json:"options"`
|
|
}
|
|
|
|
// handleDiskAssign attaches a drive as a host mount (benign, additive → EnsureMount). Self-serve.
|
|
func (s *Server) handleDiskAssign(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.disks == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
|
|
return
|
|
}
|
|
var req assignRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
|
return
|
|
}
|
|
// EnsureMount validates uuid/where/fstype/options itself (storage/validate.go).
|
|
if err := s.disks.EnsureMount(r.Context(), storage.MountSpec{
|
|
Name: req.UUID, UUID: req.UUID, Where: req.Where, FSType: req.FSType, Options: req.Options,
|
|
}); err != nil {
|
|
s.logger.Error("local-api: disk assign", "vmid", vmid, "where", req.Where, "err", err)
|
|
writeErr(w, http.StatusBadRequest, "assign failed: "+err.Error())
|
|
return
|
|
}
|
|
writeOK(w, map[string]any{"vmid": vmid, "assigned": req.Where})
|
|
}
|
|
|
|
type ejectRequest struct {
|
|
VMID int `json:"vmid"`
|
|
Where string `json:"where"`
|
|
}
|
|
|
|
// handleDiskEject safe-unmounts a host mount (benign — data preserved, re-attachable) and returns
|
|
// the guests that depend on it so the controller can warn which apps lose that storage.
|
|
func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.disks == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
|
|
return
|
|
}
|
|
var req ejectRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Where) == "" {
|
|
writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
|
|
return
|
|
}
|
|
// ROLE GATE (defense in depth): eject is permitted ONLY for a user-data mount. The agent
|
|
// classifies the role of the storage at `where` from its OWN view — never the caller's claim —
|
|
// and refuses system/backup. The UI hiding the button is NOT the control: a direct API call (or a
|
|
// compromised controller) trying to unmount /var/lib/vz or the PBS mount is refused here. Fails
|
|
// SAFE: an unresolvable mount → protected → refused (most-protected-on-ambiguity, like the wipe).
|
|
if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
|
|
s.logger.Warn("local-api: protected — eject refused by role",
|
|
"vmid", vmid, "where", req.Where, "role", role)
|
|
writeErr(w, http.StatusForbidden, "mount is system/backup-protected — eject refused (role: "+string(role)+")")
|
|
return
|
|
}
|
|
dependents := s.dependentGuests(r.Context(), req.Where)
|
|
// Record the EJECT intent BEFORE unmounting (the target still resolves to its durable-id) so the
|
|
// self-heal watchdog leaves it alone — an OFFICIAL eject is the only thing that sets this (P3); an
|
|
// out-of-band unmount records nothing and is healed.
|
|
s.recordIntent(r.Context(), req.Where, "ejected")
|
|
if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
|
|
s.logger.Error("local-api: disk eject", "vmid", vmid, "where", req.Where, "err", err)
|
|
writeErr(w, http.StatusBadRequest, "eject failed: "+err.Error())
|
|
return
|
|
}
|
|
writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
|
|
}
|
|
|
|
// handleDiskDecommission is the SELF-SERVE, NON-DESTRUCTIVE permanent removal of a user-data drive
|
|
// (B2). It mirrors handleDiskEject EXACTLY — withGuest self-scoping, scopedFromBody, and the same
|
|
// user-data ROLE GATE (a system/backup mount is refused 403; fail-safe-to-protected on ambiguity) so
|
|
// a compromised controller can't decommission protected storage. Unlike the operator-signed
|
|
// DecommissionExecutor it needs no signature: it is the customer's own drive. It records a PERMANENT
|
|
// decommission intent (the self-heal watchdog + the intent-aware re-assert never auto-mount/re-bind it
|
|
// again), prunes the guest-bind record (hygiene), and unmounts so the drive is physically removable.
|
|
// It NEVER calls any format/mkfs path — the data stays on the drive.
|
|
func (s *Server) handleDiskDecommission(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.disks == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
|
|
return
|
|
}
|
|
var req ejectRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Where) == "" {
|
|
writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
|
|
return
|
|
}
|
|
// ROLE GATE (same as eject): user-data only. The agent classifies from its OWN view, never the
|
|
// caller's claim; an unresolvable mount fails safe to protected → refused.
|
|
if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
|
|
s.logger.Warn("local-api: protected — decommission refused by role",
|
|
"vmid", vmid, "where", req.Where, "role", role)
|
|
writeErr(w, http.StatusForbidden, "mount is system/backup-protected — decommission refused (role: "+string(role)+")")
|
|
return
|
|
}
|
|
dependents := s.dependentGuests(r.Context(), req.Where)
|
|
// Resolve the durable-id BEFORE unmounting (it still resolves while mounted) for the bind prune.
|
|
id := s.durableIDForMount(r.Context(), req.Where)
|
|
// Record the PERMANENT decommission intent first (self-heal never re-mounts it again).
|
|
s.recordIntent(r.Context(), req.Where, "decommissioned")
|
|
// Hygiene: drop the guest-bind record so the startup re-assert carries no stale id.
|
|
if id != "" && s.guestBinds != nil {
|
|
if err := s.guestBinds.Remove(vmid, id); err != nil {
|
|
s.logger.Warn("local-api: guest-bind remove failed", "vmid", vmid, "durable_id", id, "err", err)
|
|
}
|
|
}
|
|
// Unmount (mirror eject) — benign, data preserved. NEVER format/mkfs here.
|
|
if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
|
|
s.logger.Error("local-api: disk decommission", "vmid", vmid, "where", req.Where, "err", err)
|
|
writeErr(w, http.StatusBadRequest, "decommission failed: "+err.Error())
|
|
return
|
|
}
|
|
writeOK(w, map[string]any{"vmid": vmid, "decommissioned": req.Where, "dependent_guests": dependents})
|
|
}
|
|
|
|
type guestAttachRequest struct {
|
|
VMID int `json:"vmid"`
|
|
Where string `json:"where"` // the host mount path of the enrolled drive (e.g. /mnt/felhom-usb)
|
|
}
|
|
|
|
// handleDiskGuestAttach binds an enrolled user-data drive's felhom-data namespace into THIS guest as
|
|
// an RW bind mount (slice 10 P2, Model A). Self-scoped (the vmid is the token's). Idempotent: if a
|
|
// mountpoint already binds `where`, it returns the existing slot without re-attaching. The drive must
|
|
// already be mounted on the host at `where` (the enroll flow's assign did that) — this only adds the
|
|
// guest passthrough. The customer's non-felhom data on the drive is NOT exposed (only felhom-data).
|
|
func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.guestAttach == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
|
|
return
|
|
}
|
|
var req guestAttachRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
|
return
|
|
}
|
|
where := strings.TrimSpace(req.Where)
|
|
if !validGuestMountPath(where) {
|
|
writeErr(w, http.StatusBadRequest, "where must be an absolute /mnt/<name> path (no traversal)")
|
|
return
|
|
}
|
|
// Read the guest config for idempotency + free-slot selection.
|
|
cfg, err := s.guests.GuestConfig(r.Context(), vmid)
|
|
if err != nil {
|
|
s.logger.Error("local-api: guest-attach guest config", "vmid", vmid, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "could not read guest config")
|
|
return
|
|
}
|
|
mounts := cfg.MountPoints()
|
|
// Idempotency: already bound at `where`? (a bind's mp= equals the guest path). Still (re)record the
|
|
// ENROLLED intent — a drive bound at a prior boot must be known to self-heal even if never
|
|
// re-attached this process lifetime.
|
|
for key, spec := range mounts {
|
|
if _, mp, _ := parseMount(spec); mp == where {
|
|
s.recordIntent(r.Context(), where, "enrolled")
|
|
s.recordGuestBind(r.Context(), vmid, where)
|
|
s.logger.Info("local-api: guest-attach idempotent (already bound)", "vmid", vmid, "where", where, "slot", key)
|
|
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": key, "already": true})
|
|
return
|
|
}
|
|
}
|
|
slot, ok := freeMountSlot(mounts)
|
|
if !ok {
|
|
writeErr(w, http.StatusConflict, "no free mountpoint slot on the guest")
|
|
return
|
|
}
|
|
if err := s.guestAttach.AttachBind(r.Context(), vmid, slot, where); err != nil {
|
|
s.logger.Error("local-api: guest-attach", "vmid", vmid, "where", where, "slot", slot, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
|
|
return
|
|
}
|
|
// Record the drive as ENROLLED so the self-heal watchdog will reconcile it (P3), and persist the
|
|
// per-guest bind so the startup re-assert can restore it after a re-provision (F9).
|
|
s.recordIntent(r.Context(), where, "enrolled")
|
|
s.recordGuestBind(r.Context(), vmid, where)
|
|
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
|
|
}
|
|
|
|
type guestRebootRequest struct {
|
|
VMID int `json:"vmid"`
|
|
}
|
|
|
|
// handleGuestReboot reboots THIS guest (self-scoped) to activate persisted-but-inactive mountpoint
|
|
// binds (slice 10 P2 activation). It runs the reboot DETACHED and returns 202 immediately, so the
|
|
// calling controller gets a clean response before the reboot takes it (and the agent — host-side —
|
|
// survives the guest reboot). User-triggered ("Újraindítás most"); the controller batches all pending
|
|
// drives into one restart.
|
|
func (s *Server) handleGuestReboot(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.guestAttach == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
|
|
return
|
|
}
|
|
if r.ContentLength != 0 {
|
|
var req guestRebootRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
|
return
|
|
}
|
|
}
|
|
base := s.baseCtx
|
|
if base == nil {
|
|
base = context.Background()
|
|
}
|
|
go func() {
|
|
// Detached: pct reboot blocks ~30s until the guest is back; don't tie it to the request ctx.
|
|
rebootCtx, cancel := context.WithTimeout(base, 5*time.Minute)
|
|
defer cancel()
|
|
if err := s.guestAttach.RebootGuest(rebootCtx, vmid); err != nil {
|
|
s.logger.Error("local-api: guest-reboot failed", "vmid", vmid, "err", err)
|
|
}
|
|
}()
|
|
s.logger.Warn("local-api: guest reboot requested (activating pending drive binds)", "vmid", vmid)
|
|
writeStatus(w, http.StatusAccepted, true, map[string]any{"vmid": vmid, "rebooting": true}, "")
|
|
}
|
|
|
|
// validGuestMountPath accepts an absolute /mnt/<name> path with no traversal (the enroll convention
|
|
// root). Mirrors the controller's mount-name discipline so a hostile `where` can't escape /mnt.
|
|
func validGuestMountPath(p string) bool {
|
|
if !strings.HasPrefix(p, "/mnt/") || strings.Contains(p, "..") {
|
|
return false
|
|
}
|
|
rest := strings.TrimPrefix(p, "/mnt/")
|
|
if rest == "" || strings.ContainsAny(rest, "/ \t") {
|
|
return false // exactly one path component under /mnt
|
|
}
|
|
for _, c := range rest {
|
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// freeMountSlot returns the lowest mpN (0..255) not present in the guest's current mountpoints. The
|
|
// bootstrap mount (mp9) and any existing data mounts are already in `mounts`, so they're skipped.
|
|
func freeMountSlot(mounts map[string]string) (string, bool) {
|
|
for i := 0; i <= 255; i++ {
|
|
key := "mp" + strconv.Itoa(i)
|
|
if _, used := mounts[key]; !used {
|
|
return key, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
type formatRequest struct {
|
|
VMID int `json:"vmid"`
|
|
Device string `json:"device"`
|
|
FSType string `json:"fstype"`
|
|
// Confirmed + DurableID authorize a USER-DATA data-bearing wipe by the customer's informed-
|
|
// confirmation bound to the device's durable id. The agent RE-RESOLVES the device's durable id
|
|
// and matches it against DurableID — a confirmation for one disk can't wipe another. Both are
|
|
// INERT for system/backup devices (those stay operator-signature only — the role is the agent's,
|
|
// never the caller's, so confirmed:true on a system device is refused).
|
|
Confirmed bool `json:"confirmed"`
|
|
DurableID string `json:"durable_id"`
|
|
// NOTE: any caller-supplied "blank"/"force" claim is still deliberately IGNORED — the agent
|
|
// inspects the device itself (8C invariant).
|
|
}
|
|
|
|
// FormatResponse is POST /disks/format. On a data-bearing refusal (slice 10B) it SURFACES the
|
|
// bound op the operator must sign: the op class + the DURABLE device id (not the mutable path) +
|
|
// the fstype — so the operator can `felhom-opsign -op storage_wipe -durable-id <…>` offline, the
|
|
// hub queues it, and the agent's signed-jobs runner verifies + executes the wipe (re-resolving the
|
|
// durable id). This is the "records/reports the bound op intent so the operator sees what to sign".
|
|
type FormatResponse struct {
|
|
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 authoritative tier for the device (system | backup | user-data).
|
|
Role string `json:"role,omitempty"`
|
|
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: the customer must re-submit with
|
|
// confirmed:true + DurableID (below) after the controller's type-to-confirm UI. NOT an operator
|
|
// signature — the customer authorizes the wipe of their own data drive.
|
|
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
|
|
DurableID string `json:"durable_id,omitempty"` // the durable id to confirm against (user-data)
|
|
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the exact op the operator must sign.
|
|
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
|
}
|
|
|
|
// PendingOp is the bound destructive intent the operator must sign offline (slice 10B). Params bind
|
|
// to the DURABLE device id so the signed authorization can't be retargeted to another disk.
|
|
type PendingOp struct {
|
|
Op string `json:"op"` // e.g. "storage_wipe"
|
|
HostScope string `json:"host_scope"` // the agent's host id (anti-retarget target)
|
|
DurableID string `json:"durable_id"` // byid:…|byuuid:… — the device's stable identity
|
|
FSType string `json:"fstype"` // the filesystem to mkfs after the wipe
|
|
}
|
|
|
|
// errFormatClientGone signals the request context was cancelled (client/controller deadline) while the
|
|
// detached mkfs keeps running — the handler returns without writing; the job record records the outcome.
|
|
var errFormatClientGone = fmt.Errorf("format client gone (mkfs continues detached)")
|
|
|
|
// awaitFormat waits for the detached mkfs result, OR returns errFormatClientGone if the request context
|
|
// is cancelled first. Crucially the mkfs itself runs off s.baseCtx, so a cancelled request never kills it
|
|
// (F20-BUG3) — abandoning the wait here only abandons the HTTP response, not the format.
|
|
func (s *Server) awaitFormat(reqCtx context.Context, done <-chan error, vmid int, device string) error {
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-reqCtx.Done():
|
|
s.logger.Warn("local-api: format client disconnected — mkfs continues detached (poll GET /disks/format/status)",
|
|
"vmid", vmid, "device", device)
|
|
return errFormatClientGone
|
|
}
|
|
}
|
|
|
|
// handleDiskFormatStatus reports the most-recent/in-flight format job (F20-BUG3), so a controller whose
|
|
// request timed out (or that reconnects after an agent restart) can learn the real outcome instead of
|
|
// assuming failure. Self-scoped (benign read).
|
|
func (s *Server) handleDiskFormatStatus(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.formatJobs == nil {
|
|
writeOK(w, map[string]any{"vmid": vmid, "phase": "idle"})
|
|
return
|
|
}
|
|
job := s.formatJobs.get()
|
|
if job == nil {
|
|
writeOK(w, map[string]any{"vmid": vmid, "phase": "idle"})
|
|
return
|
|
}
|
|
writeOK(w, map[string]any{
|
|
"vmid": vmid, "phase": job.Phase, "device": job.Device, "fstype": job.FSType,
|
|
"durable_id": job.DurableID, "error": job.Error, "started_at": job.StartedAt, "updated_at": job.UpdatedAt,
|
|
"job_id": job.JobID,
|
|
})
|
|
}
|
|
|
|
// handleDiskFormat is the security centerpiece. The agent INSPECTS the device; if it is
|
|
// data-bearing it is classified destructive and the gate refuses it `pending_signature` — the
|
|
// caller's claim is never trusted. Only a device the agent itself reads as blank is formatted.
|
|
func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid int) {
|
|
if s.disks == nil || s.diskGate == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
|
|
return
|
|
}
|
|
var req formatRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
|
return
|
|
}
|
|
if err := storage.ValidateBlockDevice(req.Device); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := storage.ValidateFSType(req.FSType); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// AGENT-INTERNAL device inspection — NEVER the caller's claim.
|
|
probe, err := s.disks.InspectDevice(r.Context(), req.Device)
|
|
if err != nil {
|
|
s.logger.Error("local-api: format device inspect", "device", req.Device, "err", err)
|
|
// inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
|
|
}
|
|
if !probe.DataBearing() {
|
|
// Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy). F20-BUG3: run
|
|
// it DETACHED off s.baseCtx so a request/client deadline can't SIGKILL mkfs mid-write; we still
|
|
// wait here to return the synchronous result (backward-compatible with the controller's client).
|
|
done := s.startFormatDetached(req.Device, "", req.FSType)
|
|
if err := s.awaitFormat(r.Context(), done, vmid, req.Device); err != nil {
|
|
if err == errFormatClientGone {
|
|
return // client gone; mkfs continues detached + the job record records the outcome
|
|
}
|
|
s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
|
|
return
|
|
}
|
|
writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
|
|
return
|
|
}
|
|
|
|
// Data-bearing → TIER by the agent's authoritative role classification (its own inspection,
|
|
// never the caller's claim). The agent also re-resolves the device's durable id; the customer's
|
|
// confirmation must bind to it.
|
|
role := s.deviceRole(r.Context(), req.Device)
|
|
deviceDurable, derr := s.deviceDurableID(req.Device)
|
|
if derr != nil {
|
|
deviceDurable = "" // refusal still stands; binding/pending-op just lack the id
|
|
}
|
|
dec := s.diskGate.AuthorizeWipe(WipeRequest{
|
|
Role: string(role), DeviceDurableID: deviceDurable,
|
|
Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID,
|
|
})
|
|
|
|
if dec.Allowed {
|
|
// USER-DATA, customer-confirmed (durable-id-bound). The gate already AUDITED it.
|
|
// [AGENT-001] anti-retarget: re-resolve the confirmed durable id to the CURRENT
|
|
// device, require the re-derived id to match, and confirm it is still
|
|
// data-bearing — then format THAT device, never the mutable req.Device path.
|
|
// This closes the classify→mkfs TOCTOU (a /dev reassignment in the window could
|
|
// otherwise wipe a different physical disk). Mirrors signedjobs.WipeExecutor.
|
|
device, rerr := s.reresolveWipe(r.Context(), deviceDurable)
|
|
if rerr != nil {
|
|
s.logger.Warn("local-api: customer-confirmed wipe REFUSED at anti-retarget re-resolve",
|
|
"vmid", vmid, "req_device", req.Device, "durable_id", deviceDurable, "err", rerr)
|
|
writeStatus(w, http.StatusConflict, false,
|
|
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true,
|
|
Role: string(role), DurableID: deviceDurable, Reason: probe.Reason()},
|
|
"wipe refused (device may have changed since confirmation): "+rerr.Error())
|
|
return
|
|
}
|
|
// F20-BUG3: run the destructive mkfs DETACHED off s.baseCtx (bound durable id recorded for
|
|
// restart-recovery), so a request/client deadline can never SIGKILL it mid-write and corrupt the
|
|
// disk. We still wait to return the synchronous result (backward-compatible with the controller).
|
|
done := s.startFormatDetached(device, deviceDurable, req.FSType)
|
|
if err := s.awaitFormat(r.Context(), done, vmid, device); err != nil {
|
|
if err == errFormatClientGone {
|
|
return // client gone; the wipe continues detached + survives a restart via the job record
|
|
}
|
|
s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", device, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Warn("local-api: USER-DATA data-bearing format — CUSTOMER CONFIRMED (no operator signature)",
|
|
"vmid", vmid, "device", device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason())
|
|
writeOK(w, FormatResponse{VMID: vmid, Device: device, Formatted: true, DataBearing: true,
|
|
Role: string(role), DurableID: deviceDurable, Reason: "customer-confirmed wipe (" + probe.Reason() + ")"})
|
|
return
|
|
}
|
|
|
|
if dec.Tier == "customer_confirmable" {
|
|
// USER-DATA refusal: either awaiting the customer's confirmation, or the confirmation didn't
|
|
// bind to THIS device. Surface the durable id to confirm against — NOT an operator signature.
|
|
msg := "device is data-bearing — customer confirmation required"
|
|
if !dec.NeedsConfirmation {
|
|
msg = "confirmation does not match this device — refused (" + dec.Reason + ")"
|
|
}
|
|
s.logger.Warn("local-api: user-data data-bearing format refused",
|
|
"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "reason", dec.Reason, "why", probe.Reason())
|
|
writeStatus(w, http.StatusForbidden, false,
|
|
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role),
|
|
NeedsConfirmation: dec.NeedsConfirmation, DurableID: deviceDurable, Reason: probe.Reason()}, msg)
|
|
return
|
|
}
|
|
|
|
// SYSTEM / BACKUP refusal: operator signature required. Surface the bound op to sign (the durable
|
|
// id binds the signed wipe to THIS exact physical disk). confirmed:true was ignored — by role.
|
|
var pending *PendingOp
|
|
if deviceDurable != "" {
|
|
pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: deviceDurable, FSType: req.FSType}
|
|
s.logger.Warn("local-api: protected (system/backup) data-bearing format refused — PENDING OPERATOR SIGNATURE",
|
|
"vmid", vmid, "device", req.Device, "role", role, "durable_id", deviceDurable, "fstype", req.FSType,
|
|
"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+deviceDurable)
|
|
} else {
|
|
s.logger.Warn("local-api: protected data-bearing format refused (no durable id)",
|
|
"vmid", vmid, "device", req.Device, "role", role, "why", probe.Reason())
|
|
}
|
|
writeStatus(w, http.StatusForbidden, false,
|
|
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), PendingOp: pending, Reason: probe.Reason()},
|
|
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
|
|
}
|
|
|
|
// guestBoundPaths returns the set of guest mountpoint paths (the `mp=` of each entry in the guest's
|
|
// config) — i.e. the host drives actually BOUND into the guest. F9: this is the guest-attached signal
|
|
// (`GuestAttached`) that distinguishes a guest-usable drive from one merely present on the host. A bind
|
|
// created by AttachBind has `mp=<where>` where `where` is the drive's host mount path, so a storage
|
|
// target is guest-attached iff its MountPath is in this set. Best-effort: a config-read error yields an
|
|
// empty set (reported as not-attached — the safe direction).
|
|
func (s *Server) guestBoundPaths(ctx context.Context, vmid int) map[string]bool {
|
|
out := map[string]bool{}
|
|
if s.guests == nil {
|
|
return out
|
|
}
|
|
cfg, err := s.guests.GuestConfig(ctx, vmid)
|
|
if err != nil {
|
|
s.logger.Warn("local-api: guest-attached check — could not read guest config", "vmid", vmid, "err", err)
|
|
return out
|
|
}
|
|
for _, spec := range cfg.MountPoints() {
|
|
if _, mp, _ := parseMount(spec); mp != "" {
|
|
out[mp] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// recordGuestBind persists that the drive at `where` (by its durable-id) is enrolled into `vmid`, so the
|
|
// startup re-assert (ReassertGuestBinds) can restore the bind after a re-provision (F9). Best-effort.
|
|
func (s *Server) recordGuestBind(ctx context.Context, vmid int, where string) {
|
|
if s.guestBinds == nil {
|
|
return
|
|
}
|
|
id := s.durableIDForMount(ctx, where)
|
|
if id == "" {
|
|
s.logger.Warn("local-api: guest-bind not recorded — durable-id unresolved", "vmid", vmid, "where", where)
|
|
return
|
|
}
|
|
if err := s.guestBinds.Record(vmid, id); err != nil {
|
|
s.logger.Warn("local-api: guest-bind record failed", "vmid", vmid, "where", where, "durable_id", id, "err", err)
|
|
return
|
|
}
|
|
s.logger.Info("local-api: guest-bind recorded for startup re-assert", "vmid", vmid, "where", where, "durable_id", id)
|
|
}
|
|
|
|
// ReassertGuestBinds re-adds, on agent startup (the host's bring-up/reconcile trigger), any enrolled
|
|
// user-data drive bind a guest is MISSING from its config (F9 — a re-provision drops the mp, and nothing
|
|
// previously restored it). For each recorded (vmid, durable-id): only when the durable-id STILL resolves
|
|
// to a present, mounted drive AND the guest lacks the bind, it re-runs AttachBind. "On durable-id match"
|
|
// — a swapped or absent drive is never auto-bound. The re-added bind is config state; it activates on the
|
|
// guest's next reboot (logged), exactly like the enroll flow. Safe to call repeatedly (idempotent).
|
|
func (s *Server) ReassertGuestBinds(ctx context.Context) {
|
|
if s.guestBinds == nil || s.guestAttach == nil || s.guests == nil {
|
|
return
|
|
}
|
|
// durable-id -> current host mount path (present drives only), from the agent's own storage view.
|
|
mountByDurable := map[string]string{}
|
|
if targets, err := s.storage.Observe(ctx); err == nil {
|
|
for _, t := range targets {
|
|
if t.DurableID != "" && t.MountPath != "" {
|
|
mountByDurable[t.DurableID] = t.MountPath
|
|
}
|
|
}
|
|
} else {
|
|
s.logger.Warn("F9 re-assert: storage view unavailable — skipping", "err", err)
|
|
return
|
|
}
|
|
for vmid, ids := range s.guestBinds.Guests() {
|
|
cfg, err := s.guests.GuestConfig(ctx, vmid)
|
|
if err != nil {
|
|
s.logger.Warn("F9 re-assert: skip guest (config read failed)", "vmid", vmid, "err", err)
|
|
continue
|
|
}
|
|
mounts := cfg.MountPoints()
|
|
boundPaths := map[string]bool{}
|
|
for _, spec := range mounts {
|
|
if _, mp, _ := parseMount(spec); mp != "" {
|
|
boundPaths[mp] = true
|
|
}
|
|
}
|
|
for _, id := range ids {
|
|
// Intent-aware (B2, the load-bearing correctness fix): NEVER re-bind a drive that is not
|
|
// currently `enrolled` — an ejected or decommissioned drive must not auto-rebind into the
|
|
// guest on agent restart, even if it is still host-mounted. Covers both the self-serve and
|
|
// the operator-signed decommission paths (both land on IntentDecommissioned). A nil intent
|
|
// store falls back to legacy bind-all (ungated), matching the watchdog's nil-intent rule.
|
|
if s.intent != nil && s.intent.Get(id) != storage.IntentEnrolled {
|
|
s.logger.Warn("F9 re-assert: skipping non-enrolled drive (intent-gated)", "vmid", vmid, "durable_id", id, "intent", string(s.intent.Get(id)))
|
|
continue
|
|
}
|
|
where, present := mountByDurable[id]
|
|
if !present {
|
|
s.logger.Warn("F9 re-assert: enrolled drive not present (durable-id absent) — skipping", "vmid", vmid, "durable_id", id)
|
|
continue
|
|
}
|
|
if boundPaths[where] {
|
|
continue // already bound — nothing to re-assert
|
|
}
|
|
slot, ok := freeMountSlot(mounts)
|
|
if !ok {
|
|
s.logger.Warn("F9 re-assert: no free mountpoint slot on guest", "vmid", vmid, "where", where)
|
|
continue
|
|
}
|
|
if err := s.guestAttach.AttachBind(ctx, vmid, slot, where); err != nil {
|
|
s.logger.Error("F9 re-assert: AttachBind failed", "vmid", vmid, "where", where, "slot", slot, "err", err)
|
|
continue
|
|
}
|
|
mounts[slot] = where // reserve the slot so a second enrolled drive takes the next one
|
|
s.logger.Warn("F9 re-assert: re-attached enrolled drive into guest config (reboot to activate)",
|
|
"vmid", vmid, "where", where, "slot", slot, "durable_id", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own
|
|
// storage view) — the key the intent store records enroll/eject against. "" if not resolvable.
|
|
func (s *Server) durableIDForMount(ctx context.Context, where string) string {
|
|
targets, err := s.storage.Observe(ctx)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, t := range targets {
|
|
if t.MountPath == where {
|
|
return t.DurableID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// recordIntent records enroll/eject intent for the drive at `where`, best-effort (a nil store, an
|
|
// unresolved durable-id, or a write error is logged, never fatal — intent is a self-heal aid, not a
|
|
// gate on the user's action). `action` is "enrolled" or "ejected".
|
|
func (s *Server) recordIntent(ctx context.Context, where, action string) {
|
|
if s.intent == nil {
|
|
return
|
|
}
|
|
id := s.durableIDForMount(ctx, where)
|
|
if id == "" {
|
|
s.logger.Warn("local-api: intent not recorded — durable-id unresolved", "where", where, "action", action)
|
|
return
|
|
}
|
|
var err error
|
|
switch action {
|
|
case "enrolled":
|
|
err = s.intent.SetEnrolled(id)
|
|
case "ejected":
|
|
err = s.intent.SetEjected(id)
|
|
case "decommissioned":
|
|
err = s.intent.SetDecommissioned(id)
|
|
}
|
|
if err != nil {
|
|
s.logger.Warn("local-api: intent record failed", "where", where, "action", action, "durable_id", id, "err", err)
|
|
return
|
|
}
|
|
s.logger.Info("local-api: drive intent recorded", "where", where, "action", action, "durable_id", id)
|
|
}
|
|
|
|
// hostReader returns the injected root-free host topology reader, or the production default. The seam
|
|
// keeps the role classification (SystemDisks) testable without touching the real /proc /dev /sys.
|
|
func (s *Server) hostReader() storage.HostReader {
|
|
if s.host != nil {
|
|
return s.host
|
|
}
|
|
return storage.NewProcHostReader()
|
|
}
|
|
|
|
// roleForMountPath resolves the AUTHORITATIVE protection role of the storage mounted at `where`, from
|
|
// the agent's OWN storage view + host topology (never the caller's claim). It mirrors deviceRole but
|
|
// keys on the mount path (the eject input). It FAILS SAFE to system (most-protected) on any
|
|
// ambiguity — a view error, or no storage target found at `where` — so an unresolvable eject is
|
|
// refused rather than silently unmounted.
|
|
func (s *Server) roleForMountPath(ctx context.Context, where string) storage.DeviceRole {
|
|
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
|
|
targets, err := s.storage.Observe(ctx)
|
|
if err != nil {
|
|
return storage.RoleSystem // can't read the view → treat as protected
|
|
}
|
|
for _, t := range targets {
|
|
if t.MountPath == where {
|
|
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
|
|
}
|
|
}
|
|
return storage.RoleSystem // no storage target at this mount → fail safe to protected
|
|
}
|
|
|
|
// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's
|
|
// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification
|
|
// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity.
|
|
func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole {
|
|
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
|
|
if targets, err := s.storage.Observe(ctx); err == nil {
|
|
for _, t := range targets {
|
|
if t.BackingDevice != "" && t.BackingDevice == device {
|
|
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
|
|
}
|
|
}
|
|
}
|
|
return storage.RoleForRawDevice(device, sysDisks, sysKnown)
|
|
}
|
|
|
|
// dependentGuests returns the VMIDs whose config has a mount whose storage backs the ejected
|
|
// mount path — best-effort (a scan failure yields an empty list; the eject still proceeds).
|
|
func (s *Server) dependentGuests(ctx context.Context, where string) []int {
|
|
if s.guestList == nil {
|
|
return nil
|
|
}
|
|
guests, err := s.guestList.ListLXC(ctx)
|
|
if err != nil {
|
|
s.logger.Warn("local-api: eject dependent-scan: list guests", "err", err)
|
|
return nil
|
|
}
|
|
// Map each storage id whose mount path == `where` (from the storage view) → dependents.
|
|
storeForPath := map[string]bool{}
|
|
if targets, err := s.storage.Observe(ctx); err == nil {
|
|
for _, t := range targets {
|
|
if t.MountPath == where {
|
|
storeForPath[t.Name] = true
|
|
}
|
|
}
|
|
}
|
|
var out []int
|
|
for _, g := range guests {
|
|
cfg, err := s.guests.GuestConfig(ctx, g.VMID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, mp := range cfg.MountPoints() {
|
|
store, mpPath, _ := parseMount(mp)
|
|
if storeForPath[store] || mpPath == where {
|
|
out = append(out, g.VMID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|