Introduction
mtui is an improved, idiomatic Rust successor to openSUSE/mtui — the Maintenance Test Update Installer, SUSE QE’s tool for validating maintenance updates: load a request by RRID, install and test it on reference hosts over SSH in parallel, then approve or reject. It drives the OBS/IBS and Gitea review workflows and openQA/QEM; reports load from and upload to TeReGen’s v2 API.
mtui is memory-safe, async-native, and distributed as two static binaries, while
preserving the data-format and workflow contracts that keep it interoperable with
the SUSE maintenance ecosystem: the RRID grammar, the refhosts.yml schema, the
report document schema, and the remote-lock wire format.
Two surfaces
mtui— the interactive REPL (line editing, tab completion, history). See Invocation for how to launch it and seed a session.mtui-mcp— a Model Context Protocol server whose tools are synthesised from the same command registry as the REPL, so the CLI and the MCP surface never drift. See MCP server.
This book
- Installation — build from source, install the binaries, completions, and man pages.
- Invocation — the
mtuiandmtui-mcpbinary flags and how to start a session. - Configuration — the TOML config file, its resolution order, and every option with its default.
- Command reference — every REPL command and its arguments, generated directly from the command registry.
- Workflow concepts — fan-out across templates, reference-host pool selection, host state, and locking.
- MCP server — running
mtui-mcp, the tool surface, the testreport and job tools, profiles, and the security boundary. - Architecture — the crate layout and the data-format contracts.
- Developer guide — the toolchain, quality gates, the command trait, how to add a command, and testing conventions.
- FAQ.
For contributor conventions and the definition of done, see AGENTS.md, which
lives in the repository root.
Installation
mtui ships as two static binaries — mtui (the REPL) and mtui-mcp (the MCP
server) — with no runtime interpreter or virtualenv. On openSUSE install the
package from the openSUSE Build Service (OBS); elsewhere take the
prebuilt x86_64 packages from the release
page, or build from source.
Requirements
- A Rust toolchain, edition 2024, MSRV 1.96. The MSRV is pinned via
rust-versioninCargo.toml. There is norust-toolchain.toml. - Optional runtime tools (see Runtime dependencies).
Build from source
# Both binaries, optimized.
cargo build --release
The root mtui facade package exposes a cli and an mcp feature, both
enabled by default, so a plain cargo build --release produces both
binaries. --no-default-features --features cli (or mcp) builds only the
REPL (or only the MCP server), if you want to skip pulling in the MCP SDK
(rmcp/axum) or the REPL’s dependencies.
The binaries land in target/release/:
install -Dm755 target/release/mtui /usr/local/bin/mtui
install -Dm755 target/release/mtui-mcp /usr/local/bin/mtui-mcp
Verify:
mtui --help
mtui --version # prints version + build provenance (sha, profile, target)
mtui-mcp --help
Shell completions
Completions for bash, zsh, and fish are pre-generated (from the two clap
parsers) and checked into dist/completions/. They are regenerable with
cargo xtask gen. Install the ones your shell uses:
# bash
install -Dm644 dist/completions/bash/mtui.bash /usr/share/bash-completion/completions/mtui
install -Dm644 dist/completions/bash/mtui-mcp.bash /usr/share/bash-completion/completions/mtui-mcp
# zsh
install -Dm644 dist/completions/zsh/_mtui /usr/share/zsh/site-functions/_mtui
install -Dm644 dist/completions/zsh/_mtui-mcp /usr/share/zsh/site-functions/_mtui-mcp
# fish
install -Dm644 dist/completions/fish/mtui.fish /usr/share/fish/vendor_completions.d/mtui.fish
install -Dm644 dist/completions/fish/mtui-mcp.fish /usr/share/fish/vendor_completions.d/mtui-mcp.fish
Man pages
Man pages for both binaries are pre-generated into dist/man/ (regenerable with
cargo xtask gen, byte-stable — they carry the plain crate version, not the
build-provenance string):
install -Dm644 dist/man/mtui.1 /usr/share/man/man1/mtui.1
install -Dm644 dist/man/mtui-mcp.1 /usr/share/man/man1/mtui-mcp.1
Runtime dependencies
mtui needs no external programs beyond its own binaries.
The QAM review workflow (assign/unassign/approve/reject/comment) talks
to the OBS/IBS API natively — no osc subprocess. It reads credentials from
your oscrc, located exactly like osc itself: $OSC_CONFIG, then
$XDG_CONFIG_HOME/osc/oscrc, then ~/.oscrc. See the [obs] table in
Configuration.
Prebuilt packages (GitHub releases)
Every release carries .deb and .rpm packages next to the tarballs, built by
the package job in .github/workflows/release.yml. They split the same way the
spec does — mtui recommends mtui-mcp, so installing mtui alone still gets
both — with three caveats the OBS build does not have:
- x86_64 only, built for
x86_64-unknown-linux-musl. No aarch64, no macOS. - Same
Name:as the OBS RPM. In a repository carrying both, they compete by version comparison alone. On openSUSE prefer the OBS package. - The
.rpms own no directories. cargo-generate-rpm has no%dir, so erasing them leaves the doc- and licensedirs behind.
Because the binaries are static the packages declare no mandatory runtime or
library dependencies — no libc, and no shell either, since nothing they ship is
a script. Both are asserted by the release job. mtui declares mtui-mcp as a
recommends.
Verify with sha256sum --ignore-missing -c mtui-packages.sha256 — one file
covers all four packages, so a partial download needs --ignore-missing.
Packaged install (openSUSE)
On openSUSE, prefer the mtui.spec package build, which installs the binaries,
completions, and man pages into the standard system paths. It builds two packages:
mtui— the REPL, its completions and man page. Recommendsmtui-mcp, so a plainzypper in mtuistill gets both binaries.mtui-mcp— the MCP server, its completions and man page. Installable on its own; it reads the same config.
Cutting a release (maintainers)
The supported openSUSE RPM is built and published from the Build Service, not
from CI. The package sources at the repo root — _service and mtui.spec —
build it fully offline from vendored crates via the OBS source services, so
nothing is fetched during the network-isolated build. CI’s .deb/.rpm are a
separate, x86_64-only convenience (see
above) and do not replace this recipe.
One-time maintainer setup
Install the source-service packages and confirm your ibs alias (the same oscrc
the native review backend reads):
zypper install obs-service-cargo osc obs-service-tar obs-service-obs_scm \
obs-service-recompress obs-service-set_version obs-service-format_spec_file \
cargo cargo-packaging
osc -A ibs whoami # confirms the `ibs` alias resolves
Release recipe (build.suse.de / IBS, project QA:Maintenance:Test)
-
Tag the release commit so
tar_scm’srevision=@PARENT_TAG@resolves andgit describe --tagsstamps the version into the binaries. The_serviceversionrewritepattern (v?([0-9].*)) tolerates a leadingvbut does not require one, and mtui’s tags carry none:26.4.1becomesVersion: 26.4.1. The format isXX.Y.Z— year-based line, major, patch. Av-prefixed tag is invalid and does not trigger the release workflow.git tag 26.4.1 git push origin 26.4.1 -
Check out the IBS package and drop in the sources:
osc -A ibs checkout QA:Maintenance:Test mtui cd QA:Maintenance:Test/mtui cp /path/to/mtui/_service /path/to/mtui/mtui.spec . -
Run all source services. This fetches the tagged source, compresses it, fills the spec
Version:, and vendors + audits every crate dependency:osc service ra # emits: mtui-<ver>.tar.zst, vendor.tar.zst (with .cargo/config + # Cargo.lock + vendor/), cargo_config, _servicedata -
Commit and build:
osc add _service _servicedata cargo_config \ mtui-<ver>.tar.zst mtui.spec vendor.tar.zst osc ci osc build # local build against the service-generated tarballs osc results # watch the OBS build
cargo_vendor audits the vendored crates against RustSec and can fail on an
advisory; triage it — i-accept-the-risk=<RUSTSEC-ID> is the security-reviewed
escape hatch, not a default. update=false in _service pins the checked-in
Cargo.lock; flip it to true only if a crate-conflict build error appears.
Local distributable tarball (optional)
To build a plain binary tarball locally (e.g. to test the install layout) without
OBS, use the xtask package helper — it assembles the documented tree (both
binaries, completions, man pages, LICENSE, README) into a
mtui-<version>-<target>.tar.gz plus a .sha256:
cargo build --release
cargo xtask package --version 26.4.1 --target "$(rustc -vV | sed -n 's/host: //p')"
# → dist/release/mtui-26.4.1-<target>.tar.gz (+ .sha256)
This tarball is a local convenience only; the OBS build uses the git-tag source
from tar_scm, not this artifact.
Invocation
mtui ships two binaries. mtui is the interactive REPL; mtui-mcp is the MCP server. Both read the same configuration and accept overrides on the command line.
mtuiis REPL-only. There is no positional single-command mode:mtuialways starts the interactive shell (optionally seeding it with-a/-k/-s). Headless, one-shot command dispatch is served bymtui-mcp, not by a CLI flag.
mtui
Maintenance Test Update Installer
Usage: mtui [OPTIONS]
Options:
-t, --template-dir <DIR>
Override config `mtui.template_dir`
-s, --sut <HOSTS>
Cumulatively override the default hosts from the template (format: `hostname,hostname2`). May be given more than once
-w, --connection-timeout <SECONDS>
Override config `mtui.connection_timeout` (seconds)
--reboot-timeout <SECONDS>
Override config `connection.reboot_timeout`: the backoff base (seconds) for post-reboot reconnect retries
--reboot-retries <COUNT>
Override config `connection.reboot_retries`: the number of post-reboot reconnect attempts beyond the first probe
-d, --debug
Enable debugging output
-c, --config <FILE>
Override the default config path
--color <COLOR>
Control coloured output
Possible values:
- auto: Colour iff stderr is a TTY and `NO_COLOR` is unset. The default
- always: Always emit colour escapes
- never: Never emit colour escapes
[default: auto]
-g, --gitea-token <TOKEN>
Gitea access token
--ssl-verify <BOOL|PATH>
Override config `mtui.ssl_verify`: TLS certificate verification for all outbound HTTP. Accepts `true`/`false` (and the spellings `yes`/`no`/ `on`/`off`/`1`/`0`), or a path to a custom CA bundle/certificate
-a, --auto-review-id <RequestReviewID>
OBS request review id, run under the automatic workflow (example: `SUSE:Maintenance:1:1`)
-k, --kernel-review-id <RequestReviewID>
OBS kernel/live-patch request review id, run under the kernel workflow (example: `SUSE:Maintenance:1:1`)
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
Loading an update at start with -a/-k connects its reference hosts and, under the automatic workflow, downloads openQA install logs — the same as running load_template in the REPL. -s/--sut overrides the hosts drawn from the template.
mtui-mcp
MCP server for mtui (tools synthesised from the command registry)
Usage: mtui-mcp [OPTIONS]
Options:
-t, --template-dir <DIR>
Override config `mtui.template_dir`
-w, --connection-timeout <SECONDS>
Override config `mtui.connection_timeout` (seconds)
-d, --debug
Enable debugging output
-c, --config <FILE>
Override the default config path
--color <COLOR>
Control coloured (log) output. Logs go to stderr; stdout is the transport
Possible values:
- auto: Colour iff stderr is a TTY and `NO_COLOR` is unset. The default
- always: Always emit colour escapes
- never: Never emit colour escapes
[default: auto]
-g, --gitea-token <TOKEN>
Gitea access token
--ssl-verify <BOOL|PATH>
Override config `mtui.ssl_verify`: TLS certificate verification for all outbound HTTP. Accepts `true`/`false` (and the spellings `yes`/`no`/ `on`/`off`/`1`/`0`), or a path to a custom CA bundle/certificate
--transport <TRANSPORT>
MCP transport to serve on: `stdio` (default, one client) or `http` (streamable HTTP, per-client session isolation)
Possible values:
- stdio: Serve over stdin/stdout (default). One process serves one client
- http: Serve over streamable HTTP. One process serves many isolated clients
[default: stdio]
--host <ADDR>
Bind address for `--transport http` (default: 127.0.0.1). Ignored under stdio. Loopback only — rmcp's DNS-rebinding guard rejects non-loopback hosts
[default: 127.0.0.1]
--port <PORT>
Bind port for `--transport http` (default: 8000). Ignored under stdio
[default: 8000]
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
mtui-mcp takes no boot-time update/host flags (-a/-k/-s are mtui only): each MCP client loads its own state at runtime via the load_template and add_host tools. See MCP server.
Configuration
mtui is configured with a sectioned TOML file. All configuration is optional: with no file present, mtui runs on built-in defaults.
File resolution
The config file is resolved in this order:
-
--config <file>— an explicit path. Short-circuits the search. -
$MTUI_CONF— an explicit path in the environment. Short-circuits the search. -
Otherwise the default pair is merged, lowest precedence first:
/etc/mtui.toml(system-wide)~/.mtui.toml(home dotfile)$XDG_CONFIG_HOME/mtui/mtui.toml(per-user XDG; falls back to~/.config/mtui/mtui.toml)
When several of these exist they are merged: an option set in a higher-precedence file overrides the same option in a lower one. The per-user XDG file therefore wins over
/etcon shared keys.
Loading is lenient: a missing or malformed file — or a single bad value — is
logged at ERROR and skipped, falling back to the default. A bad value never
invalidates the rest of the file, and loading never hard-fails.
~ is expanded to your home directory in path-valued options.
Inspecting and changing config at runtime
The config command shows the resolved configuration and can set individual
options for the running session:
config show # print all resolved options
config show connection_timeout
config set max_parallel 8
Secret options are never echoed: config show/config set print <set> for a
configured secret (currently gitea_token) instead of its value. Over MCP,
config show additionally refuses the bulk dump and operator-local values;
see MCP server.
Options
Defaults below are the built-in values.
[mtui]
| Key | Type | Default | Meaning |
|---|---|---|---|
template_dir | path | $TEMPLATE_DIR or . | Directory holding each loaded report’s working directory (install logs, result files). |
user | string | current login user | User attributed to this session (locks, logs). |
install_logs | relative dir name | install_logs | Sub-directory (single relative name, no separators) where install logs are written per update. |
ssl_verify | bool / string | true | TLS verification for outbound HTTP: a boolean, a boolean spelling (yes/no/on/off/…), or a path to a custom CA bundle. |
[connection]
| Key | Type | Default | Meaning |
|---|---|---|---|
connection_timeout | seconds (>0) | 300 | SSH per-command no-output timeout. The SSH link is kept alive, so this window, not a transport idle limit, decides when a quiet command is aborted. Does not bound the connect handshake — see connect_timeout. |
connect_timeout | seconds (>0) | 60 | SSH connect handshake budget (TCP connect, banner, and auth). Also bounds each SFTP request (lock/history file operations), which otherwise defaults to a fixed 10s. Raise it for a host with a genuinely slow banner or link (e.g. a loaded s390x LPAR, a jump-path host, or a WAN/VPN refhost); a host slower than this to respond is dropped from a batch with a warning instead of eventually connecting. |
reboot_timeout | seconds (>0) | 10 | Backoff base for reconnecting after a reboot (prepare/reboot/update). Sleeps grow as 2*(reboot_timeout + 5*count) across attempts. |
reboot_retries | int (>0) | 10 | Number of post-reboot reconnect attempts beyond the first. With the defaults this gives a rebooting host up to ~12.7 minutes to come back. |
max_parallel | int (>0) | 50 | Max hosts to fan out to concurrently (SSH/SFTP/lock/connect batches). |
max_oqa_parallel | int (>0) | 8 | Max concurrent openQA/QAM HTTP requests in the overview search (kept low to be polite to shared hosts). |
ssh_strict_host_key_checking | string | auto_add | SSH host-key checking policy (auto_add, strict, warn, …). |
SSH is pubkey-only by design. mtui authenticates from the SSH agent or
~/.ssh/id_*; there is no password auth.
reboot_timeout/reboot_retriesonly apply to the post-reboot reconnect budget. A dead SSH link mid-run/shell, or thesftppre-op check, still fails on the first probe — those are not recovering from a reboot.
[refhosts]
| Key | Type | Default | Meaning |
|---|---|---|---|
resolvers | string | https,path | Comma-separated ordered list of refhosts resolvers. |
https_uri | URL | https://qam.suse.de/refhosts/refhosts.yml | HTTPS URI of the refhosts database. |
https_expiration | seconds (>0) | 43200 (12h) | Age before a cached HTTPS fetch is considered stale. |
path | path | ~/.local/share/refdb/refhosts.yml | Local filesystem refhosts database (also used as the HTTPS resolver’s on-disk mirror; shared with the refdb tooling). |
[url]
| Key | Type | Default | Meaning |
|---|---|---|---|
bugzilla | URL | https://bugzilla.suse.com | Bugzilla base URL. |
testreports | URL | https://qam.suse.de/testreports | Testreports base URL. |
fancy_reports | URL | https://qam.suse.de/reports | “Fancy” reports base URL. |
[target]
| Key | Type | Default | Meaning |
|---|---|---|---|
tempdir | path | /tmp | Remote scratch directory on target hosts. |
[qem_dashboard]
| Key | Type | Default | Meaning |
|---|---|---|---|
api | URL | http://dashboard.qam.suse.de/api | QEM Dashboard API base URL. |
[teregen]
| Key | Type | Default | Meaning |
|---|---|---|---|
api | URL | https://qam.suse.de/api/v1 | TeReGen report/queue API base URL. |
api_v2 | URL | https://qam.suse.de/api/v2 | TeReGen v2 report/queue API base URL (the JSON-document read path). |
[openqa]
| Key | Type | Default | Meaning |
|---|---|---|---|
openqa | URL | https://openqa.suse.de | openQA instance URL. |
baremetal | URL | http://openqa.qam.suse.cz | Baremetal openQA instance URL. |
distri | string | sle | openQA install distri parameter. |
openQA API credentials (key/secret, used to sign requests with
X-API-Hash) are not part of mtui.toml. mtui delegates their discovery
to the ruoqa client, which resolves them in this order:
$OPENQA_API_KEY/$OPENQA_API_SECRET, honoured as a pair — setting only one is a configuration error. When both are set they are used for every instance, ahead ofclient.conf.- The openQA project’s own
client.conf— an INI file with one[host[:port]]section per instance — found via a tiered, non-merging search:$OPENQA_CONFIG(when set), else the user config directory ($XDG_CONFIG_HOME/openqa, or~/.config/openqawhen$XDG_CONFIG_HOMEis unset), else/etc/openqaand/usr/etc/openqa. The first tier that yields any file wins outright; later tiers are not read at all — a~/.config/openqa/client.confdoes not merge with sections that exist only in/etc/openqa/client.conf; it replaces it. Within the winning tier, each directory contributes itsclient.conf(if present) followed by its sortedclient.conf.d/*.confdrop-ins, later files overriding earlier keys.
An instance with no matching section (or no client.conf at all, and no
$OPENQA_API_KEY/$OPENQA_API_SECRET pair) is queried unauthenticated, which
openQA permits for GET requests.
[gitea]
| Key | Type | Default | Meaning |
|---|---|---|---|
token | string (secret) | (empty) | API token for the Gitea PR review workflow. The Gitea connector refuses to build without it. Masked as <set> in config output; sent only in an Authorization header, never logged. |
url | URL | https://src.suse.de | Trusted Gitea origin the token may be sent to. A PR API URL comes from checked-out metadata (which is not fully trusted), so the token is attached only to requests whose origin (scheme/host/port) matches this value, over https, with no embedded userinfo. Point it at another instance (e.g. https://src.opensuse.org) to use that one. A metadata URL on any other origin is refused before the token is sent. |
[slack]
The Slack review-request integration behind the request_review command.
Off by default. Posting into a chat workspace is an outward-facing side
effect, so it never happens implicitly: enabled must be true and both
token and channel must be set. An mtui with no [slack] section never
contacts Slack, and request_review refuses with the reason.
Enabling it also gates approve. Once enabled = true, an update can only
be approved after its Slack review request has been acknowledged. approve
refuses unless all three hold:
request_reviewrecorded a marker for the update (and committed it, so a reviewer approving from a different checkout sees it);- the marked Slack message still names that RRID — a marker copied between templates cannot launder an approval;
- somebody other than the bot left an approving reaction (👍, ✅; skin tone ignored).
If Slack cannot be read, approve fails closed — an unreadable review request
is not an approved one. There is deliberately no per-command bypass flag: a
gate with one is not a gate. Turning it off is a config change
(config set slack_enabled false), which is explicit and auditable. reject is
never gated — blocking it would strand an update a reviewer wants stopped.
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Whether the integration is available at all. Set true to opt in; leaving it unset (or false) makes request_review refuse up front, which is how a site that does not use Slack — or wants the feature switched off despite credentials being present — runs mtui. |
token | string (secret) | (empty) | Slack bot token. Required scopes: chat:write, reactions:read, channels:history. Masked as <set> in config output; sent only in an Authorization header, never logged. |
channel | string | (empty) | Channel review requests are posted to — an ID (C0123456789) or a #name. Overridable per call with request_review --channel. |
api_url | URL | https://slack.com/api | Slack API base the token may be sent to. Refused unless it is https (or http to loopback, which is what makes the test suite’s mock server possible) and carries no userinfo. |
poll_interval | int (seconds) | 120 | How often request_review --watch polls for reactions. Jittered by ±15% so several mtui instances watching the same channel do not synchronise. Slack’s tier-3 methods allow roughly 50 requests/minute. |
watch_timeout | int (seconds) | 3600 | How long request_review --watch runs before giving up. |
Example:
[slack]
enabled = true
token = "xoxb-…"
channel = "#qam-review"
[lock]
Remote-lock behaviour on target hosts, so concurrent testers can share a fleet.
| Key | Type | Default | Meaning |
|---|---|---|---|
reap_stale | bool | true | On connect, force-remove a pre-existing operation lock older than stale_age, regardless of owner. |
stale_age | seconds | 86400 | Age beyond which an operation lock is stale and reapable. 0 disables reaping. |
pool_reap_stale | bool | true | On a pool claim attempt, force-remove a pre-existing pool claim older than pool_stale_age, regardless of owner. The only automatic recovery for a claim orphaned by an uncatchable exit (SIGKILL/panic/power loss). |
pool_stale_age | seconds | 86400 | Age beyond which a pool claim is stale and reapable. 0 disables pool-claim reaping. |
pi_autolock | bool | true | When testing a Product Increment (PI), lock each refhost as it connects while the report is loaded, released on unload/quit. |
wait | seconds | 0 | Pool-claim queueing budget. 0 fails fast on a busy host. |
wait_poll | seconds (>0) | 15 | Poll interval while waiting for a busy pool lock to free. |
[mcp]
mtui-mcp server behaviour. See MCP server.
| Key | Type | Default | Meaning |
|---|---|---|---|
max_output_bytes | bytes | 100000 | Upper bound on a single tool result (truncated at the tail with a notice). 0 disables the cap. |
max_input_bytes | bytes | 10000000 | Per-host source cap on data a tool reads before returning it (testreport reads, get downloads; put payloads above it are refused). 0 disables. |
max_request_bytes | bytes | 10000000 | Upper bound on an inbound HTTP request body under --transport http (oversized requests are rejected with 413 before rmcp buffers them). Also sets rmcp’s own request-body ceiling (default 4 MB), so a single knob governs both gates. 0 disables mtui’s limit entirely, dropping even axum’s implicit 2 MB floor, and raises rmcp’s gate to unlimited. |
max_active_jobs | int | 16 | Ceiling on concurrent background jobs per session. 0 disables. |
max_completed_jobs | int | 128 | Ceiling on retained terminal job records per session (FIFO eviction). 0 disables. |
session_cap | int (>0) | 32 | Ceiling on concurrent per-client sessions under --transport http. |
session_idle_timeout | seconds (>0) | 14400 | Inactivity before an idle http session is swept. Also pins rmcp’s streamable-HTTP keep-alive (rmcp’s own default is 300 s), so the transport never tears a quiet session down before this sweeper would. |
sweep_parallel | int (>0) | 4 | Max stale sessions the idle sweeper tears down concurrently per cycle. |
profile | string | full | Tool-surface profile: full (every synthesised tool) or core (curated everyday subset). Unknown → full with a warning. Agent clients should prefer core (36 vs 65 tools on every request); see MCP server. |
tools_allow | array of strings | (empty) | Extra tool names to keep on top of the profile. |
tools_deny | array of strings | (empty) | Tool names to remove regardless of profile/allow (deny wins last). E.g. ["updates", "openqa_overview"] for a workflow that never lists the queue or polls openQA. |
audit_log | path | (unset) | Durable audit sink for tool calls (versioned JSONL, 0600): one or two records per call — an intent before and an outcome after for a tool without readOnlyHint, an outcome alone for a read-only one — plus a terminal record per background job. Unset disables auditing. A path that cannot be opened refuses to start the server; afterwards a failed pre-dispatch intent write refuses the call (nothing ran), while a failed outcome write returns the result with an in-band notice. See MCP server. |
audit_log_max_bytes | bytes | 268435456 | Size at which audit_log rotates to <path>.1, keeping five generations (.1….5); the oldest is discarded, so archiving beyond it is yours to arrange. Rotation never blocks the dispatch worker and never refuses a call: it takes the file’s advisory lock without waiting, and a lock it cannot take or a rename it cannot perform warns once and keeps appending past the cap. 0 disables rotation entirely; any other value below 4096 is raised to it with a warning. |
OTLP export of the same record needs no TOML key by design: it is env-only (
OTEL_EXPORTER_OTLP_ENDPOINT/..._LOGS_ENDPOINT,..._HEADERS/..._LOGS_HEADERS,..._PROTOCOL/..._LOGS_PROTOCOL,OTEL_SERVICE_NAME). Endpoint set withaudit_logunset is OTLP-only; neither is auditing off. See MCP server.
The profile key is
profile, nottool_profile— it already sits under the tool-scoped[mcp]table.tools_allow/tools_denyare native TOML arrays, not comma-separated strings. (Both matter if you are carrying over a config from a pre-26.0 release.)
[obs]
The native OBS/IBS QAM review backend.
| Key | Type | Default | Meaning |
|---|---|---|---|
api_url | URL | https://api.suse.de | OBS/IBS API the review backend acts against. Must equal a section header in your oscrc. |
request_timeout | seconds (>0) | 180 | Coarse wall-clock budget for a whole native OBS operation, checked between HTTP calls (not a mid-call hard kill). |
Credentials for OBS live only in your oscrc, not here. The oscrc is located
like osc itself: $OSC_CONFIG → $XDG_CONFIG_HOME/osc/oscrc → ~/.oscrc.
There is no mtui-side path option — set $OSC_CONFIG to point at a non-default
oscrc.
Secrets and credentials
- The
gitea_tokenis masked as<set>by bothconfig showandconfig set, and is never written to logs or display output. - Configured datasource URLs may embed credentials
(
scheme://user:pass@host). mtui sanitises the userinfo to***before any URL reaches a log or an error message, keeping the host visible for diagnosis. - A repository URL from the report metadata may carry userinfo
(
https://user:token@host/repo/) and is sent tozypperunchanged, so authenticated repositories keep working. Everything mtui records about the command — log lines, the timeout prompt, errors, and the command and output kept in the per-host log thatshowlogandexportprint — masks the userinfo to***, including a URL a remote tool echoes back in its output. A password that appears without ascheme://…@URL around it is not recognised.
Example
[connection]
connection_timeout = 300
connect_timeout = 60
reboot_timeout = 10
reboot_retries = 10
max_parallel = 8
[refhosts]
resolvers = "https,path"
[obs]
api_url = "https://api.suse.de"
[mcp]
profile = "core"
tools_deny = ["put", "get"]
Command reference
Every command below is available in the mtui REPL (see Invocation for how to start it and load an update). Commands not on the MCP deny-list are also exposed as mtui-mcp tools. Commands that act on a loaded template additionally accept the shared template flags -T/--template <RRID> and --all-templates (session-level commands — load_template, unload, switch, list_templates, list_refhosts, updates, config, whoami, set_log_level, help, quit (exit/EOF) — take neither, regenerate only -T); the cross-command behaviour behind them (fan-out, host-pool selection, locking) is described in Workflow concepts.
run
Runs a command on a specified host or on all enabled targets.
Usage: run [OPTIONS] [COMMAND]...
Arguments:
[COMMAND]...
Command as argv tokens (no shell); pipelines need three tokens: sh, -c, <line>
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
update
Applies the testing update to the target hosts.
Usage: update [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
--newpackage
Install new packages after update
--noprepare
Skip prepare procedure
-h, --help
Print help
install
Installs packages from the current active repositories.
Usage: install [OPTIONS] <PACKAGE>...
Arguments:
<PACKAGE>...
package to install
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
uninstall
Removes packages from the system.
Usage: uninstall [OPTIONS] <PACKAGE>...
Arguments:
<PACKAGE>...
package to remove
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
prepare
Installs missing packages and updates existing packages.
Usage: prepare [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-f, --force
force package installation
-i, --installed
prepare only installed packages
-u, --update
enable test update repositories
-h, --help
Print help
downgrade
Downgrades all related packages to the last released version.
Usage: downgrade [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
reboot
Reboots reference hosts and reconnects once they are back up.
Usage: reboot [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
set_repo
Adds or removes an issue repository to or from hosts.
Usage: set_repo [OPTIONS] <--add|--remove>
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-A, --add
Add issue repos to refhosts
-R, --remove
Remove issue repos from refhosts
-h, --help
Print help
show_update_repos
Shows the update repositories that are valid for the current update.
Usage: show_update_repos
Options:
-h, --help
Print help
add_host
Adds one or more reference hosts to the target host list.
Usage: add_host [OPTIONS]
Options:
-t, --target <HOST>
Address of the target host (FQDN). Can be repeated
-k, --keep-mode
Do not switch to the manual workflow when in automatic mode
-h, --help
Print help
remove_host
Disconnects from a host and removes it from the list.
Usage: remove_host [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
set_host_state
Sets the state of a host.
Usage: set_host_state [OPTIONS] <state>
Arguments:
<state>
enabled | disabled
[possible values: disabled, enabled]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
lock
Locks hosts for exclusive usage (the operation/zypper lock).
Usage: lock [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-c, --comment <COMMENT>...
Lock comment (keeps the lock effective across sessions)
-h, --help
Print help
unlock
Unlocks hosts previously locked with lock.
Usage: unlock [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-f, --force
Force unlock - remove locks set by other users or sessions
-p, --pool
Remove the pool claim instead of the zypper/operation lock
-h, --help
Print help
switch
Switches the active template to another loaded one.
Usage: switch <RRID>
Arguments:
<RRID>
RRID of the loaded template to make active
Options:
-h, --help
Print help
unload
Unloads one loaded template, closing only its host connections.
Usage: unload [OPTIONS] <RRID>
Arguments:
<RRID>
RRID of the loaded template to unload
Options:
--discard-authored
unload even though the report document holds edits that were never committed
-h, --help
Print help
list_templates
Lists all loaded templates, marking the active one.
Usage: list_templates
Options:
-h, --help
Print help
whoami
Displays the current user name and session PID.
Usage: whoami
Options:
-h, --help
Print help
list_products
Prints the installed products on the reference hosts.
Usage: list_products [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
reload_products
Reloads and re-parses the products on the target reference hosts.
Usage: reload_products [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
config
Shows or sets runtime configuration values.
Usage: config <COMMAND>
Commands:
show Shows runtime configuration values (all when none named).
set Sets one runtime configuration value for the running session.
help Print this message or the help of the given subcommand(s)
Options:
-h, --help
Print help
quit
Aliases: exit, EOF
Disconnect from all hosts and exit (optionally reboot/poweroff).
Usage: quit [BOOTARG]
Arguments:
[BOOTARG]
reboot or poweroff refhosts on exit
[possible values: reboot, poweroff]
Options:
-h, --help
Print help
shell
Opens an interactive shell on a reference host.
Usage: shell [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
checkout
Refreshes the loaded report document from teregen.
Usage: checkout [OPTIONS]
Options:
--discard-authored
refresh the report document even though it holds edits that were never committed
-h, --help
Print help
commit
Stores the report document and artifacts in teregen.
Usage: commit [OPTIONS]
Options:
--allow-stale
Commit a template that loaded with a stale Gitea hash (load_template --force-continue); refused otherwise.
-h, --help
Print help
show_diff
Shows the raw OBS source diff.
Usage: show_diff
Options:
-h, --help
Print help
analyze_diff
Analyzes the OBS source diff to assist code review.
Usage: analyze_diff
Options:
-h, --help
Print help
list_bugs
Lists related bugs and their Bugzilla/Jira URLs.
Usage: list_bugs
Options:
-h, --help
Print help
list_metadata
Lists the patchinfo metadata for the loaded test report.
Usage: list_metadata
Options:
-h, --help
Print help
list_hosts
Lists all connected hosts with their system and state.
Usage: list_hosts
Options:
-h, --help
Print help
list_timeout
Prints the current command timeout per host, in seconds.
Usage: list_timeout
Options:
-h, --help
Print help
list_update_commands
Lists the commands mtui would invoke to apply the update on the hosts.
Usage: list_update_commands
Options:
-h, --help
Print help
list_sessions
Lists the active SSH sessions connected to each reference host.
Usage: list_sessions [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
list_locks
Lists the lock state of all connected hosts.
Usage: list_locks [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-p, --pool
list pool-claim locks instead of zypper/operation locks
-h, --help
Print help
list_history
Lists the history of mtui events recorded on the reference hosts.
Usage: list_history [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-e, --event <event>
event type to list (repeatable)
[possible values: connect, disconnect, install, update, downgrade, prepare]
-h, --help
Print help
show_log
Prints the command protocol (issued commands + output) from the hosts.
Usage: show_log [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
--offset <N>
First log entry to show per host (1-based)
[default: 1]
--limit <N>
Max log entries per host (0: headers with entry totals only)
-h, --help
Print help
list_versions
Lists the available versions of packages in the enabled repositories.
Usage: list_versions [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-p, --package <PACKAGE>
package name to show versions for (repeatable)
-h, --help
Print help
list_packages
Lists packages and their installed versions on the reference hosts.
Usage: list_packages [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-p, --package <PACKAGE>
package name to list (repeatable)
-w, --wanted
print versions wanted by the testreport
-h, --help
Print help
set_timeout
Sets the command-execution timeout, in seconds, for the selected hosts.
Usage: set_timeout [OPTIONS] <timeout>
Arguments:
<timeout>
Timeout in seconds; "0" disables it
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-h, --help
Print help
put
Uploads a local file (or directory tree) to every enabled reference host.
Usage: put <filename>
Arguments:
<filename>
file (or directory) to upload to all hosts; the path is literal — shell-style globs are deliberately not expanded (put the directory, or let the invoking shell expand)
Options:
-h, --help
Print help
get
Downloads a remote file from every enabled reference host.
Usage: get <filename>
Arguments:
<filename>
file to download from the target hosts
Options:
-h, --help
Print help
checkers
Lists the build-check (checker) result runs for the loaded update.
Usage: checkers [OPTIONS]
Options:
--full-output
print every line of a non-passing check's output, untruncated, instead of a bounded first-line summary (some checks emit long diffs)
-h, --help
Print help
updates
Lists the update queue (unassigned + in-testing by default), fetched live from the TeReGen API.
Usage: updates [OPTIONS]
Options:
-G, --review-group <GROUP>
filter by review group as the bare group name, e.g. qam-sle (not the '<group>-review' login form, which classic rows lack); repeatable — groups are OR-ed (one server query per group)
-F, --field <FIELD>
select output fields by osc-qam name (e.g. -F Rating -F 'Assigned Roles'); repeatable, rendered as one block per update; names match case-insensitively, ignoring spaces/hyphens/underscores; narrow large queues with --limit
--json
print the raw TeReGen rows as a JSON array (each row emitted whole, unlike -F; honours --limit/--offset; not combinable with -F); an empty queue prints []; over-cap stdout is a JSON envelope {"rows": [...], "truncation": {...}} so stdout stays valid JSON
--status <STATUS>
filter by status (default: testing); use 'all' for every status
[default: testing]
--limit <N>
cap the number of rows after --offset (0 = all)
[default: 0]
--offset <N>
skip the first N rows (0 = from the start); with --limit, page any middle slice
[default: 0]
--assignee <USER>
filter to updates assigned to this user (any qam group)
--mine
filter to updates assigned to the current session user
--all-assignees
show every update regardless of assignee, overriding the unassigned default
-h, --help
Print help
openqa_overview
Prints an openQA / QAM Dashboard / build-checks overview for the loaded MU.
Usage: openqa_overview [OPTIONS]
Options:
--no-aggregated
Skip the Aggregated Updates section
--days <N>
How many days to scan back for aggregated builds (1-30, default 5)
[default: 5]
--aggregated-groups <GROUP>
Job groups to search in Aggregated Updates (default: core)
[possible values: core, containers, yast, security]
--url-openqa <URL>
Override openQA URL (default: config openqa_instance)
--url-dashboard-qam <URL>
Override QAM Dashboard base URL (default: derived from config)
--url-qam <URL>
Override QAM base URL (default: derived from config reports_url)
--test-pattern <REGEX>
Custom regex to extract test results from build-check logs
--export
Also author the overview onto the report document (testing.openqa.extra)
--no-fetch
(deferred) reuse cached overview; currently fetches anyway
-h, --help
Print help
openqa_jobs
Lists the individual openQA jobs for the loaded update’s incident build.
Usage: openqa_jobs [OPTIONS]
Options:
--all
keep obsoleted jobs (superseded by a retrigger)
--failed
show only genuinely failed jobs (pending/unfinished excluded)
--arch <ARCH>
only jobs for this architecture
--url-openqa <URL>
Override openQA URL (default: config openqa_instance)
--url-dashboard-qam <URL>
Override QAM Dashboard base URL (default: derived from config)
-h, --help
Print help
reload_openqa
Reloads information from the openQA instances.
Usage: reload_openqa
Options:
-h, --help
Print help
set_workflow
Sets the workflow and reloads data from openQA.
Usage: set_workflow <workflow>
Arguments:
<workflow>
desired workflow - auto, manual or kernel
[possible values: auto, manual, kernel]
Options:
-h, --help
Print help
set_log_level
Changes the current mtui log level.
Usage: set_log_level <level>
Arguments:
<level>
log level for mtui - info, warning, error or debug
[possible values: info, warning, error, debug]
Options:
-h, --help
Print help
assign
Assigns a review request to a user or group.
Usage: assign [OPTIONS]
Options:
-g, --group <GROUP>
Group to act on (not valid for the Gitea workflow)
-u, --user <USER>
User override for the Gitea workflow (Gitea only)
[default: ""]
-f, --force
Assign even if another user holds the review. OBS: never one you already hold (unassign first); Gitea: also your own, and without an open group
-h, --help
Print help
unassign
Unassigns a review request.
Usage: unassign [OPTIONS]
Options:
-g, --group <GROUP>
Group to act on (not valid for the Gitea workflow)
-u, --user <USER>
User override for the Gitea workflow (Gitea only)
[default: ""]
-h, --help
Print help
reject
Rejects a review request.
Usage: reject [OPTIONS] --reason <reason>
Options:
-g, --group <GROUP>
Group to act on (not valid for the Gitea workflow)
-u, --user <USER>
User override for the Gitea workflow (Gitea only)
[default: ""]
-r, --reason <reason>
Reason to reject the update (required)
[possible values: admin, retracted, build_problem, not_fixed, regression, false_reject, tracking_issue]
-m, --message [<message>...]
Rejection message (takes the remainder of the command)
--reviewer <NAME>
Name of the reviewer who reviewed this report; recorded on the report document and uploaded, then reject
--allow-stale
With --reviewer, upload a template that loaded with a stale Gitea hash (load_template --force-continue); refused otherwise.
-h, --help
Print help
comment
Adds a comment to a review request.
Usage: comment [OPTIONS]
Options:
-m, --message <message>...
The comment body (required)
-h, --help
Print help
approve
Approves the loaded update, dispatching to OSC or Gitea like the other backend-API commands.
Usage: approve [OPTIONS]
Options:
-g, --group <GROUP>
Group to approve (not valid for the Gitea workflow)
-u, --user <USER>
User override for the Gitea workflow (Gitea only)
[default: ""]
-r, --reviewer <NAME>
Record reviewer on the report document, then approve
--accept-head <SHA>
Approve only if the PR head is exactly SHA; this is also what lets a head that moved since the report was generated through non-interactively (Gitea only)
-h, --help
Print help
regenerate
Regenerates a test-report template via the TeReGen API (a given RRID need not be loaded first).
Usage: regenerate [OPTIONS] [RRID]
Arguments:
[RRID]
template to regenerate even if not loaded (default: the loaded template)
Options:
--force
overwrite an existing (but unedited) template
--ignore-inconsistent
regenerate despite inconsistent metadata (e.g. arch mismatch)
--no-wait
enqueue the job and return without waiting or reloading
-k, --kernel
load the standalone RRID as a kernel update (default: auto)
--discard-authored
override mtui's own guards against regenerating a document that already carries tester content (verdict, testers, issue answers, install/regression results), loaded or on the server, or holds edits that were never committed; without it the REPL asks before discarding such content and MCP refuses; teregen itself still refuses on a verdict or testers
-h, --help
Print help
request_review
Requests review of the loaded update in Slack.
Usage: request_review [OPTIONS]
Options:
-c, --channel <CHANNEL>
Channel to post to, overriding the configured one
-m, --message <TEXT>
Extra context appended to the review request
-w, --watch
After posting, watch the message for reviewer reactions until a verdict or timeout (Ctrl-C / job_cancel stops it). Over MCP, pair this with background=true so the call does not outlive the client timeout
--allow-stale
Request review of a template that loaded with a stale Gitea hash (load_template --force-continue); refused otherwise.
-h, --help
Print help
export
Exports the gathered update data onto the report document.
Usage: export [OPTIONS]
Options:
-t, --target <HOST>
Host to act on. Can be used multiple times. If omitted all hosts are used
-f, --force
force overwrite of existing install logs
--allow-unverified
write the unverified scaffold even when no selected host has recorded package versions
--allow-stale
Export a template that loaded with a stale Gitea hash (load_template --force-continue); refused otherwise.
-h, --help
Print help
list_refhosts
Lists reference hosts from refhosts.yml (offline search, no connect).
Usage: list_refhosts [OPTIONS]
Options:
--testplatform <QUERY>
match a SMELT testplatform query
-n, --name <GLOB>
hostname glob, e.g. 'whale-*' or '*.qam.suse.cz'
-a, --arch <ARCH>
arch filter (repeatable): x86_64 / aarch64 / ppc64le / s390x
-p, --product <SUBSTR>
base-product substring, e.g. sles / sled / SLE_HPC
--version <VERSION>
product version: 15-SP6 / 15.6 / 15 (SP optional)
--addon <SUBSTR>
addon-name substring (repeatable)
--pool
group by test-target slot (product+version+arch+addons)
--json
emit a JSON array of kept rows; over-cap stdout is a JSON envelope {"rows": [...], "truncation": {...}} so stdout stays valid JSON (--free windowing adds a "probe" object)
--limit <N>
cap the number of rows after --offset (0 = all)
[default: 0]
--offset <N>
skip the first N rows (0 = from the start); with --limit, page any middle slice
[default: 0]
--free
also probe live operation-lock and pool-claim state (connects to each shown host; --offset/--limit window first)
-v, --verbose
include addons in the output
-h, --help
Print help
load_template
Loads a maintenance-update template into the session and connects its reference hosts.
Usage: load_template [OPTIONS] <--auto-review-id <RequestReviewID>|--kernel-review-id <RequestReviewID>>
Options:
-a, --auto-review-id <RequestReviewID>
OBS request review id, e.g. SUSE:Maintenance:1:1
-k, --kernel-review-id <RequestReviewID>
OBS kernel/live-patch request review id, e.g. SUSE:Maintenance:1:1
--force-continue
Load a stale checked-out template as-is, instead of aborting, once TeReGen has refused to regenerate it non-interactively.
--discard-authored
re-load an already-loaded template even though its report document holds edits that were never committed
-h, --help
Print help
help
List commands, or show help for one command.
Usage: help [COMMAND]
Arguments:
[COMMAND]
command to show help for; omit to list all commands
Options:
-h, --help
Print help
edit
Edit the loaded report document, or a local file in $EDITOR.
Usage: edit [FILENAME]
Arguments:
[FILENAME]
File to edit (defaults to the report document)
Options:
-h, --help
Print help
Workflow concepts
The command reference documents each command’s own arguments. This page
covers the cross-cutting behaviour that sits behind those commands — how fan-out
works, how reference hosts are drawn from the pool, and how locking keeps
concurrent testers from colliding. It applies equally to the mtui REPL and the
mtui-mcp tools, since both dispatch through the same engine.
Templates and the active template
Loading a testreport with load_template adds it to the
session as a template; previously loaded templates stay loaded and the new one
becomes active. Each template owns its own reference hosts and its own report.
list_templates shows the loaded set (the active one
marked *), switch changes the active one, and
unload drops one (closing only its host connections).
Session-level commands (load_template, unload, switch, list_templates,
list_refhosts, updates, config, whoami, set_log_level, help, quit)
take no template flag: each runs exactly once, never fans out, and accepts
neither -T nor --all-templates.
Fan-out across templates
When more than one template is loaded, a command falls into one of two groups:
- Read/annotate commands (
list_*,show_*,openqa_*,checkers,checkout,export,set_workflow,comment, …) fan out across every loaded template by default — none of them mutates shared remote state, and re-running one is always safe. Each template’s output is prefixed with an=== <RRID> ===banner so results stay attributable. - Host-mutating and remote-write commands (
update,prepare,downgrade,install,uninstall,set_repo,run,reboot,lock,unlock,add_host,remove_host,get,put,approve,reject,assign,unassign,request_review,commit) never implicitly fan out: bare, they act on exactly one template — the active one in the REPL, or the sole loaded one — and refuse rather than guess when several are loaded headlessly and none is named.
Scope a single call three ways:
-T <RRID>/--template <RRID>— act on one loaded template only.--all-templates— force fan-out explicitly, on either group. Mutually exclusive with-T.--all-templates=false— suppress fan-out on a read/annotate command, narrowing it to the same single-template resolution the other group uses by default.
A headless caller (MCP) with several templates loaded and neither flag named
gets AmbiguousTemplate, not a silent guess — the error names the
loaded RRIDs and how to scope the call.
Failure handling is per-template: if a fanned-out command fails on one template it
keeps running on the rest and reports an aggregate failure at the end. When a
host-phase command (one that accepts -t) fans out without explicit -t hosts,
a loaded template with no connected host is skipped with a warning rather than
counted as a failure — so an unscoped lock/run/… still succeeds on the
templates that do have hosts. If every template is host-less the command ran
nowhere and fails with “No refhosts defined”. Naming a disconnected host with
-t <host> is still a per-template failure, and a -T-scoped call keeps
single-template error behaviour.
regenerate also runs exactly once, on the active template
by default; -T names another loaded one.
Over MCP the same model applies through per-call template= / all_templates=
parameters — see MCP server.
Reference-host pool selection
When add_host connects hosts from a testplatform (and during
the autoconnect that runs when a template loads), mtui connects one reference
host per test-target slot — a slot being a unique (product, version, arch, addons)
combination — rather than every matching candidate. So a testplatform that
resolves to several interchangeable hosts on the same architecture does not fan a
command out across all of them.
- The host drawn for each slot is chosen at random among the free candidates, spreading load across interchangeable refhosts.
- If the chosen host fails to connect, mtui automatically tries another
candidate from the same slot; only when every candidate for a slot is
unreachable or claimed does
add_hostname the slot and why in its result and leave that slot unconnected. - Under fan-out, each test-target slot draws a distinct free host from the
shared pool, arbitrated in-process so two templates never collide on the same
host. When every candidate for a slot is busy, the claim queues on the
exhausted pool according to
[lock] wait/[lock] wait_poll(see Configuration).
To search the inventory without connecting, use
list_refhosts — it reads the same source offline (no
SSH, no lock, no loaded report).
Product-drift check on connect
When a host connects, mtui checks that the products actually installed on it (from
/etc/products.d) match what refhosts.yml records for that host. Any drift — a
wrong or wrong-version base product, a wrong architecture, missing/unexpected
addons, or a dangling baseproduct symlink — is logged as a WARNING and the
host is kept (the check never aborts a connect). The qa product is ignored,
and hosts not listed in refhosts.yml are skipped silently.
Host state
Each connected host has a state, set with
set_host_state:
enabled— runs all issued commands (the default).disabled— runs nothing and prints nothing.
Commands designed to run in parallel (like run) always fan out
concurrently across enabled hosts.
Locking
Two independent locks coordinate concurrent testers on a shared fleet. Both use
the same on-host wire format (timestamp:user:pid[:comment]), so concurrent
mtui processes — and any other fleet tooling that reads the same files —
cooperate rather than clobber one another.
- Operation / zypper lock (
/var/lock/mtui.lock, PID-based) — guards serialized repository transactions (enabling/disabling the test repo, install/update/prepare/downgrade). Set it explicitly withlock; it is applied automatically around the flows that need it. Enabled locks are removed when the session exits. To make the lock effective for other sessions too, give it a comment (lock -c "<why>"). Such a reservation is never rewritten or released byrun,update,installorprepareon the host; onlyunlockremoves it. Templates loaded in the same process, and the sessions of onemtui-mcpserver, hold the operation lock against each other like separate users: a second template’srunorupdatewaits for (or, at[lock] wait = 0, refuses on) the first one’s hold. - Pool-claim lock (
/var/lock/mtui-pool.lock, RRID-based) — taken during host-pool selection to reserve a host for a template. List and remove them with the-p/--poolflag onlist_locks/unlock.
By default, lock/unlock act on the whole active template’s hosts; pass
-t <host> (repeatable) to scope either to a subset. Plain unlock releases
only the operation lock this session took: a lock it never took — another
user’s, or a live sibling template’s on a refhost shared inside the same
process — is left alone and reported skipped. A foreign lock points at
list_locks or --force; a sibling’s is named as held by this mtui process and
is released from the template that took it. unlock -f force-removes a lock held by another user or
session regardless of who took it. mtui also reaps a pre-existing lock older
than [lock] stale_age on connect (almost always left over from a crashed
session); see Configuration for reap_stale, stale_age,
wait, and wait_poll. The one exception is
list_refhosts --free: its connect is a read-only
survey, so it never reaps. Its lock column reports one of free, locked,
pool (free but pool-claimed), locked+pool, unreachable (the host could
not be probed), or unknown (the probe never completed for it); --json
keeps this vocabulary and adds a pool key: the claiming RRID, or null when
unclaimed.
Product-Increment autolock
When testing a Product Increment (PI) and [lock] pi_autolock is enabled (the
default), mtui locks each reference host — with a comment naming the request —
as it connects, for the life of the loaded report; the lock is released when
the report is unloaded or the session quits, matching how every non-PI
report’s pool claims already behave. A host added later with add_host
(including a pool-selected host, with no -t) is locked too, since the lock
comment is seeded on load and every connect path applies it. Review actions
(assign/unassign/approve/reject) do not lock or unlock hosts
themselves. A reboot clears /var/lock, so the per-host
testing lock is re-applied after the host comes back.
Assignment context on assign
After the assignment succeeds, assign prints best-effort
context from the TeReGen report API: the update’s live priority and deadline
and, when the report already carries assignment state, who currently holds or
has decided each review group (one line per group). This is informational only
and never blocks the action — its absence can also mean the lookup simply
failed, and the server caches the state for a few minutes, so a line may lag by
up to ~5 minutes.
Kernel export file names
A kernel export saves each openQA job’s install log as
<host>-zypper-<arch>.log and each LTP result as
<host>-<arch>-<test>.json. When several jobs of one export would write the
same file, each keeps its own file with the job ID inserted before the
extension (<host>-zypper-<arch>-job-<id>.log); a name only one job claims
never changes. Files from an earlier export are not deleted, so a plain
<host>-zypper-<arch>.log written before this naming existed can remain next to
the job-ID files.
MCP server
mtui-mcp is a Model Context Protocol server
that exposes mtui’s functionality as tools an LLM client can call. Its tool
surface is synthesised from the same command registry the mtui REPL uses:
each non-denied command becomes one tool, with a JSON input schema derived from
the command’s clap argument spec, dispatched through the same engine as the
REPL. Adding, renaming, or removing a command changes the MCP tools
automatically. Two commands, get and put, are denied synthesis and
re-served under the same names as hand-written tools that carry file content
in-band (see File transfer tools) — a path on the
server’s filesystem would be meaningless to a remote --transport http client,
and is never needed on stdio either. The underlying semantics of each tool are the command semantics in
the Command reference.
Building and running
The server lives behind the mcp feature, enabled by default alongside the
mtui REPL’s cli feature (see Installation):
cargo run -p mtui --bin mtui-mcp -- --help
Two transports are served:
-
stdio (default) — one process == one client. Use this for a local LLM client that spawns the server as a subprocess.
mtui-mcp # stdio -
streamable HTTP — many clients, with per-client session isolation.
mtui-mcp --transport http --host 127.0.0.1 --port 8000--host/--portbind the HTTP listener and are ignored under stdio. The bind address is loopback only — rmcp’s DNS-rebinding guard rejects non-loopback binds.
Logs go to stderr; stdout is the transport, so never write to it.
Tool profiles
The advertised tool surface can be narrowed with [mcp] profile:
full(default) — every synthesised tool.core— a curated everyday subset; the rest are filtered out before they reach the wire.
Fine-tune with [mcp] tools_allow (add specific tools on top of the profile) and
[mcp] tools_deny (remove specific tools; deny wins last). See the [mcp] table
in Configuration for these and the resource caps
(max_output_bytes, max_active_jobs, session budget, …).
Agent clients should prefer core: the full surface (65 tools) is sent on
every request, while core (36 tools) covers the everyday flow — load →
inspect → run/install → fill the report → approve/reject — without the host
bookkeeping and server-tuning tail. If a workflow never lists the queue or
polls openQA, deny those on top:
[mcp]
profile = "core"
tools_deny = ["updates", "openqa_overview"]
Security boundary
Profiles are not authentication or authorization. They reduce the advertised tool surface; they do not gate callers. HTTP session isolation is likewise not caller authentication. Keep the HTTP transport on its default loopback interface, or place it behind an authenticated boundary you trust to operate the remaining maintenance tools.
Permanent deny-list
Some commands are never synthesised into MCP tools, under every profile — this
cannot be re-enabled with [mcp] tools_allow:
- The interactive/REPL-only commands, which require a controlling terminal or
have no headless meaning:
quit,exit,EOF,switch,shell,help,edit. - The path-based transfer commands
getandput, replaced by the hand-written in-band tools of the same names (#434): their synthesized forms exchanged server-local paths a remote client cannot read or create. (Only the synthesized command forms are permanently gone; the replacement hand-writtenget/putare ordinary tools — present infull, restorable undercoreviatools_allow, removable viatools_deny.)
Local process execution and terminal launching are not on this list because they
are not in mtui at all. The former lrun (run a command as the local process
user) was removed outright — an MCP client already has its own local execution,
and a REPL user is already at a shell. The former terms (spawn a terminal
emulator per refhost) went the same way: it duplicated shell (#566).
The deny-list is intersected with the live registry and consistency-tested; a renamed or removed command that leaves a stale deny-list entry is warned about at boot, so the boundary cannot drift silently.
config_show filtering
config_show prints less over MCP than at the REPL (#410). The REPL operator
owns the machine and can read mtui.toml directly, so it prints every resolved
value verbatim (secrets as <set>). A headless client gets only what it needs
to operate the session: endpoint URLs with embedded userinfo stripped and the
operational tunables/flags. The no-argument bulk dump is refused headlessly —
name the attribute(s) explicitly — as are the operator-local values
(session_user, template_dir, refhosts_path, and a ssl_verify CA-bundle
path; its true/false form is shown). Values config show never lists
(the oscrc location, the OBS API URL, lock/history paths) reach neither surface
through this tool.
Session state and isolation
Session state is isolated per client. Under stdio one process serves one
client, so there is exactly one config / loaded template set / group of connected
hosts. Under HTTP each connected client gets its own isolated session (its own
config view, loaded templates, and hosts), so concurrent clients never see each
other’s state. Clients load their own state at runtime via the load_template and
add_host tools — mtui-mcp takes no boot-time update/host flags (see
Invocation).
The HTTP registry refuses to create more than [mcp] session_cap concurrent
sessions and reaps a session after [mcp] session_idle_timeout seconds of
inactivity (disconnecting its hosts — the SDK gives no per-session teardown
callback, so this sweep is what releases a vanished client’s SSH connections).
This isolation depends on rmcp’s legacy Mcp-Session-Id session lifecycle, so
over HTTP mtui-mcp advertises protocol revisions 2024-11-05 through
2025-11-25 and declines 2026-07-28: that revision removes protocol-level
sessions, and rmcp’s transport layer classifies any non-initialize request
carrying complete 2026-07-28 metadata as stateless regardless of config, mints
a throwaway server for it, and serves it inline — which would tear down and
rebuild the per-client McpSession (its SSH connections, pool claims) on every
such request. A client asking for it over HTTP falls back to the latest
revision mtui-mcp does support.
Over stdio there is one client per process and no per-request session to
protect, so that inline lifecycle is harmless: mtui-mcp advertises
2026-07-28 there too, so a client that opens with server/discover at that
revision succeeds instead of being refused.
Multiple templates: scoping and fan-out
A session can hold several loaded templates at once (call load_template more
than once; loading an already-loaded RRID reloads it). list_templates lists the
set, and each template keeps its own test report and SSH host group.
Every command that acts on a loaded template carries the shared -T/--template
and --all-templates flags, so its synthesised tool exposes two optional
parameters. The session-level tools — load_template, unload,
list_templates, list_refhosts, updates, config_show/config_set,
whoami, set_log_level — expose neither, and regenerate exposes only
template:
template="<RRID>"— scope this one call to a single loaded template (the analogue of the REPL-Tflag). An unknown RRID returns a clean error.all_templates=true— force fan-out across every loaded template.all_templates=false— suppress fan-out on a tool whose command fans out by default, narrowing it to one template instead.
A client still sending template/all_templates to unload, list_templates,
list_refhosts, updates, whoami or set_log_level, or all_templates to
regenerate, has them ignored with a server-log warning until 26.5.
load_template refuses them now; config_show/config_set never took them.
Omitting both parameters resolves per command: a read/annotate tool
(list_* other than the session-level ones above, show_*, openqa_*,
checkers, checkout, export, set_workflow, comment) fans out across
every loaded template, prefixing each template’s
output with an === <RRID> === banner. A host-mutating or remote-write tool
(update, prepare, downgrade, install, uninstall, set_repo, run,
reboot, lock, unlock, add_host, remove_host, approve, reject,
assign, unassign, request_review, commit, plus the hand-written get/
put transfer tools) never implicitly fans out: with several templates
loaded and neither parameter set, the call is refused rather than guessed at —
the error names the loaded RRIDs and the template=/all_templates=true
escape hatches. This is the same rule the report_* tools use for their own
required-with-several-loaded template parameter (below).
A fanned-out call that fails on one template keeps running on the others and reports an aggregate failure at the end.
switchis not an MCP tool (moving the active-template pointer is REPL-only navigation): over MCP you target a template per call withtemplate=.load_templateandunload <rrid>are exposed — each names its own RRID.list_templatesis available as a read-only listing.
Non-interactive prompt behaviour
MCP sessions run with no interactive prompter installed, so a command that would prompt at the REPL never blocks waiting for input:
approverefuses non-interactively on a Gitea checkout-hash mismatch (at the REPL it prompts for confirmation, default no) unlessaccept_headnames the PR head as Gitea reports it now — the refusal prints that head. The value is a pin, checked even when the report is not stale, and the LGTM is refused again if the head moves before it is posted. A missing Gitea token or a failed Gitea call refuses on every surface, since the check never produced a verdict to confirm. The reviewer and document checks, the Slack gate and the backend’s own checks (Gitea: hash gate, assignment, already decided; OBS:-g, assignment) all run beforereviewerrecords or uploads anything, so a refusal from them publishes nothing. The OBS verdict check cannot: it reads the uploaded document, and a Gitea head that moves during the upload is refused at the LGTM with the reviewer already stored.load_template’s interactive “Force continue loading template ?” fallback — for a stale checkout hash that TeReGen also refuses to regenerate (already hand-edited) — is exposed as the--force-continueargument; without it the load is unconditionally abandoned, since the question’s non-interactive default is “no” (openSUSE/mtui#517). A force-continued load neither repairs nor bypasses the recorded mismatch, so it does not touchapprove’s refusal above — andcommit,export,reject --reviewerandrequest_reviewrefuse the same way, unless given their own--allow-stale, so a stale-loaded report cannot be published or overwritten non-interactively either.approve’scheck_hash()re-query is live (asks Gitea again, every call) and replaces that load-time verdict forapprove -r; the others instead read whether the load itself saw a mismatch, so a report that loaded clean and only goes stale once the session is already open (a maintainer pushes to the PR whilemtui-mcpstill holds it loaded) passes them silently —commitis the one where that gap is most likely to matter, since it is the publish step.- A command timeout aborts immediately instead of offering the REPL’s wait/retry prompt.
commentandcommittake their text/message as a required argument (no editor/stdin prompt), andregenerategates overwrite on the--forceflag rather than a prompt.
SSH authentication is public-key only in every surface. A failed key auth reports
the host as unreachable; mtui-mcp never falls back to a password prompt.
File transfer tools
The hand-written get and put tools replace the synthesized commands of the
same names (#434), carrying content in-band in both directions. The REPL
get/put commands work on literal paths instead, with downloads landing in
{report_wd}/downloads/{name}.{host}.
get (read-only)
Downloads remote (an absolute file path; folders are not supported in-band)
from every enabled host and returns, per host, the remote size, a
truncated flag, and the content: UTF-8 as content, binary as content_b64
(standard base64). Reads are capped per host at [mcp] max_input_bytes,
enforced while reading — a host never buffers more than the cap (0 disables
it and reads the whole file) — and each host gets an equal share of the
[mcp] max_output_bytes wire budget. With a cap set, size is the file size
the server reports when the file is opened, so it can differ from the returned
bytes if the file changes during the read; a server that reports no size fails
the call, naming the host, rather than being read without a bound. Without a
cap, size is the number of bytes read. Truncation never splices a notice into
the content: the truncated flag and size are the signal. Pass hosts (a
list of connected hostnames) to retry or page a subset when one host’s file was
truncated; unknown or disabled names refuse the call. Any host failure fails the whole
call with the host named — partial content is never returned as success.
put
Uploads a payload carried in the call — content (UTF-8) or content_b64,
exactly one of the two — to <target tempdir>/<filename> on every enabled
host, the same destination the REPL put uses. filename must be a bare name
(no path separators). A payload above [mcp] max_input_bytes is refused
outright: truncating an upload would corrupt it. Any host failure fails the
call with the host named.
Command behaviour notes
- The
preparetool fails — instead of reporting success — when the loaded report’s metadata names no package versions, and when no prepare command could be built for a connected host (#396), so automation keying onprepare’s success does not see a false positive on a metadata-empty report. - On a report whose metadata carries a
binariesblock,preparenarrows the package list per host to what that host’s products compose, and fails by name a host whose products compose none of it — rather than sending a list zypper refuses with “capability not found” (104). An architecture a product declares but the metadata never mentions composes nothing, not the full list, so it gets the same named refusal.updateexcludes such a host from its patch, scopes its own lock/repository/package-check to the hosts that do compose it, and reports a failure naming it, instead of patching a host its own prepare established no baseline on. update’s tool result carries diagnostics for every non-fatal degradation of the run — a composition refusal, a host excluded because its test update repository genuinely failed to register (its partial add is undone and its lock released before the patch reaches its peers), a repository refresh that may have left stale metadata on a host that still gets patched, a prepare host failure it continues past, a failed--newpackagestep, a failed update leaving the test repositories configured, a failed automatic rollback, and the three ways the post-update test-repo cleanup can fail — on both the success and the failure path. The server’s tracing goes to its own stderr, not the tool result, so atracing::warn!alone would never reach an MCP client.- Every workflow fan-out (
install,uninstall,prepare,downgrade,update) confirms success as<verb> completed on <hosts>, so a clean run is never an empty tool result.update’s confirmation is printed above its diagnostics — the head of the buffer is what survivesmax_output_bytes— and is qualified whenever the run recorded a degradation:update completed on h1: the patch passed its checks, with 2 degradations reported below. The count is of degradations only; a check’s own recognised sections (the vendor-support notice, extra rpm output) ride in the same diagnostics and are routine on a healthy update, so they leave the confirmation bare. add_hostwithout-tfollowsadded <hosts>(orno reference hosts resolved/connected) with one line per test-target slot or testplatform it left without a host and why: no matching refhost, every candidate claimed by another RRID, no candidate connected (unreachable or claimed remotely), the retry budget spent, or the refhosts inventory unavailable. The call still returnsOkin that case, so these lines are the signal to check.exportemitsWARNING: no package version data recorded for <host>...lines in its tool output when a host has no recorded package data and its install-result block was therefore left unverified in the report.runprints its own verdict above its per-host output, for the same head-survives-max_output_bytesreason:run completed on h1 (exit 0), h2 (exit 1)names every host it ran on with its exit code, followed byFAILED on h2 (exit 1)when any host exited non-zero.runreturnsOkon a non-zero remote exit — a non-zero exit is often expected — so this verdict, not the tool’s success/failure status, is the signal to check.- The
runtool’scommandis argv tokens, not a shell line: the tokens are re-quoted before dispatch, so["cat /etc/os-release"]reaches the host as one quoted word (exit 127) and["zypper","lr","|","grep","x"]passes a literal|as an argument. Split the words yourself (["cat","/etc/os-release"]), and for anything needing a shell — pipes, redirection,;— ask for one explicitly:["sh","-c","zypper lr | grep x"].
Report document tools
Seven hand-written tools work on the loaded report as a document: five read
and edit it as named sections and issues rather than lines of text, and two list
and read its files. Each accepts an optional
template="<RRID>" selecting which loaded template to act on; with more than
one template loaded an unscoped call is refused — the same “one template, or
refuse” rule described under Multiple templates: scoping and
fan-out — and with zero or one loaded
it may be omitted. All refuse cleanly when no report is loaded.
The sections are verdict, comment, people, update, install, issues,
testing and review; review is absent from a PI report. Reads never change
the report.
report_sections (read-only)
- Parameters:
template(optional). - Returns
{ "id", "dirty", "sections": [{"name","size","has_unanswered"}], "complete", "unfilled": [...] }.sizeis the byte length of the section’s compact JSON (advisory).unfilledholds the RFC 6901 pointer of everynullleaf in the whole document, in sorted key order;completeis true when there is none.dirtyis true while edits awaitcommit.
report_section_read (read-only)
- Parameters (required):
section. Plus optionaltemplate. - Returns
{ "id", "section", "data" }. A section the report lacks refuses.
report_section_write
Replaces one tester section with value.
- Parameters (required):
section(verdict,comment,people,issues,testingorreview),value(any JSON —nullis a legitimate value for an unansweredverdictorcomment). Plus optionaltemplate. updateandinstallare pipeline-owned and refused. A section the report lacks refuses. Anissueswrite must keep exactly the same issue keys; usereport_issue_writefor one entry.- The whole candidate document is re-parsed against the typed schema, and a
value carrying a key the schema would drop (an unknown key in a closed
object, or
nullon an optional field) is refused. A refusal lists the offending pointers and leaves the report untouched. String patterns and array minimums are not checked locally, so the server can still refuse atcommit. - A write changes local state only and marks the report dirty;
commituploads it. A laterexportoverwritestesting.install,testing.openqaandpeople.testers. - Returns
{ "id", "section", "dirty": true, "size" }.
report_issue_read (read-only)
- Parameters (all optional):
issue_id(such asbsc#1234567),template. - Without
issue_id, returns{ "id", "issues": [{"id","title","status", "severity"}] }; with it,{ "id", "issue_id", "issue" }.
report_issue_write
Replaces one existing issue with value; the set of issues cannot grow or
shrink. Validated and applied exactly like report_section_write.
- Parameters (required):
issue_id,value. Plus optionaltemplate. - Returns
{ "id", "issue_id", "dirty": true }.
report_files (read-only)
Lists the report directory’s files — build_checks/, the install_logs
directory, results/ and checkers.log — merged by file name with the files
teregen holds for the report.
- Parameters:
template(optional). - Returns
{ "id", "files": [{"path","name","size","server"}], "server_only": [{"name","origin","size","at"}] }, plus"server_error"when the server listing failed.filesare the local regular files, sorted bypath(report-directory-relative; subdirectories and symlinks are skipped).serveris{ "origin", "size", "at" }for the server’s file of that name (originispipelineoruploaded), ornullwhen it has none; sizes differ untilcommituploads.server_onlylists server files with no local copy. - A failed server listing (not found, still generating, unreachable) never
fails the call: the local list is returned with
server_errorset.
report_file_read (read-only)
Reads one of those files as text.
- Parameters (required):
path, relative to the report directory — eithercheckers.logor a file directly insidebuild_checks/, theinstall_logsdirectory orresults/. Optional:offset(1-based first line, default 1),limit(maximum lines),template. - Returns
{ "id", "path", "total_lines", "content" }; a windowed read also carriesoffsetandreturned_lines.contentis bounded by[mcp] max_output_bytes, and the read stops at[mcp] max_input_bytes(0disables the cap), cutting a line that crosses it. A capped read ends with a notice, andtotal_linesthen counts only the lines read up to the cap; the size of the unread rest is unknown. - Refuses a path that escapes the report directory (
.., an absolute path, a symlink out), one outside the readable set, and a missing file. A file the server lists but the report directory lacks is refused with a distinct message: server-side files cannot be fetched yet (needs teregen T15).
A result larger than [mcp] max_output_bytes comes back as { "truncated": true, "size", "content" }, where content is the capped JSON text, rather than
as clipped (invalid) JSON.
Background jobs
Long-running commands run as background jobs so a tool call returns promptly with
a job id you can poll. The slow host commands — run, update, downgrade,
prepare, install, uninstall, set_repo, reboot, regenerate, add_host,
and load_template — accept a background=true parameter. Instead of
holding the request open for the minutes the operation takes, the call returns
immediately with the job id(s); when the command fans out across several
templates it mints one job per template, each independently pollable and
cancellable.
add_host and load_template -a connect to a whole batch of reference hosts, and
one unreachable candidate among them can hold the call open far past any client’s
patience — backgrounding is the only way to keep such a call cancellable via
job_cancel.
Four job-control tools manage them:
job_list(read-only) — every job in the session and its state.job_status(job_id, wait_seconds=0)(read-only) — one job’s state (running/done/failed/cancelled) and elapsed time;wait_secondsblocks for a terminal state first.job_result(job_id, wait_seconds=0)(read-only) — a finished job’s captured output; it errors while the job is still running (or wait for it withwait_seconds) and surfaces the command’s failure envelope if it failed.job_cancel(job_id)— cancel a running job. (A command already executing on a host may run to completion there even after cancel returns — the same caveat as Ctrl-C on a foregroundrun.) Two commands treat a cancel as a normal ending rather than a failure:request_review --watchstops watching (the request stays posted) andregeneratestops waiting (the server keeps building). Both return success, so their job endsdone, notcancelled, with the reply text saying what was and was not finished.
wait_seconds (0-120, default 0) turns a poll loop into one held request:
job_status and job_result park until the job is terminal or the budget
lapses, then reply exactly as a plain poll would — the running snapshot, the
output, or a “still running” error that now points at wait_seconds. Keep it
under the client’s own request timeout; a value above 120 is refused, not
clamped. While parked the call emits progress heartbeats if the client supplied
a progressToken, and a notifications/cancelled interrupts it. Over HTTP rmcp
additionally pings the per-request SSE stream every 15s; a client that silently
disconnects instead leaves the wait to run out its budget server-side, holding
nothing.
A job blocked mid host-operation cannot stop at a checkpoint, so cancelling it
force-aborts the dispatch — which skips the operation’s own unlock(). A forced
cancel therefore releases /var/lock/mtui.lock on the job’s behalf and reports
the outcome in its reply.
What it releases is deliberately narrow: only the locks the cancelled job’s own
host group actually took, and never a comment-marked (exclusive) hold such as a
Product Increment assignment lock or an operator’s lock -c <text> reservation.
Locks are owned per user + PID on the wire, so a broader sweep would remove a
sibling template’s — or another MCP session’s — live hold on a shared refhost and
report it as released.
The reply distinguishes: which hosts are now unlocked; which are still locked and
by whom (check with list_locks; unlock --force releases the whole group of
every loaded template); which release failed (check with list_locks and retry
unlock once the host is reachable); and which
templates could not be reached inside the release budget, whose lock state is
therefore unknown. A cancel with no held lock to act on says nothing extra.
The job’s template scope is recorded when the job is minted. A job started without a resolvable scope falls back to every loaded template — the conservative reading for a dispatch that may have run across all of them.
Nothing else is done at the hosts. The remote command may still be running there;
where it is a package transaction, the package manager’s own system-wide lock
keeps serialising it, but a run or reboot has no such second layer and the
release is purely mtui-side bookkeeping.
A cooperative cancel — a body that unwound at a checkpoint — is unchanged: it ran its own unlock discipline, and the cancel does not second-guess it.
Jobs are scoped to the session (under HTTP, the caller’s isolated session, so one
client never sees another’s jobs). The per-session job budget
(max_active_jobs, max_completed_jobs) and the single-result size cap
(max_output_bytes) bound resource use so one client cannot exhaust the server or
dwarf the client’s context.
Audit log
mtui-mcp executes consequential actions with nobody watching, so a configured
server keeps a durable record of what ran. Set [mcp] audit_log to a file
path; unset (the default) disables auditing and leaves behaviour byte-identical.
The sink is versioned JSONL, one object per line, opened O_APPEND with mode
0600 and fsynced before the response returns — entries survive a server
restart and are never truncated. The open is O_NOFOLLOW and the resulting
descriptor is vetted: anything that is not a regular file owned by the serving
user is refused, so a planted symlink or FIFO cannot divert the append (or the
0600 tightening) onto another file. O_NOFOLLOW covers the path’s final
component only — the sink’s directory must be writable by the serving user
alone. A same-user hardlink is allowed on purpose; it crosses no boundary.
The record is durable and append-only but not tamper-evident: there is no
hash chain and no HMAC, so anyone writing as the owner can rewrite history
undetected. Tamper evidence means shipping each record off-host as it lands —
see OTLP log export. An outcome record carries the schema
version, the arrival timestamp, the sequence number, the session id, the
transport, the tool name, the (redacted, see below) arguments, the outcome
(ok / error / unknown-tool), the duration, and the RRIDs and host names
the call resolved to. An intent record carries the same header and the
same arguments, and nothing that only exists afterwards. A terminal
record carries no arguments at all.
Host names on a record are best-effort: they are empty when the session is
busy with an exclusive background job (a backgrounded load_template,
add_host or config_set), because a record write never waits on the work it
records; rrids are always recorded.
[mcp] audit_log_max_bytes (default 256 MiB) bounds the sink: the first
open that finds the file at or over the cap rotates it to <path>.1, shifting
.1….4 down one and discarding what was .5. That is usually an append, but
the start-up probe opens the sink the same way, so a server can rotate before it
answers anything. Archiving beyond .5 is the operator’s job. 0 disables rotation altogether; any other value below 4096 is
raised to it with a warning, because a cap under one record’s worth rotates the
trail away.
Rotation is housekeeping and never a gate: it neither blocks the dispatch worker
nor refuses a call. Two servers may share one path, so it is serialised by an
advisory lock on the file itself — taken without waiting. A writer that finds
the lock held, or a filesystem that cannot take it at all (an NFS export without
lockd answers ENOLCK), appends past the cap and rotates on a later call. A
rename it cannot perform warns and keeps appending too, and that warning is not
repeated until a rotation succeeds. Two consequences worth knowing when reading
the files back: a writer that opened the sink just under the cap lands its line
in <path>.1, so seq is monotonic overall but the order across the boundary
file is not; and on NFS, Linux emulates flock with per-process POSIX locks, so
two HTTP sessions in one process do not serialise there — at worst a second
shift, never a refused call.
A tool that is not advertised readOnlyHint writes an intent record
before it runs — schema version, arrival timestamp, sequence number, session,
transport, event: "intent", tool, (redacted) arguments, and the trace
correlation when the client supplied one. Nothing else, because nothing else
exists yet. Its outcome record then carries intent: <seq>, and its
duration_ms — measured from arrival, like ts — includes the intent record’s
own write. Read-only tools skip the intent and write only the outcome record, so
a poll loop still costs one record per call.
A backgrounded call therefore writes three records: the intent, a dispatch
record naming the started job_ids, and a terminal record when the job reaches
done / failed / cancelled, joinable by job id. The terminal record carries
no arguments and no intent — it joins its dispatch by job_id, which also
matters because a terminal record’s seq is not ordered against the calls
around it in either direction: neither against its own dispatch record, since
a job that settles at once can be minted first, nor against a
job_result(wait_seconds) reply that waited for it. Join by job_id, never by
seq.
A configured sink is probed once at start-up, before the OTLP probe: if it cannot be opened the server refuses to start and names the path on stderr, rather than loading quietly and failing on the first tool call.
Afterwards the sink fails in two different ways, on purpose. If the intent
write fails the call is refused and nothing ran — the guarantee the sink
exists to make. If the outcome write fails the work has already happened, so
the executed result is still returned with
[audit: outcome record lost (<reason>)] appended — as a trailing text block on
a successful reply, or to the message of an error one. Discarding a result to
report a logging failure would tell the client a refusal that never happened.
The notice is always the last content block, so content[0] stays the
verbatim payload a --json caller parses, and it is not counted in the record’s
response_bytes. A failed terminal write can only warn — its dispatch already
answered.
Reading the log back: an intent that no later record points at means “started,
outcome not recorded” — a crash or a kill, or a lost outcome write whose client
was told in band (and which the OTLP stream may still hold).
config_set never records the value for any attribute, so a future
secret attribute cannot leak by omission; the record marks whether the attribute
is a known secret. File-body payloads (put content/content_b64,
report_section_write/report_issue_write value) never land
verbatim either: each records {bytes, sha256} over the original string, so a
credentials file or SSH key uploaded via put stays correlatable without being
persisted. Always fingerprinted, never inline, regardless of size — the same
redaction feeds the file body, the OTLP body, and the size accounting, and no
payload key is ever an indexed OTLP attribute.
Everything else is masked, not trusted. Every recorded string, at any
nesting — run and comment argv included — runs through a token scanner that
strips URL userinfo (the same sanitize_url the rest of mtui logs through) and
replaces the value of a secret-named flag or key: --password secret,
DB_PASSWORD=…, x-api-key: …, Authorization: Bearer …,
Authorization: token … and '{"password":"…"}' all record <redacted>,
through the quoting a shell line or a JSON payload wraps them in, while
token_bucket=50, max_tokens=100, grep token file and ssh -p 22 host are
left legible. Bearer, Basic and token keep their place in front of the
credential they name — but only after a key that is itself a credential name,
so Basic functionality verified stays a sentence.
The scanner keys on the name beside the value, so what it does not catch is
worth knowing rather than discovering: a bare secret with no key beside it; a
single-letter flag, whose meaning is per-program (sshpass -p secret,
curl -u user:pass, psql -U user); a value attached to one (-pSECRET,
-U user%pass); and anything passed by environment variable, stdin or a file
the tool reads.
All file I/O runs on the blocking pool (spawn_blocking), so a down or slow
disk never stalls the dispatch worker or the other calls it is serving — the
one call that waits is the one whose own records are being written.
OTLP log export
The same record can also go to an OpenTelemetry collector as OTLP/HTTP LOGS
(hand-rolled protobuf over the workspace reqwest/rustls stack — no
opentelemetry-* crates): one log record per audit event, body = the verbatim
JSONL line (already redacted and fingerprinted as above). Configuration is env-only (headers must never be CLI flags), so
there are no new TOML keys:
OTEL_EXPORTER_OTLP_ENDPOINT(base URL, gains/v1/logs) orOTEL_EXPORTER_OTLP_LOGS_ENDPOINT(full URL, wins verbatim). Unset-or-empty disables export, as does an invalid endpoint or a non-http/protobufprotocol (validated only when an endpoint exists).OTEL_EXPORTER_OTLP_HEADERS/OTEL_EXPORTER_OTLP_LOGS_HEADERS(key=value,..., values percent-decoded; logs-specific wins).OTEL_EXPORTER_OTLP_PROTOCOL/OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: onlyhttp/protobufor unset.OTEL_SERVICE_NAME(defaultmtui): resourceservice.name, the multi-deployment join key.
Sink matrix: file-only (audit_log set, no endpoint), OTLP-only (endpoint set,
audit_log unset — the JSONL line is still built in memory and used verbatim),
both (file first in seq order, then OTLP), neither (auditing off, dispatch
byte-identical).
Each record carries closed mtui.* attributes (tool, event, seq,
transport, session.id, plus outcome when there is one, response_bytes
when sized, and allowlisted kwarg keys) and the W3C trace ids when the client supplied a strict lowercase
55-byte traceparent via _meta (also echoed as the record’s trace field;
session.id stays server-minted). A startup probe posts one real diagnostics
record (~5x500 ms) before serving and latches export health; failed batches
merge into a pending audit_gap sent on recovery. Batching: 2048/stream cap,
512/batch, 500 ms interval, 10 s request timeout, 5 s shutdown flush, redirects
off, [mtui] ssl_verify TLS posture.
Export never gates a tool call. The health latch decides whether a record is
enqueued, nothing more: an unhealthy or full exporter logs a warning, merges the
record’s seq into the audit_gap it will report on recovery, and the call
still runs and still answers. The file sink stays the durable truth, so
OTLP-only mode never refuses anything. Tracing diagnostics ride a separate
best-effort queue (drop + counter; exporter/HTTP-stack targets excluded). The
endpoint value never appears in logs, records, or errors.
Cancelling a foreground call
A client that sends an explicit notifications/cancelled for an in-flight
foreground tool call drops the dispatch and releases the CommandLock it was
holding — a follow-up call on the same RRID is not left queued behind it. What
happens next depends on whether the call could be holding a host operation
lock.
A report_* tool, a transfer tool (get/put) or a parked
job_status/job_result wait never dispatches through the engine, so it cannot
hold /var/lock/mtui.lock: the cancel drops the dispatch immediately and the
tool call resolves to an error rather than a fabricated success.
A synthesised command tool (run, update, install, …) can be mid
host-operation when the cancel arrives, and dropping it outright would strand
that lock exactly as an unhandled job_cancel would. It instead runs the same
two-stage sequence job_cancel uses: the dispatch’s own cancellation token is
signalled first, giving it a short grace period to unwind at a checkpoint (a
body that does — for example one already watching the session’s cancel
token — returns its own verdict, success or failure, not a synthetic
cancellation error). Only if the grace elapses is the dispatch force-aborted;
the abort then best-effort releases /var/lock/mtui.lock on every host the
call’s own group actually took (never a comment-marked reservation) and the
error text names what it unlocked, what is still locked and by whom, and
what could not be reached inside the release budget.
This only helps a client that sends the notification. mtui-mcp’s default
transport is stdio, where there is no per-request connection to drop — a
client that simply stops waiting (e.g. because it hit its own timeout) sends
nothing, and the server has no way to learn the caller gave up. In that case
the lock is still released, just on the ordinary schedule: the connect_timeout
budget (commit A) and the shared backup-retry budget bound how long that takes
rather than leaving it unbounded. Backgrounding a slow command
(background=true, see above) plus job_cancel remains the reliable way to
recover a stuck call regardless of transport.
Long-running calls: progress heartbeats
Many commands legitimately take minutes (a run against a slow refhost, an
update, a checkout). To keep MCP clients from timing out, mtui-mcp
emits notifications/progress while a slow tool runs — for both synthesised
command tools and the hand-written tools — provided the client supplied a
progressToken on the request. Spec-compliant clients (Claude Desktop, opencode,
the MCP Inspector, Cursor, …) reset their read deadline on each frame, so a
ten-minute command still returns cleanly. Clients that ignore progress
notifications should raise their own per-server read timeout instead. The
job-control tools emit them only while parked on wait_seconds.
Connecting a client
mtui-mcp speaks standard MCP framing. A stdio client spawns the binary as a
subprocess; an HTTP client connects to http://HOST:PORT/mcp.
Claude Desktop (stdio) — in claude_desktop_config.json:
{
"mcpServers": {
"mtui": { "command": "mtui-mcp", "args": ["--transport", "stdio"] }
}
}
opencode (remote HTTP) — start mtui-mcp --transport http --port 8765, then in
opencode.json:
{
"mcp": {
"mtui": { "type": "remote", "url": "http://127.0.0.1:8765/mcp", "enabled": true }
}
}
opencode also accepts "type": "local" with "command": ["mtui-mcp", "--transport", "stdio"] to spawn it per-session instead.
Architecture
This page is a map, not a deep dive: it sketches how mtui is put together so
the rest of the book (and the source) is easier to navigate. For the day-to-day
contributor workflow — toolchain, quality gates, adding a command, testing — see
the Developer guide. For the authoritative contributor spec and
definition of done see AGENTS.md, which lives in the repository root.
Workspace layout
mtui is a Cargo workspace of single-purpose crates. Lower crates never depend on
higher ones; mtui-core is the composition root that wires everything together.
| Crate | Job |
|---|---|
mtui-types | Domain types + the error hierarchy. Pure, sync, no I/O. Includes the typed report_document model of the JSON-native TeReGen report (schema v1.0). |
mtui-config | TOML config + XDG path resolution. |
mtui-hosts | SSH/SFTP (russh), the Target/HostsGroup model, locks, the pool arbiter. Async. |
mtui-datasources | Shared HTTP; refhosts resolve/search/verify; the openQA/QEM/Gitea/OBS/oqa-search clients. Async. |
mtui-testreport | Testreport lifecycle (TeReGen v2 document ingest/upload) and the update workflow (actions/checks/export). |
mtui-core | The Command trait + registry, Session, the dispatch engine, and the wiring that ties the crates together. |
mtui (root) | Facade package owning src/bin/{mtui,mtui-mcp}.rs behind the cli/mcp features; its integration tests are in tests/it.rs. |
mtui-tui | The report-document editor: a walker over the report JSON Schema, the form model built on it, and the ratatui screen. Pure and sync; depends only on mtui-types, so neither the MCP server nor a --no-default-features build compiles ratatui. |
mtui-cli | The reedline REPL library, including the terminal handover around the mtui-tui editor. |
mtui-mcp | The rmcp server library. |
The command registry is the single source of truth
Every command implements one Command trait and is registered into a central
registry by an explicit register_all() (no auto-registration magic). Three
consumers iterate that one registry:
- the REPL’s command dispatch and tab-completion;
- the generated command reference;
- the
mtui-mcptool synthesiser, which turns each non-denied command’sclaparg spec into a JSON tool schema.
So adding, renaming, or removing a command updates the REPL, the docs, and the MCP tool surface together — they cannot drift. See MCP server for the deny-list that keeps REPL-only commands off the wire.
Session state, not globals
Commands operate on an explicit Session (config, the HostsGroup targets, the
loaded templates/metadata, the display) passed into each call — there are no
hidden globals.
Trait injection and the no-cycles rule
The update-workflow doer/check registries live in mtui-testreport, but the
install/uninstall template that needs them lives in mtui-hosts — the lower
crate. mtui-hosts therefore declares a PlanProvider trait in terms of its own
types, and mtui-testreport’s WorkflowRegistry implements it. The rule when
two crates need to cooperate across that boundary: define a trait in the lower
crate and inject an implementation from the higher one — never introduce a crate
cycle.
Inject at the point of use. update_flow::perform_install /
perform_uninstall install the provider on the HostsGroup immediately before
driving the template. OperationGroup::plans has exactly one consumer, so there
is exactly one site that cannot forget. Injecting where the group is built
looks tidier but is not equivalent: an earlier design that wired one central
spot was never called at all, leaving install and uninstall running no
command while still reporting success.
Contracts
These data-format and workflow contracts keep mtui interoperable with the SUSE
maintenance ecosystem. Each is owed to something live: the RRID grammar to
OBS/IBS/QEM, the refhosts.yml schema to the qam-metadata fleet database, the
report document schema to TeReGen, the MCP tool surface to downstream
LLM clients, and the on-host formats to other mtui processes sharing a fleet
— including older releases, which is why they are a wire format and not an
implementation detail. The crates/*/tests/ fixtures are the authority.
- RRID grammar —
project:kind:maintenance_id:review_idand its parse errors (see the FAQ). refhosts.ymlschema — location-grouped on disk, but rows are merged/flattened/de-duplicated at load; parses identically to the golden fixtures.- Report document schema v1 — the JSON schema TeReGen serves
(
report_document::SCHEMA_JSON); drift from the live schema is checked at load. - Remote-lock wire format — one line,
timestamp:user:pid[:comment], shared by the operation lock and the pool-claim lock (see Workflow concepts). - MCP tool names/schemas — downstream LLM configs depend on them; the synthesised, slimmed schemas are snapshot-tested.
- openQA
BUILDquery string —:{type}:{number}:{package}(crates/mtui-datasources/src/openqa/base.rs::OpenQABase::new), owed to openSUSE/qem-bot, which writes it (types/submissions.py:236), not to TeReGen. Each component is qem-bot’s own:typemirrors qem-dashboard’sincidents.typecolumn (git/smelt,mtui_types::UpdateSource);numberis the dashboard’s own incident number, which for every SLFO update (git- or OBS-served) is the review id, not the maintenance id (crates/mtui-datasources/src/qem_dashboard/incident.rs::QemIncident::incident_number);packageis chosen by qem-bot’ssort_packagesordering (demote arch-suffixed/-livepatch-names, then shortest, then alphabetical), not plain shortest-by-length. A Product Increment is connected to neither qem-dashboard nor openQA, so mtui skips the dashboard fetch for PI outright rather than querying a service that was never going to answer.
Deliberate design choices
- TOML config, not INI.
- Native OBS/IBS API for the QAM review workflow, not an
oscsubprocess. - Two static binaries (
mtui,mtui-mcp), no runtime interpreter or virtualenv to install. - Async I/O (
tokio) with true parallel host fan-out. - Git-vs-OBS is decided per update, from the report document, not from the RRID.
During the SL-Micro 6.0/6.1 cutover both workflows share the
SLFO:1.1id space, so no rule over the RRID’s shape can be correct — and an update can briefly be served both ways at once.mtui_types::UpdateSourceis a selection, not an observation:Gitwhen the report document’sworkflowisgitea,Obsotherwise, resolved once at load. On a dual-served updateGitwins by design, and mtui leaves that update’s OBS review request untouched —assign/approve/reject/commentonly ever reach the Gitea side. Seeapprovefor the operator-facing consequence.
Developer guide
This guide covers the day-to-day mtui development workflow: the toolchain, the
quality gates, how the command layer is structured, and how to add a command.
Architecture has the higher-level crate map and the data-format
contracts; AGENTS.md at the repo root is the authoritative contributor spec and
definition of done.
Toolchain
mtui uses the standard Rust toolchain — no rustup is assumed (the reference
dev box runs a Homebrew rustc). Edition 2024, MSRV 1.96, pinned via
rust-version in Cargo.toml.
| Task | Command |
|---|---|
| Build everything | cargo build --workspace (produces both mtui and mtui-mcp) |
| Run the REPL | cargo run -p mtui -- --help |
| Run the MCP server | cargo run -p 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 |
| Coverage | cargo llvm-cov --workspace --lcov --output-path lcov.info |
Repo automation lives in the xtask crate, invoked through the alias in
.cargo/config.toml:
| Task | Command | Effect |
|---|---|---|
| Regenerate packaging artifacts | cargo xtask gen | dist/completions/{bash,zsh,fish}/… + dist/man/*.1 |
| Regenerate generated docs | cargo xtask gen-docs | docs/src/cli.md + docs/src/invocation.md |
| Build a release tarball | cargo xtask package --version <VER> --target <TRIPLE> | dist/release/…tar.gz |
| Probe the teregen v1↔v2 corpus | cargo xtask corpus-survey | Read-only 200/404/503/other split by RRID kind |
| Check for teregen schema drift | cargo xtask schema-check | Diffs the live /api/v2/schema against the committed copy |
| Mint/reuse a teregen v2 auth token | cargo xtask teregen-login [--base <URL>] [--user <NAME>] [--namespace <NS>] [--no-store] [--no-probe] | Signs teregen’s SSH challenge with your oscrc key, caches the bearer token, and probes GET /schema; never prints the token |
| Probe the teregen v2 write client | cargo xtask teregen-put --id <RRID> [--base <URL>] [--user <NAME>] [--file <PATH>] [--if-match <ETAG>|--create] [--dry-run] | GETs a document and re-PUTs it with the just-received ETag by default; --file/--if-match/--create drive a backfill or a deliberate negative case; --dry-run prints the request instead of sending it |
The .deb/.rpm on the release page have no xtask equivalent — they are built
only by the package job in .github/workflows/release.yml.
schema-check needs qam.suse.de, unreachable from GitHub-hosted runners, so
it stays a manual dev tool: run cargo xtask schema-check when starting or
resuming report-document work, and treat drift as blocking.
Quality gates
CI (.github/workflows/) mirrors the local gate. Run the whole workspace
before claiming done:
cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --document-private-items
cargo test --workspace # default features only
cargo build --workspace --no-default-features # feature matrix (compile-only)
cargo build --workspace --all-features # feature matrix (compile-only)
Notes that save time:
- The cost is compilation, not test execution. A cold
cargo build --workspace --testsis ~80 s; the test run is ~20-25 s. Give the firstcargo test --workspaceof a session a generous timeout (≥5 min) and don’t treat a cold-cache timeout as a failure. - 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. CI only compiles--all-features; it does not test it. - Scope tight during dev:
cargo test -p <crate>for the crate you’re touching; reservecargo test --workspacefor the final gate. mtui-cli’s lib suite is the slow one (itsedit/shelltests spawn real editor/shell subprocesses) — don’t rerun it when working elsewhere.
New or changed code needs ≥80% patch coverage. For a genuinely un-coverable
best-effort network/error path, add a focused test or a justified allow — never
leave coverage silently red.
Command architecture
Every interactive command implements one Command trait
(crates/mtui-core/src/command.rs) and is registered into a central registry by an
explicit register_all() (crates/mtui-core/src/registry.rs). The REPL dispatch,
tab-completion, the generated command reference, and the mtui-mcp tool
synthesiser all iterate that one registry — so the surfaces can’t drift.
The trait has just two required methods; everything else has a default:
| Method | Signature | Default |
|---|---|---|
name | fn name(&self) -> &'static str | (required) |
call | async fn call(&self, session: &mut Session, args: &ArgMatches) -> CommandResult | (required) — the per-template command body |
aliases | fn aliases(&self) -> &'static [&'static str] | &[] |
about | fn about(&self) -> Option<&'static str> | None |
scope | fn scope(&self) -> Scope | Scope::Active |
requires_canonical_session | fn requires_canonical_session(&self, argv: &[String]) -> bool | false — answered per invocation, not per command |
skip_hostless_templates | fn skip_hostless_templates(&self) -> bool | true |
configure | fn configure(&self, cmd: clap::Command) -> clap::Command | identity (no args) |
complete | fn complete(&self, session: &Session, text: &str, line: &str) -> Vec<String> | Vec::new() |
The command body is
call(), notrun(). The trait’s providedrun()is the fan-out driver that resolves the target template(s) (per Workflow concepts) and invokescall()once per template — commands normally do not overriderun().
Commands operate on an explicit Session (config, host targets, loaded templates,
display) passed into each call. There are no hidden globals.
How to add a command
-
Implement the trait in
crates/mtui-core/src/commands/<name>.rs. A minimal command (model:commands/whoami.rs):#![allow(unused)] fn main() { use async_trait::async_trait; use clap::ArgMatches; use crate::command::Command; use crate::error::CommandResult; use crate::session::Session; /// One-line summary shown by `help` and in the generated CLI reference. pub struct Foo; #[async_trait] impl Command for Foo { fn name(&self) -> &'static str { "foo" } fn about(&self) -> Option<&'static str> { Some("Does the foo thing.") } async fn call(&self, session: &mut Session, _args: &ArgMatches) -> CommandResult { session.display.println("hello, foo"); Ok(()) } } }For a command with arguments, override
configureto addclap::Args and read them fromargsincall; for completion, overridecomplete; for a non-default fan-out policy, overridescope.commands/switch.rsis a compact model that exercises all three (a required positional,Scope::Single,requires_canonical_session, and a prefix completer). -
Re-export the type from
commands/mod.rs(thepub useblock). -
Register it with one line in
register_all()(crates/mtui-core/src/registry.rs):#![allow(unused)] fn main() { registry.register(Arc::new(commands::Foo)); }It is now a REPL command and an MCP tool automatically.
-
Decide MCP exposure. If the command is REPL-only (needs a TTY) or executes locally, add it to
MCP_DENYLISTinregistry.rs. The deny-list is intersected with the live registry and consistency-tested, so a stale entry is warned about at boot. -
Add tests (see below) with ≥80% patch coverage, and snapshot any new text output.
-
Regenerate the docs:
cargo xtask gen-docspicks the new command up intocli.mdautomatically (the drift-guard test fails until you commit the regenerated file). Add behavioural prose to Workflow concepts only if the command introduces cross-cutting behaviour the per-command help can’t express.
Testing conventions
- Unit tests are colocated in a
#[cfg(test)] mod testsin the same file (seecommands/whoami.rsandcommands/switch.rs). Cover arg parsing,callagainst a mock session, the error path, and completion. - Shared scaffolding lives in
crates/mtui-core/src/commands/mod.rs’stestkitmodule:empty_session(),session_with_hosts(...),session_with_targets(...), aFakeReporttest double, a capturing displayBuffer, andmatches(command, argv)— the helper that runs a command’s own clap parser exactly as the engine does. - Mock, don’t hit the network or real hosts. SSH goes through
MockConnection(crates/mtui-hosts/src/connection/mock.rs, implementing theConnectiontrait); HTTP goes throughwiremock; subprocesses are driven through a command-runner trait or a stub onPATH. Real hosts/containers are gated behind#[ignore]+ a CI env flag. - Snapshot text contracts with
insta(report document goldens, MCP schemas, the lock wire format, display output). Review new snapshots withcargo insta review. - Make sure the test can fail. A green run means something only if red was
reachable. Watch for fixtures that quietly disarm the assertion: a
MockConnectionanswering empty stdout builds no downgrade command, so an “assert no--oldpackage” passes vacuously; awith_changing_boot_id()mock passes a skip-the-reboot test even when only the dispatch was skipped. For a test written to pin a bug, revert the fix, watch it go red, then restore. The same applies to the fix itself — when the leaf you are gating has other callers (Target::rebootis reached bycloseand therebootcommand, not just the fan-out), gate on the layer that owns the state and probe the sibling paths. - A command whose body repoints the active template must be exercised
through
run, notcall.Command::runis what restores the pre-dispatch pointer aftercallreturns; acall-only test cannot observe that restore, so it cannot see a body-level pointer move get reverted.
The one-it.rs-per-crate rule
To keep test-compile time down, each crate’s integration tests are consolidated
into a single binary. A crate using this pattern has, in its Cargo.toml:
autotests = false
[[test]]
name = "it"
path = "tests/it.rs"
and tests/it.rs pulls each test file in as a module:
#![allow(unused)]
fn main() {
#[path = "gitea.rs"]
mod gitea;
}
So add a new integration test as a mod line in tests/it.rs, not as a new
top-level tests/*.rs (a new top-level file would be silently ignored under
autotests = false). Because all the crate’s integration tests then share one
process, anything touching a process-global (env vars, the spinner sink) must be
serialised with #[serial(...)], and tests must not assume per-binary isolation.
insta prefixes each snapshot file with the test-binary name, which is it for
these crates: it__<module>__<name>.snap.
Exception:
mtui-core. It does not use theit.rspattern — its integration tests are per-file and its snapshots are named<module>__<name>.snap(noit__prefix). Add amtui-coreintegration test as a normal top-leveltests/*.rsthere; use theit.rsmodconvention in rootmtui,mtui-hosts,mtui-datasources,mtui-testreport,mtui-tui, andmtui-mcp.
Debugging the MCP server
The MCP server page is the operator reference; this covers iterating on
the server itself (schema synthesis, the deny-list, a new report_* tool).
Start it under HTTP with debug logging:
cargo run -p mtui --bin mtui-mcp -- --transport http --port 8765 --debug
Then drive it with the MCP Inspector without an LLM in the loop:
npx @modelcontextprotocol/inspector
In the UI select Streamable HTTP, set the URL to
http://127.0.0.1:8765/mcp, connect, and use the Tools tab to list every
synthesised tool and call it with hand-crafted arguments. For a stdio smoke-test,
point the Inspector at the binary instead of a URL; it spawns the subprocess and
attaches to its stdio streams.
whoami is the cheapest end-to-end check — no loaded report, no hosts — so a
successful whoami() confirms schema synthesis, the dispatch wrapper, the session
lock, and the response envelope. The report_* tools refuse cleanly with a
“no report document loaded” message until a load_template call has run, which is the
correct refusal path.
Common issues:
- HTTP client gets a 404 at the root — the endpoint is
/mcp, not/. - A new command is missing from the tool surface — check
MCP_DENYLIST; if it isn’t denied, confirm it’s registered inregister_all()and re-exported fromcommands/mod.rs. Run with--debugto see the synthesis loop. - Tool calls time out — network-bound commands run for minutes; either raise
the client’s read timeout or use
background=true(see Background jobs). The server emits progress heartbeats for clients that supply aprogressToken.
Documentation
The book lives under docs/ (mdBook). Two pages are generated — cli.md from
the command registry and invocation.md from the binary parsers — via
cargo xtask gen-docs; a drift-guard test (cargo test -p xtask) fails if the
checked-in copies are stale. Never hand-edit those two files (or the cli.md
preamble, which is emitted by the generator). Every other page is hand-written.
Build the book locally (if mdbook is installed):
mdbook build docs
Issue tracking and commits
Work is tracked in the project’s issue tracker.
Commits follow Conventional Commits (feat/fix/docs/refactor/test/
chore/…), explaining why rather than restating the diff. Reference SUSE
Bugzilla as bsc#NNNN / boo#NNNN where applicable. Only commit when asked;
inspect git status/git diff first and stage only intended files.
FAQ
What is an RRID?
An RRID is the project:kind:maintenance_id:review_id identifier that names a
maintenance request across the SUSE ecosystem — for example
SUSE:Maintenance:12345:678901. It is parsed by splitting on : (empty tokens
dropped, so leading/trailing/doubled colons are ignored) into exactly four
positional components; more than four is rejected, and a missing component is a
parse error. This grammar and its errors are a stable contract with the rest of
the maintenance ecosystem.
Can I set the template directory without passing it every time?
Yes. Set template_dir under [mtui] in your config file, or export
$TEMPLATE_DIR (the built-in default reads it). See
Configuration.
Can I work on several updates at once?
Yes. Load more than one testreport and each is a template in the session:
list_templates shows the loaded set and switch changes the active one. A
command that acts on a template runs against the active one by default
(session-level commands such as list_templates, updates or config take no
template flag); scope it with -T <RRID>/--template <RRID>, or fan out with
--all-templates where the command allows it. Under fan-out each template gets
its own === <RRID> === banner.
Can I run mtui without loading a testreport?
Yes, for the commands that do not need one — notably list_refhosts, which
searches the reference-host inventory offline (no SSH, no lock, no loaded
template). Host-action commands need a loaded template with connected hosts.
Does mtui support SSH password authentication?
No — and it never will. mtui is pubkey-only by design: it authenticates from
your SSH agent or ~/.ssh/id_*. This is preserved from MTUI.
Can several testers use the same reference hosts at once?
Yes, but the locks are advisory and fail-fast, not a queue. Every mtui on the
fleet writes the same timestamp:user:pid[:comment] layout, so sessions see one
another’s claims — a host held by someone else is skipped, and an
install/update aborts with “Hosts locked” rather than waiting. Raise
[lock] wait (default 0) to poll for a busy host during pool arbitration.
There are two locks with that layout: the operation lock
(/var/lock/mtui.lock, PID-based, guards serialized zypper transactions) and the
pool-claim lock (/var/lock/mtui-pool.lock, RRID-based). Stale-lock reaping is
configurable under [lock] — see Configuration.
Where does mtui find the reference-host inventory?
From refhosts.yml, resolved by the ordered [refhosts] resolvers list
(default https,path): the HTTPS database ([refhosts] https_uri, cached with a
https_expiration TTL) and/or a local file ([refhosts] path). The file is
grouped by location on disk, but location is a legacy grouping — rows are merged,
flattened, and de-duplicated at load.
How do I change the editor used by the edit command?
edit spawns your $EDITOR (or $VISUAL) on the controlling terminal, as usual.
Set it in your shell environment. When the loaded report is a JSON document, a bare
edit opens the document editor instead (see the next question); edit FILE always
uses $EDITOR.
How do I fill in a report document by hand?
With a document loaded, edit opens a full-screen form over it, one tab per
section: summary (verdict and comment), people, issues, testing and
review, then update and install, which are read-only. Tab/Shift-Tab
switch sections, Up/Down move, Enter edits a field or opens a group,
Left/Right step an enum or yes/no field, n sets a field to null, Ctrl-S
saves and q/Ctrl-C quits (asking first when there are unsaved changes); ?
lists the keys.
Only the tester’s answers are editable: verdicts, comments, issue
reproducer/status/comment, the reviewer name and the review questions. Fields the
pipeline or export fills in are shown greyed. Every field is checked against the
report schema as you edit it, and Ctrl-S re-checks the whole document; a refusal
leaves the editor open on the offending field.
A save marks the document as edited but not uploaded — commit uploads it, and
unload/regenerate refuse to drop it without --discard-authored. regenerate also
looks at the server’s copy: in the REPL it asks before discarding tester content found
there or in the loaded document, and over MCP it refuses. export keeps the verdicts
and comments you entered under testing.install and testing.regression; it rewrites
only the install checks and the kernel results block inside the regression comment. The
editor needs a terminal on both stdin and stdout.
How do I export results into the testreport?
export authors the collected update data (and, for the openQA-sourced
workflows, openQA data) onto the loaded report document’s testing.* sections and
writes the install logs to the report directory. The document stays local until
commit uploads it. Re-exporting updates the same sections in place.
A Manual export must run from the session that ran update: the before/after
package versions exist only in that process, so from any other session export
refuses rather than write a report with no verdict. --allow-unverified writes
the unverified scaffold anyway.
Where do OBS/Gitea credentials come from?
- OBS/IBS: from your
oscrc, located likeoscitself —$OSC_CONFIG, then$XDG_CONFIG_HOME/osc/oscrc, then~/.oscrc. There is no mtui-side path option; point$OSC_CONFIGat a non-default file. The API to act against is[obs] api_url. - Gitea: the
[gitea] tokenconfig option. It is treated as a secret — masked as<set>inconfigoutput and sent only in anAuthorizationheader, never logged.
Do I still need osc installed?
No. The QAM review workflow (assign/unassign/approve/reject/comment)
talks to the OBS/IBS API natively — no osc subprocess.
Why does assign/approve go to Gitea when I expected OBS (or vice versa)?
mtui decides per update, from the loaded report document, not from the
RRID: an update whose document’s workflow is gitea is Gitea-served, otherwise
it is OBS-served. This matters because the RRID’s shape
cannot answer the question during the SL-Micro 6.0/6.1 cutover — both workflows
share the SLFO:1.1 id space, and an update can briefly be served both
ways at once. When that happens mtui always drives the Gitea workflow and
leaves that update’s OBS review request alone — assign/unassign/
reject/comment/approve never touch it. If an update’s OBS request looks
stuck open, check whether its report document’s workflow is gitea; if so,
that is expected, not a bug.
How do I install packages the update newly introduces?
Feature updates often add packages that only exist in the test-update repository,
so prepare can’t install them yet. Run update --newpackage to install the new
packages right after the update applies.
Why does prepare (or update) refuse to run?
When the loaded report’s metadata names no package versions, prepare and
update refuse with an error rather than reporting prepare completed on <hosts> over a no-op that installs nothing (#396). Check
list_packages -w and the report metadata; a host the fan-out cannot build a
command for (unresolvable release, no matching template) likewise fails the
run by name.
How do I control what prepare installs?
prepare has three switches:
prepare -f/--force— force installation even on package conflicts.prepare -i/--installed— narrow the package list to what each host already carries (skip pulling in additional patchinfo packages). Each host still installs its own list in a single transaction.prepare -u/--update— enable the test-update repositories and install from there.
Independently of those switches, on a report whose metadata carries a
binaries block the list is narrowed per host to what that host’s products
actually compose (base product plus addons). A host whose products compose
none of the list is failed by name rather than sent a list zypper will refuse
with “capability not found” (104); a host no product of which the metadata
names keeps the full list and logs a warning. An architecture a product
declares but the binaries block never mentions at all composes nothing
rather than falling back to the full list — it is treated as known-and-empty,
not unknown, so it gets the same named refusal as any other host whose
products compose none of the requested packages.
update’s own prepare narrows the same way, and a host it refuses is left out
of the patch — no prepare ran on it, so there is no package baseline to patch
— and named in the update’s error instead of being patched anyway. update’s
own operation lock, repository add/remove and pre/post package version check
are scoped to the hosts that actually compose it, so a refused host draws none
of them either: it cannot contend the group’s lock, have its repository
configuration changed, or block a composing peer’s patch behind its own
unresolvable updater.
Can I run a command on only some of the connected hosts?
Yes — temporarily disable the rest with set_host_state, then re-enable:
set_host_state -t hostA -t hostC disabled
run zypper lr # runs only on the still-enabled hosts
set_host_state enabled # re-enable all
disabled hosts run nothing (and print nothing).
Can I export the update log from a specific refhost?
Yes: export -t <host>. By default export writes the collected data for every
host in the list — including disabled ones — so to keep a temporarily-added host
out of the report, remove_host -t <host> before exporting.
Why doesn’t put *.rpm upload anything?
Inside the REPL nothing expands the *, so put looks for a file literally
named *.rpm and errors with File *.rpm not found. That is deliberate, not
a gap (#399): put pushes to
every enabled refhost in the group, which is exactly where an over-broad
pattern does damage, so mtui refuses to guess how wide a pattern was meant to
be. To upload several files, put the directory containing them (it is
walked recursively), or expand the glob in your own shell before invoking
mtui.
Where are the per-host install logs stored?
Under the loaded report’s directory, in
template_dir/<RRID>/install_logs/<host>.log (one file per refhost). The
install_logs sub-directory name is configurable under [mtui]; see
Configuration.
What does Ctrl-C do?
It depends on whether a command is running.
At the prompt, Ctrl-C clears the line you were typing and reprompts. It never
exits mtui — use quit, exit, or Ctrl-D for that, so the session’s hosts are
unlocked and closed on the way out.
While a command is running, the first Ctrl-C asks it to stop at its next checkpoint. Every path releases the hosts’ operation locks on the way out, so nothing is left behind — but how soon the command stops depends on where its checkpoints are:
updatechecks between its steps, and for the last time just before the point of no return: once the patch command has been dispatched the update runs to its end (rolling back on failure) rather than leaving a half-applied update behind.prepare --installedchecks before and after its installed-package probe, and stops without installing anything;downgradeon non-transactional hosts checks between packages. A transactional (SL-Micro)downgradeapplies in one transaction, so it finishes first like the commands below.- A command applying to several loaded templates stops at the next template boundary, and reports how many it got through.
install,uninstall,run, andrebootfinish the host operation already under way first, then stop.
A command that did stop at a checkpoint reports error: cancelled, naming what
it completed first where it can. A command with no checkpoint left to reach
simply finishes and reports normally — the cancel arrived too late to change
anything, and saying “cancelled” would be a lie about work that was in fact done.
Two long waits are their own case: request_review --watch and regenerate
stop watching and tell you so (the review request stays posted, the
regeneration keeps running on the server), and both count as success. Either
way the session stays usable — the next command starts with a clean slate.
A second Ctrl-C force-quits (exit status 130). This is the escape hatch for a host that has stopped responding, and it comes at a cost: the running command is abandoned where it stands, so the operation locks it holds are left behind. Your command history is still saved. mtui warns when this happens; see the next entry for the cleanup.
Ctrl-C during the teardown behaves the same way but cancels nothing: the
teardown is what releases the pool claims and closes the hosts, so it always runs
to completion. A press there warns that it is in progress, and a second one
force-quits — leaving both the operation locks and the pool claims
(unlock --force and unlock --pool). This holds however you asked to leave:
Ctrl-D and a typed quit/exit dispatch the same teardown and get the same
protection.
Two carve-outs. Ctrl-C during the initial load at startup (-a/-k/--sut,
before the first prompt appears) still exits immediately, without teardown. Any
locks or pool claims that partial load had already taken need
unlock --force/unlock --pool, as after any crash.
And in a non-interactive (piped) session — mtui reading commands from a pipe
rather than a terminal — there is no line editor holding the keyboard, so a
Ctrl-C arriving between commands is queued and then discarded when the next
command starts: it interrupts nothing. Interrupt such a session with a signal to
the process (kill -INT) while a command is running, or kill it outright.
How do I remove a dangling lock left by a crashed session?
Reconnect to the same hosts and run unlock -f (force) to remove locks left by
another user or session. mtui also reaps locks older than [lock] stale_age
automatically on connect (see Configuration); unlock -p
removes a pool-claim lock instead of the operation lock.
Which update should I pick up next?
updates lists the queue live from the TeReGen API, sorted by priority. By
default it shows the actionable pickup queue — unassigned updates that are in
testing. Widen or filter it:
updates --review-group qam-sle --limit 5
updates -G qam-sle -G qam-teradata # groups OR together
updates --mine # updates assigned to you
updates --status all # every status and assignee
updates -F Rating -F 'Assigned Roles' -F 'Unassigned Roles' # osc-qam field names
updates --json # the raw TeReGen rows, for scripting
-F accepts the field names osc qam list -F uses — case-insensitively, with
spaces, hyphens and underscores treated as equivalent: ReviewRequestID,
Incident Priority, Rating, Category, Status, Kind, Deadline,
Assignee, Assigned Roles, Unassigned Roles, Title, URL, Products,
SRCRPMs, Bugs, Creator. Fields the
TeReGen queue listing does not carry (Package-Streams, Issues, Comments) are named in the error
rather than silently absent. Unassigned Roles renders n/a when it cannot
be answered honestly: on SLFO rows (TeReGen does not expose review groups on
them) and on any row served without assignment data, where “every group open”
would be indistinguishable from the truth. --json and -F are mutually
exclusive, and Assignee/Assigned Roles are refused under --status all,
where TeReGen omits the assignment data they would render.