Client contract (rewrite pack) — python-ironicclient — branch stable/2026.2
Branch
stable/2026.2@af2ceeb9c4f2— repository https://opendev.org/openstack/python-ironicclient — analysed on 2026-11-01
This pack serves two purposes:
- **Keep
openstack baremetalworking against a Go Bare Metal service** (openkeel-ironic), by stating what the client requires. - Re-implement the client, using the machine-readable catalogue of commands.
No replay against Ironic was possible, or wanted, here. The client ran only against a local stub. Requirements below come from the client's code and its reaction to scripted answers. They are necessary, not sufficient: passing them does not mean behaving like Ironic. For the server's own behaviour, use reverse-spec-ironic/.
1. Contents of _rewrite/
| File | Content | Use |
|---|---|---|
[cli-commands_stable-2026.2.json](_rewrite/cli-commands_stable-2026.2.json) | 118 commands: class, purpose, each of the 546 arguments, client calls with file:line, errors. microversion_gates is empty: the extractor looks for SDK gates; the real ones are in 01 | generate a parser; check argument names |
[client-contract_stable-2026.2.json](_rewrite/client-contract_stable-2026.2.json) | per command: client calls, observed requests, routes of the client methods, options_on_wire, number of unit vectors, probe exit codes | requirements per command |
[api-surface_stable-2026.2.md](_rewrite/api-surface_stable-2026.2.md) | the 114 routes with source, and a comparison with Ironic's route list | gap analysis of a service |
[unit-vectors_stable-2026.2.json](_rewrite/unit-vectors_stable-2026.2.json) | 632 vectors: argv → ordered manager calls with arguments | test argument mapping 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) | per command, argv, exit code, stderr, requests with path, version, body and headers | wire reference |
[wire-options_stable-2026.2.json](_rewrite/wire-options_stable-2026.2.json) | one run per optional argument, 397 runs | option → query parameter or body field |
[python-api-probe_stable-2026.2.json](_rewrite/python-api-probe_stable-2026.2.json) | 117 calls on the 105 public manager methods | routes of the Python API |
[scenarios_stable-2026.2.json](_rewrite/scenarios_stable-2026.2.json) | 85 scripted scenarios: argv, status, output, requests | negotiation, errors, retries, waits, defects |
| Script | Role | Change from the shared tool |
|---|---|---|
[extract_plugin_stable-2026.2.py](_rewrite/extract_plugin_stable-2026.2.py) | imports the entry points, dumps parsers, walks the code | none |
[probe_plugin_stable-2026.2.py](_rewrite/probe_plugin_stable-2026.2.py) | real shell against a stub | Ironic version headers and error body, EXTRA options so that all commands reach a request, --options mode, more headers recorded, stdin from /dev/null |
[capture_plugin_stable-2026.2.py](_rewrite/capture_plugin_stable-2026.2.py) | unit tests with a recorder | test id prefix only |
[python_api_probe_stable-2026.2.py](_rewrite/python_api_probe_stable-2026.2.py) | calls each manager method | new |
[repro_scenarios_stable-2026.2.py](_rewrite/repro_scenarios_stable-2026.2.py) | scripted stub; each scenario is its own process | new |
[build_contract_stable-2026.2.py](_rewrite/build_contract_stable-2026.2.py) | merges everything | rewritten from the OSC script: no service table, no SDK routes |
Versions: python-ironicclient 6.3.1.dev2, osc-lib 4.7.0, cliff 4.16.0, python-openstackclient 10.3.1, keystoneauth1 5.17.0, Python 3.13.
To regenerate (with HOME set to an empty directory, OS_* unset, and <in from /dev/null: some unit test run hung with an open stdin and finished with it closed; the test was not isolated):
python -I -B _rewrite/extract_plugin_stable-2026.2.py <repo> python-ironicclient ironicclient _rewrite
python -I -B _rewrite/probe_plugin_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir-404> --mode 404
python -I -B _rewrite/probe_plugin_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir-200> --mode 200 --options
python -I -B _rewrite/capture_plugin_stable-2026.2.py <repo> _rewrite/cli-commands_stable-2026.2.json <run-1>/c.json <repo>/ironicclient/tests/unit <repo>
python -I -B _rewrite/capture_plugin_stable-2026.2.py <repo> _rewrite/cli-commands_stable-2026.2.json <run-2>/c.json <repo>/ironicclient/tests/unit <repo>
python -I -B _rewrite/python_api_probe_stable-2026.2.py _rewrite/cli-commands_stable-2026.2.json <dir>/api.json
python -I -B _rewrite/repro_scenarios_stable-2026.2.py <python> <dir>/repro.json
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-200>/wire-options_stable-2026.2.json <run-1>/c.json <run-2>/c.json <dir>/api.json <dir>/repro.json <ironic-routes.json>
Everything stays on 127.0.0.1. One scenario connects to a closed local port. The generic rewrite_pack.py and conformance_capture.py were not used: they expect a service or functional tests in-process, and this repository has only unit tests (.stestr.conf:2).
2. What the client requires from a Bare Metal service
| # | Requirement | Why | Evidence |
|---|---|---|---|
| 1 | Keystone catalog holds an endpoint of type baremetal, for the chosen interface and region | the plugin resolves it up front | ironicclient/osc/plugin.py:89 |
| 2 | Endpoint may end in /v1 or a prefix; next links keep the same prefix | the client trims /v1 and removes the prefix from next | ironicclient/common/http.py:63, ironicclient/common/base.py:278 |
| 3 | A request above the maximum version gets 406, not 400 | only a 406 starts negotiation | ironicclient/common/http.py:464, [observed neg-406-downgrade] |
| 4 | **GET /v1 answers 2xx and holds X-OpenStack-Ironic-API-Minimum-Version and -Maximum-Version, without a version header in the request.** | negotiation reads only that response. Otherwise every default command fails after the 406 | ironicclient/common/http.py:152, ironicclient/common/http.py:298, [observed neg-406-v1-500, neg-406-no-headers] |
| 5 | Announced maximum ≥ what commands need. A version below 1.115 is fine, but the service then answers features above it with 406 | once negotiated, a 406 is final | ironicclient/common/http.py:230 |
| 6 | **Error bodies are {"error_message": "<string>"}, the string holding JSON with faultstring, or plain text.** An object crashes the client | message extraction | ironicclient/common/http.py:106, [observed err-400-dict-error-message]; ironic:ironic/api/middleware/parsable_error.py:71 |
| 7 | No plain number or JSON null as an error body or message | same | [observed err-400-numeric-error-message, err-400-json-null] |
| 8 | Do not send Retry-After on any status but 413, including from a proxy | it crashes the client | [observed err-503-retry-after] |
| 9 | 409 and 503 are for temporary states | the client retries them 5 times over 10 s, even on POST. Use another 4xx for a permanent conflict, if the original does [check against Ironic] | ironicclient/common/http.py:324 |
| 10 | POST that can be replayed safely, or a clear 409 on the second attempt | same | [observed err-409-exhausts-retries] |
| 11 | Accept /v1/<collection>/?a=b as well as /v1/<collection> | both forms are sent | [observed req-filter-not-encoded] |
| 12 | Content-Type: application/json on every JSON response, including lists | anything else gives API response body must be a JSON object | ironicclient/common/http.py:508, [observed shape-show-text-body] |
| 13 | Tolerate Content-Type: application/json with an empty body (trait add and some PUT), and application/octet-stream on DELETE | observed headers | 03 § 2 |
| 14 | Lists answer {"<collection>": […]}; the key is not strictly needed but its absence looks like an empty list, exit 0 | a broken service looks healthy | [observed shape-list-without-key] |
| 15 | Collection keys: nodes, ports, portgroups, chassis, drivers, conductors, allocations, deploy_templates, runbooks, shards, connectors, targets, inspection_rules, vifs, traits, children, bios, firmware, history | read by name | ironicclient/v1/node.py:1503, ironicclient/v1/node.py:213, ironicclient/v1/node.py:1114 |
| 16 | Show responses are non-empty objects | {} crashes 15 commands | [observed shape-show-empty-object] |
| 17 | next holds a URL, and is absent or empty on the last page | end of loop | ironicclient/common/base.py:272 |
| 18 | limit above zero is honoured with next; no limit means one page | the client trusts it, and a missing next ends the list | [observed page-limit-2, page-limit-0] |
| 19 | Node responses hold provision_state, target_provision_state, last_error; target_provision_state is set for as long as the action runs | else --wait reports a failure on a healthy node | ironicclient/v1/node.py:1148, [observed wait-manage-other-stable] |
| 20 | The state changes, or the target is set, before the client's first poll, or --wait can return too early or fail | the first poll is immediate | ironicclient/v1/node.py:1214 |
| 21 | Failure states are named error or … failed | the only way the wait sees failure | ironicclient/v1/node.py:1140 |
| 22 | Allocation responses hold uuid, state, last_error | --wait | ironicclient/v1/allocation.py:131 |
| 23 | history get holds links | pop without a default | ironicclient/osc/v1/baremetal_node.py:2574 |
| 24 | vendor_passthru/methods entries hold http_methods | KeyError | ironicclient/osc/v1/baremetal_node.py:1163, ironicclient/osc/v1/baremetal_driver.py:241 |
| 25 | GET /v1/nodes/{id}/bios/{name} answers {"<name>": {…}} | the client reads that key | ironicclient/v1/node.py:1076, [observed shape-bios-without-name] |
| 26 | GET …/children answers {"children": [uuids]}; GET …/validate answers {iface: {result, reason}} | shape read by the commands | ironicclient/v1/node.py:327, ironicclient/osc/v1/baremetal_node.py:2128 |
| 27 | Vendor passthru is reachable as /vendor_passthru/<method>, not only ?method= | the client sends only this form; Ironic's controller takes it as _default | ironicclient/v1/node.py:429, ironic:ironic/api/controllers/v1/node.py:2057, ironic:ironic/api/controllers/v1/driver.py:259 |
| 28 | Runbook traits at ≥ 1.112 | client gate, so the service must announce it | ironicclient/v1/runbook.py:174 |
Routes
[api-surface_stable-2026.2.md](_rewrite/api-surface_stable-2026.2.md) lists 114 routes. Those not in reverse-spec-ironic/_rewrite/routes-list_stable-2026.2.json are 15, and this is how they resolve:
| Route | Finding |
|---|---|
GET /v1/nodes/{id}/children | exists: ironic:ironic/api/controllers/v1/node.py:2302. The route list is incomplete |
GET, DELETE /v1/runbooks/{id}/traits, PUT, DELETE /v1/runbooks/{id}/traits/{id} | exist: ironic:ironic/api/controllers/v1/runbook.py:197. Route list incomplete |
POST, GET, DELETE /v1/nodes/{id}/vendor_passthru/{method}, same for drivers | exist through _default. Route list incomplete |
GET /v1/chassis/{id}/nodes, /detail | exist: ironic:ironic/api/controllers/v1/chassis.py:101. Python API only |
PATCH, DELETE /v1/drivers/{id} | not found in Ironic [inferred from the route list]. Python API only, and no command uses them |
If openkeel-ironic was built from that route list, these are worth checking first. This was not checked.
The 32 Ironic routes never sent by this client are at the end of the same file: agent routes (/v1/lookup, /v1/heartbeat), /v1/nodes/{id}/vmedia, /v1/nodes/{id}/management/indicators, /v1/nodes/{id}/allocation, /v1/nodes/{id}/portgroups, DELETE /v1/inspection_rules, among others. A service needs them for other clients, not for this one. No client command covers vmedia or indicators.
3. HTTP behaviour
No middleware to describe on the client side. The order of its handling is in 02 § 2.
4. For a rewrite of the client itself
| Topic | Rule | Source |
|---|---|---|
| Names | baremetal_node_trait_add → baremetal node trait add; keep the old trait aliases | pyproject.toml:74, pyproject.toml:114 |
| Conversion | key=value is JSON-parsed. Keep or fix on purpose, see defect 9 | ironicclient/common/utils.py:66 |
| Patches | set uses add; unset uses remove, except port unset --is-smartnic, which sends add … False | ironicclient/osc/v1/baremetal_port.py:312 |
| Provision targets | table in 01 § 2 | ironicclient/v1/utils.py:27 |
| Power targets | power on, power off, rebooting, soft power off, soft rebooting | ironicclient/v1/node.py:32 |
| Exit codes | 0, 1, 2 from cliff; do not copy 02 § 5 | |
| Formatters | table, json, yaml, csv, value, -c, --sort-column, from cliff | not specified here |
| Config | clouds.yaml, OS_*, all keystoneauth plugins | osc-lib, openstacksdk |
Recommendation: keep the Python client and make the service compatible. It is the client users already have. Fixes to the defects belong upstream.
5. Conformance: what was proved
| Evidence | Result | What it proves | What it does not prove |
|---|---|---|---|
| Parser dump | 118 of 118 commands imported, 0 failures | the catalogue is complete for declared entry points | |
| Unit tests with recorder | 1,155 of 1,155 pass, in two runs. 632 vectors, 505 with calls | argv → manager calls, on 110 of 116 classes | anything about a service: client_manager.baremetal is a mock.Mock (ironicclient/tests/unit/osc/v1/fakes.py:367) |
| Comparison of the two runs | 632 of 632 same test, call sequence, status | sequences are deterministic | argument values were not compared |
| Wire probe, 404 mode | 118 of 118 commands reach a request, 118 requests, all exit 1 with the stub's message | real route and headers of each command | later steps |
Wire probe, {} mode | 118 of 118 reach a request, 98 exit 0, 20 exit 1 | which commands tolerate an empty answer | that those 98 display correct data |
| Option runs | 397: 329 exit 0, 66 exit 1, 2 exit 2. Most exit 1 are the {} crash on show or create | each option's request | server acceptance |
| Python API probe | 117 calls, 105 methods, all sent a request | method → route | |
| Scenarios | 85 | client reaction to each scripted answer | that Ironic sends such answers |
The vectors and probes were not replayed against a service or a rewrite, and nothing was compared with a live Ironic. The usual "replay is mandatory" bar is not met for service behaviour. Mocks also hide defects: defects 1, 2, 4 and 5 of 02 § 5 pass or escape the unit tests.
To close the gap: point probe_plugin_stable-2026.2.py at a devstack with Ironic stable/2026.2, then at openkeel-ironic, with seeded resources, and compare. That needs a lab, so it was out of scope here.
Classes without a unit vector (6)
All in ironicclient/osc/v1/baremetal_inspection_rule.py: CreateBaremetalInspectionRule, DeleteBaremetalInspectionRule, ListBaremetalInspectionRule, SetBaremetalInspectionRule, ShowBaremetalInspectionRule, UnsetBaremetalInspectionRule. No test file exists for them. Note that 127 vectors have no client call: they test parser failures or client-side checks.
6. Gaps found while building the pack
- The shared extractor found no static REST routes and no gates, since this client does not use openstacksdk. Routes come from the wire instead.
- The catalogue of errors is a floor: it walks from
take_action, and misses the managers. 02 § 4 adds those by hand. reverse-spec-ironic/_rewrite/routes-list_stable-2026.2.jsonlacks some routes that exist in Ironic, listed above.--config-drive <directory>was not run.
7. Non-functional requirements
- Latency: each
openstackprocess authenticates. Against an older server, it also pays the 406 andGET /v1each time, since the cache is not read. - Wait load:
--waitfetches the full node every 2 to 10 seconds per node, with no field filter (ironicclient/v1/node.py:1127). - Page size: without
--limit, the first page is all the client gets, so the server'smax_limitdecides what users see.
8. Checklist for a Bare Metal service
- [ ]
baremetalin the catalog, with the expected interface. - [ ] Version headers on
GET /v1, without a request version. - [ ] 406, with those headers, above the maximum version.
- [ ] Errors as
{"error_message": "<JSON string with faultstring>"}. - [ ] No
Retry-Afteroutside 413, including at the proxy. - [ ] 409 and 503 only for temporary states.
- [ ]
/v1/nodesand/v1/nodes/?x=yboth work;nextlinks keep the prefix. - [ ] Every route of
api-surface_stable-2026.2.mdmarked CLI or CLI option is implemented, including the ones missing from the route list. - [ ] Node, allocation, history, BIOS, validate and passthru shapes of § 2.
- [ ] The probes in
_rewrite/give the same routes and exit 0 with real resources.
Source: reverse-spec-python-ironicclient/07-rewrite-pack_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.