From 373d21a74445459ab6fa2ac8463123fdac1db490 Mon Sep 17 00:00:00 2001 From: unkinben Date: Sat, 4 Jul 2026 23:45:15 +1000 Subject: [PATCH] initial implementation: encapi ENC server + CLI Postgres-backed External Node Classifier for Puppet, replacing Cobbler. - encapi HTTP server (chi + pgx): read/write API + two ENC document shapes (reshaped for the exec terminus; cobbler-wire for enc_direct_facts.rb) - encapi-cli: classify/node/role/status CRUD + import-cobbler seeder - pkg/client Go SDK; unit tests across all packages (DB via testcontainers) - Dockerfile (distroless), Makefile, nfpm RPM (encapi-cli + encapi-enc wrapper), Woodpecker CI, docs/cutover.md --- .gitignore | 6 + .pre-commit-config.yaml | 15 ++ .woodpecker/build.yaml | 9 + .woodpecker/docker.yaml | 18 ++ .woodpecker/pre-commit.yaml | 18 ++ .woodpecker/release.yaml | 67 ++++++++ .woodpecker/test.yaml | 35 ++++ Dockerfile | 21 +++ Makefile | 73 ++++++++ README.md | 98 ++++++++++- docker-compose.yml | 38 +++++ docs/cutover.md | 86 ++++++++++ go.mod | 68 ++++++++ go.sum | 160 +++++++++++++++++ internal/cli/cli.go | 149 ++++++++++++++++ internal/cli/cli_test.go | 257 ++++++++++++++++++++++++++++ internal/cli/import.go | 162 ++++++++++++++++++ internal/cli/resources.go | 219 ++++++++++++++++++++++++ internal/config/config.go | 64 +++++++ internal/config/config_test.go | 51 ++++++ internal/database/database_test.go | 165 ++++++++++++++++++ internal/database/nodes.go | 91 ++++++++++ internal/database/postgres.go | 71 ++++++++ internal/database/roles.go | 110 ++++++++++++ internal/database/statuses.go | 75 ++++++++ internal/distro/resolver.go | 86 ++++++++++ internal/distro/resolver_test.go | 74 ++++++++ internal/enc/render.go | 82 +++++++++ internal/enc/render_test.go | 124 ++++++++++++++ internal/server/handlers.go | 223 ++++++++++++++++++++++++ internal/server/middleware.go | 56 ++++++ internal/server/server.go | 109 ++++++++++++ internal/server/server_test.go | 265 +++++++++++++++++++++++++++++ internal/testsupport/containers.go | 47 +++++ packaging/enc.conf | 3 + packaging/encapi-enc | 12 ++ packaging/nfpm.yaml | 52 ++++++ packaging/scripts/preinstall.sh | 3 + pkg/client/client.go | 174 +++++++++++++++++++ pkg/client/client_test.go | 77 +++++++++ pkg/models/models.go | 29 ++++ scripts/build-rpm.sh | 34 ++++ 42 files changed, 3575 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/docker.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 docker-compose.yml create mode 100644 docs/cutover.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/import.go create mode 100644 internal/cli/resources.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/database/database_test.go create mode 100644 internal/database/nodes.go create mode 100644 internal/database/postgres.go create mode 100644 internal/database/roles.go create mode 100644 internal/database/statuses.go create mode 100644 internal/distro/resolver.go create mode 100644 internal/distro/resolver_test.go create mode 100644 internal/enc/render.go create mode 100644 internal/enc/render_test.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/middleware.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/testsupport/containers.go create mode 100644 packaging/enc.conf create mode 100755 packaging/encapi-enc create mode 100644 packaging/nfpm.yaml create mode 100755 packaging/scripts/preinstall.sh create mode 100644 pkg/client/client.go create mode 100644 pkg/client/client_test.go create mode 100644 pkg/models/models.go create mode 100755 scripts/build-rpm.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb2f9ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/bin/ +/dist/ +*.rpm +*.zip +encapi +encapi-cli diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5b65ffe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-mod-tidy diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..6dba082 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,9 @@ +when: + - event: pull_request + +steps: + - name: docker-build + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/encapi + dry_run: true diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..3a8c612 --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,18 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker-encapi + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/encapi + build_args: + VERSION: ${CI_COMMIT_TAG} + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + 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 new file mode 100644 index 0000000..8a57ca7 --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,67 @@ +when: + - event: tag + +# Builds the encapi-cli RPM and publishes it to the ArtifactAPI rpm-internal +# repo. The server image is built separately by docker.yaml. +steps: + - name: build + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - make build-cli VERSION=${CI_COMMIT_TAG} + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: package + image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest + commands: + - ./scripts/build-rpm.sh ${CI_COMMIT_TAG} + depends_on: [build] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: upload + image: git.unkin.net/unkin/almalinux9-base:20260606 + commands: + - | + HOST="https://artifactapi.k8s.syd1.au.unkin.net" + REPO="rpm-internal" + for rpm in dist/*.rpm; do + FILE=$$(basename "$$rpm") + 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: [package] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..ed0afd8 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,35 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golang:1.25 + commands: + - make lint + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + # Container-backed DB tests self-skip when Docker is unavailable in CI; + # they run in the docker-e2e path / locally. + - make test-short + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e905eda --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o encapi ./cmd/encapi + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/encapi /usr/local/bin/encapi + +EXPOSE 8000 + +ENTRYPOINT ["encapi"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c9b65d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +.PHONY: build build-server build-cli test test-short lint fmt e2e docker compose clean tidy rpm rpm-package check-go patch minor major + +MODULE := git.unkin.net/unkin/encapi +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") +DIST := dist +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + +GO_VERSION_REQUIRED := 1.23 +GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') + +check-go: + @if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \ + echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \ + fi + +build: build-server build-cli + +build-server: check-go tidy + go build -ldflags="-s -w -X main.version=$(VERSION)" -o bin/encapi ./cmd/encapi + +build-cli: check-go + CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(DIST)/encapi-cli ./cmd/encapi-cli + +# Full suite, including the Postgres testcontainers integration tests (needs Docker). +test: check-go + TESTCONTAINERS_RYUK_DISABLED=true go test -race -count=1 ./... + +# Fast suite: skips the database package's container-backed tests. +test-short: check-go + go test -race -count=1 ./internal/config/... ./internal/enc/... ./internal/distro/... ./internal/server/... ./internal/cli/... ./pkg/... + +lint: check-go + go vet ./... + +fmt: check-go + gofmt -w . + +docker: + docker build -t encapi:$(VERSION) . + +compose: + docker compose up -d + +# --- CLI RPM (encapi-cli + the puppet ENC wrapper) --- +rpm: build-cli rpm-package + +rpm-package: + ./scripts/build-rpm.sh $(VERSION) + +clean: + rm -rf bin/ $(DIST)/ + +tidy: + go mod tidy + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/README.md b/README.md index aecd01e..1c23949 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,99 @@ # encapi -Postgres-backed External Node Classifier (ENC) for Puppet, replacing Cobbler. Go API + encapi-cli. \ No newline at end of file +A Postgres-backed **External Node Classifier (ENC)** for Puppet, written in Go. +It replaces Cobbler as the source of truth for *which role a host runs* while +keeping Puppet's node-classification contract byte-compatible. + +## Components + +| Binary | What it is | +|-----------------|-----------------------------------------------------------------------| +| `encapi` | HTTP server: serves ENC documents + a read/write API. Postgres-backed. | +| `encapi-cli` | CLI over the API. `encapi-cli classify ` is the Puppet ENC. | +| `encapi-enc` | Thin wrapper (shipped in the RPM) for Puppet's `external_nodes`. | + +The Terraform provider lives in a sibling repo: +[`terraform-provider-encapi`](https://git.unkin.net/unkin/terraform-provider-encapi). + +## Data model + +- **status** — a Puppet environment (Cobbler's "status": testing, production…). +- **role** — a class assignment target (`roles::infra::storage::vault`) with + inheritable `default_params`. +- **node** — a host (certname) pinned to one role + one environment, with + optional per-node `params` (which win over the role defaults). + +Foreign keys guarantee a node can only reference a role/status that exists, and +a role/status in use cannot be deleted. + +## ENC output + +`encapi` renders two shapes from the same data: + +- **`GET /api/v1/nodes/{certname}/enc`** — the reshaped document Puppet's + `exec` terminus consumes: `classes` as a list, `environment` dropped when it + is `testing`, and `parameters` carrying `enc_role` (list) + `enc_env`. This is + what `encapi-cli classify` prints. +- **`GET /cblr/svc/op/puppet/hostname/{certname}`** — the cobbler-wire form + (`classes` as a map, `environment` always present), so the legacy + `enc_direct_facts.rb` fact can be repointed with only a URL change. + +See [`docs/cutover.md`](docs/cutover.md) for the migration from Cobbler. + +## API + +Reads are open. Writes require `Authorization: Bearer $ENCAPI_WRITE_TOKEN` +(set the token in the server's environment; manage it in Vault). + +``` +GET /healthz +GET /api/v1/nodes list nodes +GET /api/v1/nodes/{certname} get node +PUT /api/v1/nodes/{certname} upsert node (token) +DELETE /api/v1/nodes/{certname} delete node (token) +GET /api/v1/nodes/{certname}/enc reshaped ENC (YAML) +GET /cblr/svc/op/puppet/hostname/{h} cobbler-wire ENC (YAML) +GET /api/v1/roles ... /roles/{name} (PUT/DELETE token) +GET /api/v1/statuses ... /statuses/{name} (PUT/DELETE token) +``` + +## Configuration (server env) + +| Var | Default | Purpose | +|------------------------|---------------|------------------------------------------| +| `LISTEN_ADDR` | `:8000` | listen address | +| `DBHOST/DBPORT/DBUSER/DBPASS/DBNAME/DBSSL` | localhost/5432/encapi/encapi/encapi/disable | Postgres | +| `ENCAPI_WRITE_TOKEN` | *(unset)* | bearer token for writes; unset = read-only | +| `ENCAPI_DISTRO_API_URL`| *(unset)* | optional kickstart/distro API for provisioning params | + +## CLI + +```bash +export ENCAPI_URL=https://encapi.k8s.syd1.au.unkin.net +export ENCAPI_WRITE_TOKEN=… # only needed for writes + +encapi-cli status set production +encapi-cli role set roles::infra::storage::minio --param 'replicas=4' --param 'epel="9"' +encapi-cli node set ausyd1nxvm2100.main.unkin.net --role roles::infra::storage::minio --env production +encapi-cli classify ausyd1nxvm2100.main.unkin.net + +# one-shot migration from the live Cobbler estate: +encapi-cli import-cobbler --dry-run +encapi-cli import-cobbler +``` + +## Local development + +```bash +docker compose up -d # postgres + encapi +make test # full suite (Postgres via testcontainers) +make test-short # skip container-backed DB tests +``` + +## Releases + +- **`encapi` server image** — tagging `vX.Y.Z` builds and pushes + `git.unkin.net/unkin/encapi:{tag,latest}` (distroless). +- **`encapi-cli` RPM** — the same tag builds an RPM (nfpm) and publishes it to + the ArtifactAPI `rpm-internal` repo. Installs `encapi-cli`, the `encapi-enc` + Puppet wrapper, and `/etc/encapi/enc.conf`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1217bfb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: encapi + POSTGRES_PASSWORD: encapi + POSTGRES_DB: encapi + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U encapi"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - pgdata:/var/lib/postgresql/data + + encapi: + build: + context: . + args: + VERSION: dev + environment: + DBHOST: postgres + DBUSER: encapi + DBPASS: encapi + DBNAME: encapi + LISTEN_ADDR: ":8000" + # Set a token to enable writes; leave unset for read-only. + ENCAPI_WRITE_TOKEN: "${ENCAPI_WRITE_TOKEN:-devtoken}" + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + +volumes: + pgdata: diff --git a/docs/cutover.md b/docs/cutover.md new file mode 100644 index 0000000..48c03f1 --- /dev/null +++ b/docs/cutover.md @@ -0,0 +1,86 @@ +# Cutover: Cobbler ENC → encapi + +Today Cobbler is Puppet's ENC. **Two** consumers hit +`http://cobbler.main.unkin.net/cblr/svc/op/puppet/hostname/`: + +1. **The exec ENC** — `puppet-prod` sets, in + `site/profiles/manifests/puppet/server.pp`: + ``` + node_terminus = exec + external_nodes = /opt/cobbler-enc/cobbler-enc + ``` + `/opt/cobbler-enc/cobbler-enc` (templated from + `site/profiles/templates/puppet/server/cobbler-enc.erb`) fetches Cobbler and + reshapes it (list-form classes, drops `environment` when `testing`, adds + `enc_role`/`enc_env`). + +2. **The `enc_role`/`enc_env` facts** — `modules/libs/lib/facter/enc_direct_facts.rb`, + a fact on *every* node, fetches the same Cobbler endpoint (cached 7d at + `/var/cache/puppet_enc.yaml`) and derives `enc_role = classes.keys.first`, + `enc_env = environment`. Those facts drive `enc_role_tier1/2/3` + + `enc_role_path`, i.e. the whole Hiera hierarchy. + +encapi serves drop-in replacements for **both**. + +## 1. Seed encapi from the current estate + +```bash +export ENCAPI_URL=https://encapi.k8s.syd1.au.unkin.net +export ENCAPI_WRITE_TOKEN=… +encapi-cli import-cobbler --dry-run # review +encapi-cli import-cobbler # writes statuses, roles, nodes +``` + +`import-cobbler` lists hosts from PuppetDB +(`http://puppetdbapi.service.consul:8080`), reads each host's Cobbler ENC, and +creates the derived status, role, and node. + +## 2. Repoint the exec ENC (`profiles::puppet::cobbler_enc`) + +Install the CLI RPM on the puppet masters (it ships `encapi-cli`, +`/usr/local/bin/encapi-enc`, and `/etc/encapi/enc.conf`), set +`ENCAPI_URL` in `/etc/encapi/enc.conf`, and point Puppet at the wrapper: + +``` +node_terminus = exec +external_nodes = /usr/local/bin/encapi-enc +``` + +`encapi-enc ` runs `encapi-cli classify `, which returns the +identical reshaped document (`GET /api/v1/nodes//enc`). + +## 3. Repoint the `enc_direct_facts.rb` fact + +Change only the base URL in `enc_direct_facts.rb`: + +```ruby +uri = URI("https://encapi.k8s.syd1.au.unkin.net/cblr/svc/op/puppet/hostname/#{...}") +``` + +encapi's `/cblr/svc/op/puppet/hostname/` returns the cobbler-wire shape +(`classes` as a map, `environment` always present), so `classes.keys.first` and +`environment` still resolve. No other fact logic changes. + +## 4. Verify before flipping + +For a sample of hosts, diff the old and new output: + +```bash +diff <(curl -s "http://cobbler.main.unkin.net/cblr/svc/op/puppet/hostname/$H") \ + <(curl -s "https://encapi.k8s.syd1.au.unkin.net/cblr/svc/op/puppet/hostname/$H") + +diff <(/opt/cobbler-enc/cobbler-enc "$H") \ + <(encapi-cli classify "$H") +``` + +The `classes`/`environment`/`enc_role`/`enc_env` fields must match. (Cobbler's +provisioning params — `epel`, `tree`, `from_cobbler`, `operatingsystemrelease` — +are intentionally dropped: they are unused by the Puppet manifests. If a future +need arises, wire `ENCAPI_DISTRO_API_URL` to the kickstart-replacement API and +they reappear under `parameters`.) + +## 5. Decommission + +Once masters and agents are repointed and a Puppet run is clean, retire +`profiles::puppet::cobbler_enc` and Cobbler's ENC role. Cobbler can keep doing +provisioning/kickstart; only its ENC duty moves to encapi. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a483d67 --- /dev/null +++ b/go.mod @@ -0,0 +1,68 @@ +module git.unkin.net/unkin/encapi + +go 1.25.9 + +require ( + github.com/go-chi/chi/v5 v5.3.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..24180da --- /dev/null +++ b/go.sum @@ -0,0 +1,160 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..1e1a781 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,149 @@ +// Package cli implements the encapi-cli command tree. Logic lives here (rather +// than in main) so it can be unit-tested by driving Run with in-memory streams. +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/encapi/pkg/client" +) + +// Env carries the process environment the CLI needs. +type Env struct { + URL string // ENCAPI_URL + Token string // ENCAPI_WRITE_TOKEN +} + +// LoadEnv reads configuration from the process environment, applying defaults. +func LoadEnv() Env { + url := os.Getenv("ENCAPI_URL") + if url == "" { + url = "http://localhost:8000" + } + return Env{URL: strings.TrimRight(url, "/"), Token: os.Getenv("ENCAPI_WRITE_TOKEN")} +} + +const usage = `encapi-cli — manage the Puppet External Node Classifier + +Usage: + encapi-cli classify print the ENC document Puppet consumes + encapi-cli node list + encapi-cli node get + encapi-cli node set --role --env [--param k=v ...] + encapi-cli node delete + encapi-cli role list + encapi-cli role get + encapi-cli role set [--desc ] [--param k=v ...] + encapi-cli role delete + encapi-cli status list + encapi-cli status get + encapi-cli status set [--desc ] + encapi-cli status delete + encapi-cli import-cobbler [--cobbler-url URL] [--puppetdb-url URL] [--dry-run] + +Params: + --param values are parsed as JSON when possible, so numbers, bools, lists and + objects keep their type (replicas=3 -> int, enabled=true -> bool). To force a + string, quote it: epel='"9"'. + +Environment: + ENCAPI_URL encapi base URL (default http://localhost:8000) + ENCAPI_WRITE_TOKEN bearer token, required for writes +` + +// Run executes the CLI and returns a process exit code. +func Run(args []string, env Env, stdout, stderr io.Writer) int { + if len(args) < 1 { + fmt.Fprint(stderr, usage) + return 2 + } + c := client.New(env.URL, env.Token) + ctx := context.Background() + + switch args[0] { + case "classify": + return classify(ctx, c, args[1:], stdout, stderr) + case "node": + return nodeCmd(ctx, c, args[1:], stdout, stderr) + case "role": + return roleCmd(ctx, c, args[1:], stdout, stderr) + case "status": + return statusCmd(ctx, c, args[1:], stdout, stderr) + case "import-cobbler": + return importCobbler(ctx, c, args[1:], stdout, stderr) + case "-h", "--help", "help": + fmt.Fprint(stdout, usage) + return 0 + default: + fmt.Fprintf(stderr, "unknown command %q\n\n%s", args[0], usage) + return 2 + } +} + +func classify(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + fmt.Fprintln(stderr, "usage: encapi-cli classify ") + return 2 + } + out, err := c.ENC(ctx, args[0]) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + _, _ = stdout.Write(out) + return 0 +} + +func printYAML(w io.Writer, v any) { + b, _ := yaml.Marshal(v) + _, _ = w.Write(b) +} + +// parseParams turns ["k=v", "n=3"] into a map. Values are parsed as JSON when +// possible (so numbers, bools, lists, and objects survive), else kept as +// strings. +func parseParams(pairs []string) (map[string]any, error) { + if len(pairs) == 0 { + return nil, nil + } + out := map[string]any{} + for _, p := range pairs { + k, v, ok := strings.Cut(p, "=") + if !ok { + return nil, fmt.Errorf("invalid --param %q (want key=value)", p) + } + var parsed any + if json.Unmarshal([]byte(v), &parsed) == nil { + out[k] = parsed + } else { + out[k] = v + } + } + return out, nil +} + +// leadingName splits a positional name from trailing flags. Go's flag package +// stops at the first non-flag token, so `set --flag ...` needs the name +// peeled off first. Returns ok=false when no name is present. +func leadingName(args []string) (name string, rest []string, ok bool) { + if len(args) == 0 || strings.HasPrefix(args[0], "-") { + return "", nil, false + } + return args[0], args[1:], true +} + +// stringsFlag collects repeated flag values (e.g. multiple --param). +type stringsFlag []string + +func (s *stringsFlag) String() string { return strings.Join(*s, ",") } +func (s *stringsFlag) Set(v string) error { + *s = append(*s, v) + return nil +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..70a8282 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,257 @@ +package cli + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.unkin.net/unkin/encapi/internal/database" + "git.unkin.net/unkin/encapi/internal/distro" + "git.unkin.net/unkin/encapi/internal/server" + "git.unkin.net/unkin/encapi/pkg/models" +) + +// memStore implements server.Store in memory for end-to-end CLI tests. +type memStore struct { + roles map[string]models.Role + statuses map[string]models.Status + nodes map[string]models.Node +} + +func newMem() *memStore { + return &memStore{ + roles: map[string]models.Role{"roles::base": {Name: "roles::base"}}, + statuses: map[string]models.Status{"testing": {Name: "testing"}, "production": {Name: "production"}}, + nodes: map[string]models.Node{}, + } +} + +func (m *memStore) UpsertRole(_ context.Context, r *models.Role) error { + m.roles[r.Name] = *r + return nil +} +func (m *memStore) GetRole(_ context.Context, n string) (*models.Role, error) { + r, ok := m.roles[n] + if !ok { + return nil, database.ErrNotFound + } + return &r, nil +} +func (m *memStore) ListRoles(context.Context) ([]models.Role, error) { + out := []models.Role{} + for _, r := range m.roles { + out = append(out, r) + } + return out, nil +} +func (m *memStore) DeleteRole(_ context.Context, n string) error { + if _, ok := m.roles[n]; !ok { + return database.ErrNotFound + } + delete(m.roles, n) + return nil +} +func (m *memStore) UpsertStatus(_ context.Context, s *models.Status) error { + m.statuses[s.Name] = *s + return nil +} +func (m *memStore) GetStatus(_ context.Context, n string) (*models.Status, error) { + s, ok := m.statuses[n] + if !ok { + return nil, database.ErrNotFound + } + return &s, nil +} +func (m *memStore) ListStatuses(context.Context) ([]models.Status, error) { + out := []models.Status{} + for _, s := range m.statuses { + out = append(out, s) + } + return out, nil +} +func (m *memStore) DeleteStatus(_ context.Context, n string) error { + if _, ok := m.statuses[n]; !ok { + return database.ErrNotFound + } + delete(m.statuses, n) + return nil +} +func (m *memStore) UpsertNode(_ context.Context, n *models.Node) error { + m.nodes[n.Certname] = *n + return nil +} +func (m *memStore) GetNode(_ context.Context, c string) (*models.Node, error) { + n, ok := m.nodes[c] + if !ok { + return nil, database.ErrNotFound + } + return &n, nil +} +func (m *memStore) ListNodes(context.Context) ([]models.Node, error) { + out := []models.Node{} + for _, n := range m.nodes { + out = append(out, n) + } + return out, nil +} +func (m *memStore) DeleteNode(_ context.Context, c string) error { + if _, ok := m.nodes[c]; !ok { + return database.ErrNotFound + } + delete(m.nodes, c) + return nil +} + +func run(t *testing.T, env Env, args ...string) (int, string, string) { + t.Helper() + var out, errb bytes.Buffer + code := Run(args, env, &out, &errb) + return code, out.String(), errb.String() +} + +func serverEnv(t *testing.T) (Env, *memStore) { + t.Helper() + store := newMem() + srv := server.New(store, distro.Noop{}, "tok") + ts := httptest.NewServer(srv.Router()) + t.Cleanup(ts.Close) + return Env{URL: ts.URL, Token: "tok"}, store +} + +func TestParseParams(t *testing.T) { + got, err := parseParams([]string{"s=hello", "n=3", "b=true", `j={"a":1}`}) + if err != nil { + t.Fatal(err) + } + if got["s"] != "hello" || got["n"] != float64(3) || got["b"] != true { + t.Errorf("params = %#v", got) + } + if obj, ok := got["j"].(map[string]any); !ok || obj["a"] != float64(1) { + t.Errorf("json param = %#v", got["j"]) + } + if _, err := parseParams([]string{"noequals"}); err == nil { + t.Error("expected error for malformed param") + } +} + +func TestNodeSetAndClassify(t *testing.T) { + env, _ := serverEnv(t) + + code, _, errb := run(t, env, "node", "set", "web1.example", "--role", "roles::base", "--env", "testing", "--param", "x=1") + if code != 0 { + t.Fatalf("node set exit %d: %s", code, errb) + } + + code, out, errb := run(t, env, "classify", "web1.example") + if code != 0 { + t.Fatalf("classify exit %d: %s", code, errb) + } + // testing env dropped, classes list form, enc_role present + if strings.Contains(out, "environment:") { + t.Errorf("classify output should drop testing environment:\n%s", out) + } + if !strings.Contains(out, "- roles::base") || !strings.Contains(out, "enc_role") { + t.Errorf("classify output missing expected fields:\n%s", out) + } +} + +func TestNodeSetRequiresFlags(t *testing.T) { + env, _ := serverEnv(t) + code, _, _ := run(t, env, "node", "set", "h") + if code != 2 { + t.Errorf("exit = %d, want 2 for missing flags", code) + } +} + +func TestRoleSetWithDefaultParams(t *testing.T) { + env, store := serverEnv(t) + // epel=9 auto-parses as a JSON number; the quoted form forces a string. + code, _, errb := run(t, env, "role", "set", "roles::infra::x", "--desc", "X role", "--param", "replicas=9", "--param", `epel="9"`) + if code != 0 { + t.Fatalf("role set exit %d: %s", code, errb) + } + r := store.roles["roles::infra::x"] + if r.Description != "X role" || r.DefaultParams["replicas"] != float64(9) || r.DefaultParams["epel"] != "9" { + t.Errorf("stored role = %+v", r) + } +} + +func TestStatusLifecycle(t *testing.T) { + env, store := serverEnv(t) + if code, _, e := run(t, env, "status", "set", "development", "--desc", "dev"); code != 0 { + t.Fatalf("status set: %s", e) + } + if _, ok := store.statuses["development"]; !ok { + t.Error("development status not stored") + } + if code, _, _ := run(t, env, "status", "delete", "development"); code != 0 { + t.Error("status delete failed") + } +} + +func TestClassifyUnknownNodeFails(t *testing.T) { + env, _ := serverEnv(t) + code, _, _ := run(t, env, "classify", "ghost") + if code != 1 { + t.Errorf("exit = %d, want 1", code) + } +} + +func TestUnknownCommand(t *testing.T) { + code, _, _ := run(t, Env{URL: "http://x"}, "bogus") + if code != 2 { + t.Errorf("exit = %d, want 2", code) + } +} + +func TestImportCobblerDryRun(t *testing.T) { + // Fake PuppetDB + Cobbler. + pdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"certname":"h1.example"},{"certname":"h2.example"}]`)) + })) + defer pdb.Close() + cob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "h1.example") { + _, _ = w.Write([]byte("classes:\n roles::infra::storage::vault: {}\nenvironment: testing\nparameters: {}\n")) + } else { + _, _ = w.Write([]byte("classes:\n roles::base: {}\nenvironment: production\nparameters: {}\n")) + } + })) + defer cob.Close() + + env, _ := serverEnv(t) + code, out, errb := run(t, env, "import-cobbler", "--puppetdb-url", pdb.URL, "--cobbler-url", cob.URL, "--dry-run") + if code != 0 { + t.Fatalf("import exit %d: %s", code, errb) + } + if !strings.Contains(out, "roles::infra::storage::vault") || !strings.Contains(out, "roles::base") { + t.Errorf("dry-run output:\n%s", out) + } +} + +func TestImportCobblerWrites(t *testing.T) { + pdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"certname":"h1.example"}]`)) + })) + defer pdb.Close() + cob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("classes:\n roles::infra::dns::master: {}\nenvironment: production\nparameters: {}\n")) + })) + defer cob.Close() + + env, store := serverEnv(t) + code, out, errb := run(t, env, "import-cobbler", "--puppetdb-url", pdb.URL, "--cobbler-url", cob.URL) + if code != 0 { + t.Fatalf("import exit %d: %s", code, errb) + } + n, ok := store.nodes["h1.example"] + if !ok || n.Role != "roles::infra::dns::master" || n.Environment != "production" { + t.Errorf("imported node = %+v (out=%s)", n, out) + } + if _, ok := store.roles["roles::infra::dns::master"]; !ok { + t.Error("role not created by import") + } +} diff --git a/internal/cli/import.go b/internal/cli/import.go new file mode 100644 index 0000000..6ae4ba8 --- /dev/null +++ b/internal/cli/import.go @@ -0,0 +1,162 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "sort" + "time" + + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/encapi/pkg/client" + "git.unkin.net/unkin/encapi/pkg/models" +) + +// cobblerENC is the raw document Cobbler serves at +// /cblr/svc/op/puppet/hostname/. +type cobblerENC struct { + Classes map[string]map[string]any `yaml:"classes"` + Environment string `yaml:"environment"` + Parameters map[string]any `yaml:"parameters"` +} + +// importCobbler seeds encapi from the live Cobbler estate: it lists hosts from +// PuppetDB, reads each host's Cobbler ENC, and upserts the derived status, +// role, and node. It is a one-shot migration aid. +func importCobbler(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("import-cobbler", flag.ContinueOnError) + fs.SetOutput(stderr) + cobblerURL := fs.String("cobbler-url", "http://cobbler.main.unkin.net", "Cobbler base URL") + puppetdbURL := fs.String("puppetdb-url", "http://puppetdbapi.service.consul:8080", "PuppetDB base URL") + dryRun := fs.Bool("dry-run", false, "print actions without writing") + if err := fs.Parse(args); err != nil { + return 2 + } + + hc := &http.Client{Timeout: 15 * time.Second} + hosts, err := puppetdbNodes(ctx, hc, *puppetdbURL) + if err != nil { + return fail(stderr, fmt.Errorf("enumerate PuppetDB nodes: %w", err)) + } + fmt.Fprintf(stderr, "found %d hosts in PuppetDB\n", len(hosts)) + + seenStatus := map[string]bool{} + seenRole := map[string]bool{} + var imported, skipped int + + for _, host := range hosts { + doc, err := cobblerLookup(ctx, hc, *cobblerURL, host) + if err != nil { + fmt.Fprintf(stderr, "skip %s: %v\n", host, err) + skipped++ + continue + } + role := firstClass(doc.Classes) + if role == "" { + fmt.Fprintf(stderr, "skip %s: no class in Cobbler ENC\n", host) + skipped++ + continue + } + env := doc.Environment + if env == "" { + env = "testing" + } + + if *dryRun { + fmt.Fprintf(stdout, "%s -> role=%s env=%s params=%v\n", host, role, env, doc.Classes[role]) + imported++ + continue + } + + if !seenStatus[env] { + if _, err := c.PutStatus(ctx, &models.Status{Name: env}); err != nil { + return fail(stderr, fmt.Errorf("upsert status %q: %w", env, err)) + } + seenStatus[env] = true + } + if !seenRole[role] { + if _, err := c.PutRole(ctx, &models.Role{Name: role}); err != nil { + return fail(stderr, fmt.Errorf("upsert role %q: %w", role, err)) + } + seenRole[role] = true + } + params := doc.Classes[role] + if len(params) == 0 { + params = nil + } + if _, err := c.PutNode(ctx, &models.Node{Certname: host, Role: role, Environment: env, Params: params}); err != nil { + return fail(stderr, fmt.Errorf("upsert node %q: %w", host, err)) + } + imported++ + } + fmt.Fprintf(stdout, "imported %d, skipped %d (%d roles, %d statuses)\n", imported, skipped, len(seenRole), len(seenStatus)) + return 0 +} + +// firstClass returns the sole/first class key deterministically. +func firstClass(classes map[string]map[string]any) string { + keys := make([]string, 0, len(classes)) + for k := range classes { + keys = append(keys, k) + } + if len(keys) == 0 { + return "" + } + sort.Strings(keys) + return keys[0] +} + +func puppetdbNodes(ctx context.Context, hc *http.Client, base string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/pdb/query/v4/nodes", nil) + if err != nil { + return nil, err + } + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + var nodes []struct { + Certname string `json:"certname"` + } + if err := json.NewDecoder(resp.Body).Decode(&nodes); err != nil { + return nil, err + } + out := make([]string, 0, len(nodes)) + for _, n := range nodes { + out = append(out, n.Certname) + } + sort.Strings(out) + return out, nil +} + +func cobblerLookup(ctx context.Context, hc *http.Client, base, host string) (*cobblerENC, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/cblr/svc/op/puppet/hostname/"+host, nil) + if err != nil { + return nil, err + } + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cobbler HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var doc cobblerENC + if err := yaml.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("parse cobbler yaml: %w", err) + } + return &doc, nil +} diff --git a/internal/cli/resources.go b/internal/cli/resources.go new file mode 100644 index 0000000..722908d --- /dev/null +++ b/internal/cli/resources.go @@ -0,0 +1,219 @@ +package cli + +import ( + "context" + "flag" + "fmt" + "io" + + "git.unkin.net/unkin/encapi/pkg/client" + "git.unkin.net/unkin/encapi/pkg/models" +) + +func nodeCmd(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + if len(args) < 1 { + fmt.Fprintln(stderr, "usage: encapi-cli node ...") + return 2 + } + switch args[0] { + case "list": + nodes, err := c.ListNodes(ctx) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, nodes) + return 0 + case "get": + if len(args) != 2 { + fmt.Fprintln(stderr, "usage: encapi-cli node get ") + return 2 + } + n, err := c.GetNode(ctx, args[1]) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, n) + return 0 + case "set": + return nodeSet(ctx, c, args[1:], stdout, stderr) + case "delete": + if len(args) != 2 { + fmt.Fprintln(stderr, "usage: encapi-cli node delete ") + return 2 + } + if err := c.DeleteNode(ctx, args[1]); err != nil { + return fail(stderr, err) + } + fmt.Fprintf(stdout, "deleted node %s\n", args[1]) + return 0 + default: + fmt.Fprintf(stderr, "unknown node subcommand %q\n", args[0]) + return 2 + } +} + +func nodeSet(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + name, rest, ok := leadingName(args) + if !ok { + fmt.Fprintln(stderr, "usage: encapi-cli node set --role --env [--param k=v ...]") + return 2 + } + fs := flag.NewFlagSet("node set", flag.ContinueOnError) + fs.SetOutput(stderr) + role := fs.String("role", "", "role (class) to assign") + env := fs.String("env", "", "environment/status") + var params stringsFlag + fs.Var(¶ms, "param", "param key=value (repeatable)") + if err := fs.Parse(rest); err != nil { + return 2 + } + if *role == "" || *env == "" { + fmt.Fprintln(stderr, "usage: encapi-cli node set --role --env [--param k=v ...]") + return 2 + } + p, err := parseParams(params) + if err != nil { + return fail(stderr, err) + } + n, err := c.PutNode(ctx, &models.Node{Certname: name, Role: *role, Environment: *env, Params: p}) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, n) + return 0 +} + +func roleCmd(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + if len(args) < 1 { + fmt.Fprintln(stderr, "usage: encapi-cli role ...") + return 2 + } + switch args[0] { + case "list": + roles, err := c.ListRoles(ctx) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, roles) + return 0 + case "get": + if len(args) != 2 { + fmt.Fprintln(stderr, "usage: encapi-cli role get ") + return 2 + } + r, err := c.GetRole(ctx, args[1]) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, r) + return 0 + case "set": + return roleSet(ctx, c, args[1:], stdout, stderr) + case "delete": + if len(args) != 2 { + fmt.Fprintln(stderr, "usage: encapi-cli role delete ") + return 2 + } + if err := c.DeleteRole(ctx, args[1]); err != nil { + return fail(stderr, err) + } + fmt.Fprintf(stdout, "deleted role %s\n", args[1]) + return 0 + default: + fmt.Fprintf(stderr, "unknown role subcommand %q\n", args[0]) + return 2 + } +} + +func roleSet(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + name, rest, ok := leadingName(args) + if !ok { + fmt.Fprintln(stderr, "usage: encapi-cli role set [--desc ] [--param k=v ...]") + return 2 + } + fs := flag.NewFlagSet("role set", flag.ContinueOnError) + fs.SetOutput(stderr) + desc := fs.String("desc", "", "description") + var params stringsFlag + fs.Var(¶ms, "param", "default param key=value (repeatable)") + if err := fs.Parse(rest); err != nil { + return 2 + } + p, err := parseParams(params) + if err != nil { + return fail(stderr, err) + } + r, err := c.PutRole(ctx, &models.Role{Name: name, Description: *desc, DefaultParams: p}) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, r) + return 0 +} + +func statusCmd(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + if len(args) < 1 { + fmt.Fprintln(stderr, "usage: encapi-cli status ...") + return 2 + } + switch args[0] { + case "list": + statuses, err := c.ListStatuses(ctx) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, statuses) + return 0 + case "get": + if len(args) != 2 { + fmt.Fprintln(stderr, "usage: encapi-cli status get ") + return 2 + } + s, err := c.GetStatus(ctx, args[1]) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, s) + return 0 + case "set": + return statusSet(ctx, c, args[1:], stdout, stderr) + case "delete": + if len(args) != 2 { + fmt.Fprintln(stderr, "usage: encapi-cli status delete ") + return 2 + } + if err := c.DeleteStatus(ctx, args[1]); err != nil { + return fail(stderr, err) + } + fmt.Fprintf(stdout, "deleted status %s\n", args[1]) + return 0 + default: + fmt.Fprintf(stderr, "unknown status subcommand %q\n", args[0]) + return 2 + } +} + +func statusSet(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { + name, rest, ok := leadingName(args) + if !ok { + fmt.Fprintln(stderr, "usage: encapi-cli status set [--desc ]") + return 2 + } + fs := flag.NewFlagSet("status set", flag.ContinueOnError) + fs.SetOutput(stderr) + desc := fs.String("desc", "", "description") + if err := fs.Parse(rest); err != nil { + return 2 + } + s, err := c.PutStatus(ctx, &models.Status{Name: name, Description: *desc}) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, s) + return 0 +} + +func fail(stderr io.Writer, err error) int { + fmt.Fprintln(stderr, err) + return 1 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..adf4b4a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,64 @@ +// Package config loads encapi server configuration from the environment. +package config + +import ( + "fmt" + "os" + "strconv" +) + +// Config is the fully-resolved server configuration. +type Config struct { + ListenAddr string + + DBHost string + DBPort int + DBUser string + DBPass string + DBName string + DBSSL string + + // WriteToken guards all mutating endpoints. Reads are always open. + // When empty, writes are refused entirely (fail-closed). + WriteToken string + + // DistroAPIURL, when set, points encapi at an external kickstart/distro + // API that resolves per-host provisioning params (epel, os release, ...). + // Left empty, no distro params are injected into ENC output. + DistroAPIURL string +} + +// DatabaseDSN renders a libpq/pgx connection string. +func (c *Config) DatabaseDSN() string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=%s", + c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL, + ) +} + +// Load reads configuration from the environment, applying defaults. +func Load() (*Config, error) { + dbPort, err := strconv.Atoi(getenv("DBPORT", "5432")) + if err != nil { + return nil, fmt.Errorf("invalid DBPORT: %w", err) + } + + return &Config{ + ListenAddr: getenv("LISTEN_ADDR", ":8000"), + DBHost: getenv("DBHOST", "localhost"), + DBPort: dbPort, + DBUser: getenv("DBUSER", "encapi"), + DBPass: getenv("DBPASS", "encapi"), + DBName: getenv("DBNAME", "encapi"), + DBSSL: getenv("DBSSL", "disable"), + WriteToken: os.Getenv("ENCAPI_WRITE_TOKEN"), + DistroAPIURL: os.Getenv("ENCAPI_DISTRO_API_URL"), + }, nil +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..776ded3 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,51 @@ +package config + +import "testing" + +func TestLoadDefaults(t *testing.T) { + for _, k := range []string{"LISTEN_ADDR", "DBHOST", "DBPORT", "DBUSER", "DBPASS", "DBNAME", "DBSSL", "ENCAPI_WRITE_TOKEN", "ENCAPI_DISTRO_API_URL"} { + t.Setenv(k, "") + } + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.ListenAddr != ":8000" { + t.Errorf("ListenAddr = %q, want :8000", c.ListenAddr) + } + if c.DBPort != 5432 { + t.Errorf("DBPort = %d, want 5432", c.DBPort) + } + if c.WriteToken != "" { + t.Errorf("WriteToken = %q, want empty", c.WriteToken) + } +} + +func TestLoadOverrides(t *testing.T) { + t.Setenv("LISTEN_ADDR", ":9000") + t.Setenv("DBPORT", "6543") + t.Setenv("DBHOST", "pg.example") + t.Setenv("ENCAPI_WRITE_TOKEN", "s3cret") + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.ListenAddr != ":9000" || c.DBPort != 6543 || c.DBHost != "pg.example" || c.WriteToken != "s3cret" { + t.Errorf("overrides not applied: %+v", c) + } +} + +func TestLoadBadPort(t *testing.T) { + t.Setenv("DBPORT", "notanumber") + if _, err := Load(); err == nil { + t.Fatal("expected error for non-numeric DBPORT") + } +} + +func TestDatabaseDSN(t *testing.T) { + c := &Config{DBUser: "u", DBPass: "p", DBHost: "h", DBPort: 5432, DBName: "n", DBSSL: "require"} + want := "postgres://u:p@h:5432/n?sslmode=require" + if got := c.DatabaseDSN(); got != want { + t.Errorf("DSN = %q, want %q", got, want) + } +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..a1d3f56 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,165 @@ +package database + +import ( + "context" + "errors" + "os" + "testing" + + "git.unkin.net/unkin/encapi/internal/testsupport" + "git.unkin.net/unkin/encapi/pkg/models" +) + +var testDB *DB + +func TestMain(m *testing.M) { + ctx := context.Background() + dsn, terminate, err := testsupport.StartPostgres(ctx) + if err != nil { + // Docker unavailable: run so tests self-skip via requireDB. + os.Exit(m.Run()) + } + db, err := New(dsn) + if err != nil { + terminate() + panic(err) + } + testDB = db + + code := m.Run() + db.Close() + terminate() + if code != 0 { + os.Exit(code) + } +} + +func requireDB(t *testing.T) { + t.Helper() + if testDB == nil { + t.Skip("Docker unavailable; skipping database integration test") + } +} + +// clean truncates all tables between tests for isolation. +func clean(t *testing.T) { + t.Helper() + _, err := testDB.Pool.Exec(context.Background(), `TRUNCATE nodes, roles, statuses CASCADE`) + if err != nil { + t.Fatalf("truncate: %v", err) + } +} + +func seed(t *testing.T) { + t.Helper() + ctx := context.Background() + if err := testDB.UpsertStatus(ctx, &models.Status{Name: "testing"}); err != nil { + t.Fatalf("seed status: %v", err) + } + if err := testDB.UpsertRole(ctx, &models.Role{Name: "roles::base"}); err != nil { + t.Fatalf("seed role: %v", err) + } +} + +func TestStatusCRUD(t *testing.T) { + requireDB(t) + clean(t) + ctx := context.Background() + + if err := testDB.UpsertStatus(ctx, &models.Status{Name: "production", Description: "prod"}); err != nil { + t.Fatal(err) + } + got, err := testDB.GetStatus(ctx, "production") + if err != nil || got.Description != "prod" { + t.Fatalf("GetStatus = %+v, %v", got, err) + } + // upsert updates description + if err := testDB.UpsertStatus(ctx, &models.Status{Name: "production", Description: "changed"}); err != nil { + t.Fatal(err) + } + got, _ = testDB.GetStatus(ctx, "production") + if got.Description != "changed" { + t.Errorf("description = %q, want changed", got.Description) + } + list, err := testDB.ListStatuses(ctx) + if err != nil || len(list) != 1 { + t.Fatalf("ListStatuses = %v, %v", list, err) + } + if err := testDB.DeleteStatus(ctx, "production"); err != nil { + t.Fatal(err) + } + if _, err := testDB.GetStatus(ctx, "production"); !errors.Is(err, ErrNotFound) { + t.Errorf("GetStatus after delete = %v, want ErrNotFound", err) + } +} + +func TestRoleCRUDWithParams(t *testing.T) { + requireDB(t) + clean(t) + ctx := context.Background() + + r := &models.Role{Name: "roles::infra::storage::minio", Description: "minio", DefaultParams: map[string]any{"minio_pool": "pool1", "replicas": float64(3)}} + if err := testDB.UpsertRole(ctx, r); err != nil { + t.Fatal(err) + } + got, err := testDB.GetRole(ctx, r.Name) + if err != nil { + t.Fatal(err) + } + if got.DefaultParams["minio_pool"] != "pool1" || got.DefaultParams["replicas"] != float64(3) { + t.Errorf("default_params = %#v", got.DefaultParams) + } + if _, err := testDB.GetRole(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Errorf("GetRole(nope) = %v, want ErrNotFound", err) + } +} + +func TestNodeCRUDAndForeignKeys(t *testing.T) { + requireDB(t) + clean(t) + seed(t) + ctx := context.Background() + + // node referencing an unknown role must fail the FK + badRole := &models.Node{Certname: "h1", Role: "roles::ghost", Environment: "testing"} + if err := testDB.UpsertNode(ctx, badRole); err == nil { + t.Error("expected FK violation for unknown role") + } + // node referencing an unknown environment must fail the FK + badEnv := &models.Node{Certname: "h1", Role: "roles::base", Environment: "ghost"} + if err := testDB.UpsertNode(ctx, badEnv); err == nil { + t.Error("expected FK violation for unknown environment") + } + + n := &models.Node{Certname: "h1", Role: "roles::base", Environment: "testing", Params: map[string]any{"x": "y"}} + if err := testDB.UpsertNode(ctx, n); err != nil { + t.Fatal(err) + } + got, err := testDB.GetNode(ctx, "h1") + if err != nil || got.Role != "roles::base" || got.Params["x"] != "y" { + t.Fatalf("GetNode = %+v, %v", got, err) + } + + // role in use cannot be deleted + if err := testDB.DeleteRole(ctx, "roles::base"); err == nil { + t.Error("expected error deleting role in use") + } + // status in use cannot be deleted + if err := testDB.DeleteStatus(ctx, "testing"); err == nil { + t.Error("expected error deleting status in use") + } + + list, err := testDB.ListNodes(ctx) + if err != nil || len(list) != 1 { + t.Fatalf("ListNodes = %v, %v", list, err) + } + if err := testDB.DeleteNode(ctx, "h1"); err != nil { + t.Fatal(err) + } + if _, err := testDB.GetNode(ctx, "h1"); !errors.Is(err, ErrNotFound) { + t.Errorf("GetNode after delete = %v, want ErrNotFound", err) + } + if err := testDB.DeleteNode(ctx, "h1"); !errors.Is(err, ErrNotFound) { + t.Errorf("DeleteNode missing = %v, want ErrNotFound", err) + } +} diff --git a/internal/database/nodes.go b/internal/database/nodes.go new file mode 100644 index 0000000..8591408 --- /dev/null +++ b/internal/database/nodes.go @@ -0,0 +1,91 @@ +package database + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "git.unkin.net/unkin/encapi/pkg/models" +) + +// UpsertNode creates or updates a host-to-role assignment. The referenced role +// and environment must already exist (enforced by foreign keys). +func (db *DB) UpsertNode(ctx context.Context, n *models.Node) error { + params, err := marshalParams(n.Params) + if err != nil { + return fmt.Errorf("marshal params for node %q: %w", n.Certname, err) + } + _, err = db.Pool.Exec(ctx, ` + INSERT INTO nodes (certname, role, environment, params) + VALUES ($1, $2, $3, $4) + ON CONFLICT (certname) DO UPDATE + SET role = EXCLUDED.role, + environment = EXCLUDED.environment, + params = EXCLUDED.params, + updated_at = NOW() + `, n.Certname, n.Role, n.Environment, params) + if err != nil { + return fmt.Errorf("upsert node %q: %w", n.Certname, err) + } + return nil +} + +// GetNode returns a single node or ErrNotFound. +func (db *DB) GetNode(ctx context.Context, certname string) (*models.Node, error) { + var ( + n models.Node + params []byte + ) + err := db.Pool.QueryRow(ctx, + `SELECT certname, role, environment, params FROM nodes WHERE certname = $1`, certname, + ).Scan(&n.Certname, &n.Role, &n.Environment, ¶ms) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get node %q: %w", certname, err) + } + if n.Params, err = unmarshalParams(params); err != nil { + return nil, fmt.Errorf("decode params for node %q: %w", certname, err) + } + return &n, nil +} + +// ListNodes returns all nodes ordered by certname. +func (db *DB) ListNodes(ctx context.Context) ([]models.Node, error) { + rows, err := db.Pool.Query(ctx, `SELECT certname, role, environment, params FROM nodes ORDER BY certname`) + if err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + defer rows.Close() + + out := []models.Node{} + for rows.Next() { + var ( + n models.Node + params []byte + ) + if err := rows.Scan(&n.Certname, &n.Role, &n.Environment, ¶ms); err != nil { + return nil, fmt.Errorf("scan node: %w", err) + } + if n.Params, err = unmarshalParams(params); err != nil { + return nil, fmt.Errorf("decode params: %w", err) + } + out = append(out, n) + } + return out, rows.Err() +} + +// DeleteNode removes a host assignment. +func (db *DB) DeleteNode(ctx context.Context, certname string) error { + tag, err := db.Pool.Exec(ctx, `DELETE FROM nodes WHERE certname = $1`, certname) + if err != nil { + return fmt.Errorf("delete node %q: %w", certname, err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go new file mode 100644 index 0000000..d05b425 --- /dev/null +++ b/internal/database/postgres.go @@ -0,0 +1,71 @@ +// Package database is the Postgres persistence layer for encapi. It stores +// three entities — statuses (Puppet environments), roles (class assignment +// targets with inheritable default params), and nodes (host-to-role +// assignments) — and enforces referential integrity between them. +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DB wraps a pgx connection pool. +type DB struct { + Pool *pgxpool.Pool +} + +// New connects to Postgres, verifies the connection, and runs migrations. +func New(dsn string) (*DB, error) { + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return nil, fmt.Errorf("connect to postgres: %w", err) + } + if err := pool.Ping(context.Background()); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + + db := &DB{Pool: pool} + if err := db.migrate(); err != nil { + pool.Close() + return nil, fmt.Errorf("run migrations: %w", err) + } + return db, nil +} + +// Close releases the pool. +func (db *DB) Close() { db.Pool.Close() } + +func (db *DB) migrate() error { + _, err := db.Pool.Exec(context.Background(), ` + CREATE TABLE IF NOT EXISTS statuses ( + name TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS roles ( + name TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + default_params JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS nodes ( + certname TEXT PRIMARY KEY, + role TEXT NOT NULL REFERENCES roles(name) ON UPDATE CASCADE, + environment TEXT NOT NULL REFERENCES statuses(name) ON UPDATE CASCADE, + params JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `) + if err != nil { + return err + } + return nil +} diff --git a/internal/database/roles.go b/internal/database/roles.go new file mode 100644 index 0000000..18fb3d3 --- /dev/null +++ b/internal/database/roles.go @@ -0,0 +1,110 @@ +package database + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "git.unkin.net/unkin/encapi/pkg/models" +) + +// UpsertRole creates or updates a role and its inheritable default params. +func (db *DB) UpsertRole(ctx context.Context, r *models.Role) error { + params, err := marshalParams(r.DefaultParams) + if err != nil { + return fmt.Errorf("marshal default_params for role %q: %w", r.Name, err) + } + _, err = db.Pool.Exec(ctx, ` + INSERT INTO roles (name, description, default_params) + VALUES ($1, $2, $3) + ON CONFLICT (name) DO UPDATE + SET description = EXCLUDED.description, + default_params = EXCLUDED.default_params, + updated_at = NOW() + `, r.Name, r.Description, params) + if err != nil { + return fmt.Errorf("upsert role %q: %w", r.Name, err) + } + return nil +} + +// GetRole returns a single role or ErrNotFound. +func (db *DB) GetRole(ctx context.Context, name string) (*models.Role, error) { + var ( + r models.Role + params []byte + ) + err := db.Pool.QueryRow(ctx, + `SELECT name, description, default_params FROM roles WHERE name = $1`, name, + ).Scan(&r.Name, &r.Description, ¶ms) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get role %q: %w", name, err) + } + if r.DefaultParams, err = unmarshalParams(params); err != nil { + return nil, fmt.Errorf("decode default_params for role %q: %w", name, err) + } + return &r, nil +} + +// ListRoles returns all roles ordered by name. +func (db *DB) ListRoles(ctx context.Context) ([]models.Role, error) { + rows, err := db.Pool.Query(ctx, `SELECT name, description, default_params FROM roles ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("list roles: %w", err) + } + defer rows.Close() + + out := []models.Role{} + for rows.Next() { + var ( + r models.Role + params []byte + ) + if err := rows.Scan(&r.Name, &r.Description, ¶ms); err != nil { + return nil, fmt.Errorf("scan role: %w", err) + } + if r.DefaultParams, err = unmarshalParams(params); err != nil { + return nil, fmt.Errorf("decode default_params: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// DeleteRole removes a role. It fails if any node still references it. +func (db *DB) DeleteRole(ctx context.Context, name string) error { + tag, err := db.Pool.Exec(ctx, `DELETE FROM roles WHERE name = $1`, name) + if err != nil { + return fmt.Errorf("delete role %q: %w", name, err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// marshalParams renders a params map to JSONB bytes, treating nil as {}. +func marshalParams(m map[string]any) ([]byte, error) { + if m == nil { + return []byte("{}"), nil + } + return json.Marshal(m) +} + +// unmarshalParams decodes JSONB bytes into a params map, treating empty as {}. +func unmarshalParams(b []byte) (map[string]any, error) { + m := map[string]any{} + if len(b) == 0 { + return m, nil + } + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} diff --git a/internal/database/statuses.go b/internal/database/statuses.go new file mode 100644 index 0000000..8767054 --- /dev/null +++ b/internal/database/statuses.go @@ -0,0 +1,75 @@ +package database + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "git.unkin.net/unkin/encapi/pkg/models" +) + +// ErrNotFound is returned when a requested entity does not exist. +var ErrNotFound = errors.New("not found") + +// UpsertStatus creates or updates a status (Puppet environment). +func (db *DB) UpsertStatus(ctx context.Context, s *models.Status) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO statuses (name, description) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE + SET description = EXCLUDED.description, updated_at = NOW() + `, s.Name, s.Description) + if err != nil { + return fmt.Errorf("upsert status %q: %w", s.Name, err) + } + return nil +} + +// GetStatus returns a single status or ErrNotFound. +func (db *DB) GetStatus(ctx context.Context, name string) (*models.Status, error) { + var s models.Status + err := db.Pool.QueryRow(ctx, + `SELECT name, description FROM statuses WHERE name = $1`, name, + ).Scan(&s.Name, &s.Description) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get status %q: %w", name, err) + } + return &s, nil +} + +// ListStatuses returns all statuses ordered by name. +func (db *DB) ListStatuses(ctx context.Context) ([]models.Status, error) { + rows, err := db.Pool.Query(ctx, `SELECT name, description FROM statuses ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("list statuses: %w", err) + } + defer rows.Close() + + out := []models.Status{} + for rows.Next() { + var s models.Status + if err := rows.Scan(&s.Name, &s.Description); err != nil { + return nil, fmt.Errorf("scan status: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} + +// DeleteStatus removes a status. It fails if any node still references it +// (enforced by the nodes.environment foreign key). +func (db *DB) DeleteStatus(ctx context.Context, name string) error { + tag, err := db.Pool.Exec(ctx, `DELETE FROM statuses WHERE name = $1`, name) + if err != nil { + return fmt.Errorf("delete status %q: %w", name, err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/distro/resolver.go b/internal/distro/resolver.go new file mode 100644 index 0000000..48459e0 --- /dev/null +++ b/internal/distro/resolver.go @@ -0,0 +1,86 @@ +// Package distro resolves per-host provisioning parameters (e.g. epel version, +// operating system release) from an external kickstart/distro API. +// +// This is the seam that will let encapi take over the provisioning-param half +// of Cobbler's old ENC output. Today those params (epel, tree, +// operatingsystemrelease, from_cobbler) are unused by the Puppet manifests, so +// the resolver is OFF by default: with no API configured, Resolve returns nil +// and no distro params are injected. +package distro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// Resolver returns provisioning parameters for a host, or nil if none apply. +type Resolver interface { + Resolve(ctx context.Context, certname string) (map[string]any, error) +} + +// Noop is the default resolver: it injects nothing. +type Noop struct{} + +// Resolve always returns nil. +func (Noop) Resolve(context.Context, string) (map[string]any, error) { return nil, nil } + +// HTTPResolver queries an external distro API of the form +// GET {BaseURL}/{certname} -> {"params": {...}} (or a bare JSON object). +type HTTPResolver struct { + BaseURL string + Client *http.Client +} + +// New returns a Noop resolver when baseURL is empty, otherwise an HTTPResolver. +func New(baseURL string) Resolver { + if baseURL == "" { + return Noop{} + } + return &HTTPResolver{ + BaseURL: baseURL, + Client: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Resolve fetches provisioning params for certname. A 404 means "no params for +// this host" and yields nil, nil rather than an error, so ENC rendering never +// fails just because a host is unknown to the distro API. +func (h *HTTPResolver) Resolve(ctx context.Context, certname string) (map[string]any, error) { + endpoint := fmt.Sprintf("%s/%s", h.BaseURL, url.PathEscape(certname)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + resp, err := h.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("distro api request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("distro api returned HTTP %d for %q", resp.StatusCode, certname) + } + + // Accept either {"params": {...}} or a bare {...} object. + var wrapper struct { + Params map[string]any `json:"params"` + } + dec := json.NewDecoder(resp.Body) + raw := map[string]any{} + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("decode distro api response: %w", err) + } + if p, ok := raw["params"].(map[string]any); ok { + wrapper.Params = p + } else { + wrapper.Params = raw + } + return wrapper.Params, nil +} diff --git a/internal/distro/resolver_test.go b/internal/distro/resolver_test.go new file mode 100644 index 0000000..d9b4586 --- /dev/null +++ b/internal/distro/resolver_test.go @@ -0,0 +1,74 @@ +package distro + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewReturnsNoopWhenEmpty(t *testing.T) { + if _, ok := New("").(Noop); !ok { + t.Fatal("New(\"\") should return Noop") + } + got, err := New("").Resolve(context.Background(), "host") + if err != nil || got != nil { + t.Errorf("Noop.Resolve = %v, %v; want nil, nil", got, err) + } +} + +func TestHTTPResolverWrappedParams(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/host.example" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"params":{"epel":"9","operatingsystemrelease":"9.6"}}`)) + })) + defer srv.Close() + + got, err := New(srv.URL).Resolve(context.Background(), "host.example") + if err != nil { + t.Fatal(err) + } + if got["epel"] != "9" || got["operatingsystemrelease"] != "9.6" { + t.Errorf("params = %#v", got) + } +} + +func TestHTTPResolverBareObject(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"epel":"8"}`)) + })) + defer srv.Close() + + got, err := New(srv.URL).Resolve(context.Background(), "h") + if err != nil { + t.Fatal(err) + } + if got["epel"] != "8" { + t.Errorf("params = %#v", got) + } +} + +func TestHTTPResolver404IsNil(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + got, err := New(srv.URL).Resolve(context.Background(), "unknown") + if err != nil || got != nil { + t.Errorf("got %v, %v; want nil, nil for 404", got, err) + } +} + +func TestHTTPResolverServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + if _, err := New(srv.URL).Resolve(context.Background(), "h"); err == nil { + t.Fatal("expected error on HTTP 500") + } +} diff --git a/internal/enc/render.go b/internal/enc/render.go new file mode 100644 index 0000000..0b02c97 --- /dev/null +++ b/internal/enc/render.go @@ -0,0 +1,82 @@ +// Package enc renders the External Node Classifier documents Puppet consumes. +// +// Two shapes are produced from the same node/role data: +// +// - Final(): the reshaped document the exec node_terminus expects — classes +// as a LIST, environment omitted when it equals "testing", and +// parameters carrying enc_role (list) + enc_env. This mirrors the old +// /opt/cobbler-enc/cobbler-enc wrapper output. +// - Cobbler(): the raw cobbler-wire form — classes as a MAP keyed by role, +// environment always present. This mirrors what +// cobbler's /cblr/svc/op/puppet/hostname/ returned, so the +// enc_direct_facts.rb fact (which reads classes.keys.first + environment) +// keeps working by only swapping its base URL. +package enc + +import ( + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/encapi/pkg/models" +) + +// TestingEnvironment is the sentinel environment that Puppet leaves implicit: +// when a node's environment is "testing" the key is dropped from ENC output so +// the agent falls back to its configured default environment. +const TestingEnvironment = "testing" + +// mergeParams builds the effective parameter set for a node. Precedence, +// lowest to highest: distro-provided params, role default params, node params. +// The returned map is always non-nil. +func mergeParams(node models.Node, role models.Role, distro map[string]any) map[string]any { + out := map[string]any{} + for k, v := range distro { + out[k] = v + } + for k, v := range role.DefaultParams { + out[k] = v + } + for k, v := range node.Params { + out[k] = v + } + return out +} + +// Final renders the reshaped ENC document (see package doc) as YAML. +func Final(node models.Node, role models.Role, distro map[string]any) ([]byte, error) { + params := mergeParams(node, role, distro) + // enc_role and enc_env are authoritative and computed; set them last so + // user params can never shadow them. + params["enc_role"] = []string{node.Role} + params["enc_env"] = node.Environment + + doc := map[string]any{ + "classes": []string{node.Role}, + "parameters": params, + } + if node.Environment != TestingEnvironment { + doc["environment"] = node.Environment + } + return yaml.Marshal(doc) +} + +// Cobbler renders the cobbler-wire-compatible ENC document as YAML. +func Cobbler(node models.Node, role models.Role, distro map[string]any) ([]byte, error) { + params := mergeParams(node, role, distro) + + // classes is a map keyed by role name; the value is the role's params so + // class-scoped parameters survive for callers that consume them. + classParams := map[string]any{} + for k, v := range role.DefaultParams { + classParams[k] = v + } + for k, v := range node.Params { + classParams[k] = v + } + + doc := map[string]any{ + "classes": map[string]any{node.Role: classParams}, + "environment": node.Environment, + "parameters": params, + } + return yaml.Marshal(doc) +} diff --git a/internal/enc/render_test.go b/internal/enc/render_test.go new file mode 100644 index 0000000..01477cf --- /dev/null +++ b/internal/enc/render_test.go @@ -0,0 +1,124 @@ +package enc + +import ( + "reflect" + "testing" + + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/encapi/pkg/models" +) + +func unmarshal(t *testing.T, b []byte) map[string]any { + t.Helper() + var m map[string]any + if err := yaml.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, b) + } + return m +} + +func TestFinalDropsTestingEnvironment(t *testing.T) { + out, err := Final(models.Node{Certname: "h", Role: "roles::infra::storage::vault", Environment: "testing"}, models.Role{Name: "roles::infra::storage::vault"}, nil) + if err != nil { + t.Fatal(err) + } + doc := unmarshal(t, out) + if _, ok := doc["environment"]; ok { + t.Error("environment must be omitted when testing") + } + classes, ok := doc["classes"].([]any) + if !ok || len(classes) != 1 || classes[0] != "roles::infra::storage::vault" { + t.Errorf("classes = %#v, want single-element list", doc["classes"]) + } + params := doc["parameters"].(map[string]any) + if params["enc_env"] != "testing" { + t.Errorf("enc_env = %v, want testing", params["enc_env"]) + } + encRole, _ := params["enc_role"].([]any) + if len(encRole) != 1 || encRole[0] != "roles::infra::storage::vault" { + t.Errorf("enc_role = %#v", params["enc_role"]) + } +} + +func TestFinalKeepsNonTestingEnvironment(t *testing.T) { + out, err := Final(models.Node{Certname: "h", Role: "roles::base", Environment: "production"}, models.Role{Name: "roles::base"}, nil) + if err != nil { + t.Fatal(err) + } + doc := unmarshal(t, out) + if doc["environment"] != "production" { + t.Errorf("environment = %v, want production", doc["environment"]) + } + if doc["parameters"].(map[string]any)["enc_env"] != "production" { + t.Error("enc_env should equal environment") + } +} + +func TestParamPrecedence(t *testing.T) { + // distro < role default < node param + node := models.Node{Certname: "h", Role: "r", Environment: "production", Params: map[string]any{"shared": "node", "only_node": 1}} + role := models.Role{Name: "r", DefaultParams: map[string]any{"shared": "role", "only_role": 2}} + distro := map[string]any{"shared": "distro", "only_distro": 3} + + out, err := Final(node, role, distro) + if err != nil { + t.Fatal(err) + } + params := unmarshal(t, out)["parameters"].(map[string]any) + if params["shared"] != "node" { + t.Errorf("shared = %v, want node (node param wins)", params["shared"]) + } + if params["only_role"] != 2 || params["only_distro"] != 3 || params["only_node"] != 1 { + t.Errorf("missing merged params: %#v", params) + } +} + +func TestReservedParamsCannotBeOverridden(t *testing.T) { + // A malicious/mistaken param must not shadow the computed enc_role/enc_env. + node := models.Node{Certname: "h", Role: "roles::real", Environment: "production", Params: map[string]any{"enc_role": []string{"roles::fake"}, "enc_env": "hacked"}} + out, err := Final(node, models.Role{Name: "roles::real"}, nil) + if err != nil { + t.Fatal(err) + } + params := unmarshal(t, out)["parameters"].(map[string]any) + if params["enc_env"] != "production" { + t.Errorf("enc_env = %v, computed value must win", params["enc_env"]) + } + encRole := params["enc_role"].([]any) + if encRole[0] != "roles::real" { + t.Errorf("enc_role = %#v, computed value must win", encRole) + } +} + +func TestCobblerShape(t *testing.T) { + node := models.Node{Certname: "h", Role: "roles::infra::storage::vault", Environment: "testing"} + role := models.Role{Name: "roles::infra::storage::vault", DefaultParams: map[string]any{"minio_pool": "pool1"}} + out, err := Cobbler(node, role, nil) + if err != nil { + t.Fatal(err) + } + doc := unmarshal(t, out) + // environment is ALWAYS present in cobbler-wire form, even for testing. + if doc["environment"] != "testing" { + t.Errorf("environment = %v, want testing (always present)", doc["environment"]) + } + // classes is a MAP keyed by role name, whose value carries class params. + classes, ok := doc["classes"].(map[string]any) + if !ok { + t.Fatalf("classes not a map: %#v", doc["classes"]) + } + cp, ok := classes["roles::infra::storage::vault"].(map[string]any) + if !ok { + t.Fatalf("missing role key in classes: %#v", classes) + } + if cp["minio_pool"] != "pool1" { + t.Errorf("class params = %#v, want minio_pool", cp) + } +} + +func TestMergeParamsNeverNil(t *testing.T) { + if got := mergeParams(models.Node{}, models.Role{}, nil); !reflect.DeepEqual(got, map[string]any{}) { + t.Errorf("mergeParams = %#v, want empty non-nil map", got) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..1d6313d --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,223 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/encapi/internal/database" + "git.unkin.net/unkin/encapi/internal/enc" + "git.unkin.net/unkin/encapi/pkg/models" +) + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func writeYAML(w http.ResponseWriter, b []byte) { + w.Header().Set("Content-Type", "application/x-yaml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) +} + +// mapErr translates store errors into HTTP status codes. +func mapErr(w http.ResponseWriter, err error) { + if errors.Is(err, database.ErrNotFound) { + writeError(w, http.StatusNotFound, "not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) +} + +// ---------- ENC ---------- + +// resolveNode loads a node, its role, and any distro params. +func (s *Server) resolveNode(w http.ResponseWriter, r *http.Request) (models.Node, models.Role, map[string]any, bool) { + certname := chi.URLParam(r, "certname") + node, err := s.store.GetNode(r.Context(), certname) + if err != nil { + mapErr(w, err) + return models.Node{}, models.Role{}, nil, false + } + role, err := s.store.GetRole(r.Context(), node.Role) + if err != nil { + if errors.Is(err, database.ErrNotFound) { + // A node pinned to a role that no longer exists: still classify it, + // just without default params, rather than 500. + role = &models.Role{Name: node.Role} + } else { + mapErr(w, err) + return models.Node{}, models.Role{}, nil, false + } + } + distroParams, err := s.resolver.Resolve(r.Context(), certname) + if err != nil { + writeError(w, http.StatusBadGateway, "distro resolver: "+err.Error()) + return models.Node{}, models.Role{}, nil, false + } + return *node, *role, distroParams, true +} + +func (s *Server) handleENCFinal(w http.ResponseWriter, r *http.Request) { + node, role, distroParams, ok := s.resolveNode(w, r) + if !ok { + return + } + out, err := enc.Final(node, role, distroParams) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeYAML(w, out) +} + +func (s *Server) handleENCCobbler(w http.ResponseWriter, r *http.Request) { + node, role, distroParams, ok := s.resolveNode(w, r) + if !ok { + return + } + out, err := enc.Cobbler(node, role, distroParams) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeYAML(w, out) +} + +// ---------- roles ---------- + +func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) { + roles, err := s.store.ListRoles(r.Context()) + if err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, roles) +} + +func (s *Server) getRole(w http.ResponseWriter, r *http.Request) { + role, err := s.store.GetRole(r.Context(), chi.URLParam(r, "name")) + if err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, role) +} + +func (s *Server) putRole(w http.ResponseWriter, r *http.Request) { + var role models.Role + if err := json.NewDecoder(r.Body).Decode(&role); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + role.Name = chi.URLParam(r, "name") + if err := s.store.UpsertRole(r.Context(), &role); err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, role) +} + +func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteRole(r.Context(), chi.URLParam(r, "name")); err != nil { + mapErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ---------- statuses ---------- + +func (s *Server) listStatuses(w http.ResponseWriter, r *http.Request) { + statuses, err := s.store.ListStatuses(r.Context()) + if err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, statuses) +} + +func (s *Server) getStatus(w http.ResponseWriter, r *http.Request) { + status, err := s.store.GetStatus(r.Context(), chi.URLParam(r, "name")) + if err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (s *Server) putStatus(w http.ResponseWriter, r *http.Request) { + var status models.Status + if err := json.NewDecoder(r.Body).Decode(&status); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + status.Name = chi.URLParam(r, "name") + if err := s.store.UpsertStatus(r.Context(), &status); err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (s *Server) deleteStatus(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteStatus(r.Context(), chi.URLParam(r, "name")); err != nil { + mapErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ---------- nodes ---------- + +func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) { + nodes, err := s.store.ListNodes(r.Context()) + if err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, nodes) +} + +func (s *Server) getNode(w http.ResponseWriter, r *http.Request) { + node, err := s.store.GetNode(r.Context(), chi.URLParam(r, "certname")) + if err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, node) +} + +func (s *Server) putNode(w http.ResponseWriter, r *http.Request) { + var node models.Node + if err := json.NewDecoder(r.Body).Decode(&node); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + node.Certname = chi.URLParam(r, "certname") + if node.Role == "" || node.Environment == "" { + writeError(w, http.StatusBadRequest, "role and environment are required") + return + } + if err := s.store.UpsertNode(r.Context(), &node); err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, node) +} + +func (s *Server) deleteNode(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteNode(r.Context(), chi.URLParam(r, "certname")); err != nil { + mapErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..243eb5d --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,56 @@ +package server + +import ( + "crypto/subtle" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// requireToken enforces a static bearer token on mutating endpoints. The token +// is accepted either as "Authorization: Bearer " or a bare "token" +// header. When no server token is configured, all writes are refused. +func (s *Server) requireToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.writeToken == "" { + writeError(w, http.StatusServiceUnavailable, "writes disabled: ENCAPI_WRITE_TOKEN not set") + return + } + presented := bearer(r) + if presented == "" || subtle.ConstantTimeCompare([]byte(presented), []byte(s.writeToken)) != 1 { + writeError(w, http.StatusUnauthorized, "invalid or missing write token") + return + } + next.ServeHTTP(w, r) + }) +} + +func bearer(r *http.Request) string { + if h := r.Header.Get("Authorization"); h != "" { + if after, ok := strings.CutPrefix(h, "Bearer "); ok { + return after + } + } + return r.Header.Get("token") +} + +func structuredLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + defer func() { + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "duration_ms", time.Since(start).Milliseconds(), + "remote", r.RemoteAddr, + "request_id", middleware.GetReqID(r.Context()), + ) + }() + next.ServeHTTP(ww, r) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6f2ebf4 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,109 @@ +// Package server exposes encapi over HTTP: open read endpoints (including the +// two ENC document shapes Puppet consumes) and token-guarded write endpoints. +package server + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "git.unkin.net/unkin/encapi/internal/distro" + "git.unkin.net/unkin/encapi/pkg/models" +) + +// Store is the persistence surface the HTTP handlers depend on. *database.DB +// satisfies it; tests supply a fake. +type Store interface { + UpsertRole(ctx context.Context, r *models.Role) error + GetRole(ctx context.Context, name string) (*models.Role, error) + ListRoles(ctx context.Context) ([]models.Role, error) + DeleteRole(ctx context.Context, name string) error + + UpsertStatus(ctx context.Context, s *models.Status) error + GetStatus(ctx context.Context, name string) (*models.Status, error) + ListStatuses(ctx context.Context) ([]models.Status, error) + DeleteStatus(ctx context.Context, name string) error + + UpsertNode(ctx context.Context, n *models.Node) error + GetNode(ctx context.Context, certname string) (*models.Node, error) + ListNodes(ctx context.Context) ([]models.Node, error) + DeleteNode(ctx context.Context, certname string) error +} + +// Server holds handler dependencies. +type Server struct { + store Store + resolver distro.Resolver + writeToken string +} + +// New builds a Server. writeToken guards mutating endpoints; an empty token +// fails all writes closed. +func New(store Store, resolver distro.Resolver, writeToken string) *Server { + if resolver == nil { + resolver = distro.Noop{} + } + return &Server{store: store, resolver: resolver, writeToken: writeToken} +} + +// Router returns the fully-wired HTTP handler. +func (s *Server) Router() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.Recoverer) + r.Use(structuredLogger) + + r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + + // --- ENC documents Puppet consumes (open) --- + r.Get("/api/v1/nodes/{certname}/enc", s.handleENCFinal) + r.Get("/cblr/svc/op/puppet/hostname/{certname}", s.handleENCCobbler) + + // --- JSON reads (open) --- + r.Get("/api/v1/roles", s.listRoles) + r.Get("/api/v1/roles/{name}", s.getRole) + r.Get("/api/v1/statuses", s.listStatuses) + r.Get("/api/v1/statuses/{name}", s.getStatus) + r.Get("/api/v1/nodes", s.listNodes) + r.Get("/api/v1/nodes/{certname}", s.getNode) + + // --- writes (token-guarded) --- + r.Group(func(r chi.Router) { + r.Use(s.requireToken) + r.Put("/api/v1/roles/{name}", s.putRole) + r.Delete("/api/v1/roles/{name}", s.deleteRole) + r.Put("/api/v1/statuses/{name}", s.putStatus) + r.Delete("/api/v1/statuses/{name}", s.deleteStatus) + r.Put("/api/v1/nodes/{certname}", s.putNode) + r.Delete("/api/v1/nodes/{certname}", s.deleteNode) + }) + + return r +} + +// ListenAndServe runs the HTTP server until ctx is cancelled. +func (s *Server) ListenAndServe(ctx context.Context, addr string) error { + srv := &http.Server{ + Addr: addr, + Handler: s.Router(), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + slog.Info("encapi listening", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + return nil +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..cfce54e --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,265 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/encapi/internal/database" + "git.unkin.net/unkin/encapi/pkg/models" +) + +// fakeStore is an in-memory Store for handler tests. +type fakeStore struct { + roles map[string]models.Role + statuses map[string]models.Status + nodes map[string]models.Node +} + +func newFake() *fakeStore { + return &fakeStore{ + roles: map[string]models.Role{}, + statuses: map[string]models.Status{}, + nodes: map[string]models.Node{}, + } +} + +func (f *fakeStore) UpsertRole(_ context.Context, r *models.Role) error { + f.roles[r.Name] = *r + return nil +} +func (f *fakeStore) GetRole(_ context.Context, name string) (*models.Role, error) { + r, ok := f.roles[name] + if !ok { + return nil, database.ErrNotFound + } + return &r, nil +} +func (f *fakeStore) ListRoles(context.Context) ([]models.Role, error) { + out := []models.Role{} + for _, r := range f.roles { + out = append(out, r) + } + return out, nil +} +func (f *fakeStore) DeleteRole(_ context.Context, name string) error { + if _, ok := f.roles[name]; !ok { + return database.ErrNotFound + } + delete(f.roles, name) + return nil +} +func (f *fakeStore) UpsertStatus(_ context.Context, s *models.Status) error { + f.statuses[s.Name] = *s + return nil +} +func (f *fakeStore) GetStatus(_ context.Context, name string) (*models.Status, error) { + s, ok := f.statuses[name] + if !ok { + return nil, database.ErrNotFound + } + return &s, nil +} +func (f *fakeStore) ListStatuses(context.Context) ([]models.Status, error) { + out := []models.Status{} + for _, s := range f.statuses { + out = append(out, s) + } + return out, nil +} +func (f *fakeStore) DeleteStatus(_ context.Context, name string) error { + if _, ok := f.statuses[name]; !ok { + return database.ErrNotFound + } + delete(f.statuses, name) + return nil +} +func (f *fakeStore) UpsertNode(_ context.Context, n *models.Node) error { + f.nodes[n.Certname] = *n + return nil +} +func (f *fakeStore) GetNode(_ context.Context, certname string) (*models.Node, error) { + n, ok := f.nodes[certname] + if !ok { + return nil, database.ErrNotFound + } + return &n, nil +} +func (f *fakeStore) ListNodes(context.Context) ([]models.Node, error) { + out := []models.Node{} + for _, n := range f.nodes { + out = append(out, n) + } + return out, nil +} +func (f *fakeStore) DeleteNode(_ context.Context, certname string) error { + if _, ok := f.nodes[certname]; !ok { + return database.ErrNotFound + } + delete(f.nodes, certname) + return nil +} + +func testServer() (*httptest.Server, *fakeStore) { + f := newFake() + f.statuses["testing"] = models.Status{Name: "testing"} + f.statuses["production"] = models.Status{Name: "production"} + f.roles["roles::infra::storage::vault"] = models.Role{Name: "roles::infra::storage::vault"} + f.nodes["h1.example"] = models.Node{Certname: "h1.example", Role: "roles::infra::storage::vault", Environment: "testing"} + srv := New(f, nil, "s3cret") + return httptest.NewServer(srv.Router()), f +} + +func TestReadsAreOpen(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + for _, path := range []string{"/healthz", "/api/v1/roles", "/api/v1/nodes", "/api/v1/statuses", "/api/v1/nodes/h1.example"} { + resp, err := http.Get(ts.URL + path) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("GET %s = %d, want 200", path, resp.StatusCode) + } + resp.Body.Close() + } +} + +func TestWriteRequiresToken(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/roles/roles::x", strings.NewReader(`{}`)) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no token = %d, want 401", resp.StatusCode) + } + resp.Body.Close() + + req, _ = http.NewRequest(http.MethodPut, ts.URL+"/api/v1/roles/roles::x", strings.NewReader(`{"description":"d"}`)) + req.Header.Set("Authorization", "Bearer s3cret") + resp, _ = http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusOK { + t.Fatalf("with token = %d, want 200", resp.StatusCode) + } + resp.Body.Close() +} + +func TestWriteBareTokenHeader(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/statuses/dev", strings.NewReader(`{"description":"d"}`)) + req.Header.Set("token", "s3cret") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusOK { + t.Fatalf("bare token header = %d, want 200", resp.StatusCode) + } + resp.Body.Close() +} + +func TestWritesDisabledWithoutServerToken(t *testing.T) { + f := newFake() + srv := New(f, nil, "") // no token configured + ts := httptest.NewServer(srv.Router()) + defer ts.Close() + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/roles/r", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer anything") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("= %d, want 503", resp.StatusCode) + } + resp.Body.Close() +} + +func TestENCFinalEndpoint(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + resp, err := http.Get(ts.URL + "/api/v1/nodes/h1.example/enc") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var doc map[string]any + if err := yaml.NewDecoder(resp.Body).Decode(&doc); err != nil { + t.Fatal(err) + } + if _, ok := doc["environment"]; ok { + t.Error("testing environment must be dropped in final ENC") + } + classes := doc["classes"].([]any) + if classes[0] != "roles::infra::storage::vault" { + t.Errorf("classes = %#v", classes) + } +} + +func TestENCCobblerEndpoint(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + resp, err := http.Get(ts.URL + "/cblr/svc/op/puppet/hostname/h1.example") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var doc map[string]any + if err := yaml.NewDecoder(resp.Body).Decode(&doc); err != nil { + t.Fatal(err) + } + if doc["environment"] != "testing" { + t.Errorf("cobbler form must keep environment, got %v", doc["environment"]) + } + if _, ok := doc["classes"].(map[string]any); !ok { + t.Errorf("cobbler classes must be a map, got %#v", doc["classes"]) + } +} + +func TestENCUnknownNode404(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + resp, _ := http.Get(ts.URL + "/api/v1/nodes/ghost/enc") + if resp.StatusCode != http.StatusNotFound { + t.Errorf("= %d, want 404", resp.StatusCode) + } + resp.Body.Close() +} + +func TestPutNodeValidation(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + // missing role/environment + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/nodes/h2", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer s3cret") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("= %d, want 400", resp.StatusCode) + } + resp.Body.Close() +} + +func TestPutAndGetNodeRoundTrip(t *testing.T) { + ts, _ := testServer() + defer ts.Close() + body := `{"role":"roles::infra::storage::vault","environment":"production","params":{"k":"v"}}` + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/nodes/h9", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer s3cret") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusOK { + t.Fatalf("put = %d", resp.StatusCode) + } + resp.Body.Close() + + resp, err := http.Get(ts.URL + "/api/v1/nodes/h9") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var n models.Node + _ = json.NewDecoder(resp.Body).Decode(&n) + if n.Role != "roles::infra::storage::vault" || n.Environment != "production" || n.Params["k"] != "v" { + t.Errorf("round trip node = %+v", n) + } +} diff --git a/internal/testsupport/containers.go b/internal/testsupport/containers.go new file mode 100644 index 0000000..acdf79c --- /dev/null +++ b/internal/testsupport/containers.go @@ -0,0 +1,47 @@ +// Package testsupport starts a throwaway Postgres container for +// integration-style unit tests. It is only imported from *_test.go files, so +// it never reaches the production binary. Tests skip themselves when Docker is +// unavailable. +package testsupport + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +func init() { + // The Ryuk reaper container cannot start in every environment; callers get + // an explicit terminate func for cleanup instead. + if _, ok := os.LookupEnv("TESTCONTAINERS_RYUK_DISABLED"); !ok { + _ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") + } +} + +// StartPostgres launches postgres:17-alpine and returns its DSN plus a +// terminate func. +func StartPostgres(ctx context.Context) (dsn string, terminate func(), err error) { + c, err := tcpostgres.Run(ctx, + "postgres:17-alpine", + tcpostgres.WithDatabase("encapi"), + tcpostgres.WithUsername("encapi"), + tcpostgres.WithPassword("encapi123"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + return "", nil, err + } + host, _ := c.Host(ctx) + port, _ := c.MappedPort(ctx, "5432/tcp") + dsn = fmt.Sprintf("postgres://encapi:encapi123@%s:%s/encapi?sslmode=disable", host, port.Port()) + return dsn, func() { _ = c.Terminate(ctx) }, nil +} diff --git a/packaging/enc.conf b/packaging/enc.conf new file mode 100644 index 0000000..470887b --- /dev/null +++ b/packaging/enc.conf @@ -0,0 +1,3 @@ +# encapi CLI configuration, sourced by /usr/local/bin/encapi-enc. +# Point this at the encapi server. Reads are unauthenticated. +ENCAPI_URL=https://encapi.k8s.syd1.au.unkin.net diff --git a/packaging/encapi-enc b/packaging/encapi-enc new file mode 100755 index 0000000..552cbd0 --- /dev/null +++ b/packaging/encapi-enc @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Puppet ENC entrypoint. Puppet's exec node_terminus invokes: +# external_nodes = /usr/local/bin/encapi-enc +# and calls it as `encapi-enc `. This shim forwards to +# `encapi-cli classify `, sourcing /etc/encapi/enc.conf for +# ENCAPI_URL (and, if reads were ever locked down, ENCAPI_WRITE_TOKEN). +set -euo pipefail + +[ -r /etc/encapi/enc.conf ] && . /etc/encapi/enc.conf + +exec /usr/local/bin/encapi-cli classify "$1" diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..54e4ea7 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,52 @@ +--- +# nfpm config for the encapi-cli RPM. +# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`. + +name: ${PACKAGE_NAME} +version: ${PACKAGE_VERSION} +release: ${PACKAGE_RELEASE} +arch: ${PACKAGE_ARCH} +platform: ${PACKAGE_PLATFORM} +section: default +priority: extra +description: "${PACKAGE_DESCRIPTION}" + +maintainer: ${PACKAGE_MAINTAINER} +homepage: ${PACKAGE_HOMEPAGE} +license: ${PACKAGE_LICENSE} + +disable_globbing: false + +replaces: + - encapi-cli +provides: + - encapi-cli + +contents: + # The CLI itself. + - src: dist/encapi-cli + dst: /usr/local/bin/encapi-cli + file_info: + mode: 0755 + owner: root + group: root + + # Puppet ENC entrypoint (external_nodes = /usr/local/bin/encapi-enc). + - src: packaging/encapi-enc + dst: /usr/local/bin/encapi-enc + file_info: + mode: 0755 + owner: root + group: root + + # Sample config; marked noreplace so local edits survive upgrades. + - src: packaging/enc.conf + dst: /etc/encapi/enc.conf + type: config|noreplace + file_info: + mode: 0644 + owner: root + group: root + +scripts: + preinstall: packaging/scripts/preinstall.sh diff --git a/packaging/scripts/preinstall.sh b/packaging/scripts/preinstall.sh new file mode 100755 index 0000000..74980c8 --- /dev/null +++ b/packaging/scripts/preinstall.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Ensure the config directory exists before files are laid down. +mkdir -p /etc/encapi diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..cbe6bcc --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,174 @@ +// Package client is a Go SDK for the encapi HTTP API. It is used by encapi-cli +// and can be vendored by other Go callers (e.g. the Terraform provider). +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "git.unkin.net/unkin/encapi/pkg/models" +) + +// Client talks to an encapi server. Token is required only for writes. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// New returns a Client for baseURL. Token may be empty for read-only use. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: baseURL, + Token: token, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// APIError is returned for non-2xx responses. +type APIError struct { + Status int + Msg string +} + +func (e *APIError) Error() string { return fmt.Sprintf("encapi: HTTP %d: %s", e.Status, e.Msg) } + +// NotFound reports whether err is a 404 from the API. +func NotFound(err error) bool { + var ae *APIError + if e, ok := err.(*APIError); ok { + ae = e + } + return ae != nil && ae.Status == http.StatusNotFound +} + +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + msg := decodeError(resp.Body) + return &APIError{Status: resp.StatusCode, Msg: msg} + } + if out != nil && resp.StatusCode != http.StatusNoContent { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +func decodeError(r io.Reader) string { + var e struct { + Error string `json:"error"` + } + if json.NewDecoder(r).Decode(&e) == nil && e.Error != "" { + return e.Error + } + return "request failed" +} + +// ENC fetches the reshaped ENC document (YAML) Puppet's exec terminus consumes. +func (c *Client) ENC(ctx context.Context, certname string) ([]byte, error) { + return c.getRaw(ctx, "/api/v1/nodes/"+url.PathEscape(certname)+"/enc") +} + +// ENCCobbler fetches the cobbler-wire-compatible ENC document (YAML). +func (c *Client) ENCCobbler(ctx context.Context, certname string) ([]byte, error) { + return c.getRaw(ctx, "/cblr/svc/op/puppet/hostname/"+url.PathEscape(certname)) +} + +func (c *Client) getRaw(ctx context.Context, path string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return nil, err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, &APIError{Status: resp.StatusCode, Msg: decodeError(resp.Body)} + } + return io.ReadAll(resp.Body) +} + +// --- roles --- + +func (c *Client) ListRoles(ctx context.Context) ([]models.Role, error) { + var out []models.Role + return out, c.do(ctx, http.MethodGet, "/api/v1/roles", nil, &out) +} +func (c *Client) GetRole(ctx context.Context, name string) (*models.Role, error) { + var out models.Role + return &out, c.do(ctx, http.MethodGet, "/api/v1/roles/"+url.PathEscape(name), nil, &out) +} +func (c *Client) PutRole(ctx context.Context, r *models.Role) (*models.Role, error) { + var out models.Role + return &out, c.do(ctx, http.MethodPut, "/api/v1/roles/"+url.PathEscape(r.Name), r, &out) +} +func (c *Client) DeleteRole(ctx context.Context, name string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/roles/"+url.PathEscape(name), nil, nil) +} + +// --- statuses --- + +func (c *Client) ListStatuses(ctx context.Context) ([]models.Status, error) { + var out []models.Status + return out, c.do(ctx, http.MethodGet, "/api/v1/statuses", nil, &out) +} +func (c *Client) GetStatus(ctx context.Context, name string) (*models.Status, error) { + var out models.Status + return &out, c.do(ctx, http.MethodGet, "/api/v1/statuses/"+url.PathEscape(name), nil, &out) +} +func (c *Client) PutStatus(ctx context.Context, s *models.Status) (*models.Status, error) { + var out models.Status + return &out, c.do(ctx, http.MethodPut, "/api/v1/statuses/"+url.PathEscape(s.Name), s, &out) +} +func (c *Client) DeleteStatus(ctx context.Context, name string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/statuses/"+url.PathEscape(name), nil, nil) +} + +// --- nodes --- + +func (c *Client) ListNodes(ctx context.Context) ([]models.Node, error) { + var out []models.Node + return out, c.do(ctx, http.MethodGet, "/api/v1/nodes", nil, &out) +} +func (c *Client) GetNode(ctx context.Context, certname string) (*models.Node, error) { + var out models.Node + return &out, c.do(ctx, http.MethodGet, "/api/v1/nodes/"+url.PathEscape(certname), nil, &out) +} +func (c *Client) PutNode(ctx context.Context, n *models.Node) (*models.Node, error) { + var out models.Node + return &out, c.do(ctx, http.MethodPut, "/api/v1/nodes/"+url.PathEscape(n.Certname), n, &out) +} +func (c *Client) DeleteNode(ctx context.Context, certname string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/nodes/"+url.PathEscape(certname), nil, nil) +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..c0e1b9b --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,77 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.unkin.net/unkin/encapi/pkg/models" +) + +func TestPutNodeSendsTokenAndBody(t *testing.T) { + var gotAuth, gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotMethod = r.Method + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"certname":"h1","role":"roles::base","environment":"testing"}`)) + })) + defer srv.Close() + + c := New(srv.URL, "tok") + n, err := c.PutNode(context.Background(), &models.Node{Certname: "h1", Role: "roles::base", Environment: "testing"}) + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer tok" { + t.Errorf("auth = %q", gotAuth) + } + if gotMethod != http.MethodPut || gotPath != "/api/v1/nodes/h1" { + t.Errorf("%s %s", gotMethod, gotPath) + } + if n.Role != "roles::base" { + t.Errorf("node = %+v", n) + } +} + +func TestGetRoleEscapesColons(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + _, _ = w.Write([]byte(`{"name":"roles::infra::x"}`)) + })) + defer srv.Close() + if _, err := New(srv.URL, "").GetRole(context.Background(), "roles::infra::x"); err != nil { + t.Fatal(err) + } + if gotPath != "/api/v1/roles/roles::infra::x" { + t.Errorf("path = %q", gotPath) + } +} + +func TestErrorMapping(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + })) + defer srv.Close() + _, err := New(srv.URL, "").GetNode(context.Background(), "ghost") + if err == nil || !NotFound(err) { + t.Fatalf("err = %v, want NotFound", err) + } +} + +func TestENCReturnsRawYAML(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("classes:\n- roles::base\n")) + })) + defer srv.Close() + b, err := New(srv.URL, "").ENC(context.Background(), "h1") + if err != nil { + t.Fatal(err) + } + if string(b) != "classes:\n- roles::base\n" { + t.Errorf("enc = %q", b) + } +} diff --git a/pkg/models/models.go b/pkg/models/models.go new file mode 100644 index 0000000..d969db5 --- /dev/null +++ b/pkg/models/models.go @@ -0,0 +1,29 @@ +// Package models holds the wire types shared between the encapi server, the +// encapi-cli client, and (via the generated client) the Terraform provider. +package models + +// Role is a Puppet class assignment target, e.g. "roles::infra::storage::vault". +// DefaultParams are inheritable parameters merged into every node that carries +// the role; a node's own params take precedence on key collisions. +type Role struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + DefaultParams map[string]any `json:"default_params,omitempty"` +} + +// Status is a Puppet environment (Cobbler calls these "status": testing, +// production, development, ...). The set of valid statuses is managed +// explicitly so a node can only be pinned to one that exists. +type Status struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// Node is a host-to-role assignment. Certname is the Puppet certname (fqdn). +// Params override the role's DefaultParams for this host only. +type Node struct { + Certname string `json:"certname"` + Role string `json:"role"` + Environment string `json:"environment"` + Params map[string]any `json:"params,omitempty"` +} diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..9efec26 --- /dev/null +++ b/scripts/build-rpm.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Package the (already built) encapi-cli binary into an RPM with nfpm. +# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" +VERSION="${VERSION#v}" # strip a leading v +DIST="dist" + +if [ ! -f "${DIST}/encapi-cli" ]; then + echo "ERROR: ${DIST}/encapi-cli not found; run 'make build-cli' first" >&2 + exit 1 +fi + +export PACKAGE_NAME="encapi-cli" +export PACKAGE_VERSION="${VERSION}" +export PACKAGE_RELEASE="1" +export PACKAGE_ARCH="amd64" +export PACKAGE_PLATFORM="linux" +export PACKAGE_DESCRIPTION="CLI + Puppet ENC entrypoint for encapi (External Node Classifier)" +export PACKAGE_MAINTAINER="Ben Vincent " +export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/encapi" +export PACKAGE_LICENSE="MIT" + +envsubst < packaging/nfpm.yaml > "${DIST}/nfpm.yaml" +nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm + +echo "Built:" +ls -1 "${DIST}"/*.rpm