blob: bf08c3235b29ac786205a3f167a39df772cd6515 [file] [log] [blame]
// Copyright 2018 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"go.fuchsia.dev/fuchsia/tools/build"
"go.fuchsia.dev/fuchsia/tools/integration/testsharder"
"go.fuchsia.dev/fuchsia/tools/lib/color"
"go.fuchsia.dev/fuchsia/tools/lib/flagmisc"
"go.fuchsia.dev/fuchsia/tools/lib/logger"
)
func usage() {
fmt.Printf(`testsharder [flags]
Shards tests produced by a build.
`)
}
type testsharderFlags struct {
buildDir string
outputFile string
tags flagmisc.StringsValue
modifiersPath string
targetTestCount int
targetDurationSecs int
perTestTimeoutSecs int
maxShardsPerEnvironment int
affectedTestsPath string
affectedTestsMaxAttempts int
affectedTestsMultiplyThreshold int
affectedOnly bool
realmLabel string
hermeticDeps bool
imageDeps bool
pave bool
skipUnaffected bool
}
func parseFlags() testsharderFlags {
var flags testsharderFlags
flag.StringVar(&flags.buildDir, "build-dir", "", "path to the fuchsia build directory root (required)")
flag.StringVar(&flags.outputFile, "output-file", "", "path to a file which will contain the shards as JSON, default is stdout")
flag.Var(&flags.tags, "tag", "environment tags on which to filter; only the tests that match all tags will be sharded")
flag.StringVar(&flags.modifiersPath, "modifiers", "", "path to the json manifest containing tests to modify")
flag.IntVar(&flags.targetDurationSecs, "target-duration-secs", 0, "approximate duration that each shard should run in")
flag.IntVar(&flags.maxShardsPerEnvironment, "max-shards-per-env", 8, "maximum shards allowed per environment. If <= 0, no max will be set")
// TODO(fxbug.dev/10456): Support different timeouts for different tests.
flag.IntVar(&flags.perTestTimeoutSecs, "per-test-timeout-secs", 0, "per-test timeout, applied to all tests. If <= 0, no timeout will be set")
// Despite being a misnomer, this argument is still called -max-shard-size
// for legacy reasons. If it becomes confusing, we can create a new
// target_test_count fuchsia.proto field and do a soft transition with the
// recipes to start setting the renamed argument instead.
flag.IntVar(&flags.targetTestCount, "max-shard-size", 0, "target number of tests per shard. If <= 0, will be ignored. Otherwise, tests will be placed into more, smaller shards")
flag.StringVar(&flags.affectedTestsPath, "affected-tests", "", "path to a file containing names of tests affected by the change being tested. One test name per line.")
flag.IntVar(&flags.affectedTestsMaxAttempts, "affected-tests-max-attempts", 2, "maximum attempts for each affected test. Only applied to tests that are not multiplied")
flag.IntVar(&flags.affectedTestsMultiplyThreshold, "affected-tests-multiply-threshold", 0, "if there are <= this many tests in -affected-tests, they may be multplied "+
"(modified to run many times in a separate shard), but only be multiplied if allowed by certain constraints designed to minimize false rejections and bot demand.")
flag.BoolVar(&flags.affectedOnly, "affected-only", false, "whether to create test shards for only the affected tests found in either the modifiers file or the affected-tests file.")
flag.StringVar(&flags.realmLabel, "realm-label", "", "applies this realm label to the output sharded json file generated by testsharder. If empty, no realm label is applied.")
flag.BoolVar(&flags.hermeticDeps, "hermetic-deps", false, "whether to add all the images and blobs used by the shard as dependencies")
flag.BoolVar(&flags.imageDeps, "image-deps", false, "whether to add all the images used by the shard as dependencies")
flag.BoolVar(&flags.pave, "pave", false, "whether the shards generated should pave or netboot fuchsia")
flag.BoolVar(&flags.skipUnaffected, "skip-unaffected", false, "whether the shards should ignore hermetic, unaffected tests")
flag.Usage = usage
flag.Parse()
return flags
}
func main() {
l := logger.NewLogger(logger.ErrorLevel, color.NewColor(color.ColorAuto), os.Stdout, os.Stderr, "")
// testsharder is expected to complete quite quickly, so it's generally not
// useful to include timestamps in logs. File names can be helpful though.
l.SetFlags(logger.Lshortfile)
ctx := logger.WithLogger(context.Background(), l)
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
defer cancel()
if err := mainImpl(ctx); err != nil {
logger.Fatalf(ctx, err.Error())
}
}
func mainImpl(ctx context.Context) error {
flags := parseFlags()
if flags.buildDir == "" {
return fmt.Errorf("must specify a Fuchsia build output directory")
}
m, err := build.NewModules(flags.buildDir)
if err != nil {
return err
}
return execute(ctx, flags, m)
}
type buildModules interface {
Images() []build.Image
Platforms() []build.DimensionSet
TestSpecs() []build.TestSpec
TestListLocation() []string
TestDurations() []build.TestDuration
PackageRepositories() []build.PackageRepo
}
func execute(ctx context.Context, flags testsharderFlags, m buildModules) error {
targetDuration := time.Duration(flags.targetDurationSecs) * time.Second
if flags.targetTestCount > 0 && targetDuration > 0 {
return fmt.Errorf("max-shard-size and target-duration-secs cannot both be set")
}
perTestTimeout := time.Duration(flags.perTestTimeoutSecs) * time.Second
if err := testsharder.ValidateTests(m.TestSpecs(), m.Platforms()); err != nil {
return err
}
opts := &testsharder.ShardOptions{
Tags: flags.tags,
}
// Pass in the test-list to carry over tags to the shards.
testListPath := filepath.Join(flags.buildDir, m.TestListLocation()[0])
testListEntries, err := build.LoadTestList(testListPath)
if err != nil {
return err
}
shards := testsharder.MakeShards(m.TestSpecs(), testListEntries, opts)
if perTestTimeout > 0 {
testsharder.ApplyTestTimeouts(shards, perTestTimeout)
}
testDurations := testsharder.NewTestDurationsMap(m.TestDurations())
shards = testsharder.AddExpectedDurationTags(shards, testDurations)
var modifiers []testsharder.TestModifier
if flags.modifiersPath != "" {
modifiers, err = testsharder.LoadTestModifiers(flags.modifiersPath)
if err != nil {
return err
}
}
if flags.affectedTestsPath != "" {
affectedModifiers, err := testsharder.AffectedModifiers(m.TestSpecs(), flags.affectedTestsPath, flags.affectedTestsMaxAttempts, flags.affectedTestsMultiplyThreshold)
if err != nil {
return err
}
modifiers = append(modifiers, affectedModifiers...)
}
shards, err = testsharder.ApplyModifiers(shards, modifiers)
if err != nil {
return err
}
shards, err = testsharder.MultiplyShards(ctx, shards, modifiers, testDurations, targetDuration, flags.targetTestCount)
if err != nil {
return err
}
// Remove the multiplied shards from the set of shards to analyze for
// affected tests, as we want to run these shards regardless of whether
// the associated tests are affected.
var multipliedShards []*testsharder.Shard
var nonMultipliedShards []*testsharder.Shard
for _, shard := range shards {
if strings.HasPrefix(shard.Name, testsharder.MultipliedShardPrefix) {
multipliedShards = append(multipliedShards, shard)
} else {
nonMultipliedShards = append(nonMultipliedShards, shard)
}
}
var skippedShards []*testsharder.Shard
if flags.affectedOnly {
affected := func(t testsharder.Test) bool {
return t.Affected
}
affectedShards, _ := testsharder.PartitionShards(nonMultipliedShards, affected, testsharder.AffectedShardPrefix)
shards = affectedShards
} else {
// Filter out the affected, hermetic shards from the non-multiplied shards.
hermeticAndAffected := func(t testsharder.Test) bool {
return t.Affected && t.Hermetic()
}
affectedHermeticShards, unaffectedOrNonhermeticShards := testsharder.PartitionShards(nonMultipliedShards, hermeticAndAffected, testsharder.AffectedShardPrefix)
shards = affectedHermeticShards
// Filter out unaffected hermetic shards from the remaining shards.
hermetic := func(t testsharder.Test) bool {
return t.Hermetic()
}
unaffectedHermeticShards, nonhermeticShards := testsharder.PartitionShards(unaffectedOrNonhermeticShards, hermetic, testsharder.HermeticShardPrefix)
if flags.skipUnaffected {
// Mark the unaffected, hermetic shards skipped, as we don't need to
// run them.
skippedShards, err = testsharder.MarkShardsSkipped(unaffectedHermeticShards)
if err != nil {
return err
}
} else {
shards = append(shards, unaffectedHermeticShards...)
}
// The shards should include:
// 1. Affected hermetic shards
// 2. Unaffected hermetic shards (may be skipped)
// 3. Nonhermetic shards
shards = append(shards, nonhermeticShards...)
}
// Add the multiplied shards back into the list of shards to run.
shards = append(shards, multipliedShards...)
shards = testsharder.WithTargetDuration(shards, targetDuration, flags.targetTestCount, flags.maxShardsPerEnvironment, testDurations)
if flags.hermeticDeps || flags.imageDeps {
for _, s := range shards {
testsharder.AddImageDeps(s, m.Images(), flags.pave)
if flags.hermeticDeps {
pkgRepos := m.PackageRepositories()
if len(pkgRepos) < 1 {
return errors.New("build did not generate a package repository")
}
if err := s.CreatePackageRepo(flags.buildDir, pkgRepos[0].Path); err != nil {
return err
}
}
}
}
if err := testsharder.ExtractDeps(shards, flags.buildDir); err != nil {
return err
}
if flags.realmLabel != "" {
testsharder.ApplyRealmLabel(shards, flags.realmLabel)
}
// Add back the skipped shards so that we can process and upload results
// downstream.
shards = append(shards, skippedShards...)
f := os.Stdout
if flags.outputFile != "" {
var err error
f, err = os.Create(flags.outputFile)
if err != nil {
return fmt.Errorf("unable to create %s: %v", flags.outputFile, err)
}
defer f.Close()
}
encoder := json.NewEncoder(f)
// Use 4-space indents so golden files are compatible with `fx format-code`.
encoder.SetIndent("", " ")
if err := encoder.Encode(&shards); err != nil {
return fmt.Errorf("failed to encode shards: %v", err)
}
return nil
}