// Package dockerexec is the ONE way the controller builds a `docker` / `docker compose` / // `docker-compose` process. It exists for R-650: the controller's unit tests run on DooPlex, the // build host, which is production Docker (Gitea, the registry, k3s). A test that fell through to a // real `docker run … tar` created a volume there, and one ran a real `docker compose down`. // // Under `go test` a docker command is REFUSED — its Start/Run/Output returns an error naming the // command — unless one of two things holds: // // - FELHOM_TEST_REAL_DOCKER=1 is set (a deliberate, per-run opt-in); or // - the executable resolves under os.TempDir() — a stub a test wrote into t.TempDir() and put on // PATH, which is a seam, not Docker. // // Outside `go test` (the real controller) nothing changes: Command is exec.Command. // Pinned by TestR650_* in this package and by the repo-wide TestR650_NoBareDockerExec sweep. package dockerexec import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" ) // OptInEnv is the environment variable that lets a test reach the real docker on purpose. const OptInEnv = "FELHOM_TEST_REAL_DOCKER" // underTest is a variable so this package's own tests can model the production binary. var underTest = testing.Testing // IsDocker reports whether name is a docker binary (by base name). func IsDocker(name string) bool { b := filepath.Base(name) return b == "docker" || b == "docker-compose" } // refusal returns the error a refused command carries, or nil when the command may run. func refusal(name string, args []string) error { if !IsDocker(name) || !underTest() || os.Getenv(OptInEnv) == "1" { return nil } if p, err := exec.LookPath(name); err == nil { if abs, aerr := filepath.Abs(p); aerr == nil { tmp := filepath.Clean(os.TempDir()) + string(os.PathSeparator) if strings.HasPrefix(abs, tmp) { return nil // a test's own stub on PATH } } } return fmt.Errorf("R-650: refused to run the real %q under go test (this host may be production Docker); "+ "use a seam or a stub on PATH, or set %s=1 deliberately", strings.TrimSpace(name+" "+strings.Join(args, " ")), OptInEnv) } // Command is exec.Command for a docker binary, refused under go test (see package doc). // A non-docker name passes through unchanged, so a generic runner may call it for any command. func Command(name string, args ...string) *exec.Cmd { cmd := exec.Command(name, args...) if err := refusal(name, args); err != nil { cmd.Err = err } return cmd } // CommandContext is exec.CommandContext with the same guard. func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, name, args...) if err := refusal(name, args); err != nil { cmd.Err = err } return cmd } // Runner is what *testing.M offers; named so this file does not need a test-only type. type Runner interface{ Run() int } // RunWithStub is for a package's TestMain: it puts a `docker` and a `docker-compose` that print // nothing and exit 0 at the front of PATH for the whole test binary, then runs the tests. It is the // package-wide seam for fixtures that build a real stacks.Manager (whose NewManager/ScanStacks run // `docker compose version` and `docker ps`). A test that needs a specific answer still writes its // own stub; a test that needs the real docker sets OptInEnv. (R-650) func RunWithStub(m Runner) int { dir, err := os.MkdirTemp("", "r650-docker-stub-") if err != nil { fmt.Fprintln(os.Stderr, "R-650 stub:", err) return 1 } defer os.RemoveAll(dir) for _, n := range []string{"docker", "docker-compose"} { if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { fmt.Fprintln(os.Stderr, "R-650 stub:", err) return 1 } } os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) return m.Run() }