Documentation index · Contributing
- Python ≥3.11, managed with uv. The project supports every stable, non-EOL CPython release at or above that floor.
- MCP server: FastMCP (stdio or streamable HTTP)
- CLI: Typer + Rich tables
- REST transport: httpx (async), XML parsed with defusedxml
- CLI passthrough: asyncssh — tools that
shell out to HMC CLI commands (
lssyscfg,lshwres,chsyscfg, ...) over SSH
From a source checkout, run just setup to install the locked development environment
and repository hooks. Use uv run --no-sync before CLI commands in this environment.
See Contributing for the full change and verification workflow.
The client and XML parser are tested against an HMC mocked with respx — no real hardware required. The default command reports only the configured test and coverage result:
just test
# test: passed; configured coverage gate passedUse just test-verbose for native pytest diagnostics and missing-lines coverage.
CI distributes each supported Python version across four groups:
domain-and-unit, application (CLI/MCP), procedure-arms (offline), and
tooling. Files and test identities stay in place, and shared fixtures still
require the application dependencies. For a focused local run:
just test-group applicationA group retains branch data in .coverage, JUnit results in
dist/test-results.xml, and phase timings. Its success reports combined
coverage pending. The per-version CI verification check requires successful
producers and all four matching artifacts before enforcing the unchanged 90.5%
coverage floor, running smoke/build checks and retaining a wheel. Failed,
skipped, cancelled or malformed inputs cannot produce a green verification.
just test and just verify remain complete standalone local commands.
Verifies the MCP handshake in process with a real FastMCP client and reports the exposed tool count:
just smoke
# Connected. <N> tools exposed.The count is what a client's tools/list carries: the primary catalog the policy admits
(ADR 0330). Use just smoke-verbose to list those tools; the generated
tool reference covers the whole registry.
With credentials configured (TOML profile, env vars, or flags), the cheapest end-to-end check
is console info — one Logon + one ManagementConsole GET:
hmcpctl console info
hmcpctl systems list # should print a table of your Power serversIf console info prints the HMC version, auth, TLS and the session
lifecycle all work; everything else uses the same path.
src/hmcpctl/
*.py # package-level modules and composition roots
api.py # supported connection/configuration facade (ADR 0118)
config.py # TOML profiles, environment values, and CLI configuration
errors.py # shared HMC error hierarchy
resource_identity.py # managed-system, LPAR, and VIOS selector resolution
audit/ # audit records and non-blocking diagnostic transport
authorization/ # access policy plus dispatch-time scope enforcement
client/ # HMCClient transport, response parsing, and domain mixins
documents/ # domain XML request builders and common envelopes
jobs/*.py # job outcome normalization, polling, and request builders
operations/ # presentation-neutral workflows and authorization policy
affinity/, inventory/, lpar/, metrics/, storage/
systems/, templates/, updates/, users/, vios/, virtualization/
# domain workflows; see each package for its resource scope
server_tools/ # MCP adapters: inventory, lpar, metrics, storage, systems,
# templates, users, vios, and virtualization
cli_commands/ # Typer adapters: config, jobs, lpar, metrics, storage, systems,
# vios, and virtualization
snapshots/ # portable LPAR snapshots and affinity assessment
ssh/*.py # asyncssh transport and resource-specific HMC CLI commands
tool_registry.py # local MCP tool collection and ToolSecurity metadata
_app.py # FastMCP factory and shared execution helpers
server.py # MCP composition, startup validation, and serving bootstrap
cli.py # CLI registration aggregator
tests/ # pytest + respx, no real HMC needed
scripts/ # repository guardrails, generators, test runners, smoke checks,
# and live-test harnesses
- Auth:
PUT /rest/api/web/Logonwith a LogonRequest XML body returns anX-API-Sessiontoken, sent as a header on every subsequent call;DELETE /rest/api/web/Logonlogs off. - Resources are Atom feeds of vendor media type
application/vnd.ibm.powervm.uom+xml; type=<ResourceType>. .../quick/<Property>returns a single property cheaply;.../search/(<Property>==<Value>)filters server-side.- State changes are asynchronous jobs: POST a JobRequest to
/rest/api/uom/<Type>/<uuid>/do/<Operation>, then poll/rest/api/uom/Job/<job-uuid>.
A release is an annotated Git tag; the build derives the package version from it (ADR 0250). Nothing is published to a package index, so the tag is what a consumer pins; the README shows the form.
Versions are semver. The tag is vX.Y.Z, and the GitHub release is titled
hmcpctl X.Y.Z (<short-sha>), where <short-sha> is the tag commit's
git rev-parse --short ID. A build of the tag reports X.Y.Z; a build N commits later
reports X.Y.(Z+1).devN+g<sha>, with a .dYYYYMMDD marker when tracked files are
modified. The changelog heading stays
## [X.Y.Z] - YYYY-MM-DD, because a commit cannot name its own SHA.
Live verification is a release stage (ADR 0212). Between releases, installed CLI/MCP discovery and generated tool documentation retain the last release's verification, without runtime age decay. Ordinary development changes do not require live reruns or rerun issues. An operator may authorize an ad-hoc window; its catalog evidence counts toward a candidate only while the closure matches and the gate's age ceiling holds.
A release runs in the order of the subsections below: scope it, name the candidate and freeze
main, verify the candidate live, read the evidence gate, review the docs and cut the
changelog, tag it, then reopen main. Each subsection says who decides what. The operator is
the maintainer who owns the release.
A release is a GitHub milestone (0.2.0, 0.3.0, …): every open issue carries the
milestone it ships in, and the milestone's release epic (the 0.1.0 precedent is #871; 0.2.0 is
#1836) is the issue that names:
- the consumer contract the release serves, and
- the committed claimed-set file under
docs/capabilities/releases/vX.Y.Z.json, whoseoperationsrows name eachoperation, evidencingarmandscenario. The offlinejust capability-inventoryguard validates these triples against registered scenarios.
The claimed set is what the release promises; nothing outside it is promised, whatever its
verification state. The historical v0.1.0.json contains 15 MCP claims from #871, the Tier A
kdive adopt contract and Tier B bare-CEC path. It does not claim CLI wrappers. For a new
release, commit and review its own versioned claims file before naming the candidate.
Changing claims after the window requires fresh validation and matching evidence for added
claims; never infer coverage from a similarly named scenario. Report CLI evidence or gaps
separately as live testing describes.
Before naming the candidate, run the
coupled dependency bump for fastmcp-slim,
mcp and mcp-types, which Dependabot does not update.
The candidate is the main commit whose claimed operations' code the release ships and the
live window runs on. The operator names it on the release epic before the first arm runs, and
the freeze starts then; it ends when the tag is pushed.
During the freeze, nothing may merge that changes the import closure of a claimed operation:
the .py modules under src/hmcpctl/ that
ADR 0127 fingerprints for it. That test is
the whole rule. Changes that pass it include:
- documentation, including the docs review and changelog cut below;
- the release-prep commit of tag and release step 1, which changes the changelog and the README's pinned tag; and
- evidence commits that promote observations into the catalog, including the
src/hmcpctl/_operation_maturity.jsonprojection they regenerate, which is data, not a module.
A merge that moves a claimed closure invalidates its candidate evidence, even though the packaged historical state stays frozen. Name the new candidate, obtain authorization bound to that commit, and run the release-window command for its claims before reading the gate again. Never treat a supposedly harmless delta as evidence. This is a release-freeze rule, not an instruction to run hardware after ordinary development merges.
The operator authorizes every hardware window explicitly, naming its arms, mutation boundary and VIOS access when needed, including the independent read-only baseline. Record the public-safe authorization on the release epic; keep actual system identifiers and the command's exact candidate-bound authorization JSON private. A development or PR approval is not a hardware grant.
Follow the release-window procedure from a clean, non-default candidate worktree created at the named commit. Its dry run contacts no HMC and prints the authorization template. One authorized invocation then captures independent baselines, runs each selected arm as preflight → named arm → recovery, compares after-baselines, generates evidence, promotes observations on a new local branch, and reads the claimed-set gate. Recovery runs after an attempted arm, even if the arm fails; an unclean or unreadable recovery stops the window. Failed phases, baseline drift or unreadable evidence block promotion; a written explanation does not turn a failed comparison into a pass.
The independent baseline reads system identity, partitions/states, slot owners, profile
io_slots, SR-IOV resources and, when authorized, VIOS identity, mappings, volume groups and
volumes. It preserves structural identity, including UUIDs, and compares the same selectors
before and after the window. Keep other actors quiescent. See the
baseline commands and failure exits for manual inspection.
The command never pushes, tags or commits to the original candidate branch. Review its promotion PR before merging: only the catalog, release snapshot, packaged projection and generated tool references belong in that evidence commit. Raw results, authorization, logs and baseline snapshots remain private. Publish only the generated, filtered evidence matrices with their actual tested candidate; record the promotion and gate-read commits separately.
Read the gate on the clean commit carrying the reviewed promotion, then again at the intended
tag commit. Substitute the release's committed claims file for vX.Y.Z.json:
uv run --no-sync python scripts/check_capability_inventory.py \
--require-current docs/capabilities/releases/vX.Y.Z.json--require-current selects and reports the claimed operations and exits nonzero unless each
is current; it includes closure matching and the 90-day age ceiling at read time. No manual
row comparison is needed. It cannot be combined with the global --fail-on-stale flag.
An added claim, changed closure, failed/missing observation or expired evidence holds the tag;
resolve it in an operator-authorized release window and review the resulting promotion.
just verification-report remains a global candidate diagnostic. Record its five-state counts
(current, stale, failed, unevidenced and unrecorded) on the epic and in Known issues, but unclaimed
non-current operations do not block the claimed-set gate. The optional global diagnostic is:
uv run --no-sync python scripts/check_capability_inventory.py \
--verification-report --fail-on-staleIts nonzero stale result is informational for release claims, not a reason to run unclaimed arms. CI no longer runs a per-commit verification-report job or a weekly stale-failure job.
The packaged snapshot and the live gate answer different questions. The snapshot preserves historical release state without age decay; the gate tests candidate eligibility now. A long freeze can therefore fail the age gate without changing packaged state. Record the actual candidate, promotion commit and gate-read commit on the release epic.
Before the tag, review the user-facing documentation against the candidate, and cut the
changelog, which is step 1 of tag and release. Both land on main
during the freeze. For 0.1.0 that work is #1468.
Contributors record each user-facing change under [Unreleased] in CHANGELOG.md, in the
usual Keep a Changelog categories, ADR 0118 facade
changes included. A release's changelog entry keeps those itemized categories, and it is also
the GitHub release body. From 0.2.0 on, the entry has this order under its
## [X.Y.Z] - YYYY-MM-DD heading:
- a short statement of the release's scope and the consumer contract it serves;
- a Verified section naming the claimed operations, each verified
currentat the gate; - a Known issues list that names concrete problems, including the global verification counts from the evidence gate. Every release's entry needs one; an entry without it is incomplete; and
- the itemized Keep a Changelog categories (
### Added,### Changed, and so on).
The scope statement leads, as in the 0.1.0 entry, so a reader of the release body meets what the release is for before the itemized changes.
0.1.0 is the one exception: the first release, with nothing shipped before it, so its entry carries no itemized changes. Its itemized pre-release history was dropped from the changelog, after the guidance an operator needs that only the changelog stated moved into the user documentation; Git history keeps it.
The operator authorizes the tag after the gate passes, and authorizes the GitHub release separately, after the tag. An agent or automated workflow never creates the tag or the release.
-
On a branch, cut the changelog: move the
[Unreleased]entries under a dated## [X.Y.Z] - YYYY-MM-DDheading inCHANGELOG.md, keeping their categories, and add the scope statement, Verified and Known issues sections ahead of them, in the order given under docs and changelog (tests/unit/test_changelog.pyrequires the newest release tag to have a heading). Point the changelog's link references atvX.Y.Z(the[unreleased]compare base and an[X.Y.Z]tag link), and update the README's pinned tag. For0.1.0, whose changelog already had its heading before it was tagged, step 1 instead replaced both that existing section and the[Unreleased]entries, itemized pre-release history included, with a one-off minimal entry dated for the release (scope, Verified and Known issues only), leaving an empty[Unreleased]heading. Git keeps the history. That applied to 0.1.0 alone; later releases keep their itemized categories. Merge that change tomain. -
Choose the tag commit: the
maincommit that change merged as, or a latermaincommit the freeze admits. Its CI must be green andjust verifymust pass on a clean checkout. Also run the release-mode inventory check; ordinary verification deliberately permits development closures to differ from the last release snapshot:uv run --no-sync python scripts/check_capability_inventory.py --release uv run --no-sync python tests/validate_release_artifacts.py dist . --releasejust verifybuilds the fresh wheel and sdist consumed by the second command. Release validation recomputes snapshot verification from the catalog and candidate closures; it does not apply the age ceiling. A mismatch holds the tag for reviewed release evidence. -
On that exact checkout, require both the claimed-set gate and release-mode validation:
uv run --no-sync python scripts/check_capability_inventory.py \ --require-current docs/capabilities/releases/vX.Y.Z.json --release
A failed claim holds the tag even when the snapshot matches. Follow the freeze and evidence gate rules, then repeat from step 2 at the new evidence-bearing commit. Record the passing tag-commit read on the epic. Re-read
just verification-report; if its global counts differ from Known issues, correct that entry, merge it, and repeat from step 2 on the resulting commit. -
Tag that exact commit locally, check the release build on a clean checkout of it, and only then push the tag:
git tag -a vX.Y.Z -m "hmcpctl X.Y.Z" <tag-commit-sha> git checkout --detach vX.Y.Z just build uv run --no-sync pytest tests/unit/test_changelog.py --no-cov uv run --no-sync python tests/validate_release_artifacts.py dist . --release git push origin vX.Y.Z
just buildmust producedist/hmcpctl-X.Y.Z-py3-none-any.whl; a.devor+version means the checkout is not clean or not at the tag. If any check fails, delete the local tag withgit tag -d vX.Y.Zinstead of pushing it. Check thatgit rev-parse vX.Y.Z^{commit}prints the tag commit. -
Create the GitHub release from the pushed tag. Its body is the version's changelog entry: copy the entry, without its
## [X.Y.Z]heading, into the notes file. The title carries the tag commit's short ID:short_sha=$(git rev-parse --short "vX.Y.Z^{commit}") gh release create vX.Y.Z --verify-tag --title "hmcpctl X.Y.Z ($short_sha)" \ --notes-file <notes.md>
Pushing the tag ends the freeze, and main reopens to every change. Close the release epic
and open the next one, or adopt an existing epic for it, naming its claimed operations and
consumer contract as in scope. The 0.2.0 release epic is #1836; its content epic
is #1215. A milestone's content is whatever is still assigned to it when the candidate is
named; move an unfinished issue to a later milestone rather than holding the release for it.