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

166
README.md Normal file
View File

@@ -0,0 +1,166 @@
# catalog-zone-gen
Generate RFC 9432 DNS catalog zone files from a declarative input file.
Given a list of zones with their catalog assignments and optional properties,
this tool produces one BIND-format zone file per catalog. Output is
deterministic: re-running with unchanged input produces unchanged output (no
serial bump, no file write).
## Installation
```
go install catalog-zone-gen@latest
```
Or build from source:
```
go build -o catalog-zone-gen .
```
## Usage
```
catalog-zone-gen [--config path] [--output-dir path] <input-file>
```
**Flags:**
- `--config` — path to YAML config file (default: `catz.yaml` next to the input file)
- `--output-dir` — directory for output zone files (default: same directory as the input file)
## Configuration File
The config file (default `catz.yaml`) defines catalog zone names and SOA parameters.
```yaml
catalogs:
catalog1:
zone: catalog1.example.com.
catalog2:
zone: catalog2.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
```
### Fields
| Field | Required | Description |
|-------|----------|-------------|
| `catalogs` | yes | Map of catalog names to their zone FQDNs. Names are used as references in the input file. |
| `catalogs.<name>.zone` | yes | The FQDN of the catalog zone (trailing dot optional, will be normalized). |
| `soa.mname` | yes | Primary nameserver for the SOA record. |
| `soa.rname` | yes | Responsible person email (in DNS format: `hostmaster.example.com.`). |
SOA timing values are hardcoded: refresh=900, retry=600, expire=2147483646, minimum=0.
The NS record is hardcoded to `invalid.` per RFC 9432.
## Input File Format
Whitespace and comma delimited. Lines starting with `#` are comments.
Blank lines are ignored.
```
<zone-name> <catalog>[, <catalog>...] [, group=<value>] [, coo=<fqdn>]
```
### Fields
| Position | Format | Description |
|----------|--------|-------------|
| First token | FQDN | Zone name (trailing dot optional, normalized internally). |
| Bare names | identifier | Catalog assignments — must match a key in the config `catalogs` map. At least one required. |
| `group=<value>` | key=value | RFC 9432 group property. Tells consumers to apply shared configuration to grouped zones. |
| `coo=<fqdn>` | key=value | RFC 9432 change-of-ownership property. Points to the old catalog zone during migration. |
A zone can appear in multiple catalogs (for distributing to different server groups).
### Example
```
# Production 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.
```
Whitespace and comma placement is flexible. These are all equivalent:
```
zone.example.org catalog1,catalog2
zone.example.org catalog1 , catalog2
zone.example.org catalog1, catalog2
```
## Output
One BIND-format zone file per catalog, written to the output directory.
**Filename:** `<catalog-zone-fqdn>.zone` (e.g., `catalog1.example.com.zone`)
**Example output:**
```
catalog1.example.com. 0 IN SOA ns1.example.com. hostmaster.example.com. 2026030201 900 600 2147483646 0
catalog1.example.com. 0 IN NS invalid.
version.catalog1.example.com. 0 IN TXT "2"
grfen8g.zones.catalog1.example.com. 0 IN PTR app.example.org.
group.grfen8g.zones.catalog1.example.com. 0 IN TXT "external"
coo.grfen8g.zones.catalog1.example.com. 0 IN PTR migrated.example.com.
2qvgcfg.zones.catalog1.example.com. 0 IN PTR test.example.net.
group.2qvgcfg.zones.catalog1.example.com. 0 IN TXT "internal"
1860l9o.zones.catalog1.example.com. 0 IN PTR zone.example.org.
```
### Record order
1. SOA record
2. NS record (`invalid.`)
3. Version TXT record (`"2"`)
4. Member zones sorted alphabetically by zone name, each with:
- PTR record (member zone)
- Group TXT record (if `group=` set)
- COO PTR record (if `coo=` set)
All records use TTL 0 and class IN. Fully-qualified owner names; no `$ORIGIN` or `$TTL` directives.
### Member zone hashing
Member zone labels are generated by FNV-1a 32-bit hashing the normalized zone
name, then encoding as lowercase base32hex without padding. This produces
compact labels like `grfen8g`.
Changing tools will likely produce different hash labels, which is intentional
per RFC 9432 Section 5.4 — it triggers a reconfig event on consumers.
### SOA serial
Format: `YYYYMMDDNN` where NN is a sequence number (01-99).
- New zone files start at `YYYYMMDD01`.
- On subsequent runs, the tool generates output with the existing serial and
compares bytes. If unchanged, no write occurs.
- If content differs, the serial is incremented (same date bumps NN; new date
resets to `YYYYMMDD01`).
- If NN reaches 99 on the same date: the tool errors.
## Validation
The tool validates input and reports errors with file location:
```
error: zones.txt:3: unknown catalog "bogus"
error: zones.txt:5: zone example.com. already assigned to catalog "catalog1" at line 2
```
**Checked conditions:**
- Unknown catalog name (not in config) — error
- Same zone assigned to the same catalog more than once — error
- Hash collision (two zone names produce the same hash within a catalog) — error
- Missing required config fields — error
- Unknown properties (anything other than `group` and `coo`) — error

