mtui (Maintenance Test Update Installer) is the SUSE QE tool for validating
maintenance updates: load a request by RRID, install/test it on reference hosts
over SSH, then approve/reject. It drives OBS/IBS and Gitea review workflows
and openQA/QEM; reports load from and upload to TeReGen's v2 API. It is a
memory-safe, async-native Rust workspace that ships as two single static
binaries (mtui, mtui-mcp), with no runtime interpreter.
Preserve the data-format and workflow contracts that let mtui interoperate with the SUSE maintenance ecosystem (see "Contracts" below); break compatibility only when the task explicitly calls for it.
mtui was originally ported from a Python implementation, removed in ec80791c
(the old tree is readable at git show ec80791c^:mtui/..., and on the
archive/python-main tag and the 16.0.x–19.0.x branches). Do not treat it
as an authority. Matching its behaviour is not a goal and "upstream does X" is
not a justification. Use the archive to understand why a shape exists — then
record what you learn as a statement about the constraint, not as a citation.
- Never preserve a bug, a typo, or an awkward shape for parity with it. Fixing one is welcome — in its own commit, with a CHANGELOG entry if a user or MCP client would notice. Retiring a rationale is prose; changing the bytes is not, and the two must not ride in the same commit.
- Check first that the shape is not a contract in disguise. If it is written
to a host (
/var/lock/mtui*.lock,/var/log/mtui.log, a zypper repo alias —repo_manager.rs::issue_alias), signed or transmitted to a live service (the openQA HMAC path encoding,openqa/client.rs::encode_path_for_signing), or is user-facing text an LLM client consumes, it is load-bearing even when the only comment about it says "upstream did X". Replace the stale rationale; do not delete the constraint. - A note recording a deliberate departure is a guard-rail, not a citation —
it stays. Reframe it as a positive statement of the choice ("mtui is XDG-first:
history lives at
$XDG_DATA_HOME/mtui/history") rather than a comparison. Keep the constraint, drop the comparison. Likewise for a barePorts upstream mtui.commands.foo: keep the behavioural description, drop the citation, and if the line was only a citation, drop the line. - Never strip a licence or provenance citation, even when it routes through
the old tree —
obs/inference.rs's GPL-2.0-only attribution chain to openSUSE/osc-plugin-qam is a legal requirement, not archaeology, andCargo.toml's relicensing note explains a deliberate difference. upstreamis not always the old implementation, and the distinction is by referent, not by path — the same file mixes both. Where the sentence cites a.pyfile or a Python identifier it means the removed tree; where it names a live external it stays:openSUSE/osc-plugin-qam,mjdonis/oqa-search, theosctool's ownoscrclookup (obs/oscrc.rs), the openQA server's signing rules (openqa/client.rs), and Rust crates such asrussh/rsa. Read each site; do not sweep the term.- "Python" is also a domain word here. Never rename or delete it in SUSE
package flavours (
pythonNNN-foo→python-foo,oqa_search/heuristics.rs::PYTHON_FLAVOR_RE— live openQA build-check matching), product names in fixtures (sle-module-python2-*), ortypos.tomlentries that keep the append-only CHANGELOG spell-checkable. Notetyposis a CI-only job the local gate does not run.
- Safety & robustness: strong types, exhaustive error enums (
thiserror); prefer a typedResultover silentNone-swallowing. - Performance: async I/O (
tokio), true parallel host fan-out; fast startup; single binary. - Distribution: one static
mtui+ onemtui-mcp, no runtime deps beyond a couple of subprocesses; shell completions and man pages are generated. - Maintainability: a Cargo workspace with clear crate boundaries and a single composition root, so hosts/datasources/testreport stay decoupled.
There are two entrypoints, and command/entrypoint changes must keep both green:
mtui— the interactive REPL (reedline).mtui-mcp— the MCP server, which synthesises its tools from the command registry. Adding/renaming/removing a command affects MCP tools automatically. Exception: a deny-listed command may be re-served under the same name as a hand-written tool (edit→ thereport_*document tools;get/put→ the in-band transfer tools, #434).
Cargo workspace; each crate has one job. Lower crates never depend on higher ones;
mtui-core is the composition root that wires everything.
mtui (root) facade package owning `src/bin/{mtui,mtui-mcp}.rs` behind
the `cli`/`mcp` features; integration tests in `tests/it.rs`
crates/
mtui-types/ domain types + error hierarchy (no I/O)
mtui-config/ TOML config + XDG paths
mtui-hosts/ SSH/SFTP (russh), Target/HostsGroup, locks, arbiter [async]
mtui-datasources/ shared HTTP, refhosts resolve/search/verify, openQA/QEM/Gitea/native-OBS-QAM/oqa-search [async]
mtui-testreport/ TestReport lifecycle (TeReGen v2 document ingest/upload), update workflow (actions/checks/export)
mtui-core/ Command trait + registry + Session + engine + dispatch
mtui-tui/ report-document editor: schema walker, form model, ratatui screen (no I/O)
mtui-cli/ reedline REPL library
mtui-mcp/ rmcp server library
fuzz/ cargo-fuzz harness over the untrusted-input parsers.
Detached from the workspace (own empty [workspace] table):
fuzzing needs nightly and must not affect the MSRV,
Cargo.lock, or stable CI. Run: cargo +nightly fuzz run
<target>. CI runs it via ClusterFuzzLite
(.clusterfuzzlite/ + .github/workflows/cflite_fuzz.yml).
Task breakdown is tracked in the project's issue tracker; check it for the next actionable task before working on a subsystem.
- Build everything:
cargo build --workspace(produces bothmtuiandmtui-mcp) - Run the REPL:
cargo run -p suse-mtui -- --help - Run the MCP server:
cargo run -p suse-mtui --bin mtui-mcp -- --help - Format:
cargo fmt --all - Lint (warnings are errors):
cargo clippy --workspace --all-targets --all-features -- -D warnings - Docs (broken/private links are errors):
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --document-private-items - Test:
cargo test --workspace— the cost is compilation, not test execution. A coldcargo build --workspace --testsis ~80s; the actual test run, once compiled, is only ~20-25s. The default 120s timeout is exceeded only when compiling from cold, so allow ≥300000 ms (5 min) on the first run of a session; a second run against a warmtarget/cache is seconds. - Coverage:
cargo llvm-cov --workspace --lcov --output-path lcov.info - Feature matrix (catches feature-gate rot) — compile-only. On a 10-core,
32 GiB Mac16,10, switching from the default graph took 25 s for
--no-default-featuresand 4 s for--all-features: the former changes feature unification by droppingcli/mcp, while the latter adds onlynotify-rust:cargo build --workspace --no-default-featuresand--all-features. Do not routinely test--all-features; CI only compiles it (.github/workflows/ci.ymlfeature-matrix job).
- Keep the cache warm and scope tight. During dev, run
cargo test -p <crate>for the crate you're touching, not the whole workspace — reservecargo test --workspacefor the final gate. - Test default features only while iterating. On a 10-core, 32 GiB Mac16,10,
cargo test --workspace --all-featurestook 36 s versus 28 s with default features; it adds onlynotify-rustand no extra runtime signal beyond the compile-only feature matrix. mtui-cli's lib suite is the slow one (~8s). Itsedit/shelltests spawn real editor/shell subprocesses. When working elsewhere, don't rerun it.
- Run the full gate on the whole workspace before claiming done, mirroring
CI:
cargo fmt --all --checkandcargo clippy --workspace --all-targets --all-features -- -D warningsandRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --document-private-itemsandcargo test --workspace(default features — the rootmtuifacade'smcpfeature is on by default, so this run already coversmtui-mcp's server/transport suite) and the compile-only feature matrixcargo build --workspace --no-default-features+cargo build --workspace --all-features. Tests run against default features only — do not run--all-featurestests. The long pole is cold compilation, not the test run itself, so give the firstcargo test --workspace(and the feature-matrix builds) a generous timeout (≥300000 ms) and don't treat an early timeout as a failure. - "Done" means CI observed green, not predicted green. Report status from the actual run.
- A regression test must be observed failing against the unfixed code before the fix is claimed done. Revert the fix (or hand-break the line), run that one test, see red, restore. A pinning test that was never red pins nothing.
- New/changed code needs >=80% patch coverage. If a line is genuinely un-coverable (best-effort network/error paths), add a focused test or a justified allow — never leave coverage silently red.
- Keep both surfaces (
mtui,mtui-mcp) building and passing when touching commands,Session, the registry, or entrypoints. - Preserve the Contracts below unless the task explicitly changes one.
- Changelog: add an entry to the top-most unreleased section of
CHANGELOG.mdfor user-visible changes (new/changed/removed commands or flags, behavior a user or MCP client would notice, config format changes). Internal refactors, perf/implementation-only fixes, and chore/CI-only changes do not need one (CONTRIBUTING.md§ Changelog is authoritative).
- Command registry. Every command implements the
Commandtrait and is registered into a central registry (explicitregister_all()). The REPL dispatch, tab-completion, and the MCP tool synthesiser all iterate this one registry — it is the single source of the command surface. - Session state. Commands operate on a
Session(config,HostsGrouptargets, loadedTestReport/metadata, display) passed explicitly. No hidden globals. - Trait injection instead of crate cycles.
mtui-hostsdrives the install/uninstall template through thePlanProvidertrait, declared inmtui-hostsin terms of its own types;mtui-testreport'sWorkflowRegistryimplements it, so the doer/check tables reach the host dispatch withoutmtui-hostsdepending onmtui-testreport. Do not create crate cycles — add a trait and inject. Inject at the point of use, not at construction.update_flow:: perform_install/perform_uninstallinstall the provider immediately before driving the template.OperationGroup::planshas exactly one consumer, so there is exactly one place that cannot forget to wire it. An earlier design injected at a composition root instead; nothing ever called it, everyHostsGroupwas built without a provider, andinstallreported success while running no command on any host for the whole life of the Rust port. - Config. TOML file
mtui.toml, resolved highest-precedence first from--config→$MTUI_CONF→$XDG_CONFIG_HOME/mtui/mtui.toml→~/.mtui.toml→/etc/mtui.toml; the default set is merged lowest-precedence first so per-user files override/etcon shared keys. Sectioned tables ([mtui],[connection],[refhosts],[url], ...) map to typed options. Loading is lenient: a missing/malformed file (or a bad value) is logged at ERROR and skipped, falling back to defaults; it never hard-fails. CLI-arg merging is implemented viaArgs::apply_to(crates/mtui-core/src/args.rs), the highest-precedence config layer, applied afterConfig::load. - MCP is a thin adapter.
mtui-mcpbuilds one tool per non-denied command by converting the command'sclaparg spec to a JSON schema, reconstructing argv from tool kwargs, and dispatching through the same engine as the REPL. REPL-only commands (quit,exit,EOF,edit,shell,help,switch) are deny-listed, as areget/put, which are re-served under the same names as hand-written in-band transfer tools (#434) — a deny-listed command may be replaced by a richer hand-written tool (edit→ thereport_*tools is the same pattern). The deny-list ∩ registry is consistency-tested and drift is warned about at boot. Local process execution and terminal launching have no entry because they have no command:lrunandtermswere removed by design; do not reintroduce either. - Cancellation is cooperative-first.
Sessioncarries aCancellationToken(the seam); theCommand::rundriver checks it before dispatch and between fan-out templates.Session::activatepushes the token down onto the active report'sHostsGroupso the flows can consult it. Never gate a parallel fan-out on it:run_parallelreturns(), so a host skipped mid-batch is indistinguishable from one that ran — its stalelast*snapshot sails through the post-run checks and the update reports success on a host it never touched (and every teardown is a fan-out too, so gating strands remote locks exactly when a cancel needs them released). Long serial flows polltargets.cancel_requested()at their own boundaries (downgrade's non-transactional per-package loop,prepare --installed's pre- and post-probe checkpoints, theupdatestep sequence) — but never past a point of no return:updatemakes its last check before dispatching the patch command, and the post-failure rollback runs underHostsGroup::suspend_cancellation(its own per-package checkpoint would otherwise abort the recovery at package 0 and strand the half-applied update it exists to undo).UpdateFailure::Cancelledskips the rollback because the update never ran. A checkpoint mustbreakinto its function's normal fall-through, never early-returnpast cleanup — and a real failure collected before the cancel always outranks it. MCPjob_cancelcancels the job's token, waits a short grace for a cooperative stop, then hard-aborts the worker — its reply distinguishes the two and never claims to cancel a job that had already finished. The REPL is the seam's second producer: a Ctrl-C during a command (the terminal is cooked then, so it is a real SIGINT rather than reedline's key event) is forwarded onto the session token instead of killing the process, and a second press force-exits 130 with a warning about the locks that may be left behind. It installs a fresh token per dispatched line, unconditionally — the token is one-shot, so a cancelled one left in place would kill every later dispatch at the pre-flight check,quit's teardown included, stranding exactly the locks the cancel had to release. The teardown dispatch gets the fresh token but no cancel arm: a press there only escalates, because cancelling the cleanup is what strands the locks. That is a property of the dispatch, not of the key — a typedquit/exitis routed to it by resolving the line's command position through the registry, so it is protected exactly as Ctrl-D is. A new interrupt hook belongs on this seam, never on its owntokio::signal::ctrl_c— the REPL arms SIGINT process-wide from the first prompt onward (startup seeding is still outside that window), and a headless tool call has no terminal to press Ctrl-C at, while a stdio server that does share one would fire every listener at once, interrupting work nobody asked to stop. A flow that stops on a cancel must surface asCommandError::Cancelled, not a generic failure — unless stopping is its documented success (request_review --watchreturnsOkwith "the request is still posted";regenerateabandons the wait and reports the state it last saw, since the server keeps building) — and that verdict must come from the flow's owncancelledflag, never from sniffing the session token, which would mask a real host failure that merely coincided with a cancel (seecommands/perform.rs::map_flow_error). New long-running command bodies should observe the seam at their own step boundaries, and report what they completed before stopping.
- RRID grammar
project:kind:maintenance_id:review_idand its parse errors. - refhosts.yml schema — the file is still location-grouped on disk, but
location is a legacy grouping, not a live query dimension: rows are
merged/flattened and de-duplicated at load (
version.minormay be numeric orspN). - Report document schema v1 (
report_document::SCHEMA_JSON), served by TeReGen; drift from the live schema is checked at load. - Remote lock wire format — one line
timestamp:user:pid[:comment](parsed with a 3-way split so the comment keeps embedded colons). Two locks share this layout: the operation lock/var/lock/mtui.lock(PID-based ownership, guards serialized zypper transactions) and the pool-claim lock/var/lock/mtui-pool.lock(RRID-based ownership; the comment carriesmtui pool <RRID> [<owner>]). Every mtui sharing the fleet parses this layout, including older releases — snapshot-test it (crates/mtui-hosts/tests/lock_format.rs). - Remote history format — one
timestamp:user:field1[:field2…]line appended to/var/log/mtui.logon each enabled host, written withsftp_append(O_APPEND|O_CREAT, never read-modify-write) so concurrent testers on one host do not clobber one another, and read back bylist_history. The append-only primitive exists for this contract — do not collapse it intosftp_write. - MCP tool names/schemas — downstream LLM configs depend on them; snapshot the
synthesised + slimmed schemas. Dropping a property takes two releases: the
schema loses it at once, the call keeps accepting (and ignoring) it for one
release through
crates/mtui-mcp/src/tools.rs::DEPRECATED_KWARGS, whose doc names the release that refuses them and whose test fails that version bump — and only then is it refused. The one exception is a key that already failed the call (#597:load_template'stemplate/all_templates); remove it outright and say so in the CHANGELOG.
The tests/ fixtures are the authority for these formats; treat them as golden.
- Unit tests colocated (
#[cfg(test)]); integration tests incrates/*/tests/and the facade's roottests/. - One integration-test binary per crate. Each crate's integration tests are
consolidated into a single
tests/it.rs(#[path = "<file>.rs"] mod <file>;per file) withautotests = false+[[test]] name = "it"inCargo.toml, so the crate + its heavy deps link once, not once per file (this is the main test-compile speedup). Add a new integration test as amodline intests/it.rs, not as a new top-leveltests/*.rs(a new top-level file would be silently ignored underautotests = false, or reintroduce a per-file binary if you re-enable discovery). Because all a crate's integration tests now share one process, anything touching a process-global (env vars, theset_test_sinkspinner sink) must be serialised with#[serial(<name>)](serial_test), and tests must not assume per-binary isolation (e.g. no asserting on heap-address identity — a freedArcaddress can be reused). - Mock, don't hit the network/hosts: HTTP via
wiremock; SSH via aMockConnectionimplementing theConnectiontrait; OBS/IBS and every other HTTP datasource viawiremock. - Snapshot text contracts (
insta): report document goldens, MCP schemas, lock-file format, display output.instaprefixes each.snapfile with the test-binary name, which is nowitfor every crate — so snapshot files areit__<module>__<name>.snap. A new snapshot test's file lands with that prefix automatically; don't hand-name it otherwise. - Gate real hosts/containers behind
#[ignore]+ a CI env flag (sshd integration fixture); unit tests must run offline and fast. - Capturing
tracingoutput: install the subscriber globally, scope the sink.tracing::subscriber::set_default's guard is thread-local, but callsite interest is cached process-wide: a callsite first reached from a thread with no subscriber installed is cachedInterest::never()and stays silent for every later capture, so with libtest running in parallel whichever test got there first decides whether a log assertion can even fail. Install once withset_global_defaultand move the scoping to a thread-local sink. The pattern is settled and already written up three times — canonical copy:crates/mtui-datasources/tests/log_capture.rs; alsomtui-datasources::teregenandmtui-testreport::reports::update_flow's test modules. Copy it, do not re-derive it. Known cost: an unfilteredRegistryreports nomax_level_hint, soLevelFilter::current()goes toTRACEfor that whole test binary and everydebug!/trace!in it starts evaluating its arguments; bound the layer with aLevelFilterif that ever matters. - A test that cannot fail is not evidence. Twice a real regression has sailed
through a green workspace run: once because the fixture disarmed the assertion
(a
MockConnectionanswering empty stdout builds no downgrade command, so "assert no--oldpackage" could not fail; a fresh report "clears" a field that was alreadyNone), once because the fix landed one layer too low (Target::rebootgated onTargetState) — which half-gated the lifecycle and silently broke therebootcommand while every test stayed green. Green CI is a claim about the suite, not a proof about the code. For each new assertion, name the mutation it must catch and check the fixture can express it: a changing boot id passes the disabled-host test even when only the dispatch is skipped, so that test pins a fixed one. Then break the code and watch it go red. Apply the same scepticism to the fix — when the leaf you are gating has other callers (Target::rebootis also reached bycloseand by the explicitrebootcommand), gate on the layer that owns the state and probe the sibling paths before believing the gate landed where you meant it.
- Edition 2024, MSRV 1.96.
rustfmtdefaults;clippyclean with-D warnings. - Errors:
thiserrorenums in library crates,anyhowonly at binary boundaries. Prefer a typedResultover alog + return None; where you intentionally rely on best-effort degradation, make it explicit and test it. - Logging:
tracing(notlog); levels configurable via CLI +RUST_LOG. - Async everywhere I/O happens (
tokio); keep pure logic (mtui-types) sync and I/O-free. - Never leak secrets to output or logs. Secret config fields (currently just
gitea_token, classified byis_secret_attrin theconfigcommand) are masked (<set>) by bothconfig showandconfig set— never echo their value to the display buffer (it reaches terminal scrollback and MCP output). Configured datasource URLs may embed credentials (scheme://user:pass@host); always run a URL throughsanitize_url(mtui_datasources::sanitize_url, shared withmtui-core) before logging it or putting it in an error (it replaces userinfo with***while keeping the host for diagnosis). Never render areqwest::Errordirectly — itsDisplayappends the request URL verbatim, and a redirect can put credentials back into it;impl From<reqwest::Error> for HttpErrorstrips the URL where reqwest errors enter the crate's hierarchy, so convert first and add request context yourself. Outsidemtui-datasourcesreusesanitize_url— never interpolate a raw datasource URL into a message; where it cannot apply, log the host alone or rely on the already-stripped error types. The Gitea token travels in anAuthorizationheader and is never logged. - Never add SSH password auth — MTUI is pubkey-only by design; preserve that.
- Comments must not outweigh the code. A one-line change gets at most a
one-line comment, and usually none — a three-line preamble explaining a
one-line fix is noise that goes stale the moment the line moves. Where the
code says what it does (a named function call, an obvious guard, a
matcharm), no comment is needed. Comment only what the code cannot say: why a non-obvious choice was made, a contract or invariant that must not be regressed, or a subtlety that cost real debugging time.
- Implement the
Commandtrait (name, aliases,configureargs, asyncrun,complete). - Register it in
register_all(). - It is now a REPL command and an MCP tool automatically — verify both, and check whether it belongs on the MCP deny-list.
- Add unit tests (arg parsing,
runagainst mocks, completion) with >=80% patch coverage; snapshot any new text output. - Update the command reference docs (prefer generating from the registry).
None. The QAM review workflow (assign/unassign/approve/reject/comment)
talks to the OBS/IBS API natively, never through an osc/osc-plugin-qam
subprocess (see the native OBS backend and [obs] config), reading credentials
from oscrc.
docs/src/architecture.md— architecture map (crate layout, trait injection, contracts) and the rest of the mdBook underdocs/src/(installation, configuration, developer, invocation, mcp).CONTRIBUTING.md— changelog policy;docs/src/developer.md— dev workflow.