tstest/integration: don't require TestMake, stop leaking binaries in /tmp

Previously all tests shared their tailscale+tailscaled binaries in
system /tmp directories, which often leaked, and required TestMain to
clean up (which feature/taildrop didn't use).

This makes it use testing.T.TempDir for the binaries, but still only
builds them once and efficiently as possible depending on the OS
copies them around between each test's temp dir.

Updates #15812

Change-Id: I0e2585613f272c3d798a423b8ad1737f8916f527
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2025-05-01 13:12:51 -07:00 committed by Brad Fitzpatrick
parent 3105ecd958
commit 761aea3036
4 changed files with 135 additions and 52 deletions

View File

@ -65,61 +65,151 @@ var (
// as a last ditch place to report errors. // as a last ditch place to report errors.
var MainError syncs.AtomicValue[error] var MainError syncs.AtomicValue[error]
// CleanupBinaries cleans up any resources created by calls to BinaryDir, TailscaleBinary, or TailscaledBinary. // Binaries contains the paths to the tailscale and tailscaled binaries.
// It should be called from TestMain after all tests have completed. type Binaries struct {
func CleanupBinaries() { Dir string
buildOnce.Do(func() {}) Tailscale BinaryInfo
if binDir != "" { Tailscaled BinaryInfo
os.RemoveAll(binDir) }
// BinaryInfo describes a tailscale or tailscaled binary.
type BinaryInfo struct {
Path string // abs path to tailscale or tailscaled binary
Size int64
// FD and FDmu are set on Unix to efficiently copy the binary to a new
// test's automatically-cleaned-up temp directory.
FD *os.File // for Unix (macOS, Linux, ...)
FDMu sync.Locker
// Contents is used on Windows instead of FD to copy the binary between
// test directories. (On Windows you can't keep an FD open while an earlier
// test's temp directories are deleted.)
// This burns some memory and costs more in I/O, but oh well.
Contents []byte
}
func (b BinaryInfo) CopyTo(dir string) (BinaryInfo, error) {
ret := b
ret.Path = filepath.Join(dir, path.Base(b.Path))
switch runtime.GOOS {
case "linux":
// TODO(bradfitz): be fancy and use linkat with AT_EMPTY_PATH to avoid
// copying? I couldn't get it to work, though.
// For now, just do the same thing as every other Unix and copy
// the binary.
fallthrough
case "darwin", "freebsd", "openbsd", "netbsd":
f, err := os.OpenFile(ret.Path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o755)
if err != nil {
return BinaryInfo{}, err
}
b.FDMu.Lock()
b.FD.Seek(0, 0)
size, err := io.Copy(f, b.FD)
b.FDMu.Unlock()
if err != nil {
f.Close()
return BinaryInfo{}, fmt.Errorf("copying %q: %w", b.Path, err)
}
if size != b.Size {
f.Close()
return BinaryInfo{}, fmt.Errorf("copy %q: size mismatch: %d != %d", b.Path, size, b.Size)
}
if err := f.Close(); err != nil {
return BinaryInfo{}, err
}
return ret, nil
case "windows":
return ret, os.WriteFile(ret.Path, b.Contents, 0o755)
default:
return BinaryInfo{}, fmt.Errorf("unsupported OS %q", runtime.GOOS)
} }
} }
// BinaryDir returns a directory containing test tailscale and tailscaled binaries. // GetBinaries create a temp directory using tb and builds (or copies previously
// If any test calls BinaryDir, there must be a TestMain function that calls // built) cmd/tailscale and cmd/tailscaled binaries into that directory.
// CleanupBinaries after all tests are complete. //
func BinaryDir(tb testing.TB) string { // It fails tb if the build or binary copies fail.
func GetBinaries(tb testing.TB) *Binaries {
dir := tb.TempDir()
buildOnce.Do(func() { buildOnce.Do(func() {
binDir, buildErr = buildTestBinaries() buildErr = buildTestBinaries(dir)
}) })
if buildErr != nil { if buildErr != nil {
tb.Fatal(buildErr) tb.Fatal(buildErr)
} }
return binDir if binariesCache.Dir == dir {
return binariesCache
} }
ts, err := binariesCache.Tailscale.CopyTo(dir)
// TailscaleBinary returns the path to the test tailscale binary. if err != nil {
// If any test calls TailscaleBinary, there must be a TestMain function that calls tb.Fatalf("copying tailscale binary: %v", err)
// CleanupBinaries after all tests are complete. }
func TailscaleBinary(tb testing.TB) string { tsd, err := binariesCache.Tailscaled.CopyTo(dir)
return filepath.Join(BinaryDir(tb), "tailscale"+exe()) if err != nil {
tb.Fatalf("copying tailscaled binary: %v", err)
}
return &Binaries{
Dir: dir,
Tailscale: ts,
Tailscaled: tsd,
} }
// TailscaledBinary returns the path to the test tailscaled binary.
// If any test calls TailscaleBinary, there must be a TestMain function that calls
// CleanupBinaries after all tests are complete.
func TailscaledBinary(tb testing.TB) string {
return filepath.Join(BinaryDir(tb), "tailscaled"+exe())
} }
var ( var (
buildOnce sync.Once buildOnce sync.Once
buildErr error buildErr error
binDir string binariesCache *Binaries
) )
// buildTestBinaries builds tailscale and tailscaled. // buildTestBinaries builds tailscale and tailscaled.
// It returns the dir containing the binaries. // On success, it initializes [binariesCache].
func buildTestBinaries() (string, error) { func buildTestBinaries(dir string) error {
bindir, err := os.MkdirTemp("", "") getBinaryInfo := func(name string) (BinaryInfo, error) {
bi := BinaryInfo{Path: filepath.Join(dir, name+exe())}
fi, err := os.Stat(bi.Path)
if err != nil { if err != nil {
return "", err return BinaryInfo{}, fmt.Errorf("stat %q: %v", bi.Path, err)
} }
err = build(bindir, "tailscale.com/cmd/tailscaled", "tailscale.com/cmd/tailscale") bi.Size = fi.Size()
switch runtime.GOOS {
case "windows":
bi.Contents, err = os.ReadFile(bi.Path)
if err != nil { if err != nil {
os.RemoveAll(bindir) return BinaryInfo{}, fmt.Errorf("read %q: %v", bi.Path, err)
return "", err
} }
return bindir, nil default:
bi.FD, err = os.OpenFile(bi.Path, os.O_RDONLY, 0)
if err != nil {
return BinaryInfo{}, fmt.Errorf("open %q: %v", bi.Path, err)
}
bi.FDMu = new(sync.Mutex)
// Note: bi.FD is copied around between tests but never closed, by
// design. It will be closed when the process exits, and that will
// close the inode that we're copying the bytes from for each test.
}
return bi, nil
}
err := build(dir, "tailscale.com/cmd/tailscaled", "tailscale.com/cmd/tailscale")
if err != nil {
return err
}
b := &Binaries{
Dir: dir,
}
b.Tailscale, err = getBinaryInfo("tailscale")
if err != nil {
return err
}
b.Tailscaled, err = getBinaryInfo("tailscaled")
if err != nil {
return err
}
binariesCache = b
return nil
} }
func build(outDir string, targets ...string) error { func build(outDir string, targets ...string) error {
@ -442,10 +532,11 @@ func NewTestEnv(t testing.TB, opts ...TestEnvOpt) *TestEnv {
} }
control.HTTPTestServer = httptest.NewUnstartedServer(control) control.HTTPTestServer = httptest.NewUnstartedServer(control)
trafficTrap := new(trafficTrap) trafficTrap := new(trafficTrap)
binaries := GetBinaries(t)
e := &TestEnv{ e := &TestEnv{
t: t, t: t,
cli: TailscaleBinary(t), cli: binaries.Tailscale.Path,
daemon: TailscaledBinary(t), daemon: binaries.Tailscaled.Path,
LogCatcher: logc, LogCatcher: logc,
LogCatcherServer: httptest.NewServer(logc), LogCatcherServer: httptest.NewServer(logc),
Control: control, Control: control,

View File

@ -49,7 +49,6 @@ func TestMain(m *testing.M) {
os.Setenv("TS_DISABLE_UPNP", "true") os.Setenv("TS_DISABLE_UPNP", "true")
flag.Parse() flag.Parse()
v := m.Run() v := m.Run()
CleanupBinaries()
if v != 0 { if v != 0 {
os.Exit(v) os.Exit(v)
} }

View File

@ -134,11 +134,12 @@ func newHarness(t *testing.T) *Harness {
loginServer := fmt.Sprintf("http://%s", ln.Addr()) loginServer := fmt.Sprintf("http://%s", ln.Addr())
t.Logf("loginServer: %s", loginServer) t.Logf("loginServer: %s", loginServer)
binaries := integration.GetBinaries(t)
h := &Harness{ h := &Harness{
pubKey: string(pubkey), pubKey: string(pubkey),
binaryDir: integration.BinaryDir(t), binaryDir: binaries.Dir,
cli: integration.TailscaleBinary(t), cli: binaries.Tailscale.Path,
daemon: integration.TailscaledBinary(t), daemon: binaries.Tailscaled.Path,
signer: signer, signer: signer,
loginServerURL: loginServer, loginServerURL: loginServer,
cs: cs, cs: cs,

View File

@ -28,7 +28,6 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
"tailscale.com/tstest" "tailscale.com/tstest"
"tailscale.com/tstest/integration"
"tailscale.com/types/logger" "tailscale.com/types/logger"
) )
@ -51,13 +50,6 @@ var (
}() }()
) )
func TestMain(m *testing.M) {
flag.Parse()
v := m.Run()
integration.CleanupBinaries()
os.Exit(v)
}
func TestDownloadImages(t *testing.T) { func TestDownloadImages(t *testing.T) {
if !*runVMTests { if !*runVMTests {
t.Skip("not running integration tests (need --run-vm-tests)") t.Skip("not running integration tests (need --run-vm-tests)")