R-124 recipe root namespace as PBS spells it; R-118 no root size for an absent drive; R-269 rotated-out token rejected at once; R-317 dnsmasq install probed by its unit (burn-down round 2)
gates / gates (push) Successful in 47s
gates / gates (push) Successful in 47s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package lanresolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recRunner records every privileged command EnsureDnsmasq would run and succeeds — nothing reaches
|
||||
// apt, systemctl or the root checker.
|
||||
type recRunner struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (r *recRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, strings.Join(append([]string{name}, args...), " "))
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
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) installed() bool {
|
||||
for _, c := range r.calls {
|
||||
if strings.HasPrefix(c, "apt-get install") && strings.HasSuffix(c, " dnsmasq") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fixtureRoot builds a fake host root holding exactly the given relative files and points the REAL
|
||||
// probe at it for the test's duration.
|
||||
func fixtureRoot(t *testing.T, files ...string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, f := range files {
|
||||
p := filepath.Join(root, f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
prev := hostRoot
|
||||
hostRoot = root
|
||||
t.Cleanup(func() { hostRoot = prev })
|
||||
}
|
||||
|
||||
func ensure(t *testing.T) *recRunner {
|
||||
t.Helper()
|
||||
r := &recRunner{}
|
||||
m := NewManager(r, "192.0.2.10", []string{"1.1.1.1"}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := m.EnsureDnsmasq(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureDnsmasq: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// R-317: a host with `dnsmasq-base` (the /usr/sbin/dnsmasq binary) but WITHOUT the `dnsmasq` package
|
||||
// (the service unit) must get the package installed — else the following `systemctl enable --now
|
||||
// dnsmasq` hits a unit that does not exist and LAN name resolution silently never comes up.
|
||||
//
|
||||
// RED-PROOF: probe "usr/sbin/dnsmasq" instead of the unit paths in dnsmasqUnitInstalled → this fails
|
||||
// with "install was skipped".
|
||||
func TestEnsureDnsmasq_BinaryWithoutUnitInstalls(t *testing.T) {
|
||||
fixtureRoot(t, "usr/sbin/dnsmasq")
|
||||
r := ensure(t)
|
||||
if !r.installed() {
|
||||
t.Fatalf("install was skipped on a dnsmasq-base-only host (binary present, unit absent) — "+
|
||||
"the enable that follows targets a missing unit (R-317). calls: %q", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDnsmasq_UnitPresentSkipsInstall(t *testing.T) {
|
||||
for _, unit := range []string{"usr/lib/systemd/system/dnsmasq.service", "lib/systemd/system/dnsmasq.service"} {
|
||||
t.Run(unit, func(t *testing.T) {
|
||||
fixtureRoot(t, "usr/sbin/dnsmasq", unit)
|
||||
if r := ensure(t); r.installed() {
|
||||
t.Fatalf("apt-get install ran although the dnsmasq unit is present at %s: %q", unit, r.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDnsmasq_NothingPresentInstalls(t *testing.T) {
|
||||
fixtureRoot(t)
|
||||
if r := ensure(t); !r.installed() {
|
||||
t.Fatalf("install skipped on a host with no dnsmasq at all: %q", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Production wiring for the hostRoot seam: the shipped probe resolves against the real root and asks
|
||||
// about the unit the `dnsmasq` package owns — never the dnsmasq-base binary.
|
||||
func TestEnsureDnsmasq_ProductionProbeIsTheUnit(t *testing.T) {
|
||||
if hostRoot != "/" {
|
||||
t.Fatalf("hostRoot default = %q, want \"/\" — the production probe would look in the wrong tree", hostRoot)
|
||||
}
|
||||
var sawUsrLib bool
|
||||
for _, p := range dnsmasqUnitPaths {
|
||||
full := filepath.Join(hostRoot, p)
|
||||
if strings.HasSuffix(full, "/sbin/dnsmasq") || strings.HasSuffix(full, "/bin/dnsmasq") {
|
||||
t.Errorf("probe path %s is the dnsmasq-base binary, not the dnsmasq unit (R-317)", full)
|
||||
}
|
||||
if full == "/usr/lib/systemd/system/dnsmasq.service" {
|
||||
sawUsrLib = true
|
||||
}
|
||||
}
|
||||
if !sawUsrLib {
|
||||
t.Errorf("probe paths %q miss /usr/lib/systemd/system/dnsmasq.service (dpkg -S: owned by dnsmasq)", dnsmasqUnitPaths)
|
||||
}
|
||||
}
|
||||
@@ -101,11 +101,35 @@ func NewManager(runner proxmox.Runner, hostIP string, upstreams []string, logger
|
||||
}
|
||||
}
|
||||
|
||||
// hostRoot is the filesystem root the install probe resolves against: "/" in production; a test
|
||||
// points it at a fixture tree so the REAL probe runs against files it controls.
|
||||
var hostRoot = "/"
|
||||
|
||||
// dnsmasqUnitPaths are where the `dnsmasq` package ships its systemd unit (Debian; /lib is the
|
||||
// pre-usrmerge spelling). R-317: probe the UNIT, never /usr/sbin/dnsmasq — that binary belongs to
|
||||
// `dnsmasq-base`, so a host carrying dnsmasq-base without dnsmasq used to skip the install and then
|
||||
// `systemctl enable --now dnsmasq` failed against a unit that is not there (resolver never up).
|
||||
// Pinned by TestEnsureDnsmasq_BinaryWithoutUnitInstalls.
|
||||
var dnsmasqUnitPaths = []string{
|
||||
"usr/lib/systemd/system/dnsmasq.service",
|
||||
"lib/systemd/system/dnsmasq.service",
|
||||
}
|
||||
|
||||
// dnsmasqUnitInstalled reports whether the dnsmasq service unit (the `dnsmasq` package) is present.
|
||||
func dnsmasqUnitInstalled() bool {
|
||||
for _, p := range dnsmasqUnitPaths {
|
||||
if _, err := os.Stat(filepath.Join(hostRoot, p)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnsureDnsmasq makes dnsmasq present + enabled and writes the host base config. Idempotent: it
|
||||
// installs the package only when absent, and writes the base drop-in only when its content changes.
|
||||
func (m *Manager) EnsureDnsmasq(ctx context.Context) error {
|
||||
if _, err := os.Stat("/usr/sbin/dnsmasq"); err != nil { // metadata read, no privilege needed
|
||||
m.logger.Info("lanresolver: dnsmasq absent — installing")
|
||||
if !dnsmasqUnitInstalled() { // metadata read, no privilege needed
|
||||
m.logger.Info("lanresolver: dnsmasq service unit absent — installing")
|
||||
if out, errOut, ierr := m.runner.Run(ctx, "apt-get", "install", "-y", "-q", "dnsmasq"); ierr != nil {
|
||||
return fmt.Errorf("install dnsmasq: %s: %w", strings.TrimSpace(string(errOut))+string(out), ierr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user