4 Commits

Author SHA1 Message Date
benvin fe44518287 Merge pull request 'Package encapic as an RPM for the VM puppet masters' (#2) from benvin/rpm-packaging into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #2
2026-09-19 18:40:41 +10:00
unkin-agent 2241ea3d72 Stop nested class parameters being parsed as classes
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
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
2026-09-19 18:12:47 +10:00
unkin-agent 1e48c06bfe 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-<version>-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
2026-09-19 18:12:47 +10:00
benvin 75ed5e5cdf Merge pull request 'Add encapic ENC client' (#1) from benvin/initial into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #1
2026-07-25 10:18:40 +10:00
9 changed files with 266 additions and 38 deletions
+79
View File
@@ -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-<version>-1.x86_64.rpm
file_name_template: "{{ .ConventionalFileName }}"
vendor: unkin
homepage: https://git.unkin.net/unkin/encapic
maintainer: Ben Vincent <ben@unkin.net>
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 <artifact>.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:"
+22
View File
@@ -16,3 +16,25 @@ steps:
limits: limits:
memory: 2Gi memory: 2Gi
cpu: 2 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
+39 -28
View File
@@ -18,13 +18,21 @@ steps:
memory: 2Gi memory: 2Gi
cpu: 2 cpu: 2
# Build the static linux/amd64 binary and its checksum, both attached to the # goreleaser is the single driver for the release: it builds the static
# Gitea release. The binary is what the puppet compilers pull at pod start. # binary, builds the RPM (nfpm is built in) and creates the Gitea release
- name: build # with the binary, RPM and per-artifact sha256 files attached. The binary
image: golang:1.25 # 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: commands:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o encapic_linux_amd64 . # goreleaser reads the tag history for the changelog; Woodpecker's clone
- sha256sum encapic_linux_amd64 > encapic_linux_amd64.sha256 # can be shallow.
- git fetch --tags --force
- go install github.com/goreleaser/goreleaser/v2@v2.17.1
- goreleaser release --clean
depends_on: [test] depends_on: [test]
backend_options: backend_options:
kubernetes: kubernetes:
@@ -37,35 +45,38 @@ steps:
memory: 2Gi memory: 2Gi
cpu: 2 cpu: 2
# Cut a Gitea release with the binary + checksum attached. # goreleaser cannot PUT to artifactapi, so the RPM it built into dist/ is
- name: release # 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 image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands: 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 HOST="https://artifactapi.k8s.syd1.au.unkin.net"
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check REPO="rpm-internal"
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") for rpm in dist/*.rpm; do
if [ -n "$PREV_TAG" ]; then FILE=$$(basename "$$rpm")
NOTES=$(git log "${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s") # artifactapi has no HEAD route (returns 405); probe with GET against
else # the served path (RPMs are stored under Packages/) to avoid re-upload.
NOTES=$(git log --pretty=format:"- %s") 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 fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "${NOTES}" --login gitea --repo "${CI_REPO}" echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
tea releases assets create "${CI_COMMIT_TAG}" \ curl -f -X PUT \
encapic_linux_amd64 \ "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
encapic_linux_amd64.sha256 \ -H "Content-Type: application/x-rpm" \
--login gitea --repo "${CI_REPO}" --data-binary @"$$rpm"
depends_on: [build] done
depends_on: [goreleaser]
backend_options: backend_options:
kubernetes: kubernetes:
serviceAccountName: default serviceAccountName: default
resources: resources:
requests: requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi memory: 512Mi
cpu: 500m cpu: 1
limits:
memory: 2Gi
cpu: 2
+13 -1
View File
@@ -5,7 +5,7 @@ GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
OS ?= $(shell go env GOOS) OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH) 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 all: build
@@ -22,12 +22,24 @@ lint:
fmt: fmt:
gofmt -w . gofmt -w .
pre-commit:
uvx pre-commit run --all-files
clean: clean:
rm -rf $(DIST) $(BINARY) rm -rf $(DIST) $(BINARY)
install: install:
go install $(GOFLAGS) . 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. # Bump helpers — read the latest semver tag and create the next one.
# If no tag exists yet, start from v0.0.0. # 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) _LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
+27 -2
View File
@@ -69,8 +69,33 @@ make build # static binary into dist/
make test # go test -v -race ./... make test # go test -v -race ./...
make lint # golangci-lint make lint # golangci-lint
make fmt # gofmt -w . 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` Release: `make minor` (etc.) tags `vX.Y.Z` and pushes it; the `release`
Woodpecker pipeline builds `encapic_linux_amd64` (+ `.sha256`) and attaches Woodpecker pipeline runs goreleaser, which attaches `encapic_linux_amd64`
them to a Gitea release. (+ `.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.
+13
View File
@@ -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 "$@"
+4
View File
@@ -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
+15 -6
View File
@@ -81,6 +81,10 @@ func parseClasses(lines []string, i int, inline string) ([]string, int, error) {
} }
var classes []string 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 j := i + 1
for ; j < len(lines); j++ { for ; j < len(lines); j++ {
line := lines[j] line := lines[j]
@@ -88,18 +92,23 @@ func parseClasses(lines []string, i int, inline string) ([]string, int, error) {
continue continue
} }
trimmed := strings.TrimSpace(line) 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 == "-" { if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
// Block list form, at either the parent indent ("- roles::base", // Block list form, at either the parent indent ("- roles::base",
// as yaml.dump emits) or nested (" - roles::base"). // as yaml.dump emits) or nested (" - roles::base").
classes = append(classes, unquote(strings.TrimSpace(trimmed[1:]))) classes = append(classes, unquote(strings.TrimSpace(trimmed[1:])))
continue 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) name, _, ok := splitKV(trimmed)
if !ok { if !ok {
return nil, 0, fmt.Errorf("unexpected classes entry: %q", line) return nil, 0, fmt.Errorf("unexpected classes entry: %q", line)
+53
View File
@@ -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])
}
}
}