Roboleaf product configuration runner

The application rbcrun executes Starlark scripts that define Android product configurations.
See README.md for details.

Test: go test
Fixes: 180529448
Change-Id: I7d728b47d3f381b7052a0d7d51c9e698e5c2e316
diff --git a/tools/rbcrun/host.go b/tools/rbcrun/host.go
new file mode 100644
index 0000000..f1697f1
--- /dev/null
+++ b/tools/rbcrun/host.go
@@ -0,0 +1,263 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rbcrun
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"go.starlark.net/starlark"
+	"go.starlark.net/starlarkstruct"
+)
+
+const callerDirKey = "callerDir"
+
+var LoadPathRoot = "."
+var shellPath string
+
+type modentry struct {
+	globals starlark.StringDict
+	err     error
+}
+
+var moduleCache = make(map[string]*modentry)
+
+var builtins starlark.StringDict
+
+func moduleName2AbsPath(moduleName string, callerDir string) (string, error) {
+	path := moduleName
+	if ix := strings.LastIndex(path, ":"); ix >= 0 {
+		path = path[0:ix] + string(os.PathSeparator) + path[ix+1:]
+	}
+	if strings.HasPrefix(path, "//") {
+		return filepath.Abs(filepath.Join(LoadPathRoot, path[2:]))
+	} else if strings.HasPrefix(moduleName, ":") {
+		return filepath.Abs(filepath.Join(callerDir, path[1:]))
+	} else {
+		return filepath.Abs(path)
+	}
+}
+
+// loader implements load statement. The format of the loaded module URI is
+//  [//path]:base[|symbol]
+// The file path is $ROOT/path/base if path is present, <caller_dir>/base otherwise.
+// The presence of `|symbol` indicates that the loader should return a single 'symbol'
+// bound to None if file is missing.
+func loader(thread *starlark.Thread, module string) (starlark.StringDict, error) {
+	pipePos := strings.LastIndex(module, "|")
+	mustLoad := pipePos < 0
+	var defaultSymbol string
+	if !mustLoad {
+		defaultSymbol = module[pipePos+1:]
+		module = module[:pipePos]
+	}
+	modulePath, err := moduleName2AbsPath(module, thread.Local(callerDirKey).(string))
+	if err != nil {
+		return nil, err
+	}
+	e, ok := moduleCache[modulePath]
+	if e == nil {
+		if ok {
+			return nil, fmt.Errorf("cycle in load graph")
+		}
+
+		// Add a placeholder to indicate "load in progress".
+		moduleCache[modulePath] = nil
+
+		// Decide if we should load.
+		if !mustLoad {
+			if _, err := os.Stat(modulePath); err == nil {
+				mustLoad = true
+			}
+		}
+
+		// Load or return default
+		if mustLoad {
+			childThread := &starlark.Thread{Name: "exec " + module, Load: thread.Load}
+			// Cheating for the sake of testing:
+			// propagate starlarktest's Reporter key, otherwise testing
+			// the load function may cause panic in starlarktest code.
+			const testReporterKey = "Reporter"
+			if v := thread.Local(testReporterKey); v != nil {
+				childThread.SetLocal(testReporterKey, v)
+			}
+
+			childThread.SetLocal(callerDirKey, filepath.Dir(modulePath))
+			globals, err := starlark.ExecFile(childThread, modulePath, nil, builtins)
+			e = &modentry{globals, err}
+		} else {
+			e = &modentry{starlark.StringDict{defaultSymbol: starlark.None}, nil}
+		}
+
+		// Update the cache.
+		moduleCache[modulePath] = e
+	}
+	return e.globals, e.err
+}
+
+// fileExists returns True if file with given name exists.
+func fileExists(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple,
+	kwargs []starlark.Tuple) (starlark.Value, error) {
+	var path string
+	if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &path); err != nil {
+		return starlark.None, err
+	}
+	if stat, err := os.Stat(path); err != nil || stat.IsDir() {
+		return starlark.False, nil
+	}
+	return starlark.True, nil
+}
+
+// regexMatch(pattern, s) returns True if s matches pattern (a regex)
+func regexMatch(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple,
+	kwargs []starlark.Tuple) (starlark.Value, error) {
+	var pattern, s string
+	if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &pattern, &s); err != nil {
+		return starlark.None, err
+	}
+	match, err := regexp.MatchString(pattern, s)
+	if err != nil {
+		return starlark.None, err
+	}
+	if match {
+		return starlark.True, nil
+	}
+	return starlark.False, nil
+}
+
+// wildcard(pattern, top=None) expands shell's glob pattern. If 'top' is present,
+// the 'top/pattern' is globbed and then 'top/' prefix is removed.
+func wildcard(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple,
+	kwargs []starlark.Tuple) (starlark.Value, error) {
+	var pattern string
+	var top string
+
+	if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &pattern, &top); err != nil {
+		return starlark.None, err
+	}
+
+	var files []string
+	var err error
+	if top == "" {
+		if files, err = filepath.Glob(pattern); err != nil {
+			return starlark.None, err
+		}
+	} else {
+		prefix := top + string(filepath.Separator)
+		if files, err = filepath.Glob(prefix + pattern); err != nil {
+			return starlark.None, err
+		}
+		for i := range files {
+			files[i] = strings.TrimPrefix(files[i], prefix)
+		}
+	}
+	return makeStringList(files), nil
+}
+
+// shell(command) runs OS shell with given command and returns back
+// its output the same way as Make's $(shell ) function. The end-of-lines
+// ("\n" or "\r\n") are replaced with " " in the result, and the trailing
+// end-of-line is removed.
+func shell(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple,
+	kwargs []starlark.Tuple) (starlark.Value, error) {
+	var command string
+	if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &command); err != nil {
+		return starlark.None, err
+	}
+	if shellPath == "" {
+		return starlark.None,
+			fmt.Errorf("cannot run shell, SHELL environment variable is not set (running on Windows?)")
+	}
+	cmd := exec.Command(shellPath, "-c", command)
+	// We ignore command's status
+	bytes, _ := cmd.Output()
+	output := string(bytes)
+	if strings.HasSuffix(output, "\n") {
+		output = strings.TrimSuffix(output, "\n")
+	} else {
+		output = strings.TrimSuffix(output, "\r\n")
+	}
+
+	return starlark.String(
+		strings.ReplaceAll(
+			strings.ReplaceAll(output, "\r\n", " "),
+			"\n", " ")), nil
+}
+
+func makeStringList(items []string) *starlark.List {
+	elems := make([]starlark.Value, len(items))
+	for i, item := range items {
+		elems[i] = starlark.String(item)
+	}
+	return starlark.NewList(elems)
+}
+
+// propsetFromEnv constructs a propset from the array of KEY=value strings
+func structFromEnv(env []string) *starlarkstruct.Struct {
+	sd := make(map[string]starlark.Value, len(env))
+	for _, x := range env {
+		kv := strings.SplitN(x, "=", 2)
+		sd[kv[0]] = starlark.String(kv[1])
+	}
+	return starlarkstruct.FromStringDict(starlarkstruct.Default, sd)
+}
+
+func setup(env []string) {
+	// Create the symbols that aid makefile conversion. See README.md
+	builtins = starlark.StringDict{
+		"struct":   starlark.NewBuiltin("struct", starlarkstruct.Make),
+		"rblf_cli": structFromEnv(env),
+		"rblf_env": structFromEnv(os.Environ()),
+		// To convert makefile's $(wildcard foo)
+		"rblf_file_exists": starlark.NewBuiltin("rblf_file_exists", fileExists),
+		// To convert makefile's $(filter ...)/$(filter-out)
+		"rblf_regex": starlark.NewBuiltin("rblf_regex", regexMatch),
+		// To convert makefile's $(shell cmd)
+		"rblf_shell": starlark.NewBuiltin("rblf_shell", shell),
+		// To convert makefile's $(wildcard foo*)
+		"rblf_wildcard": starlark.NewBuiltin("rblf_wildcard", wildcard),
+	}
+
+	// NOTE(asmundak): OS-specific.
+	shellPath, _ = os.LookupEnv("SHELL")
+}
+
+// Parses, resolves, and executes a Starlark file.
+// filename and src parameters are as for starlark.ExecFile:
+// * filename is the name of the file to execute,
+//   and the name that appears in error messages;
+// * src is an optional source of bytes to use instead of filename
+//   (it can be a string, or a byte array, or an io.Reader instance)
+// * commandVars is an array of "VAR=value" items. They are accessible from
+//   the starlark script as members of the `rblf_cli` propset.
+func Run(filename string, src interface{}, commandVars []string) error {
+	setup(commandVars)
+
+	mainThread := &starlark.Thread{
+		Name:  "main",
+		Print: func(_ *starlark.Thread, msg string) { fmt.Println(msg) },
+		Load:  loader,
+	}
+	absPath, err := filepath.Abs(filename)
+	if err == nil {
+		mainThread.SetLocal(callerDirKey, filepath.Dir(absPath))
+		_, err = starlark.ExecFile(mainThread, absPath, src, builtins)
+	}
+	return err
+}