Repository: octodns/octodns

Issue:
OwnershipProcessor causes DuplicateRecordException in alias zones
First of all, thank you for octoDNS!

When trying to configure alias zones, I encountered the following error:

```
strutztm@nixos ~/octodns> octodns-sync --config-file ./octodns.yaml
2025-08-25T16:47:09  [139782469125952] INFO  Manager __init__: config_file=./octodns.yaml, (octoDNS 1.13.0)
2025-08-25T16:47:09  [139782469125952] INFO  Manager _config_executor: max_workers=1
2025-08-25T16:47:09  [139782469125952] INFO  Manager _config_include_meta: include_meta=False
2025-08-25T16:47:09  [139782469125952] INFO  Manager _config_enable_checksum: enable_checksum=False
2025-08-25T16:47:09  [139782469125952] INFO  Manager _config_auto_arpa: auto_arpa=False
2025-08-25T16:47:09  [139782469125952] INFO  Manager __init__: global_processors=[]
2025-08-25T16:47:09  [139782469125952] INFO  Manager __init__: global_post_processors=[]
2025-08-25T16:47:09  [139782469125952] INFO  Manager __init__: provider=config (octodns.provider.yaml 1.13.0)
2025-08-25T16:47:09  [139782469125952] INFO  Manager __init__: provider=target (octodns.provider.yaml 1.13.0)
2025-08-25T16:47:09  [139782469125952] INFO  Manager __init__: processor=ownership (octodns.processor.ownership 1.13.0)
2025-08-25T16:47:09  [139782469125952] INFO  Manager sync: eligible_zones=[], eligible_targets=[], dry_run=True, force=False, plan_output_fh=<stdout>, checksum=None
2025-08-25T16:47:09  [139782469125952] INFO  Manager sync:   zone=example.com.
2025-08-25T16:47:09  [139782469125952] INFO  Manager sync:     sources=['config']
2025-08-25T16:47:09  [139782469125952] INFO  Manager sync:     processors=['ownership']
2025-08-25T16:47:09  [139782469125952] INFO  Manager sync:     targets=['target']
2025-08-25T16:47:09  [139782469125952] INFO  Manager sync:   zone=example.net.
2025-08-25T16:47:09  [139782469125952] INFO  YamlProvider[config] populate:   found 1 records, exists=True
2025-08-25T16:47:09  [139782469125952] INFO  YamlProvider[target] plan: desired=example.com.
2025-08-25T16:47:09  [139782469125952] INFO  YamlProvider[target] populate:   found 0 records, exists=False
2025-08-25T16:47:09  [139782469125952] INFO  YamlProvider[target] plan:   Creates=2, Updates=0, Deletes=0, Existing=0, Meta=False
Traceback (most recent call last):
  File "/nix/store/1wfckz43viymdayxba90wzc8v991k0bm-octodns-1.13.0/bin/.octodns-sync-wrapped", line 9, in <module>
    sys.exit(main())
             ~~~~^^
  File "/nix/store/1wfckz43viymdayxba90wzc8v991k0bm-octodns-1.13.0/lib/python3.13/site-packages/octodns/cmds/sync.py", line 62, in main
    manager.sync(
    ~~~~~~~~~~~~^
        eligible_zones=args.zone,
        ^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<4 lines>...
        checksum=args.checksum,
        ^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/nix/store/1wfckz43viymdayxba90wzc8v991k0bm-octodns-1.13.0/lib/python3.13/site-packages/octodns/manager.py", line 808, in sync
    plans += [p for f in futures for p in f.result()[0]]
                                          ~~~~~~~~^^
  File "/nix/store/1wfckz43viymdayxba90wzc8v991k0bm-octodns-1.13.0/lib/python3.13/site-packages/octodns/manager.py", line 63, in result
    return self.func(*self.args, **self.kwargs)
           ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/nix/store/1wfckz43viymdayxba90wzc8v991k0bm-octodns-1.13.0/lib/python3.13/site-packages/octodns/manager.py", line 524, in _populate_and_plan
    zone = processor.process_source_zone(zone, sources=sources)
  File "/nix/store/1wfckz43viymdayxba90wzc8v991k0bm-octodns-1.13.0/lib/python3.13/site-packages/octodns/processor/ownership.py", line 40, in process_source_zone
    desired.add_record(txt, lenient=True)
    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
  File "/nix/store/1wfckz43viymdayxba90wzc8v991k0bm-octodns-1.13.0/lib/python3.13/site-packages/octodns/zone.py", line 190, in add_record
    raise DuplicateRecordException(
    ...<3 lines>...
    )
octodns.zone.DuplicateRecordException: Duplicate record _owner.ns.example.net., type TXT
```

