Files
encapic/render.go
T
benvin e200c0f7d2
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
Add encapic ENC client
The uv/python ENC script fails its first-invocation dependency resolution on
fresh compiler pods (exits 135/2), which breaks puppet agent catalog
compilation. encapic is a stdlib-only Go replacement with no runtime
dependency resolution.

- Add encapic CLI: fetch the cobbler-wire ENC document from encapi
  (ENCAPI_URL override, in-cluster default compiled in) and reshape it to
  match the python script byte-for-byte (classes to list, enc_role/enc_env
  parameters, environment dropped when testing), exiting non-zero on any
  HTTP/parse failure so the puppet exec ENC fails safe.
- Hand-parse the small fixed cobbler-wire YAML and hand-emit the output using
  the standard library only.
- Add table-driven normalisation tests, httptest 200/404/500/timeout tests,
  and a golden-output test.
- Add Makefile (build/test/lint/fmt + patch/minor/major with version
  ldflags), .gitignore, .pre-commit-config.yaml, and Woodpecker pipelines
  (build/test/pre-commit on pull_request; release on v* tags).
2026-07-25 09:55:31 +10:00

190 lines
5.0 KiB
Go

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
}