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
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/bin/
|
||||
/dist/
|
||||
*.rpm
|
||||
*.zip
|
||||
encapi
|
||||
encapi-cli
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+21
@@ -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"]
|
||||
@@ -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
|
||||
@@ -1,3 +1,99 @@
|
||||
# encapi
|
||||
|
||||
Postgres-backed External Node Classifier (ENC) for Puppet, replacing Cobbler. Go API + encapi-cli.
|
||||
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 <host>` 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`.
|
||||
|
||||
@@ -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:
|
||||
@@ -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/<host>`:
|
||||
|
||||
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 <certname>` runs `encapi-cli classify <certname>`, which returns the
|
||||
identical reshaped document (`GET /api/v1/nodes/<certname>/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/<host>` 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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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 <certname> print the ENC document Puppet consumes
|
||||
encapi-cli node list
|
||||
encapi-cli node get <certname>
|
||||
encapi-cli node set <certname> --role <r> --env <e> [--param k=v ...]
|
||||
encapi-cli node delete <certname>
|
||||
encapi-cli role list
|
||||
encapi-cli role get <name>
|
||||
encapi-cli role set <name> [--desc <d>] [--param k=v ...]
|
||||
encapi-cli role delete <name>
|
||||
encapi-cli status list
|
||||
encapi-cli status get <name>
|
||||
encapi-cli status set <name> [--desc <d>]
|
||||
encapi-cli status delete <name>
|
||||
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 <certname>")
|
||||
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 <name> --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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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/<host>.
|
||||
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
|
||||
}
|
||||
@@ -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 <list|get|set|delete> ...")
|
||||
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 <certname>")
|
||||
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 <certname>")
|
||||
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 <certname> --role <r> --env <e> [--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 <certname> --role <r> --env <e> [--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 <list|get|set|delete> ...")
|
||||
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 <name>")
|
||||
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 <name>")
|
||||
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 <name> [--desc <d>] [--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 <list|get|set|delete> ...")
|
||||
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 <name>")
|
||||
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 <name>")
|
||||
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 <name> [--desc <d>]")
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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/<host> 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <token>" 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Executable
+12
@@ -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 <certname>`. This shim forwards to
|
||||
# `encapi-cli classify <certname>`, 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"
|
||||
@@ -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
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ensure the config directory exists before files are laid down.
|
||||
mkdir -p /etc/encapi
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Executable
+34
@@ -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 <ben@unkin.net>"
|
||||
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
|
||||
Reference in New Issue
Block a user