OpenKeel: the sovereign French Cloud solution of the inherent group, with a European reach Discover inherent

Reverse-spec · Services

designate — Rewrite pack

Rewrite pack — designate — branch stable/2026.2

Branch stable/2026.2 @ e3dd774db145 — repository https://opendev.org/openstack/designate — analysed on 2026-10-29

Target: openkeel-designate, a Go re-implementation of the Designate API backed by ExternalDNS (recordsets become DNSEndpoint CRs) and by the DNS server's own API for zones. Client repositories read for § 4, cited with a prefix: neutron: (stable/2026.2 @ eef1a0f7ee36), python-designateclient: (stable/2026.2 @ 9231eef90192, 7.0.0+2), heat: (stable/2026.2 @ 5872396e548c), skyline-console: (stable/2026.2 @ 806d200392ee). Target platform types: external-dns: (v0.24.0 @ c40894ac060e, sparse clone of apis/ and endpoint/), external-dns-operator: (release-4.16 @ 1a2a68d11dbb).

1. Contents of _rewrite/

FileUse in a rewrite
[routes_stable-2026.2.json](_rewrite/routes_stable-2026.2.json)the 78 operations: method, path, handler, success statuses, content types, UUID-checked parameters, body object, central RPC methods called and every policy rule enforced (with file:line). Authority for routing and authorisation
[route-probe_stable-2026.2.json](_rewrite/route-probe_stable-2026.2.json)proof that each path reaches its handler in the real Pecan apps (77/78; the 78th is the intentional 405 of GET …/tasks/abandon), and the five policy operations whose documented path matches no route
[openapi_stable-2026.2.json](_rewrite/openapi_stable-2026.2.json)OpenAPI 3.0.3 skeleton from the routes, the adapters and the api-ref (x-policies, x-central-calls, x-handler); request bodies from the adapters. Types and stubs, not behaviour
[request-schemas_stable-2026.2.json](_rewrite/request-schemas_stable-2026.2.json)per resource: the API_v2 adapter fields (rename, x-read-only, x-immutable) joined with the object field types and limits; per record type (rrdata): the rdata fields parsed from the record text
[api-ref_stable-2026.2.json](_rewrite/api-ref_stable-2026.2.json)the 68 documented operations with parameters and samples
[schema-sqlite-migrated_stable-2026.2.sql](_rewrite/schema-sqlite-migrated_stable-2026.2.sql)the schema production databases have (Alembic head f49c4409c8ba)
[schema-mysql_stable-2026.2.sql](_rewrite/schema-mysql_stable-2026.2.sql), [schema-postgresql_stable-2026.2.sql](_rewrite/schema-postgresql_stable-2026.2.sql)DDL generated from designate.storage.sqlalchemy.tables.metadata
[schema-diff_stable-2026.2.md](_rewrite/schema-diff_stable-2026.2.md)24 differences between the two: use the migrated schema (§ 5)
[policies_stable-2026.2.json](_rewrite/policies_stable-2026.2.json), [policies_stable-2026.2.yaml](_rewrite/policies_stable-2026.2.yaml)the 92 default rules (check strings, scopes, deprecated rules, operations)
[config_stable-2026.2.conf](_rewrite/config_stable-2026.2.conf)oslo-config-generator sample of designate.conf with every namespace of etc/designate/designate-config-generator.conf
[exchanges_stable-2026.2.json](_rewrite/exchanges_stable-2026.2.json)548 HTTP exchanges of the 300 functional API tests (v2 and admin), with test ids and db_delta
[fixture-seeds_stable-2026.2.json](_rewrite/fixture-seeds_stable-2026.2.json)per test: database rows at its first request, configuration and policy overrides, the test context's project
[response-schemas_stable-2026.2.json](_rewrite/response-schemas_stable-2026.2.json) / [.md](_rewrite/response-schemas_stable-2026.2.md)response JSON schemas inferred per operation and status (75 operations)
[observed-errors_stable-2026.2.md](_rewrite/observed-errors_stable-2026.2.md)228 (operation, status, type, message) combinations seen in the tests
[cases-parse-query-results_stable-2026.2.json](_rewrite/cases-parse-query-results_stable-2026.2.json), [cases-zone-status_stable-2026.2.json](_rewrite/cases-zone-status_stable-2026.2.json), [cases-soa-record_stable-2026.2.json](_rewrite/cases-soa-record_stable-2026.2.json), [cases-zonefile-import_stable-2026.2.json](_rewrite/cases-zonefile-import_stable-2026.2.json)263 vectors of the pure algorithms (§ 3.7)
[replay-report_stable-2026.2.json](_rewrite/replay-report_stable-2026.2.json)replay results of the vectors and of the HTTP exchanges (§ 7)
[tools/designate_routes.py](_rewrite/tools/designate_routes.py), [tools/schema_diff.py](_rewrite/tools/schema_diff.py)the extraction tools (Pecan walk + live probe; schema comparison)

