Documentation
¶
Overview ¶
Package testenv provides cross-platform process environment isolation for tests.
Index ¶
- Constants
- Variables
- func AllowRealServiceManagerInChildProcesses(reason string) (func(), error)
- func ConfigDir(home string) string
- func HomeEnv(home string) [][2]string
- func Main(m *testing.M, run func() int) int
- func PlantServiceManagerShims(dir string) (logPath string, planted []string, err error)
- func RealHostMain(m *testing.M, reason string, run func() int) int
- func Root() string
- func RunWithNonLoopbackDialGuard(run func() int, packageName string) int
- func RunWithServiceManagerGuard(run func() int, packageName string, seams ...ServiceManagerSeam) (code int)
- func SetHome(t testing.TB, home string) string
- func TailnetDevicePath(nodeID string) string
- func UnfakedHostSeam(seam string) error
- type ServiceManagerAttempt
- type ServiceManagerCall
- type ServiceManagerGuard
- func (g *ServiceManagerGuard) AllowReal(manager, reason string) func()
- func (g *ServiceManagerGuard) Attempts() []ServiceManagerAttempt
- func (g *ServiceManagerGuard) Blocked(manager string, real ServiceManagerCall) ServiceManagerCall
- func (g *ServiceManagerGuard) DriftedSeams() []string
- func (g *ServiceManagerGuard) Install(seams ...ServiceManagerSeam) func()
- func (g *ServiceManagerGuard) Report() bool
- func (g *ServiceManagerGuard) ServiceManagerShim() *ServiceManagerPathShim
- type ServiceManagerPathShim
- type ServiceManagerSeam
- type ServiceManagerShimCall
- type StatefulTailnet
- func (f *StatefulTailnet) Client(apiKey string) *tailscale.Client
- func (f *StatefulTailnet) DelayNext(method, path string, delay time.Duration)
- func (f *StatefulTailnet) Devices() []tailscale.Device
- func (f *StatefulTailnet) FailNext(method, path string, status int)
- func (f *StatefulTailnet) Policy() (string, string)
- func (f *StatefulTailnet) Requests() []TailnetRequest
- func (f *StatefulTailnet) SetDevices(devices []tailscale.Device)
- func (f *StatefulTailnet) SetPolicy(policy string)
- func (f *StatefulTailnet) SetTailnetSettings(settings tailscale.TailnetSettings)
- func (f *StatefulTailnet) URL() string
- type TailnetRequest
Constants ¶
const ( TailnetDevicesPath = "/api/v2/tailnet/-/devices" TailnetPolicyPath = "/api/v2/tailnet/-/acl" TailnetSettingsPath = "/api/v2/tailnet/-/settings" )
const DoctorSkipTailscaleSSHEnv = "TSLINK_DOCTOR_SKIP_TAILSCALE_SSH"
DoctorSkipTailscaleSSHEnv is the product knob that makes `tslink doctor` skip its read of the local tailscaled (cmd/doctor.go). Main sets it so every compiled tslink child a test runs inherits it; in-process tests fake the doctor seams instead, so the knob changes nothing inside the test binary.
const IsolationFailure = "testenv: cannot isolate this test binary"
IsolationFailure starts the message Main prints when it refuses to run a test binary it could not isolate.
const NetworkGuardReportEnv = "TSLINK_NETWORK_GUARD_REPORT"
const RootEnv = "TSLINK_TESTENV_ROOT"
RootEnv names the temporary root Main created for the running test binary. Every home, config, data and cache location of the process points inside it.
A test binary that starts with RootEnv naming a root whose marker a running test binary holds locked was started by a test in an already isolated binary (a helper re-exec such as `os.Args[0] -test.run=^TestX$`). Its TSLINK_ variables were put there by that test, not by the contributor's shell, so Main keeps them; it still moves every location into a fresh root of its own.
const RootPrefix = "tslink-testenv-"
RootPrefix is the name prefix of each root Main creates under os.TempDir().
const ServiceManagerGuardReportEnv = "TSLINK_SERVICE_MANAGER_GUARD_REPORT"
ServiceManagerGuardReportEnv makes the guard print its per-package summary line even when it recorded zero blocked attempts, so a green run can still prove the guard was installed rather than silently absent.
Accepted true values are 1, t, true, y, yes and on (case-insensitive). Anything else, including the empty string, is off. The spelling used to be an exact "1" comparison, which silently treated TSLINK_..._REPORT=true as off; a CI operator who asked for the audit trail and got silence had no way to tell that from "the guard is not installed".
const ServiceManagerShimExitCode = 97
ServiceManagerShimExitCode is the status the planted fakes exit with. It is outside the range tslink itself uses (0-5) and outside the shell's 126/127, so a test can tell "the shim refused" apart from both a real failure and a missing binary.
const ServiceManagerShimLogEnv = "TSLINK_TEST_SERVICE_MANAGER_SHIM_LOG"
ServiceManagerShimLogEnv names the file the planted fake service managers append their argv to. The shim bakes in a default path, so an invocation that inherits nothing still leaves a record; a test that wants its own record sets this on the child it spawns and reads that file instead.
const ServiceManagerShimStderrPrefix = "tslink-test-service-manager-shim:"
ServiceManagerShimStderrPrefix begins every line the planted fakes write to stderr. A child that prints this proves the resolution reached the shim rather than the real binary.
Variables ¶
var ErrServiceManagerBlocked = errors.New("test service manager guard blocked a real OS service manager call")
ErrServiceManagerBlocked is returned in place of running the real service manager binary. It is a sentinel no real launchctl/systemctl invocation can ever produce, so a test can assert on it with errors.Is and know for certain that nothing was executed.
Why this guard exists: on 2026-09-16 a `go test` run of package cmd reached the real launchctl exit and ran `bootout`+`bootstrap` against the live gui/<uid> domain. launchd's domain namespace is global to the login session, so it is reachable from any process regardless of HOME or t.TempDir(); the job label is a compile-time constant shared with the installed daemon. The test therefore replaced the developer's own com.tslink.daemon job with one pointing at the ephemeral cmd.test binary, and when the temp dir was cleaned up launchd could no longer start it (exit 78, EX_CONFIG). That daemon stayed down for 23 minutes. Path isolation cannot prevent this; only closing the process exit can.
var ErrUnfakedHostSeam = errors.New("a test reached a production seam that touches a real host resource without faking it")
ErrUnfakedHostSeam is returned by a host seam's test-binary default. A production seam that reaches a real host resource (a tsnet node, the local tailscaled, a browser) is replaced in its package's TestMain by a default that returns this error instead; a test that exercises the seam installs its own fake.
Functions ¶
func AllowRealServiceManagerInChildProcesses ¶
AllowRealServiceManagerInChildProcesses takes the planted shim directory back out of PATH, so that a test which must drive the caller's real service manager reaches it instead of a fake that answers ServiceManagerShimExitCode for everything.
It exists because the shim's protection is not selective: it rewrites this process's PATH once, and every child that inherits the environment resolves the manager to a fake. That is the right default and it is also wrong for exactly one test in this repo -- the systemd install e2e, which is gated behind TSLINK_SYSTEMD_E2E=1, refuses to run against anything that looks like a real installation, and exists precisely to drive real systemd inside a disposable VM. Without an opt-out that test does not fail; it silently verifies the shim.
The opt-out is deliberately loud and narrow:
- it requires a reason, and the reason is printed in the guard's teardown report alongside the seam-level AllowReal opt-ins, so a run that took it cannot look like a run that did not;
- it fails rather than degrading when no shim is installed, because a caller asking for this has decided the fakes are in its way, and quietly returning a no-op would leave it believing it got what it asked for;
- it only moves PATH. The in-process seams stay blocked; reaching those still requires ServiceManagerGuard.AllowReal.
Call the returned function to put PATH back.
func ConfigDir ¶
ConfigDir returns the non-empty TSLink config override associated with home. It deliberately does not require the directory to exist yet.
func HomeEnv ¶
HomeEnv returns the variable assignments that make home the only home of a process: the conventional home variables of every platform, the platform config/data/cache/state directories derived from them, and TSLink's config override. Main and SetHome both apply exactly this set.
func Main ¶
Main is the TestMain entry point of every test binary in this module. It isolates the process before any test runs, calls run (m.Run when nil), and returns the exit code for os.Exit:
- every TSLINK_ variable inherited from the contributor's environment is removed, so an exported credential or knob can neither be consumed by a test nor change a result; a test that needs one sets it with t.Setenv;
- HOME, USERPROFILE, APPDATA, LOCALAPPDATA, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, XDG_STATE_HOME and TSLINK_CONFIG_DIR point into one fresh temporary root, so os.UserHomeDir, os.UserConfigDir, os.UserCacheDir and config.Dir resolve there, in this process and in every child it starts;
- DoctorSkipTailscaleSSHEnv is set for compiled tslink children;
- a call to a host seam's test-binary default (UnfakedHostSeam) turns the package red with the call's stack;
- the root is removed after run returns, and roots that interrupted, timed-out or crashed binaries left in os.TempDir() are removed before the new one is made.
Everything that may still reach a real host resource is an explicit opt-in inside a test: t.Setenv, a fake that delegates to the real seam, or RealHostMain for a whole binary.
func PlantServiceManagerShims ¶
PlantServiceManagerShims writes one executable fake per service manager into dir, creates the log the fakes append to, and returns the log path together with the names it actually planted. The caller owns dir.
The fakes are /bin/sh scripts rather than compiled binaries so that planting them costs no build and cannot itself fail for toolchain reasons. On Windows they are not planted at all: none of these binaries exists there, and a shell script would not be executable anyway. That is stated here rather than hidden behind a nil return, because "the shim protects nothing on Windows" is a fact about the mechanism and not a detail.
func RealHostMain ¶
RealHostMain runs a test binary without Main's isolation. It exists for the one gated test that must reach real host state (the Linux systemd E2E, which cmd's TestMain admits only with TSLINK_SYSTEMD_E2E=1 and one exact -test.run selector on a disposable VM). reason is printed so the run's log records the opt-in. Unfaked host seams are still reported.
func Root ¶
func Root() string
Root returns the root Main created for this test binary, or "" when the binary is not running under Main. Before Main has run, a non-empty Root means the binary was started by a test of an isolated binary: helper modes that a TestMain dispatches before calling Main check it, so a variable a contributor exported cannot turn a top-level run into a helper.
Once Main has made this binary's root, Root reports it without opening its marker. This process holds that marker's lock, and only local flock promises that a second open file of it is a separate lock holder. Where Linux emulates flock with byte-range locks (NFS, SMB/CIFS), such a probe can be refused, and Root would report "", or be granted to the process that already holds the lock, and its unlock and close would then drop the lock while the root is in use, so another binary's reclaimStaleRoots could remove it.
func RunWithNonLoopbackDialGuard ¶
RunWithNonLoopbackDialGuard runs one package's complete test binary while intercepting the real TCP dial hook used by http.DefaultTransport. Loopback dials remain available for httptest servers; every non-loopback attempt is blocked, recorded, and turns an otherwise-passing package into a failure.
func RunWithServiceManagerGuard ¶
func RunWithServiceManagerGuard(run func() int, packageName string, seams ...ServiceManagerSeam) (code int)
RunWithServiceManagerGuard runs one package's complete test binary with every listed OS service manager exit closed. See ErrServiceManagerBlocked for the incident this prevents.
Teardown is deferred so a panic in run() still restores the seams and still emits the report: the blocked-attempt list is the only record of what went wrong, and losing it to a panic means the next reader sees a crash with no explanation.
func SetHome ¶
SetHome points every home, config, data and cache lookup of the process at home (the same variable set Main uses, see HomeEnv) and returns TSLink's config override. The override is always non-empty and does not need to exist yet; testing.TB.Setenv restores the prior environment exactly during cleanup.
func TailnetDevicePath ¶
TailnetDevicePath returns the exact REST path used by the v2 SDK for one device. Tests should pass a NodeID, matching TSLink's deletion contract.
func UnfakedHostSeam ¶
UnfakedHostSeam records that the named seam ran its test-binary default and returns an error that names it. Main prints every recorded call with its stack after the tests finish and fails the package, so a forgotten fake is loud even when the product code turns the error into a soft outcome.
Types ¶
type ServiceManagerAttempt ¶
ServiceManagerAttempt records one blocked call.
type ServiceManagerCall ¶
ServiceManagerCall is one function-shaped process exit to a service manager.
type ServiceManagerGuard ¶
type ServiceManagerGuard struct {
// contains filtered or unexported fields
}
ServiceManagerGuard intercepts service-manager process exits for the lifetime of one test binary. The default state is deny: a seam that no test stubbed returns ErrServiceManagerBlocked without executing anything, the call site is recorded with its stack, and the package exits non-zero.
func ActiveServiceManagerGuard ¶
func ActiveServiceManagerGuard() *ServiceManagerGuard
ActiveServiceManagerGuard returns the guard installed by the running RunWithServiceManagerGuard, or nil when no guard is installed. A package whose TestMain claims to be guarded can assert on this being non-nil.
func NewServiceManagerGuard ¶
func NewServiceManagerGuard(packageName string) *ServiceManagerGuard
NewServiceManagerGuard builds an installed-nowhere guard. Callers wire seams with Install; tests construct guards directly to exercise the guard itself.
func (*ServiceManagerGuard) AllowReal ¶
func (g *ServiceManagerGuard) AllowReal(manager, reason string) func()
AllowReal is the only way for a guarded seam to reach the real binary: forgetting to stub a seam cannot silently fall through to it, and every opt-in is named in the guard's report.
It is not a claim that the real binary is otherwise unreachable from a test process. A test can still build its own exec.Command, or re-point the seam variable at one (see DriftedSeams and the source inventory test for the two checks that cover those). The returned function revokes the opt-in.
func (*ServiceManagerGuard) Attempts ¶
func (g *ServiceManagerGuard) Attempts() []ServiceManagerAttempt
Attempts returns the blocked calls recorded so far.
func (*ServiceManagerGuard) Blocked ¶
func (g *ServiceManagerGuard) Blocked(manager string, real ServiceManagerCall) ServiceManagerCall
Blocked returns the replacement call for manager, remembering real so an explicit AllowReal opt-in can delegate to it later.
func (*ServiceManagerGuard) DriftedSeams ¶
func (g *ServiceManagerGuard) DriftedSeams() []string
DriftedSeams names every seam that is no longer pointing at guard code.
This is the commission half of the guard. Default-deny closes the omission case (a test that forgot to stub), but nothing stops a test from writing
launchctlCombinedOutput = func(args ...string) ([]byte, error) {
return exec.Command(theRealBinary, args...).CombinedOutput()
}
and never putting it back: that goes *through* the seam variable, so the guard's own closure is simply gone. Comparing the seam's current code pointer against the closure Install put there turns that into a red run.
What it cannot see, stated plainly:
- a seam that is re-pointed and then restored before this runs. Every closure Blocked returns shares one code pointer (they come from the same func literal), so "restored to a guard closure" is the only property checked, not "restored to *my* guard closure".
- anything that happens after the check: a leaked goroutine, or the window between restore() and process exit.
- a test that never touches the seam and calls exec.Command itself. That is the source inventory's job, not this one.
func (*ServiceManagerGuard) Install ¶
func (g *ServiceManagerGuard) Install(seams ...ServiceManagerSeam) func()
Install replaces every seam with its blocking counterpart and returns a restore function that puts the real implementations back.
A malformed seam panics rather than being skipped. A seam with a nil Get or Set protects nothing, and the old silent `continue` produced a guard that looked installed and reported nothing while one exit stayed wide open.
func (*ServiceManagerGuard) Report ¶
func (g *ServiceManagerGuard) Report() bool
Report writes the guard summary to stderr and reports whether the package must fail. A blocked attempt is always fatal for the package: the test that made it is not isolated, and the next run of it on a developer machine is what takes that developer's own installed daemon down.
An opt-in line is printed whenever one was taken, independent of the env var and of whether anything was blocked. An AllowReal that runs the real binary and blocks nothing used to leave no trace at all unless the operator had already set the report env, i.e. the audit record vanished in exactly the environment (CI) where nobody is watching. A run with no opt-ins and no blocked attempts stays silent unless the env asks.
func (*ServiceManagerGuard) ServiceManagerShim ¶
func (g *ServiceManagerGuard) ServiceManagerShim() *ServiceManagerPathShim
ServiceManagerShim returns the child-process PATH shim this guard installed, or nil when none was installed. A test uses it to find the default log and to point a child at a log of its own.
type ServiceManagerPathShim ¶
type ServiceManagerPathShim struct {
// Dir is the directory put at the front of PATH. It is set even on a
// platform where nothing was planted, because the log lives there too.
Dir string
// LogPath is the file the fakes append to when the child inherits no
// override for ServiceManagerShimLogEnv.
LogPath string
// Planted names the fakes that actually exist in Dir. It is empty on
// Windows, where the shim protects nothing; see PlantServiceManagerShims.
//
// It is a list rather than a bool because the teardown report has to say
// which of "no child called a manager" and "no fake was there to call"
// produced a count of zero. Those two read identically otherwise, and the
// second is the one that means the package ran unprotected.
Planted []string
}
ServiceManagerPathShim is a directory of fake service manager binaries plus the file they record into.
func InstallServiceManagerPathShim ¶
func InstallServiceManagerPathShim() (*ServiceManagerPathShim, func(), error)
InstallServiceManagerPathShim plants the fakes in a fresh temporary directory and puts that directory at the front of this process's PATH.
Why PATH and not a helper every spawn site has to call: production resolves its service manager by bare name (exec.Command("launchctl", ...)), and Go resolves a bare name against the *spawning* process's PATH. A child that inherits this process's environment therefore lands on the fake, and so does its own children. Inheriting is the default for exec.Cmd (a nil Env) and for every helper in this repo (append(os.Environ(), ...)), so the protected path is the one a spawn site gets by doing nothing. There is no call to forget.
Bypassing it requires a deliberate act that is visible in source: building cmd.Env from scratch without os.Environ(), overwriting PATH afterwards, or naming the binary by absolute path. The source scan in service_manager_exit_inventory_test.go is what covers those.
The returned restore function puts PATH back and removes the planted directory; read Calls() before calling it.
func (*ServiceManagerPathShim) Calls ¶
func (s *ServiceManagerPathShim) Calls() ([]ServiceManagerShimCall, error)
Calls parses this shim's log.
type ServiceManagerSeam ¶
type ServiceManagerSeam struct {
Manager string
Get func() ServiceManagerCall
Set func(ServiceManagerCall)
}
ServiceManagerSeam binds one package-level seam variable to the guard. Get captures the real implementation so an explicitly opted-in test can still reach it; Set installs the blocking replacement.
type ServiceManagerShimCall ¶
ServiceManagerShimCall is one recorded invocation of a planted fake.
func ReadServiceManagerShimCalls ¶
func ReadServiceManagerShimCalls(logPath string) ([]ServiceManagerShimCall, error)
ReadServiceManagerShimCalls parses a shim log into calls.
func (ServiceManagerShimCall) IsReadOnly ¶
func (c ServiceManagerShimCall) IsReadOnly() bool
IsReadOnly reports whether the call could not have changed system state.
func (ServiceManagerShimCall) String ¶
func (c ServiceManagerShimCall) String() string
String renders the call the way it was typed.
func (ServiceManagerShimCall) Verb ¶
func (c ServiceManagerShimCall) Verb() string
Verb is the first non-flag argument, which is what decides whether the call would have changed system state. An invocation with no such argument returns the empty string and is treated as unknown, i.e. not read-only.
type StatefulTailnet ¶
type StatefulTailnet struct {
// contains filtered or unexported fields
}
StatefulTailnet is an in-memory httptest implementation of the Tailscale v2 REST resources TSLink currently consumes: devices, policy file, and tailnet settings. Its wire payloads use the v2.9.0 SDK types and JSON field names.
func NewStatefulTailnet ¶
func NewStatefulTailnet(t testing.TB) *StatefulTailnet
NewStatefulTailnet starts an isolated loopback server with an empty policy.
func (*StatefulTailnet) Client ¶
func (f *StatefulTailnet) Client(apiKey string) *tailscale.Client
Client returns the real v2 SDK client configured for this fake.
func (*StatefulTailnet) DelayNext ¶
func (f *StatefulTailnet) DelayNext(method, path string, delay time.Duration)
DelayNext delays the next exact method/path request. A caller with a shorter context deadline observes a real HTTP timeout/cancellation.
func (*StatefulTailnet) Devices ¶
func (f *StatefulTailnet) Devices() []tailscale.Device
Devices returns an isolated snapshot of current device state.
func (*StatefulTailnet) FailNext ¶
func (f *StatefulTailnet) FailNext(method, path string, status int)
FailNext makes the next exact method/path request return an API error.
func (*StatefulTailnet) Policy ¶
func (f *StatefulTailnet) Policy() (string, string)
Policy returns the raw policy and its current HTTP ETag value.
func (*StatefulTailnet) Requests ¶
func (f *StatefulTailnet) Requests() []TailnetRequest
Requests returns recorded non-secret request metadata.
func (*StatefulTailnet) SetDevices ¶
func (f *StatefulTailnet) SetDevices(devices []tailscale.Device)
SetDevices replaces the fake tailnet's device state.
func (*StatefulTailnet) SetPolicy ¶
func (f *StatefulTailnet) SetPolicy(policy string)
SetPolicy replaces the raw HuJSON policy and advances its ETag.
func (*StatefulTailnet) SetTailnetSettings ¶
func (f *StatefulTailnet) SetTailnetSettings(settings tailscale.TailnetSettings)
SetTailnetSettings replaces the read-only settings response.
func (*StatefulTailnet) URL ¶
func (f *StatefulTailnet) URL() string
URL returns the loopback-only base URL accepted by tailapi.APIBaseURLEnv.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package localapitest builds Tailscale LocalAPI clients for tests.
|
Package localapitest builds Tailscale LocalAPI clients for tests. |