controllerswap: stdin tee write + narrow FELHOM_CONTROLLERSWAP grants (non-root, v0.45.0)
writeImage drops bash -c/printf for GuestExecStdin(img+\n -> tee /etc/felhom-controller-image); new Runner.RunStdin/GuestExecStdin route stdin through the fenced sudo -n runner. 5 narrow, auditable sudoers grants (no general pct exec, no bash -c) + capability manifest entries (Critical) so the self-probe watches them and the build-test asserts coverage (companion red-proof). No controller change; swap orchestration/rollback/state unchanged. Spike GO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
This commit is contained in:
@@ -3,6 +3,35 @@
|
|||||||
All notable changes to **felhom-agent** are recorded here. Update on every code
|
All notable changes to **felhom-agent** are recorded here. Update on every code
|
||||||
change that gets pushed.
|
change that gets pushed.
|
||||||
|
|
||||||
|
## v0.45.0 — controller-swap under non-root: stdin `tee` write + narrow sudoers grants (Option A) (2026-06-29)
|
||||||
|
|
||||||
|
Restores fleet controller-swap / managed auto-update under the **non-root** agent — the one capability
|
||||||
|
the 2026-06-29 sudoers audit deliberately left broken because the old write vector needed arbitrary
|
||||||
|
in-guest execution. Mechanics spike-proven
|
||||||
|
(`felhom.eu/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md`, GO). **No controller
|
||||||
|
change** — the swap endpoint contract is unchanged; only the agent's internal write mechanism + the
|
||||||
|
allowlist.
|
||||||
|
|
||||||
|
- **`writeImage` no longer shells out.** Was `GuestExec("bash","-c","printf '%s\n' '<img>' > <file>")`
|
||||||
|
(the swap's only interpolated/shell vector). Now
|
||||||
|
`GuestExecStdin(strings.NewReader(img+"\n"), "tee", "/etc/felhom-controller-image")` — the image ref
|
||||||
|
is piped on **stdin** into an in-guest `tee`; no shell, no interpolation. The trailing `\n` keeps the
|
||||||
|
on-disk bytes byte-identical to the golden's `printf '%s\n'`, and the bootstrap reads `IMAGE=$(cat …)`
|
||||||
|
(newline-stripping), so the write is consumed identically. `ValidControllerImage` still gates upstream.
|
||||||
|
- **New stdin seam (no fenced-runner bypass):** `proxmox.Runner.RunStdin` / `ExecRunner.RunStdin` (Run
|
||||||
|
with `cmd.Stdin`), `GuestBinder.GuestExecStdin`, and `GuestExecutor.GuestExecStdin` — the swap routes
|
||||||
|
stdin through the SAME `sudo -n` fenced runner as every other privileged op.
|
||||||
|
- **`FELHOM_CONTROLLERSWAP` sudoers alias (5 narrow, auditable grants):** `cat <fixed file>`,
|
||||||
|
`docker image inspect *`, `docker inspect -f *`, `systemctl restart <fixed unit>`,
|
||||||
|
`tee <FIXED image file>`. **No general `pct exec`, no `bash -c`** — the spike's negative controls
|
||||||
|
(arbitrary exec, `tee` to any other path, `docker rm`, `rm -rf`) stay denied. The 5 are added to the
|
||||||
|
v0.44.0 **capability manifest** (Critical — a silently-broken fleet auto-update is operator-alert-worthy),
|
||||||
|
so the self-probe watches them and the build-test asserts grant↔code coverage (companion red-proof:
|
||||||
|
dropping the `tee` grant fails the gate — demonstrated red→green on the real file).
|
||||||
|
- Existing swap tests (happy / rollback-on-unhealthy / image-absent / no-healthcheck / bad-image /
|
||||||
|
single-flight) pass over the new write path; a new test asserts the write is stdin-`tee` with exact
|
||||||
|
`image\n` and **no** shell vector. Version `0.44.0 → 0.45.0`.
|
||||||
|
|
||||||
## v0.44.0 — privileged-capability self-probe (build-time manifest test + runtime probe + hub snapshot) (2026-06-29)
|
## v0.44.0 — privileged-capability self-probe (build-time manifest test + runtime probe + hub snapshot) (2026-06-29)
|
||||||
|
|
||||||
The agent now self-checks the `sudo -n` grants it depends on, so a missing allowlist entry (the
|
The agent now self-checks the `sudo -n` grants it depends on, so a missing allowlist entry (the
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import (
|
|||||||
|
|
||||||
// version is the agent version. Overridable at build time with
|
// version is the agent version. Overridable at build time with
|
||||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||||
var version = "0.44.0"
|
var version = "0.45.0"
|
||||||
|
|
||||||
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
|
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
|
||||||
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
|
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
|
||||||
|
|||||||
@@ -100,4 +100,23 @@ Cmnd_Alias FELHOM_INTERMEDIARY = \
|
|||||||
/usr/bin/lxc-info -n [0-9]* -p -H, \
|
/usr/bin/lxc-info -n [0-9]* -p -H, \
|
||||||
/usr/sbin/pct set [0-9]* -mp8 /mnt/felhom-drives*
|
/usr/sbin/pct set [0-9]* -mp8 /mnt/felhom-drives*
|
||||||
|
|
||||||
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY
|
# Controller-swap / managed auto-update (Option A, non-root). The agent owns the in-guest controller
|
||||||
|
# image SWAP (it survives the controller being killed mid-swap): read the baked image ref, check the
|
||||||
|
# pre-pulled target is present, rewrite /etc/felhom-controller-image, restart the bootstrap unit,
|
||||||
|
# health-check, roll back on failure. Each grant is bounded — NO general `pct exec` and NO `bash -c`:
|
||||||
|
# cat <fixed file> — read the current image ref (read-only)
|
||||||
|
# docker image inspect * — is the pre-pulled target present? (read-only)
|
||||||
|
# docker inspect -f * — container running/health/image (read-only; `*` spans the -f template
|
||||||
|
# + container across spaces, spike-confirmed)
|
||||||
|
# systemctl restart <fixed unit> — re-run the golden's bootstrap (the only state change)
|
||||||
|
# tee <FIXED image file> — WRITE the ref; content is fed on STDIN (no shell, no interpolation),
|
||||||
|
# the agent strict-validates the ref (controllerImageRe) before the write.
|
||||||
|
# Validated GO: felhom.eu/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md.
|
||||||
|
Cmnd_Alias FELHOM_CONTROLLERSWAP = \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- cat /etc/felhom-controller-image, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- docker image inspect *, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- docker inspect -f *, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- systemctl restart felhom-controller-bootstrap.service, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- tee /etc/felhom-controller-image
|
||||||
|
|
||||||
|
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP
|
||||||
|
|||||||
@@ -89,4 +89,12 @@ var manifest = []Capability{
|
|||||||
{"dnsmasq-rm", "dnsmasq drop-in remove (decommission)", "/usr/bin/rm", []string{"-f", "/etc/dnsmasq.d/felhom-x.conf"}, false},
|
{"dnsmasq-rm", "dnsmasq drop-in remove (decommission)", "/usr/bin/rm", []string{"-f", "/etc/dnsmasq.d/felhom-x.conf"}, false},
|
||||||
{"dnsmasq-guest-ip", "guest LAN IP discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "ip", "-4", "-o", "addr", "show", "dev", "eth0"}, false},
|
{"dnsmasq-guest-ip", "guest LAN IP discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "ip", "-4", "-o", "addr", "show", "dev", "eth0"}, false},
|
||||||
{"dnsmasq-guest-domain", "guest domain discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml"}, false},
|
{"dnsmasq-guest-domain", "guest domain discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml"}, false},
|
||||||
|
|
||||||
|
// ---- Controller-swap / managed auto-update (FELHOM_CONTROLLERSWAP, v0.45.0; Critical: a
|
||||||
|
// silently-broken fleet auto-update is operator-alert-worthy) ----
|
||||||
|
{"controllerswap-read", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "cat", "/etc/felhom-controller-image"}, true},
|
||||||
|
{"controllerswap-image-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "image", "inspect", "gitea.dooplex.hu/admin/felhom-controller:0.0.0"}, true},
|
||||||
|
{"controllerswap-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "inspect", "-f", "{{.State.Running}}", "felhom-controller"}, true},
|
||||||
|
{"controllerswap-restart", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "systemctl", "restart", "felhom-controller-bootstrap.service"}, true},
|
||||||
|
{"controllerswap-write", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "tee", "/etc/felhom-controller-image"}, true},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,3 +179,40 @@ func TestRedProof_DroppedGrantFailsCheck(t *testing.T) {
|
|||||||
t.Errorf("guest-init-pid should be covered by the real sudoers")
|
t.Errorf("guest-init-pid should be covered by the real sudoers")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRedProof_DroppedControllerSwapTeeFailsCheck is the companion red-proof for the v0.45.0
|
||||||
|
// FELHOM_CONTROLLERSWAP grants: with the `tee /etc/felhom-controller-image` line removed, the
|
||||||
|
// controllerswap-write capability MUST be reported uncovered. Proves the build gate watches the new
|
||||||
|
// swap write grant (so dropping it can't ship a non-root agent that silently can't auto-update).
|
||||||
|
func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(sudoersPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sudoers: %v", err)
|
||||||
|
}
|
||||||
|
var kept []string
|
||||||
|
for _, ln := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.Contains(ln, "tee /etc/felhom-controller-image") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, ln)
|
||||||
|
}
|
||||||
|
mutated := strings.Join(kept, "\n")
|
||||||
|
entries := parseSudoersEntries(t, mutated)
|
||||||
|
|
||||||
|
var write Capability
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
if c.Name == "controllerswap-write" {
|
||||||
|
write = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if write.Name == "" {
|
||||||
|
t.Fatal("manifest missing controllerswap-write")
|
||||||
|
}
|
||||||
|
cmdline := write.Binary + " " + strings.Join(write.ReprArgs, " ")
|
||||||
|
if matchesAny(cmdline, entries) {
|
||||||
|
t.Errorf("red-proof FAILED: controllerswap-write still matches after dropping the tee grant")
|
||||||
|
}
|
||||||
|
if full := parseSudoersEntries(t, string(data)); !matchesAny(cmdline, full) {
|
||||||
|
t.Errorf("controllerswap-write should be covered by the real sudoers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -39,6 +40,9 @@ func ValidControllerImage(ref string) bool { return controllerImageRe.MatchStrin
|
|||||||
// faked in tests. The single seam the swap composes over (no hand-rolled pct).
|
// faked in tests. The single seam the swap composes over (no hand-rolled pct).
|
||||||
type GuestExecutor interface {
|
type GuestExecutor interface {
|
||||||
GuestExec(ctx context.Context, vmid int, args ...string) (string, error)
|
GuestExec(ctx context.Context, vmid int, args ...string) (string, error)
|
||||||
|
// GuestExecStdin is GuestExec with the command's stdin fed from stdin — the swap write pipes the
|
||||||
|
// image ref into an in-guest `tee` (no shell vector).
|
||||||
|
GuestExecStdin(ctx context.Context, vmid int, stdin io.Reader, args ...string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerSwapState is the durable record of a swap (crash-safety + status). Written before the swap
|
// ControllerSwapState is the durable record of a swap (crash-safety + status). Written before the swap
|
||||||
@@ -133,10 +137,13 @@ func (c *ControllerSwapper) imagePresent(ctx context.Context, vmid int, image st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ControllerSwapper) writeImage(ctx context.Context, vmid int, image string) error {
|
func (c *ControllerSwapper) writeImage(ctx context.Context, vmid int, image string) error {
|
||||||
// image is strict-validated (controllerImageRe) before we ever get here, so this single-quoted
|
// Non-root path: pipe the image ref into an in-guest `tee` over stdin — no shell, no
|
||||||
// interpolation cannot smuggle shell metacharacters.
|
// interpolation, no `bash -c` (the only swap vector that would have needed an arbitrary-exec
|
||||||
cmd := fmt.Sprintf("printf '%%s\\n' '%s' > %s", image, controllerImageFile)
|
// grant). The trailing "\n" makes the on-disk bytes byte-identical to the golden's
|
||||||
_, err := c.exec.GuestExec(ctx, vmid, "bash", "-c", cmd)
|
// `printf '%s\n'`; the bootstrap reads `IMAGE=$(cat …)` so the newline is stripped on read
|
||||||
|
// (spike SPIKE-controllerswap-narrow-grants-2026-06-29). image is strict-validated
|
||||||
|
// (controllerImageRe) upstream in Swap; defence-in-depth, the stdin path can't smuggle anyway.
|
||||||
|
_, err := c.exec.GuestExecStdin(ctx, vmid, strings.NewReader(image+"\n"), "tee", controllerImageFile)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -24,12 +23,11 @@ type fakeGuestExec struct {
|
|||||||
present map[string]bool // images pulled into the guest
|
present map[string]bool // images pulled into the guest
|
||||||
good map[string]bool // images that report healthy when running
|
good map[string]bool // images that report healthy when running
|
||||||
containerImg string // image the running container currently has
|
containerImg string // image the running container currently has
|
||||||
|
teeStdin []string // raw bytes piped into each `tee` write (the swap's write vector)
|
||||||
failRestart bool
|
failRestart bool
|
||||||
noHealthBlock bool // if set, .State.Health is absent ("none")
|
noHealthBlock bool // if set, .State.Health is absent ("none")
|
||||||
}
|
}
|
||||||
|
|
||||||
var writeImgRe = regexp.MustCompile(`'([^']+)' >`)
|
|
||||||
|
|
||||||
func (f *fakeGuestExec) GuestExec(_ context.Context, _ int, args ...string) (string, error) {
|
func (f *fakeGuestExec) GuestExec(_ context.Context, _ int, args ...string) (string, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -42,13 +40,6 @@ func (f *fakeGuestExec) GuestExec(_ context.Context, _ int, args ...string) (str
|
|||||||
return args[3], nil
|
return args[3], nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("no such image: %s", args[3])
|
return "", fmt.Errorf("no such image: %s", args[3])
|
||||||
case len(args) >= 3 && args[0] == "bash" && args[1] == "-c":
|
|
||||||
m := writeImgRe.FindStringSubmatch(args[2])
|
|
||||||
if m == nil {
|
|
||||||
return "", fmt.Errorf("fake: unparseable write cmd %q", args[2])
|
|
||||||
}
|
|
||||||
f.imageFile = m[1]
|
|
||||||
return "", nil
|
|
||||||
case len(args) >= 3 && args[0] == "systemctl" && args[1] == "restart":
|
case len(args) >= 3 && args[0] == "systemctl" && args[1] == "restart":
|
||||||
if f.failRestart {
|
if f.failRestart {
|
||||||
return "", fmt.Errorf("fake: systemctl restart failed")
|
return "", fmt.Errorf("fake: systemctl restart failed")
|
||||||
@@ -70,17 +61,50 @@ func (f *fakeGuestExec) GuestExec(_ context.Context, _ int, args ...string) (str
|
|||||||
return "", fmt.Errorf("fake: unexpected exec %v", args)
|
return "", fmt.Errorf("fake: unexpected exec %v", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GuestExecStdin models the swap's write vector: `tee /etc/felhom-controller-image` with the image
|
||||||
|
// piped on stdin. It records the raw stdin bytes and sets the modeled file content (newline-stripped,
|
||||||
|
// as the bootstrap's `IMAGE=$(cat …)` read would see it).
|
||||||
|
func (f *fakeGuestExec) GuestExecStdin(_ context.Context, _ int, stdin io.Reader, args ...string) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, args)
|
||||||
|
b, _ := io.ReadAll(stdin)
|
||||||
|
if len(args) >= 2 && args[0] == "tee" && args[1] == controllerImageFile {
|
||||||
|
f.teeStdin = append(f.teeStdin, string(b))
|
||||||
|
f.imageFile = strings.TrimSpace(string(b))
|
||||||
|
return string(b), nil // tee echoes stdin to stdout
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("fake: unexpected exec-stdin args=%v stdin=%q", args, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrote reports whether the image was written via the stdin `tee` vector with the exact `image\n`
|
||||||
|
// bytes (byte-identical to the golden's printf '%s\n').
|
||||||
func (f *fakeGuestExec) wrote(image string) bool {
|
func (f *fakeGuestExec) wrote(image string) bool {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
for _, c := range f.calls {
|
for _, w := range f.teeStdin {
|
||||||
if len(c) >= 3 && c[0] == "bash" && strings.Contains(c[2], "'"+image+"'") {
|
if w == image+"\n" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// usedShell reports whether ANY exec used a shell vector (bash/-c/printf/sh) — the thing the swap
|
||||||
|
// rewrite removes. A regression to `bash -c "printf … >"` would make this true.
|
||||||
|
func (f *fakeGuestExec) usedShell() bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, c := range f.calls {
|
||||||
|
for _, a := range c {
|
||||||
|
if a == "bash" || a == "sh" || a == "-c" || strings.HasPrefix(a, "printf") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func newTestSwapper(t *testing.T, fe *fakeGuestExec) *ControllerSwapper {
|
func newTestSwapper(t *testing.T, fe *fakeGuestExec) *ControllerSwapper {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s := NewControllerSwapper(fe, t.TempDir(), nil)
|
s := NewControllerSwapper(fe, t.TempDir(), nil)
|
||||||
@@ -122,6 +146,38 @@ func TestControllerSwap_Happy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The write vector must be the stdin `tee` with byte-identical `image\n` and NO shell — the
|
||||||
|
// controllerswap.go writeImage rewrite. This would FAIL on the pre-change `bash -c "printf … >"` impl.
|
||||||
|
func TestControllerSwap_WriteViaStdinTee_NoShell(t *testing.T) {
|
||||||
|
fe := &fakeGuestExec{
|
||||||
|
imageFile: prevImg,
|
||||||
|
present: map[string]bool{newImg: true},
|
||||||
|
good: map[string]bool{newImg: true, prevImg: true},
|
||||||
|
}
|
||||||
|
s := newTestSwapper(t, fe)
|
||||||
|
if st := s.Swap(context.Background(), 9201, newImg); st.State != "done" {
|
||||||
|
t.Fatalf("state = %q, want done", st.State)
|
||||||
|
}
|
||||||
|
if !fe.wrote(newImg) {
|
||||||
|
t.Errorf("expected a tee write of %q+\\n; teeStdin=%q", newImg, fe.teeStdin)
|
||||||
|
}
|
||||||
|
sawTee := false
|
||||||
|
for _, c := range fe.calls {
|
||||||
|
if len(c) >= 2 && c[0] == "tee" {
|
||||||
|
sawTee = true
|
||||||
|
if c[1] != controllerImageFile {
|
||||||
|
t.Errorf("tee target = %q, want fixed %q", c[1], controllerImageFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawTee {
|
||||||
|
t.Error("no tee call recorded — writeImage did not use the stdin tee vector")
|
||||||
|
}
|
||||||
|
if fe.usedShell() {
|
||||||
|
t.Errorf("swap used a shell vector (bash/-c/printf) — must be stdin tee only; calls=%v", fe.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The load-bearing failure path: an unhealthy target must roll back to the previous image.
|
// The load-bearing failure path: an unhealthy target must roll back to the previous image.
|
||||||
func TestControllerSwap_RollbackOnUnhealthy(t *testing.T) {
|
func TestControllerSwap_RollbackOnUnhealthy(t *testing.T) {
|
||||||
fe := &fakeGuestExec{
|
fe := &fakeGuestExec{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package localapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -124,3 +125,15 @@ func (b *GuestBinder) GuestExec(ctx context.Context, vmid int, args ...string) (
|
|||||||
}
|
}
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GuestExecStdin is GuestExec with the in-guest command's stdin fed from stdin. The controller-swap
|
||||||
|
// write uses it to pipe the image ref into an in-guest `tee` (no shell vector, no interpolation),
|
||||||
|
// through the same fenced runner so the `sudo -n` prefix stays in one place.
|
||||||
|
func (b *GuestBinder) GuestExecStdin(ctx context.Context, vmid int, stdin io.Reader, args ...string) (string, error) {
|
||||||
|
pctArgs := append([]string{"exec", strconv.Itoa(vmid), "--"}, args...)
|
||||||
|
out, stderr, err := b.runner.RunStdin(ctx, stdin, "pct", pctArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("pct exec %d %v: %w: %s", vmid, args, err, strings.TrimSpace(string(stderr)))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ type recRunner struct {
|
|||||||
fail string // if a command's name == fail, return an error
|
fail string // if a command's name == fail, return an error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *recRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
return r.Run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *recRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
func (r *recRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.cmds = append(r.cmds, append([]string{name}, args...))
|
r.cmds = append(r.cmds, append([]string{name}, args...))
|
||||||
@@ -70,7 +74,10 @@ type mintMinter struct {
|
|||||||
vmids []int
|
vmids []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mintMinter) Mint(vmid int) (string, error) { m.vmids = append(m.vmids, vmid); return m.token, nil }
|
func (m *mintMinter) Mint(vmid int) (string, error) {
|
||||||
|
m.vmids = append(m.vmids, vmid)
|
||||||
|
return m.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ type mockRunner struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
return m.Run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
func (m *mockRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
m.calls++
|
m.calls++
|
||||||
m.lastCmd = name
|
m.lastCmd = name
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,10 @@ import (
|
|||||||
// production implementation; tests inject a mock to assert which commands ran.
|
// production implementation; tests inject a mock to assert which commands ran.
|
||||||
type Runner interface {
|
type Runner interface {
|
||||||
Run(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error)
|
Run(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error)
|
||||||
|
// RunStdin is Run with the command's stdin fed from r (nil = no stdin). Used by the
|
||||||
|
// controller-swap write (image piped into an in-guest `tee`), so the swap needs no shell
|
||||||
|
// vector. Keeps the sudo-prefix/mode handling in the same place as Run.
|
||||||
|
RunStdin(ctx context.Context, stdin io.Reader, name string, args ...string) (stdout, stderr []byte, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunnerMode selects how privileged commands are executed.
|
// RunnerMode selects how privileged commands are executed.
|
||||||
@@ -48,6 +53,12 @@ type ExecRunner struct {
|
|||||||
|
|
||||||
// Run implements Runner.
|
// Run implements Runner.
|
||||||
func (r *ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, []byte, error) {
|
func (r *ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
return r.RunStdin(ctx, nil, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunStdin is Run with the process stdin fed from stdin (nil = no stdin). The sudo-prefix/mode
|
||||||
|
// handling is identical to Run — kept here so both paths share one place.
|
||||||
|
func (r *ExecRunner) RunStdin(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if r.Mode == RunnerSudo {
|
if r.Mode == RunnerSudo {
|
||||||
sudo := r.SudoPath
|
sudo := r.SudoPath
|
||||||
@@ -59,6 +70,7 @@ func (r *ExecRunner) Run(ctx context.Context, name string, args ...string) ([]by
|
|||||||
cmd = exec.CommandContext(ctx, name, args...)
|
cmd = exec.CommandContext(ctx, name, args...)
|
||||||
}
|
}
|
||||||
var stdout, stderr capBuf
|
var stdout, stderr capBuf
|
||||||
|
cmd.Stdin = stdin
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,10 @@ type scriptedRunner struct {
|
|||||||
errs map[string]error
|
errs map[string]error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *scriptedRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
return r.Run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *scriptedRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
func (r *scriptedRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
r.calls = append(r.calls, append([]string{name}, args...))
|
r.calls = append(r.calls, append([]string{name}, args...))
|
||||||
base := name
|
base := name
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +19,10 @@ type scriptRunner struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *scriptRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
return s.Run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *scriptRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
func (s *scriptRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
s.calls = append(s.calls, append([]string{name}, args...))
|
s.calls = append(s.calls, append([]string{name}, args...))
|
||||||
return s.out[name], nil, s.err
|
return s.out[name], nil, s.err
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ func TestSystemDisks_FromBootMounts(t *testing.T) {
|
|||||||
func TestRoleForStorage_DemoMapping(t *testing.T) {
|
func TestRoleForStorage_DemoMapping(t *testing.T) {
|
||||||
sys, ok := SystemDisks(demoHost())
|
sys, ok := SystemDisks(demoHost())
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
typ string
|
typ string
|
||||||
device string
|
device string
|
||||||
want DeviceRole
|
want DeviceRole
|
||||||
}{
|
}{
|
||||||
{"builtin local (root fs)", hub.StorageTypeLocal, "", RoleSystem},
|
{"builtin local (root fs)", hub.StorageTypeLocal, "", RoleSystem},
|
||||||
{"local-lvm (lvmthin)", hub.StorageTypeLVMThin, "", RoleSystem},
|
{"local-lvm (lvmthin)", hub.StorageTypeLVMThin, "", RoleSystem},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ type recordingRunner struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
return r.Run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
r.calls = append(r.calls, append([]string{name}, args...))
|
r.calls = append(r.calls, append([]string{name}, args...))
|
||||||
return nil, nil, r.err
|
return nil, nil, r.err
|
||||||
|
|||||||
Reference in New Issue
Block a user