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:
166
README.md
Normal file
166
README.md
Normal 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
249
catalog.go
Normal 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
332
catalog_test.go
Normal 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
78
config.go
Normal 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
141
config_test.go
Normal 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
14
go.mod
Normal 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
12
go.sum
Normal 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
30
helpers_test.go
Normal 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
130
input.go
Normal 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
277
input_test.go
Normal 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
76
main.go
Normal 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
425
main_test.go
Normal 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
9
testdata/catz.yaml
vendored
Normal 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
5
testdata/zones.txt
vendored
Normal 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.
|
||||
Reference in New Issue
Block a user