Client contract (rewrite pack) — python-heatclient — branch stable/2026.2
Branch
stable/2026.2@88120f11830d(5.3.0-2-g88120f1) — repository https://opendev.org/openstack/python-heatclient — analysed on 2026-11-01 Heat reference: [../reverse-spec-heat/](../reverse-spec-heat/00-summary_stable-2026.2.md), branchstable/2026.2@5872396e5, read only. Versions: osc-lib 4.7.0, cliff 4.16.0, keystoneauth1 5.17.0, python-openstackclient 10.3.1.
The goal is to state **what the openstack orchestration commands require from Heat, so that a Go Heat keeps them working unchanged, and to provide data for testing it. The evidence comes from a local stub and from mocks. **No Python Heat and no rewrite were involved, so nothing here proves conformance of any service. See § 5.
1. Contents of _rewrite/
| File | Content | Use |
|---|---|---|
[cli-commands_stable-2026.2.json](_rewrite/cli-commands_stable-2026.2.json) | the 49 commands: class, purpose, each of the 162 arguments, client calls with file:line, error sites | generate a parser; check argument names |
[client-contract_stable-2026.2.json](_rewrite/client-contract_stable-2026.2.json) | per command: manager methods, static and observed requests, unit vector count, probe outcomes | requirements per command |
[api-surface_stable-2026.2.md](_rewrite/api-surface_stable-2026.2.md) | the 57 operations with source and users | gap analysis of a service |
[unit-vectors_stable-2026.2.json](_rewrite/unit-vectors_stable-2026.2.json) | 230 vectors: argv → ordered manager calls with arguments ({uuid} normalised) | 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 and for 59 option variants: argv, exit, exception, requests with method, path, headers, body | wire reference; replay the argv against a service |
[scenarios_stable-2026.2.json](_rewrite/scenarios_stable-2026.2.json) | 74 scenarios with hand-written answers: normal flows (G), defects (D), error bodies (E), legacy shell (L), templates (T); argv, exit, exception, stdout, requests with bodies | examples of good sequences; list of shapes that break the client |
extract_plugin_, probe_plugin_, capture_plugin_, repro_scenarios_, build_contract_stable-2026.2.py, inventory.py, check_refs.py | the scripts used, adapted for this package | regenerate everything |
Adaptations: the probe's stub answers Heat's lookup 302 and Heat's fault body, and has fixture files, because otherwise half of the commands stop at the first answer. The capture runs from the repository root, where the tests expect their templates. All runs: empty HOME, no OS_*, stdin from /dev/null, 127.0.0.1 only.
2. What the commands require from Heat
Transport and routing
| # | Requirement | If absent | Source |
|---|---|---|---|
| 1 | Catalog entry orchestration whose URL already includes the tenant: <base>/v1/{tenant_id} | no endpoint; there is no discovery and no fallback | heatclient/osc/plugin.py:42-43 |
| 2 | GET /stacks/{name_or_id} answers 302 with Location holding /stacks/{name}/{id} | Location not returned with redirect, or list index out of range [observed, D11, D10] | heatclient/v1/stacks.py:111-116 |
| 3 | The same 302 on POST|PUT|PATCH|DELETE /stacks/{x}, and on /stacks/{x}/resources|events|template|actions|environment|files | those commands use the typed name directly | heatclient/v1/stacks.py:182-185, heatclient/v1/stacks.py:210, heatclient/v1/actions.py:39, heatclient/v1/events.py:61, heatclient/v1/resources.py:66 |
| 4 | Location keeps the query string | resolve_outputs=False, sort_dir, marker, limit are lost on the redirected request [observed: G01 with a fixed Location] | heat:heat/api/openstack/v1/stacks.py:438-440 |
| 5 | Lookup works by ID as well as by name | stack show <uuid>, and each stack create right after its POST, which looks up stack['id'] | heatclient/osc/v1/stack.py:213 |
| 6 | Absolute or relative Location: keystoneauth follows it; _resolve_stack_id needs only the /stacks/ marker | heatclient/v1/stacks.py:116 | |
| 7 | Content-Type: application/json on JSON answers | the body is ignored: None, then AttributeError or empty output [observed, D24] | heatclient/common/utils.py:436-445 |
| 8 | Fault body with error.message on every 4xx and 5xx | ERROR: None [observed, E03], ERROR: b'…' [observed, E04] | heatclient/exc.py:48-73 |
| 9 | error is an object, never a string | a logging traceback [observed, E05] | heatclient/exc.py:67 |
| 10 | 404 for "stack or child not found", not 400 | most commands' translated messages, the output fallback, and the end of stack delete --wait depend on it | heatclient/osc/v1/stack.py:804-805 |
| 11 | 403 is the only other status stack delete tolerates per stack | any other stops the loop | heatclient/osc/v1/stack.py:792-797 |
| 12 | Accept Content-Type: application/json on GET and DELETE, and bodies of JSON null on signal | sent as is [observed, G30] | heatclient/common/http.py:310-311, heatclient/v1/resources.py:115 |
| 13 | Accept tags as a comma-separated string, adopt_stack_data as a string, template: null on PATCH, files keys that are file:// URLs | [observed, G01, D05, G06] | heatclient/osc/v1/stack.py:177, heatclient/osc/v1/stack.py:883 |
| 14 | Ignore or tolerate the unknown key timeout on adopt | stack adopt always sends it, null by default (defect 5) | heatclient/osc/v1/stack.py:887 |
Correction to the Heat reverse-spec: its 07 cites heatclient:heatclient/common/http.py:191-243 for "the client re-sends the same method on 302". That code belongs to the older HTTPClient. With OSC, the path is SessionClient, and keystoneauth re-sends the method (keystoneauth1:keystoneauth1/session.py:1246) [observed]. The requirement stands; the source differs.
Response keys the client reads
Missing keys cause the raw errors of 02 § 4, defect 12. Keys marked * have no fallback.
| Route | Keys | Source |
|---|---|---|
POST /stacks | stack\*, stack.id\* | heatclient/osc/v1/stack.py:205, heatclient/osc/v1/stack.py:213 |
POST /stacks/preview | stack\*; prints all keys | heatclient/v1/stacks.py:165 |
GET /stacks | stacks\*; id\*, stack_name, stack_status, creation_time, updated_time; optional stack_owner, parent, deletion_time, project | heatclient/common/base.py:118, heatclient/osc/v1/stack.py:658-712 |
GET /stacks/{n}/{i} | stack\*, then id, stack_name, description, creation_time, updated_time, stack_status\*, stack_status_reason, parameters, outputs, links. stack_status must hold a _, or stack failures list fails | heatclient/osc/v1/stack.py:455-475, heatclient/v1/stacks.py:87-90 |
PUT|PATCH …/preview | resource_changes | heatclient/osc/v1/stack.py:387 |
…/outputs | outputs\*; output_key, description | heatclient/osc/v1/stack.py:1089, heatclient/osc/v1/stack.py:1098 |
…/outputs/{k} | output\*; output_error if present is an error | heatclient/osc/v1/stack.py:1046, heatclient/osc/v1/stack.py:1062 |
…/snapshots | snapshots\*; id, name, action, status, status_reason, creation_time | heatclient/osc/v1/snapshot.py:55-66 |
POST …/snapshots | id, name, action, status, status_reason, data, creation_time | heatclient/osc/v1/snapshot.py:175-184 |
…/resources | resources\*; resource_name, physical_resource_id, resource_type, resource_status, updated_time, links (rel: self, stack, nested), required_by for -f dot | heatclient/osc/v1/resource.py:133-146, heatclient/common/resource_formatter.py:22-27, heatclient/common/resource_formatter.py:125 |
…/resources/{r} | resource\*; physical_resource_id for nested hooks | heatclient/v1/resources.py:89, heatclient/common/hook_utils.py:47 |
…/metadata | metadata\*, an object | heatclient/osc/v1/resource.py:186 |
…/events | events\*; id, resource_name\*, resource_status, resource_status_reason, physical_resource_id, event_time\*, links (rel: stack, root_stack, self) | heatclient/common/utils.py:200-260, heatclient/common/event_utils.py:86-91, heatclient/common/event_utils.py:195-203 |
…/events/{e} | event\*; prints all keys | heatclient/v1/events.py:88 |
/software_configs | software_configs\*; id, name, group, creation_time | heatclient/osc/v1/software_config.py:107 |
/software_configs/{c}, POST | software_config\*; id\*, name, group, config, inputs, outputs, options, creation_time | heatclient/v1/software_configs.py:61, heatclient/osc/v1/software_config.py:228-238, heatclient/osc/v1/software_deployment.py:124 |
/software_deployments | software_deployments\*; id, config_id, server_id, action, status, creation_time, status_reason | heatclient/osc/v1/software_deployment.py:210-213 |
/software_deployments/{d}, POST | software_deployment\*; id, server_id, config_id\*, creation_time, updated_time, status, status_reason, input_values, action, output_values\* with deploy_stdout, deploy_stderr. Never empty on create | heatclient/osc/v1/software_deployment.py:130, heatclient/osc/v1/software_deployment.py:168, heatclient/osc/v1/software_deployment.py:253-265, heatclient/osc/v1/software_deployment.py:339 |
/software_deployments/metadata/{s} | metadata | heatclient/v1/software_deployments.py:50 |
/resource_types | resource_types\*: strings, or objects with resource_type, description under with_description=True | heatclient/v1/resource_types.py:31-36 |
/template_versions | template_versions\*, at least 2 entries; version, type, aliases | heatclient/osc/v1/template.py:44-54 |
/template_versions/{v}/functions | template_functions\*; functions, description | heatclient/osc/v1/template.py:95-98 |
/services | services\*; hostname, binary, engine_id, host, topic, updated_at, status | heatclient/osc/v1/service.py:35-36 |
/build_info | an object; api, engine formatted as JSON | heatclient/osc/v1/build_info.py:39-45 |
POST /validate | any object | heatclient/osc/v1/template.py:186-188 |
Texts and values the client parses
A rewrite must keep these strings exactly.
| Value | Compared with | Purpose | Client | Heat |
|---|---|---|---|---|
Stack CREATE started, Stack UPDATE started, Stack DELETE started | event resource_status_reason | start of the current action | heatclient/common/event_utils.py:27, heatclient/common/event_utils.py:31, heatclient/common/event_utils.py:35 | heat:heat/engine/stack.py:1282 |
CREATE paused until Hook pre-create is cleared, and the UPDATE and DELETE forms | same | a hook is pending | heatclient/common/event_utils.py:28, heatclient/common/event_utils.py:32, heatclient/common/event_utils.py:36 | heat:heat/engine/resource.py:538 |
Hook pre-create is cleared, and the other types | same | a hook was released | heatclient/common/event_utils.py:29, heatclient/common/event_utils.py:33, heatclient/common/event_utils.py:37 | heat:heat/engine/resource.py:2486 |
<ACTION>_IN_PROGRESS, <ACTION>_COMPLETE, <ACTION>_FAILED, with actions CREATE, UPDATE, DELETE, SUSPEND, RESUME, CHECK, ADOPT | stack status and event status | end of --wait, cancel and hook rules | heatclient/common/event_utils.py:183-187, heatclient/common/hook_utils.py:65-74, heatclient/osc/v1/stack.py:1307-1311 | |
stack event: resource_name = stack name, physical_resource_id = stack ID = last segment of the stack link | event fields | spot the event that ends the wait | heatclient/common/event_utils.py:195-203 | |
root_stack link on events | event links | the server honoured nested_depth; without it the client walks nested stacks | heatclient/common/event_utils.py:86-92 | |
nested link on resources; the last 2 segments of its href are name/id | resource links | nested stacks | heatclient/common/utils.py:138-144, heatclient/osc/v1/stack_failures.py:71-72 | |
stack link: …/stacks/{name}/{id} | event and resource links | stack name of an event | heatclient/common/event_utils.py:154-160, heatclient/v1/resources.py:39-45 | |
resource status ends with FAILED; resource_type is OS::Heat::StructuredDeployment or OS::Heat::SoftwareDeployment | resource fields | failure report | heatclient/osc/v1/stack_failures.py:68, heatclient/osc/v1/stack_failures.py:99-100 |
stack_status for cancellation is lowered and compared with create_in_progress and update_in_progress (heatclient/osc/v1/stack.py:1307-1318).
What the commands never call
GET /stacks/detail, GET / (versions), CFN routes, PUT /software_deployments/{id} from OSC (heatclient/v1/software_deployments.py:70 serves the legacy shell), and the actions:* other than the 5 listed. A Heat rewrite must validate them by other means: see the Heat pack.
3. Interaction sequences worth testing
Scenarios G01 to G31 give good sequences. Those a service most easily breaks:
- Name lookup with each method (G05, G06, G08, G27).
- Event paging:
limit=1&sort_dir=descto get a marker, thenmarker=<id>&sort_dir=ascuntil the stack event (G02, G05). - 404 on events once a stack is gone (G08).
- Nested events:
nested_depthwith aroot_stacklink (G13); without it, the fallback multiplies requests (G14). - Hook cycle: event texts in order (G17),
unset_hooksignal onname/id(G18). - Client pagination:
limitreturning fewer than asked, thenmarker=<last id>(G10).
4. For a rewrite of the CLI itself
| Topic | Rule | Source |
|---|---|---|
| Names | stack_resource_mark_unhealthy → stack resource mark unhealthy; scripts depend on these and on argument names, including the odd --with_conditions | pyproject.toml:45-93, heatclient/osc/v1/template.py:74 |
| Exit codes | 0, 1, 2 as in 02; do not copy its § 4 | cliff:cliff/command.py:196 |
| Formats | log default for events; dot for resources; JSON or YAML defaults on RawFormat commands | pyproject.toml:95-109, heatclient/common/format_utils.py:20-55 |
| Client-side template handling | the largest part: get_file, type resolution, environment merge rules, file keys as absolute URLs, base64 for non UTF-8 | heatclient/common/template_utils.py:110-178, heatclient/common/template_utils.py:196-213, heatclient/common/utils.py:410-421 |
--parameter | ; split when given once; repeated key gives a list | heatclient/common/utils.py:322-349 |
| Event log line | <time> <id> [<resource path>]: <status> <reason>, with paths built across nested stacks | heatclient/common/utils.py:178-266 |
| Plugin | Python entry points; a non-Python rewrite cannot be loaded by openstack | pyproject.toml:42-43 |
Recommendation: keep the Python client and make the service compatible. The commands are thin, the behaviour worth keeping sits in template handling and event logic, and the plugin only loads in Python. If the plugin is carried forward, fix defects 1, 2, 3 and 5 first. Those are small changes, but none was made here, because the repository is read-only for this work.
5. Conformance: what was proved
| Evidence | Result | What it proves | What it does not prove |
|---|---|---|---|
| Parser dump | 49 of 49 classes imported, 0 failures | the catalogue is complete for the declared entry points | |
| OSC unit tests with recorder | 239 of 239 pass, 230 vectors with a parsed command, for 48 of 49 classes | argv → manager calls | anything about a service: managers are mocks. defects 1 and 5 pass these tests |
| Second run of the capture | 230 of 230 vectors with the same test, call sequence and status | method sequences are deterministic | argument values were not compared |
| Whole unit suite | 913 run, 893 pass; 17 failures and 3 errors, none in heatclient/tests/unit/osc | the managers build the expected URLs from mocks | see Blind spots in 00 for the cause of the failures |
| Probe 404 | 49 commands and 59 variants; 45 + 47 runs sent a request; 151 requests; all 49 default runs exit 1 | real routes, headers and bodies up to the first error | what a service answers |
Probe 200 ({}) | 45 + 47 runs sent a request; 164 requests; 17 + 12 runs exit 0 | which commands tolerate an empty answer | that those commands work: {} is not a Heat answer |
| Scenarios | 74 runs, each with scripted answers | the client's handling of those bytes, including every defect listed | that a real Heat answers like this; the bodies come from reading Heat and its reverse-spec, not from a capture |
The usual "replay is mandatory" bar is not met. No vector was replayed against a Python Heat or a rewrite, because the rules of this work exclude any real service. To close the gap, on a devstack stable/2026.2:
- run
heatclient/tests/functional/osc; - run the probe's argv and the scenario argv with a real template, and compare routes and statuses with the JSON files here;
- then do the same against the Go Heat.
Also compare the Heat pack's exchanges_stable-2026.2.json, which recorded this client's requests on the real Python Heat through heat-tempest-plugin: a useful cross-check, not done here.
Classes without a unit vector (1)
heatclient.osc.v1.stack:ListFileStack (stack file list, heatclient/osc/v1/stack.py:628): no test. It was seen on the stub only.
Vectors without calls: 13 tests stop on the client before any call, on purpose (bad arguments, a declined prompt).
6. Gaps found while building the pack
- 3 operations were never sent by the probes, only by scenarios:
POST /software_deployments,DELETE /software_deployments/{id},GET …/events/{id}. - 4 commands send nothing with minimal arguments:
software deployment create(defect 1),software deployment output show,stack output show(both need an optional positional or--all),stack update(needs-tor--existing). Their variants do. - The 200 probe stops at the first
{}that the client cannot use, so only the scenarios reach later steps. --waitpaths ran withtime.sleepreplaced, so real timing was not tested.- Swift calls were seen only with the legacy shell, on a stub answering 201 to everything.
- The extractor's REST resolution targets openstacksdk, so it found almost nothing here (
with_rest=2). The static route table inbuild_contract_stable-2026.2.pywas written by hand fromheatclient/v1/*.py. A route added to a manager would not appear by itself.
7. Checklist for a Go Heat
- [ ] 302 lookup with every method, sub-paths, the query kept (§ 2 #2–5).
- [ ] Fault body with
error.message, JSON content type (§ 2 #7–9). - [ ] 404 semantics, including events of a deleted stack (§ 2 #10).
- [ ] Response keys of § 2, collection keys always present, even when empty.
- [ ] Event texts and links of § 2, unchanged.
- [ ]
eventshonouringmarker,limit,sort_dir,sort_keys,nested_depth, with aroot_stacklink. - [ ] At least 2 template versions with
aliases. - [ ] Decide
global_tenantpolicy knowing thatstack list --longsends it (defect 2). - [ ] Run the probes' argv with
software deployment create --signal-transport NO_SIGNAL, since the default fails on the client.
Source: reverse-spec-python-heatclient/07-rewrite-pack_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.