Merge pull request 'Add encapic ENC client' (#1) from benvin/initial into main
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# built binary (repo root)
|
||||
/encapic
|
||||
# cross-compiled artifacts (e.g. encapic_linux_amd64)
|
||||
/encapic_*
|
||||
dist/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user