An async openQA REST API client, built on
reqwest. It provides HMAC-SHA1 request signing,
client.conf discovery, and a YAML-response fallback, aimed at wire
compatibility with the openQA server.
use ruoqa::ClientBuilder;
# async fn run() -> ruoqa::Result<()> {
let client = ClientBuilder::new()
.server("openqa.opensuse.org")
.build()?;
let jobs = client
.request(reqwest::Method::GET, "/api/v1/jobs?limit=10", None)
.await?;
println!("{jobs}");
# Ok(())
# }Responses are classified by content type, not assumed to be JSON: a JSON media
type (application/json, or an application/…+json suffix) is decoded as
JSON, a YAML media type (text/yaml, application/yaml, application/x-yaml,
text/x-yaml) is decoded with a budget-limited YAML parser (rejecting alias
bombs rather than exhausting memory), a 204 No Content or any other 2xx with
an empty body becomes Value::Null, and everything else — including openQA's
ok/ack/OK text routes such as GET /api/v1/auth and the mutex/barrier
lock routes, served as text/html — arrives as a JSON string instead of an
error. Non-2xx responses become Error::Request; use Client::send_raw to
bypass parsing and the response-size cap (e.g. for asset downloads),
Client::request_as to deserialize into your own type instead of a generic
serde_json::Value, and Client::request_typed to get an ApiResponse
back when a JSON string and a text body must be told apart.
openQA's isos endpoint, the main way to schedule jobs, expects
application/x-www-form-urlencoded rather than JSON — use
Client::request_form for that:
use ruoqa::ClientBuilder;
# async fn run() -> ruoqa::Result<()> {
let client = ClientBuilder::new()
.server("openqa.opensuse.org")
.build()?;
let scheduled = client
.request_form(
reqwest::Method::POST,
"/api/v1/isos",
&[("DISTRI", "opensuse"), ("VERSION", "Tumbleweed")],
)
.await?;
println!("{scheduled}");
# Ok(())
# }Credentials are read from INI-style client.conf files, searched in three
tiers, in order:
$OPENQA_CONFIG(only when set and non-empty)$XDG_CONFIG_HOME/openqa(only when$XDG_CONFIG_HOMEis set and absolute), else~/.config/openqa/etc/openqa, then/usr/etc/openqa
The first tier that has any file wins outright — a user client.conf
now replaces /etc/openqa/client.conf instead of merging with it, and later
tiers are not read at all. Within a tier, each directory contributes its
client.conf (if present) followed by its client.conf.d/*.conf drop-ins,
sorted by name, later files winning; a directory with only drop-ins does not
stop the scan of the tier's remaining directories.
Two deliberate divergences from upstream: $XDG_CONFIG_HOME is a ruoqa
extension inside tier 2 (upstream hardcodes ~/.config/openqa), and a
client.conf that fails to parse is always Error::Config rather than
being silently skipped.
ClientBuilder::config_paths overrides this whole search with an explicit
path list, e.g. to point at a fixture in tests; the list is read in plain
order with no tiering, and an empty list skips reading client.conf
entirely.
Each section is keyed by the server host (or full base URL) and provides the
API key/secret:
[openqa.opensuse.org]
key = YOUR_API_KEY
secret = YOUR_API_SECRETThe lookup tries the bare server section first, then the full base URL
section; both key and secret must be present in a section for it to
count. When present, requests are HMAC-SHA1 signed and the X-API-Key
header is sent. Without credentials only unauthenticated GET requests are
possible.
Credentials are resolved in three tiers: explicit
ClientBuilder::api_key/api_secret calls, then
$OPENQA_API_KEY/$OPENQA_API_SECRET, then client.conf. The first tier
that supplies a complete key+secret pair wins outright; sources are
never mixed, and a half-set pair from any tier (e.g. only api_key, or only
$OPENQA_API_KEY) is a ClientBuilder::build error. Empty environment
values count as unset. This is a deliberate divergence from upstream's
OpenQA::UserAgent, which resolves the key and the secret independently and
so permits mismatched pairs.
Scheme defaulting: the scheme defaults to https, except for loopback
hosts (localhost, 127.0.0.1, ::1, and any of these with a port, e.g.
localhost:9526), which default to http. You can also pass a
fully-qualified server such as http://openqa.internal.
TlsMode controls certificate verification:
TlsMode::PlatformVerifier(default) — uses the OS trust store, so enterprise/internal CAs installed system-wide just work.TlsMode::CustomCa { certs, replace_roots }— trusts a specific CA bundle.replace_roots: truepins to only that CA, discarding the platform roots;falsemerges it with them.TlsMode::danger_accept_invalid_certs()— disables certificate verification entirely.
Security warning:
TlsMode::danger_accept_invalid_certs()disables TLS certificate verification and exposes the connection to man-in-the-middle attacks. Use it only against trusted instances on trusted networks; preferTlsMode::CustomCawith the internal CA bundle instead. Building a client with this mode logs atracing::warn!.
A warning is also logged via tracing::warn! if credentials would be sent
over plaintext http to a non-loopback host.
ClientBuilder::http_client takes a pre-built reqwest::Client instead of
letting ruoqa construct one, e.g. to share a connection pool or proxy
configuration with the rest of your application. Accept: application/json,
X-API-Key, and User-Agent are still injected by ruoqa on every
outgoing request.
use ruoqa::ClientBuilder;
# async fn run() -> ruoqa::Result<()> {
let http_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.build()
.expect("reqwest::Client should build");
let client = ClientBuilder::new()
.server("openqa.opensuse.org")
.http_client(http_client)
.build()?;
# let _ = client;
# Ok(())
# }The injected client must disable reqwest's own redirects and retries:
redirect::Policy::none()—ruoqafollows redirects itself and refuses cross-origin hops; reqwest does not strip customX-API-*headers on a cross-origin redirect, so leaving reqwest's redirect policy on would leak credentials off-origin.retry::never()—ruoqare-signs every attempt; a reqwest-level retry replays a stale signature (the server's tolerance is 300 s) and can duplicate non-idempotent writes.
ClientBuilder::tls/ClientBuilder::timeouts are the caller's
responsibility on an injected client, so calling either alongside
http_client is a build() error.
Timeouts |
Value |
|---|---|
connect |
10 s |
read (per-read inactivity) |
30 s |
total (whole request, incl. body) |
60 s |
pool_idle |
90 s |
RetryPolicy |
Value |
|---|---|
max_retries |
4 |
initial_backoff |
500 ms |
multiplier |
2.0 |
max_backoff |
30 s |
deadline |
120 s |
honor_retry_after |
true |
max_retry_after |
60 s |
retry_statuses |
408, 413, 429, 444, 500, 502, 503, 504, 509, 521, 522, 599 |
idempotent_methods |
GET, HEAD, OPTIONS, PUT, DELETE (not POST) |
retry_non_idempotent |
false |
multiplier must be finite and >= 1.0; ClientBuilder::build rejects
anything else. Backoff is exponential with full jitter (uniform(0, backoff)). Call
RetryPolicy::upstream_compat for the openQA-python-client's numbers
(5 retries, 10 s initial backoff, 60 s cap, no deadline); the jitter, method
restriction, and Retry-After handling are ruoqa's own hardening, not
that client's. deadline is a budget for the
whole Client::execute call — every attempt, every backoff, and every
redirect hop — and a request still in flight when it expires is aborted with
Error::DeadlineExceeded; the response body is then read under
Timeouts, not the deadline. A retryable status is only replayed for a
method in idempotent_methods, unless the server signalled backpressure
(429/503 with Retry-After) or retry_non_idempotent is set; the same
rule governs transport errors and statuses alike.
-
Async only. No synchronous/blocking facade; bring your own
tokio::runtime::Handle::block_onif you need one. -
Accept: application/jsonon every request — openQA's text routes (ok/ack/OK) ignore it and answertext/htmlregardless. -
Restricted, same-origin redirects (default cap of 3 hops, configurable via
ClientBuilder::max_redirects); a cross-origin redirect is an error rather than silently droppingX-API-Key/X-API-Hashor (worse) forwarding them off-origin. Redirect method/body handling follows Mojolicious (openQA's own client):301/302/303turn aPOSTinto a bodylessGET, and only307/308replay the original method and body. -
Sub-path deployments (
servergiven as e.g.openqa.example.com/openqa) are supported:base_urlkeeps the path and gains a trailing slash. A leading/on a request path means "relative to the base URL", not "origin root" —/api/v1/jobsandapi/v1/jobsresolve identically, both landing inside the configured prefix.client.confsections stay keyed by host ([openqa.example.com]), matching upstream; a section named after the path is never matched. Note the upstream caveat: the openQA server signsglobal.base_url's path plus the request it receives, so a sub-path deployment only authenticates correctly ifbase_urlmatches what the reverse proxy actually strips. -
Request paths must be relative and stay within the base URL's path, and every request URL — including one in a caller-built [
PreparedRequest] — is checked againstbase_url's origin and path before signing, so untrusted input in a path can never send credentials to another origin or escape a sub-path prefix. -
No typed openQA response models — responses are
serde_json::Value(or your own type viaClient::request_as), classified generically viaApiResponse. -
No CLI binary.
-
No worker protocol.
ruoqais an external REST client. The worker-side surface is deliberately out of scope: multipart artefact upload (POST /api/v1/jobs/:id/artefact— the only file-upload route in the API, and one that answers404 No worker assignedunless a worker is currently running the job), its chunked-asset variant, theX-API-JobTokenroutes (whoami,mutex,barrier,mm, whichos-autoinsttest modules use to synchronise during a job run), and the worker WebSocket (/api/v1/ws/:workerid). -
…but nothing is walled off. [
PreparedRequest]'s fields are public andClient::executetakes one directly, so a caller holding a job token — or hand-assembling a multipart body — can reach those routes without a helper:use reqwest::Method; use reqwest::header::{HeaderName, HeaderValue}; use ruoqa::ClientBuilder; # async fn run() -> ruoqa::Result<()> { let client = ClientBuilder::new().server("openqa.example.com").build()?; let mut prepared = client.prepare_form(Method::POST, "/api/v1/mutex/my_lock", &[("action", "lock")])?; prepared.headers.insert( HeaderName::from_static("x-api-jobtoken"), HeaderValue::from_static("the-job-token"), ); let resp = client.execute(&prepared, false).await?; println!("{}", resp.status()); # Ok(()) # }
The request is still origin- and prefix-checked in
execute, and still HMAC-signed — harmless on a job-token route, which ignores the extra headers. -
Response bodies are capped (32 MiB by default, configurable via
ClientBuilder::max_response_bytes) unless read viaClient::send_raw. -
URLs are userinfo-redacted wherever they appear in errors or logs.
-
Error::DeadlineExceededdoes not mean the server did not act on the request — an aborted in-flight write may already have been committed. -
A
POSTanswered with 500/502/504 (or a bare 503) is surfaced, not replayed — openQA's write routes are not idempotent and the write may already have committed.
While ruoqa is at 0.1.x, the minor version is the breaking bump:
0.1.x → 0.2.0 for anything the Cargo SemVer
reference calls a
major change (removing a public item, adding a variant to a non-#[non_exhaustive]
enum, adding a non-defaulted trait item, …). Patch releases (0.1.x) are
additive/fixes only. Error, JobState, JobResult, and ModuleResult are
marked #[non_exhaustive] so the server adding new states or job results
doesn't force a breaking release.
GPL-3.0-or-later. See COPYING.