github.com/dylandreimerink/gobpfld@v0.6.1-0.20220205171531-e79c330ad608/cmd/testsuite/main.go (about)

     1  package main
     2  
     3  import (
     4  	"bytes"
     5  	"crypto/sha256"
     6  	"encoding/hex"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"io/fs"
    11  	"net/http"
    12  	"os"
    13  	"os/exec"
    14  	"os/user"
    15  	"path"
    16  	"sort"
    17  	"strconv"
    18  	"strings"
    19  	"time"
    20  
    21  	"github.com/dylandreimerink/gocovmerge"
    22  	"github.com/dylandreimerink/tarp"
    23  	"github.com/spf13/cobra"
    24  	"golang.org/x/sys/unix"
    25  	"golang.org/x/tools/cover"
    26  )
    27  
    28  func main() {
    29  	//nolint:errcheck // can't do anything about an error, cobra prints it already
    30  	rootCmd().Execute()
    31  }
    32  
    33  func rootCmd() *cobra.Command {
    34  	c := &cobra.Command{}
    35  
    36  	c.AddCommand(
    37  		testCmd(),
    38  	)
    39  
    40  	return c
    41  }
    42  
    43  var (
    44  	flagVerbose    bool
    45  	flagKeepTmp    bool
    46  	flagNoPowerOff bool
    47  
    48  	flagOutputDir  string
    49  	flagCover      bool
    50  	flagCoverMode  string
    51  	flagHTMLReport bool
    52  
    53  	flagShort    bool
    54  	flagFailFast bool
    55  	flagRun      string
    56  	flagTestEnvs []string
    57  
    58  	originalUID = func() int {
    59  		if os.Getenv("SUDO_UID") != "" {
    60  			uid, err := strconv.Atoi(os.Getenv("SUDO_UID"))
    61  			if err == nil {
    62  				return uid
    63  			}
    64  		}
    65  		return os.Getuid()
    66  	}()
    67  	originalGID = func() int {
    68  		if os.Getenv("SUDO_GID") != "" {
    69  			gid, err := strconv.Atoi(os.Getenv("SUDO_GID"))
    70  			if err == nil {
    71  				return gid
    72  			}
    73  		}
    74  		return os.Getgid()
    75  	}()
    76  )
    77  
    78  func testCmd() *cobra.Command {
    79  	c := &cobra.Command{
    80  		Use:   "test",
    81  		Short: "Build and run unit/integration tests",
    82  		RunE:  buildAndRunTests,
    83  	}
    84  
    85  	f := c.Flags()
    86  	f.BoolVarP(&flagVerbose, "verbose", "v", false, "If set, both this command will output verbosely and all called "+
    87  		"commands will be called verbosely as well, thus outputting extra information")
    88  	f.BoolVar(&flagKeepTmp, "keep-tmp", false, "If set, the temporary directories will not be deleted after the test"+
    89  		"run so intermediate files can be inspected")
    90  	f.BoolVar(&flagNoPowerOff, "no-poweroff", false, "If set, the VM will not power off automatically, allowing manual"+
    91  		" inspection")
    92  
    93  	f.StringVarP(&flagOutputDir, "output-dir", "o", "./gobpfld-test-results", "Path to the directory where the result "+
    94  		"files are stored (report, coverage, profiling, tracing)")
    95  	f.BoolVar(&flagCover, "cover", false, "Enable coverage analysis")
    96  	f.StringVar(&flagCoverMode, "covermode", "set", "set,count,atomic. Set the mode for coverage analysis for the"+
    97  		" package[s] being tested. The default is \"set\" unless -race is enabled, in which case it is \"atomic\".")
    98  	f.BoolVar(&flagHTMLReport, "html-report", false, "If set, a HTML report will be created combining all available "+
    99  		"data, including all results, coverage, and profiling")
   100  
   101  	f.BoolVar(&flagShort, "short", false, "Tell long-running tests to shorten their run time.")
   102  	f.BoolVar(&flagFailFast, "failfast", false, "Do not start new tests after the first test failure.")
   103  	f.StringVar(&flagRun, "run", "", "Run only those tests and examples matching the regular expression."+
   104  		"For tests, the regular expression is split by unbracketed slash (/) "+
   105  		"characters into a sequence of regular expressions, and each part "+
   106  		"of a test's identifier must match the corresponding element in "+
   107  		"the sequence, if any. Note that possible parents of matches are "+
   108  		"run too, so that -run=X/Y matches and runs and reports the result "+
   109  		"of all tests matching X, even those without sub-tests matching Y, "+
   110  		"because it must run them to look for those sub-tests.")
   111  	f.StringArrayVar(&flagTestEnvs, "env", nil, "If set, tests will only be ran in the given environments")
   112  	return c
   113  }
   114  
   115  // A list of packages to be included in the test suite
   116  var packages = []string{
   117  	"github.com/dylandreimerink/gobpfld",
   118  	"github.com/dylandreimerink/gobpfld/bpfsys",
   119  	"github.com/dylandreimerink/gobpfld/bpftypes",
   120  	"github.com/dylandreimerink/gobpfld/ebpf",
   121  	"github.com/dylandreimerink/gobpfld/kernelsupport",
   122  	"github.com/dylandreimerink/gobpfld/perf",
   123  	"github.com/dylandreimerink/gobpfld/internal/cstr",
   124  	"github.com/dylandreimerink/gobpfld/internal/syscall",
   125  
   126  	// this package may contain complex tests which have no other logical place.
   127  	"github.com/dylandreimerink/gobpfld/cmd/testsuite/integration",
   128  }
   129  
   130  func printlnVerbose(args ...interface{}) {
   131  	if !flagVerbose {
   132  		return
   133  	}
   134  
   135  	fmt.Println(args...)
   136  }
   137  
   138  // testEnv represents a combination of factors to test for
   139  type testEnv struct {
   140  	arch       string
   141  	kernel     string
   142  	bzImageURL string
   143  }
   144  
   145  var availableEnvs = map[string]testEnv{
   146  	"linux-5.15.5-amd64": {
   147  		arch:       "amd64",
   148  		kernel:     "5.15.5",
   149  		bzImageURL: "https://github.com/dylandreimerink/bpfci/raw/master/dist/amd64-5.15.5-bzImage",
   150  	},
   151  	"linux-5.4.167-amd64": {
   152  		arch:       "amd64",
   153  		kernel:     "5.4.167",
   154  		bzImageURL: "https://github.com/dylandreimerink/bpfci/raw/master/dist/amd64-5.4.167-bzImage",
   155  	},
   156  	"linux-4.14.260-amd64": {
   157  		arch:       "amd64",
   158  		kernel:     "4.14.260",
   159  		bzImageURL: "https://github.com/dylandreimerink/bpfci/raw/master/dist/amd64-4.14.260-bzImage",
   160  	},
   161  	// "linux-5.15.5-arm64": {
   162  	// 	arch:   "arm64",
   163  	// 	kernel: "5.15.5",
   164  	// },
   165  }
   166  
   167  func buildAndRunTests(cmd *cobra.Command, args []string) error {
   168  	cmd.SilenceUsage = true
   169  
   170  	// We need to run as root for a lot of the steps involved like mounting disks
   171  	err := elevate()
   172  	if err != nil {
   173  		return fmt.Errorf("error while elevating: %w", err)
   174  	}
   175  
   176  	environments := flagTestEnvs
   177  	if len(environments) == 0 {
   178  		for env := range availableEnvs {
   179  			environments = append(environments, env)
   180  		}
   181  	} else {
   182  		for _, env := range flagTestEnvs {
   183  			if _, ok := availableEnvs[env]; !ok {
   184  				var actualEnvNames []string
   185  				for ae := range availableEnvs {
   186  					actualEnvNames = append(actualEnvNames, ae)
   187  				}
   188  
   189  				return fmt.Errorf(
   190  					"'%s' is not a valid test environment, pick from: %s",
   191  					env,
   192  					strings.Join(actualEnvNames, ", "),
   193  				)
   194  			}
   195  		}
   196  	}
   197  	sort.Strings(environments)
   198  
   199  	results := make(map[string]map[string]testResult)
   200  	for _, pkg := range packages {
   201  		// Test* to exclude benchmarks(for now)
   202  		testsStr, err := execCmd("go", "test", pkg, "-tags", "bpftests", "-list", "Test*")
   203  		if err != nil {
   204  			return fmt.Errorf("listing tests: %w", err)
   205  		}
   206  
   207  		lines := strings.Split(string(testsStr), "\n")
   208  		if len(lines) > 2 {
   209  			lines = lines[:len(lines)-2]
   210  		} else {
   211  			lines = nil
   212  		}
   213  
   214  		for _, env := range environments {
   215  			testMap := results[env]
   216  			if testMap == nil {
   217  				testMap = make(map[string]testResult)
   218  			}
   219  
   220  			for _, test := range lines {
   221  				testMap[test] = testResult{
   222  					Name:   test,
   223  					Status: statusUntested,
   224  				}
   225  			}
   226  			results[env] = testMap
   227  		}
   228  	}
   229  
   230  	// TODO run tests in goroutine and display progress bar (in non-verbose mode)
   231  
   232  	envFailed := make(map[string]bool)
   233  
   234  	for _, curEnvName := range environments {
   235  		envResults, err := testEnvironment(&testCtx{
   236  			envName: curEnvName,
   237  		})
   238  		for k, v := range envResults {
   239  			results[curEnvName][k] = v
   240  		}
   241  		if err != nil {
   242  			if !errors.Is(err, errTestsFailed) {
   243  				return err
   244  			}
   245  
   246  			envFailed[curEnvName] = true
   247  
   248  			// If we want to fail fast, don't test any other environments, report what we have
   249  			if flagFailFast {
   250  				break
   251  			}
   252  		}
   253  	}
   254  
   255  	if flagHTMLReport {
   256  		htmlPath := path.Join(flagOutputDir, "report.html")
   257  		printlnVerbose("OPEN:", htmlPath)
   258  		htmlFile, err := os.Create(htmlPath)
   259  		if err != nil {
   260  			return fmt.Errorf("create html report: %w", err)
   261  		}
   262  		defer htmlFile.Close()
   263  		defer func() {
   264  			err := os.Chown(htmlPath, originalUID, originalGID)
   265  			if err != nil {
   266  				fmt.Fprintln(os.Stderr, "CHOWN: err", err)
   267  			}
   268  		}()
   269  
   270  		err = renderHTMLReport(results, htmlFile)
   271  		if err != nil {
   272  			return fmt.Errorf("render html report: %w", err)
   273  		}
   274  	} else {
   275  		for env, envResults := range results {
   276  			if envFailed[env] {
   277  				fmt.Println("FAIL:", env)
   278  			} else {
   279  				fmt.Println("PASS:", env)
   280  			}
   281  
   282  			for _, testResult := range envResults {
   283  				fmt.Printf("  %s: %s (%s)\n", testResult.Status, testResult.Name, testResult.Duration)
   284  			}
   285  		}
   286  	}
   287  
   288  	// If there is at least one failed environment, return a non-0 exit code
   289  	if len(envFailed) != 0 {
   290  		os.Exit(2)
   291  	}
   292  
   293  	return nil
   294  }
   295  
   296  var errTestsFailed = errors.New("one or more tests failed")
   297  
   298  type testCtx struct {
   299  	// Set before testEnvironment
   300  	envName string
   301  
   302  	// Set by testEnvironment
   303  	results     map[string]testResult
   304  	curEnv      testEnv
   305  	tmpDir      string
   306  	executables []string
   307  
   308  	// Set by buildVMDiskImg
   309  	diskPath string
   310  
   311  	// Set by downloadLinux
   312  	bzPath     string
   313  	initrdPath string
   314  }
   315  
   316  func testEnvironment(ctx *testCtx) (map[string]testResult, error) {
   317  	ctx.curEnv = availableEnvs[ctx.envName]
   318  	ctx.results = make(map[string]testResult)
   319  
   320  	printlnVerbose("=== Running tests for", ctx.envName, "===")
   321  
   322  	// example: /tmp/bpftestsuite-amd64-1099045701
   323  	var err error
   324  	ctx.tmpDir, err = os.MkdirTemp(os.TempDir(), strings.Join([]string{"bpftestsuite", ctx.curEnv.arch, "*"}, "-"))
   325  	if err != nil {
   326  		return ctx.results, fmt.Errorf("error while making a temporary directory: %w", err)
   327  	}
   328  
   329  	printlnVerbose("Using tempdir:", ctx.tmpDir)
   330  
   331  	// cleanup the temp dir after we are done, unless the user wan't to keep it
   332  	if !flagKeepTmp {
   333  		defer func() {
   334  			printlnVerbose("--- Cleaning up tmp dir ---")
   335  			err := os.RemoveAll(ctx.tmpDir)
   336  			if err != nil {
   337  				fmt.Fprintf(os.Stderr, "error while cleaning up tmp dir '%s': %s", ctx.tmpDir, err.Error())
   338  			}
   339  			printlnVerbose("RM:", ctx.tmpDir)
   340  		}()
   341  	}
   342  
   343  	err = buildTestBinaries(ctx)
   344  	if err != nil {
   345  		return ctx.results, fmt.Errorf("buildTestBinaries: %w", err)
   346  	}
   347  
   348  	err = genVMRunScript(ctx)
   349  	if err != nil {
   350  		return ctx.results, fmt.Errorf("genVMRunScript: %w", err)
   351  	}
   352  
   353  	err = buildVMDiskImg(ctx)
   354  	if err != nil {
   355  		return ctx.results, fmt.Errorf("buildVMDiskImg: %w", err)
   356  	}
   357  
   358  	err = downloadLinux(ctx)
   359  	if err != nil {
   360  		return ctx.results, fmt.Errorf("downloadLinux: %w", err)
   361  	}
   362  
   363  	err = runTestInVM(ctx)
   364  	if err != nil {
   365  		return ctx.results, fmt.Errorf("runTestInVM: %w", err)
   366  	}
   367  
   368  	err = extractData(ctx)
   369  	if err != nil {
   370  		return ctx.results, fmt.Errorf("extractData: %w", err)
   371  	}
   372  
   373  	err = processResults(ctx)
   374  	if err != nil {
   375  		return ctx.results, fmt.Errorf("processResults: %w", err)
   376  	}
   377  
   378  	return ctx.results, nil
   379  }
   380  
   381  func buildTestBinaries(ctx *testCtx) error {
   382  	printlnVerbose("--- Build test binaries ---")
   383  
   384  	envVars := append(
   385  		os.Environ(),              // Append to existing ENV vars
   386  		"GOARCH="+ctx.curEnv.arch, // Set target architecture
   387  		"CGO_ENABLED=0",           // Disable CGO (to trigger static compilation)
   388  	)
   389  
   390  	buildFlags := []string{
   391  		"test",              // invoke the test sub-command
   392  		"-c",                // Compile the binary, but don't execute it
   393  		"-tags", "bpftests", // Include tests that use the BPF syscall
   394  	}
   395  
   396  	// Include cover mode when building, because according to `go help testflag` coverage reporting annotates
   397  	// the test binary.
   398  	if flagCover {
   399  		buildFlags = append(buildFlags, "-covermode", flagCoverMode)
   400  		buildFlags = append(buildFlags, "-coverpkg", strings.Join(packages, ","))
   401  	}
   402  
   403  	if flagRun != "" {
   404  		buildFlags = append(buildFlags, "-run", flagRun)
   405  	}
   406  
   407  	ctx.executables = make([]string, 0, len(packages))
   408  	for _, pkg := range packages {
   409  		pkgName := strings.Join([]string{path.Base(pkg), "test"}, ".")
   410  		execPath := path.Join(ctx.tmpDir, pkgName)
   411  
   412  		arguments := append(
   413  			buildFlags,
   414  			"-o", execPath, // Output test in the temporary directory
   415  			pkg,
   416  		)
   417  
   418  		_, err := execEnvCmd(envVars, "go", arguments...)
   419  		if err != nil {
   420  			return fmt.Errorf("error while building tests: %w", err)
   421  		}
   422  
   423  		// If a package contains no tests, no executable is generated
   424  		if _, err := os.Stat(execPath); err == nil {
   425  			ctx.executables = append(ctx.executables, pkgName)
   426  		}
   427  	}
   428  
   429  	return nil
   430  }
   431  
   432  func genVMRunScript(ctx *testCtx) error {
   433  	printlnVerbose("--- Generate VM run script ---")
   434  
   435  	// Make a buffer for the actual script which will execute the tests inside the VM
   436  	scriptBuf := bytes.Buffer{}
   437  	scriptBuf.WriteString("#!/bin/sh\n\n")
   438  
   439  	// The the location where the disk will be mounted in the VM
   440  	const vmPath = "/mnt/root"
   441  	for _, execName := range ctx.executables {
   442  		flags := []string{
   443  			// Always return verbose output, it contains info about which tests actually ran or were skipped
   444  			"-test.v",
   445  		}
   446  
   447  		if flagCover {
   448  			flags = append(flags, "-test.coverprofile", path.Join(vmPath, execName+".cover"))
   449  		}
   450  
   451  		if flagFailFast {
   452  			flags = append(flags, "-test.failfast")
   453  		}
   454  
   455  		if flagShort {
   456  			flags = append(flags, "-test.short")
   457  		}
   458  
   459  		if flagRun != "" {
   460  			flags = append(flags, "-test.run", flagRun)
   461  		}
   462  
   463  		// Run script, write stdout to "$exec.results", write stderr to "$exec.error" and the exit code to "$exec.exit"
   464  		fmt.Fprintf(
   465  			&scriptBuf,
   466  			"%s %s > %s 2> %s\necho $? > %s\n",
   467  			path.Join(vmPath, execName),
   468  			strings.Join(flags, " "),
   469  			path.Join(vmPath, execName+".results"),
   470  			path.Join(vmPath, execName+".error"),
   471  			path.Join(vmPath, execName+".exit"),
   472  		)
   473  	}
   474  
   475  	if !flagNoPowerOff {
   476  		// The last command is the poweroff command(busybox shutdown command), this will cause the VM to exit
   477  		// after all tests have been ran.
   478  		fmt.Fprintln(&scriptBuf, "poweroff -f")
   479  	}
   480  
   481  	// Write the shell script
   482  	printlnVerbose(scriptBuf.String())
   483  
   484  	//nolint:gosec // Creating an executable on purpose
   485  	err := os.WriteFile(path.Join(ctx.tmpDir, "run.sh"), scriptBuf.Bytes(), 0755)
   486  	if err != nil {
   487  		return fmt.Errorf("error while writing run script: %w", err)
   488  	}
   489  
   490  	return nil
   491  }
   492  
   493  const mntPath = "/mnt/bpftestdisk"
   494  
   495  func buildVMDiskImg(ctx *testCtx) error {
   496  	printlnVerbose("--- Build VM disk image ---")
   497  
   498  	ctx.diskPath = path.Join(ctx.tmpDir, "disk.img")
   499  	// Create a 256MB(should be plenty) raw disk which we will later use to add the test executables to the VM
   500  	// and later get back the test results
   501  	_, err := execCmd("qemu-img", "create", ctx.diskPath, "256M")
   502  	if err != nil {
   503  		return fmt.Errorf("error while creating qemu image: %w", err)
   504  	}
   505  
   506  	// Add master boot record partition table to raw image
   507  	_, err = execCmd(
   508  		"parted",
   509  		"-s", ctx.diskPath,
   510  		"mklabel msdos",
   511  		"mkpart primary ext2 2048s 100%",
   512  	)
   513  	if err != nil {
   514  		return fmt.Errorf("error while creating qemu image: %w", err)
   515  	}
   516  
   517  	// Create a loop device from the disk file which will allow us to mount it
   518  	loopDevBytes, err := execCmd("losetup", "--partscan", "--show", "--find", ctx.diskPath)
   519  	if err != nil {
   520  		return fmt.Errorf("error while creating loop device: %w", err)
   521  	}
   522  	loopDev := strings.TrimSpace(string(loopDevBytes))
   523  
   524  	printlnVerbose("MKDIR: ", mntPath)
   525  	err = os.Mkdir(mntPath, 0755)
   526  	if err != nil && err != fs.ErrExist {
   527  		return fmt.Errorf("error while making mnt dir: %w", err)
   528  	}
   529  	defer func() {
   530  		// Remove the mount path
   531  		printlnVerbose("RM:", mntPath)
   532  		err = os.Remove(mntPath)
   533  		if err != nil {
   534  			fmt.Fprintf(os.Stderr, "Error while deleting mount dir '%s': %s", mntPath, err.Error())
   535  		}
   536  	}()
   537  
   538  	// Make a EXT2 filesystem on the loop device's 1st partition
   539  	_, err = execCmd("mkfs", "-t", "ext2", "-L", "bpfdisk", loopDev+"p1")
   540  	if err != nil {
   541  		return fmt.Errorf("error while creating FS on loop device: %w", err)
   542  	}
   543  
   544  	// Mount the first partion of the loop device
   545  	_, err = execCmd("mount", loopDev+"p1", mntPath)
   546  	if err != nil {
   547  		return fmt.Errorf("error while mounting loop device: %w", err)
   548  	}
   549  
   550  	// Copy all executables and the run script to the new disk
   551  	copyFiles := append(ctx.executables, "run.sh")
   552  	for _, fileName := range copyFiles {
   553  		tmpPath := path.Join(ctx.tmpDir, fileName)
   554  		mntPath := path.Join(mntPath, fileName)
   555  
   556  		err = copyFile(tmpPath, mntPath, 0755)
   557  		if err != nil {
   558  			return err
   559  		}
   560  	}
   561  
   562  	// Unmount the loop device
   563  	_, err = execCmd("umount", mntPath)
   564  	if err != nil {
   565  		return fmt.Errorf("error while unmounting loop device: %w", err)
   566  	}
   567  
   568  	// Remove the loop device
   569  	_, err = execCmd("losetup", "-d", loopDev)
   570  	if err != nil {
   571  		fmt.Fprintf(os.Stderr, "Error while deleting loop device '%s': %s", loopDev, err.Error())
   572  	}
   573  
   574  	return nil
   575  }
   576  
   577  func downloadLinux(ctx *testCtx) error {
   578  	printlnVerbose("--- Checking/downloading bzImage and initrd ---")
   579  
   580  	const cacheDir = "/var/cache/bpfld"
   581  	printlnVerbose("MKDIR", cacheDir)
   582  	err := os.MkdirAll(cacheDir, 0755)
   583  	if err != nil {
   584  		return fmt.Errorf("error while creating cache directory: %w", err)
   585  	}
   586  
   587  	bzFilename := fmt.Sprintf("%s-%s.bzImage", ctx.curEnv.arch, ctx.curEnv.kernel)
   588  	ctx.bzPath = path.Join(cacheDir, bzFilename)
   589  	dlBZ := false
   590  
   591  	printlnVerbose("CHECKSUM:", ctx.bzPath)
   592  	bzFile, err := os.Open(ctx.bzPath)
   593  	if err != nil {
   594  		dlBZ = true
   595  	} else {
   596  		// Calculate the sha256 hash of the existing bzImage
   597  		h := sha256.New()
   598  
   599  		_, err = io.Copy(h, bzFile)
   600  		if err != nil {
   601  			return fmt.Errorf("error while hashing bzImage: %w", err)
   602  		}
   603  		bzFile.Close()
   604  
   605  		bzHash := h.Sum(nil)
   606  
   607  		printlnVerbose("GET: ", ctx.curEnv.bzImageURL+".sha256")
   608  		resp, err := http.Get(ctx.curEnv.bzImageURL + ".sha256")
   609  		if err != nil {
   610  			return fmt.Errorf("error while downloading bzImage hash: %w", err)
   611  		}
   612  		defer resp.Body.Close()
   613  
   614  		body, err := io.ReadAll(resp.Body)
   615  		if err != nil {
   616  			return fmt.Errorf("error while reading bzImage hash: %w", err)
   617  		}
   618  
   619  		bodyStr := strings.TrimSpace(string(body))
   620  		if bodyStr != hex.EncodeToString(bzHash) {
   621  			printlnVerbose(
   622  				"Remote =", bodyStr+",",
   623  				"Local =", hex.EncodeToString(bzHash),
   624  			)
   625  			dlBZ = true
   626  		}
   627  	}
   628  
   629  	// If we can't stat the bzImage in the cache dir, download it
   630  	if dlBZ {
   631  		err = func() error {
   632  			printlnVerbose("DOWNLOAD: ", ctx.curEnv.bzImageURL)
   633  			resp, err := http.Get(ctx.curEnv.bzImageURL)
   634  			if err != nil {
   635  				return fmt.Errorf("error while downloading bzImage: %w", err)
   636  			}
   637  			defer resp.Body.Close()
   638  
   639  			bzFile, err = os.OpenFile(ctx.bzPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
   640  			if err != nil {
   641  				return fmt.Errorf("error while creating bzImage: %w", err)
   642  			}
   643  			defer bzFile.Close()
   644  
   645  			_, err = io.Copy(bzFile, resp.Body)
   646  			if err != nil {
   647  				return fmt.Errorf("error while copying bzImage: %w", err)
   648  			}
   649  
   650  			return nil
   651  		}()
   652  		if err != nil {
   653  			return err
   654  		}
   655  	}
   656  
   657  	const initrdURL = "https://github.com/dylandreimerink/bpfci/raw/master/dist/amd64-initrd.gz"
   658  	ctx.initrdPath = path.Join(cacheDir, "initrd.gz")
   659  	dlInitrd := false
   660  
   661  	printlnVerbose("CHECKSUM:", ctx.initrdPath)
   662  	initrdFile, err := os.Open(ctx.initrdPath)
   663  	if err != nil {
   664  		dlInitrd = true
   665  	} else {
   666  		// Calculate the sha256 hash of the existing bzImage
   667  		h := sha256.New()
   668  
   669  		_, err = io.Copy(h, initrdFile)
   670  		if err != nil {
   671  			return fmt.Errorf("error while hashing initrd: %w", err)
   672  		}
   673  		bzFile.Close()
   674  
   675  		initrdHash := h.Sum(nil)
   676  
   677  		printlnVerbose("GET: ", initrdURL+".sha256")
   678  		resp, err := http.Get(initrdURL + ".sha256")
   679  		if err != nil {
   680  			return fmt.Errorf("error while downloading initrd hash: %w", err)
   681  		}
   682  		defer resp.Body.Close()
   683  
   684  		body, err := io.ReadAll(resp.Body)
   685  		if err != nil {
   686  			return fmt.Errorf("error while reading initrd hash: %w", err)
   687  		}
   688  
   689  		bodyStr := strings.TrimSpace(string(body))
   690  		if bodyStr != hex.EncodeToString(initrdHash) {
   691  			printlnVerbose(
   692  				"Remote =", bodyStr+",",
   693  				"Local =", hex.EncodeToString(initrdHash),
   694  			)
   695  			dlInitrd = true
   696  		}
   697  	}
   698  
   699  	if dlInitrd {
   700  		err = func() error {
   701  			printlnVerbose("DOWNLOAD: ", initrdURL)
   702  			resp, err := http.Get(initrdURL)
   703  			if err != nil {
   704  				return fmt.Errorf("error while downloading initrd: %w", err)
   705  			}
   706  			defer resp.Body.Close()
   707  
   708  			initrdFile, err = os.OpenFile(ctx.initrdPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
   709  			if err != nil {
   710  				return fmt.Errorf("error while creating initrd: %w", err)
   711  			}
   712  			defer initrdFile.Close()
   713  
   714  			_, err = io.Copy(initrdFile, resp.Body)
   715  			if err != nil {
   716  				return fmt.Errorf("error while copying initrd: %w", err)
   717  			}
   718  
   719  			return nil
   720  		}()
   721  		if err != nil {
   722  			return err
   723  		}
   724  	}
   725  
   726  	return nil
   727  }
   728  
   729  func runTestInVM(ctx *testCtx) error {
   730  	// TODO setup bridge and tap devices
   731  	printlnVerbose("--- Starting test run in VM ---")
   732  
   733  	arguments := []string{
   734  		"-m", "4G", // Give the VM 4GB RAM, should be plenty
   735  		"-kernel", ctx.bzPath, // Start kernel for the given environment
   736  		"-initrd", ctx.initrdPath, // Use this initial ram disk (which will call our run.sh after setup)
   737  		"-drive", "format=raw,file=" + ctx.diskPath, // Use the created disk as a drive
   738  		"-netdev", "tap,id=bpfnet0,ifname=bpfci-tap1,script=no,downscript=no",
   739  		"-device", "e1000,mac=de:ad:be:ef:00:01,netdev=bpfnet0", // Add a E1000 NIC
   740  		"-append", "root=/dev/sda1",
   741  		// TODO run with no-graphics and capture kernel output
   742  	}
   743  	_, err := execCmd("qemu-system-x86_64", arguments...)
   744  	if err != nil {
   745  		fmt.Fprintf(os.Stderr, "Error while starting VM: %s", err.Error())
   746  	}
   747  
   748  	return nil
   749  }
   750  
   751  func extractData(ctx *testCtx) error {
   752  	printlnVerbose("--- Remounting disk ---")
   753  
   754  	// Create a loop device from the disk file which will allow us to mount it
   755  	loopDevBytes, err := execCmd("losetup", "--partscan", "--show", "--find", ctx.diskPath)
   756  	if err != nil {
   757  		return fmt.Errorf("error while creating loop device: %w", err)
   758  	}
   759  	loopDev := strings.TrimSpace(string(loopDevBytes))
   760  	defer func() {
   761  		// Remove the loop device
   762  		_, err = execCmd("losetup", "-d", loopDev)
   763  		if err != nil {
   764  			fmt.Fprintf(os.Stderr, "Error while deleting loop device '%s': %s", loopDev, err.Error())
   765  		}
   766  	}()
   767  
   768  	printlnVerbose("MKDIR: ", mntPath)
   769  	err = os.Mkdir(mntPath, 0755)
   770  	if err != nil && err != fs.ErrExist {
   771  		return fmt.Errorf("error while making mnt dir: %w", err)
   772  	}
   773  	defer func() {
   774  		// Remove the mount path
   775  		printlnVerbose("RM:", mntPath)
   776  		err = os.Remove(mntPath)
   777  		if err != nil {
   778  			fmt.Fprintf(os.Stderr, "Error while deleting mount dir '%s': %s", mntPath, err.Error())
   779  		}
   780  	}()
   781  
   782  	// Mount the first partion of the loop device
   783  	_, err = execCmd("mount", loopDev+"p1", mntPath)
   784  	if err != nil {
   785  		return fmt.Errorf("error while mounting loop device: %w", err)
   786  	}
   787  	defer func() {
   788  		// Unmount the loop device
   789  		_, err = execCmd("umount", mntPath)
   790  		if err != nil {
   791  			fmt.Fprintf(os.Stderr, "Error while unmounting loop device: %s", err.Error())
   792  		}
   793  	}()
   794  
   795  	copyFiles := []string{}
   796  	for _, execName := range ctx.executables {
   797  		copyFiles = append(copyFiles, execName+".results")
   798  		copyFiles = append(copyFiles, execName+".error")
   799  		copyFiles = append(copyFiles, execName+".exit")
   800  
   801  		if flagCover {
   802  			copyFiles = append(copyFiles, execName+".cover")
   803  		}
   804  	}
   805  
   806  	for _, fileName := range copyFiles {
   807  		err = copyFile(path.Join(mntPath, fileName), path.Join(ctx.tmpDir, fileName), 0644)
   808  		if err != nil {
   809  			fmt.Fprintln(os.Stderr, "error while copying:", err.Error())
   810  		}
   811  	}
   812  
   813  	// Merge all .cover files
   814  	if flagCover {
   815  		var merged []*cover.Profile
   816  		for _, execName := range ctx.executables {
   817  			profiles, err := cover.ParseProfiles(path.Join(ctx.tmpDir, execName+".cover"))
   818  			if err != nil {
   819  				fmt.Fprintln(os.Stderr, "failed to parse profiles:", err.Error())
   820  				continue
   821  			}
   822  			for _, p := range profiles {
   823  				merged = gocovmerge.AddProfile(merged, p)
   824  			}
   825  		}
   826  
   827  		coverPath := path.Join(ctx.tmpDir, "gobpfld.cover")
   828  		coverFile, err := os.Create(path.Join(ctx.tmpDir, "gobpfld.cover"))
   829  		if err != nil {
   830  			return fmt.Errorf("make combined coverfile: %w", err)
   831  		}
   832  
   833  		gocovmerge.DumpProfiles(merged, coverFile)
   834  
   835  		err = coverFile.Close()
   836  		if err != nil {
   837  			return fmt.Errorf("close combined coverfile: %w", err)
   838  		}
   839  		err = os.Chown(coverPath, originalUID, originalGID)
   840  		if err != nil {
   841  			return fmt.Errorf("chown combined coverfile: %w", err)
   842  		}
   843  
   844  		if flagHTMLReport {
   845  			err = tarp.GenerateHTMLReport([]string{coverPath}, path.Join(ctx.tmpDir, "gobpfld.cover.html"))
   846  			if err != nil {
   847  				return fmt.Errorf("make html coverage report: %w", err)
   848  			}
   849  			err = os.Chown(path.Join(ctx.tmpDir, "gobpfld.cover.html"), originalUID, originalGID)
   850  			if err != nil {
   851  				return fmt.Errorf("chown html coverage report: %w", err)
   852  			}
   853  		}
   854  	}
   855  
   856  	copyFiles = []string{}
   857  	if flagCover {
   858  		copyFiles = append(copyFiles, "gobpfld.cover")
   859  		if flagHTMLReport {
   860  			copyFiles = append(copyFiles, "gobpfld.cover.html")
   861  		}
   862  	}
   863  
   864  	// If there are no output files
   865  	if len(copyFiles) == 0 {
   866  		return nil
   867  	}
   868  
   869  	outDir := path.Join(flagOutputDir, ctx.envName)
   870  
   871  	printlnVerbose("STAT:", outDir)
   872  	// If the directory exists, remove it
   873  	if _, err = os.Stat(outDir); err == nil {
   874  		printlnVerbose("RM:", outDir)
   875  		os.RemoveAll(outDir)
   876  	}
   877  
   878  	printlnVerbose("MKDIR:", outDir)
   879  	err = os.MkdirAll(outDir, 0755)
   880  	if err != nil {
   881  		return fmt.Errorf("make output dir: %w", err)
   882  	}
   883  
   884  	printlnVerbose("CHOWN:", flagOutputDir)
   885  	err = os.Chown(flagOutputDir, originalUID, originalGID)
   886  	if err != nil {
   887  		return fmt.Errorf("chown output dir: %w", err)
   888  	}
   889  
   890  	printlnVerbose("CHOWN:", outDir)
   891  	err = os.Chown(outDir, originalUID, originalGID)
   892  	if err != nil {
   893  		return fmt.Errorf("chown output dir: %w", err)
   894  	}
   895  
   896  	for _, fileName := range copyFiles {
   897  		err = copyFile(path.Join(ctx.tmpDir, fileName), path.Join(outDir, fileName), 0644)
   898  		if err != nil {
   899  			return err
   900  		}
   901  
   902  		printlnVerbose("CHOWN:", path.Join(outDir, fileName))
   903  		err = os.Chown(path.Join(outDir, fileName), originalUID, originalGID)
   904  		if err != nil {
   905  			return fmt.Errorf("chown output file: %w", err)
   906  		}
   907  	}
   908  
   909  	return nil
   910  }
   911  
   912  func processResults(ctx *testCtx) error {
   913  	printlnVerbose("--- Processing results ---")
   914  
   915  	exitWithErr := false
   916  
   917  	for _, execName := range ctx.executables {
   918  		exitPath := path.Join(ctx.tmpDir, execName+".exit")
   919  		errCodeBytes, err := os.ReadFile(exitPath)
   920  		if err != nil {
   921  			return fmt.Errorf("read exit code file '%s': %w", exitPath, err)
   922  		}
   923  
   924  		exitCode, err := strconv.Atoi(strings.TrimSpace(string(errCodeBytes)))
   925  		if err != nil {
   926  			return fmt.Errorf("exit code file atoi '%s': %w", exitPath, err)
   927  		}
   928  
   929  		resultPath := path.Join(ctx.tmpDir, execName+".results")
   930  		testResults, err := os.ReadFile(resultPath)
   931  		if err != nil {
   932  			return fmt.Errorf("read results file '%s': %w", resultPath, err)
   933  		}
   934  
   935  		// Get back the package name from the executable name
   936  		pkg := strings.TrimSuffix(execName, ".test")
   937  
   938  		// If error code == 0, the executable returned without errors
   939  		if exitCode != 0 {
   940  			printlnVerbose(fmt.Sprintf("%s FAIL\nTests exited with code '%d'", pkg, exitCode))
   941  			exitWithErr = true
   942  
   943  			errorPath := path.Join(ctx.tmpDir, execName+".error")
   944  			testError, err := os.ReadFile(errorPath)
   945  			if err != nil {
   946  				return fmt.Errorf("read error file '%s': %w", errorPath, err)
   947  			}
   948  
   949  			fmt.Printf("Stdout:\n%s\n", string(testResults))
   950  			fmt.Printf("Stderr:\n%s\n", string(testError))
   951  		}
   952  
   953  		for _, line := range strings.Split(string(testResults), "\n") {
   954  			const (
   955  				passPrefix = "--- PASS:"
   956  				failPrefix = "--- FAIL:"
   957  				skipPrefix = "--- SKIP:"
   958  			)
   959  
   960  			var status testStatus
   961  
   962  			if strings.HasPrefix(line, passPrefix) {
   963  				line = strings.TrimSpace(strings.TrimPrefix(line, passPrefix))
   964  				status = statusPass
   965  			} else if strings.HasPrefix(line, failPrefix) {
   966  				line = strings.TrimSpace(strings.TrimPrefix(line, failPrefix))
   967  				status = statusFail
   968  			} else if strings.HasPrefix(line, skipPrefix) {
   969  				line = strings.TrimSpace(strings.TrimPrefix(line, skipPrefix))
   970  				status = statusSkip
   971  			} else {
   972  				continue
   973  			}
   974  
   975  			parts := strings.Split(line, " ")
   976  			if len(parts) < 2 {
   977  				fmt.Fprintln(os.Stderr, "unexpected test results(parts < 2)")
   978  				continue
   979  			}
   980  
   981  			testName := parts[0]
   982  			durationStr := strings.Trim(parts[1], "()")
   983  			duration, err := time.ParseDuration(durationStr)
   984  			if err != nil {
   985  				fmt.Fprintln(os.Stderr, "unexpected test results:", err.Error())
   986  				continue
   987  			}
   988  
   989  			ctx.results[testName] = testResult{
   990  				Name:     testName,
   991  				Status:   status,
   992  				Duration: duration,
   993  			}
   994  		}
   995  	}
   996  
   997  	if exitWithErr {
   998  		return errTestsFailed
   999  	}
  1000  
  1001  	return nil
  1002  }
  1003  
  1004  type testStatus string
  1005  
  1006  const (
  1007  	// has not (yet) been run
  1008  	statusUntested testStatus = "UNTESTED"
  1009  	// tested and passed
  1010  	statusPass testStatus = "PASS"
  1011  	// tested and failed
  1012  	statusFail testStatus = "FAIL"
  1013  	// skipped testing, due to -short flag or kernel incompatibility
  1014  	statusSkip testStatus = "SKIP"
  1015  )
  1016  
  1017  type testResult struct {
  1018  	Name     string
  1019  	Status   testStatus
  1020  	Duration time.Duration
  1021  	// TODO return sub-test data?
  1022  }
  1023  
  1024  func copyFile(from, to string, perm fs.FileMode) error {
  1025  	printlnVerbose("CP:", from, "->", to)
  1026  	fromFile, err := os.Open(from)
  1027  	if err != nil {
  1028  		return fmt.Errorf("error while opening file: %w", err)
  1029  	}
  1030  
  1031  	toFile, err := os.OpenFile(to, os.O_CREATE|os.O_WRONLY, perm)
  1032  	if err != nil {
  1033  		return fmt.Errorf("error while creating file: %w", err)
  1034  	}
  1035  
  1036  	_, err = io.Copy(toFile, fromFile)
  1037  	if err != nil {
  1038  		return fmt.Errorf("error while copying file: %w", err)
  1039  	}
  1040  
  1041  	err = toFile.Close()
  1042  	if err != nil {
  1043  		return fmt.Errorf("error while closing file: %w", err)
  1044  	}
  1045  
  1046  	err = fromFile.Close()
  1047  	if err != nil {
  1048  		return fmt.Errorf("error while closing file: %w", err)
  1049  	}
  1050  
  1051  	return nil
  1052  }
  1053  
  1054  func execCmd(name string, args ...string) ([]byte, error) {
  1055  	return execEnvCmd(nil, name, args...)
  1056  }
  1057  
  1058  func execEnvCmd(env []string, name string, args ...string) ([]byte, error) {
  1059  	// we have to do this bullshit because you can't explode a ...string to a ...interface{}
  1060  	// so we have to joint into as single string which can be passed to a ...interface{}
  1061  	printlnVerbose(strings.Join(append([]string{"EXEC:", name}, args...), " "))
  1062  
  1063  	cmd := exec.Command(name, args...)
  1064  	if env != nil {
  1065  		cmd.Env = env
  1066  	}
  1067  	output, err := cmd.Output()
  1068  	if err != nil {
  1069  		fmt.Fprintln(os.Stderr, string(output))
  1070  		if ee, ok := err.(*exec.ExitError); ok {
  1071  			fmt.Fprintln(os.Stderr, string(ee.Stderr))
  1072  		}
  1073  		return nil, err
  1074  	}
  1075  
  1076  	return output, nil
  1077  }
  1078  
  1079  // elevate checks if we are currently running as root, if not we will request the user to elevate the program
  1080  func elevate() error {
  1081  	curUser, err := user.Current()
  1082  	if err != nil {
  1083  		return fmt.Errorf("error while getting user: %w", err)
  1084  	}
  1085  
  1086  	// If we are user 0(root), we don't need to elevate
  1087  	if curUser.Uid == "0" {
  1088  		return nil
  1089  	}
  1090  
  1091  	fmt.Println("This testsuit requires root privileges, attempting to elevate via sudo...")
  1092  
  1093  	exec := lookupExec("sudo")
  1094  	if exec == "" {
  1095  		// Fallback if we can't resolve via PATH
  1096  		exec = "/usr/bin/sudo"
  1097  	}
  1098  
  1099  	// Elevate to root by execve'ing sudo with the current args. This should prompt the user for their sudo password
  1100  	// and then continue executing this program(again from the start, since this process will be replaced)
  1101  	// NOTE: The `--preserve-env=PATH` will make sure that the current PATH is preserved which is important since most
  1102  	// users will not have setup root with the correct go environment variables.
  1103  	err = unix.Exec(exec, append([]string{"sudo", "--preserve-env=PATH"}, os.Args...), os.Environ())
  1104  	if err != nil {
  1105  		return fmt.Errorf("error execve'ing into sudo: %w", err)
  1106  	}
  1107  
  1108  	return nil
  1109  }
  1110  
  1111  // lookupExec performs a executable lookup based on the PATH environment variable
  1112  func lookupExec(name string) string {
  1113  	pathVar := os.Getenv("PATH")
  1114  	exec := ""
  1115  
  1116  	for _, dir := range strings.Split(pathVar, ":") {
  1117  		abs := path.Join(dir, name)
  1118  		stat, err := os.Stat(abs)
  1119  		if err != nil || stat.IsDir() {
  1120  			continue
  1121  		}
  1122  		exec = abs
  1123  		break
  1124  	}
  1125  
  1126  	return exec
  1127  }