Add --bind-conf flag for BIND domains.conf generation
Generate a BIND-format domains.conf file alongside catalog zones. New input properties: file= (zone data path) and dnssec (bare flag). When --bind-conf is set, every zone must have file= or it errors. Renames ZoneEntry.File to ZonesFile (input path for error messages) and adds ZoneFile (BIND file path) and DNSSEC (bool) fields.
This commit is contained in:
48
README.md
48
README.md
@@ -22,13 +22,14 @@ go build -o catalog-zone-gen .
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
catalog-zone-gen [--config path] [--output-dir path] <input-file>
|
catalog-zone-gen [--config path] [--output-dir path] [--bind-conf path] <input-file>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flags:**
|
**Flags:**
|
||||||
|
|
||||||
- `--config` — path to YAML config file (default: `catz.yaml` next to the input file)
|
- `--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)
|
- `--output-dir` — directory for output zone files (default: same directory as the input file)
|
||||||
|
- `--bind-conf` — path to write a BIND `domains.conf` file (optional; see [BIND Config Output](#bind-config-output))
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ Whitespace and comma delimited. Lines starting with `#` are comments.
|
|||||||
Blank lines are ignored.
|
Blank lines are ignored.
|
||||||
|
|
||||||
```
|
```
|
||||||
<zone-name> <catalog>[, <catalog>...] [, group=<value>] [, coo=<fqdn>]
|
<zone-name> <catalog>[, <catalog>...] [, group=<value>] [, coo=<fqdn>] [, file=<path>] [, dnssec]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
@@ -75,6 +76,8 @@ Blank lines are ignored.
|
|||||||
| Bare names | identifier | Catalog assignments — must match a key in the config `catalogs` map. At least one required. |
|
| 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. |
|
| `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. |
|
| `coo=<fqdn>` | key=value | RFC 9432 change-of-ownership property. Points to the old catalog zone during migration. |
|
||||||
|
| `file=<path>` | key=value | Zone data file path for BIND config output. Required when `--bind-conf` is used. |
|
||||||
|
| `dnssec` | bare flag | Adds `dnssec-policy standard; inline-signing yes;` to the BIND config for this zone. |
|
||||||
|
|
||||||
A zone can appear in multiple catalogs (for distributing to different server groups).
|
A zone can appear in multiple catalogs (for distributing to different server groups).
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ A zone can appear in multiple catalogs (for distributing to different server gro
|
|||||||
|
|
||||||
```
|
```
|
||||||
# Production zones
|
# Production zones
|
||||||
zone.example.org catalog1, catalog2
|
zone.example.org catalog1, catalog2, file=data/zones/example.org
|
||||||
zone.example.com catalog2, coo=old-catalog.example.com.
|
zone.example.com catalog2, coo=old-catalog.example.com., file=data/zones/example.com
|
||||||
test.example.net catalog1, group=internal
|
test.example.net catalog1, group=internal, file=data/zones/example.net, dnssec
|
||||||
app.example.org catalog1, group=external, coo=migrated.example.com.
|
app.example.org catalog1, group=external, coo=migrated.example.com., file=data/zones/app.example.org
|
||||||
```
|
```
|
||||||
|
|
||||||
Whitespace and comma placement is flexible. These are all equivalent:
|
Whitespace and comma placement is flexible. These are all equivalent:
|
||||||
@@ -163,4 +166,35 @@ error: zones.txt:5: zone example.com. already assigned to catalog "catalog1" at
|
|||||||
- Same zone assigned to the same catalog more than once — 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
|
- Hash collision (two zone names produce the same hash within a catalog) — error
|
||||||
- Missing required config fields — error
|
- Missing required config fields — error
|
||||||
- Unknown properties (anything other than `group` and `coo`) — error
|
- Unknown properties (anything other than `group`, `coo`, `file`) — error
|
||||||
|
- Empty `file=` value — error
|
||||||
|
- `dnssec=<anything>` (dnssec is a bare flag, not a key=value) — error
|
||||||
|
- When `--bind-conf` is used: any zone missing `file=` — error
|
||||||
|
|
||||||
|
## BIND Config Output
|
||||||
|
|
||||||
|
When `--bind-conf <path>` is specified, a BIND `domains.conf` file is written
|
||||||
|
in addition to the catalog zone files. This file defines all zones as `type
|
||||||
|
master` with their `file` paths from the `file=` input property.
|
||||||
|
|
||||||
|
**Example output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# THIS FILE IS GENERATED BY catalog-zone-gen
|
||||||
|
#=============================================
|
||||||
|
#
|
||||||
|
zone "askask.com" {
|
||||||
|
type master;
|
||||||
|
file "data/ask/askask.com";
|
||||||
|
};
|
||||||
|
zone "bitcard.org" {
|
||||||
|
type master;
|
||||||
|
file "data/misc/bitcard.org"; dnssec-policy standard; inline-signing yes;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- Zones are sorted alphabetically by name
|
||||||
|
- 8-space indentation
|
||||||
|
- DNSSEC zones (marked with `dnssec` in the input) get `dnssec-policy standard;
|
||||||
|
inline-signing yes;` on the same line as `file`
|
||||||
|
- Every zone must have a `file=` property when `--bind-conf` is used
|
||||||
|
|||||||
64
bindconf.go
Normal file
64
bindconf.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateBindConf produces a BIND domains.conf from all zone entries.
|
||||||
|
// Zones are sorted alphabetically. Each zone block uses 8-space indentation.
|
||||||
|
// DNSSEC zones get dnssec-policy and inline-signing directives on the file line.
|
||||||
|
func generateBindConf(entries []ZoneEntry) string {
|
||||||
|
sorted := make([]ZoneEntry, len(entries))
|
||||||
|
copy(sorted, entries)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].Zone < sorted[j].Zone
|
||||||
|
})
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# THIS FILE IS GENERATED BY catalog-zone-gen\n")
|
||||||
|
b.WriteString("#=============================================\n")
|
||||||
|
b.WriteString("#\n")
|
||||||
|
|
||||||
|
for _, entry := range sorted {
|
||||||
|
// Strip trailing dot for BIND zone name
|
||||||
|
zoneName := strings.TrimSuffix(entry.Zone, ".")
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "zone \"%s\" {\n", zoneName)
|
||||||
|
b.WriteString(" type master;\n")
|
||||||
|
|
||||||
|
fileLine := fmt.Sprintf(" file \"%s\";", entry.ZoneFile)
|
||||||
|
if entry.DNSSEC {
|
||||||
|
fileLine += " dnssec-policy standard; inline-signing yes;"
|
||||||
|
}
|
||||||
|
b.WriteString(fileLine + "\n")
|
||||||
|
b.WriteString("};\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBindConf checks that every zone entry has a non-empty ZoneFile.
|
||||||
|
func validateBindConf(entries []ZoneEntry) error {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.ZoneFile == "" {
|
||||||
|
return fmt.Errorf("%s:%d: zone %s missing file= property (required for --bind-conf)",
|
||||||
|
entry.ZonesFile, entry.Line, entry.Zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBindConf validates entries and writes the BIND config to path.
|
||||||
|
func writeBindConf(path string, entries []ZoneEntry) error {
|
||||||
|
if err := validateBindConf(entries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := generateBindConf(entries)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("writing bind config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
138
bindconf_test.go
Normal file
138
bindconf_test.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateBindConf(t *testing.T) {
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "bitcard.org.", ZoneFile: "data/misc/bitcard.org", DNSSEC: true, ZonesFile: "test", Line: 1},
|
||||||
|
{Zone: "askask.com.", ZoneFile: "data/ask/askask.com", ZonesFile: "test", Line: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := generateBindConf(entries)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen")
|
||||||
|
assertContains(t, got, "#=============================================")
|
||||||
|
|
||||||
|
// Zones should be sorted alphabetically (askask.com before bitcard.org)
|
||||||
|
askIdx := strings.Index(got, "askask.com")
|
||||||
|
bitIdx := strings.Index(got, "bitcard.org")
|
||||||
|
if askIdx == -1 || bitIdx == -1 {
|
||||||
|
t.Fatal("expected both zones in output")
|
||||||
|
}
|
||||||
|
if askIdx >= bitIdx {
|
||||||
|
t.Error("expected askask.com before bitcard.org (alphabetical sort)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-DNSSEC zone
|
||||||
|
assertContains(t, got, `zone "askask.com" {`)
|
||||||
|
assertContains(t, got, ` type master;`)
|
||||||
|
assertContains(t, got, ` file "data/ask/askask.com";`)
|
||||||
|
|
||||||
|
// DNSSEC zone has directives on file line
|
||||||
|
assertContains(t, got, ` file "data/misc/bitcard.org"; dnssec-policy standard; inline-signing yes;`)
|
||||||
|
|
||||||
|
// No trailing dot in zone name
|
||||||
|
if strings.Contains(got, `zone "askask.com."`) {
|
||||||
|
t.Error("zone name should not have trailing dot in BIND config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateBindConfEmpty(t *testing.T) {
|
||||||
|
got := generateBindConf(nil)
|
||||||
|
// Should just have the header
|
||||||
|
lines := splitLines(got)
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Errorf("expected 3 header lines for empty input, got %d", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateBindConfNoDNSSEC(t *testing.T) {
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := generateBindConf(entries)
|
||||||
|
|
||||||
|
if strings.Contains(got, "dnssec-policy") {
|
||||||
|
t.Error("non-DNSSEC zone should not have dnssec-policy")
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "inline-signing") {
|
||||||
|
t.Error("non-DNSSEC zone should not have inline-signing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBindConf(t *testing.T) {
|
||||||
|
t.Run("all zones have file", func(t *testing.T) {
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", ZoneFile: "data/a", ZonesFile: "zones.txt", Line: 1},
|
||||||
|
{Zone: "b.example.com.", ZoneFile: "data/b", ZonesFile: "zones.txt", Line: 2},
|
||||||
|
}
|
||||||
|
if err := validateBindConf(entries); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", ZoneFile: "data/a", ZonesFile: "zones.txt", Line: 1},
|
||||||
|
{Zone: "b.example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 3},
|
||||||
|
}
|
||||||
|
err := validateBindConf(entries)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
assertContains(t, err.Error(), "zones.txt:3:")
|
||||||
|
assertContains(t, err.Error(), "b.example.com.")
|
||||||
|
assertContains(t, err.Error(), "missing file=")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBindConf(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "domains.conf")
|
||||||
|
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1},
|
||||||
|
{Zone: "example.org.", ZoneFile: "data/example.org", DNSSEC: true, ZonesFile: "test", Line: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeBindConf(path, entries); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
|
||||||
|
assertContains(t, got, `zone "example.com"`)
|
||||||
|
assertContains(t, got, `zone "example.org"`)
|
||||||
|
assertContains(t, got, "dnssec-policy standard")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBindConfValidationError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "domains.conf")
|
||||||
|
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writeBindConf(path, entries)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
assertContains(t, err.Error(), "zones.txt:5:")
|
||||||
|
|
||||||
|
// File should not have been written
|
||||||
|
if _, statErr := os.Stat(path); statErr == nil {
|
||||||
|
t.Error("file should not be written when validation fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ func generateCatalogZone(catName string, cfg *Config, members []ZoneEntry, seria
|
|||||||
h := hashZoneName(entry.Zone)
|
h := hashZoneName(entry.Zone)
|
||||||
if existing, ok := hashToZone[h]; ok && existing != entry.Zone {
|
if existing, ok := hashToZone[h]; ok && existing != entry.Zone {
|
||||||
return "", fmt.Errorf("%s:%d: hash collision between %s and %s in catalog %q",
|
return "", fmt.Errorf("%s:%d: hash collision between %s and %s in catalog %q",
|
||||||
entry.File, entry.Line, existing, entry.Zone, catName)
|
entry.ZonesFile, entry.Line, existing, entry.Zone, catName)
|
||||||
}
|
}
|
||||||
hashToZone[h] = entry.Zone
|
hashToZone[h] = entry.Zone
|
||||||
zoneHash[entry.Zone] = h
|
zoneHash[entry.Zone] = h
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ func TestGenerateCatalogZone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
members := []ZoneEntry{
|
members := []ZoneEntry{
|
||||||
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", File: "test", Line: 2},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", ZonesFile: "test", Line: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
||||||
@@ -203,7 +203,7 @@ func TestGenerateCatalogZoneCOO(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
members := []ZoneEntry{
|
members := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", ZonesFile: "test", Line: 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
||||||
|
|||||||
47
input.go
47
input.go
@@ -9,21 +9,23 @@ import (
|
|||||||
|
|
||||||
// ZoneEntry represents a parsed line from the input file.
|
// ZoneEntry represents a parsed line from the input file.
|
||||||
type ZoneEntry struct {
|
type ZoneEntry struct {
|
||||||
Zone string // Normalized FQDN
|
Zone string // Normalized FQDN
|
||||||
Catalogs []string // Catalog names (bare names from input)
|
Catalogs []string // Catalog names (bare names from input)
|
||||||
Group string // Optional RFC 9432 group property
|
Group string // Optional RFC 9432 group property
|
||||||
COO string // Optional RFC 9432 change-of-ownership FQDN
|
COO string // Optional RFC 9432 change-of-ownership FQDN
|
||||||
File string // Source file
|
ZoneFile string // file= property: zone data path for BIND config
|
||||||
Line int // Source line number
|
DNSSEC bool // dnssec flag: adds dnssec-policy to BIND config
|
||||||
|
ZonesFile string // Input file path (for error messages)
|
||||||
|
Line int // Input line number (for error messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CatalogMembers groups zone entries by catalog name.
|
// CatalogMembers groups zone entries by catalog name.
|
||||||
type CatalogMembers map[string][]ZoneEntry
|
type CatalogMembers map[string][]ZoneEntry
|
||||||
|
|
||||||
func parseInput(path string, cfg *Config) (CatalogMembers, error) {
|
func parseInput(path string, cfg *Config) ([]ZoneEntry, CatalogMembers, error) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening input: %w", err)
|
return nil, nil, fmt.Errorf("opening input: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
@@ -39,15 +41,19 @@ func parseInput(path string, cfg *Config) (CatalogMembers, error) {
|
|||||||
|
|
||||||
entry, err := parseLine(line, path, lineNum)
|
entry, err := parseLine(line, path, lineNum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
return nil, nil, fmt.Errorf("reading %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCatalogMembers(entries, cfg)
|
members, err := buildCatalogMembers(entries, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return entries, members, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
|
func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
|
||||||
@@ -58,9 +64,9 @@ func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := ZoneEntry{
|
entry := ZoneEntry{
|
||||||
Zone: normalizeFQDN(tokens[0]),
|
Zone: normalizeFQDN(tokens[0]),
|
||||||
File: file,
|
ZonesFile: file,
|
||||||
Line: lineNum,
|
Line: lineNum,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tok := range tokens[1:] {
|
for _, tok := range tokens[1:] {
|
||||||
@@ -77,9 +83,18 @@ func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
|
|||||||
return ZoneEntry{}, fmt.Errorf("%s:%d: empty coo value", file, lineNum)
|
return ZoneEntry{}, fmt.Errorf("%s:%d: empty coo value", file, lineNum)
|
||||||
}
|
}
|
||||||
entry.COO = normalizeFQDN(value)
|
entry.COO = normalizeFQDN(value)
|
||||||
|
case "file":
|
||||||
|
if value == "" {
|
||||||
|
return ZoneEntry{}, fmt.Errorf("%s:%d: empty file value", file, lineNum)
|
||||||
|
}
|
||||||
|
entry.ZoneFile = value
|
||||||
|
case "dnssec":
|
||||||
|
return ZoneEntry{}, fmt.Errorf("%s:%d: dnssec is a flag, use without =", file, lineNum)
|
||||||
default:
|
default:
|
||||||
return ZoneEntry{}, fmt.Errorf("%s:%d: unknown property %q", file, lineNum, key)
|
return ZoneEntry{}, fmt.Errorf("%s:%d: unknown property %q", file, lineNum, key)
|
||||||
}
|
}
|
||||||
|
} else if tok == "dnssec" {
|
||||||
|
entry.DNSSEC = true
|
||||||
} else {
|
} else {
|
||||||
// Bare name = catalog assignment
|
// Bare name = catalog assignment
|
||||||
entry.Catalogs = append(entry.Catalogs, tok)
|
entry.Catalogs = append(entry.Catalogs, tok)
|
||||||
@@ -107,7 +122,7 @@ func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, erro
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
for _, catName := range entry.Catalogs {
|
for _, catName := range entry.Catalogs {
|
||||||
if _, ok := cfg.Catalogs[catName]; !ok {
|
if _, ok := cfg.Catalogs[catName]; !ok {
|
||||||
return nil, fmt.Errorf("%s:%d: unknown catalog %q", entry.File, entry.Line, catName)
|
return nil, fmt.Errorf("%s:%d: unknown catalog %q", entry.ZonesFile, entry.Line, catName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if seen[catName] == nil {
|
if seen[catName] == nil {
|
||||||
@@ -115,7 +130,7 @@ func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, erro
|
|||||||
}
|
}
|
||||||
if prevLine, dup := seen[catName][entry.Zone]; dup {
|
if prevLine, dup := seen[catName][entry.Zone]; dup {
|
||||||
return nil, fmt.Errorf("%s:%d: zone %s already assigned to catalog %q at line %d",
|
return nil, fmt.Errorf("%s:%d: zone %s already assigned to catalog %q at line %d",
|
||||||
entry.File, entry.Line, entry.Zone, catName, prevLine)
|
entry.ZonesFile, entry.Line, entry.Zone, catName, prevLine)
|
||||||
}
|
}
|
||||||
seen[catName][entry.Zone] = entry.Line
|
seen[catName][entry.Zone] = entry.Line
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ func TestParseLine(t *testing.T) {
|
|||||||
wantCats []string
|
wantCats []string
|
||||||
wantGrp string
|
wantGrp string
|
||||||
wantCOO string
|
wantCOO string
|
||||||
|
wantFile string
|
||||||
|
wantDNS bool
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -55,6 +57,28 @@ func TestParseLine(t *testing.T) {
|
|||||||
wantZone: "zone.example.org.",
|
wantZone: "zone.example.org.",
|
||||||
wantCats: []string{"catalog1"},
|
wantCats: []string{"catalog1"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "with file property",
|
||||||
|
line: "zone.example.org catalog1, file=data/zones/example.org",
|
||||||
|
wantZone: "zone.example.org.",
|
||||||
|
wantCats: []string{"catalog1"},
|
||||||
|
wantFile: "data/zones/example.org",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with dnssec flag",
|
||||||
|
line: "zone.example.org catalog1, dnssec",
|
||||||
|
wantZone: "zone.example.org.",
|
||||||
|
wantCats: []string{"catalog1"},
|
||||||
|
wantDNS: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with file and dnssec",
|
||||||
|
line: "zone.example.org catalog1, file=data/zones/example.org, dnssec",
|
||||||
|
wantZone: "zone.example.org.",
|
||||||
|
wantCats: []string{"catalog1"},
|
||||||
|
wantFile: "data/zones/example.org",
|
||||||
|
wantDNS: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "no catalog",
|
name: "no catalog",
|
||||||
line: "zone.example.org",
|
line: "zone.example.org",
|
||||||
@@ -80,6 +104,16 @@ func TestParseLine(t *testing.T) {
|
|||||||
line: "zone.example.org catalog1, coo=",
|
line: "zone.example.org catalog1, coo=",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "empty file value",
|
||||||
|
line: "zone.example.org catalog1, file=",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dnssec with equals",
|
||||||
|
line: "zone.example.org catalog1, dnssec=yes",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -111,6 +145,12 @@ func TestParseLine(t *testing.T) {
|
|||||||
if entry.COO != tt.wantCOO {
|
if entry.COO != tt.wantCOO {
|
||||||
t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO)
|
t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO)
|
||||||
}
|
}
|
||||||
|
if entry.ZoneFile != tt.wantFile {
|
||||||
|
t.Errorf("zonefile = %q, want %q", entry.ZoneFile, tt.wantFile)
|
||||||
|
}
|
||||||
|
if entry.DNSSEC != tt.wantDNS {
|
||||||
|
t.Errorf("dnssec = %v, want %v", entry.DNSSEC, tt.wantDNS)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +190,8 @@ func TestBuildCatalogMembers(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("valid input", func(t *testing.T) {
|
t.Run("valid input", func(t *testing.T) {
|
||||||
entries := []ZoneEntry{
|
entries := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 1},
|
||||||
{Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 2},
|
{Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, ZonesFile: "test", Line: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := buildCatalogMembers(entries, cfg)
|
members, err := buildCatalogMembers(entries, cfg)
|
||||||
@@ -169,7 +209,7 @@ func TestBuildCatalogMembers(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("unknown catalog", func(t *testing.T) {
|
t.Run("unknown catalog", func(t *testing.T) {
|
||||||
entries := []ZoneEntry{
|
entries := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"unknown"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"unknown"}, ZonesFile: "test", Line: 1},
|
||||||
}
|
}
|
||||||
_, err := buildCatalogMembers(entries, cfg)
|
_, err := buildCatalogMembers(entries, cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -179,8 +219,8 @@ func TestBuildCatalogMembers(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("duplicate zone in same catalog", func(t *testing.T) {
|
t.Run("duplicate zone in same catalog", func(t *testing.T) {
|
||||||
entries := []ZoneEntry{
|
entries := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 1},
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 2},
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 2},
|
||||||
}
|
}
|
||||||
_, err := buildCatalogMembers(entries, cfg)
|
_, err := buildCatalogMembers(entries, cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -207,7 +247,7 @@ test.example.net catalog1, group=internal
|
|||||||
`
|
`
|
||||||
writeTestFile(t, dir, "zones.txt", content)
|
writeTestFile(t, dir, "zones.txt", content)
|
||||||
|
|
||||||
members, err := parseInput(inputPath, cfg)
|
_, members, err := parseInput(inputPath, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -228,7 +268,7 @@ func TestParseInputErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("missing file", func(t *testing.T) {
|
t.Run("missing file", func(t *testing.T) {
|
||||||
_, err := parseInput("/nonexistent/zones.txt", cfg)
|
_, _, err := parseInput("/nonexistent/zones.txt", cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing file")
|
t.Fatal("expected error for missing file")
|
||||||
}
|
}
|
||||||
@@ -238,7 +278,7 @@ func TestParseInputErrors(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "zones.txt")
|
path := filepath.Join(dir, "zones.txt")
|
||||||
writeTestFile(t, dir, "zones.txt", "zone-with-no-catalog\n")
|
writeTestFile(t, dir, "zones.txt", "zone-with-no-catalog\n")
|
||||||
_, err := parseInput(path, cfg)
|
_, _, err := parseInput(path, cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid line")
|
t.Fatal("expected error for invalid line")
|
||||||
}
|
}
|
||||||
@@ -255,7 +295,7 @@ func TestBuildCatalogMembersSameZoneDifferentCatalogs(t *testing.T) {
|
|||||||
|
|
||||||
// Same zone in different catalogs is OK
|
// Same zone in different catalogs is OK
|
||||||
entries := []ZoneEntry{
|
entries := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, ZonesFile: "test", Line: 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := buildCatalogMembers(entries, cfg)
|
members, err := buildCatalogMembers(entries, cfg)
|
||||||
|
|||||||
14
main.go
14
main.go
@@ -12,8 +12,9 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "", "path to YAML config (default: catz.yaml next to input file)")
|
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)")
|
outputDir := flag.String("output-dir", "", "directory for output zone files (default: same as input file)")
|
||||||
|
bindConf := flag.String("bind-conf", "", "path to write BIND domains.conf")
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] <input-file>\n")
|
fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] [--bind-conf path] <input-file>\n")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -39,7 +40,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := parseInput(inputFile, cfg)
|
entries, members, err := parseInput(inputFile, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -70,6 +71,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *bindConf != "" {
|
||||||
|
if err := writeBindConf(*bindConf, entries); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||||
|
hasErrors = true
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: written\n", *bindConf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if hasErrors {
|
if hasErrors {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
125
main_test.go
125
main_test.go
@@ -38,7 +38,7 @@ app.example.org catalog1, group=external, coo=migrated.example.com.
|
|||||||
t.Fatalf("loadConfig: %v", err)
|
t.Fatalf("loadConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg)
|
_, members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parseInput: %v", err)
|
t.Fatalf("parseInput: %v", err)
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ soa:
|
|||||||
}
|
}
|
||||||
|
|
||||||
members1 := []ZoneEntry{
|
members1 := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
now := fixedTime(2026, 1, 15)
|
now := fixedTime(2026, 1, 15)
|
||||||
@@ -139,8 +139,8 @@ soa:
|
|||||||
|
|
||||||
// Second run with different input (same day)
|
// Second run with different input (same day)
|
||||||
members2 := []ZoneEntry{
|
members2 := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
||||||
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
changed, err = processCatalog("cat1", cfg, members2, dir, now)
|
changed, err = processCatalog("cat1", cfg, members2, dir, now)
|
||||||
@@ -195,9 +195,9 @@ soa:
|
|||||||
|
|
||||||
// Input in reverse order
|
// Input in reverse order
|
||||||
members := []ZoneEntry{
|
members := []ZoneEntry{
|
||||||
{Zone: "z.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
{Zone: "z.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
||||||
{Zone: "m.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
{Zone: "m.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3},
|
||||||
}
|
}
|
||||||
|
|
||||||
now := fixedTime(2026, 1, 15)
|
now := fixedTime(2026, 1, 15)
|
||||||
@@ -371,7 +371,7 @@ soa:
|
|||||||
}
|
}
|
||||||
|
|
||||||
members := []ZoneEntry{
|
members := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
day1 := fixedTime(2026, 1, 15)
|
day1 := fixedTime(2026, 1, 15)
|
||||||
@@ -385,8 +385,8 @@ soa:
|
|||||||
|
|
||||||
// Add a zone on day 2
|
// Add a zone on day 2
|
||||||
members2 := []ZoneEntry{
|
members2 := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
||||||
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
day2 := fixedTime(2026, 1, 16)
|
day2 := fixedTime(2026, 1, 16)
|
||||||
@@ -404,9 +404,9 @@ soa:
|
|||||||
// Simulate not using the tool for a while, running on a much later date
|
// Simulate not using the tool for a while, running on a much later date
|
||||||
day3 := fixedTime(2026, 6, 15)
|
day3 := fixedTime(2026, 6, 15)
|
||||||
members3 := []ZoneEntry{
|
members3 := []ZoneEntry{
|
||||||
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
||||||
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
||||||
{Zone: "c.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3},
|
{Zone: "c.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3},
|
||||||
}
|
}
|
||||||
|
|
||||||
changed, err = processCatalog("cat1", cfg, members3, dir, day3)
|
changed, err = processCatalog("cat1", cfg, members3, dir, day3)
|
||||||
@@ -420,3 +420,102 @@ soa:
|
|||||||
content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
||||||
assertContains(t, content3, "2026061501")
|
assertContains(t, content3, "2026061501")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCLIBindConf(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := `zone.example.org catalog1, file=data/zones/example.org, dnssec
|
||||||
|
zone.example.com catalog1, file=data/zones/example.com
|
||||||
|
`
|
||||||
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
||||||
|
|
||||||
|
bindConfPath := filepath.Join(dir, "domains.conf")
|
||||||
|
|
||||||
|
cmd = exec.Command(binary,
|
||||||
|
"--config", filepath.Join(dir, "catz.yaml"),
|
||||||
|
"--output-dir", dir,
|
||||||
|
"--bind-conf", bindConfPath,
|
||||||
|
filepath.Join(dir, "zones.txt"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bind conf was written
|
||||||
|
content := readTestFile(t, bindConfPath)
|
||||||
|
assertContains(t, content, "# THIS FILE IS GENERATED BY catalog-zone-gen")
|
||||||
|
|
||||||
|
// zone.example.com should come before zone.example.org (sorted)
|
||||||
|
comIdx := strings.Index(content, "zone.example.com")
|
||||||
|
orgIdx := strings.Index(content, "zone.example.org")
|
||||||
|
if comIdx >= orgIdx {
|
||||||
|
t.Error("expected zone.example.com before zone.example.org (alphabetical)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSSEC zone
|
||||||
|
assertContains(t, content, "dnssec-policy standard; inline-signing yes;")
|
||||||
|
// Non-DNSSEC zone should not have it
|
||||||
|
lines := splitLines(content)
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "example.com") && strings.Contains(line, "file") {
|
||||||
|
if strings.Contains(line, "dnssec-policy") {
|
||||||
|
t.Error("example.com should not have dnssec-policy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCLIBindConfMissingFile(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Zone without file= property
|
||||||
|
inputContent := "zone.example.org catalog1\n"
|
||||||
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
||||||
|
|
||||||
|
bindConfPath := filepath.Join(dir, "domains.conf")
|
||||||
|
|
||||||
|
cmd = exec.Command(binary,
|
||||||
|
"--config", filepath.Join(dir, "catz.yaml"),
|
||||||
|
"--output-dir", dir,
|
||||||
|
"--bind-conf", bindConfPath,
|
||||||
|
filepath.Join(dir, "zones.txt"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when file= is missing with --bind-conf")
|
||||||
|
}
|
||||||
|
assertContains(t, string(out), "missing file=")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user