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 }