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).
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user