249
catalog.go Normal file
View File

@@ -0,0 +1,249 @@
package main
import (
"encoding/base32"
"fmt"
"hash/fnv"
"os"
"sort"
"strings"
"time"
"codeberg.org/miekg/dns"
)
// base32hex encoding, lowercase, no padding
var b32 = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding)
// Hardcoded SOA timing values
const (
soaRefresh = 900
soaRetry = 600
soaExpire = 2147483646
soaMinimum = 0
)
// hashZoneName computes the FNV-1a 32-bit hash of a normalized zone name
// and returns it as a lowercase base32hex string (no padding).
func hashZoneName(zone string) string {
h := fnv.New32a()
h.Write([]byte(zone))
sum := h.Sum32()
b := make([]byte, 4)
b[0] = byte(sum >> 24)
b[1] = byte(sum >> 16)
b[2] = byte(sum >> 8)
b[3] = byte(sum)
return b32.EncodeToString(b)
}
// generateCatalogZone builds the zone file content for a single catalog.
func generateCatalogZone(catName string, cfg *Config, members []ZoneEntry, serial uint32) (string, error) {
catCfg := cfg.Catalogs[catName]
origin := catCfg.Zone
var records []string
// SOA
soaStr := fmt.Sprintf("%s 0 IN SOA %s %s %d %d %d %d %d",
origin, cfg.SOA.Mname, cfg.SOA.Rname, serial, soaRefresh, soaRetry, soaExpire, soaMinimum)
soaRR, err := dns.New(soaStr)
if err != nil {
return "", fmt.Errorf("building SOA: %w", err)
}
records = append(records, soaRR.String())
// NS
nsStr := fmt.Sprintf("%s 0 IN NS invalid.", origin)
nsRR, err := dns.New(nsStr)
if err != nil {
return "", fmt.Errorf("building NS: %w", err)
}
records = append(records, nsRR.String())
// Version TXT
verStr := fmt.Sprintf("version.%s 0 IN TXT \"2\"", origin)
verRR, err := dns.New(verStr)
if err != nil {
return "", fmt.Errorf("building version TXT: %w", err)
}
records = append(records, verRR.String())
// Sort members alphabetically by normalized zone name
sorted := make([]ZoneEntry, len(members))
copy(sorted, members)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Zone < sorted[j].Zone
})
// Check for hash collisions
hashToZone := make(map[string]string)
for _, entry := range sorted {
h := hashZoneName(entry.Zone)
if existing, ok := hashToZone[h]; ok && existing != entry.Zone {
return "", fmt.Errorf("error: %s:%d: hash collision between %s and %s in catalog %q",
entry.File, entry.Line, existing, entry.Zone, catName)
}
hashToZone[h] = entry.Zone
}
// Member records
for _, entry := range sorted {
h := hashZoneName(entry.Zone)
// PTR record
ptrOwner := fmt.Sprintf("%s.zones.%s", h, origin)
ptrStr := fmt.Sprintf("%s 0 IN PTR %s", ptrOwner, entry.Zone)
ptrRR, err := dns.New(ptrStr)
if err != nil {
return "", fmt.Errorf("building PTR for %s: %w", entry.Zone, err)
}
records = append(records, ptrRR.String())
// Group TXT (optional)
if entry.Group != "" {
groupOwner := fmt.Sprintf("group.%s.zones.%s", h, origin)
groupStr := fmt.Sprintf("%s 0 IN TXT \"%s\"", groupOwner, entry.Group)
groupRR, err := dns.New(groupStr)
if err != nil {
return "", fmt.Errorf("building group TXT for %s: %w", entry.Zone, err)
}
records = append(records, groupRR.String())
}
// COO PTR (optional)
if entry.COO != "" {
cooOwner := fmt.Sprintf("coo.%s.zones.%s", h, origin)
cooStr := fmt.Sprintf("%s 0 IN PTR %s", cooOwner, entry.COO)
cooRR, err := dns.New(cooStr)
if err != nil {
return "", fmt.Errorf("building coo PTR for %s: %w", entry.Zone, err)
}
records = append(records, cooRR.String())
}
}
return strings.Join(records, "\n") + "\n", nil
}
// readExistingSerial reads an existing zone file and extracts the SOA serial.
// Returns 0, nil if the file doesn't exist.
func readExistingSerial(path string) (uint32, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("reading existing zone %s: %w", path, err)
}
defer f.Close()
zp := dns.NewZoneParser(f, "", path)
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
if soa, ok := rr.(*dns.SOA); ok {
return soa.Serial, nil
}
}
if err := zp.Err(); err != nil {
return 0, fmt.Errorf("parsing existing zone %s: %w", path, err)
}
return 0, nil
}
// readExistingContent reads the full content of an existing zone file.
// Returns empty string if file doesn't exist.
func readExistingContent(path string) (string, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", err
}
return string(data), nil
}
// defaultSerial returns a serial for today with sequence 01: YYYYMMDD01.
func defaultSerial(now time.Time) uint32 {
return uint32(now.Year())*1000000 +
uint32(now.Month())*10000 +
uint32(now.Day())*100 + 1
}
// bumpSerial increments a serial. If same date, bumps the sequence number.
// If different date, starts at YYYYMMDD01.
// Returns error if sequence reaches 99 and needs another bump.
func bumpSerial(old uint32, now time.Time) (uint32, error) {
todayBase := uint32(now.Year())*1000000 +
uint32(now.Month())*10000 +
uint32(now.Day())*100
if old >= todayBase && old < todayBase+100 {
// Same date, bump sequence
seq := old - todayBase
if seq >= 99 {
return 0, fmt.Errorf("serial overflow: already at %d, cannot bump further on the same date", old)
}
return old + 1, nil
}
// Different date or old serial doesn't follow our format
return todayBase + 1, nil
}
// processCatalog handles generating and writing a single catalog zone file.
// Returns true if the file was written (changed), false if unchanged.
func processCatalog(catName string, cfg *Config, members []ZoneEntry, outputDir string, now time.Time) (bool, error) {
catCfg := cfg.Catalogs[catName]
outputPath := outputDir + "/" + catCfg.Zone + "zone"
// Read existing serial
oldSerial, err := readExistingSerial(outputPath)
if err != nil {
return false, err
}
// Use old serial or default
serial := oldSerial
if serial == 0 {
serial = defaultSerial(now)
}
// Generate with current serial
content, err := generateCatalogZone(catName, cfg, members, serial)
if err != nil {
return false, err
}
// Compare with existing file
existing, err := readExistingContent(outputPath)
if err != nil {
return false, err
}
if content == existing {
return false, nil
}
// Content changed — bump serial only if we had an existing file.
// For new files, the default serial is already correct.
if oldSerial != 0 {
newSerial, err := bumpSerial(serial, now)
if err != nil {
return false, err
}
if newSerial != serial {
content, err = generateCatalogZone(catName, cfg, members, newSerial)
if err != nil {
return false, err
}
}
}
if err := os.WriteFile(outputPath, []byte(content), 0o644); err != nil {
return false, fmt.Errorf("writing zone file %s: %w", outputPath, err)
}
return true, nil
}

