Merge pull request 'Add encapic ENC client' (#1) from benvin/initial into main
ci/woodpecker/tag/release Pipeline was successful

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-25 10:18:40 +10:00
14 changed files with 1115 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
# built binary (repo root)
/encapic
# cross-compiled artifacts (e.g. encapic_linux_amd64)
/encapic_*
dist/
+17
View File
@@ -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
+18
View File
@@ -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
+18
View File
@@ -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
+71
View File
@@ -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
+33
View File
@@ -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
+52
View File
@@ -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)
+74 -1
View File
@@ -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.
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 <certname>
```
encapic fetches the cobbler-wire ENC document from
```
${ENCAPI_URL}/cblr/svc/op/puppet/hostname/<certname>
```
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/<certname>/enc`, which serves the fully
reshaped document. encapic deliberately consumes the **cobbler-wire** endpoint
(`/cblr/svc/op/puppet/hostname/<certname>`) 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.
+3
View File
@@ -0,0 +1,3 @@
module encapic
go 1.25
+121
View File
@@ -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 <certname>
//
// it fetches ${ENCAPI_URL}/cblr/svc/op/puppet/hostname/<certname> (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/<certname>/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 <certname>", 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
}
+128
View File
@@ -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)
}
}
+195
View File
@@ -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
}
+189
View File
@@ -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
}
+191
View File
@@ -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
}