mirror of
https://gerrit.hackerspace.pl/hscloud
synced 2024-10-08 17:27:45 +00:00
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>
This commit is contained in:
parent
8e22f6c7db
commit
a03b60b310
6 changed files with 284 additions and 2 deletions
3
BUILD
3
BUILD
|
@ -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(
|
||||
|
|
|
@ -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
12
go/workspace/exports.nix
Normal 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
159
go/workspace/nix.go
Normal 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
90
go/workspace/nix_test.go
Normal 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
2
nix/readtree/BUILD
Normal file
|
@ -0,0 +1,2 @@
|
|||
# Used by //go/workspace tests.
|
||||
exports_files(["default.nix"])
|
Loading…
Reference in a new issue