8f356346eb
Implements the six review comments on PR #1: - Per-host PXE-enable gate: read NetBox pxe_enabled custom field; a known host with it false gets the safe local-boot script (Cobbler netboot_enabled). Add a token-guarded POST /provisioned/{ident} callback that clears pxe_enabled in NetBox, plus a %post snippet in the default kickstarts that calls it. - Templates from a git repo: bootapi clones a templates repo and re-pulls every BOOTAPI_TEMPLATE_GIT_INTERVAL (default 3m), atomically swapping the template set (last-good kept on parse failure; embedded defaults are the startup fallback). Metrics for syncs/failures/generation. - Distro catalog (catalog/*.yaml): NetBox host -> boot images/kickstart, so adding an OS is a YAML + template change. Ships almalinux + fedora entries (artifactapi remotes); debian/talos path documented. - Boot images from the artifactapi almalinux/fedora remotes via the catalog. - Bind resolvers, puppet server/CA and PUPPETCA_URL env file now target the k8s services (198.18.200.7; puppet(ca).k8s.syd1.au.unkin.net). - Boot path served over plain HTTP (installers lack CA trust) with an optional parallel HTTPS listener; docs say do not 301 the boot endpoints. New packages: internal/catalog, internal/gitsync. NetBox client gains a pxe_enabled write (token needs that scope - noted in docs). `bootapi validate` subcommand validates a template/catalog set for the templates-repo CI. go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
143 lines
4.4 KiB
Go
143 lines
4.4 KiB
Go
package gitsync
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/bootapi/internal/model"
|
|
"git.unkin.net/unkin/bootapi/internal/render"
|
|
"git.unkin.net/unkin/bootapi/templates"
|
|
)
|
|
|
|
// gitRepo creates a real git repo at dir with an initial almalinux9 override.
|
|
func gitRepo(t *testing.T, dir string) {
|
|
t.Helper()
|
|
gitCmd(t, "", "git", "init", "-b", "main", dir)
|
|
gitCmd(t, dir, "git", "config", "user.email", "t@example.net")
|
|
gitCmd(t, dir, "git", "config", "user.name", "test")
|
|
writeKS(t, dir, "GITSYNC-V1 {{ .Hostname }}\n")
|
|
gitCmd(t, dir, "git", "add", "-A")
|
|
gitCmd(t, dir, "git", "commit", "-m", "v1")
|
|
}
|
|
|
|
func writeKS(t *testing.T, dir, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func gitCmd(t *testing.T, dir, name string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command(name, args...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("%s %v: %v: %s", name, args, err, out)
|
|
}
|
|
}
|
|
|
|
func renderKS(t *testing.T, e *render.Engine) string {
|
|
t.Helper()
|
|
h := &model.Host{Hostname: "web01", Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64"}
|
|
out, _, err := e.RenderKickstart(h)
|
|
if err != nil {
|
|
t.Fatalf("RenderKickstart: %v", err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func TestBootstrapAndReload(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not available")
|
|
}
|
|
src := t.TempDir()
|
|
gitRepo(t, src)
|
|
|
|
s := New(Options{URL: src, Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
|
|
set, err := s.Bootstrap(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Bootstrap: %v", err)
|
|
}
|
|
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
|
|
s.SetEngine(eng)
|
|
|
|
if got := renderKS(t, eng); !contains(got, "GITSYNC-V1 web01") {
|
|
t.Fatalf("initial render missing v1 override:\n%s", got)
|
|
}
|
|
gen1 := s.Generation()
|
|
|
|
// Commit v2 upstream, then poll: the engine must swap to the new content.
|
|
writeKS(t, src, "GITSYNC-V2 {{ .Hostname }}\n")
|
|
gitCmd(t, src, "git", "add", "-A")
|
|
gitCmd(t, src, "git", "commit", "-m", "v2")
|
|
|
|
s.pollOnce(context.Background())
|
|
if got := renderKS(t, eng); !contains(got, "GITSYNC-V2 web01") {
|
|
t.Fatalf("after reload, render missing v2:\n%s", got)
|
|
}
|
|
if s.Generation() <= gen1 {
|
|
t.Errorf("generation did not advance: %d <= %d", s.Generation(), gen1)
|
|
}
|
|
if s.Syncs() != 1 {
|
|
t.Errorf("syncs = %d, want 1", s.Syncs())
|
|
}
|
|
}
|
|
|
|
func TestReloadKeepsLastGoodOnParseError(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not available")
|
|
}
|
|
src := t.TempDir()
|
|
gitRepo(t, src)
|
|
|
|
s := New(Options{URL: src, Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
|
|
set, err := s.Bootstrap(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
|
|
s.SetEngine(eng)
|
|
|
|
// Push a template that fails to parse.
|
|
writeKS(t, src, "BROKEN {{ .Hostname \n")
|
|
gitCmd(t, src, "git", "add", "-A")
|
|
gitCmd(t, src, "git", "commit", "-m", "broken")
|
|
|
|
s.pollOnce(context.Background())
|
|
|
|
// The last-good v1 set must still be served, and a failure recorded.
|
|
if got := renderKS(t, eng); !contains(got, "GITSYNC-V1 web01") {
|
|
t.Fatalf("last-good not kept after parse failure:\n%s", got)
|
|
}
|
|
if s.Failures() != 1 {
|
|
t.Errorf("failures = %d, want 1", s.Failures())
|
|
}
|
|
if s.Syncs() != 0 {
|
|
t.Errorf("syncs = %d, want 0 (bad push must not count as a sync)", s.Syncs())
|
|
}
|
|
}
|
|
|
|
func TestBootstrapDegradesToEmbedded(t *testing.T) {
|
|
// A bogus URL must not fail startup: Bootstrap returns the embedded set.
|
|
s := New(Options{URL: "/nonexistent/repo", Branch: "main", Interval: time.Hour, WorkDir: filepath.Join(t.TempDir(), "co")}, templates.FS)
|
|
set, err := s.Bootstrap(context.Background())
|
|
if err == nil {
|
|
t.Error("expected a non-nil (non-fatal) error describing the degrade")
|
|
}
|
|
if set == nil {
|
|
t.Fatal("expected the embedded fallback Set, got nil")
|
|
}
|
|
eng := render.NewEngine(render.RenderConfig{DefaultTemplate: "almalinux9", ArtifactBase: "https://af"}, set)
|
|
// Embedded almalinux9 template still renders.
|
|
if got := renderKS(t, eng); !contains(got, "rootpw") {
|
|
t.Errorf("embedded fallback did not render a real kickstart:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|