Three test seams were still package-level mutable vars, which tests
had to swap before use. That's the classic path to flaky parallel
tests — two goroutines fighting over the same global fake. Push each
down to the struct that owns the behaviour.
internal/daemon/dns_routing.go
lookupExecutableFunc + vmDNSAddrFunc → fields on *HostNetwork,
defaulted at newHostNetwork time. dns_routing_test builds
HostNetwork{..., lookupExecutable: stub, vmDNSAddr: stub} inline,
no more t.Cleanup dance around package-level vars.
internal/daemon/preflight.go + doctor.go
vsockHostDevicePath (mutable string) → vsockHostDevice field on
*VMService, defaulted via defaultVsockHostDevice constant in
newVMService. Preflight reads s.vsockHostDevice; doctor reads
d.vm.vsockHostDevice. Logger test sets d.vm.vsockHostDevice = tmp
after wireServices.
internal/daemon/workspace/workspace.go
HostCommandOutputFunc → *Inspector struct with a Runner field.
Every git-using helper (GitOutput, GitTrimmedOutput,
GitResolvedConfigValue, RunHostCommand, ListSubmodules,
ListOverlayPaths, CountUntrackedPaths, InspectRepo,
ImportRepoToGuest, PrepareRepoCopy) is now a method on *Inspector.
NewInspector() wraps the real host runner for production;
WorkspaceService holds one via repoInspector, CLI deps holds one
too. cli_test.go's submodule-rejection test builds its own
Inspector with a scripted Runner instead of patching a global.
Pure helpers (FinalizeScript, ResolveSourcePath, ParsePrepareMode,
ShellQuote, FormatStepError, GitFileURL, ParseNullSeparatedOutput)
stay free functions since they don't touch the host.
Sentinel: grep for HostCommandOutputFunc, lookupExecutableFunc,
vmDNSAddrFunc, vsockHostDevicePath is now empty across internal/.
make lint test green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
6.5 KiB
Go
229 lines
6.5 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"banger/internal/daemon/fcproc"
|
|
"banger/internal/firecracker"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
"banger/internal/system"
|
|
"banger/internal/vmdns"
|
|
"banger/internal/vsockagent"
|
|
)
|
|
|
|
// HostNetwork owns the daemon's side of host networking: the TAP
|
|
// interface pool, the bridge, per-VM tap/NAT/DNS wiring, and the
|
|
// firecracker-process primitives (bridge setup, socket access,
|
|
// pgrep-based PID resolution, ctrl-alt-del, wait/kill) plus DM
|
|
// snapshot helpers. The Daemon holds one *HostNetwork and routes
|
|
// lifecycle calls through it instead of reaching into host-state
|
|
// directly.
|
|
//
|
|
// Fields stay unexported so peer services (VMService, etc.) access
|
|
// HostNetwork only through consumer-defined interfaces, not by
|
|
// fishing around in its struct. Construction goes through
|
|
// newHostNetwork with an explicit dependency bag so the wiring is
|
|
// auditable.
|
|
type HostNetwork struct {
|
|
runner system.CommandRunner
|
|
logger *slog.Logger
|
|
config model.DaemonConfig
|
|
layout paths.Layout
|
|
closing chan struct{}
|
|
|
|
tapPool tapPool
|
|
vmDNS *vmdns.Server
|
|
|
|
// Test seams. Default to real implementations at construction;
|
|
// tests build HostNetwork with stubs instead of mutating package
|
|
// globals, so parallel tests can't race each other's fake state.
|
|
lookupExecutable func(name string) (string, error)
|
|
vmDNSAddr func(server *vmdns.Server) string
|
|
}
|
|
|
|
// hostNetworkDeps is the explicit wiring bag newHostNetwork expects.
|
|
// Keeping the deps in a dedicated struct rather than positional args
|
|
// makes the construction site in Daemon.Open read like a declaration.
|
|
type hostNetworkDeps struct {
|
|
runner system.CommandRunner
|
|
logger *slog.Logger
|
|
config model.DaemonConfig
|
|
layout paths.Layout
|
|
closing chan struct{}
|
|
}
|
|
|
|
func newHostNetwork(deps hostNetworkDeps) *HostNetwork {
|
|
return &HostNetwork{
|
|
runner: deps.runner,
|
|
logger: deps.logger,
|
|
config: deps.config,
|
|
layout: deps.layout,
|
|
closing: deps.closing,
|
|
lookupExecutable: system.LookupExecutable,
|
|
vmDNSAddr: func(server *vmdns.Server) string { return server.Addr() },
|
|
}
|
|
}
|
|
|
|
// --- DNS server lifecycle -------------------------------------------
|
|
|
|
func (n *HostNetwork) startVMDNS(addr string) error {
|
|
server, err := vmdns.New(addr, n.logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n.vmDNS = server
|
|
if n.logger != nil {
|
|
n.logger.Info("vm dns serving", "dns_addr", server.Addr())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *HostNetwork) stopVMDNS() error {
|
|
if n == nil || n.vmDNS == nil {
|
|
return nil
|
|
}
|
|
err := n.vmDNS.Close()
|
|
n.vmDNS = nil
|
|
return err
|
|
}
|
|
|
|
func (n *HostNetwork) setDNS(ctx context.Context, vmName, guestIP string) error {
|
|
if n.vmDNS == nil {
|
|
return nil
|
|
}
|
|
if err := n.vmDNS.Set(vmdns.RecordName(vmName), guestIP); err != nil {
|
|
return err
|
|
}
|
|
n.ensureVMDNSResolverRouting(ctx)
|
|
return nil
|
|
}
|
|
|
|
func (n *HostNetwork) removeDNS(dnsName string) error {
|
|
if dnsName == "" || n.vmDNS == nil {
|
|
return nil
|
|
}
|
|
return n.vmDNS.Remove(dnsName)
|
|
}
|
|
|
|
// replaceDNS replaces the DNS server's full record set. Callers
|
|
// (Daemon.rebuildDNS) filter by vm-alive first; HostNetwork just
|
|
// takes the pre-filtered map.
|
|
func (n *HostNetwork) replaceDNS(records map[string]string) error {
|
|
if n.vmDNS == nil {
|
|
return nil
|
|
}
|
|
return n.vmDNS.Replace(records)
|
|
}
|
|
|
|
// --- Firecracker process helpers ------------------------------------
|
|
|
|
// fc builds a fresh fcproc.Manager from the HostNetwork's current
|
|
// runner, config, and layout. Manager is stateless beyond those
|
|
// handles, so constructing per call keeps tests that build literals
|
|
// working without extra wiring.
|
|
func (n *HostNetwork) fc() *fcproc.Manager {
|
|
return fcproc.New(n.runner, fcproc.Config{
|
|
FirecrackerBin: n.config.FirecrackerBin,
|
|
BridgeName: n.config.BridgeName,
|
|
BridgeIP: n.config.BridgeIP,
|
|
CIDR: n.config.CIDR,
|
|
RuntimeDir: n.layout.RuntimeDir,
|
|
}, n.logger)
|
|
}
|
|
|
|
func (n *HostNetwork) ensureBridge(ctx context.Context) error {
|
|
return n.fc().EnsureBridge(ctx)
|
|
}
|
|
|
|
func (n *HostNetwork) ensureSocketDir() error {
|
|
return n.fc().EnsureSocketDir()
|
|
}
|
|
|
|
func (n *HostNetwork) createTap(ctx context.Context, tap string) error {
|
|
return n.fc().CreateTap(ctx, tap)
|
|
}
|
|
|
|
func (n *HostNetwork) firecrackerBinary() (string, error) {
|
|
return n.fc().ResolveBinary()
|
|
}
|
|
|
|
func (n *HostNetwork) ensureSocketAccess(ctx context.Context, socketPath, label string) error {
|
|
return n.fc().EnsureSocketAccess(ctx, socketPath, label)
|
|
}
|
|
|
|
func (n *HostNetwork) findFirecrackerPID(ctx context.Context, apiSock string) (int, error) {
|
|
return n.fc().FindPID(ctx, apiSock)
|
|
}
|
|
|
|
func (n *HostNetwork) resolveFirecrackerPID(ctx context.Context, machine *firecracker.Machine, apiSock string) int {
|
|
return n.fc().ResolvePID(ctx, machine, apiSock)
|
|
}
|
|
|
|
func (n *HostNetwork) sendCtrlAltDel(ctx context.Context, apiSockPath string) error {
|
|
return n.fc().SendCtrlAltDel(ctx, apiSockPath)
|
|
}
|
|
|
|
func (n *HostNetwork) waitForExit(ctx context.Context, pid int, apiSock string, timeout time.Duration) error {
|
|
return n.fc().WaitForExit(ctx, pid, apiSock, timeout)
|
|
}
|
|
|
|
func (n *HostNetwork) killVMProcess(ctx context.Context, pid int) error {
|
|
return n.fc().Kill(ctx, pid)
|
|
}
|
|
|
|
// waitForGuestVSockAgent is a HostNetwork helper because it's
|
|
// fundamentally about waiting for a vsock socket the firecracker
|
|
// process is serving on. No daemon state needed.
|
|
func (n *HostNetwork) waitForGuestVSockAgent(ctx context.Context, socketPath string, timeout time.Duration) error {
|
|
if strings.TrimSpace(socketPath) == "" {
|
|
return errors.New("vsock path is required")
|
|
}
|
|
|
|
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(vsockReadyPoll)
|
|
defer ticker.Stop()
|
|
|
|
var lastErr error
|
|
for {
|
|
pingCtx, pingCancel := context.WithTimeout(waitCtx, 3*time.Second)
|
|
err := vsockagent.Health(pingCtx, n.logger, socketPath)
|
|
pingCancel()
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
|
|
select {
|
|
case <-waitCtx.Done():
|
|
if lastErr != nil {
|
|
return fmt.Errorf("guest vsock agent not ready: %w", lastErr)
|
|
}
|
|
return errors.New("guest vsock agent not ready before timeout")
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Utilities used across networking ------------------------------
|
|
|
|
func defaultVSockPath(runtimeDir, vmID string) string {
|
|
return filepath.Join(runtimeDir, "fc-"+system.ShortID(vmID)+".vsock")
|
|
}
|
|
|
|
func defaultVSockCID(guestIP string) (uint32, error) {
|
|
ip := net.ParseIP(strings.TrimSpace(guestIP)).To4()
|
|
if ip == nil {
|
|
return 0, fmt.Errorf("guest IP is not IPv4: %q", guestIP)
|
|
}
|
|
return 10000 + uint32(ip[3]), nil
|
|
}
|