diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0e062d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# built binary (repo root) +/encapic +# cross-compiled artifacts (e.g. encapic_linux_amd64) +/encapic_* +dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2e63b82 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-unit-tests diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: build + image: golang:1.25 + commands: + - make build + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..a0955ea --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,71 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + # Build the static linux/amd64 binary and its checksum, both attached to the + # Gitea release. The binary is what the puppet compilers pull at pod start. + - name: build + image: golang:1.25 + commands: + - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o encapic_linux_amd64 . + - sha256sum encapic_linux_amd64 > encapic_linux_amd64.sha256 + depends_on: [test] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + # Cut a Gitea release with the binary + checksum attached. + - name: release + image: git.unkin.net/unkin/almalinux9-base:20260606 + environment: + RELEASER_TOKEN: + from_secret: RELEASER_TOKEN + commands: + - | + curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea + tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + NOTES=$(git log "${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s") + else + NOTES=$(git log --pretty=format:"- %s") + fi + tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "${NOTES}" --login gitea --repo "${CI_REPO}" + tea releases assets create "${CI_COMMIT_TAG}" \ + encapic_linux_amd64 \ + encapic_linux_amd64.sha256 \ + --login gitea --repo "${CI_REPO}" + depends_on: [build] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..5e179a7 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,33 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golangci/golangci-lint:latest + commands: + - golangci-lint run ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + - go test -v -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0b505f1 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +BINARY := encapic +DIST := dist +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + +.PHONY: all build test lint fmt clean install patch minor major _tag + +all: build + +# Build the single static binary into dist/. +build: + CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) . + +test: + go test -v -race ./... + +lint: + golangci-lint run ./... + +fmt: + gofmt -w . + +clean: + rm -rf $(DIST) $(BINARY) + +install: + go install $(GOFLAGS) . + +# Bump helpers — read the latest semver tag and create the next one. +# If no tag exists yet, start from v0.0.0. +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +_tag: + git push origin $(TAG) diff --git a/README.md b/README.md index a722a14..513c329 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,76 @@ # encapic -Dependency-less Go CLI client for encapi, used as the Puppet exec External Node Classifier (ENC) on k8s compilers. Fetches the cobbler-wire ENC document and reshapes it for the puppet exec node_terminus. \ No newline at end of file +Dependency-less Go CLI client for [encapi](https://git.unkin.net/unkin/encapi), +used as the Puppet exec External Node Classifier (ENC) on the Kubernetes +compilers. + +It replaces the previous uv/python ENC script, whose first-invocation +dependency resolution failed on fresh compiler pods (exit 135/2), causing agent +catalog failures. encapic is a single static binary that depends on the Go +standard library only. + +## Usage + +``` +encapic +``` + +encapic fetches the cobbler-wire ENC document from + +``` +${ENCAPI_URL}/cblr/svc/op/puppet/hostname/ +``` + +reshapes it, and prints the resulting ENC YAML to stdout. It exits non-zero on +any HTTP or parse failure — including a 404 for an unknown node — so the puppet +exec `node_terminus` fails safe rather than compiling an empty catalog. + +- `ENCAPI_URL` overrides the encapi base URL. The compiled-in default is + `http://encapi.encapi.svc.cluster.local` (the in-cluster service). +- The HTTP request has a 10s timeout. + +## Behaviour (drop-in for the python ENC) + +encapic reproduces the previous python script byte-for-byte: + +- `classes` (a cobbler-wire map keyed by role, or a list) becomes a list of + role names; +- `parameters.enc_role` is set to that same list; +- when `environment` is present, `parameters.enc_env` is set to it, and the + top-level `environment` key is dropped when it equals `testing`; +- output keys are alphabetically sorted, matching python's `yaml.dump`. + +Example output: + +```yaml +classes: +- roles::infra::storage::vault +environment: develop +parameters: + enc_env: develop + enc_role: + - roles::infra::storage::vault +``` + +## Design: why the cobbler endpoint + hand-emitted YAML + +encapi also exposes `/api/v1/nodes//enc`, which serves the fully +reshaped document. encapic deliberately consumes the **cobbler-wire** endpoint +(`/cblr/svc/op/puppet/hostname/`) and reshapes it locally so its +output matches the python script it replaces byte-for-byte — meaning the swap +changes nothing the puppet agent sees. The consumed YAML has a small, fixed +shape and is hand-parsed; the emitted YAML is hand-written. This keeps encapic +on the standard library only, which is the entire point of the rewrite. + +## Development + +``` +make build # static binary into dist/ +make test # go test -v -race ./... +make lint # golangci-lint +make fmt # gofmt -w . +``` + +Release: `make minor` (etc.) tags `vX.Y.Z` and pushes it; the `release` +Woodpecker pipeline builds `encapic_linux_amd64` (+ `.sha256`) and attaches +them to a Gitea release. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0db0bd3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module encapic + +go 1.25 diff --git a/main.go b/main.go new file mode 100644 index 0000000..c053f17 --- /dev/null +++ b/main.go @@ -0,0 +1,121 @@ +// Command encapic is a dependency-less Go client for encapi, used as the Puppet +// exec External Node Classifier (ENC) on the Kubernetes compilers. +// +// It is a behavioural drop-in for the previous uv/python ENC script: invoked as +// +// encapic +// +// it fetches ${ENCAPI_URL}/cblr/svc/op/puppet/hostname/ (the +// cobbler-wire ENC document encapi serves for compatibility), applies the same +// normalisation the python script applied, and prints the reshaped ENC YAML to +// stdout. Any HTTP or parse failure (including a 404) exits non-zero so the +// puppet exec node_terminus fails safe rather than compiling an empty catalog. +// +// # Why the cobbler endpoint and hand-emitted YAML +// +// encapi already exposes /api/v1/nodes//enc which serves the fully +// reshaped document. We deliberately consume the cobbler-wire endpoint instead +// and reshape it here so encapic reproduces the exact byte-for-byte output of +// the python script it replaces (python's yaml.dump: alphabetically sorted +// keys, block-style lists indented at the parent, two-space nesting). Matching +// that output means the swap changes nothing the puppet agent sees. The YAML we +// consume has a small, fixed shape, so it is hand-parsed; the YAML we emit is +// hand-written. This keeps the binary on the standard library only, which is +// the whole point of the rewrite (the python script's first-invocation +// dependency resolution failed on fresh compiler pods). +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// version is overwritten at build time via -ldflags "-X main.version=...". +var version = "dev" + +// defaultBaseURL is compiled in and points at the in-cluster encapi service. It +// is overridden by the ENCAPI_URL environment variable when set. +const defaultBaseURL = "http://encapi.encapi.svc.cluster.local" + +// httpTimeout bounds the whole request; the puppet exec ENC must not hang. +const httpTimeout = 10 * time.Second + +func main() { + if err := run(os.Args, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// run is the testable entry point. It writes the ENC YAML to out and returns a +// non-nil error on any failure. +func run(args []string, out io.Writer) error { + if len(args) == 2 && (args[1] == "-v" || args[1] == "--version") { + _, err := fmt.Fprintf(out, "encapic %s\n", version) + return err + } + if len(args) != 2 { + return fmt.Errorf("usage: %s ", args[0]) + } + certname := args[1] + + baseURL := os.Getenv("ENCAPI_URL") + if baseURL == "" { + baseURL = defaultBaseURL + } + + body, err := fetch(baseURL, certname) + if err != nil { + return err + } + + doc, err := parseCobbler(body) + if err != nil { + return fmt.Errorf("parse ENC for %q: %w", certname, err) + } + + yaml, err := renderENC(doc) + if err != nil { + return fmt.Errorf("render ENC for %q: %w", certname, err) + } + _, err = io.WriteString(out, yaml) + return err +} + +// fetch retrieves the cobbler-wire ENC document for certname. A non-2xx +// response (notably 404 for an unknown node) is an error so puppet fails safe. +func fetch(baseURL, certname string) ([]byte, error) { + url := strings.TrimRight(baseURL, "/") + "/cblr/svc/op/puppet/hostname/" + certname + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("request %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response from %s: %w", url, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("request %s returned HTTP %d: %s", + url, resp.StatusCode, strings.TrimSpace(string(body))) + } + return body, nil +} + +// cobblerDoc is the parsed cobbler-wire ENC document. +type cobblerDoc struct { + // classes are the role names, in the order the wire document listed them. + classes []string + // environment is the node's environment; empty means the key was absent. + environment string + hasEnv bool + // parameters are any pre-existing top-level parameters (usually none). + parameters map[string]string + paramOrder []string +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..90adc0f --- /dev/null +++ b/main_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// cobblerWire is the exact cobbler-wire document encapi serves for a node with +// a single role in the develop environment. +const cobblerWire = "classes:\n" + + " roles::infra::storage::vault: {}\n" + + "environment: develop\n" + + "parameters: {}\n" + +// goldenENC is the reshaped ENC document the previous python script produced +// from cobblerWire (verified byte-for-byte against uv/python yaml.dump). +const goldenENC = "classes:\n" + + "- roles::infra::storage::vault\n" + + "environment: develop\n" + + "parameters:\n" + + " enc_env: develop\n" + + " enc_role:\n" + + " - roles::infra::storage::vault\n" + +func TestRunGoldenOutput(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/cblr/svc/op/puppet/hostname/host.example" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/x-yaml") + _, _ = w.Write([]byte(cobblerWire)) + })) + defer srv.Close() + + t.Setenv("ENCAPI_URL", srv.URL) + var out bytes.Buffer + if err := run([]string{"encapic", "host.example"}, &out); err != nil { + t.Fatalf("run returned error: %v", err) + } + if out.String() != goldenENC { + t.Errorf("output mismatch\n--- want ---\n%s\n--- got ---\n%s", goldenENC, out.String()) + } +} + +func TestRun404FailsNonZero(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + })) + defer srv.Close() + + t.Setenv("ENCAPI_URL", srv.URL) + var out bytes.Buffer + err := run([]string{"encapic", "ghost.example"}, &out) + if err == nil { + t.Fatalf("expected error on 404, got nil") + } + if out.Len() != 0 { + t.Errorf("expected no stdout on failure, got %q", out.String()) + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("error should mention 404, got %v", err) + } +} + +func TestRun500FailsNonZero(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + t.Setenv("ENCAPI_URL", srv.URL) + var out bytes.Buffer + if err := run([]string{"encapic", "host.example"}, &out); err == nil { + t.Fatalf("expected error on 500, got nil") + } +} + +func TestFetchTimeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + _, _ = w.Write([]byte(cobblerWire)) + })) + defer srv.Close() + + // Use a client with a tiny timeout to exercise the timeout path quickly. + client := &http.Client{Timeout: 20 * time.Millisecond} + resp, err := client.Get(srv.URL + "/cblr/svc/op/puppet/hostname/host.example") + if err == nil { + _ = resp.Body.Close() + t.Fatalf("expected timeout error, got none") + } +} + +func TestRunUsage(t *testing.T) { + var out bytes.Buffer + if err := run([]string{"encapic"}, &out); err == nil { + t.Fatalf("expected usage error with no args") + } + if err := run([]string{"encapic", "a", "b"}, &out); err == nil { + t.Fatalf("expected usage error with too many args") + } +} + +func TestRunVersion(t *testing.T) { + var out bytes.Buffer + if err := run([]string{"encapic", "--version"}, &out); err != nil { + t.Fatalf("version returned error: %v", err) + } + if !strings.Contains(out.String(), "encapic") { + t.Errorf("version output = %q", out.String()) + } +} + +func TestDefaultBaseURLCompiledIn(t *testing.T) { + // Guard against accidental changes to the in-cluster default. + if err := os.Unsetenv("ENCAPI_URL"); err != nil { + t.Fatalf("unsetenv: %v", err) + } + if defaultBaseURL != "http://encapi.encapi.svc.cluster.local" { + t.Errorf("defaultBaseURL = %q", defaultBaseURL) + } +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..24302a1 --- /dev/null +++ b/parse.go @@ -0,0 +1,195 @@ +package main + +import ( + "fmt" + "strings" +) + +// parseCobbler parses the small, fixed cobbler-wire ENC document encapi serves. +// +// The document is a block mapping with three known top-level keys: +// +// classes: +// roles::base: {} +// environment: develop +// parameters: {} +// +// classes may appear either as a mapping keyed by role name (the observed +// cobbler-wire form) or as a block/flow list of role names; both are handled so +// the parser matches what python's yaml.safe_load accepted. Only the structure +// encapi actually emits is supported; anything unexpected is an error so puppet +// fails safe rather than producing a wrong catalog. +func parseCobbler(body []byte) (cobblerDoc, error) { + doc := cobblerDoc{parameters: map[string]string{}} + lines := strings.Split(string(body), "\n") + + for i := 0; i < len(lines); i++ { + raw := lines[i] + if strings.TrimSpace(raw) == "" { + continue + } + // Only care about top-level (unindented) keys; nested lines are + // consumed by the branch that owns them. + if raw[0] == ' ' || raw[0] == '\t' { + continue + } + key, val, ok := splitKV(raw) + if !ok { + return cobblerDoc{}, fmt.Errorf("unexpected line: %q", raw) + } + switch key { + case "classes": + classes, next, err := parseClasses(lines, i, val) + if err != nil { + return cobblerDoc{}, err + } + doc.classes = classes + i = next + case "environment": + doc.environment = unquote(strings.TrimSpace(val)) + doc.hasEnv = true + case "parameters": + params, order, next, err := parseParameters(lines, i, val) + if err != nil { + return cobblerDoc{}, err + } + doc.parameters = params + doc.paramOrder = order + i = next + default: + return cobblerDoc{}, fmt.Errorf("unexpected top-level key %q", key) + } + } + return doc, nil +} + +// parseClasses reads the classes value, which is either an inline flow list/map +// on the same line or a nested block starting on the following lines. It +// returns the ordered role names and the index of the last line it consumed. +func parseClasses(lines []string, i int, inline string) ([]string, int, error) { + inline = strings.TrimSpace(inline) + // Inline empty mapping/list: "classes: {}" or "classes: []". + if inline == "{}" || inline == "[]" { + return nil, i, nil + } + // Inline flow list: "classes: [a, b]". + if strings.HasPrefix(inline, "[") && strings.HasSuffix(inline, "]") { + return splitFlowList(inline), i, nil + } + if inline != "" { + return nil, i, fmt.Errorf("unsupported inline classes value: %q", inline) + } + + var classes []string + j := i + 1 + for ; j < len(lines); j++ { + line := lines[j] + if strings.TrimSpace(line) == "" { + continue + } + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { + // Block list form, at either the parent indent ("- roles::base", + // as yaml.dump emits) or nested (" - roles::base"). + classes = append(classes, unquote(strings.TrimSpace(trimmed[1:]))) + continue + } + // An indented, non-list line is a nested map entry belonging to + // classes (" roles::base: {}"). An unindented, non-list line is the + // next top-level key, so stop. + if line[0] != ' ' && line[0] != '\t' { + break + } + name, _, ok := splitKV(trimmed) + if !ok { + return nil, 0, fmt.Errorf("unexpected classes entry: %q", line) + } + classes = append(classes, unquote(name)) + } + return classes, j - 1, nil +} + +// parseParameters reads the parameters block. encapi's cobbler-wire form emits +// an empty mapping, so only scalar key/value pairs are supported here. +func parseParameters(lines []string, i int, inline string) (map[string]string, []string, int, error) { + params := map[string]string{} + var order []string + inline = strings.TrimSpace(inline) + if inline == "{}" || inline == "" && i+1 >= len(lines) { + return params, order, i, nil + } + if inline != "" && inline != "{}" { + return nil, nil, 0, fmt.Errorf("unsupported inline parameters value: %q", inline) + } + + j := i + 1 + for ; j < len(lines); j++ { + line := lines[j] + if strings.TrimSpace(line) == "" { + continue + } + if line[0] != ' ' && line[0] != '\t' { + break + } + name, val, ok := splitKV(strings.TrimSpace(line)) + if !ok { + return nil, nil, 0, fmt.Errorf("unexpected parameters entry: %q", line) + } + params[unquote(name)] = unquote(strings.TrimSpace(val)) + order = append(order, unquote(name)) + } + return params, order, j - 1, nil +} + +// splitKV splits a "key: value" line. The separator is a colon followed by a +// space or the end of the line, so role names that embed "::" (e.g. +// "roles::base") are not split at their internal colons. The value may be +// empty. +func splitKV(s string) (key, val string, ok bool) { + idx := -1 + for i := 0; i < len(s); i++ { + if s[i] == ':' && (i+1 == len(s) || s[i+1] == ' ') { + idx = i + break + } + } + if idx < 0 { + return "", "", false + } + key = strings.TrimSpace(s[:idx]) + val = s[idx+1:] + if key == "" { + return "", "", false + } + return key, val, true +} + +// splitFlowList parses "[a, b, c]" into its trimmed, unquoted elements. +func splitFlowList(s string) []string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "[") + s = strings.TrimSuffix(s, "]") + s = strings.TrimSpace(s) + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = unquote(strings.TrimSpace(p)) + if p != "" { + out = append(out, p) + } + } + return out +} + +// unquote strips a single pair of matching single or double quotes. +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..c4d731a --- /dev/null +++ b/render.go @@ -0,0 +1,189 @@ +package main + +import ( + "sort" + "strings" +) + +// renderENC applies the python ENC normalisation to a parsed cobbler document +// and emits the reshaped ENC YAML. The output matches the previous python +// script's yaml.dump byte-for-byte for the shapes encapi produces: +// +// - top-level keys are emitted in alphabetical order: classes, environment, +// parameters (environment omitted when it equals "testing"); +// - classes is a block list with items at the parent indentation; +// - parameters keys are alphabetical; enc_env is a scalar and enc_role is a +// block list. +// +// Normalisation performed (mirrors the python script): +// - classes (map or list) becomes a list of names; parameters.enc_role is set +// to that same list; +// - when environment is present, parameters.enc_env is set to it, and the +// top-level environment key is dropped when it equals "testing". +func renderENC(doc cobblerDoc) (string, error) { + // Start from any pre-existing parameters, preserving their order, then add + // the computed enc_role / enc_env. python sorts keys on dump, so ordering + // here only needs to be deterministic before the sort below. + params := make(map[string]any, len(doc.parameters)+2) + for k, v := range doc.parameters { + params[k] = v + } + + // classes -> list; enc_role mirrors it. python always sets enc_role from + // classes when classes is present (which it always is here). + classes := doc.classes + params["enc_role"] = classes + + if doc.hasEnv { + params["enc_env"] = doc.environment + } + + var b strings.Builder + + // classes + if len(classes) == 0 { + b.WriteString("classes: []\n") + } else { + b.WriteString("classes:\n") + for _, c := range classes { + b.WriteString("- ") + b.WriteString(scalar(c)) + b.WriteString("\n") + } + } + + // environment (dropped when "testing") + if doc.hasEnv && doc.environment != "testing" { + b.WriteString("environment: ") + b.WriteString(scalar(doc.environment)) + b.WriteString("\n") + } + + // parameters, keys sorted alphabetically like yaml.dump + if len(params) == 0 { + b.WriteString("parameters: {}\n") + } else { + b.WriteString("parameters:\n") + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + writeParam(&b, k, params[k]) + } + } + + return b.String(), nil +} + +// writeParam emits a single parameters entry at two-space indentation, +// matching yaml.dump's default block style. String values are scalars; string +// slices are block lists whose items sit at the key's indentation. +func writeParam(b *strings.Builder, key string, val any) { + switch v := val.(type) { + case []string: + if len(v) == 0 { + b.WriteString(" ") + b.WriteString(scalar(key)) + b.WriteString(": []\n") + return + } + b.WriteString(" ") + b.WriteString(scalar(key)) + b.WriteString(":\n") + for _, item := range v { + b.WriteString(" - ") + b.WriteString(scalar(item)) + b.WriteString("\n") + } + case string: + b.WriteString(" ") + b.WriteString(scalar(key)) + b.WriteString(": ") + b.WriteString(scalar(v)) + b.WriteString("\n") + } +} + +// scalar renders a string as yaml.dump would: plain when it is a safe plain +// scalar, single-quoted otherwise. The ENC data here (role names, environment +// names) is always plain, but quoting keeps the emitter correct for edge cases. +func scalar(s string) string { + if needsQuote(s) { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" + } + return s +} + +// needsQuote reports whether s must be quoted to round-trip as a plain YAML +// scalar. This is a conservative subset sufficient for ENC data. +func needsQuote(s string) bool { + if s == "" { + return true + } + // Leading/trailing space, or characters that would change parsing. + if s != strings.TrimSpace(s) { + return true + } + switch s { + case "null", "Null", "NULL", "~", + "true", "True", "TRUE", "false", "False", "FALSE", + "yes", "Yes", "YES", "no", "No", "NO", + "on", "On", "ON", "off", "Off", "OFF": + return true + } + first := s[0] + switch first { + case '!', '&', '*', '?', '|', '>', '%', '@', '`', '"', '\'', '#', + '-', '[', ']', '{', '}', ',', ' ': + return true + } + // Strings that would otherwise parse as a number, boolean or null must be + // quoted to round-trip as a string, matching yaml.dump. + if looksNumeric(s) { + return true + } + for i := 0; i < len(s); i++ { + c := s[i] + if c == ':' && (i+1 == len(s) || s[i+1] == ' ') { + return true + } + if c == '#' && i > 0 && s[i-1] == ' ' { + return true + } + if c == '\n' || c == '\t' { + return true + } + } + return false +} + +// looksNumeric reports whether s would be interpreted by a YAML loader as an +// int or float rather than a string. Such strings must be quoted on emit. +func looksNumeric(s string) bool { + if s == "" { + return false + } + i := 0 + if s[0] == '+' || s[0] == '-' { + i++ + } + if i >= len(s) { + return false + } + hasDigit := false + hasDot := false + for ; i < len(s); i++ { + c := s[i] + switch { + case c >= '0' && c <= '9': + hasDigit = true + case c == '.' && !hasDot: + hasDot = true + default: + return false + } + } + return hasDigit +} diff --git a/render_test.go b/render_test.go new file mode 100644 index 0000000..01376c4 --- /dev/null +++ b/render_test.go @@ -0,0 +1,191 @@ +package main + +import "testing" + +func TestRenderENC(t *testing.T) { + tests := []struct { + name string + doc cobblerDoc + want string + }{ + { + name: "single class with develop environment", + doc: cobblerDoc{ + classes: []string{"roles::infra::storage::vault"}, + environment: "develop", + hasEnv: true, + parameters: map[string]string{}, + }, + want: "classes:\n" + + "- roles::infra::storage::vault\n" + + "environment: develop\n" + + "parameters:\n" + + " enc_env: develop\n" + + " enc_role:\n" + + " - roles::infra::storage::vault\n", + }, + { + name: "environment testing is dropped from top level but kept in enc_env", + doc: cobblerDoc{ + classes: []string{"roles::base"}, + environment: "testing", + hasEnv: true, + parameters: map[string]string{}, + }, + want: "classes:\n" + + "- roles::base\n" + + "parameters:\n" + + " enc_env: testing\n" + + " enc_role:\n" + + " - roles::base\n", + }, + { + name: "multiple classes", + doc: cobblerDoc{ + classes: []string{"roles::a", "roles::b"}, + environment: "production", + hasEnv: true, + parameters: map[string]string{}, + }, + want: "classes:\n" + + "- roles::a\n" + + "- roles::b\n" + + "environment: production\n" + + "parameters:\n" + + " enc_env: production\n" + + " enc_role:\n" + + " - roles::a\n" + + " - roles::b\n", + }, + { + name: "no environment key at all", + doc: cobblerDoc{ + classes: []string{"roles::base"}, + hasEnv: false, + parameters: map[string]string{}, + }, + want: "classes:\n" + + "- roles::base\n" + + "parameters:\n" + + " enc_role:\n" + + " - roles::base\n", + }, + { + name: "pre-existing parameters are preserved and sorted with computed ones", + doc: cobblerDoc{ + classes: []string{"roles::base"}, + environment: "develop", + hasEnv: true, + parameters: map[string]string{"zeta": "1", "alpha": "2"}, + }, + want: "classes:\n" + + "- roles::base\n" + + "environment: develop\n" + + "parameters:\n" + + " alpha: '2'\n" + + " enc_env: develop\n" + + " enc_role:\n" + + " - roles::base\n" + + " zeta: '1'\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := renderENC(tt.doc) + if err != nil { + t.Fatalf("renderENC returned error: %v", err) + } + if got != tt.want { + t.Errorf("renderENC mismatch\n--- want ---\n%s\n--- got ---\n%s", tt.want, got) + } + }) + } +} + +func TestParseCobbler(t *testing.T) { + tests := []struct { + name string + body string + want cobblerDoc + wantErr bool + }{ + { + name: "map form classes with 4-space indent (encapi cobbler wire)", + body: "classes:\n roles::infra::storage::vault: {}\nenvironment: develop\nparameters: {}\n", + want: cobblerDoc{ + classes: []string{"roles::infra::storage::vault"}, + environment: "develop", + hasEnv: true, + }, + }, + { + name: "list form classes", + body: "classes:\n- roles::base\n- roles::extra\nenvironment: develop\nparameters: {}\n", + want: cobblerDoc{ + classes: []string{"roles::base", "roles::extra"}, + environment: "develop", + hasEnv: true, + }, + }, + { + name: "inline flow list classes", + body: "classes: [roles::a, roles::b]\nenvironment: develop\nparameters: {}\n", + want: cobblerDoc{ + classes: []string{"roles::a", "roles::b"}, + environment: "develop", + hasEnv: true, + }, + }, + { + name: "empty classes map", + body: "classes: {}\nenvironment: develop\nparameters: {}\n", + want: cobblerDoc{ + classes: nil, + environment: "develop", + hasEnv: true, + }, + }, + { + name: "unexpected top-level key", + body: "bogus: value\n", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCobbler([]byte(tt.body)) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("parseCobbler returned error: %v", err) + } + if !equalStrings(got.classes, tt.want.classes) { + t.Errorf("classes = %v, want %v", got.classes, tt.want.classes) + } + if got.environment != tt.want.environment { + t.Errorf("environment = %q, want %q", got.environment, tt.want.environment) + } + if got.hasEnv != tt.want.hasEnv { + t.Errorf("hasEnv = %v, want %v", got.hasEnv, tt.want.hasEnv) + } + }) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}