Skip to content

Commit c3f9938

Browse files
authored
Merge pull request openSUSE#322 from plusky/feat/osc-native-phase0
feat(obs)!: replace the osc qam plugin with a native direct-OBS-API backend
2 parents bdb6d6b + e9590ef commit c3f9938

25 files changed

Lines changed: 2546 additions & 468 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
99

1010
### Added
1111

12+
- The QAM review actions (approve/assign/unassign/reject/comment) now call the
13+
OBS/IBS API directly instead of shelling out to the external `osc qam`
14+
plugin — no `osc` library and no subprocess. Credentials are read from the
15+
user's existing `~/.oscrc` (SSH-signature auth, reproduced in-process with
16+
paramiko), so the `osc-plugin-qam` dependency is no longer required. Two
17+
behaviour notes: OBS TLS is now governed by `[mtui] ssl_verify` rather than
18+
oscrc's own TLS knobs (`sslcertck`/`cafile`/`capath`/`no_verify`); and the
19+
new `[obs] request_timeout` (default 180s) is a coarse between-call budget
20+
layered over the per-call HTTP timeout, not a hard mid-call kill. The review
21+
key must be an Ed25519 private key on disk; ssh-agent, encrypted, and
22+
RSA/ECDSA keys are not yet supported. A new `[obs]` config section
23+
(`api_url`, `conffile`, `request_timeout`) is introduced.
24+
1225
- `mtui-mcp` is now much more token-efficient. Tool schemas are automatically
1326
slimmed of redundant pydantic boilerplate (the per-field `title` keys and the
1427
`anyOf: [T, null]` optional-field unions), shrinking the tool-list payload

