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/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
+}