blob: 951cb80934b96db638f7cb1a539c8200ba0c4a2b [file] [log] [blame] [edit]
// Copyright 2021 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// The testplan tool evaluates Starlark files to generate ChromeOS
// chromiumos.test.api.v1.HWTestPlan protos.
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io/ioutil"
"math/rand"
"os"
"regexp"
"strings"
"time"
"github.com/golang/glog"
"github.com/golang/protobuf/jsonpb"
"github.com/golang/protobuf/proto"
"github.com/maruel/subcommands"
buildpb "go.chromium.org/chromiumos/config/go/build/api"
"go.chromium.org/chromiumos/config/go/payload"
testpb "go.chromium.org/chromiumos/config/go/test/api"
test_api_v1 "go.chromium.org/chromiumos/config/go/test/api/v1"
"go.chromium.org/chromiumos/infra/proto/go/testplans"
luciflag "go.chromium.org/luci/common/flag"
testplan "chromiumos/test/plan/internal"
"chromiumos/test/plan/internal/compatibility"
"chromiumos/test/plan/internal/coveragerules"
)
// Version is set to the CROS_GO_VERSION eclass variable at build time. See
// cros-go.eclass for details.
var Version string
// errToCode converts an error into an exit code.
func errToCode(a subcommands.Application, err error) int {
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", a.GetName(), err)
return 1
}
return 0
}
// addExistingFlags adds all currently defined flags to generateRun.
//
// Some packages define flags in their init functions (e.g. glog). In order for
// these flags to be defined on a command, they need to be defined in the
// CommandRun function as well.
func (r *generateRun) addExistingFlags() {
if !flag.Parsed() {
panic("flag.Parse() must be called before addExistingFlags()")
}
flag.VisitAll(func(f *flag.Flag) {
r.GetFlags().Var(f.Value, f.Name, f.Usage)
})
}
var application = &subcommands.DefaultApplication{
Name: "testplan",
Title: "A tool to evaluate Starlark files to generate ChromeOS chromiumos.test.api.v1.HWTestPlan protos.",
Commands: []*subcommands.Command{
cmdGenerate,
cmdVersion,
subcommands.CmdHelp,
},
}
var cmdVersion = &subcommands.Command{
UsageLine: "version",
ShortDesc: "Prints the Portage package version information used to build the tool.",
CommandRun: func() subcommands.CommandRun {
return &versionRun{}
},
}
type versionRun struct {
subcommands.CommandRunBase
}
func (r *versionRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
if Version == "" {
fmt.Println("testplan version unknown, likely was not built with Portage")
return 1
}
fmt.Printf("testplan version: %s\n", Version)
return 0
}
var cmdGenerate = &subcommands.Command{
UsageLine: "generate -plan plan1.star [-plan plan2.star] -dutattributes PATH -buildmetadata -out OUTPUT",
ShortDesc: "generate HW test plan protos",
LongDesc: `Generate HW test plan protos.
Evaluates Starlark files to generate HWTestPlans as newline-delimited json protos.
`,
CommandRun: func() subcommands.CommandRun {
r := &generateRun{}
// TODO(b/182898188): Add more details on proto input / output for
// Starlark files once it is implemented.
r.Flags.Var(
luciflag.StringSlice(&r.planPaths),
"plan",
"Starlark file to use. Must be specified at least once.",
)
r.Flags.StringVar(
&r.dutAttributeListPath,
"dutattributes",
"",
"Path to a proto file containing a DutAttributeList. Can be JSON "+
"or binary proto.",
)
r.Flags.StringVar(
&r.buildMetadataListPath,
"buildmetadata",
"",
"Path to a proto file containing a SystemImage.BuildMetadataList. "+
"Can be JSON or binary proto.",
)
r.Flags.StringVar(
&r.flatConfigListPath,
"flatconfiglist",
"",
"Path to a proto file containing a FlatConfigList. Can be JSON or "+
"binary proto.",
)
r.Flags.BoolVar(
&r.ctpV1,
"ctpv1",
false,
"Output GenerateTestPlanResponse protos instead of HWTestPlans, "+
"for backwards compatibility with CTP1. Output is still "+
"to <out>. generatetestplanreq must be set if this flag is "+
"true",
)
r.Flags.StringVar(
&r.generateTestPlanReqPath,
"generatetestplanreq",
"",
"Path to a proto file containing a GenerateTestPlanRequest. Can be"+
"JSON or binary proto. Should be set iff ctpv1 is set.",
)
r.Flags.StringVar(
&r.boardPriorityListPath,
"boardprioritylist",
"",
"Path to a proto file containing a BoardPriorityList. Can be JSON"+
"or binary proto. Should be set iff ctpv1 is set.",
)
r.Flags.StringVar(
&r.out,
"out",
"",
"Path to the output HWTestPlans.",
)
r.Flags.StringVar(
&r.textSummaryOut,
"textsummaryout",
"",
"Path to write a more easily human-readable summary of the "+
"HWTestPlans to. If not set, no summary is written.",
)
r.addExistingFlags()
return r
},
}
type generateRun struct {
subcommands.CommandRunBase
planPaths []string
buildMetadataListPath string
dutAttributeListPath string
flatConfigListPath string
ctpV1 bool
generateTestPlanReqPath string
boardPriorityListPath string
out string
textSummaryOut string
}
func (r *generateRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
return errToCode(a, r.run())
}
// The v1 jsonpb package has known issues with FieldMasks:
// https://github.com/golang/protobuf/issues/745. This is fixed in the v2
// package, but as of 6/28/2021 the version of dev-go/protobuf ebuild installs
// v1.3.2 of the github.com/golang/protobuf package, which does not contain the
// MessageV2 function (required to use the v2 jsonproto package).
//
// Bumping the version of this Portage package broke dependent packages. As a
// workaround, parse out the known FieldMask messages from the jsonpb files
// we are reading.
//
// TODO(b/189223005): Fix dependent package or install multiple versions of the
// protobuf package.
var publicReplicationRegexp = regexp.MustCompile(`(?m),?\s*"publicReplication":\s*{\s*"publicFields":\s*"[^"]*"\s*}`)
// parseJsonpb parses the jsonpb in b into m.
func parseJsonpb(b []byte, m proto.Message) error {
lenBeforeRegexp := len(b)
b = publicReplicationRegexp.ReplaceAll(b, []byte{})
if lenBeforeRegexp != len(b) {
glog.V(2).Infof(
`Removed "publicReplication" fields with regexp. Length before: %d. Length after: %d`,
lenBeforeRegexp, len(b),
)
}
return jsonpb.Unmarshal(bytes.NewReader(b), m)
}
// readBinaryOrJSONPb reads path into m, attempting to parse as both a binary
// and json encoded proto.
//
// This function is meant as a convenience so the CLI can take either json or
// binary protos as input. This function guesses at whether to attempt to parse
// as binary or json first based on path's suffix.
func readBinaryOrJSONPb(path string, m proto.Message) error {
b, err := ioutil.ReadFile(path)
if err != nil {
return err
}
if strings.HasSuffix(path, ".jsonpb") || strings.HasSuffix(path, ".jsonproto") {
glog.Infof("Attempting to parse %q as jsonpb first", path)
err = parseJsonpb(b, m)
if err == nil {
return nil
}
glog.Warningf("Parsing %q as jsonpb failed, attempting to parse as binary pb", path)
return proto.Unmarshal(b, m)
}
glog.Infof("Attempting to parse %q as binary pb first", path)
err = proto.Unmarshal(b, m)
if err == nil {
return nil
}
glog.Warningf("Parsing %q as binarypb failed, attempting to parse as jsonpb", path)
return parseJsonpb(b, m)
}
// readTextpb reads the textpb at path into m.
func readTextpb(path string, m proto.Message) error {
b, err := ioutil.ReadFile(path)
if err != nil {
return err
}
return proto.UnmarshalText(string(b), m)
}
// writePlans writes a newline-delimited json file containing plans to outPath.
func writePlans(plans []*test_api_v1.HWTestPlan, outPath, textSummaryOutPath string) error {
outFile, err := os.Create(outPath)
if err != nil {
return err
}
defer outFile.Close()
marshaler := jsonpb.Marshaler{}
allRules := []*testpb.CoverageRule{}
for _, plan := range plans {
jsonString, err := marshaler.MarshalToString(plan)
if err != nil {
return err
}
jsonString += "\n"
if _, err = outFile.Write([]byte(jsonString)); err != nil {
return err
}
allRules = append(allRules, plan.GetCoverageRules()...)
}
if textSummaryOutPath != "" {
glog.Infof("Writing text summary file to %s", textSummaryOutPath)
textSummaryOutFile, err := os.Create(textSummaryOutPath)
if err != nil {
return err
}
defer textSummaryOutFile.Close()
if err = coveragerules.WriteTextSummary(textSummaryOutFile, allRules); err != nil {
return err
}
}
return nil
}
// validateFlags checks valid flags are passed to generate, e.g. all required
// flags are set.
func (r *generateRun) validateFlags() error {
if len(r.planPaths) == 0 {
return errors.New("at least one -plan is required")
}
if r.dutAttributeListPath == "" {
return errors.New("-dutattributes is required")
}
if r.buildMetadataListPath == "" {
return errors.New("-buildmetadata is required")
}
if r.flatConfigListPath == "" {
return errors.New("-flatconfiglist is required")
}
if r.out == "" {
return errors.New("-out is required")
}
if r.ctpV1 != (r.generateTestPlanReqPath != "") {
return errors.New("-generatetestplanreq must be set iff -out is set.")
}
if r.ctpV1 != (r.boardPriorityListPath != "") {
return errors.New("-boardprioritylist must be set iff -out is set.")
}
return nil
}
// run is the actual implementation of the generate command.
func (r *generateRun) run() error {
ctx := context.Background()
if err := r.validateFlags(); err != nil {
return err
}
buildMetadataList := &buildpb.SystemImage_BuildMetadataList{}
if err := readBinaryOrJSONPb(r.buildMetadataListPath, buildMetadataList); err != nil {
return err
}
glog.Infof("Read %d SystemImage.Metadata from %s", len(buildMetadataList.Values), r.buildMetadataListPath)
for _, buildMetadata := range buildMetadataList.Values {
glog.V(2).Infof("Read BuildMetadata: %s", buildMetadata)
}
dutAttributeList := &testpb.DutAttributeList{}
if err := readBinaryOrJSONPb(r.dutAttributeListPath, dutAttributeList); err != nil {
return err
}
glog.Infof("Read %d DutAttributes from %s", len(dutAttributeList.DutAttributes), r.dutAttributeListPath)
for _, dutAttribute := range dutAttributeList.DutAttributes {
glog.V(2).Infof("Read DutAttribute: %s", dutAttribute)
}
glog.Infof("Starting read of FlatConfigs from %s (may be slow if file is large)", r.flatConfigListPath)
flatConfigList := &payload.FlatConfigList{}
if err := readBinaryOrJSONPb(r.flatConfigListPath, flatConfigList); err != nil {
return err
}
glog.Infof("Read %d FlatConfigs from %s", len(flatConfigList.Values), r.flatConfigListPath)
hwTestPlans, err := testplan.Generate(
ctx, r.planPaths, buildMetadataList, dutAttributeList, flatConfigList,
)
if err != nil {
return err
}
if r.ctpV1 {
glog.Infof(
"Outputting GenerateTestPlanRequest to %s instead of HWTestPlan, for backwards compatibility with CTPV1",
r.out,
)
generateTestPlanReq := &testplans.GenerateTestPlanRequest{}
if err := readBinaryOrJSONPb(r.generateTestPlanReqPath, generateTestPlanReq); err != nil {
return err
}
boardPriorityList := &testplans.BoardPriorityList{}
if err := readBinaryOrJSONPb(r.boardPriorityListPath, boardPriorityList); err != nil {
return err
}
resp, err := compatibility.ToCTP1(
rand.New(rand.NewSource(time.Now().Unix())),
hwTestPlans, generateTestPlanReq, dutAttributeList, boardPriorityList,
)
if err != nil {
return err
}
outFile, err := os.Create(r.out)
defer outFile.Close()
respBytes, err := proto.Marshal(resp)
if err != nil {
return err
}
if _, err := outFile.Write(respBytes); err != nil {
return err
}
return nil
}
glog.Infof("Generated %d CoverageRules, writing to %s", len(hwTestPlans), r.out)
return writePlans(hwTestPlans, r.out, r.textSummaryOut)
}
func main() {
os.Exit(subcommands.Run(application, nil))
}