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:
unkinben
2026-07-04 23:45:15 +10:00
parent b83d214e9a
commit 373d21a744
42 changed files with 3575 additions and 1 deletions
+149
View File
@@ -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
}
+257
View File
@@ -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")
}
}
+162
View File
@@ -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
}
+219
View File
@@ -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(&params, "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(&params, "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
}
+64
View File
@@ -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
}
+51
View File
@@ -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)
}
}
+165
View File
@@ -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)
}
}
+91
View File
@@ -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, &params)
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, &params); 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
}
+71
View File
@@ -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
}
+110
View File
@@ -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, &params)
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, &params); 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
}
+75
View File
@@ -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
}
+86
View File
@@ -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
}
+74
View File
@@ -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")
}
}
+82
View File
@@ -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)
}
+124
View File
@@ -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)
}
}
+223
View File
@@ -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)
}
+56
View File
@@ -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)
})
}
+109
View File
@@ -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
}
+265
View File
@@ -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)
}
}
+47
View File
@@ -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
}