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

Reverse-spec · Clients

python-ironicclient — Rewrite pack

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 baremetal working 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/

FileContentUse
[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 01generate 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 codesrequirements 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 listgap analysis of a service
[unit-vectors_stable-2026.2.json](_rewrite/unit-vectors_stable-2026.2.json)632 vectors: argv → ordered manager calls with argumentstest 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 headerswire reference
[wire-options_stable-2026.2.json](_rewrite/wire-options_stable-2026.2.json)one run per optional argument, 397 runsoption → 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 methodsroutes of the Python API
[scenarios_stable-2026.2.json](_rewrite/scenarios_stable-2026.2.json)85 scripted scenarios: argv, status, output, requestsnegotiation, errors, retries, waits, defects
ScriptRoleChange 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 codenone
[probe_plugin_stable-2026.2.py](_rewrite/probe_plugin_stable-2026.2.py)real shell against a stubIronic 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 recordertest id prefix only
[python_api_probe_stable-2026.2.py](_rewrite/python_api_probe_stable-2026.2.py)calls each manager methodnew
[repro_scenarios_stable-2026.2.py](_rewrite/repro_scenarios_stable-2026.2.py)scripted stub; each scenario is its own processnew
[build_contract_stable-2026.2.py](_rewrite/build_contract_stable-2026.2.py)merges everythingrewritten 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

#RequirementWhyEvidence
1Keystone catalog holds an endpoint of type baremetal, for the chosen interface and regionthe plugin resolves it up frontironicclient/osc/plugin.py:89
2Endpoint may end in /v1 or a prefix; next links keep the same prefixthe client trims /v1 and removes the prefix from nextironicclient/common/http.py:63, ironicclient/common/base.py:278
3A request above the maximum version gets 406, not 400only a 406 starts negotiationironicclient/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 406ironicclient/common/http.py:152, ironicclient/common/http.py:298, [observed neg-406-v1-500, neg-406-no-headers]
5Announced maximum ≥ what commands need. A version below 1.115 is fine, but the service then answers features above it with 406once negotiated, a 406 is finalironicclient/common/http.py:230
6**Error bodies are {"error_message": "<string>"}, the string holding JSON with faultstring, or plain text.** An object crashes the clientmessage extractionironicclient/common/http.py:106, [observed err-400-dict-error-message]; ironic:ironic/api/middleware/parsable_error.py:71
7No plain number or JSON null as an error body or messagesame[observed err-400-numeric-error-message, err-400-json-null]
8Do not send Retry-After on any status but 413, including from a proxyit crashes the client[observed err-503-retry-after]
9409 and 503 are for temporary statesthe 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
10POST that can be replayed safely, or a clear 409 on the second attemptsame[observed err-409-exhausts-retries]
11Accept /v1/<collection>/?a=b as well as /v1/<collection>both forms are sent[observed req-filter-not-encoded]
12Content-Type: application/json on every JSON response, including listsanything else gives API response body must be a JSON objectironicclient/common/http.py:508, [observed shape-show-text-body]
13Tolerate Content-Type: application/json with an empty body (trait add and some PUT), and application/octet-stream on DELETEobserved headers03 § 2
14Lists answer {"<collection>": […]}; the key is not strictly needed but its absence looks like an empty list, exit 0a broken service looks healthy[observed shape-list-without-key]
15Collection keys: nodes, ports, portgroups, chassis, drivers, conductors, allocations, deploy_templates, runbooks, shards, connectors, targets, inspection_rules, vifs, traits, children, bios, firmware, historyread by nameironicclient/v1/node.py:1503, ironicclient/v1/node.py:213, ironicclient/v1/node.py:1114
16Show responses are non-empty objects{} crashes 15 commands[observed shape-show-empty-object]
17next holds a URL, and is absent or empty on the last pageend of loopironicclient/common/base.py:272
18limit above zero is honoured with next; no limit means one pagethe client trusts it, and a missing next ends the list[observed page-limit-2, page-limit-0]
19Node responses hold provision_state, target_provision_state, last_error; target_provision_state is set for as long as the action runselse --wait reports a failure on a healthy nodeironicclient/v1/node.py:1148, [observed wait-manage-other-stable]
20The state changes, or the target is set, before the client's first poll, or --wait can return too early or failthe first poll is immediateironicclient/v1/node.py:1214
21Failure states are named error or … failedthe only way the wait sees failureironicclient/v1/node.py:1140
22Allocation responses hold uuid, state, last_error--waitironicclient/v1/allocation.py:131
23history get holds linkspop without a defaultironicclient/osc/v1/baremetal_node.py:2574
24vendor_passthru/methods entries hold http_methodsKeyErrorironicclient/osc/v1/baremetal_node.py:1163, ironicclient/osc/v1/baremetal_driver.py:241
25GET /v1/nodes/{id}/bios/{name} answers {"<name>": {…}}the client reads that keyironicclient/v1/node.py:1076, [observed shape-bios-without-name]
26GET …/children answers {"children": [uuids]}; GET …/validate answers {iface: {result, reason}}shape read by the commandsironicclient/v1/node.py:327, ironicclient/osc/v1/baremetal_node.py:2128
27Vendor passthru is reachable as /vendor_passthru/<method>, not only ?method=the client sends only this form; Ironic's controller takes it as _defaultironicclient/v1/node.py:429, ironic:ironic/api/controllers/v1/node.py:2057, ironic:ironic/api/controllers/v1/driver.py:259
28Runbook traits at ≥ 1.112client gate, so the service must announce itironicclient/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:

RouteFinding
GET /v1/nodes/{id}/childrenexists: 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 driversexist through _default. Route list incomplete
GET /v1/chassis/{id}/nodes, /detailexist: 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

TopicRuleSource
Namesbaremetal_node_trait_add → baremetal node trait add; keep the old trait aliasespyproject.toml:74, pyproject.toml:114
Conversionkey=value is JSON-parsed. Keep or fix on purpose, see defect 9ironicclient/common/utils.py:66
Patchesset uses add; unset uses remove, except port unset --is-smartnic, which sends add … Falseironicclient/osc/v1/baremetal_port.py:312
Provision targetstable in 01 § 2ironicclient/v1/utils.py:27
Power targetspower on, power off, rebooting, soft power off, soft rebootingironicclient/v1/node.py:32
Exit codes0, 1, 2 from cliff; do not copy 02 § 5
Formatterstable, json, yaml, csv, value, -c, --sort-column, from cliffnot specified here
Configclouds.yaml, OS_*, all keystoneauth pluginsosc-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

EvidenceResultWhat it provesWhat it does not prove
Parser dump118 of 118 commands imported, 0 failuresthe catalogue is complete for declared entry points
Unit tests with recorder1,155 of 1,155 pass, in two runs. 632 vectors, 505 with callsargv → manager calls, on 110 of 116 classesanything about a service: client_manager.baremetal is a mock.Mock (ironicclient/tests/unit/osc/v1/fakes.py:367)
Comparison of the two runs632 of 632 same test, call sequence, statussequences are deterministicargument values were not compared
Wire probe, 404 mode118 of 118 commands reach a request, 118 requests, all exit 1 with the stub's messagereal route and headers of each commandlater steps
Wire probe, {} mode118 of 118 reach a request, 98 exit 0, 20 exit 1which commands tolerate an empty answerthat those 98 display correct data
Option runs397: 329 exit 0, 66 exit 1, 2 exit 2. Most exit 1 are the {} crash on show or createeach option's requestserver acceptance
Python API probe117 calls, 105 methods, all sent a requestmethod → route
Scenarios85client reaction to each scripted answerthat 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.json lacks some routes that exist in Ironic, listed above.
  • --config-drive <directory> was not run.

7. Non-functional requirements

  • Latency: each openstack process authenticates. Against an older server, it also pays the 406 and GET /v1 each time, since the cache is not read.
  • Wait load: --wait fetches 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's max_limit decides what users see.

8. Checklist for a Bare Metal service

  • [ ] baremetal in 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-After outside 413, including at the proxy.
  • [ ] 409 and 503 only for temporary states.
  • [ ] /v1/nodes and /v1/nodes/?x=y both work; next links keep the prefix.
  • [ ] Every route of api-surface_stable-2026.2.md marked 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.