Files
encapic/main.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

122 lines
4.0 KiB
Go

// 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") {
fmt.Fprintf(out, "encapic %s\n", version)
return nil
}
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 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
}