cros_test_ready: initial version
Add a new tool to check if a DUT is ready to be used for testing.
BUG=b:284903141
TEST=sudo emerge-hana cros-test-ready
Cq-Depend: chromium:4702115
Cq-Depend: chromium:4700387
Change-Id: I4cda386dcc2afcafd40d4ebe47cd15aacb5c9b76
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/4702272
Tested-by: Seewai Fu <seewaifu@google.com>
Reviewed-by: Derek Beckett <dbeckett@chromium.org>
Commit-Queue: Seewai Fu <seewaifu@google.com>
diff --git a/src/chromiumos/test/check/cmd/cros_test_ready/main.go b/src/chromiumos/test/check/cmd/cros_test_ready/main.go
new file mode 100644
index 0000000..d0c4779
--- /dev/null
+++ b/src/chromiumos/test/check/cmd/cros_test_ready/main.go
@@ -0,0 +1,211 @@
+// Copyright 2023 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Package main implements the cros-test-finder for finding tests based on tags.
+package main
+
+import (
+ "context"
+ "crypto/md5"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/golang/protobuf/jsonpb"
+ "github.com/google/subcommands"
+
+ "go.chromium.org/chromiumos/config/go/test/api"
+)
+
+const (
+ defaultConfigPath = "/usr/local/etc/cros_test_ready_config.jsonpb"
+)
+
+// createLogFile creates a file and its parent directory for logging purpose.
+func createLogFile(fullPath string) (*os.File, error) {
+ if fullPath == "" {
+ t := time.Now()
+ fullPath = fmt.Sprintf("/var/tmp/cros_test_ready/log_%s.txt", t.Format("20060102150405"))
+ }
+ if err := os.MkdirAll(fullPath, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create directory %v: %w", fullPath, err)
+ }
+
+ logFullPathName := filepath.Join(fullPath, "log.txt")
+
+ // Log the full output of the command to disk.
+ logFile, err := os.Create(logFullPathName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create file %v: %w", fullPath, err)
+ }
+ return logFile, nil
+}
+
+// newLogger creates a logger. Using go default logger for now.
+func newLogger(logFile *os.File) *log.Logger {
+ var writer io.Writer = os.Stderr
+ if logFile != nil {
+ writer = io.MultiWriter(logFile, os.Stderr)
+ }
+ return log.New(writer, "", log.LstdFlags|log.LUTC)
+}
+
+// readInput reads a CrosTestReadyConfig jsonproto file and returns a pointer to RunTestsRequest.
+func readInput(fileName string) (*api.CrosTestReadyConfig, error) {
+ f, err := os.Open(fileName)
+ if err != nil {
+ return nil, fmt.Errorf("fail to read file %v: %v", fileName, err)
+ }
+ req := api.CrosTestReadyConfig{}
+ umrsh := jsonpb.Unmarshaler{}
+ umrsh.AllowUnknownFields = true
+ if err := umrsh.Unmarshal(f, &req); err != nil {
+ return nil, fmt.Errorf("fail to unmarshal file %v: %v", fileName, err)
+ }
+ return &req, nil
+}
+
+// Version is the version info of this command. It is filled in during emerge.
+var Version = "<unknown>"
+
+type src2DestDir struct {
+ src string
+ destDir string
+}
+
+type fileMappings struct {
+ mapping []src2DestDir
+}
+
+func (fm *fileMappings) String() string {
+ return fmt.Sprintf("%v", fm.mapping)
+}
+
+func (fm *fileMappings) Set(value string) error {
+ mapping := strings.Split(value, ":")
+ if len(mapping) != 2 {
+ return fmt.Errorf("invalid string for file mapping: %q", mapping)
+ }
+ fm.mapping = append(fm.mapping, src2DestDir{src: mapping[0], destDir: mapping[1]})
+ return nil
+}
+
+// checkCmd implements subcommands.Command to check
+// if a DUT is test ready to be used for testing.
+type checkCmd struct {
+ config string
+ logFile string
+ exitOnError bool
+ logger *log.Logger
+}
+
+// newCheckCmd returns a new checkCmd that will check
+// if a DUT is test ready to be used for testing.
+func newCheckCmd() *checkCmd {
+ return &checkCmd{}
+}
+
+func (*checkCmd) Name() string { return "check" }
+func (*checkCmd) Synopsis() string { return "check" }
+func (*checkCmd) Usage() string {
+ return `Usage: generate [flag]...
+
+Description:
+ Check whether a DUT is test ready.
+ It will exit with status 0 if all checks are passed.
+ Otherwise, it will exit with status 1.
+
+Flag:
+`
+}
+
+func (c *checkCmd) SetFlags(f *flag.FlagSet) {
+ f.StringVar(&c.config,
+ "config", defaultConfigPath,
+ "Specify the jsonpb configuration file for what are needed to be checked on the DUT.")
+ f.StringVar(&c.logFile,
+ "log", "",
+ "Specify the log file path (default: /var/tmp/cros_test_ready/log_<YYYYmmDDHHMMSS>.txt)")
+ f.BoolVar(&c.exitOnError,
+ "exit_on_error",
+ true,
+ "Specify whether cros_test_ready should exit when it encounters the first error.")
+
+}
+
+func (c *checkCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+ fullLog, err := createLogFile(c.logFile)
+ if err != nil {
+ c.logger = newLogger(nil)
+ c.logger.Printf("Failed to create log file %s; use stderr only: %v", c.logFile, err)
+ } else {
+ c.logger = newLogger(fullLog)
+ defer fullLog.Close()
+ }
+
+ checkConfig, err := readInput(c.config)
+ if err != nil {
+ c.logger.Fatalf("Failed to read configuration %s: %v", c.config, err)
+ }
+ hasError := false
+
+ for _, fileChecksum := range checkConfig.GetChecksums() {
+ checksum, err := md5Str(fileChecksum.GetKey())
+ if err != nil {
+ c.logger.Printf("Failed to get checksum for file %s: %v", fileChecksum.GetKey(), err)
+ hasError = true
+ }
+ if checksum != fileChecksum.GetValue() {
+ c.logger.Printf("Failed to get expected checksum for file %s: got %q; wanted %q",
+ fileChecksum.GetKey(), checksum, fileChecksum.GetValue())
+ hasError = true
+ }
+ if c.exitOnError && hasError {
+ return subcommands.ExitFailure
+ }
+ }
+ return subcommands.ExitSuccess
+}
+
+func md5Str(filename string) (string, error) {
+ f, err := os.Open(filename)
+ if err != nil {
+ return "", fmt.Errorf("failed to open file %s: %v", filename, err)
+ }
+ defer f.Close()
+
+ h := md5.New()
+ if _, err := io.Copy(h, f); err != nil {
+ return "", fmt.Errorf("failed to calculate checksum for file %s: %v", filename, err)
+ }
+
+ return fmt.Sprintf("%x", h.Sum(nil)), nil
+}
+
+func mainInternal(ctx context.Context) int {
+ subcommands.Register(subcommands.HelpCommand(), "")
+ subcommands.Register(subcommands.FlagsCommand(), "")
+ subcommands.Register(subcommands.CommandsCommand(), "")
+ subcommands.Register(newCheckCmd(), "")
+
+ version := flag.Bool("version", false, "print version and exit")
+
+ flag.Parse()
+
+ if *version {
+ fmt.Printf("cros_test_ready version %s\n", Version)
+ return 0
+ }
+
+ return int(subcommands.Execute(ctx))
+}
+
+func main() {
+ os.Exit(mainInternal(context.Background()))
+}
diff --git a/src/chromiumos/test/check/python/cros_test_ready_config_generator.py b/src/chromiumos/test/check/python/cros_test_ready_config_generator.py
new file mode 100755
index 0000000..011bc53
--- /dev/null
+++ b/src/chromiumos/test/check/python/cros_test_ready_config_generator.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+# Copyright 2023 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Generate create_test_ready.jsonpb for checking DUT readiness for testing."""
+
+import argparse
+import hashlib
+import os
+import sys
+
+from chromiumos.test.api import cros_test_ready_cli_pb2 as test_ready_pb
+from google.protobuf.json_format import MessageToJson
+import six
+
+
+# If running in Autotest dir, keep this.
+os.environ["PY_VERSION"] = "3"
+
+# NOTE: this MUST be run in Python3, if we get configured back to PY2, exit.
+if six.PY2:
+ sys.exit(1)
+
+
+def autotest_checksum_dirs():
+ """Return a mapping between source and destination paths of directories.
+
+ This function return a mapping between source and destination paths
+ of autotest directories.
+ """
+
+ return { "/usr/local/autotest": "/usr/local/autotest" }
+
+
+def default_checksum_files():
+ """Return a mapping between source and destination paths of files.
+
+ For test image only sources that are not in /user/local will be
+ moved to /user/local in a DUT.
+ """
+
+ return {
+ "/usr/local/autotest/bin/autotest":
+ "/usr/local/autotest/bin/autotest",
+
+ "/usr/libexec/tast/bundles/local/cros":
+ "/usr/local/libexec/tast/bundles/local/cros",
+
+ "/usr/bin/local_test_runner":
+ "/usr/local/bin/local_test_runner",
+ }
+
+
+def parse_local_arguments(args):
+ """Parse the CLI."""
+ parser = argparse.ArgumentParser(
+ description="Create config file for checking if a DUT is test ready."
+ )
+ parser.add_argument(
+ "-src_root",
+ dest="src_root",
+ default=None,
+ help="Path to root directory of the source files or directories.",
+ )
+ parser.add_argument(
+ "-output_file",
+ dest="output_file",
+ default=None,
+ help="Where to write the cros_test_ready.jsonpb.",
+ )
+ return parser.parse_args(args)
+
+
+def md5(filename):
+ with open(filename, "rb") as f:
+ content = f.read()
+ readable_hash = hashlib.md5(content).hexdigest()
+ return readable_hash
+
+
+def serialize_checksum_from_file(src, dest):
+ """Calculate checksum of a file."""
+ try:
+ md5_value = md5(src)
+ return test_ready_pb.CrosTestReadyConfig.KeyValue(
+ key=dest, value=md5_value
+ )
+ except Exception as e:
+ raise Exception(
+ "Failed to calculate checksum for file %s: %s" % (src, e)
+ )
+
+
+def serialize_autotest_checksum_from_dir(src_dir, dest_dir):
+ """Calculate checksums of non-hidden _python_ files under a directory."""
+ results = []
+ try:
+ for root, _, filenames in os.walk(src_dir):
+ for filename in filenames:
+ # Ignore hidden files.
+ if filename.startswith("."):
+ continue
+ # Only check for python files for autotest.
+ if not filename.endswith(".py"):
+ continue
+ src_file_path = os.path.join(root, filename)
+ dest_file_path = src_file_path.replace(src_dir, dest_dir)
+ results.append(
+ serialize_checksum_from_file(
+ src_file_path, dest_file_path
+ )
+ )
+ except Exception as e:
+ raise Exception(
+ "Failed to calculate checksum for files under %s: %s"
+ % (src_dir, e)
+ )
+ return results
+
+
+def serialize_checksum(src_root):
+ """Return a serialized TestCaseInfo obj."""
+ checksums = []
+ filenames = default_checksum_files()
+ for src, dest in filenames.items():
+ checksums.append(serialize_checksum_from_file(
+ src_root + src, dest))
+ # Calculate checksum for python files under autotest directories.
+ dirs = autotest_checksum_dirs()
+ for src_dir, dest_dir in dirs.items():
+ checksums_for_dir = serialize_autotest_checksum_from_dir(
+ src_root + src_dir, dest_dir)
+ checksums = checksums + checksums_for_dir
+ return test_ready_pb.CrosTestReadyConfig(checksums=checksums)
+
+
+def main():
+ """Generate the metadata, and if an output path is given, save it."""
+ args = parse_local_arguments(sys.argv[1:])
+ serialized = serialize_checksum(args.src_root)
+ json_obj = MessageToJson(serialized)
+
+ if args.output_file:
+ with open(args.output_file, "w", encoding="utf-8") as wf:
+ wf.write(json_obj)
+
+
+if __name__ == "__main__":
+ main()