332
catalog_test.go Normal file
View File

@@ -0,0 +1,332 @@
package main
import (
"testing"
)
func TestHashZoneName(t *testing.T) {
tests := []struct {
zone string
want string
}{
{"example.com.", "lq2vr60"},
{"example.org.", "an5oaj8"},
// Same input should always produce same output
{"example.com.", "lq2vr60"},
}
for _, tt := range tests {
got := hashZoneName(tt.zone)
if got != tt.want {
t.Errorf("hashZoneName(%q) = %q, want %q", tt.zone, got, tt.want)
}
}
}
func TestHashZoneNameUniqueness(t *testing.T) {
zones := []string{
"example.com.",
"example.org.",
"example.net.",
"test.example.com.",
"app.example.org.",
"zone.example.org.",
"zone.example.com.",
"test.example.net.",
}
hashes := make(map[string]string)
for _, zone := range zones {
h := hashZoneName(zone)
if existing, ok := hashes[h]; ok {
t.Errorf("hash collision: %q and %q both hash to %q", existing, zone, h)
}
hashes[h] = zone
}
}
func TestBumpSerial(t *testing.T) {
tests := []struct {
name string
old uint32
year int
month int
day int
want uint32
wantErr bool
}{
{
name: "new serial from zero",
old: 0,
year: 2026, month: 3, day: 2,
want: 2026030201,
},
{
name: "bump same date",
old: 2026030201,
year: 2026, month: 3, day: 2,
want: 2026030202,
},
{
name: "new date",
old: 2026030205,
year: 2026, month: 3, day: 3,
want: 2026030301,
},
{
name: "old serial different format",
old: 12345,
year: 2026, month: 3, day: 2,
want: 2026030201,
},
{
name: "overflow at 99",
old: 2026030299,
year: 2026, month: 3, day: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := fixedTime(tt.year, tt.month, tt.day)
got, err := bumpSerial(tt.old, now)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("bumpSerial(%d) = %d, want %d", tt.old, got, tt.want)
}
})
}
}
func TestDefaultSerial(t *testing.T) {
now := fixedTime(2026, 3, 2)
got := defaultSerial(now)
want := uint32(2026030201)
if got != want {
t.Errorf("defaultSerial() = %d, want %d", got, want)
}
}
func TestGenerateCatalogZone(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{
"cat1": {Zone: "catalog.example.com."},
},
SOA: SOAConfig{
Mname: "ns1.example.com.",
Rname: "hostmaster.example.com.",
},
}
members := []ZoneEntry{
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", File: "test", Line: 2},
}
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify SOA is first line
lines := splitLines(content)
if len(lines) < 4 {
t.Fatalf("expected at least 4 lines, got %d", len(lines))
}
// SOA should contain the serial
assertContains(t, lines[0], "2026030201")
assertContains(t, lines[0], "SOA")
// NS should be second
assertContains(t, lines[1], "NS")
assertContains(t, lines[1], "invalid.")
// Version TXT should be third
assertContains(t, lines[2], "TXT")
assertContains(t, lines[2], "\"2\"")
// a.example.com should come before b.example.com (sorted)
aIdx := -1
bIdx := -1
for i, line := range lines {
if containsStr(line, "a.example.com.") {
if aIdx == -1 {
aIdx = i
}
}
if containsStr(line, "b.example.com.") {
if bIdx == -1 {
bIdx = i
}
}
}
if aIdx == -1 || bIdx == -1 {
t.Fatal("expected both a.example.com and b.example.com in output")
}
if aIdx >= bIdx {
t.Errorf("a.example.com (line %d) should come before b.example.com (line %d)", aIdx, bIdx)
}
// a.example.com should have a group TXT
foundGroup := false
for _, line := range lines {
if containsStr(line, "group.") && containsStr(line, "\"mygroup\"") {
foundGroup = true
}
}
if !foundGroup {
t.Error("expected group TXT record for a.example.com")
}
}
func TestGenerateCatalogZoneCOO(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{
"cat1": {Zone: "catalog.example.com."},
},
SOA: SOAConfig{
Mname: "ns1.example.com.",
Rname: "hostmaster.example.com.",
},
}
members := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", File: "test", Line: 1},
}
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
foundCOO := false
for _, line := range splitLines(content) {
if containsStr(line, "coo.") && containsStr(line, "old.example.com.") {
foundCOO = true
}
}
if !foundCOO {
t.Error("expected coo PTR record")
}
}
func TestReadExistingSerial(t *testing.T) {
t.Run("file does not exist", func(t *testing.T) {
serial, err := readExistingSerial("/nonexistent/path.zone")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if serial != 0 {
t.Errorf("serial = %d, want 0", serial)
}
})
t.Run("valid zone file", func(t *testing.T) {
dir := t.TempDir()
path := dir + "/test.zone"
content := "example.com. 0 IN SOA ns1.example.com. hostmaster.example.com. 2026030205 900 600 2147483646 0\nexample.com. 0 IN NS invalid.\n"
writeTestFile(t, dir, "test.zone", content)
serial, err := readExistingSerial(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if serial != 2026030205 {
t.Errorf("serial = %d, want 2026030205", serial)
}
})
t.Run("zone file with no SOA", func(t *testing.T) {
dir := t.TempDir()
path := dir + "/test.zone"
content := "example.com. 0 IN NS invalid.\n"
writeTestFile(t, dir, "test.zone", content)
serial, err := readExistingSerial(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if serial != 0 {
t.Errorf("serial = %d, want 0 (no SOA found)", serial)
}
})
}
func TestReadExistingContent(t *testing.T) {
t.Run("file does not exist", func(t *testing.T) {
content, err := readExistingContent("/nonexistent/path.zone")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "" {
t.Errorf("content = %q, want empty", content)
}
})
t.Run("file exists", func(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, dir, "test.zone", "hello\n")
content, err := readExistingContent(dir + "/test.zone")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "hello\n" {
t.Errorf("content = %q, want %q", content, "hello\n")
}
})
}
func TestGenerateCatalogZoneEmpty(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{
"cat1": {Zone: "catalog.example.com."},
},
SOA: SOAConfig{
Mname: "ns1.example.com.",
Rname: "hostmaster.example.com.",
},
}
content, err := generateCatalogZone("cat1", cfg, nil, 2026030201)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
lines := splitLines(content)
if len(lines) != 3 {
t.Fatalf("expected 3 lines (SOA, NS, version TXT), got %d", len(lines))
}
assertContains(t, lines[0], "SOA")
assertContains(t, lines[1], "NS")
assertContains(t, lines[2], "TXT")
}
func TestNormalizeFQDN(t *testing.T) {
tests := []struct {
in, want string
}{
{"example.com.", "example.com."},
{"example.com", "example.com."},
{"Example.COM.", "example.com."},
{" example.com ", "example.com."},
{"", ""},
}
for _, tt := range tests {
got := normalizeFQDN(tt.in)
if got != tt.want {
t.Errorf("normalizeFQDN(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

78
config.go Normal file
View File

@@ -0,0 +1,78 @@
package main
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// CatalogConfig holds the configuration for a single catalog zone.
type CatalogConfig struct {
Zone string `yaml:"zone"`
}
// SOAConfig holds the configurable SOA fields.
type SOAConfig struct {
Mname string `yaml:"mname"`
Rname string `yaml:"rname"`
}
// Config holds the parsed catz.yaml configuration.
type Config struct {
Catalogs map[string]CatalogConfig `yaml:"catalogs"`
SOA SOAConfig `yaml:"soa"`
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateConfig(&cfg); err != nil {
return nil, err
}
// Normalize zone FQDNs
for name, cat := range cfg.Catalogs {
cat.Zone = normalizeFQDN(cat.Zone)
cfg.Catalogs[name] = cat
}
cfg.SOA.Mname = normalizeFQDN(cfg.SOA.Mname)
cfg.SOA.Rname = normalizeFQDN(cfg.SOA.Rname)
return &cfg, nil
}
func validateConfig(cfg *Config) error {
if len(cfg.Catalogs) == 0 {
return fmt.Errorf("config: no catalogs defined")
}
for name, cat := range cfg.Catalogs {
if cat.Zone == "" {
return fmt.Errorf("config: catalog %q has no zone defined", name)
}
}
if cfg.SOA.Mname == "" {
return fmt.Errorf("config: soa.mname is required")
}
if cfg.SOA.Rname == "" {
return fmt.Errorf("config: soa.rname is required")
}
return nil
}
func normalizeFQDN(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name != "" && !strings.HasSuffix(name, ".") {
name += "."
}
return name
}

141
config_test.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfig(t *testing.T) {
t.Run("valid config", func(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, dir, "catz.yaml", `catalogs:
cat1:
zone: catalog1.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.
`)
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Catalogs["cat1"].Zone != "catalog1.example.com." {
t.Errorf("zone = %q, want %q", cfg.Catalogs["cat1"].Zone, "catalog1.example.com.")
}
})
t.Run("normalizes FQDNs", func(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, dir, "catz.yaml", `catalogs:
cat1:
zone: Catalog1.Example.COM
soa:
mname: NS1.Example.COM
rname: Hostmaster.Example.COM
`)
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Catalogs["cat1"].Zone != "catalog1.example.com." {
t.Errorf("zone = %q, want normalized FQDN", cfg.Catalogs["cat1"].Zone)
}
if cfg.SOA.Mname != "ns1.example.com." {
t.Errorf("mname = %q, want normalized FQDN", cfg.SOA.Mname)
}
})
t.Run("missing file", func(t *testing.T) {
_, err := loadConfig("/nonexistent/catz.yaml")
if err == nil {
t.Fatal("expected error for missing file")
}
})
t.Run("invalid YAML", func(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, dir, "catz.yaml", "{{invalid yaml")
_, err := loadConfig(filepath.Join(dir, "catz.yaml"))
if err == nil {
t.Fatal("expected error for invalid YAML")
}
})
}
func TestValidateConfig(t *testing.T) {
t.Run("no catalogs", func(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{},
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
}
if err := validateConfig(cfg); err == nil {
t.Fatal("expected error for no catalogs")
}
})
t.Run("nil catalogs", func(t *testing.T) {
cfg := &Config{
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
}
if err := validateConfig(cfg); err == nil {
t.Fatal("expected error for nil catalogs")
}
})
t.Run("empty zone", func(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{"cat1": {Zone: ""}},
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
}
if err := validateConfig(cfg); err == nil {
t.Fatal("expected error for empty zone")
}
})
t.Run("missing mname", func(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}},
SOA: SOAConfig{Rname: "hostmaster.example.com."},
}
if err := validateConfig(cfg); err == nil {
t.Fatal("expected error for missing mname")
}
})
t.Run("missing rname", func(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}},
SOA: SOAConfig{Mname: "ns1.example.com."},
}
if err := validateConfig(cfg); err == nil {
t.Fatal("expected error for missing rname")
}
})
t.Run("valid config", func(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}},
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
}
if err := validateConfig(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestLoadConfigWritePermission(t *testing.T) {
// Test that loadConfig returns error for unreadable file
dir := t.TempDir()
path := filepath.Join(dir, "catz.yaml")
writeTestFile(t, dir, "catz.yaml", "catalogs: {}")
if err := os.Chmod(path, 0o000); err != nil {
t.Skip("cannot change file permissions")
}
defer os.Chmod(path, 0o644)
_, err := loadConfig(path)
if err == nil {
t.Fatal("expected error for unreadable file")
}
}

14
go.mod Normal file
View File

@@ -0,0 +1,14 @@
module catalog-zone-gen
go 1.25.5
require (
codeberg.org/miekg/dns v0.6.61
gopkg.in/yaml.v3 v3.0.1
)
require (
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
codeberg.org/miekg/dns v0.6.61 h1:EZZG2MWW3IeUQvX+L4Qg/2LrU96yH045p/M8PB1P6AE=
codeberg.org/miekg/dns v0.6.61/go.mod h1:fIxAzBMDPnXWSw0fp8+pfZMRiAqYY4+HHYLzUo/S6Dg=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

30
helpers_test.go Normal file
View File

@@ -0,0 +1,30 @@
package main
import (
"strings"
"testing"
"time"
)
func fixedTime(year, month, day int) time.Time {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}
func splitLines(s string) []string {
s = strings.TrimRight(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func assertContains(t *testing.T, s, substr string) {
t.Helper()
if !strings.Contains(s, substr) {
t.Errorf("expected %q to contain %q", s, substr)
}
}
func containsStr(s, substr string) bool {
return strings.Contains(s, substr)
}

130
input.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
// ZoneEntry represents a parsed line from the input file.
type ZoneEntry struct {
Zone string // Normalized FQDN
Catalogs []string // Catalog names (bare names from input)
Group string // Optional RFC 9432 group property
COO string // Optional RFC 9432 change-of-ownership FQDN
File string // Source file
Line int // Source line number
}
// CatalogMembers groups zone entries by catalog name.
type CatalogMembers map[string][]ZoneEntry
func parseInput(path string, cfg *Config) (CatalogMembers, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening input: %w", err)
}
defer f.Close()
var entries []ZoneEntry
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
entry, err := parseLine(line, path, lineNum)
if err != nil {
return nil, err
}
entries = append(entries, entry)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
return buildCatalogMembers(entries, cfg)
}
func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
// Split on whitespace first, then handle comma separation within tokens
tokens := tokenize(line)
if len(tokens) < 2 {
return ZoneEntry{}, fmt.Errorf("error: %s:%d: expected zone name followed by at least one catalog name", file, lineNum)
}
entry := ZoneEntry{
Zone: normalizeFQDN(tokens[0]),
File: file,
Line: lineNum,
}
for _, tok := range tokens[1:] {
if strings.Contains(tok, "=") {
key, value, _ := strings.Cut(tok, "=")
switch strings.ToLower(key) {
case "group":
if value == "" {
return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty group value", file, lineNum)
}
entry.Group = value
case "coo":
if value == "" {
return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty coo value", file, lineNum)
}
entry.COO = normalizeFQDN(value)
default:
return ZoneEntry{}, fmt.Errorf("error: %s:%d: unknown property %q", file, lineNum, key)
}
} else {
// Bare name = catalog assignment
entry.Catalogs = append(entry.Catalogs, tok)
}
}
if len(entry.Catalogs) == 0 {
return ZoneEntry{}, fmt.Errorf("error: %s:%d: no catalog assignment for zone %s", file, lineNum, entry.Zone)
}
return entry, nil
}
// tokenize splits a line on whitespace and commas, stripping empty tokens.
func tokenize(line string) []string {
// Replace commas with spaces, then split on whitespace
line = strings.ReplaceAll(line, ",", " ")
fields := strings.Fields(line)
return fields
}
func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, error) {
members := make(CatalogMembers)
// Track duplicates: catalog -> zone -> line
seen := make(map[string]map[string]int)
for _, entry := range entries {
for _, catName := range entry.Catalogs {
if _, ok := cfg.Catalogs[catName]; !ok {
return nil, fmt.Errorf("error: %s:%d: unknown catalog %q", entry.File, entry.Line, catName)
}
if seen[catName] == nil {
seen[catName] = make(map[string]int)
}
if prevLine, dup := seen[catName][entry.Zone]; dup {
return nil, fmt.Errorf("error: %s:%d: zone %s already assigned to catalog %q at line %d",
entry.File, entry.Line, entry.Zone, catName, prevLine)
}
seen[catName][entry.Zone] = entry.Line
members[catName] = append(members[catName], entry)
}
}
return members, nil
}

277
input_test.go Normal file
View File

@@ -0,0 +1,277 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestParseLine(t *testing.T) {
tests := []struct {
name string
line string
wantZone string
wantCats []string
wantGrp string
wantCOO string
wantErr bool
}{
{
name: "simple single catalog",
line: "zone.example.org catalog1",
wantZone: "zone.example.org.",
wantCats: []string{"catalog1"},
},
{
name: "multiple catalogs comma separated",
line: "zone.example.org catalog1, catalog2",
wantZone: "zone.example.org.",
wantCats: []string{"catalog1", "catalog2"},
},
{
name: "with group",
line: "test.example.net catalog1, group=internal",
wantZone: "test.example.net.",
wantCats: []string{"catalog1"},
wantGrp: "internal",
},
{
name: "with coo",
line: "zone.example.com catalog2, coo=old-catalog.example.com.",
wantZone: "zone.example.com.",
wantCats: []string{"catalog2"},
wantCOO: "old-catalog.example.com.",
},
{
name: "with group and coo",
line: "app.example.org catalog1, group=external, coo=migrated.example.com.",
wantZone: "app.example.org.",
wantCats: []string{"catalog1"},
wantGrp: "external",
wantCOO: "migrated.example.com.",
},
{
name: "trailing dot on zone",
line: "zone.example.org. catalog1",
wantZone: "zone.example.org.",
wantCats: []string{"catalog1"},
},
{
name: "no catalog",
line: "zone.example.org",
wantErr: true,
},
{
name: "only properties no catalog",
line: "zone.example.org group=foo",
wantErr: true,
},
{
name: "unknown property",
line: "zone.example.org catalog1, foo=bar",
wantErr: true,
},
{
name: "empty group value",
line: "zone.example.org catalog1, group=",
wantErr: true,
},
{
name: "empty coo value",
line: "zone.example.org catalog1, coo=",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry, err := parseLine(tt.line, "test.txt", 1)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.Zone != tt.wantZone {
t.Errorf("zone = %q, want %q", entry.Zone, tt.wantZone)
}
if len(entry.Catalogs) != len(tt.wantCats) {
t.Fatalf("catalogs = %v, want %v", entry.Catalogs, tt.wantCats)
}
for i, cat := range entry.Catalogs {
if cat != tt.wantCats[i] {
t.Errorf("catalog[%d] = %q, want %q", i, cat, tt.wantCats[i])
}
}
if entry.Group != tt.wantGrp {
t.Errorf("group = %q, want %q", entry.Group, tt.wantGrp)
}
if entry.COO != tt.wantCOO {
t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO)
}
})
}
}
func TestTokenize(t *testing.T) {
tests := []struct {
line string
want []string
}{
{"zone.example.org catalog1, catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
{"zone.example.org catalog1,catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
{"zone.example.org catalog1 , catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
{"zone.example.org\tcatalog1", []string{"zone.example.org", "catalog1"}},
}
for _, tt := range tests {
got := tokenize(tt.line)
if len(got) != len(tt.want) {
t.Errorf("tokenize(%q) = %v, want %v", tt.line, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("tokenize(%q)[%d] = %q, want %q", tt.line, i, got[i], tt.want[i])
}
}
}
}
func TestBuildCatalogMembers(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{
"catalog1": {Zone: "catalog1.example.com."},
"catalog2": {Zone: "catalog2.example.com."},
},
}
t.Run("valid input", func(t *testing.T) {
entries := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 2},
}
members, err := buildCatalogMembers(entries, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(members["catalog1"]) != 2 {
t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"]))
}
if len(members["catalog2"]) != 1 {
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
}
})
t.Run("unknown catalog", func(t *testing.T) {
entries := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"unknown"}, File: "test", Line: 1},
}
_, err := buildCatalogMembers(entries, cfg)
if err == nil {
t.Fatal("expected error for unknown catalog")
}
})
t.Run("duplicate zone in same catalog", func(t *testing.T) {
entries := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 2},
}
_, err := buildCatalogMembers(entries, cfg)
if err == nil {
t.Fatal("expected error for duplicate zone")
}
})
}
func TestParseInput(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{
"catalog1": {Zone: "catalog1.example.com."},
"catalog2": {Zone: "catalog2.example.com."},
},
}
dir := t.TempDir()
inputPath := filepath.Join(dir, "zones.txt")
content := `# Comment
zone.example.org catalog1, catalog2
test.example.net catalog1, group=internal
`
if err := os.WriteFile(inputPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
members, err := parseInput(inputPath, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(members["catalog1"]) != 2 {
t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"]))
}
if len(members["catalog2"]) != 1 {
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
}
}
func TestParseInputErrors(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{
"catalog1": {Zone: "catalog1.example.com."},
},
}
t.Run("missing file", func(t *testing.T) {
_, err := parseInput("/nonexistent/zones.txt", cfg)
if err == nil {
t.Fatal("expected error for missing file")
}
})
t.Run("invalid line in input", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "zones.txt")
if err := os.WriteFile(path, []byte("zone-with-no-catalog\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := parseInput(path, cfg)
if err == nil {
t.Fatal("expected error for invalid line")
}
})
}
func TestBuildCatalogMembersSameZoneDifferentCatalogs(t *testing.T) {
cfg := &Config{
Catalogs: map[string]CatalogConfig{
"catalog1": {Zone: "catalog1.example.com."},
"catalog2": {Zone: "catalog2.example.com."},
},
}
// Same zone in different catalogs is OK
entries := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 1},
}
members, err := buildCatalogMembers(entries, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(members["catalog1"]) != 1 {
t.Errorf("catalog1 members = %d, want 1", len(members["catalog1"]))
}
if len(members["catalog2"]) != 1 {
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
}
}

