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).
196 lines
5.5 KiB
Go
196 lines
5.5 KiB
Go
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
|
|
}
|