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>
changes/08/1508/5
q3k 2023-04-01 14:47:44 +00:00 committed by q3k
parent 8e22f6c7db
commit a03b60b310
6 changed files with 284 additions and 2 deletions

3
BUILD
View File

@ -1,6 +1,9 @@
# Gazelle settings
load("@bazel_gazelle//:def.bzl", "gazelle")
# Used by //go/workspace tests.
exports_files(["WORKSPACE", "default.nix"])
# gazelle:prefix code.hackerspace.pl/hscloud
# gazelle:go_naming_convention go_default_library
gazelle(

View File

@ -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"],
)

12
go/workspace/exports.nix Normal file
View File

@ -0,0 +1,12 @@
# This file contains test exports for //go/workspace.EvalHscloudNix tests.
{ hscloud, ... }:
{
someArray = ["hello" "there"];
someAttrset = {
foo = "foo";
bar = {
baz = 42;
};
};
}

159
go/workspace/nix.go Normal file
View File

@ -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
}

90
go/workspace/nix_test.go Normal file
View File

@ -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)
}
}
}
}

2
nix/readtree/BUILD Normal file
View File

@ -0,0 +1,2 @@
# Used by //go/workspace tests.
exports_files(["default.nix"])