*: do not require env.sh
This removes the need to source env.{sh,fish} when working with hscloud.
This is done by:
1. Implementing a Go library to reliably detect the location of the
active hscloud checkout. That in turn is enabled by
BUILD_WORKSPACE_DIRECTORY being now a thing in Bazel.
2. Creating a tool `hscloud`, with a command `hscloud workspace` that
returns the workspace path.
3. Wrapping this tool to be accessible from Python and Bash.
4. Bumping all users of hscloud_root to use either the Go library or
one of the two implemented wrappers.
We also drive-by replace tools/install.sh to be a proper sh_binary, and
make it yell at people if it isn't being ran as `bazel run
//tools:install`.
Finally, we also drive-by delete cluster/tools/nixops.sh which was never used.
Change-Id: I7873714319bfc38bbb930b05baa605c5aa36470a
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1169
Reviewed-by: informatic <informatic@hackerspace.pl>
diff --git a/go/workspace/workspace.go b/go/workspace/workspace.go
new file mode 100644
index 0000000..f8f2ef9
--- /dev/null
+++ b/go/workspace/workspace.go
@@ -0,0 +1,101 @@
+package workspace
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "sync"
+ "syscall"
+)
+
+// isWorkspace returns whether a given string is a valid path pointing to a
+// Bazel workspace directory.
+func isWorkspace(dir string) bool {
+ w := path.Join(dir, "WORKSPACE")
+ if _, err := os.Stat(w); err == nil {
+ return true
+ }
+ return false
+}
+
+// getPathFSID returns an opaque filesystem identifier for a given path.
+func getPathFSID(dir string) (uint64, error) {
+ st, err := os.Stat(dir)
+ if err != nil {
+ // No need to wrap err, as stat errors are already quite explicit
+ // (they also include the stat'd path).
+ return 0, err
+ }
+ switch x := st.Sys().(type) {
+ case *syscall.Stat_t:
+ return x.Dev, nil
+ default:
+ return 0, fmt.Errorf("unsupported operating system (got stat type %+v)", st.Sys())
+ }
+}
+
+// lookForWorkspace recurses up a directory until it finds a WORKSPACE, the
+// root, or crosses a filesystem boundary.
+func lookForWorkspace(dir string) (string, error) {
+ fsid, err := getPathFSID(dir)
+ if err != nil {
+ return "", fmt.Errorf("could not get initial FSID: %w", err)
+ }
+ for {
+ if dir == "." || dir == "/" || dir == "" {
+ return "", fmt.Errorf("got up to root before finding workspace")
+ }
+
+ fsid2, err := getPathFSID(dir)
+ if err != nil {
+ return "", fmt.Errorf("could not get parent FWID: %w", err)
+ }
+ if fsid2 != fsid {
+ return "", fmt.Errorf("crossed filesystem boundaries before finding workspace")
+ }
+
+ if isWorkspace(dir) {
+ return dir, nil
+ }
+ dir = path.Dir(dir)
+ }
+}
+
+// Get returns the workspace directory from which a given
+// command line tool is running. This handles the following
+// cases:
+//
+// 1. The command line tool was invoked via `bazel run`.
+// 2. The command line tool was started in the workspace directory or a
+// subdirectory.
+//
+// If the workspace directory path cannot be inferred based on the above
+// assumptions, an error is returned.
+func Get() (string, error) {
+ workspaceOnce.Do(func() {
+ workspace, workspaceErr = get()
+ })
+ return workspace, workspaceErr
+}
+
+var (
+ workspace string
+ workspaceErr error
+ workspaceOnce sync.Once
+)
+
+func get() (string, error) {
+ if p := os.Getenv("BUILD_WORKSPACE_DIRECTORY"); p != "" && isWorkspace(p) {
+ return p, nil
+ }
+
+ wd, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ p, err := lookForWorkspace(wd)
+ if err != nil {
+ return "", fmt.Errorf("not invoked from `bazel run` and could not find workspace root by traversing upwards: %w", err)
+ }
+ return p, nil
+}