From 1e48c06bfe8a5466b579ee043102fa173bf766c0 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 18:12:47 +1000 Subject: [PATCH 1/2] Package encapic as an RPM published to rpm-internal The VM puppet masters are dnf-managed hosts moving off cobbler onto encapi, and encapic ships only as a release binary the k8s compilers curl. - drive the release with goreleaser, keeping the encapic_linux_amd64 asset and its .sha256 byte-compatible with the compiler initContainer - build encapic--1.x86_64.rpm and PUT it to artifactapi rpm-internal - install /usr/bin/encapic, the /usr/bin/encapic-enc ENC entrypoint and /etc/encapic/encapic.conf as %config(noreplace) - add make rpm/rpm-package/pre-commit and a PR packaging check --- .goreleaser.yaml | 79 ++++++++++++++++++++++++++++++++++++++++ .woodpecker/build.yaml | 22 +++++++++++ .woodpecker/release.yaml | 69 ++++++++++++++++++++--------------- Makefile | 14 ++++++- README.md | 29 ++++++++++++++- packaging/encapic-enc | 13 +++++++ packaging/encapic.conf | 4 ++ 7 files changed, 198 insertions(+), 32 deletions(-) create mode 100644 .goreleaser.yaml create mode 100755 packaging/encapic-enc create mode 100644 packaging/encapic.conf diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..21567d9 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,79 @@ +version: 2 + +project_name: encapic + +builds: + - id: encapic + main: . + binary: encapic + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Version }} + +archives: + # Raw binary, not an archive: the puppetserver-compiler initContainer fetches + # this exact asset name by URL and sha256-verifies it before installing it. + - id: binaries + ids: [encapic] + formats: [binary] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + +nfpms: + - id: encapic + ids: [encapic] + package_name: encapic + # encapic--1.x86_64.rpm + file_name_template: "{{ .ConventionalFileName }}" + vendor: unkin + homepage: https://git.unkin.net/unkin/encapic + maintainer: Ben Vincent + description: Puppet exec ENC client for encapi + license: MIT + formats: + - rpm + release: "1" + bindir: /usr/bin + contents: + # Puppet's exec node_terminus passes a certname and no environment, so + # external_nodes points at this shim, which sources the URL first. + - src: packaging/encapic-enc + dst: /usr/bin/encapic-enc + file_info: + mode: 0755 + # noreplace: puppet manages this file's contents (hiera-driven URL), so an + # upgrade must not overwrite it. + - src: packaging/encapic.conf + dst: /etc/encapic/encapic.conf + type: "config|noreplace" + file_info: + mode: 0644 + +checksum: + # One .sha256 per file rather than a single checksums.txt: the + # compiler initContainer fetches encapic_linux_amd64.sha256 next to the binary. + split: true + name_template: "{{ .ArtifactName }}.sha256" + +release: + gitea: + owner: unkin + name: encapic + +gitea_urls: + api: https://git.unkin.net/api/v1 + download: https://git.unkin.net + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index 74123b5..322dfdc 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -16,3 +16,25 @@ steps: limits: memory: 2Gi cpu: 2 + + # The release itself only runs on a tag, so validate the packaging config and + # build the artifacts (binary + RPM) here without publishing anything. + - name: package + image: golang:1.26 + commands: + - git fetch --tags --force + - go install github.com/goreleaser/goreleaser/v2@v2.17.1 + - goreleaser check + - goreleaser release --snapshot --clean + - ls -l dist/*.rpm dist/*.sha256 + depends_on: [build] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index a0955ea..e3565aa 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -18,13 +18,21 @@ steps: memory: 2Gi cpu: 2 - # Build the static linux/amd64 binary and its checksum, both attached to the - # Gitea release. The binary is what the puppet compilers pull at pod start. - - name: build - image: golang:1.25 + # goreleaser is the single driver for the release: it builds the static + # binary, builds the RPM (nfpm is built in) and creates the Gitea release + # with the binary, RPM and per-artifact sha256 files attached. The binary + # asset name is unchanged — the puppet compilers pull it at pod start. + - name: goreleaser + image: golang:1.26 + environment: + GITEA_TOKEN: + from_secret: RELEASER_TOKEN commands: - - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o encapic_linux_amd64 . - - sha256sum encapic_linux_amd64 > encapic_linux_amd64.sha256 + # goreleaser reads the tag history for the changelog; Woodpecker's clone + # can be shallow. + - git fetch --tags --force + - go install github.com/goreleaser/goreleaser/v2@v2.17.1 + - goreleaser release --clean depends_on: [test] backend_options: kubernetes: @@ -37,35 +45,38 @@ steps: memory: 2Gi cpu: 2 - # Cut a Gitea release with the binary + checksum attached. - - name: release + # goreleaser cannot PUT to artifactapi, so the RPM it built into dist/ is + # published to the local rpm repo here (a real yum repo; repodata + # regenerates automatically). + - name: upload-rpm image: git.unkin.net/unkin/almalinux9-base:20260606 - environment: - RELEASER_TOKEN: - from_secret: RELEASER_TOKEN commands: - | - curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea - tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - if [ -n "$PREV_TAG" ]; then - NOTES=$(git log "${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s") - else - NOTES=$(git log --pretty=format:"- %s") - fi - tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "${NOTES}" --login gitea --repo "${CI_REPO}" - tea releases assets create "${CI_COMMIT_TAG}" \ - encapic_linux_amd64 \ - encapic_linux_amd64.sha256 \ - --login gitea --repo "${CI_REPO}" - depends_on: [build] + HOST="https://artifactapi.k8s.syd1.au.unkin.net" + REPO="rpm-internal" + for rpm in dist/*.rpm; do + FILE=$$(basename "$$rpm") + # artifactapi has no HEAD route (returns 405); probe with GET against + # the served path (RPMs are stored under Packages/) to avoid re-upload. + code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true) + if [ "$$code" = "200" ]; then + echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload" + continue + fi + echo "Uploading $$FILE to $$REPO (existence probe returned $$code)" + curl -f -X PUT \ + "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \ + -H "Content-Type: application/x-rpm" \ + --data-binary @"$$rpm" + done + depends_on: [goreleaser] backend_options: kubernetes: serviceAccountName: default resources: requests: - memory: 128Mi - cpu: 100m - limits: memory: 512Mi - cpu: 500m + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Makefile b/Makefile index 0b505f1..a92247c 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" OS ?= $(shell go env GOOS) ARCH ?= $(shell go env GOARCH) -.PHONY: all build test lint fmt clean install patch minor major _tag +.PHONY: all build test lint fmt pre-commit clean install rpm rpm-package patch minor major _tag all: build @@ -22,12 +22,24 @@ lint: fmt: gofmt -w . +pre-commit: + uvx pre-commit run --all-files + clean: rm -rf $(DIST) $(BINARY) install: go install $(GOFLAGS) . +# Build every release artifact locally without publishing: the raw binary the +# puppet compilers fetch, and the RPM the VM masters install. +rpm: rpm-package + +# goreleaser drives the build and the nfpm packaging in one pass, so packaging +# an already-built binary is not a separate step. +rpm-package: + goreleaser release --snapshot --clean + # Bump helpers — read the latest semver tag and create the next one. # If no tag exists yet, start from v0.0.0. _LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) diff --git a/README.md b/README.md index 513c329..11a2220 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,33 @@ make build # static binary into dist/ make test # go test -v -race ./... make lint # golangci-lint make fmt # gofmt -w . +make rpm # binary + RPM into dist/ via goreleaser, publishing nothing ``` Release: `make minor` (etc.) tags `vX.Y.Z` and pushes it; the `release` -Woodpecker pipeline builds `encapic_linux_amd64` (+ `.sha256`) and attaches -them to a Gitea release. +Woodpecker pipeline runs goreleaser, which attaches `encapic_linux_amd64` +(+ `.sha256`) and the RPM to a Gitea release, and then publishes the RPM to the +artifactapi `rpm-internal` yum repo. + +## Installation + +The kubernetes compilers fetch `encapic_linux_amd64` from the Gitea release in +an initContainer and install it to `/opt/bin/encapic`. + +The VM puppet masters install the RPM from `rpm-internal`: + +``` +dnf install encapic +``` + +It ships three paths: + +- `/usr/bin/encapic` — the binary; +- `/usr/bin/encapic-enc` — the ENC entrypoint. Puppet's exec `node_terminus` + invokes `external_nodes` with a certname and no environment, so the shim + sources the URL before exec'ing the binary. Point `external_nodes` at this, + not at `/usr/bin/encapic`; +- `/etc/encapic/encapic.conf` — `ENCAPI_URL=...`, defaulting to the external + address `https://encapi.k8s.syd1.au.unkin.net` because a VM cannot resolve + the in-cluster default. Shipped `%config(noreplace)` so puppet may own its + contents without an upgrade reverting them. diff --git a/packaging/encapic-enc b/packaging/encapic-enc new file mode 100755 index 0000000..88fbd22 --- /dev/null +++ b/packaging/encapic-enc @@ -0,0 +1,13 @@ +#!/bin/sh +# Puppet's exec node_terminus invokes external_nodes with a certname and no +# environment, so the encapi URL cannot be inherited; source it here instead. +set -eu + +if [ -r /etc/encapic/encapic.conf ]; then + . /etc/encapic/encapic.conf +fi +if [ -n "${ENCAPI_URL:-}" ]; then + export ENCAPI_URL +fi + +exec /usr/bin/encapic "$@" diff --git a/packaging/encapic.conf b/packaging/encapic.conf new file mode 100644 index 0000000..481e61d --- /dev/null +++ b/packaging/encapic.conf @@ -0,0 +1,4 @@ +# Base URL of the encapi service, read by /usr/bin/encapic-enc. +# The binary's compiled-in default is the in-cluster address, which VM puppet +# masters cannot resolve. +ENCAPI_URL=https://encapi.k8s.syd1.au.unkin.net -- 2.47.3 From 2241ea3d72cbc42c3c1f86f5cc6f56afe2f14a4c Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 18:12:47 +1000 Subject: [PATCH 2/2] Stop nested class parameters being parsed as classes encapi's cobbler-wire document keys class parameters under the role name, and the line-based parser treats every indented line as a class, so a node with class params is classified with the parameter keys as extra classes. - track the indentation of the classes block and skip deeper lines - cover the nested form real cobbler-imported nodes return --- parse.go | 21 ++++++++++++++------ parse_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 parse_test.go diff --git a/parse.go b/parse.go index 24302a1..7421872 100644 --- a/parse.go +++ b/parse.go @@ -81,6 +81,10 @@ func parseClasses(lines []string, i int, inline string) ([]string, int, error) { } var classes []string + // Entries of the block sit at one indentation level. A class's value may + // itself be a nested block (class parameters, which encapi emits keyed by + // role name), whose deeper lines are values, not class names. + entryIndent := -1 j := i + 1 for ; j < len(lines); j++ { line := lines[j] @@ -88,18 +92,23 @@ func parseClasses(lines []string, i int, inline string) ([]string, int, error) { continue } trimmed := strings.TrimSpace(line) + indent := len(line) - len(strings.TrimLeft(line, " \t")) + // An unindented, non-list line is the next top-level key, so stop. + if indent == 0 && !strings.HasPrefix(trimmed, "-") { + break + } + if entryIndent < 0 { + entryIndent = indent + } + if indent > entryIndent { + continue + } 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) diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..317c16d --- /dev/null +++ b/parse_test.go @@ -0,0 +1,53 @@ +package main + +import "testing" + +// Nodes imported from cobbler carry class parameters, which encapi emits as a +// nested block under the role name. ausyd1nxvm2020/2021/2022 carry the role +// name as its own class parameter, so a line-based parser that treats every +// indented line as a class name emits the role twice. +func TestParseClassesNestedBlockIsOneClass(t *testing.T) { + body := []byte("classes:\n" + + " roles::infra::proxy::jumphost:\n" + + " roles::infra::proxy::jumphost: '~'\n" + + "environment: develop\n" + + "parameters: {}\n") + + doc, err := parseCobbler(body) + if err != nil { + t.Fatalf("parseCobbler returned error: %v", err) + } + want := []string{"roles::infra::proxy::jumphost"} + if len(doc.classes) != len(want) || doc.classes[0] != want[0] { + t.Errorf("classes = %q, want %q", doc.classes, want) + } + if doc.environment != "develop" { + t.Errorf("environment = %q, want develop", doc.environment) + } +} + +// The same shape with ordinary class parameters: the parameter keys are values +// of the class, never classes themselves. +func TestParseClassesNestedParamsAreNotClasses(t *testing.T) { + body := []byte("classes:\n" + + " roles::base:\n" + + " listen_port: 8140\n" + + " tls: true\n" + + " roles::infra::dns::master: {}\n" + + "environment: production\n" + + "parameters: {}\n") + + doc, err := parseCobbler(body) + if err != nil { + t.Fatalf("parseCobbler returned error: %v", err) + } + want := []string{"roles::base", "roles::infra::dns::master"} + if len(doc.classes) != len(want) { + t.Fatalf("classes = %q, want %q", doc.classes, want) + } + for i := range want { + if doc.classes[i] != want[i] { + t.Errorf("classes[%d] = %q, want %q", i, doc.classes[i], want[i]) + } + } +} -- 2.47.3