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>
192 lines
6.4 KiB
Go
192 lines
6.4 KiB
Go
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
|
|
}
|