From 04dca5dada8c2d971ff3b54eeedc5ab6e53a29ac Mon Sep 17 00:00:00 2001 From: James O'Doherty Date: Thu, 4 Jun 2026 22:57:35 -0400 Subject: refactor: decouple namespace operations and improve test coverage - Introduce `namespace.Ops` interface to decouple `Manager` from system-level namespace operations, enabling easier unit testing via mocks. - Add unit tests for `internal/paths` to verify path resolution logic across different environment configurations. - Implement `EnsureBinary` helper in E2E tests to gracefully skip tests when `WG_WRAP_BIN` is not set, allowing `go test ./...` to pass in non-build environments. - Apply project-wide formatting and fix linting issues. --- internal/manager/manager.go | 68 ++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 34 deletions(-) (limited to 'internal/manager') diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 99a1a32..ffb02c0 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -9,11 +9,11 @@ // To support multiple concurrent commands on the same WireGuard tunnel without re-establishing // connections, wg-wrap employs session-based persistent, unprivileged namespaces. // -// 1. Tracking: Process usage is tracked using active PID files inside the runtime base directory. -// 2. Ref-Counting & Cleanup: Active PIDs are regularly pruned. When the last active process exits, -// the namespace is unpinned and resources are reclaimed. -// 3. Setns Join: When a new process is executed on an active profile, it discovers an active PID -// and attaches itself to the existing User, Mount, and Network namespaces of the active tunnel. +// 1. Tracking: Process usage is tracked using active PID files inside the runtime base directory. +// 2. Ref-Counting & Cleanup: Active PIDs are regularly pruned. When the last active process exits, +// the namespace is unpinned and resources are reclaimed. +// 3. Setns Join: When a new process is executed on an active profile, it discovers an active PID +// and attaches itself to the existing User, Mount, and Network namespaces of the active tunnel. package manager import ( @@ -30,22 +30,22 @@ import ( "git.theodohertyfamily.com/wg-wrap/pkg/wgconf" ) -// Manager orchestrates the high-level lifecycle of WireGuard tunnels -// and their associated network namespaces. type Manager struct { // PM is the path manager used to resolve configuration and runtime directories. PM *paths.PathManager + // NS handles the network namespace operations. + NS namespace.Ops } -// New creates a new Manager with the given path manager. -func New(pm *paths.PathManager) *Manager { - return &Manager{PM: pm} +// New creates a new Manager with the given path manager and namespace operations. +func New(pm *paths.PathManager, ns namespace.Ops) *Manager { + return &Manager{PM: pm, NS: ns} } // Bootstrap ensures the process is running in an isolated user and network namespace. // If an active session already exists for the profile, it joins it. func (m *Manager) Bootstrap(cfg *config.Config) error { - if namespace.IsIsolated() { + if m.NS.IsIsolated() { return nil } @@ -53,28 +53,28 @@ func (m *Manager) Bootstrap(cfg *config.Config) error { _ = os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", m.PM.RuntimeBaseDir()) // Acquire startup lock to prevent concurrent bootstrap/joining races. - lockFile, lockErr := namespace.AcquireProfileLock(m.PM, cfg.Profile) + lockFile, lockErr := m.NS.AcquireProfileLock(m.PM, cfg.Profile) if lockErr == nil { - defer namespace.ReleaseProfileLock(lockFile) + defer m.NS.ReleaseProfileLock(lockFile) } // Before bootstrapping, see if an active namespace/process for the profile exists. - activePid, err := namespace.FindActiveProfilePid(m.PM, cfg.Profile) + activePid, err := m.NS.FindActiveProfilePid(m.PM, cfg.Profile) if err == nil && activePid > 0 { // Release the lock before executing the command to allow others to join. - namespace.ReleaseProfileLock(lockFile) + m.NS.ReleaseProfileLock(lockFile) // Register this PID before joining to prevent the race where the joining process // hasn't registered itself yet, causing the existing process to think it's the last one. - _ = namespace.RegisterProcess(m.PM, cfg.Profile) + _ = m.NS.RegisterProcess(m.PM, cfg.Profile) - if err := namespace.BootstrapJoin(activePid); err != nil { + if err := m.NS.BootstrapJoin(activePid); err != nil { return fmt.Errorf("failed to join existing namespace: %w", err) } return nil } - if err := namespace.Bootstrap(); err != nil { + if err := m.NS.Bootstrap(); err != nil { return fmt.Errorf("bootstrap failed: %w", err) } @@ -84,25 +84,25 @@ func (m *Manager) Bootstrap(cfg *config.Config) error { // Execute manages the full execution lifecycle inside an isolated namespace: // lock acquisition, PID registration, tunnel initialization, command execution, and cleanup. func (m *Manager) Execute(cfg *config.Config, verbose bool) error { - if !namespace.IsIsolated() { + if !m.NS.IsIsolated() { return fmt.Errorf("Execute called without namespace isolation") } // Acquire execution lock during configuration and startup inside the namespace. - lockFile, lockErr := namespace.AcquireProfileLock(m.PM, cfg.Profile) + lockFile, lockErr := m.NS.AcquireProfileLock(m.PM, cfg.Profile) var lockFileReleased bool if lockErr == nil { defer func() { if !lockFileReleased { - namespace.ReleaseProfileLock(lockFile) + m.NS.ReleaseProfileLock(lockFile) } }() } - if err := namespace.PruneStalePids(m.PM, cfg.Profile); err != nil { + if err := m.NS.PruneStalePids(m.PM, cfg.Profile); err != nil { fmt.Fprintf(os.Stderr, "failed to prune stale pids: %v\n", err) } - if err := namespace.RegisterProcess(m.PM, cfg.Profile); err != nil { + if err := m.NS.RegisterProcess(m.PM, cfg.Profile); err != nil { return fmt.Errorf("failed to register process: %w", err) } @@ -113,31 +113,31 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error { if lockErr == nil && !lockFileReleased { cleanupLock = lockFile } else { - cleanupLock, cleanupErr = namespace.AcquireProfileLock(m.PM, cfg.Profile) + cleanupLock, cleanupErr = m.NS.AcquireProfileLock(m.PM, cfg.Profile) } if cleanupErr == nil { - if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil { + if err := m.NS.UnregisterProcess(m.PM, cfg.Profile); err != nil { fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err) } - if err := namespace.PruneStalePids(m.PM, cfg.Profile); err != nil { + if err := m.NS.PruneStalePids(m.PM, cfg.Profile); err != nil { fmt.Fprintf(os.Stderr, "failed to prune stale pids during cleanup: %v\n", err) } - last, lastErr := namespace.IsLastProcess(m.PM, cfg.Profile) + last, lastErr := m.NS.IsLastProcess(m.PM, cfg.Profile) if lastErr == nil && last { - if err := namespace.UnpinNamespace(m.PM, cfg.Profile); err != nil { + if err := m.NS.UnpinNamespace(m.PM, cfg.Profile); err != nil { fmt.Fprintf(os.Stderr, "failed to unpin namespace: %v\n", err) } } if lockErr == nil && !lockFileReleased { lockFileReleased = true } - namespace.ReleaseProfileLock(cleanupLock) + m.NS.ReleaseProfileLock(cleanupLock) } else { - if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil { + if err := m.NS.UnregisterProcess(m.PM, cfg.Profile); err != nil { fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err) } } @@ -191,7 +191,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error { } defer tunnel.Close() - if err := namespace.PinNamespace(m.PM, cfg.Profile); err != nil { + if err := m.NS.PinNamespace(m.PM, cfg.Profile); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to pin namespace: %v\n", err) } } else { @@ -203,7 +203,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error { } lockFileReleased = true - namespace.ReleaseProfileLock(lockFile) + m.NS.ReleaseProfileLock(lockFile) cmd := exec.Command(cfg.Command[0], cfg.Command[1:]...) cmd.Stdin = os.Stdin @@ -220,7 +220,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error { // StopProfile stops a profile session by unpinning its namespace. func (m *Manager) StopProfile(profile string) error { - if err := namespace.UnpinNamespace(m.PM, profile); err != nil { + if err := m.NS.UnpinNamespace(m.PM, profile); err != nil { return fmt.Errorf("failed to stop profile: %w", err) } return nil @@ -228,7 +228,7 @@ func (m *Manager) StopProfile(profile string) error { // VerifyLifecycle checks for an active session for the given profile. func (m *Manager) VerifyLifecycle(profile string) error { - activePid, err := namespace.FindActiveProfilePid(m.PM, profile) + activePid, err := m.NS.FindActiveProfilePid(m.PM, profile) if err != nil || activePid <= 0 { return fmt.Errorf("no active session found for profile %s", profile) } -- cgit v1.2.3