diff options
Diffstat (limited to 'internal/testsuite/testsuite.go')
| -rw-r--r-- | internal/testsuite/testsuite.go | 910 |
1 files changed, 910 insertions, 0 deletions
diff --git a/internal/testsuite/testsuite.go b/internal/testsuite/testsuite.go new file mode 100644 index 00000000..395bf8f2 --- /dev/null +++ b/internal/testsuite/testsuite.go @@ -0,0 +1,910 @@ +// Package testsuite provides many quick-and-dirty integration testing utilities. +// +// Attempting to import this package outside testing causes the resulting +// program to panic. +package testsuite + +import ( + "bufio" + "context" + "crypto/sha512" + "encoding/json" + "errors" + "fmt" + "io" + "iter" + "log" + "os" + "os/exec" + "os/signal" + "os/user" + "path/filepath" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "hakurei.app/check" + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/store" +) + +// serial is the next value returned by [NewSerial]. +var serial atomic.Uint64 + +// NewSerial returns a serial number. +func NewSerial() uint64 { return serial.Add(1) - 1 } + +// ReceiveSignals blocks until a termination signal arrives, and terminates. +func ReceiveSignals() { + s := make(chan os.Signal, 3) + signal.Notify(s, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + log.Fatalf("terminating on signal %s", <-s) +} + +var ( + // users caches [user.LookupId] calls. + users = make(map[string]*user.User) + // usersMu synchronises access to users. + usersMu sync.RWMutex +) + +// mustLookupId is like [user.LookupId], but the first result is cached. +func mustLookupId(uid string) *user.User { + usersMu.RLock() + v, ok := users[uid] + usersMu.RUnlock() + if ok { + return v + } + + u, err := user.LookupId(uid) + if err != nil { + log.Fatal(err) + } + + usersMu.Lock() + users[uid] = u + usersMu.Unlock() + return u +} + +// MustAppendEnv adds extra environment variables to cmd. +func MustAppendEnv(cmd *exec.Cmd, env ...string) { + if len(cmd.Env) == 0 { + var cred syscall.Credential + if cmd.SysProcAttr != nil && cmd.SysProcAttr.Credential != nil { + cred = *cmd.SysProcAttr.Credential + } + u := mustLookupId(strconv.Itoa(int(cred.Uid))) + + cmd.Env = append(cmd.Env, + "PATH="+os.Getenv("PATH"), + "HOME="+u.HomeDir, + "USER="+u.Username, + "USERNAME="+u.Username, + ) + } + cmd.Env = append(cmd.Env, env...) +} + +// MustRun runs command and terminates the testsuite on error. +func MustRun(cred *syscall.Credential, extraEnv []string, command ...string) { + cmd := exec.Command(command[0], command[1:]...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGKILL, + Credential: cred, + } + MustAppendEnv(cmd, extraEnv...) + if err := cmd.Run(); err != nil { + log.Fatalf("must run %s: %v", strings.Join(command, " "), err) + } +} + +// ErrUnexpectedSuccess is returned for processes expected to exit with a +// non-zero code, but failed to do so. +var ErrUnexpectedSuccess = errors.New("process unexpectedly exited with code 0") + +// MustFail runs command and terminates the testsuite if the program fails to +// start or exits with code 0. +func MustFail( + cred *syscall.Credential, + extraEnv []string, + quiet bool, + command ...string, +) { + cmd := exec.Command(command[0], command[1:]...) + if !quiet { + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGKILL, + Credential: cred, + } + MustAppendEnv(cmd, extraEnv...) + if err := cmd.Run(); err == nil { + log.Fatal(ErrUnexpectedSuccess) + } else if e, ok := errors.AsType[*exec.ExitError](err); !ok { + log.Fatalf("must fail %s: %v", strings.Join(command, " "), err) + } else if !e.Exited() { + log.Fatalf("must fail %s: %v", strings.Join(command, " "), e) + } +} + +// MustStart starts cmd and returns a channel delivering its wait error. +func MustStart(cmd *exec.Cmd) (done <-chan error) { + if err := cmd.Start(); err != nil { + log.Fatal(err) + } + d := make(chan error, 1) + go func() { d <- cmd.Wait() }() + return d +} + +// MustStartWith wraps [MustStart] and creates the [exec.Cmd] object internally. +func MustStartWith( + ctx context.Context, + cred *syscall.Credential, + quiet bool, + extraEnv []string, + files []*os.File, + command ...string, +) (proc *os.Process, done <-chan error) { + cmd := exec.CommandContext(ctx, command[0], command[1:]...) + if !quiet { + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + } + cmd.ExtraFiles = files + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGTERM, + Credential: cred, + } + MustAppendEnv(cmd, extraEnv...) + return cmd.Process, MustStart(cmd) +} + +// MustOutput is like [MustRun] and [MustFail], but collects the entire output +// in memory and returns them as strings. +func MustOutput( + ctx context.Context, + cred *syscall.Credential, + extraEnv []string, + exitCode int, + name string, + command ...string, +) (string, string) { + var stdout, stderr strings.Builder + cmd := exec.CommandContext(ctx, command[0], command[1:]...) + cmd.Stdout, cmd.Stderr = &stdout, &stderr + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGTERM, + Credential: cred, + } + MustAppendEnv(cmd, extraEnv...) + + err := cmd.Run() + if err == nil { + if exitCode == 0 { + return stdout.String(), stderr.String() + } + log.Fatalf( + "%s succeeded unexpectedly"+ + "\nstdout = %q\nstderr = %q", + name, + stdout.String(), + stderr.String(), + ) + } + + if e, ok := errors.AsType[*exec.ExitError](err); !ok { + log.Fatal(err) + } else if e.ExitCode() != exitCode { + log.Fatalf( + "%s: %v"+ + "\nstdout = %q\nstderr = %q", + name, e, + stdout.String(), + stderr.String(), + ) + } + return stdout.String(), stderr.String() +} + +// AssertOutput returns a function that terminates the program if called with +// arguments differing from the ones it is initialised with. +func AssertOutput(action, stdout, stderr string) ( + assert func(stdout, stderr string), +) { + return func(gotStdout, gotStderr string) { + if stdout != gotStdout || stderr != gotStderr { + log.Fatalf( + "unexpected output %s"+ + "\nstdout = %q\nstderr = %q", + action, + gotStdout, + gotStderr, + ) + } + } +} + +// MustCheckFilter is like [CheckFilter], but terminates the test suite if a +// non-nil error is returned. Otherwise, the tracee is terminated after it +// resumes. +func MustCheckFilter(name string, pid int, sum [sha512.Size]byte) { + if err := CheckFilter( + pid, + 1, /* podman installs its own filter */ + sum, + ); err != nil { + log.Fatalln(name, err) + } else if err = syscall.Kill(pid, syscall.SIGTERM); err != nil { + log.Fatalf("cannot terminate tracee: %v", err) + } +} + +// FilterTerminated returns a non-nil error if err is not an [exec.ExitError] +// describing a process terminated by a syscall.SIGTERM signal. +func FilterTerminated(err error) error { + if err == nil { + return ErrUnexpectedSuccess + } + + e, ok := errors.AsType[*exec.ExitError](err) + if !ok { + return err + } + + if e.ExitCode() == 0x80+int(syscall.SIGTERM) { + return nil + } + return e +} + +// MustDecodeFrom runs the specified command and decodes its JSON output, +// storing the result in the value pointed to by v. +func MustDecodeFrom[T any]( + ctx context.Context, + cred *syscall.Credential, + env []string, + v *T, + name string, + arg ...string, +) { + cmd := exec.CommandContext(ctx, name, arg...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGKILL, + Credential: cred, + } + cmd.Stderr = os.Stderr + + r, err := cmd.StdoutPipe() + if err != nil { + log.Fatal(err) + } + + if err = cmd.Start(); err != nil { + log.Fatalf("start %s: %v", name, err) + } + + if err = json.NewDecoder(r).Decode(v); err != nil { + log.Fatalf("decode %s: %v", name, err) + } + + if err = cmd.Wait(); err != nil { + log.Fatalf("wait for %s: %v", name, err) + } +} + +// Poll repeatedly runs command until it succeeds. +func Poll( + d time.Duration, + cred *syscall.Credential, + extraEnv []string, + command ...string, +) { + for range time.NewTicker(d).C { + cmd := exec.Command(command[0], command[1:]...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGKILL, + Credential: cred, + } + MustAppendEnv(cmd, extraEnv...) + if err := cmd.Run(); err != nil { + if e, ok := errors.AsType[*exec.ExitError](err); ok && e.Exited() { + continue + } + log.Fatalf("poll %s: %v", strings.Join(command, " "), err) + } + break + } +} + +// PollPID polls until the process referred to by pid no longer exists. +func PollPID(name string, pid int) { + for { + time.Sleep(10 * time.Millisecond) + if err := syscall.Kill(pid, 0); err == nil { + continue + } else if errors.Is(err, syscall.ESRCH) { + break + } else { + log.Fatalf("cannot poll for %s: %v", name, err) + } + } +} + +const ( + // XDGRuntimeDir is the hardcoded XDG runtime directory of the only user + // mapped in hsurc. + XDGRuntimeDir = "/var/run/user/1000" + + // XDGRuntimeEnv is the environment variable string for XDG_RUNTIME_DIR. + XDGRuntimeEnv = "XDG_RUNTIME_DIR=" + XDGRuntimeDir + + // HakureiPrefix is the hakurei work directory of the only user mapped in + // hsurc. + HakureiPrefix = "/tmp/hakurei.0/" +) + +// MustPrepareUserDirs creates user-specific directories. +func MustPrepareUserDirs(cred *syscall.Credential) { + if err := os.MkdirAll(XDGRuntimeDir, 0700); err != nil { + log.Fatal(err) + } else if err = os.Chown( + XDGRuntimeDir, + int(cred.Uid), + int(cred.Gid), + ); err != nil { + log.Fatal(err) + } +} + +// SessionEnv returns environment variables referring to the hosted session. +func SessionEnv(dbusEnv string) []string { + return []string{ + "PATH=" + os.Getenv("PATH"), + "TERM=xterm", + XDGRuntimeEnv, + WaylandEnv, + "DISPLAY=:0", + dbusEnv, + } +} + +// quietServices is initialised from the environment and controls whether +// session services should have their outputs connected. +var quietServices = os.Getenv("HAKUREI_VERBOSE_SERVICES") == "" + +// MustStartSessionBus starts a session bus that is never explicitly terminated. +// The test suite is terminated if the session bus daemon terminates. +func MustStartSessionBus(cred *syscall.Credential) (dbusEnv string) { + r, w, err := os.Pipe() + if err != nil { + log.Fatal(err) + } + + // this is never explicitly terminated + _, done := MustStartWith( + context.Background(), cred, quietServices, nil, []*os.File{w}, + "dbus-daemon", + "--print-address=3", + "--address=unix:path="+XDGRuntimeDir+"/dbus", + "--session", + "--nofork", + "--nopidfile", + ) + + go func() { + if _err := <-done; _err != nil { + log.Fatalf("session bus terminated unexpectedly: %v", _err) + } + log.Fatal("session bus terminated unexpectedly") + }() + + dbusEnv, err = bufio.NewReader(r).ReadString('\n') + if err != nil { + log.Fatal(err) + } + dbusEnv = dbusEnv[:len(dbusEnv)-1] + log.Printf("dbus listening on %s", dbusEnv) + dbusEnv = "DBUS_SESSION_BUS_ADDRESS=" + dbusEnv + + if err = r.Close(); err != nil { + log.Fatal(err) + } + return +} + +const ( + // SwayEnv is the environment variable string for the sway IPC socket. + SwayEnv = "SWAYSOCK=" + XDGRuntimeDir + "/sway" + // WaylandEnv is the environment variable string for the wayland display. + WaylandEnv = "WAYLAND_DISPLAY=wayland-1" +) + +// MustStartSway starts the sway wayland display server which must be terminated +// by calling [TerminateSway]. +func MustStartSway( + wg *sync.WaitGroup, + cred *syscall.Credential, + dbusEnv string, +) { + wg.Go(func() { + // this is terminated via swaymsg + _, done := MustStartWith( + context.Background(), cred, quietServices, []string{ + "WLR_BACKENDS=headless", + XDGRuntimeEnv, + SwayEnv, + dbusEnv, + }, nil, + "sway", + ) + if err := <-done; err != nil { + log.Fatalf("sway terminated unexpectedly: %v", err) + } + }) + + Poll( + 50*time.Millisecond, + cred, + []string{SwayEnv}, + "swaymsg", + ) + Poll( + 50*time.Millisecond, + cred, + []string{"DISPLAY=:0"}, + "xset", "q", + ) + log.Printf("sway available via %s", SwayEnv) +} + +// TerminateSway requests for the sway server to terminate via sway IPC. +func TerminateSway(cred *syscall.Credential) { + MustFail(cred, []string{SwayEnv}, quietServices, "swaymsg", "exit") +} + +// SwayNode represents a Sway window with hakurei-specific fields. +type SwayNode struct { + // Window title. + Name string `json:"name"` + // Descendents. + Nodes []SwayNode `json:"nodes"` + // Wayland app id. + AppID string `json:"app_id,omitempty"` + + // Should always be app.hakurei for cmd/hakurei containers. + Sandbox string `json:"sandbox_engine,omitempty"` + // Configured via [hst]. + ID string `json:"sandbox_app_id,omitempty"` + // Hakurei instance identifier. + Instance hst.ID `json:"sandbox_instance_id,omitempty"` +} + +// All returns an iterator over n and all descendents of n. +func (n *SwayNode) All() iter.Seq[*SwayNode] { + return func(yield func(*SwayNode) bool) { + if !yield(n) { + return + } + + for _, d := range n.Nodes { + for v := range d.All() { + if !yield(v) { + return + } + } + } + } +} + +// PollSwayWindow continuously polls sway for a Wayland client to appear. +func PollSwayWindow( + ctx context.Context, + cred *syscall.Credential, + f func(n *SwayNode) bool, +) *SwayNode { + var t SwayNode + for { + time.Sleep(25 * time.Millisecond) + MustDecodeFrom( + ctx, cred, []string{SwayEnv}, &t, + "swaymsg", "-t", "get_tree", + ) + for n := range t.All() { + if f(n) { + return n + } + } + } +} + +// MustStartPipeWire starts a PipeWire server that is never explicitly +// terminated. The test suite is terminated if the PipeWire server terminates. +func MustStartPipeWire(cred *syscall.Credential, dbusEnv string) (version string) { + version, _ = MustOutput( + context.Background(), nil, nil, 0, + "pipewire version", + "pipewire", "--version", + ) + _, version, _ = strings.Cut(version, "Compiled with libpipewire ") + _, version, _ = strings.Cut(version, "\nLinked with libpipewire") + version = strings.TrimSpace(version) + + // this is never explicitly terminated + _, done := MustStartWith( + context.Background(), cred, quietServices, []string{ + XDGRuntimeEnv, + dbusEnv, + }, nil, + "pipewire", + ) + + go func() { + if _err := <-done; _err != nil { + log.Fatalf("pipewire terminated unexpectedly: %v", _err) + } + log.Fatal("pipewire terminated unexpectedly") + }() + + Poll(50*time.Millisecond, cred, []string{ + XDGRuntimeEnv, + dbusEnv, + }, + "wpctl", + "status", + ) + + Poll(50*time.Millisecond, cred, []string{ + XDGRuntimeEnv, + dbusEnv, + }, + "pw-cli", + "load-module", + "libpipewire-module-null-sink", + ) + + _, _done := MustStartWith( + context.Background(), cred, quietServices, []string{ + XDGRuntimeEnv, + dbusEnv, + }, nil, + "wireplumber", + ) + + go func() { + if _err := <-_done; _err != nil { + log.Fatalf("wireplumber terminated unexpectedly: %v", _err) + } + log.Fatal("wireplumber terminated unexpectedly") + }() + return +} + +// MustScanFor continuously scans the proc filesystem and calls f for each entry +// visited. +func MustScanFor(f func(ps *StatScanner) bool) int { + var ps StatScanner + + for ps.Scan() { + if f(&ps) { + break + } + } + if err := ps.Err(); err != nil { + log.Fatal(err) + } + return ps.Stat().PID +} + +// storeLookup describes a store lookup request. +type storeLookup struct { + // Identifier to look up. + id hst.ID + // Address of value to write result to. + p *hst.State + // Error encountered while fulfilling the request. + done chan<- error +} + +var ( + // storeThreads holds channels for dispatching requests to goroutines + // locked to threads configured with specific credentials. + storeThreads = make(map[[2]uint32]chan<- storeLookup) + // storeThreadsMu synchronises access to storeThreads. + storeThreadsMu sync.Mutex +) + +// LookupStore looks up the specified identifier from the hakurei state store +// and stores its contents in the value pointed to by p. +func LookupStore(cred *syscall.Credential, id hst.ID, p *hst.State) error { + var key [2]uint32 + if cred != nil { + key[0], key[1] = cred.Uid, cred.Gid + } + + storeThreadsMu.Lock() + c, ok := storeThreads[key] + if !ok { + s := store.New(check.MustAbs(HakureiPrefix + "state")) + r := make(chan storeLookup) + go func() { + if cred != nil { + runtime.LockOSThread() + if err := syscall.Setfsuid(int(cred.Uid)); err != nil { + log.Fatalf("setfsuid: %v", err) + } else if err = syscall.Setfsgid(int(cred.Gid)); err != nil { + log.Fatalf("setfsgid: %v", err) + } + } + + req: + for req := range r { + ok = false + entries, copyError := s.All() + for entry := range entries { + if entry.ID == req.id { + ok = true + if _, err := entry.Load(req.p, nil); err != nil { + req.done <- err + continue req + } + break + } + } + if err := copyError(); err != nil { + req.done <- err + continue + } + if !ok { + req.done <- fmt.Errorf("instance %s is not present in store", id.String()) + continue + } + req.done <- nil + } + }() + c = r + storeThreads[key] = c + } + storeThreadsMu.Unlock() + + done := make(chan error, 1) + c <- storeLookup{id, p, done} + return <-done +} + +// StartHakurei starts a hakurei container configured by c. +func StartHakurei( + ctx context.Context, + wg *sync.WaitGroup, + cred *syscall.Credential, + dbusEnv string, + processCommand func(cmd *exec.Cmd), + onDone func(err error, output string), + scan, wait, verbose, insecure bool, + c *hst.Config, +) (p hst.State, pid int, err error) { + var hakureiFlags string + if verbose { + hakureiFlags += " -v" + } + if insecure { + hakureiFlags += " --insecure" + } + + var notifySuffix string + if wait { + notifySuffix += " 1>&3" + } + + cmd := exec.CommandContext(ctx, + "script", "/dev/null", + "-E", "always", + "-qec", + "hakurei"+hakureiFlags+" run "+ + "--identifier-fd=5"+ + " 4"+notifySuffix, + ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGTERM, + Credential: cred, + } + var output strings.Builder + cmd.Stdout, cmd.Stderr = &output, &output + cmd.Env = SessionEnv(dbusEnv) + + var notify, _notify, _conf, conf, ident, _ident *os.File + if notify, _notify, err = os.Pipe(); err != nil { + return + } + cmd.ExtraFiles = append(cmd.ExtraFiles, _notify) + if _conf, conf, err = os.Pipe(); err != nil { + _, _ = notify.Close(), _notify.Close() + return + } + cmd.ExtraFiles = append(cmd.ExtraFiles, _conf) + if ident, _ident, err = os.Pipe(); err != nil { + _, _ = notify.Close(), _notify.Close() + _, _ = conf.Close(), _conf.Close() + return + } + cmd.ExtraFiles = append(cmd.ExtraFiles, _ident) + if processCommand != nil { + processCommand(cmd) + } + + done := MustStart(cmd) + wg.Go(func() { _err := <-done; onDone(_err, output.String()) }) + + if err = errors.Join( + _notify.Close(), + _conf.Close(), + _ident.Close(), + ); err != nil { + _, _, _ = notify.Close(), conf.Close(), ident.Close() + return + } + + if err = json.NewEncoder(conf).Encode(c); err != nil { + _, _, _ = notify.Close(), conf.Close(), ident.Close() + return + } else if err = conf.Close(); err != nil { + _, _ = notify.Close(), ident.Close() + return + } + + if !scan && len(cmd.ExtraFiles) != 3 { + err = errors.Join(notify.Close(), ident.Close()) + return + } + + var id hst.ID + if _, err = io.ReadFull(ident, id[:]); err != nil { + _, _ = notify.Close(), ident.Close() + if !scan && errors.Is(err, io.EOF) { + err = nil + } + return + } else if err = ident.Close(); err != nil { + _ = notify.Close() + return + } + + if !scan { + err = notify.Close() + return + } + + if wait { + if _, err = io.ReadFull(notify, make([]byte, 8)); err != nil { + _ = notify.Close() + return + } + } + if err = notify.Close(); err != nil { + return + } + + var identifiers []hst.ID + MustDecodeFrom( + ctx, cred, nil, &identifiers, + "hakurei", "--json", "ps", "--short", + ) + if !slices.Contains(identifiers, id) { + log.Fatalf("(%s) instance not present in ps output", c.Container.Hostname) + } + + var got hst.State + MustDecodeFrom( + ctx, cred, nil, &got, + "hakurei", "--json", "show", id.String(), + ) + + if err = LookupStore(cred, id, &p); err != nil { + return + } + + if !reflect.DeepEqual(got, p) { + log.Fatalf("show: %#v, want %#v", got, p) + } + + var stat syscall.Stat_t + pid = MustScanFor(func(ps *StatScanner) bool { + select { + case err = <-done: + if err == nil { + log.Fatal("test process terminated unexpectedly") + } + log.Fatal(err) + default: + break + } + + var args []string + if args, err = ps.Stat().Args(); err != nil { + if IsNotExist(err) { + return false + } + log.Fatal(err) + } else if !slices.Equal(args, c.Container.Args) { + return false + } + + if err = ps.Stat().Stat(&stat); err != nil { + if IsNotExist(err) { + return false + } + log.Fatal(err) + } + + uid := hst.ToUser[uint32](0, uint32(c.Identity)) + if stat.Uid != uid || stat.Gid != uid { + return false + } + + var t []byte + if t, err = os.ReadFile(filepath.Join( + fhs.Proc, + strconv.Itoa(ps.Stat().PPID), + "stat", + )); err != nil { + if IsNotExist(err) { + return false + } + log.Fatal(err) + } + + var _stat Stat + if err = _stat.UnmarshalText(t); err != nil { + log.Fatal(err) + } + if _stat.PPID != p.ShimPID { + return false + } + + return true + }) + return +} + +// MustStartHakurei is like [StartHakurei], but terminates the test suite if +// an error is encountered. +func MustStartHakurei( + ctx context.Context, + wg *sync.WaitGroup, + cred *syscall.Credential, + dbusEnv string, + onDone func(err error, output string), + scan, wait, verbose, insecure bool, + c *hst.Config, +) (p hst.State, pid int) { + _ctx, cancel := context.WithCancel(ctx) + + var err error + p, pid, err = StartHakurei( + _ctx, wg, cred, dbusEnv, + nil, func(err error, output string) { + defer cancel() + onDone(err, output) + }, scan, wait, verbose, insecure, c, + ) + if err != nil { + log.Printf( + "(%s) cannot start hakurei container: %v", + c.Container.Hostname, + err, + ) + cancel() + } + return +} |