Paths in exchanges, fixture-seeds, response-schemas and observed-errors have **no /v2 or /admin prefix**: the functional tests mount each application at / (designate/tests/functional/api/v2/__init__.py:41). /quotas/{id} therefore mixes the v2 and admin operations in the inferred schemas; the test id (…api.admin…) tells them apart.

Library versions used for the extraction: dnspython 2.8.0, SQLAlchemy 2.0.52, alembic 1.19.1, pecan 1.8.0, WebOb 1.8.11, oslo.messaging 18.3.1, oslo.db 18.1.1, oslo.policy 6.0.1, oslo.versionedobjects 3.12.0, keystonemiddleware 13.0.1, tooz 9.1.0, openstacksdk 4.20.0 (upper-constraints stable/2026.2), Python 3.12.

2. HTTP behaviour that the contracts do not show

2.1 Middleware pipeline

Outermost first (etc/designate/api-paste.ini:20): http_proxy_to_wsgi, cors, request_id, faultwrapper, validation_API_v2, sizelimit (oslo max_request_body_size, 114688 bytes), osprofiler, authtoken, keystonecontext, maintenance, normalizeuri, app. Consequences a rewrite must keep:

  • A missing or invalid token is answered by keystonemiddleware (401, its own body), before any Designate logic; maintenance (503) applies only to authenticated requests; the error body of the faultwrapper is produced for everything below it, so a 413 from sizelimit is not in Designate's format.
  • Context flags refused by policy answer 403 with an empty body (designate/api/middleware.py:168), unlike policy refusals in central (JSON 403).
  • Trailing slashes are ignored (designate/api/middleware.py:255): /v2/zones/ = /v2/zones.

2.2 Error body

{"code": <int>, "type": "<snake_case>", "message": "<text>", "request_id": "req-<uuid>"}, errors only for validation errors (§ errors). type is the stable machine contract: python-designateclient keeps it (python-designateclient:designateclient/exceptions.py:37); Designate's own tests assert on code and type (designate/tests/functional/api/v2/__init__.py:84). Keep the misspelled invalid_zone_transfer_request class name semantics and the duplicate_zone_attribute type reused by DuplicateZoneMaster (designate/exceptions.py:379).

2.3 Other cross-cutting rules

  • No microversions; GET / returns the static version list with v2.4 as CURRENT (designate/api/versions.py:67).
  • Status codes: 202 when the object is PENDING, 201/200 otherwise (§ REST API); GET /v2/recordsets/{id} is a 301.
  • Body keys: unknown keys are rejected (400), never ignored (designate/objects/adapters/base.py:189).
  • Collections: links.next whenever the page is full, metadata.total_count unless OpenStack-DNS-Hide-Counts; filters are exact matches with * wildcards; one value per filter.
  • Names: zone and recordset names are absolute (trailing dot) and returned as stored; the database comparison is case-insensitive on MySQL's default collation [inferred] but Designate does not lower-case names except when checking TLDs (designate/central/service.py:156).

3. Core algorithms: formal specification

3.1 Zone creation checks (order matters: it decides which error wins)

From designate/central/service.py:848:

  1. tenant_id := zone.tenant_id or context.project_id; policy create_zone with {project_id, tenant_id, zone_name} → 403.
  2. Catalog zones are refused through the API → 403 (designate/central/service.py:3181).
  3. tenant_id must not be null → 401 missing_project_id (designate/central/service.py:389).
  4. Quota: count(zones where tenant_id) + 1 <= quota.zones → 413 (designate/central/service.py:551).
  5. Name (designate/central/service.py:125): length ≤ max_zone_name_len; > 1 label; if any TLD exists, some suffix of the labels must be a TLD and the name must not equal one; blacklist: any pattern re.search-matching the name (20 ms per pattern, timeout = match) → 400 unless use_blacklisted_zone.
  6. TTL ≥ min_ttl unless use_low_ttl → 400.
  7. Ownership across all pools: exact name of another tenant → 409; the nearest existing ancestor (by label stripping) owned by another tenant → 403 illegal_child; any %.<name> of another tenant → 403 illegal_parent (designate/central/service.py:335).
  8. Scheduler → pool_id (404 none, 500 several).
  9. Non-default pool needs a POOL TSIG key → 400.
  10. Parent within the same pool and tenant → parent_zone_id; existing sub-zones of the same tenant are re-parented after creation (designate/central/service.py:943).
  11. Pool NS records non-empty → else 500.
  12. Persist: SECONDARY with no serial gets serial = 1; refresh := floor(min + (max-min) * random()); SOA + NS recordsets created managed with serial = zone.serial, without serial increment (designate/central/service.py:923).

