go/workspace: implement EvalHscloudNix
This allows us to access hscloud nix 'facts' from Go.
Change-Id: Ic8fc3350a7d073947c44529fcae0bbb8627421aa
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1508
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/go/workspace/BUILD.bazel b/go/workspace/BUILD.bazel
index 34f8acc..222d3c7 100644
--- a/go/workspace/BUILD.bazel
+++ b/go/workspace/BUILD.bazel
@@ -1,8 +1,24 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
- srcs = ["workspace.go"],
+ srcs = [
+ "nix.go",
+ "workspace.go",
+ ],
importpath = "code.hackerspace.pl/hscloud/go/workspace",
visibility = ["//visibility:public"],
)
+
+go_test(
+ name = "go_default_test",
+ srcs = ["nix_test.go"],
+ data = [
+ ":exports.nix",
+ "//:WORKSPACE",
+ "//:default.nix",
+ "//nix/readtree:default.nix",
+ ],
+ embed = [":go_default_library"],
+ deps = ["@com_github_google_go_cmp//cmp:go_default_library"],
+)
diff --git a/go/workspace/exports.nix b/go/workspace/exports.nix
new file mode 100644
index 0000000..6e01b4f
--- /dev/null
+++ b/go/workspace/exports.nix
@@ -0,0 +1,12 @@
+# This file contains test exports for //go/workspace.EvalHscloudNix tests.
+{ hscloud, ... }:
+
+{
+ someArray = ["hello" "there"];
+ someAttrset = {
+ foo = "foo";
+ bar = {
+ baz = 42;
+ };
+ };
+}
diff --git a/go/workspace/nix.go b/go/workspace/nix.go
new file mode 100644
index 0000000..8d005a7
--- /dev/null
+++ b/go/workspace/nix.go
@@ -0,0 +1,159 @@
+package workspace
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "unicode"
+)
+
+// EvalHscloudNix takes a hscloud attribute path (eg.
+// ops.machines.exports.kubeMachineNames) and evaluates its value. The
+// resulting value is then deserialized into the given target, which uses
+// encoding/json under the hood.
+//
+// The given path will be checked for basic mistakes, but is otherwise not
+// sanitized. Do not call this function with untrusted input. Even better, only
+// call it with constant strings.
+func EvalHscloudNix(ctx context.Context, target any, path string) error {
+ if err := checkNixPath(path); err != nil {
+ return fmt.Errorf("invalid path: %w", err)
+ }
+ ws, err := Get()
+ if err != nil {
+ return fmt.Errorf("could not find workspace: %w", err)
+ }
+
+ expression := `
+ let
+ hscloud = (import %q {});
+ in
+ hscloud.%s
+
+ `
+ expression = fmt.Sprintf(expression, ws, path)
+
+ args := []string{
+ // Do not attempt to actually instantiate derivation, just do an eval.
+ "--eval",
+ // Fully evaluate expression.
+ "--strict",
+ // Serialize evaluated expression into JSON.
+ "--json",
+ // Use following expression instead of file.
+ "-E",
+ expression,
+ }
+ cmd := exec.CommandContext(ctx, "nix-instantiate", args...)
+ out, err := cmd.Output()
+ if err != nil {
+ eerr := err.(*exec.ExitError)
+ return fmt.Errorf("nix-instantiate failed: %w, stderr: %q", err, eerr.Stderr)
+ }
+
+ if err := json.Unmarshal(out, target); err != nil {
+ return fmt.Errorf("unmarshaling json into target failed: %w", err)
+ }
+ return nil
+}
+
+// checkNixPath validates that the given path looks enough like a Nix attribute
+// path. It's not a security measure, it's an anti-footgun measure.
+func checkNixPath(s string) error {
+ if len(s) < 1 {
+ return fmt.Errorf("empty path")
+ }
+
+ // Split path into parts, supporting quotes.
+ var parts []string
+
+ // State machine: either:
+ // !quote && !mustEnd or
+ // quote && !mustEnd or
+ // !quote && mustEnd.
+ quoted := false
+ mustEnd := false
+ // Current part.
+ part := ""
+ for _, c := range s {
+ if c > unicode.MaxASCII {
+ return fmt.Errorf("only ASCII characters supported")
+ }
+
+ // If we must end a part, make sure it actually ends here.
+ if mustEnd {
+ if c != '.' {
+ return fmt.Errorf("quotes can only end at path boundaries")
+ }
+ mustEnd = false
+ parts = append(parts, part)
+ part = ""
+ continue
+ }
+
+ // Perform quoting logic. Only one a full part may be quoted.
+ if c == '"' {
+ if !quoted {
+ if len(part) > 0 {
+ return fmt.Errorf("quotes can only start at path boundaries")
+ }
+ quoted = true
+ continue
+ } else {
+ quoted = false
+ mustEnd = true
+ }
+ continue
+ }
+
+ // Perform dot/period logic - different if we're in a quoted fragment
+ // or not.
+ if !quoted {
+ // Not in quoted part: finish part.
+ if c == '.' {
+ parts = append(parts, part)
+ part = ""
+ continue
+ }
+ } else {
+ // In quoted part: consume dot into part.
+ if c == '.' {
+ part += string(c)
+ continue
+ }
+ }
+
+ // Consume characters, making sure we only handle what's supported in a
+ // nix attrset accessor.
+ switch {
+ // Letters and underscores can be anywhere in the partq.
+ case c >= 'a' && c <= 'z':
+ case c >= 'A' && c <= 'Z':
+ case c == '_':
+
+ // Digits/hyphens cannot start a part.
+ case c >= '0' && c <= '9', c == '-':
+ if len(part) == 0 {
+ return fmt.Errorf("part cannot start with a %q", string(c))
+ }
+ default:
+ return fmt.Errorf("unexpected char: %q", string(c))
+ }
+ part += string(c)
+
+ }
+ if quoted {
+ return fmt.Errorf("unterminated quote")
+ }
+ parts = append(parts, part)
+
+ // Make sure no parts are empty, ie. there are no double dots.
+ for _, part := range parts {
+ if len(part) == 0 {
+ return fmt.Errorf("empty attrpath part")
+ }
+ }
+
+ return nil
+}
diff --git a/go/workspace/nix_test.go b/go/workspace/nix_test.go
new file mode 100644
index 0000000..acb6d99
--- /dev/null
+++ b/go/workspace/nix_test.go
@@ -0,0 +1,90 @@
+package workspace
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestCheckNixPath(t *testing.T) {
+ for _, te := range []struct {
+ in string
+ okay bool
+ }{
+ {"foo", true},
+ {"foo.bar.baz", true},
+ {"foo.bar.baz.", false},
+ {"foo.bar.baz..", false},
+ {".foo.bar.baz", false},
+ {"..foo.bar.baz", false},
+ {"foo..bar.baz", false},
+ {".", false},
+ {"", false},
+
+ {"ops.machines.\"test.example.com\".config", true},
+ {"ops.machines.\"test.example.com.config", false},
+ {"ops.machines.\"test.example.com\"bar.config", false},
+ {"ops.machines.bar\"test.example.com\".config", false},
+ {"\"test.example.com\"bar", false},
+ {"test.example.com\"", false},
+
+ {"foo--.__bar.b-a-z---", true},
+ {"foo.0bar", false},
+ {"foo.-bar", false},
+
+ {"test\\.test", false},
+ {"test test", false},
+ } {
+ err := checkNixPath(te.in)
+ if te.okay && err != nil {
+ t.Errorf("%q: expected okay, got %v", te.in, err)
+ }
+ if !te.okay && err == nil {
+ t.Errorf("%q: expected error", te.in)
+ }
+ }
+}
+
+// TestEvalHscloud nix exercises EvalHscloudNix against
+// //go/workspace/exports.nix.
+func TestEvalHscloudNix(t *testing.T) {
+ ctx, ctxC := context.WithCancel(context.Background())
+ defer ctxC()
+
+ {
+ var got []string
+ if err := EvalHscloudNix(ctx, &got, "go.workspace.exports.someArray"); err != nil {
+ t.Errorf("Accessing someArray failed: %v", err)
+ } else {
+ want := []string{"hello", "there"}
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("someArray diff:\n%s", diff)
+ }
+ }
+ }
+
+ {
+ type barS struct {
+ Baz int64 `json:"baz"`
+ }
+ type gotS struct {
+ Foo string `json:"foo"`
+ Bar barS `json:"bar"`
+ }
+ var got gotS
+ if err := EvalHscloudNix(ctx, &got, "go.workspace.exports.someAttrset"); err != nil {
+ t.Errorf("Accessing someAttrset failed: %v", err)
+ } else {
+ want := gotS{
+ Foo: "foo",
+ Bar: barS{
+ Baz: 42,
+ },
+ }
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("someAttrset diff:\n%s", diff)
+ }
+ }
+ }
+}