Add catalog-zone-gen tool

Generate RFC 9432 DNS catalog zone files from a declarative input file.
Parses zone-to-catalog assignments with optional group and coo properties,
produces deterministic BIND-format output with automatic SOA serial
management and change detection.
This commit is contained in:
2026-02-28 16:13:58 -08:00
parent 5f230676d7
commit 1f2f39f40c
14 changed files with 1944 additions and 0 deletions

425
main_test.go Normal file
View File

@@ -0,0 +1,425 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestIntegrationEndToEnd(t *testing.T) {
dir := t.TempDir()
// Write config
configContent := `catalogs:
catalog1:
zone: catalog1.example.com.
catalog2:
zone: catalog2.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
`
writeTestFile(t, dir, "catz.yaml", configContent)
// Write input
inputContent := `# Test zones
zone.example.org catalog1, catalog2
zone.example.com catalog2, coo=old-catalog.example.com.
test.example.net catalog1, group=internal
app.example.org catalog1, group=external, coo=migrated.example.com.
`
writeTestFile(t, dir, "zones.txt", inputContent)
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg)
if err != nil {
t.Fatalf("parseInput: %v", err)
}
now := fixedTime(2026, 1, 15)
// First run: should create files
for catName, catMembers := range members {
changed, err := processCatalog(catName, cfg, catMembers, dir, now)
if err != nil {
t.Fatalf("processCatalog(%s): %v", catName, err)
}
if !changed {
t.Errorf("expected %s to be changed on first run", catName)
}
}
// Verify files exist
cat1Path := filepath.Join(dir, "catalog1.example.com.zone")
cat2Path := filepath.Join(dir, "catalog2.example.com.zone")
cat1Content := readTestFile(t, cat1Path)
cat2Content := readTestFile(t, cat2Path)
// Verify catalog1 content
assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tSOA")
assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tNS\tinvalid.")
assertContains(t, cat1Content, "version.catalog1.example.com.\t0\tIN\tTXT\t\"2\"")
assertContains(t, cat1Content, "IN\tPTR\tapp.example.org.")
assertContains(t, cat1Content, "IN\tPTR\ttest.example.net.")
assertContains(t, cat1Content, "IN\tPTR\tzone.example.org.")
assertContains(t, cat1Content, "IN\tTXT\t\"internal\"")
assertContains(t, cat1Content, "IN\tTXT\t\"external\"")
assertContains(t, cat1Content, "IN\tPTR\tmigrated.example.com.")
// Verify catalog2 content
assertContains(t, cat2Content, "catalog2.example.com.\t0\tIN\tSOA")
assertContains(t, cat2Content, "IN\tPTR\tzone.example.com.")
assertContains(t, cat2Content, "IN\tPTR\tzone.example.org.")
assertContains(t, cat2Content, "IN\tPTR\told-catalog.example.com.")
// Verify serial format
assertContains(t, cat1Content, "2026011501")
// Second run (same time): should be unchanged
for catName, catMembers := range members {
changed, err := processCatalog(catName, cfg, catMembers, dir, now)
if err != nil {
t.Fatalf("processCatalog(%s) second run: %v", catName, err)
}
if changed {
t.Errorf("expected %s to be unchanged on second run", catName)
}
}
// Verify content hasn't changed
cat1After := readTestFile(t, cat1Path)
if cat1After != cat1Content {
t.Error("catalog1 content changed on idempotent run")
}
}
func TestIntegrationSerialBump(t *testing.T) {
dir := t.TempDir()
configContent := `catalogs:
cat1:
zone: cat1.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
`
writeTestFile(t, dir, "catz.yaml", configContent)
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
if err != nil {
t.Fatal(err)
}
members1 := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
}
now := fixedTime(2026, 1, 15)
// First run
changed, err := processCatalog("cat1", cfg, members1, dir, now)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Error("expected changed on first run")
}
content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
assertContains(t, content1, "2026011501")
// Second run with different input (same day)
members2 := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
}
changed, err = processCatalog("cat1", cfg, members2, dir, now)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Error("expected changed when input differs")
}
content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
assertContains(t, content2, "2026011502")
// Third run with same input: unchanged
changed, err = processCatalog("cat1", cfg, members2, dir, now)
if err != nil {
t.Fatal(err)
}
if changed {
t.Error("expected unchanged on third run")
}
// Fourth run next day with same input: new serial
nextDay := fixedTime(2026, 1, 16)
changed, err = processCatalog("cat1", cfg, members2, dir, nextDay)
if err != nil {
t.Fatal(err)
}
// Content hasn't changed, so this should be unchanged even though date differs
if changed {
t.Error("expected unchanged when content hasn't changed")
}
}
func TestIntegrationSortOrder(t *testing.T) {
dir := t.TempDir()
configContent := `catalogs:
cat1:
zone: cat1.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
`
writeTestFile(t, dir, "catz.yaml", configContent)
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
if err != nil {
t.Fatal(err)
}
// Input in reverse order
members := []ZoneEntry{
{Zone: "z.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
{Zone: "m.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3},
}
now := fixedTime(2026, 1, 15)
_, err = processCatalog("cat1", cfg, members, dir, now)
if err != nil {
t.Fatal(err)
}
content := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
lines := splitLines(content)
// Find PTR lines and verify order
var ptrZones []string
for _, line := range lines {
if containsStr(line, "\tPTR\t") && !containsStr(line, "coo.") {
// Extract the PTR target
parts := strings.Split(line, "\t")
ptrZones = append(ptrZones, parts[len(parts)-1])
}
}
if len(ptrZones) != 3 {
t.Fatalf("expected 3 PTR records, got %d", len(ptrZones))
}
if ptrZones[0] != "a.example.com." || ptrZones[1] != "m.example.com." || ptrZones[2] != "z.example.com." {
t.Errorf("PTR records not sorted: %v", ptrZones)
}
}
func TestIntegrationCLI(t *testing.T) {
// Build the binary
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
cmd := exec.Command("go", "build", "-o", binary, ".")
cmd.Dir = projectDir(t)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go build failed: %v\n%s", err, out)
}
dir := t.TempDir()
configContent := `catalogs:
catalog1:
zone: catalog1.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
`
writeTestFile(t, dir, "catz.yaml", configContent)
inputContent := "a.example.com. catalog1\n"
writeTestFile(t, dir, "zones.txt", inputContent)
// Run the binary
cmd = exec.Command(binary, "--config", filepath.Join(dir, "catz.yaml"), "--output-dir", dir, filepath.Join(dir, "zones.txt"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out)
}
// Verify output file exists
zonePath := filepath.Join(dir, "catalog1.example.com.zone")
if _, err := os.Stat(zonePath); err != nil {
t.Fatalf("output file not created: %v", err)
}
content := readTestFile(t, zonePath)
assertContains(t, content, "catalog1.example.com.")
assertContains(t, content, "a.example.com.")
}
func TestIntegrationCLIErrors(t *testing.T) {
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
cmd := exec.Command("go", "build", "-o", binary, ".")
cmd.Dir = projectDir(t)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go build failed: %v\n%s", err, out)
}
t.Run("no arguments", func(t *testing.T) {
cmd := exec.Command(binary)
err := cmd.Run()
if err == nil {
t.Error("expected error with no arguments")
}
})
t.Run("missing input file", func(t *testing.T) {
cmd := exec.Command(binary, "/nonexistent/zones.txt")
err := cmd.Run()
if err == nil {
t.Error("expected error with missing input file")
}
})
t.Run("unknown catalog in input", func(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, dir, "catz.yaml", `catalogs:
cat1:
zone: cat1.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
`)
writeTestFile(t, dir, "zones.txt", "a.example.com. unknown_catalog\n")
cmd := exec.Command(binary,
"--config", filepath.Join(dir, "catz.yaml"),
"--output-dir", dir,
filepath.Join(dir, "zones.txt"))
out, err := cmd.CombinedOutput()
if err == nil {
t.Error("expected error for unknown catalog")
}
if !containsStr(string(out), "unknown catalog") {
t.Errorf("expected 'unknown catalog' in error output, got: %s", out)
}
})
}
func writeTestFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func projectDir(t *testing.T) string {
t.Helper()
// Find the project root by looking for go.mod
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find project root (go.mod)")
}
dir = parent
}
}
// Verify the serial stays the same when the same time is used but content changes on a new day.
func TestIntegrationSerialNewDay(t *testing.T) {
dir := t.TempDir()
configContent := `catalogs:
cat1:
zone: cat1.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
`
writeTestFile(t, dir, "catz.yaml", configContent)
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
if err != nil {
t.Fatal(err)
}
members := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
}
day1 := fixedTime(2026, 1, 15)
_, err = processCatalog("cat1", cfg, members, dir, day1)
if err != nil {
t.Fatal(err)
}
content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
assertContains(t, content1, "2026011501")
// Add a zone on day 2
members2 := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
}
day2 := fixedTime(2026, 1, 16)
changed, err := processCatalog("cat1", cfg, members2, dir, day2)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Error("expected changed with new zone")
}
content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
assertContains(t, content2, "2026011601")
// Simulate not using the tool for a while, running on a much later date
day3 := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
members3 := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
{Zone: "c.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3},
}
changed, err = processCatalog("cat1", cfg, members3, dir, day3)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Error("expected changed with new zone")
}
content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
assertContains(t, content3, "2026061501")
}