mtui/data_sources/obs/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Native OBS/IBS QAM review backend.
2+
3+
Replaces the shell-out to the external ``osc qam`` plugin with direct
4+
OBS API calls over :mod:`requests` (mirroring :mod:`mtui.data_sources.gitea`)
5+
and native OBS "Signature" (SSH) authentication reproduced with paramiko.
6+
No module here imports the ``osc`` library or shells out to ``osc``.
7+
8+
Credentials come from the user's existing ``~/.oscrc`` (parsed natively by
9+
:mod:`~mtui.data_sources.obs.oscrc`) — oscrc stays the single source of
10+
truth for OBS credentials; mtui reads it and authenticates itself.
11+
12+
This first milestone lands the credential-reader foundation; the wire-format
13+
signer, the auth handshake, the HTTP client, and the operation logic land in
14+
subsequent milestones behind the ``[obs] backend`` flag (default ``plugin``).
15+
"""
16+
17+
from __future__ import annotations

mtui/data_sources/obs/auth.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""OBS "Signature" (SSH) authentication as a ``requests`` auth handler.
2+
3+
Reproduces the OBS Signature challenge/response in-process (no ``osc``, no
4+
subprocess): the first request goes out unauthenticated (the session may
5+
already hold a cookie); on a 401 the ``WWW-Authenticate: Signature`` realm
6+
is read, an SSHSIG over ``(created): <epoch>`` is built with paramiko, and
7+
the request is resent exactly once with the ``Authorization: Signature``
8+
header. Day-one scope is an Ed25519 key from an explicit file; encrypted,
9+
agent, and non-Ed25519 keys fail closed with a typed :class:`ObsConfigError`.
10+
The Authorization header and its signature are never logged.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import time
16+
from logging import getLogger
17+
from pathlib import Path
18+
from typing import Any, override
19+
from urllib.request import parse_http_list, parse_keqv_list
20+
21+
import paramiko
22+
import requests
23+
import requests.auth
24+
25+
from ...support.exceptions import ObsConfigError
26+
from . import sshsig
27+
28+
logger = getLogger("mtui.data_sources.obs.auth")
29+
30+
_SIGNATURE_SCHEME = "signature"
31+
32+
33+
def _challenge_params(response: requests.Response) -> dict[str, dict[str, str]]:
34+
"""Parse ``WWW-Authenticate`` into ``{scheme: {param: value}}``.
35+
36+
Reads the RAW multi-value header list from urllib3 rather than
37+
``response.headers['WWW-Authenticate']`` — ``requests`` comma-merges
38+
duplicate headers into a single unparseable string, which would hide a
39+
second challenge (e.g. ``Basic`` alongside ``Signature``).
40+
"""
41+
raw = getattr(response.raw, "headers", None)
42+
if raw is not None and hasattr(raw, "getlist"):
43+
lines = raw.getlist("WWW-Authenticate")
44+
else: # pragma: no cover - requests always exposes urllib3 raw headers
45+
merged = response.headers.get("WWW-Authenticate", "")
46+
lines = [merged] if merged else []
47+
48+
schemes: dict[str, dict[str, str]] = {}
49+
for line in lines:
50+
line = line.strip()
51+
if not line:
52+
continue
53+
scheme, _, rest = line.partition(" ")
54+
params: dict[str, str] = {}
55+
if rest.strip():
56+
try:
57+
params = parse_keqv_list(parse_http_list(rest))
58+
except ValueError:
59+
params = {}
60+
schemes[scheme.lower()] = params
61+
return schemes
62+
63+
64+
class ObsSignatureAuth(requests.auth.AuthBase):
65+
"""A ``requests`` auth handler for OBS SSH-signature authentication."""
66+
67+
def __init__(self, user: str, sshkey_path: Path) -> None:
68+
"""Initialise with the acting user and its Ed25519 private-key path."""
69+
self.user = user
70+
self.sshkey_path = sshkey_path
71+
self._retried = False
72+
73+
def _load_key(self) -> paramiko.Ed25519Key:
74+
"""Load the Ed25519 key, failing closed (never prompting)."""
75+
try:
76+
return paramiko.Ed25519Key.from_private_key_file(str(self.sshkey_path))
77+
except paramiko.PasswordRequiredException as e:
78+
raise ObsConfigError(
79+
f"ssh key {self.sshkey_path} is passphrase-protected; the native "
80+
"OBS backend needs an unencrypted key or an ssh-agent (agent "
81+
"support is not yet available)"
82+
) from e
83+
except paramiko.SSHException as e:
84+
raise ObsConfigError(
85+
f"ssh key {self.sshkey_path} is not a usable Ed25519 key "
86+
f"({e}); only Ed25519 keys are supported by the native OBS "
87+
"backend today"
88+
) from e
89+
except OSError as e:
90+
raise ObsConfigError(
91+
f"ssh key {self.sshkey_path} could not be read: {e}"
92+
) from e
93+
94+
def _authorization(self, realm: str) -> str:
95+
"""Build the ``Authorization: Signature …`` header value for ``realm``."""
96+
created = int(time.time())
97+
blob = sshsig.sign_created(self._load_key(), realm, created)
98+
return (
99+
f'Signature keyId="{self.user}",algorithm="ssh",'
100+
f'headers="(created)",created={created},signature="{blob}"'
101+
)
102+
103+
def handle_401(
104+
self, response: requests.Response, **kwargs: Any
105+
) -> requests.Response:
106+
"""On a 401 Signature challenge, sign and resend the request once."""
107+
if response.status_code != 401 or self._retried:
108+
return response
109+
110+
schemes = _challenge_params(response)
111+
if _SIGNATURE_SCHEME not in schemes:
112+
logger.error(
113+
"OBS returned 401 but did not offer Signature auth (offered: %s)",
114+
", ".join(sorted(schemes)) or "nothing",
115+
)
116+
return response
117+
118+
self._retried = True
119+
realm = schemes[_SIGNATURE_SCHEME].get("realm", "")
120+
121+
# Consume and release the challenge response before resending.
122+
_ = response.content
123+
response.close()
124+
125+
prepared = response.request.copy()
126+
prepared.headers["Authorization"] = self._authorization(realm)
127+
128+
retried = response.connection.send(prepared, **kwargs)
129+
retried.history.append(response)
130+
retried.request = prepared
131+
return retried
132+
133+
@override
134+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
135+
"""Attach the 401 handler; the first request carries no Authorization."""
136+
self._retried = False
137+
r.register_hook("response", self.handle_401)
138+
return r

mtui/data_sources/obs/client.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""HTTP client for the OBS/IBS API (native ``requests``, no osc).
2+
3+
Mirrors :mod:`mtui.data_sources.gitea`'s request wrapper: one
4+
:class:`requests.Session` built via :mod:`mtui.support.http`, with
5+
:class:`~mtui.data_sources.obs.auth.ObsSignatureAuth` driving SSH-signature
6+
auth and the IBS session cookie reused in-memory across the handful of calls
7+
one operation makes. Every hop is bounded by ``HTTP_TIMEOUT``; a coarse
8+
wall-clock budget (``[obs] request_timeout``) is checked between calls (there
9+
is no safe in-process mid-call hard kill across mtui's worker threads).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import time
15+
from logging import getLogger
16+
from typing import TYPE_CHECKING
17+
from urllib.parse import urlparse
18+
from xml.etree import ElementTree as ET
19+
20+
import requests
21+
22+
from ...support.http import (
23+
HTTP_TIMEOUT,
24+
build_session,
25+
is_ssl_verification_error,
26+
resolve_verify,
27+
ssl_verification_hint,
28+
)
29+
from .auth import ObsSignatureAuth
30+
from .errors import ObsApiError, ObsTimeoutError
31+
32+
if TYPE_CHECKING:
33+
from ...support.config import Config
34+
from .oscrc import ObsCredentials
35+
36+
logger = getLogger("mtui.data_sources.obs.client")
37+
38+
39+
def _error_summary(text: str) -> str:
40+
"""Extract ``<status><summary>`` from an OBS error body (best effort)."""
41+
try:
42+
root = ET.fromstring(text)
43+
except ET.ParseError:
44+
return ""
45+
summary = root.findtext("summary")
46+
return summary.strip() if summary else ""
47+
48+
49+
class ObsClient:
50+
"""A thin OBS API client over one authenticated ``requests.Session``."""
51+
52+
def __init__(self, config: Config, credentials: ObsCredentials) -> None:
53+
"""Build the session and attach SSH-signature auth.
54+
55+
Args:
56+
config: mtui config (supplies ``obs_api_url``, ``ssl_verify`` and
57+
the coarse ``obs_request_timeout`` budget).
58+
credentials: Resolved oscrc credentials (user + key path).
59+
60+
"""
61+
self._api_url = config.obs_api_url.rstrip("/")
62+
verify = resolve_verify(True, config.ssl_verify)
63+
self._session = build_session(verify)
64+
self._auth = ObsSignatureAuth(credentials.user, credentials.sshkey_path)
65+
# Coarse between-calls budget: a whole operation makes a few calls;
66+
# the deadline is checked before each one.
67+
self._deadline = time.monotonic() + float(config.obs_request_timeout)
68+
69+
def _url(self, path: str) -> str:
70+
return f"{self._api_url}/{path.lstrip('/')}"
71+
72+
def _check_budget(self, url: str) -> None:
73+
if time.monotonic() > self._deadline:
74+
raise ObsTimeoutError(
75+
f"OBS operation exceeded its between-calls time budget before {url}"
76+
)
77+
78+
def _request(
79+
self,
80+
method: str,
81+
path: str,
82+
params: dict[str, str | int] | None = None,
83+
body: str | None = None,
84+
) -> requests.Response:
85+
url = self._url(path)
86+
self._check_budget(url)
87+
headers = {"Accept": "application/xml"}
88+
data: bytes | None = None
89+
if body is not None:
90+
headers["Content-Type"] = "application/xml; charset=utf-8"
91+
data = body.encode("utf-8")
92+
try:
93+
# NB: never log the Authorization header or the request body.
94+
logger.debug("OBS %s %s", method, url)
95+
response = self._session.request(
96+
method,
97+
url,
98+
params=params,
99+
data=data,
100+
headers=headers,
101+
auth=self._auth,
102+
timeout=HTTP_TIMEOUT,
103+
)
104+
except requests.exceptions.RequestException as e:
105+
if is_ssl_verification_error(e):
106+
logger.error(ssl_verification_hint(urlparse(url).hostname))
107+
logger.debug("OBS TLS error detail: %s", e)
108+
else:
109+
logger.error("OBS %s %s failed: %s", method, url, e)
110+
raise
111+
112+
if not response.ok:
113+
summary = _error_summary(response.text)
114+
logger.warning(
115+
"OBS %s %s -> %s%s",
116+
method,
117+
url,
118+
response.status_code,
119+
f": {summary}" if summary else "",
120+
)
121+
raise ObsApiError(response.status_code, url, summary)
122+
return response
123+
124+
def get(
125+
self, path: str, params: dict[str, str | int] | None = None
126+
) -> requests.Response:
127+
"""GET ``path`` (relative to the API base) and return the response."""
128+
return self._request("GET", path, params=params)
129+
130+
def post(
131+
self,
132+
path: str,
133+
params: dict[str, str | int] | None = None,
134+
body: str = "",
135+
) -> requests.Response:
136+
"""POST ``body`` to ``path`` with ``params`` and return the response."""
137+
return self._request("POST", path, params=params, body=body)

mtui/data_sources/obs/errors.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Internal error types for the native OBS backend (no osc import).
2+
3+
The caller-facing :class:`~mtui.support.exceptions.ObsConfigError` lives in
4+
``mtui.support.exceptions``; these transport/timeout subtypes are internal to
5+
the backend. All subclass :class:`ObsError`, so the ``OSC`` facade catches the
6+
whole family with one ``except ObsError`` and returns ``False``.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from ...support.exceptions import ObsError
12+
13+
14+
class ObsApiError(ObsError):
15+
"""An OBS API call returned a non-2xx HTTP response."""
16+
17+
def __init__(self, status: int, url: str, summary: str = "") -> None:
18+
"""Record the status, URL and any parsed error summary."""
19+
self.status = status
20+
self.url = url
21+
self.summary = summary
22+
detail = f": {summary}" if summary else ""
23+
super().__init__(f"OBS API returned {status} for {url}{detail}")
24+
25+
26+
class ObsTimeoutError(ObsError):
27+
"""A native OBS operation exceeded its coarse between-calls budget."""

0 commit comments

Comments
 (0)