3.2 SOA and NS synthesis

SOA rdata (designate/central/service.py:414): MNAME RNAME SERIAL REFRESH RETRY EXPIRE MINIMUM where MNAME = hostname of the pool NS record with the lowest priority (first one on ties), RNAME = email with every . before the @ escaped (\.) and @ replaced by ., plus a trailing dot. Defaults: ttl 3600, refresh 3500–3600, retry 600, expire 86400, minimum 3600 (designate/conf/base.py:42). The SOA is rewritten on every serial increment (designate/central/service.py:451); the apex NS recordset holds one record per pool NS record and follows pool changes. 201 recorded vectors (cases-soa-record).

3.3 Serial, statuses and propagation

  • Serial = current Unix time (designate/storage/sqlalchemy/__init__.py:628), set by the producer at most every [producer_task:increment_serial]/interval for zones flagged increment_serial; records take serial = now when changed (designate/central/service.py:1591). Serials therefore are not RFC 1982 increments but timestamps; two changes in the same second share a serial [inferred].
  • Poll outcome (designate/worker/tasks/zone.py:386), input = the serial each nameserver answered (None = no answer, 0 = no zone): for a deletion, positives = answers equal to 0; otherwise positives = answers ≥ zone serial, consensus_serial = the lowest positive answer, no_zones = answers equal to 0. Success when 100 × positives / total ≥ threshold_percentage; NO_ZONE when (total − no_zones) is below the threshold for a non-delete (designate/worker/tasks/zone.py:633). 9 vectors (cases-parse-query-results).
  • State machine _update_zone_or_record_status(obj, status, serial) (designate/central/service.py:2637) — 46 vectors (cases-zone-status):
ReportedObject beforeObject after
SUCCESS, serial ≥ obj.serialPENDING/ERROR, action CREATE/UPDATENONE / ACTIVE
SUCCESS, serial ≥ obj.serialPENDING/ERROR, action DELETENONE / DELETED
ERROR, serial ≥ obj.serial or serial = 0PENDINGstatus ERROR, action unchanged
NO_ZONEaction CREATE/UPDATECREATE / ERROR
NO_ZONEaction DELETENONE / DELETED
anything else—unchanged

update_status ignores a report whose action differs from the zone's current action, and turns a non-error report on a deleting zone into NO_ZONE (designate/central/service.py:2553).

  • Retries: per target poll_max_retries (10) × poll_retry_interval (15 s); after max_prop_time (455 s), zones still PENDING are re-pushed every 120 s, zones in ERROR forever (designate/worker/tasks/zone.py:896).

3.4 Recordset validation and record parsing

  • Per type, the record text is parsed by from_string of designate/objects/rrdata_<type>.py into typed fields (_rewrite/request-schemas_stable-2026.2.json → rrdata, 17 types), then validated by the field types (integer ranges, host names ending with a dot, quoted TXT strings ≤ 255 per string…). The stored data is the text as sent [inferred: re-rendering is not applied]; uniqueness is md5("<recordset_id>:<data>") (designate/storage/sqlalchemy/__init__.py:1178), so "10 mx.example." and "10 mx.example." are different records.
  • Placement rules (§ REST API) are applied in this order: TTL, name in zone, CNAME rules, not in a child zone, CNAME single record (designate/central/service.py:1551), then quotas (designate/central/service.py:1577).
  • PUT computes the new record set as (old − removed) ∪ added by data string, keeping the ids and statuses of unchanged records (designate/objects/adapters/api_v2/recordset.py:93).

3.5 Zone file import

dns.zone.from_text(text, relativize=False, check_origin=False); $ORIGIN mandatory; SOA required; an SOA TTL of 0 becomes min_ttl; zone ttl = SOA TTL, serial/refresh/retry/expire from the SOA, email from RNAME with \. unescaped (designate/dnsutils.py:64, designate/dnsutils.py:87); every other rdataset becomes a recordset (TTL omitted when 0); the apex SOA and NS are dropped and replaced by Designate's (designate/central/service.py:2916). Then the normal create_zone path (§ 3.1). 7 vectors (cases-zonefile-import, including a missing SOA and an escaped email). Exports render designate/resources/templates/export-zone.jinja2 from the database.

3.6 Floating IP PTR

Key <region>:<fip uuid> (designate/common/constants.py:84); reverse zone = the three first octets reversed + .in-addr.arpa. (IPv4 /24 only, designate/network_api/base.py:130); record name = dns.reversename.from_address(ip); the zone belongs to managed_resource_tenant_id with managed_resource_email and is created on demand; the PTR record carries managed_resource_type = ptr:floatingip, managed_resource_id = <fip id>, managed_resource_region, managed_extra = <address>, managed_tenant_id = <project> (designate/central/service.py:2101). Listing deletes PTRs whose floating IP moved to another project (designate/central/service.py:1961).

3.7 Test cases of the algorithms

FileFunctionCasesCoverageReplay
cases-parse-query-resultsdesignate.worker.tasks.zone:parse_query_results9create/update/delete, no answer, no zone, mixed serials9/9 pass
cases-zone-statusdesignate.central.service:Service._update_zone_or_record_status46SUCCESS/ERROR/NO_ZONE × CREATE/UPDATE/DELETE, zone and record objects46/46 pass
cases-soa-recorddesignate.central.service:Service._build_soa_record201NS priorities, escaped emails, serial values201/201 pass
cases-zonefile-importdesignate.dnsutils:from_dnspython_zone7full zone, escaped SOA email, zero SOA TTL (config.min_ttl recorded), missing SOA7/7 pass

All are deterministic. The zone-status before state is captured before the call (the function mutates its argument); the zone files are rendered before the call (an SOA TTL of 0 is overwritten by the function).

4. What the clients expect

ExpectationWhere the client relies on it
A zone is found by name with GET /v2/zones?name=<fqdn> returning exactly one item (two zones with the same name for the same project, e.g. split horizon in two pools, breaks name resolution with NoUniqueMatch)python-designateclient:designateclient/v2/utils.py:24
Recordset names without a trailing dot are completed client-side with GET /v2/zones/{zone}python-designateclient:designateclient/v2/recordsets.py:23
Paging follows links.next and re-sends its query parameterspython-designateclient:designateclient/v2/base.py:36
Error statuses map to exception classes: 400 BadRequest, 401/403 Forbidden, 404 NotFound, 409 Conflict, 413 OverQuota, ≥ 500 Unknown; the body is splatted into the exception, unknown keys ignored, non-JSON bodies toleratedpython-designateclient:designateclient/v2/client.py:99
Headers X-Auth-All-Projects, X-Designate-Edit-Managed-Records, X-Designate-Hard-Delete, X-Auth-Sudo-Project-ID with True/False strings; X-Designate-Delete-Shares: true on zone deletepython-designateclient:designateclient/v2/client.py:65, python-designateclient:designateclient/v2/zones.py:76
Zone import sends text/dns (raw zone file) or application/json {zonefile, attributes}; the export file is fetched with Accept: text/dnspython-designateclient:designateclient/v2/zones.py:179, python-designateclient:designateclient/v2/zones.py:173
PTR set/unset: PATCH /v2/reverse/floatingips/<region>:<id> with {"ptrdname": <name or null>, "description", "ttl"}python-designateclient:designateclient/v2/reverse.py:20
The OSC plugin exposes 60 commands (zone *, recordset *, ptr record *, zone transfer *, zone share *, zone import/export *, tld *, tsigkey *, zone blacklist *, dns quota *, dns service *, dns limit list) — all on the routes of § 1python-designateclient:pyproject.toml:43
Neutron uses the user's token on [designate]/url for forward records and its own credentials with edit_managed=True for PTRs; [designate]/url includes /v2neutron:neutron/services/externaldns/drivers/designate/driver.py:38
Neutron maps 404 → DNSDomainNotFound, 409 → DuplicateRecordSet, 413 → ExternalDNSOverQuota on recordset creationneutron:neutron/services/externaldns/drivers/designate/driver.py:70
Neutron creates a missing reverse zone <prefix>.in-addr.arpa. (or ip6.arpa., prefix sizes from its config) itself, with email admin@<domain>, and treats 409 as "already exists"neutron:neutron/services/externaldns/drivers/designate/driver.py:103
Neutron deletes a record with the user client first and falls back to the admin client on 400 (managed records refuse the user client with 400, not 403)neutron:neutron/services/externaldns/drivers/designate/driver.py:154
Neutron lists recordsets with criterion={"name": <fqdn>} and filters records itself; 403 on the zone counts as "not found" and is retried with all_projectsneutron:neutron/services/externaldns/drivers/designate/driver.py:174
Heat OS::Designate::Zone and OS::Designate::RecordSet poll GET until status != PENDING, raise on ERRORheat:heat/engine/resources/openstack/designate/zone.py:164, heat:heat/engine/resources/openstack/designate/recordset.py:109
Heat considers a delete complete only when GET answers 404 (designateclient NotFound)heat:heat/engine/resources/openstack/designate/zone.py:197, heat:heat/engine/resources/openstack/designate/recordset.py:153
Heat resolves zones by id, then by name (zones.list(criterion={'name': …}))heat:heat/engine/clients/os/designate.py:41
Heat sends type_, email, ttl, description, masters and updates zones with PATCH; OS::Designate::Domain / Record are hidden (v1 API gone)heat:heat/engine/resources/openstack/designate/zone.py:156, heat:heat/engine/resources/openstack/designate/domain.py:25
Skyline sends x-auth-all-projects: true|false on lists and details, reads metadata.total_count for the page count, sorts with sort_key/sort_dirskyline-console:src/stores/designate/base.js:30, skyline-console:src/stores/designate/base.js:61
Skyline updates zones with PATCH and recordsets with PUT (update), deletes recordsets by zone and idskyline-console:src/stores/designate/zones.js:31, skyline-console:src/client/client/base.js:196
Skyline's reverse DNS page uses the item id (<region>:<uuid>) and unsets with {"ptrdname": null}; "Unset" is offered only for status == ACTIVEskyline-console:src/pages/network/containers/DNS/Reverse/actions/Unset.jsx:34
Skyline only uses zones, zones/{id}/recordsets and reverse/floatingipsskyline-console:src/client/designate/index.js:21

5. Database strategy

  • Reuse the migrated schema (schema-sqlite-migrated), not the table definitions: the migrations add indexes the tables lack (zones (tenant_id, deleted), zones (delayed_notify), zones (increment_serial), recordsets (type, name), records (status, zone_id, tenant_id, created_at, serial)…), the unique (tenant_id, resource) on quotas, and a zone_tasks.zone_id foreign key (schema diff).
  • Fixed rows: the default pool 794ccc2c-d751-44fe-b57f-8894c9f5c842 is inserted by a migration (designate/conf/central.py:41); everything else comes from the pools YAML file.
  • Identifiers: UUIDs stored as CHAR(32) hex without dashes on MySQL, native UUID on PostgreSQL (designate/storage/sqlalchemy/types.py:22) and rendered with dashes; soft-deleted zones set deleted = <id hex> to keep (name, deleted, pool_id) unique; shard = int(id[0:3], 16) for zones and zone_shard for recordsets/records (designate/storage/sqlalchemy/tables.py:56).
  • Locking: no optimistic concurrency (the version column is incremented but never checked); per-zone distributed lock in central (zone-<id>, create-new-zone); deadlocks retried 150 × 50 ms with the whole method re-run. A rewrite with transactions per request should keep "one writer per zone" semantics, for example SELECT … FOR UPDATE on the zone row.
  • Integrity in code: records.recordset_id, recordsets.zone_id have foreign keys with cascades; zones.parent_zone_id, zones.pool_id, quota resources and TSIG resource_id do not.
  • Secrets: TSIG secrets are stored in clear.

6. Authentication, policies, configuration and CLI

  • Auth: keystonemiddleware auth_token + KeystoneContextMiddleware (designate/api/middleware.py:132); the context also reads X-Service-Catalog (used to find Neutron) and OpenStack-System-Scope.
  • Policies: 92 oslo.policy rules (_rewrite/policies_stable-2026.2.yaml) with targets built in central (project_id, tenant_id, zone_type, zone_shared, recordset_project_id, target_project_id, all_tenants); enforce_new_defaults keeps oslo.policy 6.0.1's default (true) and token scope is always checked (the enforce_scope option no longer exists in that release); Designate overrides neither (designate/common/config.py:18 only sets CORS defaults). Operators' policy.yaml files use the same rule names: keep them.
  • Configuration: designate.conf groups service:api, service:central, service:worker, service:producer, producer_task:*, service:mdns, service:sink, handler:*, network_api:neutron, storage:sqlalchemy, coordination, heartbeat_emitter, keystone, keystone_authtoken (_rewrite/config_stable-2026.2.conf). Options a rewrite on ExternalDNS still needs: [service:api] paging limits and flags, [service:central] (min_ttl, max_*_len, managed_resource_*, default_pool_id, scheduler_filters), quotas, supported_record_type, default_soa_*, [service:worker] poll options (re-used for ExternalDNS status checks), [network_api:neutron], [handler:*].
  • CLI: designate-manage database sync|version, pool update|show_config|generate_file (from the pools YAML file), tlds import, service clean (designate/manage/). the pools YAML file is the operators' interface to the pool model (etc/designate/pools.yaml.sample).

7. Conformance strategy

  • HTTP suite: exchanges + fixture-seeds are a language-independent suite. Per test: empty database (migrated schema) → load the seed rows (UUID keys, no re-mapping needed) → apply the seed configuration and policy overrides → send the recorded requests in order, applying each db_delta first, substituting the UUIDs and transfer keys the server generated (pair them by walking recorded and replayed bodies) → compare status and normalised body (UUIDs numbered, timestamps, serials ≥ 10⁹ and request ids templated, records compared as sets). Requests carry X-Test-Role, X-Test-Tenant-ID, X-Test-All-Tenants; the seed's extra.test_context gives the default project of requests without X-Test-Tenant-ID — a rewrite needs a test-only middleware with the same semantics (designate/api/middleware.py:190).
  • Proof on the original: replay of the 299 tests with exchanges: 501/548 exchanges identical. The 47 others: 29 come from tests that patch central or storage with mocks (forced MessagingTimeout, exceptions), 10 from the asynchronous import thread racing the next request, 6 from floating IPs held in memory by the fake network API, 2 unexplained (test_delete_zone_share body, test_post_abandon_zone 204 vs 403) (_rewrite/replay-report_stable-2026.2.json). These 47 must be checked by reading the test, not by replay.
  • Algorithm vectors: § 3.7, 263/263 pass on the original.
  • Original fixtures for reference: designate/tests/functional/__init__.py:349 (services, database, policy), designate/tests/functional/api/v2/__init__.py:37 (app stack), designate/tests/base_fixtures.py:136 (golden migrated SQLite database).
  • Black-box suite: designate-tempest-plugin (branchless, cloned at master @ a34f83a38b45) against a deployed openkeel-designate; not run here.
  • DNS side: there is no recorded DNS wire data: mdns, NOTIFY and AXFR disappear with ExternalDNS (§ 8); the conformance of the DNS side is "the authoritative server answers the records" (§ 8.4).

8. ExternalDNS backend: what can and cannot be reproduced

In Designate the DNS servers are secondaries: the worker only creates or deletes an empty slave zone on each target (§ interactions), then the server pulls the whole zone by AXFR from designate-mdns, which renders it from the database with Designate's SOA and NS. With ExternalDNS, records are pushed to the provider one name at a time and the provider owns the zone. Everything that relied on mdns being the master changes.

8.1 Mapping

Designate conceptopenkeel-designate on ExternalDNSFidelity
Zone create/delete (backend.create_zone/delete_zone)zone backend driver calling the DNS server API as a primary/native zone (PowerDNS kind: Native or Master, Route 53 hosted zone, Infoblox authoritative zone…), then register the zone with ExternalDNS (--domain-filter, operator spec.domains)partial: a new driver contract, not Designate's (Designate's pdns4 creates kind: slave, designate/backend/impl_pdns4.py:124)
Recordsetone DNSEndpoint per recordset: dnsName (no trailing dot), recordType, targets = all records, recordTTL = recordset TTL or zone TTL (external-dns:apis/v1alpha1/dnsendpoint.go:53, external-dns:endpoint/endpoint.go:319)exact for A, AAAA, CNAME, TXT, SRV, NS (non-apex), PTR, MX, NAPTR, TLSA
Record typesExternalDNS knows A, AAAA, CNAME, TXT, SRV, NS, PTR, MX, NAPTR, DNAME, TLSA (external-dns:endpoint/endpoint.go:76)none for SPF, SSHFP, CAA, CERT, HTTPS, SVCB: remove them from supported_record_type so the API answers Designate's own 400 invalid_object "'X' is not a supported record type" (designate/objects/recordset.py:127)
SOAowned by the DNS server (provider-generated MNAME/RNAME, its own serial policy)none for content: Designate's SOA (pool NS with lowest priority, zone email, timestamp serial, randomised refresh) cannot be imposed through ExternalDNS. Keep the SOA recordset in the database (API shows it, 400 on writes) and set what the provider API allows at zone creation (PowerDNS: soa_edit_api, RNAME via the SOA record of the zone create call) [inferred]
Apex NSowned by the DNS server / provider (Route 53 assigns its own name servers)partial: with PowerDNS/BIND the zone backend can set the pool NS records at creation; with public clouds the API must report the provider's NS (GET …/nameservers) — a behaviour change
Zone serialprovider's serial (PowerDNS increments on change when soa_edit_api is set; Route 53 keeps 1)none for Designate's semantics: the serial field must be read back from the provider (SOA query) or kept as Designate's timestamp without meaning on the wire. Do not use it for propagation checks
increment_serial / delayed_notify batching, producer increment_serial and delayed_notify tasksnot needed: ExternalDNS reconciles on its own interval (--interval, default 1 min)n/a; replace the 5 s batching by "debounce DNSEndpoint writes per zone" if the provider rate-limits
NOTIFY to targets and also_notifiesdone by the primary DNS server itself (PowerDNS also-notify, BIND notify yes) for its secondariesn/a for Designate; configure on the server
mdns AXFR/IXFR for pool serversthe provider replicates to its own secondaries (PowerDNS AXFR to its slaves, cloud providers internally)n/a; external secondaries that pulled from mdns must be re-pointed at the primary server
Catalog zones (RFC 9432) per poolPowerDNS ≥ 4.7 producer catalog, BIND catalog zones — server featurenone in ExternalDNS; optional zone-backend feature
SECONDARY zones (type: SECONDARY, masters, NOTIFY to mdns, tasks/xfr, periodic_secondary_refresh)zone backend creates a secondary zone on the DNS server with the masters; ExternalDNS must exclude it (it would fight the AXFR)partial (PowerDNS/BIND); none on Route 53/Azure/GCP (no secondary zones) → refuse type: SECONDARY with 400 there
Zone transfer requests/accepts (ownership)pure database operation, unchanged: update tenant_id, relabel the zone's DNSEndpoints (openkeel.io/project-id)exact
Zone importparse the zone file (§ 3.5) into recordsets → DNSEndpoints; the file's SOA/NS are dropped as in Designateexact for supported types; records of unsupported types → import ERROR (Designate would accept them) — document
Zone exportrender from the database (export-zone.jinja2 equivalent)exact, but the SOA/NS shown are the database's, not necessarily the provider's
PTR for floating IPs (/v2/reverse/floatingips)recordsets of type PTR in reverse zones owned by managed_resource_tenant_id, created through the zone backendexact; the reverse zone must be delegated to the provider, and for public clouds reverse zones of public IPs are usually not delegable → PTR only works for address space the operator controls
Neutron/Nova sinkconsumers of OpenKeel events creating managed recordsetsexact (same managed-record model)
Pools, pool targets, nameservers, the pools YAML file, scheduler filtersone dns_backends[] entry per former pool → one ExternalDNS instance (Helm chart or ExternalDNS CR) with a label filter; pool_id and zone attributes still select the backendpartial: /v2/pools stays read-only with ns_records and attributes; targets, masters, also_notifies lose their meaning
TSIG keys (/v2/tsigkeys)only meaningful for RFC 2136 backends (ExternalDNS --rfc2136-tsig-*) and secondary zonespartial; keep the API for compatibility
designate-worker, designate-producer, designate-mdnsreplaced by a reconciler in openkeel-designate that writes DNSEndpoints and checks the server—

8.2 The DNSEndpoint

apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
  name: rs-<recordset uuid>
  namespace: openkeel-dns
  labels: {openkeel.io/dns-backend: default, openkeel.io/zone-id: <zone uuid>, openkeel.io/project-id: <project>}
spec:
  endpoints:
    - dnsName: www.example.org
      recordType: MX
      targets: ["10 mail.example.org"]
      recordTTL: 3600
  • MX/SRV/NAPTR keep their numeric fields in the target string; TXT targets are given unquoted (ExternalDNS quotes); Designate stores TXT data with quotes ("abc" "def", see cases-zonefile-import) → strip one level of quoting, and multi-string TXT records need one target per record [inferred].
  • Absolute names: Designate names end with a dot, ExternalDNS names do not; CNAME/MX/NS/PTR targets too.
  • DNSEndpointStatus only carries observedGeneration (external-dns:apis/v1alpha1/dnsendpoint.go:61): it does not say whether the provider accepted the record.
  • Ownership: ExternalDNS writes a TXT registry record per name (--txt-owner-id, --txt-prefix) and only touches names it owns. A tenant TXT recordset with the registry's name would collide: use a prefix, and refuse recordset names starting with it.
  • A zone apex CNAME is refused by Designate already; ExternalDNS would create it on some providers — keep Designate's validation in front.

8.3 Statuses (keep PENDING → ACTIVE/ERROR)

Clients depend on the statuses (Heat waits for != PENDING, raises on ERROR; Skyline shows them; § 4). Without mdns and SOA serial polling, ACTIVE must be derived from the server:

  • recordset: query the zone's authoritative server(s) for name/type until the answer equals the records (as a set), with the [service:worker] poll budget (poll_delay, poll_retry_interval, poll_max_retries) → ACTIVE, else ERROR; deletion → wait for NXDOMAIN/NODATA, then delete the row (Heat waits for the 404).
  • zone: ACTIVE when the zone backend created it and the server answers its SOA authoritatively (AA); with the operator, ExternalDNS.status.zones also lists the zones ExternalDNS manages (external-dns-operator:api/v1beta1/externaldns_types.go:580).
  • errors: ExternalDNS reports provider errors only in its logs and metrics: a rejected record stays PENDING until the poll budget is exhausted, then ERROR, without a message. This is coarser than Designate (which knows when the backend refused a zone) but compatible with the clients.
  • The update_status state machine (§ 3.3) can be kept unchanged, fed by the reconciler instead of the worker.

8.4 Deployment choices

  • External DNS Operator only supports AWS, GCP, Azure, BlueCat and Infoblox (external-dns-operator:api/v1beta1/externaldns_types.go:379), with a CRD source and a label filter (external-dns-operator:api/v1beta1/externaldns_types.go:454). Use it for those; PowerDNS, RFC 2136 (BIND, Knot) and CoreDNS need the ExternalDNS Helm chart with --source=crd.
  • RFC 2136 servers (BIND, Knot) cannot create zones through ExternalDNS: the zone backend needs rndc addzone (as Designate's bind9 driver, but as a primary zone) or a catalog zone; record updates then go through dynamic updates signed with TSIG.
  • Infoblox backends in Designate create secondary zones in a DNS view per project when multi_tenant is set (designate/backend/impl_infoblox.py:85); ExternalDNS writes to one view per instance → one ExternalDNS instance per view.

8.5 Behaviour that changes for clients (to document in openkeel-designate)

  1. serial no longer moves on every change (or moves with the provider's rules).
  2. GET /v2/zones/{id}/nameservers returns the provider's NS for cloud providers.
  3. SECONDARY zones are refused on providers without secondary zones; tasks/xfr is a no-op or 400.
  4. Unsupported record types (SPF, SSHFP, CAA, CERT, HTTPS, SVCB) are refused at creation and import.
  5. POST /v2/zones/{id}/tasks/abandon leaves the DNSEndpoints in place unless they are deleted explicitly (Designate leaves the DNS server zone, which is the same intent).
  6. X-Designate-Hard-Delete has no backend meaning.
  7. TSIG keys and pool targets are informational.

9. Gaps found while building the pack

  • Routes without documentation: the /admin API; /v2/service_status alias; GET …/tasks/abandon (405). Five policy operations name paths that do not exist (route-probe, e.g. GET /v2/zones/tasks/transfer_requests/{zone_transfer_accept_id}).
  • **No policy on reads of /v2/reverse/floatingips** (Neutron's own filtering by the user's token is the only control); find_zone_shares and find_zone_transfer_requests are @ (filtered in storage).
  • 500s: json-patch PATCH (NotImplementedError), abandon/pool-move handlers forcing 500 without a body, MultiplePoolsFound and NoServersConfigured as 500.
  • IPv6 floating IP PTR: the reverse zone computation only handles IPv4 [inferred].
  • Not captured: mdns DNS frames, worker/backend exchanges (the functional worker tests use the fake backend), sink end to end, designate-tempest-plugin.
  • Harness findings (fixed in the skill, not in Designate): the oslo.service backend must be selected before importing Designate (otherwise green threads never run and zone imports hang); designate.conf registers groups keyed by OptGroup objects; the recorder now keeps static methods static and lets adapters snapshot mutated arguments (before_call).

10. Non-functional requirements

  • One central write path per zone (lock or row lock); the producer's sharding is replaced by the reconciler's own work queue — keep "one push per zone at a time".
  • API latency: every Designate call is one RPC round trip plus SQL; the slowest is GET /v2/reverse/floatingips (Neutron in every region, 5 in parallel).
  • Propagation budget: 455 s before ERROR with the defaults; ExternalDNS adds its sync interval (default 60 s).
  • Scale: max_limit_v2 1000 items per page; quotas default 10 zones, 500 recordsets and records per zone, 20 records per recordset, export of 1000 recordsets.

11. Rewrite checklist

  • [ ] Routes and statuses of routes_stable-2026.2.json, including 202 vs 201/200, 301 for /v2/recordsets/{id}, empty-body 401/403 from the context middleware.
  • [ ] Error body {code, type, message, request_id[, errors]} with Designate's type strings.
  • [ ] Policy rules and targets (§ 6), context flags and their policies, all_projects / sudo / managed / hard delete / delete shares headers.
  • [ ] Paging (limit=max, links.next on full pages, metadata.total_count, hide-counts header), exact filters with *.
  • [ ] Zone creation checks in the order of § 3.1; recordset validation of § 3.4 with the 17 rdata parsers (minus the types ExternalDNS cannot serve).
  • [ ] SOA/NS synthesis in the database (§ 3.2), serial as Unix time, state machine of § 3.3 (46 vectors).
  • [ ] Zone import (§ 3.5, 7 vectors), export template, transfer requests/accepts, shares, quotas, limits, TLDs, blacklists, TSIG keys, read-only pools.
  • [ ] Floating IP PTR (§ 3.6) through Neutron with the user's token.
  • [ ] DNSEndpoint writer and status reconciler (§ 8.2, 8.3); zone backend drivers (PowerDNS API first).
  • [ ] Migrated schema and default pool row; designate-manage-compatible pool import from the pools YAML file.
  • [ ] Replay of exchanges + fixture-seeds (target: 501/548 as the original), plus designate-tempest-plugin.
  • [ ] Client checks: python-designateclient/OSC, Neutron's driver, Heat resources, Skyline pages (§ 4).

Source: reverse-spec-designate/07-rewrite-pack_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.