Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 mtui and mtui-mcp binary 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-version in Cargo.toml. There is no rust-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. Recommends mtui-mcp, so a plain zypper in mtui still 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)

  1. Tag the release commit so tar_scm’s revision=@PARENT_TAG@ resolves and git describe --tags stamps the version into the binaries. The _service versionrewrite pattern (v?([0-9].*)) tolerates a leading v but does not require one, and mtui’s tags carry none: 26.4.1 becomes Version: 26.4.1. The format is XX.Y.Z — year-based line, major, patch. A v-prefixed tag is invalid and does not trigger the release workflow.

    git tag 26.4.1
    git push origin 26.4.1
    
  2. 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 .
    
  3. 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
    
  4. 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.

mtui is REPL-only. There is no positional single-command mode: mtui always starts the interactive shell (optionally seeding it with -a/-k/-s). Headless, one-shot command dispatch is served by mtui-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:

  1. --config <file> — an explicit path. Short-circuits the search.

  2. $MTUI_CONF — an explicit path in the environment. Short-circuits the search.

  3. 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 /etc on 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]

KeyTypeDefaultMeaning
template_dirpath$TEMPLATE_DIR or .Directory holding each loaded report’s working directory (install logs, result files).
userstringcurrent login userUser attributed to this session (locks, logs).
install_logsrelative dir nameinstall_logsSub-directory (single relative name, no separators) where install logs are written per update.
ssl_verifybool / stringtrueTLS verification for outbound HTTP: a boolean, a boolean spelling (yes/no/on/off/…), or a path to a custom CA bundle.

[connection]

KeyTypeDefaultMeaning
connection_timeoutseconds (>0)300SSH 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_timeoutseconds (>0)60SSH 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_timeoutseconds (>0)10Backoff base for reconnecting after a reboot (prepare/reboot/update). Sleeps grow as 2*(reboot_timeout + 5*count) across attempts.
reboot_retriesint (>0)10Number 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_parallelint (>0)50Max hosts to fan out to concurrently (SSH/SFTP/lock/connect batches).
max_oqa_parallelint (>0)8Max concurrent openQA/QAM HTTP requests in the overview search (kept low to be polite to shared hosts).
ssh_strict_host_key_checkingstringauto_addSSH 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_retries only apply to the post-reboot reconnect budget. A dead SSH link mid-run/shell, or the sftp pre-op check, still fails on the first probe — those are not recovering from a reboot.

[refhosts]

KeyTypeDefaultMeaning
resolversstringhttps,pathComma-separated ordered list of refhosts resolvers.
https_uriURLhttps://qam.suse.de/refhosts/refhosts.ymlHTTPS URI of the refhosts database.
https_expirationseconds (>0)43200 (12h)Age before a cached HTTPS fetch is considered stale.
pathpath~/.local/share/refdb/refhosts.ymlLocal filesystem refhosts database (also used as the HTTPS resolver’s on-disk mirror; shared with the refdb tooling).

[url]

KeyTypeDefaultMeaning
bugzillaURLhttps://bugzilla.suse.comBugzilla base URL.
testreportsURLhttps://qam.suse.de/testreportsTestreports base URL.
fancy_reportsURLhttps://qam.suse.de/reports“Fancy” reports base URL.

[target]

KeyTypeDefaultMeaning
tempdirpath/tmpRemote scratch directory on target hosts.

[qem_dashboard]

KeyTypeDefaultMeaning
apiURLhttp://dashboard.qam.suse.de/apiQEM Dashboard API base URL.

[teregen]

KeyTypeDefaultMeaning
apiURLhttps://qam.suse.de/api/v1TeReGen report/queue API base URL.
api_v2URLhttps://qam.suse.de/api/v2TeReGen v2 report/queue API base URL (the JSON-document read path).

[openqa]