76
main.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
func main() {
configPath := flag.String("config", "", "path to YAML config (default: catz.yaml next to input file)")
outputDir := flag.String("output-dir", "", "directory for output zone files (default: same as input file)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] <input-file>\n")
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() != 1 {
flag.Usage()
os.Exit(1)
}
inputFile := flag.Arg(0)
inputDir := filepath.Dir(inputFile)
if *configPath == "" {
*configPath = filepath.Join(inputDir, "catz.yaml")
}
if *outputDir == "" {
*outputDir = inputDir
}
cfg, err := loadConfig(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
members, err := parseInput(inputFile, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
now := time.Now().UTC()
// Process catalogs in sorted order for deterministic output
catNames := make([]string, 0, len(members))
for name := range members {
catNames = append(catNames, name)
}
sort.Strings(catNames)
hasErrors := false
for _, catName := range catNames {
changed, err := processCatalog(catName, cfg, members[catName], *outputDir, now)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasErrors = true
continue
}
catZone := cfg.Catalogs[catName].Zone
if changed {
fmt.Fprintf(os.Stderr, "%s%s: updated\n", catZone, "zone")
} else {
fmt.Fprintf(os.Stderr, "%s%s: unchanged\n", catZone, "zone")
}
}
if hasErrors {
os.Exit(1)
}
}

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")
}

9
testdata/catz.yaml vendored Normal file
View File

@@ -0,0 +1,9 @@
catalogs:
catalog1:
zone: catalog1.example.com.
catalog2:
zone: catalog2.example.com.
soa:
mname: ns1.example.com.
rname: hostmaster.example.com.

5
testdata/zones.txt vendored Normal file
View File

@@ -0,0 +1,5 @@
# Production 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.