github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/tools/syz-testbed/testbed.go (about)

     1  // Copyright 2021 syzkaller project authors. All rights reserved.
     2  // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
     3  
     4  // syz-testbed automatically checks out, builds and sets up a number of syzkaller instances.
     5  // This might be very helpful e.g. when gauging the effect of new changes on the total syzkaller
     6  // performance.
     7  // For details see docs/syz_testbed.md.
     8  
     9  package main
    10  
    11  import (
    12  	"encoding/json"
    13  	"flag"
    14  	"fmt"
    15  	"log"
    16  	"os/exec"
    17  	"path/filepath"
    18  	"regexp"
    19  	"sync"
    20  	"time"
    21  
    22  	"github.com/google/syzkaller/pkg/config"
    23  	"github.com/google/syzkaller/pkg/osutil"
    24  	"github.com/google/syzkaller/pkg/tool"
    25  	"github.com/google/syzkaller/pkg/vcs"
    26  )
    27  
    28  var (
    29  	flagConfig = flag.String("config", "", "config file")
    30  )
    31  
    32  type TestbedConfig struct {
    33  	Name          string           `json:"name"`           // name of the testbed
    34  	Target        string           `json:"target"`         // what application to test
    35  	MaxInstances  int              `json:"max_instances"`  // max # of simultaneously running instances
    36  	RunTime       DurationConfig   `json:"run_time"`       // lifetime of an instance (default "24h")
    37  	HTTP          string           `json:"http"`           // on which port to set up a simple web dashboard
    38  	BenchCmp      string           `json:"benchcmp"`       // path to the syz-benchcmp executable
    39  	Corpus        string           `json:"corpus"`         // path to the corpus file
    40  	Workdir       string           `json:"workdir"`        // instances will be checked out there
    41  	ReproConfig   ReproTestConfig  `json:"repro_config"`   // syz-repro benchmarking config
    42  	ManagerConfig json.RawMessage  `json:"manager_config"` // base manager config
    43  	ManagerMode   string           `json:"manager_mode"`   // manager mode flag
    44  	Checkouts     []CheckoutConfig `json:"checkouts"`
    45  }
    46  
    47  type DurationConfig struct {
    48  	time.Duration
    49  }
    50  
    51  type CheckoutConfig struct {
    52  	Name          string          `json:"name"`
    53  	Repo          string          `json:"repo"`
    54  	Branch        string          `json:"branch"`
    55  	ManagerConfig json.RawMessage `json:"manager_config"` // a patch to manager config
    56  }
    57  
    58  type ReproTestConfig struct {
    59  	InputLogs     string   `json:"input_logs"`      // take crash logs from a folder
    60  	InputWorkdir  string   `json:"input_workdir"`   // take crash logs from a syzkaller's workdir
    61  	CrashesPerBug int      `json:"crashes_per_bug"` // how many crashes must be taken from each bug
    62  	SkipBugs      []string `json:"skip_bugs"`       // crashes to exclude from the workdir, list of regexps
    63  }
    64  
    65  type TestbedContext struct {
    66  	Config         *TestbedConfig
    67  	Checkouts      []*Checkout
    68  	NextCheckoutID int
    69  	NextInstanceID int
    70  	Target         TestbedTarget
    71  	mu             sync.Mutex
    72  }
    73  
    74  func main() {
    75  	flag.Parse()
    76  	benchcmp, _ := exec.LookPath("syz-benchcmp")
    77  	cfg := &TestbedConfig{
    78  		Name:     "testbed",
    79  		Target:   "syz-manager",
    80  		BenchCmp: benchcmp,
    81  		RunTime:  DurationConfig{24 * time.Hour},
    82  		ReproConfig: ReproTestConfig{
    83  			CrashesPerBug: 1,
    84  		},
    85  		ManagerMode: "fuzzing",
    86  	}
    87  	err := config.LoadFile(*flagConfig, &cfg)
    88  	if err != nil {
    89  		tool.Failf("failed to read config: %s", err)
    90  	}
    91  
    92  	err = checkConfig(cfg)
    93  	if err != nil {
    94  		tool.Failf("invalid config: %s", err)
    95  	}
    96  	ctx := TestbedContext{
    97  		Config: cfg,
    98  		Target: targetConstructors[cfg.Target](cfg),
    99  	}
   100  	go ctx.setupHTTPServer()
   101  
   102  	for _, checkoutCfg := range cfg.Checkouts {
   103  		mgrCfg := ctx.MakeMgrConfig(cfg.ManagerConfig, checkoutCfg.ManagerConfig)
   104  		co, err := ctx.NewCheckout(&checkoutCfg, mgrCfg)
   105  		if err != nil {
   106  			tool.Failf("checkout failed: %s", err)
   107  		}
   108  		ctx.Checkouts = append(ctx.Checkouts, co)
   109  	}
   110  
   111  	shutdown := make(chan struct{})
   112  	osutil.HandleInterrupts(shutdown)
   113  
   114  	go func() {
   115  		const period = 90 * time.Second
   116  		for {
   117  			time.Sleep(period)
   118  			err := ctx.SaveStats()
   119  			if err != nil {
   120  				log.Printf("stats saving error: %s", err)
   121  			}
   122  		}
   123  	}()
   124  
   125  	ctx.Loop(shutdown)
   126  }
   127  
   128  func (ctx *TestbedContext) MakeMgrConfig(base, patch json.RawMessage) json.RawMessage {
   129  	mgrCfg, err := config.MergeJSONs(base, patch)
   130  	if err != nil {
   131  		tool.Failf("failed to apply a patch to the base manager config: %s", err)
   132  	}
   133  	// We don't care much about the specific ports of syz-managers.
   134  	mgrCfg, err = config.PatchJSON(mgrCfg, map[string]interface{}{"HTTP": ":0"})
   135  	if err != nil {
   136  		tool.Failf("failed to assign empty HTTP value: %s", err)
   137  	}
   138  	return mgrCfg
   139  }
   140  
   141  func (ctx *TestbedContext) GetStatViews() ([]StatView, error) {
   142  	groupsCompleted := []RunResultGroup{}
   143  	groupsAll := []RunResultGroup{}
   144  	for _, checkout := range ctx.Checkouts {
   145  		running := checkout.GetRunningResults()
   146  		completed := checkout.GetCompletedResults()
   147  		groupsCompleted = append(groupsCompleted, RunResultGroup{
   148  			Name:    checkout.Name,
   149  			Results: completed,
   150  		})
   151  		groupsAll = append(groupsAll, RunResultGroup{
   152  			Name:    checkout.Name,
   153  			Results: append(completed, running...),
   154  		})
   155  	}
   156  	return []StatView{
   157  		{
   158  			Name:   "completed",
   159  			Groups: groupsCompleted,
   160  		},
   161  		{
   162  			Name:   "all",
   163  			Groups: groupsAll,
   164  		},
   165  	}, nil
   166  }
   167  
   168  func (ctx *TestbedContext) TestbedStatsTable() *Table {
   169  	table := NewTable("Checkout", "Running", "Completed", "Last started")
   170  	for _, checkout := range ctx.Checkouts {
   171  		checkout.mu.Lock()
   172  		last := ""
   173  		if !checkout.LastRunning.IsZero() {
   174  			last = time.Since(checkout.LastRunning).Round(time.Second).String()
   175  		}
   176  		table.AddRow(checkout.Name,
   177  			fmt.Sprintf("%d", len(checkout.Running)),
   178  			fmt.Sprintf("%d", len(checkout.Completed)),
   179  			last,
   180  		)
   181  		checkout.mu.Unlock()
   182  	}
   183  	return table
   184  }
   185  
   186  func (ctx *TestbedContext) SaveStats() error {
   187  	// Preventing concurrent saving of the stats.
   188  	ctx.mu.Lock()
   189  	defer ctx.mu.Unlock()
   190  	views, err := ctx.GetStatViews()
   191  	if err != nil {
   192  		return err
   193  	}
   194  	for _, view := range views {
   195  		dir := filepath.Join(ctx.Config.Workdir, "stats_"+view.Name)
   196  		err := ctx.Target.SaveStatView(&view, dir)
   197  		if err != nil {
   198  			return err
   199  		}
   200  	}
   201  	table := ctx.TestbedStatsTable()
   202  	return table.SaveAsCsv(filepath.Join(ctx.Config.Workdir, "testbed.csv"))
   203  }
   204  
   205  func (ctx *TestbedContext) Slot(slotID int, stop chan struct{}, ret chan error) {
   206  	// It seems that even gracefully finished syz-managers can leak GCE instances.
   207  	// To allow for that strange behavior, let's reuse syz-manager names in each slot,
   208  	// so that its VMs will in turn reuse the names of the leaked ones.
   209  	slotName := fmt.Sprintf("%s-%d", ctx.Config.Name, slotID)
   210  	for {
   211  		checkout, instance, err := ctx.Target.NewJob(slotName, ctx.Checkouts)
   212  		if err != nil {
   213  			ret <- fmt.Errorf("failed to create instance: %w", err)
   214  			return
   215  		}
   216  		checkout.AddRunning(instance)
   217  		retChannel := make(chan error)
   218  		go func() {
   219  			retChannel <- instance.Run()
   220  		}()
   221  
   222  		var retErr error
   223  		select {
   224  		case <-stop:
   225  			instance.Stop()
   226  			<-retChannel
   227  			retErr = fmt.Errorf("instance was killed")
   228  		case retErr = <-retChannel:
   229  		}
   230  
   231  		// For now, we only archive instances that finished normally (ret == nil).
   232  		// syz-testbed will anyway stop after such an error, so it's not a problem
   233  		// that they remain in Running.
   234  		if retErr != nil {
   235  			ret <- retErr
   236  			return
   237  		}
   238  		err = checkout.ArchiveInstance(instance)
   239  		if err != nil {
   240  			ret <- fmt.Errorf("a call to ArchiveInstance failed: %w", err)
   241  			return
   242  		}
   243  	}
   244  }
   245  
   246  // Create instances, run them, stop them, archive them, and so on...
   247  func (ctx *TestbedContext) Loop(stop chan struct{}) {
   248  	stopAll := make(chan struct{})
   249  	errors := make(chan error)
   250  	for i := 0; i < ctx.Config.MaxInstances; i++ {
   251  		go ctx.Slot(i, stopAll, errors)
   252  	}
   253  
   254  	exited := 0
   255  	select {
   256  	case <-stop:
   257  		log.Printf("stopping the experiment")
   258  	case err := <-errors:
   259  		exited = 1
   260  		log.Printf("an instance has failed (%s), stopping everything", err)
   261  	}
   262  	close(stopAll)
   263  	for ; exited < ctx.Config.MaxInstances; exited++ {
   264  		<-errors
   265  	}
   266  }
   267  
   268  func (d *DurationConfig) UnmarshalJSON(data []byte) error {
   269  	var v interface{}
   270  	if err := json.Unmarshal(data, &v); err != nil {
   271  		return err
   272  	}
   273  	str, ok := v.(string)
   274  	if !ok {
   275  		return fmt.Errorf("%s was expected to be a string", data)
   276  	}
   277  	parsed, err := time.ParseDuration(str)
   278  	if err == nil {
   279  		d.Duration = parsed
   280  	}
   281  	return err
   282  }
   283  
   284  func (d *DurationConfig) MarshalJSON() ([]byte, error) {
   285  	return json.Marshal(d.String())
   286  }
   287  
   288  func checkReproTestConfig(cfg *ReproTestConfig) error {
   289  	if cfg.InputLogs != "" && !osutil.IsExist(cfg.InputLogs) {
   290  		return fmt.Errorf("input_log folder does not exist: %v", cfg.InputLogs)
   291  	}
   292  	if cfg.InputWorkdir != "" && !osutil.IsExist(cfg.InputWorkdir) {
   293  		return fmt.Errorf("input_workdir folder does not exist: %v", cfg.InputWorkdir)
   294  	}
   295  	if cfg.CrashesPerBug < 1 {
   296  		return fmt.Errorf("crashes_per_bug cannot be less than 1: %d", cfg.CrashesPerBug)
   297  	}
   298  	return nil
   299  }
   300  
   301  func checkConfig(cfg *TestbedConfig) error {
   302  	testbedNameRe := regexp.MustCompile(`^[0-9a-z\-]{1,20}$`)
   303  	if !testbedNameRe.MatchString(cfg.Name) {
   304  		return fmt.Errorf("invalid testbed name: %v", cfg.Name)
   305  	}
   306  	if cfg.Workdir == "" {
   307  		return fmt.Errorf("workdir is empty")
   308  	}
   309  	cfg.Workdir = osutil.Abs(cfg.Workdir)
   310  	err := osutil.MkdirAll(cfg.Workdir)
   311  	if err != nil {
   312  		return err
   313  	}
   314  	if cfg.Corpus != "" && !osutil.IsExist(cfg.Corpus) {
   315  		return fmt.Errorf("corpus %v does not exist", cfg.Corpus)
   316  	}
   317  	if cfg.MaxInstances < 1 {
   318  		return fmt.Errorf("max_instances cannot be less than 1")
   319  	}
   320  	if cfg.BenchCmp != "" && !osutil.IsExist(cfg.BenchCmp) {
   321  		return fmt.Errorf("benchmp path is specified, but %s does not exist", cfg.BenchCmp)
   322  	}
   323  	if _, ok := targetConstructors[cfg.Target]; !ok {
   324  		return fmt.Errorf("unknown target %s", cfg.Target)
   325  	}
   326  	if err = checkReproTestConfig(&cfg.ReproConfig); err != nil {
   327  		return err
   328  	}
   329  	cfg.Corpus = osutil.Abs(cfg.Corpus)
   330  	names := make(map[string]bool)
   331  	for idx := range cfg.Checkouts {
   332  		co := &cfg.Checkouts[idx]
   333  		if !vcs.CheckRepoAddress(co.Repo) {
   334  			return fmt.Errorf("invalid repo: %s", co.Repo)
   335  		}
   336  		if co.Branch == "" {
   337  			co.Branch = "master"
   338  		} else if !vcs.CheckBranch(co.Branch) {
   339  			return fmt.Errorf("invalid branch: %s", co.Branch)
   340  		}
   341  		if names[co.Name] {
   342  			return fmt.Errorf("duplicate checkout name: %v", co.Name)
   343  		}
   344  		names[co.Name] = true
   345  	}
   346  	return nil
   347  }