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,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
|
||||
}
|
||||
Reference in New Issue
Block a user