slice 8C Phase A: agent disk endpoints + data-bearing classifier gate + mkfs (v0.12.0)
internal/storage: mkfs executor (Format, device-pinned, narrow FELHOM_FORMAT sudoers) + data-bearing device inspection (InspectDevice/DeviceProbe via blkid+lsblk; conservative — ambiguous=data-bearing). internal/localapi: /disks (+ data-bearing flag), /disks/assign (EnsureMount), /disks/eject (Unmount + dependent guests), /disks/format. SECURITY CENTERPIECE: the agent inspects the device itself; data-bearing format -> ClassStorageWipe gate -> pending_signature refused; the caller's claim is never trusted. Additive (no controller change yet). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
// slice-4 reversibility gate. Satisfied by an adapter over reconcile.Gate in main.go. In 8C an
|
||||
// unsigned destructive op returns (false, "pending_signature"); the signed path is slice 10.
|
||||
type StorageGate interface {
|
||||
AuthorizeWipe(device string) (allowed bool, reason string)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ---- 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 | ""
|
||||
DataBearing bool `json:"data_bearing"` // agent device-inspection verdict (UI hint)
|
||||
DataReason string `json:"data_reason,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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,
|
||||
}
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
dependents := s.dependentGuests(r.Context(), req.Where)
|
||||
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})
|
||||
}
|
||||
|
||||
type formatRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
// NOTE: any caller-supplied "blank"/"force" claim is deliberately IGNORED — the agent
|
||||
// inspects the device itself (8C invariant).
|
||||
}
|
||||
|
||||
// FormatResponse is POST /disks/format.
|
||||
type FormatResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Device string `json:"device"`
|
||||
Formatted bool `json:"formatted"`
|
||||
DataBearing bool `json:"data_bearing"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// Destructive: route through the gate. With no operator signature (8C) → pending_signature.
|
||||
allowed, reason := s.diskGate.AuthorizeWipe(req.Device)
|
||||
s.logger.Warn("local-api: refusing format of a data-bearing device",
|
||||
"vmid", vmid, "device", req.Device, "why", probe.Reason(), "gate", reason)
|
||||
if !allowed {
|
||||
writeStatus(w, http.StatusForbidden, false,
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Reason: probe.Reason()},
|
||||
"device is data-bearing — format requires operator authorization ("+reason+")")
|
||||
return
|
||||
}
|
||||
// A signed completion would land here in slice 10; 8C never reaches it (gate refuses unsigned).
|
||||
writeErr(w, http.StatusForbidden, "data-bearing format is not supported in this slice")
|
||||
return
|
||||
}
|
||||
|
||||
// Blank device → benign → mkfs.
|
||||
if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// ---- fakes ------------------------------------------------------------------------------
|
||||
|
||||
type fakeDiskOps struct {
|
||||
mu sync.Mutex
|
||||
probe storage.DeviceProbe // returned by InspectDevice (Device filled per call)
|
||||
inspectErr error
|
||||
formatCalls []string
|
||||
mountCalls []storage.MountSpec
|
||||
unmountCalls []string
|
||||
}
|
||||
|
||||
func (f *fakeDiskOps) InspectDevice(_ context.Context, device string) (storage.DeviceProbe, error) {
|
||||
p := f.probe
|
||||
p.Device = device
|
||||
return p, f.inspectErr
|
||||
}
|
||||
func (f *fakeDiskOps) Format(_ context.Context, device, _ string) error {
|
||||
f.mu.Lock()
|
||||
f.formatCalls = append(f.formatCalls, device)
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDiskOps) EnsureMount(_ context.Context, spec storage.MountSpec) error {
|
||||
f.mu.Lock()
|
||||
f.mountCalls = append(f.mountCalls, spec)
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDiskOps) Unmount(_ context.Context, where string) error {
|
||||
f.mu.Lock()
|
||||
f.unmountCalls = append(f.unmountCalls, where)
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) }
|
||||
|
||||
type fakeGate struct {
|
||||
allowed bool
|
||||
reason string
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (g *fakeGate) AuthorizeWipe(device string) (bool, string) {
|
||||
g.calls = append(g.calls, device)
|
||||
return g.allowed, g.reason
|
||||
}
|
||||
|
||||
type fakeGuestList struct{ guests []proxmox.Guest }
|
||||
|
||||
func (f fakeGuestList) ListLXC(context.Context) ([]proxmox.Guest, error) { return f.guests, nil }
|
||||
|
||||
// newDiskServer builds a server wired with the 8C disk deps (token A → guest 8200).
|
||||
func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl GuestLister) http.Handler {
|
||||
t.Helper()
|
||||
if sv == nil {
|
||||
sv = fakeStorage{}
|
||||
}
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Guests: &fakeGuests{},
|
||||
Backups: &fakeBackups{},
|
||||
Store: &fakeStore{},
|
||||
Storage: sv,
|
||||
Tokens: staticTokens{"A": 8200, "B": 9300},
|
||||
Disks: d,
|
||||
DiskGate: g,
|
||||
Guests2: gl,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
return srv.Handler()
|
||||
}
|
||||
|
||||
// ---- the security centerpiece -----------------------------------------------------------
|
||||
|
||||
// A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted.
|
||||
func TestFormat_BlankDevice_Formats(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set
|
||||
g := &fakeGate{allowed: false, reason: "pending_signature"}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("blank format: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
|
||||
t.Fatalf("mkfs not called for blank device: %v", got)
|
||||
}
|
||||
if len(g.calls) != 0 {
|
||||
t.Fatal("gate was consulted for a blank-device format (should be benign)")
|
||||
}
|
||||
}
|
||||
|
||||
// THE HEADLINE 8C TEST: a caller asks to format a DATA-BEARING device. The agent inspects the
|
||||
// device itself, classifies it destructive, the gate refuses pending_signature, and **mkfs is
|
||||
// NEVER called** — the caller's intent cannot wipe data-bearing storage.
|
||||
func TestFormat_DataBearingDevice_RefusedNoMkfs(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
g := &fakeGate{allowed: false, reason: "pending_signature"}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("data-bearing format: got %d want 403", w.Code)
|
||||
}
|
||||
if got := d.formatted(); len(got) != 0 {
|
||||
t.Fatalf("mkfs WAS called on a data-bearing device — security invariant violated: %v", got)
|
||||
}
|
||||
if len(g.calls) != 1 || g.calls[0] != "/dev/sdb" {
|
||||
t.Fatalf("gate not consulted for the destructive format: %v", g.calls)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "operator authorization") {
|
||||
t.Fatalf("response did not signal operator-authorization needed: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as
|
||||
// data-bearing → refused, mkfs not called.
|
||||
func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true
|
||||
g := &fakeGate{allowed: false, reason: "pending_signature"}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("ambiguous device: got %d want 403", w.Code)
|
||||
}
|
||||
if got := d.formatted(); len(got) != 0 {
|
||||
t.Fatalf("mkfs called on an unprobed device: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Even a (hypothetical) gate that ALLOWS does not format a data-bearing device in 8C (the signed
|
||||
// completion is slice 10).
|
||||
func TestFormat_DataBearing_GateAllows_StillNoMkfsIn8C(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasPartitionTable: true}}
|
||||
g := &fakeGate{allowed: true, reason: "signed"}
|
||||
h := newDiskServer(t, d, g, nil, nil)
|
||||
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d want 403 (8C never formats data-bearing)", w.Code)
|
||||
}
|
||||
if len(d.formatted()) != 0 {
|
||||
t.Fatal("mkfs called on a data-bearing device even though 8C must refuse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
|
||||
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
|
||||
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/../etc","fstype":"ext4"}`); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad device: got %d want 400", w.Code)
|
||||
}
|
||||
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ntfs"}`); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad fstype: got %d want 400", w.Code)
|
||||
}
|
||||
if len(d.formatted()) != 0 {
|
||||
t.Fatal("formatted despite invalid input")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- assign / eject ---------------------------------------------------------------------
|
||||
|
||||
func TestAssign_EnsureMount(t *testing.T) {
|
||||
d := &fakeDiskOps{}
|
||||
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
|
||||
w := do(t, h, "POST", "/disks/assign", "A", `{"uuid":"1234-ABCD","where":"/mnt/data","fstype":"ext4"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("assign: got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if len(d.mountCalls) != 1 || d.mountCalls[0].Where != "/mnt/data" || d.mountCalls[0].UUID != "1234-ABCD" {
|
||||
t.Fatalf("EnsureMount not called correctly: %+v", d.mountCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEject_UnmountAndDependents(t *testing.T) {
|
||||
d := &fakeDiskOps{}
|
||||
// storage view: target "bulk" is mounted at /mnt/bulk
|
||||
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "bulk", MountPath: "/mnt/bulk"}}}
|
||||
// guest 8200 mounts storage "bulk"; guest 9300 does not
|
||||
gl := fakeGuestList{guests: []proxmox.Guest{{VMID: 8200}, {VMID: 9300}}}
|
||||
h := newDiskServerWithGuestConfigs(t, d, sv, gl, map[int]map[string]string{
|
||||
8200: {"mp0": "bulk:200,mp=/mnt/media,backup=0"},
|
||||
9300: {"mp0": "local-lvm:8,mp=/var,backup=1"},
|
||||
})
|
||||
|
||||
w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/bulk"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("eject: got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
d.mu.Lock()
|
||||
unmounts := append([]string(nil), d.unmountCalls...)
|
||||
d.mu.Unlock()
|
||||
if len(unmounts) != 1 || unmounts[0] != "/mnt/bulk" {
|
||||
t.Fatalf("Unmount not called: %v", unmounts)
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
DependentGuests []int `json:"dependent_guests"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
found := false
|
||||
for _, v := range resp.Data.DependentGuests {
|
||||
if v == 8200 {
|
||||
found = true
|
||||
}
|
||||
if v == 9300 {
|
||||
t.Fatal("9300 listed as dependent but it does not mount bulk")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("dependent guest 8200 not reported: %v", resp.Data.DependentGuests)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- auth / config ----------------------------------------------------------------------
|
||||
|
||||
func TestDisks_CrossGuest403(t *testing.T) {
|
||||
h := newDiskServer(t, &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}, &fakeGate{}, nil, nil)
|
||||
if w := do(t, h, "GET", "/disks?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest /disks: got %d want 403", w.Code)
|
||||
}
|
||||
if w := do(t, h, "POST", "/disks/format", "A", `{"vmid":9300,"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest format: got %d want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisks_NotConfigured(t *testing.T) {
|
||||
// a server with no disk deps → 503 on disk endpoints
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{},
|
||||
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200},
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
h := srv.Handler()
|
||||
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("unconfigured format: got %d want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------------------
|
||||
|
||||
// newDiskServerWithGuestConfigs is like newDiskServer but with a fakeGuests that returns the given
|
||||
// per-vmid mount maps (so eject's dependent-scan can resolve).
|
||||
func newDiskServerWithGuestConfigs(t *testing.T, d *fakeDiskOps, sv StorageView, gl GuestLister, mounts map[int]map[string]string) http.Handler {
|
||||
t.Helper()
|
||||
fg := &fakeGuestsCfg{mounts: mounts}
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
|
||||
Storage: sv, Tokens: staticTokens{"A": 8200, "B": 9300},
|
||||
Disks: d, DiskGate: &fakeGate{}, Guests2: gl,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
return srv.Handler()
|
||||
}
|
||||
|
||||
// fakeGuestsCfg returns per-vmid mountpoints from GuestConfig.
|
||||
type fakeGuestsCfg struct{ mounts map[int]map[string]string }
|
||||
|
||||
func (f *fakeGuestsCfg) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) {
|
||||
extra := map[string]json.RawMessage{}
|
||||
for k, v := range f.mounts[vmid] {
|
||||
b, _ := json.Marshal(v)
|
||||
extra[k] = b
|
||||
}
|
||||
return proxmox.GuestConfig{Extra: extra}, nil
|
||||
}
|
||||
func (f *fakeGuestsCfg) Snapshot(context.Context, int, string, string) (string, error) { return "", nil }
|
||||
func (f *fakeGuestsCfg) Rollback(context.Context, int, string) (string, error) { return "", nil }
|
||||
func (f *fakeGuestsCfg) WaitTask(context.Context, string, proxmox.WaitOptions) (proxmox.TaskStatus, error) {
|
||||
return proxmox.TaskStatus{ExitStatus: "OK"}, nil
|
||||
}
|
||||
@@ -65,7 +65,13 @@ type Options struct {
|
||||
// is "due" when no successful backup is recorded OR the newest one is older than this. 0 → a
|
||||
// safe default (24h). The hub-served policy is slice 10; this is the agent-local cadence.
|
||||
BackupCadence time.Duration
|
||||
Logger *slog.Logger
|
||||
// Disk management (slice 8C) — OPTIONAL. When Disks + DiskGate are set, the /disks endpoints
|
||||
// are served; otherwise they report "not configured". DiskGate authorizes the destructive
|
||||
// (data-bearing) format path; Guests lists guests for the eject dependent-warning.
|
||||
Disks DiskOps
|
||||
DiskGate StorageGate
|
||||
Guests2 GuestLister
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// defaultBackupCadence is the fallback /backup/due window when none is configured.
|
||||
@@ -104,6 +110,10 @@ type Server struct {
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
disks DiskOps // slice 8C (optional)
|
||||
diskGate StorageGate // slice 8C (optional)
|
||||
guestList GuestLister // slice 8C (optional)
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobs map[int]*backupJob // per-guest backup job state (slice 8B)
|
||||
|
||||
@@ -133,10 +143,13 @@ func NewServer(o Options) (*Server, error) {
|
||||
store: o.Store,
|
||||
storage: o.Storage,
|
||||
tokens: o.Tokens,
|
||||
cadence: cadence,
|
||||
logger: o.Logger,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
jobs: map[int]*backupJob{},
|
||||
cadence: cadence,
|
||||
logger: o.Logger,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
disks: o.Disks,
|
||||
diskGate: o.DiskGate,
|
||||
guestList: o.Guests2,
|
||||
jobs: map[int]*backupJob{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -150,6 +163,11 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /backup/due", s.withGuest(s.handleBackupDue))
|
||||
mux.HandleFunc("GET /backup/status", s.withGuest(s.handleBackupStatus))
|
||||
mux.HandleFunc("GET /restore-test/status", s.withGuest(s.handleRestoreTestStatus))
|
||||
// Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate.
|
||||
mux.HandleFunc("GET /disks", s.withGuest(s.handleDisks))
|
||||
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
|
||||
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
|
||||
mux.HandleFunc("POST /disks/format", s.withGuest(s.handleDiskFormat))
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user