Client contract (rewrite pack) — python-designateclient — branch stable/2026.2
Branch
stable/2026.2@9231eef90192— repository https://opendev.org/openstack/python-designateclient — analysed on 2026-11-01
This pack serves two purposes:
- Keep the commands and the Python API working against a re-implemented DNS service, by stating what they require.
- Re-implement the commands, from the machine-readable catalogue.
No replay against a real service was possible. Everything on the wire comes from a local stub, and the requirements below are what the client does with a response, not what Designate does. See § 5.
1. Contents of _rewrite/
| File | Content | Use |
|---|---|---|
[cli-commands_stable-2026.2.json](_rewrite/cli-commands_stable-2026.2.json) | the 60 commands: class, purpose, each of the 265 arguments (names, type, default, choices, required, exclusive groups, help), client calls, controller routes with file:line, errors raised | generate a parser; check argument names |
[client-contract_stable-2026.2.json](_rewrite/client-contract_stable-2026.2.json) | per command: client methods, observed requests with headers and body, static routes, what the response must hold, unit vector count, exit codes per stub mode | requirements per command |
[api-surface_stable-2026.2.md](_rewrite/api-surface_stable-2026.2.md) | the 61 operations with their commands, whether they exist in Designate's route list, the 2 Python-only entries, and the Designate routes never called | gap analysis of a service |
[unit-vectors_stable-2026.2.json](_rewrite/unit-vectors_stable-2026.2.json) | 97 vectors. kind: api: Python call → HTTP method, URL, headers, body. kind: command: argv → calls on the mock. Normalised ({uuid}) | test wire format without Python |
[wire-probe-404_stable-2026.2.json](_rewrite/wire-probe-404_stable-2026.2.json), [wire-probe-200_stable-2026.2.json](_rewrite/wire-probe-200_stable-2026.2.json), [wire-probe-fixture_stable-2026.2.json](_rewrite/wire-probe-fixture_stable-2026.2.json) | for each command: argv, exit code, stderr, requests with status, headers and body. Stub answers a fault, {}, or a plausible body | wire reference |
[defects-repro_stable-2026.2.json](_rewrite/defects-repro_stable-2026.2.json) | 103 scenarios with chosen answers: argv, exit code, stdout, stderr, requests | evidence for 02 § 4 and for § 2 below |
| Script | Role |
|---|---|
[extract_plugin_stable-2026.2.py](_rewrite/extract_plugin_stable-2026.2.py) | imports the entry points, dumps parsers, walks the code with ast, resolves controller methods to routes. Adapted: controller_rest reads the controllers |
[probe_plugin_stable-2026.2.py](_rewrite/probe_plugin_stable-2026.2.py) | local stub and real shell. Adapted: the version document follows designate:designate/api/versions.py:37, faults follow Designate's shape, a fixture mode, UUIDs for zone arguments, a temporary zone file, and a reset of the client cache between commands |
[repro_defects_stable-2026.2.py](_rewrite/repro_defects_stable-2026.2.py) | the scenarios, written for this package |
[capture_plugin_stable-2026.2.py](_rewrite/capture_plugin_stable-2026.2.py) | runs the unit tests with a recorder. Adapted: also records requests-mock history |
[build_contract_stable-2026.2.py](_rewrite/build_contract_stable-2026.2.py) | merges everything and compares with Designate's routes |
[inventory.py](_rewrite/inventory.py), [check_refs.py](_rewrite/check_refs.py) | copies of the skill's scripts, unchanged |
The generic rewrite_pack.py and conformance_capture.py were not used: they expect a service or functional tests running in-process, while the functional tests here need a live cloud (designateclient/functionaltests/config.py:21).
Versions: python-designateclient 7.0.1.dev2, osc-lib 4.7.0, cliff 4.16.0, python-openstackclient 10.3.1, keystoneauth1 5.17.0, requests-mock 1.12.1, Python 3.13.
To regenerate:
python -I -B _rewrite/extract_plugin_stable-2026.2.py <repo> python-designateclient designateclient _rewrite
for m in 404 200 fixture; do python -I -B _rewrite/probe_plugin_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir-$m> --mode $m; done
python -I -B _rewrite/repro_defects_stable-2026.2.py _rewrite/probe_plugin_stable-2026.2.py <repro.json>
python -I -B _rewrite/capture_plugin_stable-2026.2.py <repo> _rewrite/cli-commands_stable-2026.2.json <run-1>/c.json <repo>/designateclient/tests <repo>
python -I -B _rewrite/capture_plugin_stable-2026.2.py <repo> _rewrite/cli-commands_stable-2026.2.json <run-2>/c.json <repo>/designateclient/tests <repo>
python -I -B _rewrite/build_contract_stable-2026.2.py . <dir-404>/wire-probe_stable-2026.2.json <dir-200>/wire-probe_stable-2026.2.json <dir-fixture>/wire-probe_stable-2026.2.json <run-1>/c.json <run-2>/c.json <repro.json> <designate-routes.json> <gen-dir>
Set HOME to an empty directory and unset OS_*, so that no real clouds.yaml is read. The stub binds to 127.0.0.1.
2. What the client requires from a DNS service
Apply this to any service that must work with the unmodified client. "Client" evidence is on the stub; the service column of Designate is what was read.
| # | Requirement | Why | Evidence |
|---|---|---|---|
| 1 | a dns entry in the catalog, with the interface used (default public) | otherwise public endpoint for dns service ... not found | [observed] no-dns-in-catalog |
| 2 | either the catalog URL has no version and its root answers a version list with a v2 or v2.x entry and a self link to .../v2, or the catalog URL ends in /v2 | a root 404 sends the client to /zones without /v2 | [observed] discovery-*, designate:designate/api/versions.py:37 |
| 3 | all routes of [api-surface](_rewrite/api-surface_stable-2026.2.md), verbs included: recordset set is PUT, zone set and others are PATCH | [observed] | |
| 4 | a GET /zones/{id} and similar accept a UUID; ?name= filters exactly on zones, recordsets, TLDs and TSIG keys, and answers 200 with an empty list when nothing matches | the name lookup; a 404 there shows a different message | designateclient/v2/utils.py:32 |
| 5 | collections always hold their key: zones, recordsets, transfer_requests, transfer_accepts, exports, imports, shared_zones, tlds, tsigkeys, blacklists, floatingips, service_statuses, nameservers | otherwise a bare key name on stderr, exit 1 | [observed] wire-probe-200 |
| 6 | links, when present, is an object; links.next is present only when there is more to read, is never null, and carries the query to re-send, including a changed marker | the client loops forever otherwise | [observed] pagination-*, list-links-null |
| 7 | zone objects hold masters (list) and attributes (object) in every answer, including PATCH and DELETE | _format_zone reads them without a default | [observed] zone-no-attributes, designateclient/v2/cli/zones.py:32 |
| 8 | recordset objects hold records (list), including PUT and DELETE; zone delete, recordset delete, recordset set, ptr record set, zone share create and every create answer with a body | otherwise a Python error, exit 1, after the action | [observed] *-empty-* |
| 9 | service statuses hold capabilities and stats, and quota, TLD and similar objects are non-empty | formatter and zip | [observed] service-list-no-stats, quota-empty-object |
| 10 | failures use only 400, 401, 403, 404, 409, 413 or ≥ 500, if the user is to see them | any other 4xx is displayed as success | [observed] defect 3. Designate itself uses 405, 406 and 415 |
| 11 | fault bodies are JSON objects holding message, or errors.errors[].message, or type | the text shown; a non-object gives a TypeError text | [observed] 400-*, 404-other-shape |
| 12 | POST /zones/tasks/imports accepts text/dns, and application/json with {zonefile, attributes} | both forms exist | [observed] import-*, designate:designate/api/v2/controllers/zones/tasks/imports.py:79 |
| 13 | GET .../export honours Accept: text/dns and answers the zone text | shown as data | [observed] hdr-export-accept |
| 14 | Content-Type: application/json on a GET or DELETE without body is tolerated | sent on every request | [observed] |
| 15 | the header values True and true are parsed as booleans; the four feature headers are honoured | scripts rely on the flags | [observed] hdr-*, designate:designate/api/middleware.py:67 |
| 16 | X-Auth-All-Projects: True works on a quota call for the caller's own project | sent implicitly by dns quota set and reset | [observed] quota-*-own-implicit |
| 17 | GET /quotas works without a project ID; /quotas/None should not be taken for a project | what a token without project produces | [observed] quota-*-domain-scope |
| 18 | floating IP keys region:id are accepted with a raw colon in the path | sent as is | [observed] |
| 19 | a success status is not checked: 200, 201, 202 and 204 are all fine, as long as the bodies of 8 are there | designateclient/v2/client.py:119 | |
| 20 | recordset list --action will send ?action=. Designate refuses it; decide whether to accept it | compatibility choice | defect 5 |
Fields the commands display but do not need are listed per command in the class columns. A missing one gives an empty cell, not an error.
What the Python API users add
| Requirement | Source |
|---|---|
keep designateclient.client.Client('2', session=, endpoint_type=, service_type=, region_name=) and v2.client.Client(session=, auth=, endpoint_override=, edit_managed=, all_projects=) | heat:heat/engine/clients/os/designate.py:32, neutron:neutron/services/externaldns/drivers/designate/driver.py:46 |
keep the exception classes and the status mapping: Neutron turns OverQuota into its own error and ignores a Conflict on zone creation | neutron:neutron/services/externaldns/drivers/designate/driver.py:107 |
keep name resolution in recordsets.*: Neutron passes zone names and recordset names | neutron:neutron/services/externaldns/drivers/designate/driver.py:165 |
3. Coverage against Designate
Static comparison with reverse-spec-designate/_rewrite/routes_stable-2026.2.json: 61 of 61 client operations exist there. Of its 78 routes, 16 are never called by the client: the /admin API, pool administration, GET /recordsets/{id}, the /service_status alias, and GET /zones/{id}/tasks/abandon. GET /pools is reachable only through the Python API. This compares method and path only, with no bodies, and nothing was run.
4. For a rewrite of the commands
| Topic | Rule | Source |
|---|---|---|
| Names | zone_transfer_request_create → zone transfer request create; scripts depend on these and on argument names, including --service_name | pyproject.toml:42, designateclient/v2/cli/service_statuses.py:47 |
| Exit codes | 0, 1, 2 as in 02 § 1; do not copy defect 3 | cliff:cliff/command.py:196 |
| Columns | list columns are fixed per class; --long adds some; --all-projects inserts project_id | designateclient/v2/cli/zones.py:50 |
Rows of ShowOne | sorted by key, links removed except on transfers and quotas | designateclient/v2/cli/zones.py:133 |
| Name resolution | zones, recordsets, TLDs, TSIG keys only | 01 § 1 |
| Defects not to copy | 1, 2, 3, 4, 6, 7, 8, 9, 10, 14 | 02 § 4 |
Recommendation: keep the Python client and fix it upstream rather than rewriting it. It is small (60 commands, thin controllers), and Neutron and Heat call its Python API. A Go rewrite would not serve them.
5. Conformance: what was proved
| Evidence | Result | What it proves | What it does not prove |
|---|---|---|---|
| Parser dump | 60 of 60 classes imported, 0 failures | the catalogue matches pyproject.toml | |
| Unit tests with recorder | 111 of 111 pass, 97 vectors, in two runs | Python call → HTTP against requests-mock (87), argv → mock (10) | anything about a service; only 5 of 60 command classes have command vectors |
| Comparison of the two runs | 97 of 97: same test, status and sequence, with HTTP content compared for API vectors | stability, after normalising UUIDs | argument values of command vectors were not compared |
| Wire probe, 404 mode | 60 of 60 reach a request; all exit 1 with the fault text | routes up to the first error | |
Wire probe, {} mode | 60 of 60 reach a request; 15 exit 0, 45 exit 1 (20 unpacking errors, 25 missing keys) | which commands read what | |
| Wire probe, fixture mode | 60 of 60 exit 0 with hand-written bodies | commands complete on plausible bodies | that the bodies are right |
| Defect scenarios | 103 run, reproducing the defects of 02 § 4 except 18, read only | client reaction to chosen answers | that a service sends them, unless read in Designate |
| Route comparison | 61 of 61 | names of routes exist | bodies, semantics |
Replay against a real service, the usual bar, is not met. The mocks also hide real defects: none of the 18 defects is caught by a unit test.
To close the gap: run the probe's fixture commands, with real resources, against a devstack on stable/2026.2, and compare statuses and keys; or run designateclient/functionaltests against it. Neither was attempted, as the rules for this work allow only the local stub.
Command classes without a command vector
55 of 60. Vectors exist only for CreateRecordSetCommand, ListRecordSetsCommand, ListTLDsCommand, CreateZoneCommand, ListZonesCommand. The controllers behind the others mostly have API vectors, which do not test argument parsing. Zone shares, exports and transfers do have API vectors [see unit-vectors].
6. Gaps found while building the pack
- The generic stub's version document pointed at
/dns/, without/v2, so every command would have gone to/dns/zones[inferred; the unfixed stub was not run]. It was replaced by the shape of Designate before the first probe. Scenariodiscovery-root-404then gave requirement 2. - The tool's
logging.disablehid every error message, so it was removed. - OSC caches the client on the class (
osc-lib:osc_lib/clientmanager.py:51). Without a reset, one in-process run leaked--sudo-project-idinto later scenarios, which is how defect 8 was found. capture_pluginrecordscheck_parseronly, and would have missed the 87 API tests. It was extended.- Name lookups are not in the wire probes, since those pass UUIDs. They are in
defects-repro.
7. Non-functional requirements
- Latency: each
openstackinvocation asks for a token and reads the version document, so these two are on the hot path. A script of N commands sends N token requests. - Name arguments cost one to two extra requests per command.
- Listing cost: lists read all pages; the page size is the service's.
- Payload:
zone import createreads the whole file into memory, then sends it (designateclient/v2/cli/zones.py:737).
8. Checklist for a DNS service
- [ ]
dnsin the catalog, with apublicendpoint. - [ ] Version document at the catalog root, or
/v2in the catalog URL. - [ ] Every route of
api-surface_stable-2026.2.mdimplemented, or its absence documented. - [ ] Collection keys always present;
links.nextonly when there is more. - [ ]
masters,attributes,records,capabilities,stats,idalways present where read. - [ ] Delete and set answers carry a body.
- [ ] Failures use the translated statuses, or the gap is accepted knowingly.
- [ ] Fault bodies are objects with
message. - [ ]
text/dnsimport and export, JSON import with attributes. - [ ] Feature headers honoured, values
Trueandtrueaccepted. - [ ] The probe in fixture mode's commands, pointed at the service, exit 0 with real resources.
Source: reverse-spec-python-designateclient/07-rewrite-pack_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.