This seems to be happening for every record type, not just NS records. I can reproduce the error with the following config:

octodns.yaml:
```yaml
---
processors:
  ownership:
    class: octodns.processor.ownership.OwnershipProcessor
providers:
  config:
    class: octodns.provider.yaml.YamlProvider
    directory: ./config
  target:
    class: octodns.provider.yaml.YamlProvider
    directory: ./target
zones:
  'example.com.':
    processors:
      - ownership
    sources:
      - config
    targets:
      - target
  'example.net.':
    alias: 'example.com.'
```

config/example.com.yaml:
```yaml
---
? ''
: - ttl: 300
    type: NS
    values:
    - dns1.example.com.

```


Discussion:
Good catch. Fix is fairly simple, new option for the `OwnershipProcessor`, `should_replace`, that tells it to replace on duplicate ownership records rather than throw the error. https://github.com/octodns/octodns/pull/1298



Patch:
diff --git a/.changelog/5f9e8b0b6b7f4093b109a56d12239302.md b/.changelog/5f9e8b0b6b7f4093b109a56d12239302.md
new file mode 100644
index 000000000..fc3d2b533
--- /dev/null
+++ b/.changelog/5f9e8b0b6b7f4093b109a56d12239302.md
@@ -0,0 +1,4 @@
+---
+type: minor
+---
+Add OwnershipProcessor.should_replace to support ownership on alias zones
diff --git a/octodns/processor/ownership.py b/octodns/processor/ownership.py
index d17996a5e..ed9346374 100644
--- a/octodns/processor/ownership.py
+++ b/octodns/processor/ownership.py
@@ -14,16 +14,27 @@
 # and thus "own" them going forward.
 class OwnershipProcessor(BaseProcessor):
     def __init__(
-        self, name, txt_name='_owner', txt_value='*octodns*', txt_ttl=60
+        self,
+        name,
+        txt_name='_owner',
+        txt_value='*octodns*',
+        txt_ttl=60,
+        should_replace=False,
     ):
         super().__init__(name)
         self.txt_name = txt_name
         self.txt_value = txt_value
         self.txt_ttl = txt_ttl
         self._txt_values = [txt_value]
+        self.should_replace = should_replace
 
     def process_source_zone(self, desired, *args, **kwargs):
         for record in desired.records:
+            if self._is_ownership(record):
+                # don't apply ownership to existing ownership recorcs, most
+                # likely to see this in an alias zone that will be proccessed
+                # once as the original and a 2nd time as the alias
+                continue
             # Then create and add an ownership TXT for each of them
             record_name = record.name.replace('*', '_wildcard')
             if record.name:
@@ -37,7 +48,7 @@ def process_source_zone(self, desired, *args, **kwargs):
             )
             # add these w/lenient to cover the case when the ownership record
             # for a NS delegation record should technically live in the subzone
-            desired.add_record(txt, lenient=True)
+            desired.add_record(txt, lenient=True, replace=self.should_replace)
 
         return desired
 


Test patch:
diff --git a/tests/test_octodns_processor_ownership.py b/tests/test_octodns_processor_ownership.py
index 95682e59b..052a67c38 100644
--- a/tests/test_octodns_processor_ownership.py
+++ b/tests/test_octodns_processor_ownership.py
@@ -9,7 +9,7 @@
 from octodns.processor.ownership import OwnershipProcessor
 from octodns.provider.plan import Plan
 from octodns.record import Delete, Record
-from octodns.zone import Zone
+from octodns.zone import DuplicateRecordException, Zone
 
 zone = Zone('unit.tests.', [])
 records = {}
@@ -150,3 +150,30 @@ def test_remove_last_change(self):
         self.assertEqual(1, len(plan.changes))
         plan = ownership.process_plan(plan)
         self.assertFalse(plan)
+
+    def test_should_replace(self):
+        ownership = OwnershipProcessor('ownership')
+        self.assertFalse(ownership.should_replace)
+
+        zone = Zone('unit.tests.', [])
+        record = Record.new(
+            zone, 'a', {'ttl': 30, 'type': 'A', 'value': '4.4.4.4'}
+        )
+        zone.add_record(record)
+
+        got = ownership.process_source_zone(zone.copy())
+        self.assertEqual(
+            ['_owner.a.a', 'a'], sorted([r.name for r in got.records])
+        )
+
+        # will fail w/a duplicate
+        with self.assertRaises(DuplicateRecordException):
+            ownership.process_source_zone(got.copy())
+
+        # enable should_replace, will replace instead of failing
+        ownership.should_replace = True
+        got = ownership.process_source_zone(got.copy())
+        # same expected result
+        self.assertEqual(
+            ['_owner.a.a', 'a'], sorted([r.name for r in got.records])
+        )