github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/test.go (about)

     1  package cli
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"log/slog"
     8  	"os"
     9  	"path/filepath"
    10  	"runtime"
    11  	"sync"
    12  
    13  	"chainguard.dev/apko/pkg/build/types"
    14  	"chainguard.dev/melange/pkg/build"
    15  	"github.com/chainguard-dev/clog"
    16  	charmlog "github.com/charmbracelet/log"
    17  	"github.com/spf13/cobra"
    18  	"github.com/wolfi-dev/wolfictl/pkg/dag"
    19  	"go.opentelemetry.io/otel"
    20  	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
    21  	"go.opentelemetry.io/otel/sdk/trace"
    22  	"golang.org/x/sync/errgroup"
    23  )
    24  
    25  func cmdTest() *cobra.Command {
    26  	var traceFile string
    27  
    28  	cfg := testConfig{}
    29  
    30  	cmd := &cobra.Command{
    31  		Use:  "test",
    32  		Long: `Test wolfi packages. Accepts either no positional arguments (for testing everything) or a list of packages to test.`,
    33  		Example: `
    34      # Test everything for every x86_64 and aarch64
    35      wolfictl test
    36  
    37      # Test a few packages
    38      wolfictl test \
    39        --arch aarch64 \
    40        hello-wolfi wget
    41  
    42  
    43      # Test a single local package
    44      wolfictl test \
    45        --arch aarch64 \
    46        -k local-melange.rsa.pub \
    47        -r ./packages \
    48        -r https://packages.wolfi.dev/os \
    49        -k https://packages.wolfi.dev/os/wolfi-signing.rsa.pub \
    50        hello-wolfi
    51      `,
    52  		SilenceErrors: true,
    53  		RunE: func(cmd *cobra.Command, args []string) error {
    54  			ctx := cmd.Context()
    55  
    56  			if traceFile != "" {
    57  				w, err := os.Create(traceFile)
    58  				if err != nil {
    59  					return fmt.Errorf("creating trace file: %w", err)
    60  				}
    61  				defer w.Close()
    62  				exporter, err := stdouttrace.New(stdouttrace.WithWriter(w))
    63  				if err != nil {
    64  					return fmt.Errorf("creating stdout exporter: %w", err)
    65  				}
    66  				tp := trace.NewTracerProvider(trace.WithBatcher(exporter))
    67  				otel.SetTracerProvider(tp)
    68  
    69  				defer func() {
    70  					if err := tp.Shutdown(context.WithoutCancel(ctx)); err != nil {
    71  						clog.FromContext(ctx).Errorf("Shutting down trace provider: %v", err)
    72  					}
    73  				}()
    74  
    75  				tctx, span := otel.Tracer("wolfictl").Start(ctx, "test")
    76  				defer span.End()
    77  				ctx = tctx
    78  			}
    79  
    80  			if cfg.jobs == 0 {
    81  				cfg.jobs = runtime.GOMAXPROCS(0)
    82  			}
    83  
    84  			if cfg.pipelineDir == "" {
    85  				cfg.pipelineDir = filepath.Join(cfg.dir, "pipelines")
    86  			}
    87  			if cfg.outDir == "" {
    88  				cfg.outDir = filepath.Join(cfg.dir, "packages")
    89  			}
    90  
    91  			if cfg.cacheDir != "" {
    92  				if err := os.MkdirAll(cfg.cacheDir, os.ModePerm); err != nil {
    93  					return fmt.Errorf("creating cache directory: %w", err)
    94  				}
    95  			}
    96  
    97  			return testAll(ctx, &cfg, args)
    98  		},
    99  	}
   100  
   101  	cmd.Flags().StringVarP(&cfg.dir, "dir", "d", ".", "directory to search for melange configs")
   102  	cmd.Flags().StringVar(&cfg.pipelineDir, "pipeline-dir", "./pipelines", "directory used to extend defined built-in pipelines")
   103  	cmd.Flags().StringVar(&cfg.runner, "runner", "docker", "which runner to use to enable running commands, default is based on your platform.")
   104  	cmd.Flags().StringSliceVar(&cfg.archs, "arch", []string{"x86_64", "aarch64"}, "arch of package to build")
   105  	cmd.Flags().StringSliceVarP(&cfg.extraKeys, "keyring-append", "k", []string{"https://packages.wolfi.dev/os/wolfi-signing.rsa.pub"}, "path to extra keys to include in the build environment keyring")
   106  	cmd.Flags().StringSliceVarP(&cfg.extraRepos, "repository-append", "r", []string{"https://packages.wolfi.dev/os"}, "path to extra repositories to include in the build environment")
   107  	cmd.Flags().StringSliceVar(&cfg.extraPackages, "test-package-append", []string{"wolfi-base"}, "extra packages to install for each of the test environments")
   108  	cmd.Flags().StringVar(&cfg.cacheDir, "cache-dir", "./melange-cache/", "directory used for cached inputs")
   109  	cmd.Flags().StringVar(&cfg.cacheSource, "cache-source", "", "directory or bucket used for preloading the cache")
   110  	cmd.Flags().BoolVar(&cfg.debug, "debug", true, "enable test debug logging")
   111  
   112  	cmd.Flags().IntVarP(&cfg.jobs, "jobs", "j", 0, "number of jobs to run concurrently (default is GOMAXPROCS)")
   113  	cmd.Flags().StringVar(&traceFile, "trace", "", "where to write trace output")
   114  
   115  	return cmd
   116  }
   117  
   118  type testConfig struct {
   119  	archs         []string
   120  	extraKeys     []string
   121  	extraRepos    []string
   122  	extraPackages []string
   123  
   124  	outDir      string // used for keeping logs consistent with build
   125  	dir         string
   126  	pipelineDir string
   127  	runner      string
   128  	debug       bool
   129  
   130  	cacheSource string
   131  	cacheDir    string
   132  
   133  	jobs int
   134  }
   135  
   136  func testAll(ctx context.Context, cfg *testConfig, packages []string) error {
   137  	log := clog.FromContext(ctx)
   138  
   139  	pkgs, err := cfg.getPackages(ctx)
   140  	if err != nil {
   141  		return fmt.Errorf("getting packages: %w", err)
   142  	}
   143  
   144  	archs := make([]types.Architecture, 0, len(cfg.archs))
   145  	for _, arch := range cfg.archs {
   146  		archs = append(archs, types.ParseArchitecture(arch))
   147  
   148  		archDir := cfg.logDir(arch)
   149  		if err := os.MkdirAll(archDir, os.ModePerm); err != nil {
   150  			return fmt.Errorf("creating buildlogs directory: %w", err)
   151  		}
   152  	}
   153  
   154  	eg, ctx := errgroup.WithContext(ctx)
   155  	if cfg.jobs > 0 {
   156  		log.Info("Limiting max jobs", "jobs", cfg.jobs)
   157  		eg.SetLimit(cfg.jobs)
   158  	}
   159  
   160  	// If we're only testing one package or restricting to 1 job, we log to
   161  	// stdout, otherwise we to log to a file
   162  	sequential := len(packages) == 1 || cfg.jobs == 1
   163  
   164  	failures := testFailures{}
   165  
   166  	testPkgs := pkgs.Packages()
   167  	if len(packages) > 0 {
   168  		sub, err := pkgs.Sub(packages...)
   169  		if err != nil {
   170  			return fmt.Errorf("getting packages to test: %w", err)
   171  		}
   172  		testPkgs = sub.Packages()
   173  	}
   174  
   175  	// We don't care about the actual dag deps, so we use a simple fan-out
   176  	for _, pkg := range testPkgs {
   177  		pkg := pkg
   178  
   179  		for _, arch := range archs {
   180  			arch := arch
   181  
   182  			eg.Go(func() error {
   183  				log.Infof("Testing %s", pkg.Name())
   184  
   185  				pctx := ctx
   186  				if !sequential {
   187  					logf, err := cfg.packageLogFile(pkg, arch.ToAPK())
   188  					if err != nil {
   189  						return fmt.Errorf("creating log file: %w", err)
   190  					}
   191  					defer logf.Close()
   192  
   193  					pctx = clog.WithLogger(pctx,
   194  						clog.New(slog.NewTextHandler(logf, nil)),
   195  					)
   196  				}
   197  
   198  				if err := testArch(pctx, cfg, pkg, arch); err != nil {
   199  					log.Errorf("Testing package: %s: %q", pkg.Name(), err)
   200  					failures.add(pkg.Name())
   201  				}
   202  
   203  				return nil
   204  			})
   205  		}
   206  	}
   207  
   208  	if err := eg.Wait(); err != nil {
   209  		return err
   210  	}
   211  
   212  	log.Info("Finished testing packages")
   213  
   214  	if failures.count > 0 {
   215  		log.Fatalf("failed to test %d packages", failures.count)
   216  	}
   217  
   218  	return nil
   219  }
   220  
   221  func testArch(ctx context.Context, cfg *testConfig, pkgCfg *dag.Configuration, arch types.Architecture) error {
   222  	ctx, span := otel.Tracer("wolifctl").Start(ctx, pkgCfg.Package.Name)
   223  	defer span.End()
   224  
   225  	runner, err := newRunner(ctx, cfg.runner)
   226  	if err != nil {
   227  		return fmt.Errorf("creating runner: %w", err)
   228  	}
   229  
   230  	sdir, err := pkgSourceDir(cfg.dir, pkgCfg.Package.Name)
   231  	if err != nil {
   232  		return fmt.Errorf("creating source directory: %w", err)
   233  	}
   234  
   235  	tc, err := build.NewTest(ctx,
   236  		build.WithTestArch(arch),
   237  		build.WithTestConfig(pkgCfg.Path),
   238  		build.WithTestPipelineDir(cfg.pipelineDir),
   239  		build.WithTestExtraKeys(cfg.extraKeys),
   240  		build.WithTestExtraRepos(cfg.extraRepos),
   241  		build.WithExtraTestPackages(cfg.extraPackages),
   242  		build.WithTestRunner(runner),
   243  		build.WithTestSourceDir(sdir),
   244  		build.WithTestCacheDir(cfg.cacheDir),
   245  		build.WithTestCacheSource(cfg.cacheSource),
   246  		build.WithTestDebug(cfg.debug),
   247  	)
   248  	if err != nil {
   249  		return fmt.Errorf("creating tester: %w", err)
   250  	}
   251  	defer tc.Close()
   252  
   253  	if err := tc.TestPackage(ctx); err != nil {
   254  		return err
   255  	}
   256  
   257  	return nil
   258  }
   259  
   260  func (c *testConfig) getPackages(ctx context.Context) (*dag.Packages, error) {
   261  	ctx, span := otel.Tracer("wolfictl").Start(ctx, "getPackages")
   262  	defer span.End()
   263  
   264  	// We want to ignore info level here during setup, but further down below we pull whatever was passed to use via ctx.
   265  	log := clog.New(charmlog.NewWithOptions(os.Stderr, charmlog.Options{ReportTimestamp: true, Level: charmlog.WarnLevel}))
   266  	ctx = clog.WithLogger(ctx, log)
   267  
   268  	pkgs, err := dag.NewPackages(ctx, os.DirFS(c.dir), c.dir, c.pipelineDir)
   269  	if err != nil {
   270  		return nil, fmt.Errorf("parsing packages: %w", err)
   271  	}
   272  
   273  	return pkgs, nil
   274  }
   275  
   276  func (c *testConfig) logDir(arch string) string {
   277  	return filepath.Join(c.outDir, arch, "testlogs")
   278  }
   279  
   280  func (c *testConfig) packageLogFile(pkg *dag.Configuration, arch string) (io.WriteCloser, error) {
   281  	logDir := c.logDir(arch)
   282  
   283  	if err := os.MkdirAll(logDir, os.ModePerm); err != nil {
   284  		return nil, fmt.Errorf("creating log directory: %w", err)
   285  	}
   286  
   287  	filePath := filepath.Join(logDir, fmt.Sprintf("%s.test.log", pkg.FullName()))
   288  
   289  	f, err := os.Create(filePath)
   290  	if err != nil {
   291  		return nil, fmt.Errorf("creating log file: %w", err)
   292  	}
   293  
   294  	return f, nil
   295  }
   296  
   297  func pkgSourceDir(workspaceDir, pkgName string) (string, error) {
   298  	sdir := filepath.Join(workspaceDir, pkgName)
   299  	if _, err := os.Stat(sdir); os.IsNotExist(err) {
   300  		if err := os.MkdirAll(sdir, os.ModePerm); err != nil {
   301  			return "", fmt.Errorf("creating source directory %s: %v", sdir, err)
   302  		}
   303  	} else if err != nil {
   304  		return "", fmt.Errorf("creating source directory: %v", err)
   305  	}
   306  
   307  	return sdir, nil
   308  }
   309  
   310  type testFailures struct {
   311  	mu       sync.Mutex
   312  	failures []string
   313  	count    int
   314  }
   315  
   316  func (t *testFailures) add(fail string) {
   317  	t.mu.Lock()
   318  	defer t.mu.Unlock()
   319  	t.count++
   320  	t.failures = append(t.failures, fail)
   321  }