Files
felhom-agent/internal/localapi/guestbind.go
T
admin c1d04c28c1 agent v0.25.0: slice 10 P2 — bind enrolled user-data drives into the guest
POST /disks/guest-attach binds an enrolled drive's felhom-data namespace into
the guest (Model A: felhom-data is the bind source mounted at /mnt/<name>, so
only Felhom's namespace crosses in). GuestBinder does mkdir+chown(100000)+pct set
(RW bind) via the fenced runner. Idempotent, free-slot selection, path-validated.
Spike-proven on 9201. Pairs with controller P2C + golden /mnt:rslave (P2B).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:38:55 +02:00

80 lines
3.8 KiB
Go

package localapi
import (
"context"
"fmt"
"log/slog"
"strconv"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// Guest data-drive passthrough (slice 10 P2, Model A). An enrolled external user-data drive is
// mounted on the HOST at /mnt/<name>; this binds its felhom-data NAMESPACE into the guest so the
// in-guest controller + apps can use it. Confinement is the inner (host→guest) bind: only
// <drive>/felhom-data crosses into the guest — the customer's other data on the drive never does.
//
// Model A: the felhom-data dir is bound AT the guest's /mnt/<name> (so the guest's /mnt/<name> IS the
// felhom-data namespace; `findmnt` shows /dev/sdXN[/felhom-data], which the controller's mount strip
// already handles). The bind is RW (NOT ro=1 like the bootstrap mount). The namespace is chowned to
// the unprivileged-LXC base so the guest reads it as root-owned (per-app subdirs are chowned to the
// app's mapped UID at deploy — NOT here). Spike-proven on 9201 (see usb-passthrough-spike memory).
// guestMappedRoot is the unprivileged-LXC idmap base — guest root (UID 0) == host UID 100000. chowning
// the namespace to this makes the guest see it as root:root, writable by the in-guest controller.
const guestMappedRoot = "100000:100000"
// felhomDataNS is the Felhom-managed namespace directory created on every external data drive. Only
// this subtree is exposed to the guest (matches the controller's appbackup.FelhomDataDir).
const felhomDataNS = "felhom-data"
// GuestBinder attaches a host data-drive's felhom-data namespace into a guest as an RW bind mount via
// `pct set` (a root@pam op — same fenced Runner the provision back-half uses for its bind). It does
// NOT make HTTP calls; the slot selection + idempotency live in the handler (which has the guest
// config). Satisfies localapi.GuestAttacher.
type GuestBinder struct {
runner proxmox.Runner
logger *slog.Logger
}
// NewGuestBinder builds a binder over the given root-CLI runner.
func NewGuestBinder(r proxmox.Runner, logger *slog.Logger) *GuestBinder {
if logger == nil {
logger = slog.Default()
}
return &GuestBinder{runner: r, logger: logger}
}
// AttachBind creates + chowns <where>/felhom-data on the host and binds it into the guest at <where>
// (Model A). mountKey is the chosen guest slot ("mp3"). Idempotency + slot choice are the caller's
// (it reads the guest config); this performs the host-root steps only.
func (b *GuestBinder) AttachBind(ctx context.Context, vmid int, mountKey, where string) error {
src := where + "/" + felhomDataNS // host source = the felhom-data namespace on the drive
// 1. Ensure the namespace dir exists (idempotent; created fresh + uniformly owned, so the drive's
// pre-existing mixed-ownership customer data is never touched).
if err := b.run(ctx, "mkdir", "-p", src); err != nil {
return fmt.Errorf("guest-attach: create namespace %s: %w", src, err)
}
// 2. chown the namespace ROOT to the guest base (NOT -R: per-app subdirs are chowned at deploy).
if err := b.run(ctx, "chown", guestMappedRoot, src); err != nil {
return fmt.Errorf("guest-attach: chown namespace %s: %w", src, err)
}
// 3. Bind it into the guest at `where`, RW. Bind form (host path), NEVER storage:size (that volume
// form would create a fresh empty disk and lose the existing data).
spec := fmt.Sprintf("%s,mp=%s", src, where)
if err := b.run(ctx, "pct", "set", strconv.Itoa(vmid), "-"+mountKey, spec); err != nil {
return fmt.Errorf("guest-attach: pct set %s: %w", spec, err)
}
b.logger.Info("guest-attach: data drive bound into guest",
"vmid", vmid, "slot", mountKey, "source", src, "guest_path", where)
return nil
}
func (b *GuestBinder) run(ctx context.Context, name string, args ...string) error {
_, stderr, err := b.runner.Run(ctx, name, args...)
if err != nil {
return fmt.Errorf("%s: %w: %s", name, err, string(stderr))
}
return nil
}