KeyTypeDefaultMeaning
openqaURLhttps://openqa.suse.deopenQA instance URL.
baremetalURLhttp://openqa.qam.suse.czBaremetal openQA instance URL.
distristringsleopenQA 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:

  1. $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 of client.conf.
  2. 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/openqa when $XDG_CONFIG_HOME is unset), else /etc/openqa and /usr/etc/openqa. The first tier that yields any file wins outright; later tiers are not read at all — a ~/.config/openqa/client.conf does not merge with sections that exist only in /etc/openqa/client.conf; it replaces it. Within the winning tier, each directory contributes its client.conf (if present) followed by its sorted client.conf.d/*.conf drop-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]

KeyTypeDefaultMeaning
tokenstring (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.
urlURLhttps://src.suse.deTrusted 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:

  1. request_review recorded a marker for the update (and committed it, so a reviewer approving from a different checkout sees it);
  2. the marked Slack message still names that RRID — a marker copied between templates cannot launder an approval;
  3. 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.

KeyTypeDefaultMeaning
enabledboolfalseWhether 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.
tokenstring (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.
channelstring(empty)Channel review requests are posted to — an ID (C0123456789) or a #name. Overridable per call with request_review --channel.
api_urlURLhttps://slack.com/apiSlack 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_intervalint (seconds)120How 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_timeoutint (seconds)3600How 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.

KeyTypeDefaultMeaning
reap_stalebooltrueOn connect, force-remove a pre-existing operation lock older than stale_age, regardless of owner.
stale_ageseconds86400Age beyond which an operation lock is stale and reapable. 0 disables reaping.
pool_reap_stalebooltrueOn 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_ageseconds86400Age beyond which a pool claim is stale and reapable. 0 disables pool-claim reaping.
pi_autolockbooltrueWhen testing a Product Increment (PI), lock each refhost as it connects while the report is loaded, released on unload/quit.
waitseconds0Pool-claim queueing budget. 0 fails fast on a busy host.
wait_pollseconds (>0)15Poll interval while waiting for a busy pool lock to free.

[mcp]

mtui-mcp server behaviour. See MCP server.

KeyTypeDefaultMeaning
max_output_bytesbytes100000Upper bound on a single tool result (truncated at the tail with a notice). 0 disables the cap.
max_input_bytesbytes10000000Per-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_bytesbytes10000000Upper 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_jobsint16Ceiling on concurrent background jobs per session. 0 disables.
max_completed_jobsint128Ceiling on retained terminal job records per session (FIFO eviction). 0 disables.
session_capint (>0)32Ceiling on concurrent per-client sessions under --transport http.
session_idle_timeoutseconds (>0)14400Inactivity 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_parallelint (>0)4Max stale sessions the idle sweeper tears down concurrently per cycle.
profilestringfullTool-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_allowarray of strings(empty)Extra tool names to keep on top of the profile.
tools_denyarray 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_logpath(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_bytesbytes268435456Size 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 with audit_log unset is OTLP-only; neither is auditing off. See MCP server.

The profile key is profile, not tool_profile — it already sits under the tool-scoped [mcp] table. tools_allow/tools_deny are 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.

KeyTypeDefaultMeaning
api_urlURLhttps://api.suse.deOBS/IBS API the review backend acts against. Must equal a section header in your oscrc.
request_timeoutseconds (>0)180Coarse 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_token is masked as <set> by both config show and config 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 to zypper unchanged, 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 that showlog and export print — masks the userinfo to ***, including a URL a remote tool echoes back in its output. A password that appears without a scheme://…@ 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_host name 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 with lock; 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 by run, update, install or prepare on the host; only unlock removes it. Templates loaded in the same process, and the sessions of one mtui-mcp server, hold the operation lock against each other like separate users: a second template’s run or update waits 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/--pool flag on list_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/--port bind 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 get and put, 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-written get/put are ordinary tools — present in full, restorable under core via tools_allow, removable via tools_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 -T flag). 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.

switch is not an MCP tool (moving the active-template pointer is REPL-only navigation): over MCP you target a template per call with template=. load_template and unload <rrid> are exposed — each names its own RRID. list_templates is 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:

  • approve refuses non-interactively on a Gitea checkout-hash mismatch (at the REPL it prompts for confirmation, default no) unless accept_head names 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 before reviewer records 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-continue argument; 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 touch approve’s refusal above — and commit, export, reject --reviewer and request_review refuse the same way, unless given their own --allow-stale, so a stale-loaded report cannot be published or overwritten non-interactively either. approve’s check_hash() re-query is live (asks Gitea again, every call) and replaces that load-time verdict for approve -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 while mtui-mcp still holds it loaded) passes them silently — commit is 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.
  • comment and commit take their text/message as a required argument (no editor/stdin prompt), and regenerate gates overwrite on the --force flag 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 prepare tool 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 on prepare’s success does not see a false positive on a metadata-empty report.
  • On a report whose metadata carries a binaries block, prepare narrows 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. update excludes 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 --newpackage step, 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 a tracing::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 survives max_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_host without -t follows added <hosts> (or no 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 returns Ok in that case, so these lines are the signal to check.
  • export emits WARNING: 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.
  • run prints its own verdict above its per-host output, for the same head-survives-max_output_bytes reason: run completed on h1 (exit 0), h2 (exit 1) names every host it ran on with its exit code, followed by FAILED on h2 (exit 1) when any host exited non-zero. run returns Ok on 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 run tool’s command is 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": [...] }. size is the byte length of the section’s compact JSON (advisory). unfilled holds the RFC 6901 pointer of every null leaf in the whole document, in sorted key order; complete is true when there is none. dirty is true while edits await commit.

report_section_read (read-only)

  • Parameters (required): section. Plus optional template.
  • 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, testing or review), value (any JSON — null is a legitimate value for an unanswered verdict or comment). Plus optional template.
  • update and install are pipeline-owned and refused. A section the report lacks refuses. An issues write must keep exactly the same issue keys; use report_issue_write for 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 null on 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 at commit.
  • A write changes local state only and marks the report dirty; commit uploads it. A later export overwrites testing.install, testing.openqa and people.testers.
  • Returns { "id", "section", "dirty": true, "size" }.

report_issue_read (read-only)

  • Parameters (all optional): issue_id (such as bsc#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 optional template.
  • 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. files are the local regular files, sorted by path (report-directory-relative; subdirectories and symlinks are skipped). server is { "origin", "size", "at" } for the server’s file of that name (origin is pipeline or uploaded), or null when it has none; sizes differ until commit uploads. server_only lists 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_error set.

report_file_read (read-only)

Reads one of those files as text.

  • Parameters (required): path, relative to the report directory — either checkers.log or a file directly inside build_checks/, the install_logs directory or results/. Optional: offset (1-based first line, default 1), limit (maximum lines), template.
  • Returns { "id", "path", "total_lines", "content" }; a windowed read also carries offset and returned_lines. content is bounded by [mcp] max_output_bytes, and the read stops at [mcp] max_input_bytes (0 disables the cap), cutting a line that crosses it. A capped read ends with a notice, and total_lines then 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_seconds blocks 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 with wait_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 foreground run.) Two commands treat a cancel as a normal ending rather than a failure: request_review --watch stops watching (the request stays posted) and regenerate stops waiting (the server keeps building). Both return success, so their job ends done, not cancelled, 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) or OTEL_EXPORTER_OTLP_LOGS_ENDPOINT (full URL, wins verbatim). Unset-or-empty disables export, as does an invalid endpoint or a non-http/protobuf protocol (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: only http/protobuf or unset.
  • OTEL_SERVICE_NAME (default mtui): resource service.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.

CrateJob
mtui-typesDomain 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-configTOML config + XDG path resolution.
mtui-hostsSSH/SFTP (russh), the Target/HostsGroup model, locks, the pool arbiter. Async.
mtui-datasourcesShared HTTP; refhosts resolve/search/verify; the openQA/QEM/Gitea/OBS/oqa-search clients. Async.
mtui-testreportTestreport lifecycle (TeReGen v2 document ingest/upload) and the update workflow (actions/checks/export).
mtui-coreThe 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-tuiThe 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-cliThe reedline REPL library, including the terminal handover around the mtui-tui editor.
mtui-mcpThe 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-mcp tool synthesiser, which turns each non-denied command’s clap arg 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_id and its parse errors (see the FAQ).
  • refhosts.yml schema — 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 BUILD query 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: type mirrors qem-dashboard’s incidents.type column (git/smelt, mtui_types::UpdateSource); number is 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); package is chosen by qem-bot’s sort_packages ordering (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 osc subprocess.
  • 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.1 id 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::UpdateSource is a selection, not an observation: Git when the report document’s workflow is gitea, Obs otherwise, resolved once at load. On a dual-served update Git wins by design, and mtui leaves that update’s OBS review request untouched — assign/approve/reject/comment only ever reach the Gitea side. See approve for 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.

TaskCommand
Build everythingcargo build --workspace (produces both mtui and mtui-mcp)
Run the REPLcargo run -p mtui -- --help
Run the MCP servercargo run -p mtui --bin mtui-mcp -- --help
Formatcargo 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
Testcargo test --workspace
Coveragecargo llvm-cov --workspace --lcov --output-path lcov.info

Repo automation lives in the xtask crate, invoked through the alias in .cargo/config.toml:

TaskCommandEffect
Regenerate packaging artifactscargo xtask gendist/completions/{bash,zsh,fish}/… + dist/man/*.1
Regenerate generated docscargo xtask gen-docsdocs/src/cli.md + docs/src/invocation.md
Build a release tarballcargo xtask package --version <VER> --target <TRIPLE>dist/release/…tar.gz
Probe the teregen v1↔v2 corpuscargo xtask corpus-surveyRead-only 200/404/503/other split by RRID kind
Check for teregen schema driftcargo xtask schema-checkDiffs the live /api/v2/schema against the committed copy
Mint/reuse a teregen v2 auth tokencargo 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 clientcargo 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 --tests is ~80 s; the test run is ~20-25 s. Give the first cargo test --workspace of 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-features took 36 s versus 28 s with default features; it adds only notify-rust and 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; reserve cargo test --workspace for the final gate.
  • mtui-cli’s lib suite is the slow one (its edit/shell tests 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:

MethodSignatureDefault
namefn name(&self) -> &'static str(required)
callasync fn call(&self, session: &mut Session, args: &ArgMatches) -> CommandResult(required) — the per-template command body
aliasesfn aliases(&self) -> &'static [&'static str]&[]
aboutfn about(&self) -> Option<&'static str>None
scopefn scope(&self) -> ScopeScope::Active
requires_canonical_sessionfn requires_canonical_session(&self, argv: &[String]) -> boolfalse — answered per invocation, not per command
skip_hostless_templatesfn skip_hostless_templates(&self) -> booltrue
configurefn configure(&self, cmd: clap::Command) -> clap::Commandidentity (no args)
completefn complete(&self, session: &Session, text: &str, line: &str) -> Vec<String>Vec::new()

The command body is call(), not run(). The trait’s provided run() is the fan-out driver that resolves the target template(s) (per Workflow concepts) and invokes call() once per template — commands normally do not override run().

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

  1. 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 configure to add clap::Args and read them from args in call; for completion, override complete; for a non-default fan-out policy, override scope. commands/switch.rs is a compact model that exercises all three (a required positional, Scope::Single, requires_canonical_session, and a prefix completer).

  2. Re-export the type from commands/mod.rs (the pub use block).

  3. 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.

  4. Decide MCP exposure. If the command is REPL-only (needs a TTY) or executes locally, add it to MCP_DENYLIST in registry.rs. The deny-list is intersected with the live registry and consistency-tested, so a stale entry is warned about at boot.

  5. Add tests (see below) with ≥80% patch coverage, and snapshot any new text output.

  6. Regenerate the docs: cargo xtask gen-docs picks the new command up into cli.md automatically (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 tests in the same file (see commands/whoami.rs and commands/switch.rs). Cover arg parsing, call against a mock session, the error path, and completion.
  • Shared scaffolding lives in crates/mtui-core/src/commands/mod.rs’s testkit module: empty_session(), session_with_hosts(...), session_with_targets(...), a FakeReport test double, a capturing display Buffer, and matches(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 the Connection trait); HTTP goes through wiremock; subprocesses are driven through a command-runner trait or a stub on PATH. 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 with cargo 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 MockConnection answering empty stdout builds no downgrade command, so an “assert no --oldpackage” passes vacuously; a with_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::reboot is reached by close and the reboot command, 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, not call. Command::run is what restores the pre-dispatch pointer after call returns; a call-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 the it.rs pattern — its integration tests are per-file and its snapshots are named <module>__<name>.snap (no it__ prefix). Add a mtui-core integration test as a normal top-level tests/*.rs there; use the it.rs mod convention in root mtui, mtui-hosts, mtui-datasources, mtui-testreport, mtui-tui, and mtui-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 in register_all() and re-exported from commands/mod.rs. Run with --debug to 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 a progressToken.

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 like osc itself — $OSC_CONFIG, then $XDG_CONFIG_HOME/osc/oscrc, then ~/.oscrc. There is no mtui-side path option; point $OSC_CONFIG at a non-default file. The API to act against is [obs] api_url.
  • Gitea: the [gitea] token config option. It is treated as a secret — masked as <set> in config output and sent only in an Authorization header, 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:

  • update checks 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 --installed checks before and after its installed-package probe, and stops without installing anything; downgrade on non-transactional hosts checks between packages. A transactional (SL-Micro) downgrade applies 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, and reboot finish 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.