banger/internal/daemon/host_network.go
Thales Maciel ecb18ce6ca
seams: move the last four package globals onto instance fields
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>
2026-04-22 12:07:14 -03:00

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
}