e200c0f7d2
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).
190 lines
5.0 KiB
Go
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
|
|
}
|