fcproc: targeted tests for waitForPath + EnsureSocketAccess error paths
Every non-happy branch in fcproc was zero-covered before this. Given
that EnsureSocketAccess gates the firecracker control plane on the
daemon's ability to chown the API + vsock sockets off root, those
failure paths are exactly the ones we need pinned.
New file internal/daemon/fcproc/fcproc_test.go adds a local scripted
Runner (fcproc is a leaf package — can't pull the daemon's
scriptedRunner in) and six tests:
waitForPath:
- TestWaitForPathReturnsDeadlineExceededWhenSocketNeverAppears —
timeout branch wraps context.DeadlineExceeded with the label,
and waits at least one poll tick before giving up
- TestWaitForPathReturnsOnceSocketAppears — happy path with a
mid-wait file creation via goroutine
- TestWaitForPathRespectsContextCancellation — ctx.Done() beats
the poll interval so a cancelled request doesn't stall
EnsureSocketAccess:
- TestEnsureSocketAccessChownFailureBubbles — chown error surfaces
untouched; chmod not attempted when chown fails
- TestEnsureSocketAccessChmodFailureBubbles — chmod error surfaces
after chown succeeds
- TestEnsureSocketAccessTimesOutBeforeTouchingRunner — ordering
contract: no sudo calls when the socket never materialises
Package function coverage moved 55.2% → 62.1%.
Integration-level chown-race test was considered (run a real shell
that exercises buildProcessRunner's script with a fake firecracker
binary) but skipped — requires `sudo -n` in the test env and makes
CI fragile. The socket-ownership regression this slice is meant to
guard against is covered at the unit level here; the
manual-smoke in the plan's verification section remains the
end-to-end check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cef9bf92a5
commit
2f3db9b104
1 changed files with 192 additions and 0 deletions
192
internal/daemon/fcproc/fcproc_test.go
Normal file
192
internal/daemon/fcproc/fcproc_test.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package fcproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scriptedRunner is a minimal Runner that records every call and
|
||||
// plays back a pre-scripted sequence of (name, args, out, err)
|
||||
// steps. Failing to match or running past the script fails the
|
||||
// test. Mirrors the pattern from internal/daemon/snapshot_test.go
|
||||
// but lives here because fcproc is a leaf package — it can't import
|
||||
// its parent's test helpers.
|
||||
type scriptedRunner struct {
|
||||
t *testing.T
|
||||
runs []scriptedCall
|
||||
sudos []scriptedCall
|
||||
}
|
||||
|
||||
type scriptedCall struct {
|
||||
matchName string // empty for RunSudo (sudo has no distinct name arg)
|
||||
matchArgs []string // nil means "don't care"
|
||||
out []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *scriptedRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
r.t.Helper()
|
||||
if len(r.runs) == 0 {
|
||||
r.t.Fatalf("unexpected Run(%q, %v)", name, args)
|
||||
}
|
||||
step := r.runs[0]
|
||||
r.runs = r.runs[1:]
|
||||
if step.matchName != "" && step.matchName != name {
|
||||
r.t.Fatalf("Run name = %q, want %q", name, step.matchName)
|
||||
}
|
||||
return step.out, step.err
|
||||
}
|
||||
|
||||
func (r *scriptedRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) {
|
||||
r.t.Helper()
|
||||
if len(r.sudos) == 0 {
|
||||
r.t.Fatalf("unexpected RunSudo(%v)", args)
|
||||
}
|
||||
step := r.sudos[0]
|
||||
r.sudos = r.sudos[1:]
|
||||
return step.out, step.err
|
||||
}
|
||||
|
||||
// TestWaitForPathReturnsDeadlineExceededWhenSocketNeverAppears pins
|
||||
// the timeout branch of waitForPath. If this drifts, every callsite
|
||||
// that wraps it (EnsureSocketAccess on the firecracker API +
|
||||
// vsock sockets) loses its bounded wait.
|
||||
func TestWaitForPathReturnsDeadlineExceededWhenSocketNeverAppears(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "never-created.sock")
|
||||
start := time.Now()
|
||||
err := waitForPath(context.Background(), missing, 150*time.Millisecond, "api socket")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want wrapped context.DeadlineExceeded", err)
|
||||
}
|
||||
if !contains(err.Error(), "api socket") {
|
||||
t.Fatalf("err = %v, want label 'api socket' in message", err)
|
||||
}
|
||||
// Timeout should fire close to the configured budget, not zero
|
||||
// (tight-loop regression) and not way over (missing select
|
||||
// regression). The 100ms poll tick plus the initial stat makes
|
||||
// the lower bound noisy; check we at least waited a tick.
|
||||
if elapsed < 90*time.Millisecond {
|
||||
t.Fatalf("returned after %s; waitForPath exited before its timeout budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForPathReturnsOnceSocketAppears pins the happy path:
|
||||
// when the file materialises mid-wait, the function returns nil
|
||||
// without having to walk to its deadline.
|
||||
func TestWaitForPathReturnsOnceSocketAppears(t *testing.T) {
|
||||
socketPath := filepath.Join(t.TempDir(), "will-appear.sock")
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_ = os.WriteFile(socketPath, []byte{}, 0o600)
|
||||
}()
|
||||
if err := waitForPath(context.Background(), socketPath, 2*time.Second, "api socket"); err != nil {
|
||||
t.Fatalf("waitForPath: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForPathRespectsContextCancellation pins the ctx.Done()
|
||||
// branch — a canceled request must not be blocked by the poll
|
||||
// interval.
|
||||
func TestWaitForPathRespectsContextCancellation(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "never.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
err := waitForPath(ctx, missing, 5*time.Second, "api socket")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled when ctx is cancelled mid-wait", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSocketAccessChownFailureBubbles verifies a sudo chown
|
||||
// error surfaces untouched. The daemon's cleanup path relies on
|
||||
// this — if chown fails, the socket is still root-owned and can't
|
||||
// be used by the invoking user, so we absolutely must not pretend
|
||||
// success.
|
||||
func TestEnsureSocketAccessChownFailureBubbles(t *testing.T) {
|
||||
socketPath := filepath.Join(t.TempDir(), "present.sock")
|
||||
if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
chownErr := errors.New("sudo chown failed")
|
||||
runner := &scriptedRunner{
|
||||
t: t,
|
||||
sudos: []scriptedCall{{err: chownErr}},
|
||||
}
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
|
||||
err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket")
|
||||
if !errors.Is(err, chownErr) {
|
||||
t.Fatalf("err = %v, want chown error", err)
|
||||
}
|
||||
// chmod must not have been attempted.
|
||||
if len(runner.sudos) != 0 {
|
||||
t.Fatalf("chmod was attempted after chown failed: %d sudo calls left", len(runner.sudos))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSocketAccessChmodFailureBubbles verifies the chmod step
|
||||
// (the belt-and-braces tighten to 0600 after chown) also surfaces
|
||||
// errors cleanly.
|
||||
func TestEnsureSocketAccessChmodFailureBubbles(t *testing.T) {
|
||||
socketPath := filepath.Join(t.TempDir(), "present.sock")
|
||||
if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
chmodErr := errors.New("sudo chmod failed")
|
||||
runner := &scriptedRunner{
|
||||
t: t,
|
||||
sudos: []scriptedCall{
|
||||
{}, // chown succeeds
|
||||
{err: chmodErr}, // chmod fails
|
||||
},
|
||||
}
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
|
||||
err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket")
|
||||
if !errors.Is(err, chmodErr) {
|
||||
t.Fatalf("err = %v, want chmod error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSocketAccessTimesOutBeforeTouchingRunner pins the
|
||||
// ordering contract: if waitForPath never sees the socket, the
|
||||
// sudo commands must not run. Running chown/chmod against a
|
||||
// non-existent path would just noise the logs.
|
||||
func TestEnsureSocketAccessTimesOutBeforeTouchingRunner(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "never.sock")
|
||||
runner := &scriptedRunner{t: t} // no scripted calls — any runner invocation fails the test
|
||||
mgr := New(runner, Config{}, slog.Default())
|
||||
|
||||
// EnsureSocketAccess's waitForPath has a hardcoded 5s timeout,
|
||||
// and we can't inject a shorter one without widening the API.
|
||||
// Use a short context instead — cancellation short-circuits
|
||||
// waitForPath via the ctx.Done() branch.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := mgr.EnsureSocketAccess(ctx, missing, "api socket")
|
||||
if err == nil {
|
||||
t.Fatal("EnsureSocketAccess: want error when socket never appears")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue