Rewrite pack — designate — branch stable/2026.2
Branch
stable/2026.2@e3dd774db145— repository https://opendev.org/openstack/designate — analysed on 2026-10-29Target:
openkeel-designate, a Go re-implementation of the Designate API backed by ExternalDNS (recordsets becomeDNSEndpointCRs) 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 ofapis/andendpoint/),external-dns-operator:(release-4.16 @1a2a68d11dbb).
1. Contents of _rewrite/
| File | Use 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
sizelimitis 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 withv2.4asCURRENT(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.nextwhenever the page is full,metadata.total_countunlessOpenStack-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:
tenant_id := zone.tenant_id or context.project_id; policycreate_zonewith{project_id, tenant_id, zone_name}→ 403.- Catalog zones are refused through the API → 403 (
designate/central/service.py:3181). tenant_idmust not be null → 401missing_project_id(designate/central/service.py:389).- Quota:
count(zones where tenant_id) + 1 <= quota.zones→ 413 (designate/central/service.py:551). - 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 patternre.search-matching the name (20 ms per pattern, timeout = match) → 400 unlessuse_blacklisted_zone. - TTL ≥
min_ttlunlessuse_low_ttl→ 400. - 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 → 403illegal_parent(designate/central/service.py:335). - Scheduler →
pool_id(404 none, 500 several). - Non-default pool needs a
POOLTSIG key → 400. - 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). - Pool NS records non-empty → else 500.
- Persist: SECONDARY with no serial gets
serial = 1;refresh := floor(min + (max-min) * random()); SOA + NS recordsets createdmanagedwithserial = 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]/intervalfor zones flaggedincrement_serial; records takeserial = nowwhen 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 when100 × positives / total ≥ threshold_percentage;NO_ZONEwhen(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):
| Reported | Object before | Object after |
|---|---|---|
SUCCESS, serial ≥ obj.serial | PENDING/ERROR, action CREATE/UPDATE | NONE / ACTIVE |
SUCCESS, serial ≥ obj.serial | PENDING/ERROR, action DELETE | NONE / DELETED |
ERROR, serial ≥ obj.serial or serial = 0 | PENDING | status ERROR, action unchanged |
| NO_ZONE | action CREATE/UPDATE | CREATE / ERROR |
| NO_ZONE | action DELETE | NONE / 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); aftermax_prop_time(455 s), zones stillPENDINGare re-pushed every 120 s, zones inERRORforever (designate/worker/tasks/zone.py:896).
3.4 Recordset validation and record parsing
- Per type, the record text is parsed by
from_stringofdesignate/objects/rrdata_<type>.pyinto 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 storeddatais the text as sent [inferred: re-rendering is not applied]; uniqueness ismd5("<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) ∪ addedby 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
| File | Function | Cases | Coverage | Replay |
|---|---|---|---|---|
cases-parse-query-results | designate.worker.tasks.zone:parse_query_results | 9 | create/update/delete, no answer, no zone, mixed serials | 9/9 pass |
cases-zone-status | designate.central.service:Service._update_zone_or_record_status | 46 | SUCCESS/ERROR/NO_ZONE × CREATE/UPDATE/DELETE, zone and record objects | 46/46 pass |
cases-soa-record | designate.central.service:Service._build_soa_record | 201 | NS priorities, escaped emails, serial values | 201/201 pass |
cases-zonefile-import | designate.dnsutils:from_dnspython_zone | 7 | full zone, escaped SOA email, zero SOA TTL (config.min_ttl recorded), missing SOA | 7/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
| Expectation | Where 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 parameters | python-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 tolerated | python-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 delete | python-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/dns | python-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 § 1 | python-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 /v2 | neutron:neutron/services/externaldns/drivers/designate/driver.py:38 |
Neutron maps 404 → DNSDomainNotFound, 409 → DuplicateRecordSet, 413 → ExternalDNSOverQuota on recordset creation | neutron: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_projects | neutron:neutron/services/externaldns/drivers/designate/driver.py:174 |
Heat OS::Designate::Zone and OS::Designate::RecordSet poll GET until status != PENDING, raise on ERROR | heat: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_dir | skyline-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 id | skyline-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 == ACTIVE | skyline-console:src/pages/network/containers/DNS/Reverse/actions/Unset.jsx:34 |
Skyline only uses zones, zones/{id}/recordsets and reverse/floatingips | skyline-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)onquotas, and azone_tasks.zone_idforeign key (schema diff). - Fixed rows: the default pool
794ccc2c-d751-44fe-b57f-8894c9f5c842is 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, nativeUUIDon PostgreSQL (designate/storage/sqlalchemy/types.py:22) and rendered with dashes; soft-deleted zones setdeleted = <id hex>to keep(name, deleted, pool_id)unique;shard = int(id[0:3], 16)for zones andzone_shardfor recordsets/records (designate/storage/sqlalchemy/tables.py:56). - Locking: no optimistic concurrency (the
versioncolumn 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 exampleSELECT … FOR UPDATEon the zone row. - Integrity in code:
records.recordset_id,recordsets.zone_idhave foreign keys with cascades;zones.parent_zone_id,zones.pool_id, quota resources and TSIGresource_iddo 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 readsX-Service-Catalog(used to find Neutron) andOpenStack-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_defaultskeeps oslo.policy 6.0.1's default (true) and token scope is always checked (theenforce_scopeoption no longer exists in that release); Designate overrides neither (designate/common/config.py:18only sets CORS defaults). Operators'policy.yamlfiles use the same rule names: keep them. - Configuration:
designate.confgroupsservice: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-seedsare 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 eachdb_deltafirst, 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,recordscompared as sets). Requests carryX-Test-Role,X-Test-Tenant-ID,X-Test-All-Tenants; the seed'sextra.test_contextgives the default project of requests withoutX-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_sharebody,test_post_abandon_zone204 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 concept | openkeel-designate on ExternalDNS | Fidelity |
|---|---|---|
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) |
| Recordset | one 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 types | ExternalDNS 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) |
| SOA | owned 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 NS | owned 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 serial | provider'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 tasks | not 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_notifies | done by the primary DNS server itself (PowerDNS also-notify, BIND notify yes) for its secondaries | n/a for Designate; configure on the server |
| mdns AXFR/IXFR for pool servers | the 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 pool | PowerDNS ≥ 4.7 producer catalog, BIND catalog zones — server feature | none 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 import | parse the zone file (§ 3.5) into recordsets → DNSEndpoints; the file's SOA/NS are dropped as in Designate | exact for supported types; records of unsupported types → import ERROR (Designate would accept them) — document |
| Zone export | render 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 backend | exact; 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 sink | consumers of OpenKeel events creating managed recordsets | exact (same managed-record model) |
| Pools, pool targets, nameservers, the pools YAML file, scheduler filters | one 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 backend | partial: /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 zones | partial; keep the API for compatibility |
designate-worker, designate-producer, designate-mdns | replaced 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", seecases-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.
DNSEndpointStatusonly carriesobservedGeneration(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/typeuntil the answer equals the records (as a set), with the[service:worker]poll budget (poll_delay,poll_retry_interval,poll_max_retries) →ACTIVE, elseERROR; deletion → wait for NXDOMAIN/NODATA, then delete the row (Heat waits for the 404). - zone:
ACTIVEwhen the zone backend created it and the server answers its SOA authoritatively (AA); with the operator,ExternalDNS.status.zonesalso 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
PENDINGuntil the poll budget is exhausted, thenERROR, without a message. This is coarser than Designate (which knows when the backend refused a zone) but compatible with the clients. - The
update_statusstate 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'sbind9driver, 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_tenantis 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)
serialno longer moves on every change (or moves with the provider's rules).GET /v2/zones/{id}/nameserversreturns the provider's NS for cloud providers.SECONDARYzones are refused on providers without secondary zones;tasks/xfris a no-op or 400.- Unsupported record types (SPF, SSHFP, CAA, CERT, HTTPS, SVCB) are refused at creation and import.
POST /v2/zones/{id}/tasks/abandonleaves the DNSEndpoints in place unless they are deleted explicitly (Designate leaves the DNS server zone, which is the same intent).X-Designate-Hard-Deletehas no backend meaning.- TSIG keys and pool targets are informational.
9. Gaps found while building the pack
- Routes without documentation: the
/adminAPI;/v2/service_statusalias;GET …/tasks/abandon(405). Five policyoperationsname 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_sharesandfind_zone_transfer_requestsare@(filtered in storage). - 500s: json-patch PATCH (
NotImplementedError), abandon/pool-move handlers forcing 500 without a body,MultiplePoolsFoundandNoServersConfiguredas 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
fakebackend), 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.confregisters groups keyed byOptGroupobjects; 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
ERRORwith the defaults; ExternalDNS adds its sync interval (default 60 s). - Scale:
max_limit_v21000 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'stypestrings. - [ ] Policy rules and targets (§ 6), context flags and their policies,
all_projects/ sudo / managed / hard delete / delete shares headers. - [ ] Paging (
limit=max,links.nexton 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.