platform-graphics: Unified config for trace-profiling tools

Specifying profiling configurations through various SSH, tunnel and
profile config files has become confusing and time consuming. Unified
configuration puts all the config parameters in a single file, given to
tool Profile with cmd-line arg "-config". Unified config files can still
be combined with the "include" property. (Support in tool Harvest and
documentation in README with follow in separate CLs.)

BUG=None
TEST=(1) Build with make and run various Profile configurations. (2) emerge-nami graphics-utils-go.

Change-Id: Ib2a59cdd6860b3a51f9e7326617172cad11ffa7e
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/graphics/+/2180527
Reviewed-by: John Bates <jbates@google.com>
Reviewed-by: Georges Winkenbach <gwink@chromium.org>
Tested-by: Georges Winkenbach <gwink@chromium.org>
Commit-Queue: Georges Winkenbach <gwink@chromium.org>
diff --git a/src/trace_profiling/cmd/profile/main.go b/src/trace_profiling/cmd/profile/main.go
index 76321c6..96cb933 100644
--- a/src/trace_profiling/cmd/profile/main.go
+++ b/src/trace_profiling/cmd/profile/main.go
@@ -88,6 +88,7 @@
 }
 
 func main() {
+	var argUnifiedConfigFilePath string
 	var argTunnelConfigFilepath string
 	var argSSHConfigFilepath string
 	var argBundleConfigFilePath string
@@ -96,6 +97,8 @@
 	var argAlwaysCopyTraces bool
 	var argEnableVerbose bool
 
+	flag.StringVar(&argUnifiedConfigFilePath, "config", "",
+		"A single, unified configuration file")
 	flag.StringVar(&argTunnelConfigFilepath, "tunnel-config", "/no-tunnel/",
 		"Optional tunnel (port-forwarding) configuration file")
 	flag.StringVar(&argSSHConfigFilepath, "ssh-config", "ssh_config.json",
@@ -112,12 +115,17 @@
 		"Enable verbose mode, to see more info during profiling")
 	flag.Parse()
 
-	// If a bundle is specified, it takes precedence over individual config files.
+	// Unidied configuration takes precendence, then a config bundle if available.
+	// Otherwise, we look for individual SSH, tunnel and profile config files.
 	var sshParams *remote.SSHParams
 	var tunnelParams *remote.TunnelParams
 	var profParams *profile.ProfileParams
+	var profilerConfig *profile.ProfilerConfig
 	var err error
-	if argBundleConfigFilePath != "" {
+	if argUnifiedConfigFilePath != "" {
+		profilerConfig = profile.CreateProfilerConfig()
+		err = profilerConfig.ParseJSONFile(argUnifiedConfigFilePath)
+	} else if argBundleConfigFilePath != "" {
 		sshParams, tunnelParams, profParams, err = getParamsFromBundle(argBundleConfigFilePath)
 	} else {
 		sshParams, tunnelParams, profParams, err = getParamsFromFiles(
@@ -129,13 +137,28 @@
 		return
 	}
 
+	if profilerConfig != nil {
+		sshParams = profilerConfig.GetSSHParams()
+		tunnelParams = profilerConfig.GetTunnelParams()
+		profParams = profilerConfig.GetProfilerParams()
+
+		if sshParams == nil {
+			fmt.Fprintf(os.Stderr, "No SSH configuration in %s\n", argUnifiedConfigFilePath)
+			return
+		}
+		if profParams == nil {
+			fmt.Fprintf(os.Stderr, "No Profiler configuration in %s\n", argUnifiedConfigFilePath)
+			return
+		}
+	}
+
 	// Tunneling is optional. If no tunnel parameters are provided, the SHH
 	// connection to the target device will be direct.
 	var tunnel *remote.Tunnel
 	if tunnelParams != nil {
 		tunnel, err = remote.CreateTunnel(tunnelParams)
 		if err != nil {
-			fmt.Fprintf(os.Stderr, "Create tunnel %s\n", err.Error())
+			fmt.Fprintf(os.Stderr, "Error creating tunnel %s\n", err.Error())
 			return
 		}
 	}
diff --git a/src/trace_profiling/cmd/profile/profile/json_config.go b/src/trace_profiling/cmd/profile/profile/json_config.go
new file mode 100644
index 0000000..e571200
--- /dev/null
+++ b/src/trace_profiling/cmd/profile/profile/json_config.go
@@ -0,0 +1,182 @@
+// Copyright 2020 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.
+
+package profile
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+)
+
+// ConfigPropertyHandler is an interfaces for handlers associated with top-level
+// properties in the config file. Handlers are associated with top-level
+// properties by calling function AddHandler on JSONConfig.
+type ConfigPropertyHandler interface {
+	parseJSONData(jsonData string) error
+}
+
+// JSONConfig is a helper class for parsing JSON configuration files. By iteself,
+// JSONConfig scan top-level properties JSON files and recursively processes
+// config files listed in top-level property with name "include". It becomes more
+// useful when handlers are associated with top-level properties other than
+// "include".
+type JSONConfig struct {
+	handlers     map[string]ConfigPropertyHandler
+	jsonData     map[string]interface{}
+	includeDepth int
+}
+
+// CreateJSONConfig creates and returns a JSONConfig instance.
+func CreateJSONConfig() *JSONConfig {
+	var jc = JSONConfig{}
+	jc.handlers = make(map[string]ConfigPropertyHandler)
+	return &jc
+}
+
+// AddHandler adds a handler for property with name <fieldName> to the JSONConfig
+// object. The handler will be invoked each time a top-level property with that
+// name is found in the config file or in the included files.
+func (jc *JSONConfig) AddHandler(fieldName string, handler ConfigPropertyHandler) error {
+	if fieldName == "include" {
+		return fmt.Errorf("invalid field name for custom handler: %s", fieldName)
+	}
+
+	jc.handlers[fieldName] = handler
+	return nil
+}
+
+// OpenJSONConfigFile open json file with path <jsonFile> and ensures it is ready
+// for processing. If the file doesn't look like a file that can be processed
+// with this JSONConfig instance, an error is returned.
+func (jc *JSONConfig) OpenJSONConfigFile(jsonFile string) error {
+	file, err := os.Open(jsonFile)
+	if err != nil {
+		return fmt.Errorf("cannot open config file <%s>; error=%w", jsonFile, err)
+	}
+	defer file.Close()
+
+	var decode = json.NewDecoder(file)
+	if err := decode.Decode(&jc.jsonData); err != nil {
+		return err
+	}
+
+	if !jc.canProcessData(jc.jsonData) {
+		return fmt.Errorf("unable to process JSON data in %s", jsonFile)
+	}
+
+	return nil
+}
+
+// Process processes a json config file that was successfully opened with
+// OpenJSONConfigFile.
+func (jc *JSONConfig) Process() error {
+	return jc.processJSONData(jc.jsonData)
+}
+
+// Returns whether a json data map (a map of property names to values) is one
+// that can be processed by JSNConfig. That is so when the json data has either
+// an "include" property or at least one property that can be processed by one
+// of the available handlers.
+func (jc *JSONConfig) canProcessData(jsonData map[string]interface{}) bool {
+	if jsonData != nil {
+		if _, ok := jsonData["include"]; ok {
+			return true
+		}
+
+		for propertyName := range jsonData {
+			if _, ok := jc.handlers[propertyName]; ok {
+				return ok
+			}
+		}
+	}
+
+	return false
+}
+
+// Process config json data that is represented as a map of property names to values.
+func (jc *JSONConfig) processJSONData(data map[string]interface{}) error {
+	// Process the include files first.
+	if includes, ok := data["include"]; ok {
+		if err := jc.includeFiles(includes); err != nil {
+			return err
+		}
+	}
+
+	// Process property values that have an associated handler.
+	for propertyName := range data {
+		if handler, ok := jc.handlers[propertyName]; ok {
+			if err := jc.invokeHandler(handler, data[propertyName]); err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+// Process included files up to the maximum include depth.
+func (jc *JSONConfig) includeFiles(files interface{}) error {
+	// The include property value may be either a single file (a string) or
+	// a list of files (array of strings). Map either into a file list.
+	fileList := make([]string, 0, 16)
+	if f, ok := files.(string); ok {
+		fileList = append(fileList, f)
+	} else if list, ok := files.([]interface{}); ok {
+		for _, incFile := range list {
+			if f, ok := incFile.(string); ok {
+				fileList = append(fileList, f)
+			}
+		}
+	} else {
+		return fmt.Errorf("invalid value type for include: %v", files)
+	}
+
+	// Process the included files in the order found. We limit how deep we can
+	// recursively include files as a way of avoiding infinite include loops.
+	for _, oneFile := range fileList {
+		file, err := os.Open(oneFile)
+		if err != nil {
+			return fmt.Errorf("cannot include file <%s>; error=%w", oneFile, err)
+		}
+		defer file.Close()
+
+		jc.includeDepth++
+		if jc.includeDepth == 10 {
+			return fmt.Errorf("too many includes: %d", jc.includeDepth)
+		}
+
+		if err := jc.parseFile(file); err != nil {
+			return err
+		}
+		jc.includeDepth--
+	}
+
+	return nil
+}
+
+// Process a json file.
+func (jc *JSONConfig) parseFile(file *os.File) error {
+	var decode = json.NewDecoder(file)
+	var data map[string]interface{}
+	if err := decode.Decode(&data); err != nil {
+		return err
+	}
+
+	return jc.processJSONData(data)
+}
+
+// Look for a handler associated with a top-level property and invoke it if found.
+func (jc *JSONConfig) invokeHandler(handler ConfigPropertyHandler, data interface{}) error {
+	// The handler expect the json data as a string. So we must map the interface{} value
+	// back to a string. We can do that with the json encoder.
+	var buffer = new(bytes.Buffer)
+	var encode = json.NewEncoder(buffer)
+	if err := encode.Encode(data); err != nil {
+		return err
+	}
+
+	return handler.parseJSONData(buffer.String())
+}
diff --git a/src/trace_profiling/cmd/profile/profile/profiler_config.go b/src/trace_profiling/cmd/profile/profile/profiler_config.go
new file mode 100644
index 0000000..4e6dd7d
--- /dev/null
+++ b/src/trace_profiling/cmd/profile/profile/profiler_config.go
@@ -0,0 +1,71 @@
+// Copyright 2020 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.
+
+package profile
+
+import (
+	"encoding/json"
+	"trace_profiling/cmd/profile/remote"
+)
+
+// All the config data relevant to Profiler in one place, for parsing from json.
+type allProfilerConfigs struct {
+	TunnelConfig   *remote.TunnelParams `json:"tunnelConfig"`
+	SSHConfig      *remote.SSHParams    `json:"sshConfig"`
+	ProfilerConfig *ProfileParams       `json:"profilerConfig"`
+}
+
+// ProfilerConfig is a helper for parsing Profiler configuration data from json
+// config files.
+type ProfilerConfig struct {
+	jsonConfig *JSONConfig
+	configData allProfilerConfigs
+}
+
+// CreateProfilerConfig creates and returns a ProfilerConfig instance.
+func CreateProfilerConfig() *ProfilerConfig {
+	pc := ProfilerConfig{
+		jsonConfig: CreateJSONConfig(),
+	}
+
+	pc.jsonConfig.AddHandler("Profile", &pc)
+	return &pc
+}
+
+// ParseJSONFile parse json file with path <jsonFile>. When this function is
+// successful, the parsed data may be retrieved with functions GetSSHParams,
+// GetTunnelParams and GetProfilerParams.
+func (pc *ProfilerConfig) ParseJSONFile(jsonFile string) error {
+	if err := pc.jsonConfig.OpenJSONConfigFile(jsonFile); err != nil {
+		return err
+	}
+
+	return pc.jsonConfig.Process()
+}
+
+// GetProfilerParams returns the ProfilerParams parsed from the json file.
+// May return nil.
+func (pc *ProfilerConfig) GetProfilerParams() *ProfileParams {
+	return pc.configData.ProfilerConfig
+}
+
+// GetSSHParams returns the SSHParams parsed from the json file. May return nil.
+func (pc *ProfilerConfig) GetSSHParams() *remote.SSHParams {
+	return pc.configData.SSHConfig
+}
+
+// GetTunnelParams returns the TunnelParams parsed from the json file.
+// May return nil.
+func (pc *ProfilerConfig) GetTunnelParams() *remote.TunnelParams {
+	return pc.configData.TunnelConfig
+}
+
+// parseJSONData is the handler function for interface ConfigPropertyHandler.
+func (pc *ProfilerConfig) parseJSONData(jsonData string) error {
+	if err := json.Unmarshal([]byte(jsonData), &pc.configData); err != nil {
+		return err
+	}
+
+	return nil
+}