midpoint-cli 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- midpoint_cli/__init__.py +234 -0
- midpoint_cli/client/__init__.py +199 -0
- midpoint_cli/client/audit.py +178 -0
- midpoint_cli/client/audit_query.py +247 -0
- midpoint_cli/client/configuration.py +15 -0
- midpoint_cli/client/constants.py +37 -0
- midpoint_cli/client/errors.py +74 -0
- midpoint_cli/client/logging_config.py +122 -0
- midpoint_cli/client/midpoint_client.py +866 -0
- midpoint_cli/client/object_query.py +230 -0
- midpoint_cli/client/objects.py +814 -0
- midpoint_cli/client/observer.py +29 -0
- midpoint_cli/client/patch.py +113 -0
- midpoint_cli/client/progress.py +80 -0
- midpoint_cli/client/rest.py +490 -0
- midpoint_cli/client/safe_xml.py +24 -0
- midpoint_cli/client/server_info.py +130 -0
- midpoint_cli/client/server_version.py +74 -0
- midpoint_cli/client/session.py +142 -0
- midpoint_cli/client/task_definitions.py +110 -0
- midpoint_cli/prompt/__init__.py +196 -0
- midpoint_cli/prompt/audit.py +253 -0
- midpoint_cli/prompt/base.py +218 -0
- midpoint_cli/prompt/complete.py +291 -0
- midpoint_cli/prompt/configuration.py +258 -0
- midpoint_cli/prompt/connector.py +45 -0
- midpoint_cli/prompt/console.py +818 -0
- midpoint_cli/prompt/delete.py +89 -0
- midpoint_cli/prompt/get.py +66 -0
- midpoint_cli/prompt/info.py +53 -0
- midpoint_cli/prompt/log.py +98 -0
- midpoint_cli/prompt/org.py +122 -0
- midpoint_cli/prompt/output.py +33 -0
- midpoint_cli/prompt/put.py +71 -0
- midpoint_cli/prompt/report.py +69 -0
- midpoint_cli/prompt/resource.py +217 -0
- midpoint_cli/prompt/role.py +96 -0
- midpoint_cli/prompt/script.py +154 -0
- midpoint_cli/prompt/search.py +51 -0
- midpoint_cli/prompt/shadow.py +87 -0
- midpoint_cli/prompt/task.py +296 -0
- midpoint_cli/prompt/user.py +406 -0
- midpoint_cli-1.6.0.dist-info/METADATA +1334 -0
- midpoint_cli-1.6.0.dist-info/RECORD +47 -0
- midpoint_cli-1.6.0.dist-info/WHEEL +4 -0
- midpoint_cli-1.6.0.dist-info/entry_points.txt +3 -0
- midpoint_cli-1.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""REST transport: authenticated calls to the Midpoint REST API and their error handling."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any, NamedTuple
|
|
6
|
+
from urllib.parse import urljoin, urlparse
|
|
7
|
+
from xml.etree import ElementTree
|
|
8
|
+
from xml.etree.ElementTree import Element
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from urllib3.exceptions import MaxRetryError
|
|
12
|
+
|
|
13
|
+
from midpoint_cli.client.configuration import MidpointClientConfiguration
|
|
14
|
+
from midpoint_cli.client.errors import (
|
|
15
|
+
MidpointInvalidOid,
|
|
16
|
+
MidpointMissingPassword,
|
|
17
|
+
MidpointObjectNotFound,
|
|
18
|
+
MidpointServerError,
|
|
19
|
+
MidpointUnsupportedOperation,
|
|
20
|
+
)
|
|
21
|
+
from midpoint_cli.client.objects import MidpointObjectType, MidpointObjectTypes
|
|
22
|
+
from midpoint_cli.client.observer import MidpointCommunicationObserver, NopMidpointCommunicationObserver
|
|
23
|
+
from midpoint_cli.client.patch import patch_from_file
|
|
24
|
+
from midpoint_cli.client.safe_xml import fromstring as safe_fromstring
|
|
25
|
+
from midpoint_cli.client.session import (
|
|
26
|
+
READ_TIMEOUT_ENV_VAR,
|
|
27
|
+
RETRY_TIMEOUT_ENV_VAR,
|
|
28
|
+
CustomHTTPAdapter,
|
|
29
|
+
CustomRetryManager,
|
|
30
|
+
read_timeout_seconds,
|
|
31
|
+
retry_attempts_for,
|
|
32
|
+
retry_timeout_seconds,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Connect timeout in seconds for every REST call; the read timeout is configurable
|
|
36
|
+
# (MidpointClientConfiguration.read_timeout). Without them a server that accepts the
|
|
37
|
+
# connection but never answers hangs the CLI forever. A connect timeout is retried like any
|
|
38
|
+
# other connection failure, a read timeout is not (see session.py).
|
|
39
|
+
CONNECT_TIMEOUT_SECONDS = 10
|
|
40
|
+
|
|
41
|
+
# Header in which Midpoint 4.10+ answers a search sent with returnTotalCount=true.
|
|
42
|
+
TOTAL_COUNT_HEADER = 'X-Total-Count'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Midpoint's native repository only accepts UUIDs, but the generic repository (H2 and
|
|
46
|
+
# older setups) stores any string up to 36 characters, so OIDs such as 'my-role-01' exist.
|
|
47
|
+
# Accept a conservative token: no '/', '?', '#', '%', whitespace, and no leading '.', so
|
|
48
|
+
# urljoin cannot resolve '..' segments into another collection or out of the context path.
|
|
49
|
+
_SAFE_PATH_SEGMENT = re.compile(r'[A-Za-z0-9][A-Za-z0-9._:-]*')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_path_segment(value: str, what: str = 'OID') -> str:
|
|
53
|
+
"""Return value unchanged if it is safe as a single REST URL path segment, else raise MidpointInvalidOid."""
|
|
54
|
+
if not isinstance(value, str) or _SAFE_PATH_SEGMENT.fullmatch(value) is None:
|
|
55
|
+
raise MidpointInvalidOid(
|
|
56
|
+
f'Invalid {what} {value!r}: only letters, digits and ".", "_", ":", "-" are allowed, '
|
|
57
|
+
f'starting with a letter or digit'
|
|
58
|
+
)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _search_options(options: Sequence[str]) -> dict[str, Any]:
|
|
63
|
+
"""The ``options`` query parameters of a search: GetOperationOptions in their REST form (e.g. 'raw')."""
|
|
64
|
+
return {'options': [_check_path_segment(o, 'option') for o in options]} if options else {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PutResult(NamedTuple):
|
|
68
|
+
"""Outcome of an upload: the object type, its OID (None if the server did not report one) and
|
|
69
|
+
whether it was created by POST (True) or written to a known OID by PUT (False)."""
|
|
70
|
+
|
|
71
|
+
object_type: MidpointObjectType
|
|
72
|
+
oid: str | None
|
|
73
|
+
created: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_ERROR_DETAIL_MAX_LEN = 500
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_error_detail(content: bytes) -> str | None:
|
|
80
|
+
"""Pull the human-readable reason out of a Midpoint error body.
|
|
81
|
+
|
|
82
|
+
Midpoint answers a failed REST call with an OperationResultType document whose
|
|
83
|
+
<message> element carries the actual reason ("Object of type 'UserType' with oid
|
|
84
|
+
'...' was not found."). Without it the CLI could only report a bare status code.
|
|
85
|
+
Bodies that aren't XML (proxy error pages, plain text) are reported verbatim,
|
|
86
|
+
truncated.
|
|
87
|
+
"""
|
|
88
|
+
if not content:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
tree = safe_fromstring(content)
|
|
93
|
+
except ElementTree.ParseError:
|
|
94
|
+
text = ' '.join(content.decode('utf-8', errors='replace').split())
|
|
95
|
+
return text[:_ERROR_DETAIL_MAX_LEN] or None
|
|
96
|
+
|
|
97
|
+
# Document order, so a top-level <message> wins over a nested <partialResults> one.
|
|
98
|
+
for node in tree.iter():
|
|
99
|
+
if node.tag.rsplit('}', 1)[-1] == 'message' and node.text and node.text.strip():
|
|
100
|
+
return ' '.join(node.text.split())[:_ERROR_DETAIL_MAX_LEN]
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _describe_request_failure(
|
|
106
|
+
url: str,
|
|
107
|
+
error: requests.RequestException,
|
|
108
|
+
retry_timeout: float | None = None,
|
|
109
|
+
read_timeout: float | None = None,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Turn a transport-level requests failure into a one-line, actionable message."""
|
|
112
|
+
# requests wraps the urllib3 MaxRetryError whose reason is the actual cause; its own
|
|
113
|
+
# str() repeats the pool and URL details, so report the innermost reason instead.
|
|
114
|
+
reason: object = error
|
|
115
|
+
retried = bool(error.args) and isinstance(error.args[0], MaxRetryError)
|
|
116
|
+
if retried and error.args[0].reason is not None:
|
|
117
|
+
reason = error.args[0].reason
|
|
118
|
+
|
|
119
|
+
if isinstance(error, requests.exceptions.SSLError):
|
|
120
|
+
return (
|
|
121
|
+
f'TLS/SSL error on the REST call to {url}: {reason} — '
|
|
122
|
+
f'check the server certificate and that the URL scheme (http/https) is correct'
|
|
123
|
+
)
|
|
124
|
+
# A MaxRetryError means the retry budget of session.py was spent before giving up.
|
|
125
|
+
gave_up = (
|
|
126
|
+
f' (gave up after retrying for up to {retry_timeout:g} s; use --timeout or {RETRY_TIMEOUT_ENV_VAR} to wait longer)'
|
|
127
|
+
if retried and retry_timeout is not None
|
|
128
|
+
else ''
|
|
129
|
+
)
|
|
130
|
+
if isinstance(error, requests.exceptions.ConnectTimeout):
|
|
131
|
+
return f'Timed out connecting to the server for the REST call to {url}: {reason}{gave_up}'
|
|
132
|
+
if isinstance(error, requests.exceptions.Timeout):
|
|
133
|
+
within = f' within {read_timeout:g} seconds' if read_timeout is not None else ''
|
|
134
|
+
return (
|
|
135
|
+
f'Server did not answer the REST call to {url}{within} '
|
|
136
|
+
f'(use --read-timeout or {READ_TIMEOUT_ENV_VAR} to wait longer)'
|
|
137
|
+
)
|
|
138
|
+
if isinstance(error, requests.exceptions.ConnectionError):
|
|
139
|
+
return (
|
|
140
|
+
f'Cannot connect to the server for the REST call to {url}: {reason}{gave_up} '
|
|
141
|
+
f'— check the configured server URL'
|
|
142
|
+
)
|
|
143
|
+
return f'REST call to {url} failed: {reason}'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class RestApiClient:
|
|
147
|
+
"""Low-level client of the Midpoint REST API (``ws/rest/...``), one method per HTTP call.
|
|
148
|
+
|
|
149
|
+
The verbs return what the call needs, so their return types differ: a decoded ``str``
|
|
150
|
+
(get_element, delete, patch_element, get_endpoint, post_xml), raw ``bytes``
|
|
151
|
+
(execute_action), a parsed ``Element`` (get_elements, search_elements), an OID
|
|
152
|
+
(create_element) or a PutResult (put_element).
|
|
153
|
+
|
|
154
|
+
Every verb raises the same errors, all subclasses of MidpointServerError:
|
|
155
|
+
|
|
156
|
+
- MidpointInvalidOid before anything is sent, when an OID, endpoint or action is not a
|
|
157
|
+
safe URL path segment;
|
|
158
|
+
- MidpointMissingPassword before anything is sent, when the configuration has no password;
|
|
159
|
+
- MidpointObjectNotFound when the server answers 404;
|
|
160
|
+
- MidpointServerError for any other error status (with the server's message when it
|
|
161
|
+
gives one), a redirect or HTML page (a base URL without the Midpoint context path),
|
|
162
|
+
and transport failures (refused connection, timeout, TLS error, retries exhausted).
|
|
163
|
+
|
|
164
|
+
Methods that parse the answer also raise ``xml.etree.ElementTree.ParseError`` when it is
|
|
165
|
+
not XML or holds constructs refused by safe_xml.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
client_configuration: MidpointClientConfiguration,
|
|
171
|
+
observer: MidpointCommunicationObserver | None = None,
|
|
172
|
+
):
|
|
173
|
+
self.configuration = client_configuration
|
|
174
|
+
|
|
175
|
+
if observer is None:
|
|
176
|
+
# No-op observer implementation
|
|
177
|
+
observer = NopMidpointCommunicationObserver()
|
|
178
|
+
|
|
179
|
+
session = requests.Session()
|
|
180
|
+
|
|
181
|
+
# Overall wall-clock budget for retrying a request that gets no answer (session.py).
|
|
182
|
+
timeout = client_configuration.timeout
|
|
183
|
+
self.retry_timeout_seconds = timeout if timeout is not None else retry_timeout_seconds()
|
|
184
|
+
attempts = retry_attempts_for(self.retry_timeout_seconds)
|
|
185
|
+
read_timeout = client_configuration.read_timeout
|
|
186
|
+
self.read_timeout_seconds = read_timeout if read_timeout is not None else read_timeout_seconds()
|
|
187
|
+
adapter = CustomHTTPAdapter(
|
|
188
|
+
max_retries=CustomRetryManager(
|
|
189
|
+
connect=attempts, total=attempts, timeout_seconds=self.retry_timeout_seconds, observer=observer
|
|
190
|
+
),
|
|
191
|
+
observer=observer,
|
|
192
|
+
)
|
|
193
|
+
session.mount('http://', adapter)
|
|
194
|
+
session.mount('https://', adapter)
|
|
195
|
+
|
|
196
|
+
self.requests_session = session
|
|
197
|
+
|
|
198
|
+
def _url(self, path: str) -> str:
|
|
199
|
+
"""Resolve a server-relative path against the configured base URL.
|
|
200
|
+
|
|
201
|
+
``urljoin`` replaces the last path segment of a base without a trailing slash, so
|
|
202
|
+
'http://host/midpoint' + 'ws/rest/users' would lose the context path and target
|
|
203
|
+
'http://host/ws/rest/users'. The base is read at call time because the configuration
|
|
204
|
+
is filled in field by field after the client is built.
|
|
205
|
+
"""
|
|
206
|
+
base = self.configuration.url
|
|
207
|
+
if not base.endswith('/'):
|
|
208
|
+
base += '/'
|
|
209
|
+
return urljoin(base, path)
|
|
210
|
+
|
|
211
|
+
def _collection_url(self, element_class: MidpointObjectType, *segments: str) -> str:
|
|
212
|
+
"""Build the REST URL of a collection (``ws/rest/<endpoint>``), followed by already checked segments."""
|
|
213
|
+
if element_class.endpoint is None:
|
|
214
|
+
raise AssertionError(f'Element class {element_class} has no endpoint')
|
|
215
|
+
path = [_check_path_segment(element_class.endpoint, 'endpoint'), *segments]
|
|
216
|
+
return self._url('ws/rest/' + '/'.join(path))
|
|
217
|
+
|
|
218
|
+
def _object_url(self, element_class: MidpointObjectType, element_oid: str, action: str | None = None) -> str:
|
|
219
|
+
"""Build the REST URL of one object (optionally of an action on it), validating every segment."""
|
|
220
|
+
if element_class.endpoint is None:
|
|
221
|
+
raise AssertionError(f'Element class {element_class} has no endpoint')
|
|
222
|
+
endpoint = _check_path_segment(element_class.endpoint, 'endpoint')
|
|
223
|
+
segments = [_check_path_segment(element_oid)]
|
|
224
|
+
if action is not None:
|
|
225
|
+
segments.append(_check_path_segment(action, 'action'))
|
|
226
|
+
return self._url('ws/rest/' + '/'.join([endpoint, *segments]))
|
|
227
|
+
|
|
228
|
+
def _rest_request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
|
|
229
|
+
"""Send an authenticated REST request, refusing redirects and HTML answers.
|
|
230
|
+
|
|
231
|
+
Midpoint REST endpoints answer directly with XML: a redirect or an HTML body
|
|
232
|
+
means the call landed on the web UI instead of the REST API — typically a base
|
|
233
|
+
URL missing the /midpoint/ context path (recent Midpoint container images serve
|
|
234
|
+
strictly under /midpoint, and the root URL answers with redirects ending at the
|
|
235
|
+
HTML login page). Silently following that redirect used to surface as a cryptic
|
|
236
|
+
``xml.etree.ElementTree.ParseError: mismatched tag`` when the login page was
|
|
237
|
+
parsed as XML.
|
|
238
|
+
"""
|
|
239
|
+
if not self.configuration.password:
|
|
240
|
+
raise MidpointMissingPassword(
|
|
241
|
+
f'No password configured for user {self.configuration.username}: '
|
|
242
|
+
f'refusing to send an unauthenticated REST call to {url}'
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
response = self.requests_session.request(
|
|
246
|
+
method,
|
|
247
|
+
url,
|
|
248
|
+
auth=(self.configuration.username, self.configuration.password),
|
|
249
|
+
allow_redirects=False,
|
|
250
|
+
timeout=(CONNECT_TIMEOUT_SECONDS, self.read_timeout_seconds),
|
|
251
|
+
**kwargs,
|
|
252
|
+
)
|
|
253
|
+
except requests.RequestException as e:
|
|
254
|
+
# Transport failures (refused connection, timeout, TLS error, exhausted retries)
|
|
255
|
+
# are reported like server errors so command handlers print a clean message
|
|
256
|
+
# instead of a traceback.
|
|
257
|
+
raise MidpointServerError(
|
|
258
|
+
_describe_request_failure(url, e, self.retry_timeout_seconds, self.read_timeout_seconds)
|
|
259
|
+
) from e
|
|
260
|
+
|
|
261
|
+
if 300 <= response.status_code < 400:
|
|
262
|
+
location = response.headers.get('Location', '(no Location header)')
|
|
263
|
+
raise MidpointServerError(
|
|
264
|
+
f'Server redirected the REST call on {url} to {location}. '
|
|
265
|
+
f'REST endpoints never redirect — check the configured server URL: '
|
|
266
|
+
f"it must include the Midpoint context path, e.g. 'http://localhost:8080/midpoint/'."
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if 'text/html' in response.headers.get('Content-Type', ''):
|
|
270
|
+
raise MidpointServerError(
|
|
271
|
+
f'Server answered the REST call on {url} with an HTML page '
|
|
272
|
+
f'(HTTP {response.status_code}) instead of a REST response — '
|
|
273
|
+
f'check the configured server URL: '
|
|
274
|
+
f"it must include the Midpoint context path, e.g. 'http://localhost:8080/midpoint/'."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Checked here rather than in each verb so no REST call can silently succeed on an
|
|
278
|
+
# error status. Reaching this point means the status is < 300 or >= 400: the redirect
|
|
279
|
+
# branch above already rejected 301-399.
|
|
280
|
+
if response.status_code >= 400:
|
|
281
|
+
message = f'Server responded with status code {response.status_code} on {url}'
|
|
282
|
+
if response.status_code in (401, 403):
|
|
283
|
+
message += ' — check the configured username and password'
|
|
284
|
+
detail = _extract_error_detail(response.content)
|
|
285
|
+
if detail:
|
|
286
|
+
message += f': {detail}'
|
|
287
|
+
error = MidpointObjectNotFound(message) if response.status_code == 404 else MidpointServerError(message)
|
|
288
|
+
error.status_code = response.status_code
|
|
289
|
+
raise error
|
|
290
|
+
|
|
291
|
+
return response
|
|
292
|
+
|
|
293
|
+
def get_element(self, element_class: MidpointObjectType, element_oid: str) -> str:
|
|
294
|
+
"""GET one object (``ws/rest/<collection>/<oid>``) and return its XML, decoded to ``str``.
|
|
295
|
+
|
|
296
|
+
element_class gives the collection and element_oid the object. Errors as described in
|
|
297
|
+
the class docstring; a missing object raises MidpointObjectNotFound.
|
|
298
|
+
"""
|
|
299
|
+
response = self._rest_request('get', self._object_url(element_class, element_oid))
|
|
300
|
+
|
|
301
|
+
return response.content.decode()
|
|
302
|
+
|
|
303
|
+
def delete(self, element_class: MidpointObjectType, element_oid: str) -> str:
|
|
304
|
+
"""DELETE one object (``ws/rest/<collection>/<oid>``) and return the answer body, decoded to ``str``.
|
|
305
|
+
|
|
306
|
+
Midpoint answers a successful delete with no content, so the result is usually empty.
|
|
307
|
+
Errors as described in the class docstring; a missing object raises MidpointObjectNotFound.
|
|
308
|
+
"""
|
|
309
|
+
response = self._rest_request('delete', self._object_url(element_class, element_oid))
|
|
310
|
+
|
|
311
|
+
return response.content.decode()
|
|
312
|
+
|
|
313
|
+
def patch_element(self, element_class: MidpointObjectType, element_oid: str, xml_body: str) -> str:
|
|
314
|
+
"""PATCH one object with xml_body, an ``ObjectModificationType`` document of item deltas.
|
|
315
|
+
|
|
316
|
+
Returns the answer body decoded to ``str``, usually empty. Errors as described in the
|
|
317
|
+
class docstring; a server that refuses the modification answers an error status.
|
|
318
|
+
"""
|
|
319
|
+
target_url = self._object_url(element_class, element_oid)
|
|
320
|
+
response = self._rest_request(
|
|
321
|
+
'patch',
|
|
322
|
+
target_url,
|
|
323
|
+
data=xml_body,
|
|
324
|
+
headers={'Content-Type': 'application/xml'},
|
|
325
|
+
)
|
|
326
|
+
return response.content.decode()
|
|
327
|
+
|
|
328
|
+
def get_elements(self, element_class: MidpointObjectType) -> Element:
|
|
329
|
+
"""GET a whole collection (``ws/rest/<collection>``) and return the parsed ``<objects>`` ``Element``.
|
|
330
|
+
|
|
331
|
+
Every object of the type is downloaded: use search_elements to filter or page on the
|
|
332
|
+
server. Errors as described in the class docstring, including ParseError.
|
|
333
|
+
"""
|
|
334
|
+
url = self._collection_url(element_class)
|
|
335
|
+
response = self._rest_request('get', url)
|
|
336
|
+
tree = safe_fromstring(response.content)
|
|
337
|
+
return tree
|
|
338
|
+
|
|
339
|
+
def search_elements(
|
|
340
|
+
self, element_class: MidpointObjectType, query_xml: str, options: Sequence[str] = ()
|
|
341
|
+
) -> Element:
|
|
342
|
+
"""POST a QueryType to the search endpoint of a collection and return the parsed object list.
|
|
343
|
+
|
|
344
|
+
options are GetOperationOptions in their REST form (e.g. ``raw``), sent as ``options``
|
|
345
|
+
query parameters, which the search endpoint of 4.4 to 4.11 reads.
|
|
346
|
+
"""
|
|
347
|
+
response = self._search(element_class, query_xml, _search_options(options))
|
|
348
|
+
return safe_fromstring(response.content)
|
|
349
|
+
|
|
350
|
+
def count_elements(
|
|
351
|
+
self, element_class: MidpointObjectType, query_xml: str, options: Sequence[str] = ()
|
|
352
|
+
) -> int | None:
|
|
353
|
+
"""The number of objects matching a QueryType, ignoring its paging, or None if the server cannot tell.
|
|
354
|
+
|
|
355
|
+
Midpoint 4.10 added ``returnTotalCount`` to the search endpoint: the answer then carries
|
|
356
|
+
the count in an ``X-Total-Count`` header. Earlier versions ignore the parameter and send
|
|
357
|
+
no header. Give the query a small maxSize, as the matching objects are still returned.
|
|
358
|
+
options are sent as in search_elements.
|
|
359
|
+
"""
|
|
360
|
+
params: dict[str, Any] = {'returnTotalCount': 'true', **_search_options(options)}
|
|
361
|
+
response = self._search(element_class, query_xml, params)
|
|
362
|
+
header = response.headers.get(TOTAL_COUNT_HEADER)
|
|
363
|
+
try:
|
|
364
|
+
return int(header) if header is not None else None
|
|
365
|
+
except ValueError:
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
def _search(
|
|
369
|
+
self, element_class: MidpointObjectType, query_xml: str, params: dict[str, Any] | None
|
|
370
|
+
) -> requests.Response:
|
|
371
|
+
url = self._collection_url(element_class, 'search')
|
|
372
|
+
kwargs: dict[str, Any] = {'params': params} if params else {}
|
|
373
|
+
return self._rest_request(
|
|
374
|
+
'post',
|
|
375
|
+
url,
|
|
376
|
+
data=query_xml.encode('utf-8'),
|
|
377
|
+
headers={'Content-Type': 'application/xml'},
|
|
378
|
+
**kwargs,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def create_element(self, element_class: MidpointObjectType, xml_body: str) -> str | None:
|
|
382
|
+
"""POST a new object to its collection; return its OID from the Location header (None if not reported)."""
|
|
383
|
+
url = self._collection_url(element_class)
|
|
384
|
+
response = self._rest_request(
|
|
385
|
+
'post',
|
|
386
|
+
url,
|
|
387
|
+
data=xml_body.encode('utf-8'),
|
|
388
|
+
headers={'Content-Type': 'application/xml'},
|
|
389
|
+
)
|
|
390
|
+
return self._created_oid(response)
|
|
391
|
+
|
|
392
|
+
def execute_action(self, element_class: MidpointObjectType, element_oid: str, action: str) -> bytes:
|
|
393
|
+
"""POST an action on one object (``ws/rest/<collection>/<oid>/<action>``) and return the raw answer.
|
|
394
|
+
|
|
395
|
+
Actions are e.g. ``run``, ``suspend`` and ``resume`` on tasks, or ``test`` on resources,
|
|
396
|
+
whose answer is an ``OperationResultType`` document. The body is returned as undecoded
|
|
397
|
+
``bytes``, to be parsed by the caller. Errors as described in the class docstring.
|
|
398
|
+
"""
|
|
399
|
+
response = self._rest_request('post', self._object_url(element_class, element_oid, action))
|
|
400
|
+
|
|
401
|
+
return response.content
|
|
402
|
+
|
|
403
|
+
def get_endpoint(self, path: str) -> str:
|
|
404
|
+
"""GET any path under the configured base URL (e.g. ``actuator/info``, ``ws/rest/self``).
|
|
405
|
+
|
|
406
|
+
Returns the answer body decoded to ``str``, whatever its format (XML or JSON). path is
|
|
407
|
+
relative to the base URL and is not validated: callers pass fixed paths only. Errors as
|
|
408
|
+
described in the class docstring.
|
|
409
|
+
"""
|
|
410
|
+
url = self._url(path)
|
|
411
|
+
response = self._rest_request('get', url)
|
|
412
|
+
return response.content.decode()
|
|
413
|
+
|
|
414
|
+
def post_xml(self, path: str, xml_body: str) -> str:
|
|
415
|
+
"""POST an XML body to an arbitrary path under the configured base URL."""
|
|
416
|
+
url = self._url(path)
|
|
417
|
+
response = self._rest_request(
|
|
418
|
+
'post',
|
|
419
|
+
url,
|
|
420
|
+
data=xml_body,
|
|
421
|
+
headers={'Content-Type': 'application/xml'},
|
|
422
|
+
)
|
|
423
|
+
return response.content.decode()
|
|
424
|
+
|
|
425
|
+
def put_element(self, xml_filename: str, patch_file: str | None, patch_write: bool) -> PutResult:
|
|
426
|
+
"""Upload an object from an XML file, creating or replacing it.
|
|
427
|
+
|
|
428
|
+
A root element with an ``oid`` attribute is PUT to the object URL, which creates or
|
|
429
|
+
overwrites the object at that OID. Without one, the document is POSTed to the type
|
|
430
|
+
collection and the server picks the OID, read back from the ``Location`` header.
|
|
431
|
+
The patch, if any, is applied first, so it may add, change or drop the OID.
|
|
432
|
+
|
|
433
|
+
xml_filename is the local file to upload. patch_file, if not None, is an XML patch file
|
|
434
|
+
applied to it first; with patch_write the patched document is also written next to the
|
|
435
|
+
patch file (``<patch_file>.xml``). Returns a PutResult with the object type, its OID
|
|
436
|
+
(None when the server created the object without reporting one) and whether it was
|
|
437
|
+
created by POST.
|
|
438
|
+
|
|
439
|
+
Raises OSError when a file cannot be read, ParseError when the document is not XML,
|
|
440
|
+
MidpointTypeNotFound when its root element is not a known object type,
|
|
441
|
+
MidpointUnsupportedOperation when the type has no REST collection, and the errors
|
|
442
|
+
described in the class docstring.
|
|
443
|
+
"""
|
|
444
|
+
with open(xml_filename) as xml_file:
|
|
445
|
+
xml_body = xml_file.read()
|
|
446
|
+
|
|
447
|
+
if patch_file is not None:
|
|
448
|
+
xml_body = patch_from_file(xml_body, patch_file, patch_write)
|
|
449
|
+
|
|
450
|
+
# A local file, not server XML: parsed with the standard parser, as before, so the user's own
|
|
451
|
+
# files keep working whatever they declare (S314 targets untrusted input).
|
|
452
|
+
tree_root = ElementTree.fromstring(xml_body) # noqa: S314
|
|
453
|
+
|
|
454
|
+
root_tag_name = tree_root.tag.split('}', 1)[1] if '}' in tree_root.tag else tree_root.tag # strip namespace
|
|
455
|
+
object_type = MidpointObjectTypes.find_by_name(root_tag_name)
|
|
456
|
+
|
|
457
|
+
if not object_type.endpoint:
|
|
458
|
+
raise MidpointUnsupportedOperation(f'Upload of {object_type} is not supported through REST API')
|
|
459
|
+
|
|
460
|
+
object_oid = tree_root.attrib.get('oid')
|
|
461
|
+
headers = {'Content-Type': 'application/xml'}
|
|
462
|
+
|
|
463
|
+
if object_oid is None:
|
|
464
|
+
collection_url = self._collection_url(object_type)
|
|
465
|
+
response = self._rest_request('post', collection_url, data=xml_body, headers=headers)
|
|
466
|
+
return PutResult(object_type, self._created_oid(response), created=True)
|
|
467
|
+
|
|
468
|
+
# The OID comes from the uploaded file, so it is validated before anything is sent.
|
|
469
|
+
target_url = self._object_url(object_type, object_oid)
|
|
470
|
+
self._rest_request('put', target_url, data=xml_body, headers=headers)
|
|
471
|
+
return PutResult(object_type, object_oid, created=False)
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _created_oid(response: requests.Response) -> str | None:
|
|
475
|
+
"""Read the OID of a newly created object from the Location header of a POST answer.
|
|
476
|
+
|
|
477
|
+
Midpoint answers 201 Created (202 Accepted for tasks) with a Location ending in the
|
|
478
|
+
new OID. An answer without Location means the object was accepted but not created
|
|
479
|
+
yet, e.g. while it waits for an approval, so it has no OID to report.
|
|
480
|
+
"""
|
|
481
|
+
location = response.headers.get('Location')
|
|
482
|
+
if not location:
|
|
483
|
+
return None
|
|
484
|
+
oid = urlparse(location).path.rstrip('/').rsplit('/', 1)[-1]
|
|
485
|
+
try:
|
|
486
|
+
return _check_path_segment(oid)
|
|
487
|
+
except MidpointInvalidOid as e:
|
|
488
|
+
raise MidpointServerError(
|
|
489
|
+
f'The object was created, but the Location header {location!r} does not end with a valid OID'
|
|
490
|
+
) from e
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Parsing of XML received from the server, hardened with defusedxml.
|
|
2
|
+
|
|
3
|
+
The stdlib parser expands internal entities, so a hostile or intercepted response
|
|
4
|
+
(plain http://) could exhaust memory with a "billion laughs" document. defusedxml
|
|
5
|
+
refuses entity declarations and external references instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from xml.etree.ElementTree import Element, ParseError
|
|
9
|
+
|
|
10
|
+
from defusedxml import DefusedXmlException
|
|
11
|
+
from defusedxml.ElementTree import fromstring as _defused_fromstring
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fromstring(text: str | bytes) -> Element:
|
|
15
|
+
"""Parse server-supplied XML into an Element.
|
|
16
|
+
|
|
17
|
+
A refused construct is raised as ``ParseError`` so callers keep a single
|
|
18
|
+
error path for any response that cannot be used as XML.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
root: Element = _defused_fromstring(text)
|
|
22
|
+
except DefusedXmlException as e:
|
|
23
|
+
raise ParseError(f'Refused unsafe XML from the server: {e}') from e
|
|
24
|
+
return root
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""What `info` shows about the server, and how it is read from each REST source.
|
|
2
|
+
|
|
3
|
+
Midpoint has no single REST call for the "About" page of the web UI, so the information is
|
|
4
|
+
gathered from calls that every supported release (4.4 to 4.11) answers the same way:
|
|
5
|
+
|
|
6
|
+
* ``actuator/info``: version, build, branch, build timestamp and license (see server_version.py);
|
|
7
|
+
* ``ws/rest/nodes``: the cluster nodes, each with its host, state, Midpoint build and last
|
|
8
|
+
check-in (NodeType, unchanged from 4.4.11 to 4.11.0);
|
|
9
|
+
* ``ws/rest/systemConfigurations/<oid>``: the deployment name and whether a subscription
|
|
10
|
+
identifier is configured (SystemConfigurationType/deploymentInformation);
|
|
11
|
+
* ``ws/rest/self``: the account the CLI is logged in as.
|
|
12
|
+
|
|
13
|
+
The repository implementation is not exposed over REST. Midpoint 4.10 dropped the generic
|
|
14
|
+
(SQL) repository, so from 4.10 on the repository is known to be the native PostgreSQL one;
|
|
15
|
+
for earlier versions it is reported as unknown.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from xml.etree.ElementTree import Element
|
|
20
|
+
|
|
21
|
+
from midpoint_cli.client.objects import namespaces
|
|
22
|
+
from midpoint_cli.client.server_version import MidpointVersion
|
|
23
|
+
|
|
24
|
+
# OID of the single SystemConfigurationType object, the same in every Midpoint release.
|
|
25
|
+
SYSTEM_CONFIGURATION_OID = '00000000-0000-0000-0000-000000000001'
|
|
26
|
+
|
|
27
|
+
# The 4.10.5 and 4.11.0 distributions no longer ship repo-sql-impl, the generic repository.
|
|
28
|
+
NATIVE_REPOSITORY_ONLY_SINCE = (4, 10)
|
|
29
|
+
|
|
30
|
+
# actuator/info fields and the label `info` shows them under, in display order.
|
|
31
|
+
ACTUATOR_FIELDS = (
|
|
32
|
+
('version', 'Midpoint Version'),
|
|
33
|
+
('build', 'Build'),
|
|
34
|
+
('branch', 'Branch'),
|
|
35
|
+
('buildTimestamp', 'Build Timestamp'),
|
|
36
|
+
('license', 'License'),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _text(parent: Element | None, path: str) -> str | None:
|
|
41
|
+
"""Stripped text of the element at path under parent, or None when missing or blank."""
|
|
42
|
+
node = parent.find(path, namespaces) if parent is not None else None
|
|
43
|
+
text = node.text.strip() if node is not None and node.text is not None else ''
|
|
44
|
+
return text or None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ServerInformation:
|
|
49
|
+
"""Server properties in display order, and the sources that could not be read, with the reason."""
|
|
50
|
+
|
|
51
|
+
properties: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
unavailable: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class NodeInformation:
|
|
57
|
+
identifier: str
|
|
58
|
+
hostname: str | None = None
|
|
59
|
+
state: str | None = None
|
|
60
|
+
version: str | None = None
|
|
61
|
+
last_check_in: str | None = None
|
|
62
|
+
|
|
63
|
+
def describe(self) -> str:
|
|
64
|
+
"""One line for the node, e.g. 'mp1.example.com, running, Midpoint 4.8.14, last check-in 2026-...'."""
|
|
65
|
+
parts = [
|
|
66
|
+
self.hostname,
|
|
67
|
+
self.state,
|
|
68
|
+
f'Midpoint {self.version}' if self.version else None,
|
|
69
|
+
f'last check-in {self.last_check_in}' if self.last_check_in else None,
|
|
70
|
+
]
|
|
71
|
+
return ', '.join(part for part in parts if part) or '(no details)'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_nodes(objects: Element) -> list[NodeInformation]:
|
|
75
|
+
"""The nodes of a ws/rest/nodes object list.
|
|
76
|
+
|
|
77
|
+
The execution state (running, paused, error, ...) is more telling than the operational
|
|
78
|
+
state (up, down, starting), so it is shown when the node has one.
|
|
79
|
+
"""
|
|
80
|
+
nodes = []
|
|
81
|
+
for node in objects:
|
|
82
|
+
identifier = _text(node, 'c:nodeIdentifier') or _text(node, 'c:name')
|
|
83
|
+
if identifier is None:
|
|
84
|
+
continue
|
|
85
|
+
nodes.append(
|
|
86
|
+
NodeInformation(
|
|
87
|
+
identifier=identifier,
|
|
88
|
+
hostname=_text(node, 'c:hostname'),
|
|
89
|
+
state=_text(node, 'c:executionState') or _text(node, 'c:operationalState'),
|
|
90
|
+
version=_text(node, 'c:build/c:version'),
|
|
91
|
+
last_check_in=_text(node, 'c:lastCheckInTime'),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return nodes
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_logged_in_user(user: Element) -> str | None:
|
|
98
|
+
"""'name (full name)' of the ws/rest/self answer, or just the name."""
|
|
99
|
+
name = _text(user, 'c:name')
|
|
100
|
+
if name is None:
|
|
101
|
+
return None
|
|
102
|
+
full_name = _text(user, 'c:fullName')
|
|
103
|
+
return f'{name} ({full_name})' if full_name and full_name != name else name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_deployment(system_configuration: Element) -> dict[str, str]:
|
|
107
|
+
"""Deployment name and subscription status from the system configuration.
|
|
108
|
+
|
|
109
|
+
Only the presence of a subscription identifier is reported: its validity (format, check
|
|
110
|
+
digit, expiry) is decided by the server, with rules that changed across releases.
|
|
111
|
+
"""
|
|
112
|
+
deployment = system_configuration.find('c:deploymentInformation', namespaces)
|
|
113
|
+
properties: dict[str, str] = {}
|
|
114
|
+
name = _text(deployment, 'c:name')
|
|
115
|
+
if name:
|
|
116
|
+
properties['Deployment Name'] = name
|
|
117
|
+
subscription = _text(deployment, 'c:subscriptionIdentifier')
|
|
118
|
+
properties['Subscription'] = (
|
|
119
|
+
'identifier configured (validity is checked by the server)' if subscription else 'none configured'
|
|
120
|
+
)
|
|
121
|
+
return properties
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def describe_repository(version: MidpointVersion | None) -> str | None:
|
|
125
|
+
"""The repository implementation as far as the version tells, or None when the version is unknown."""
|
|
126
|
+
if version is None:
|
|
127
|
+
return None
|
|
128
|
+
if version.at_least(*NATIVE_REPOSITORY_ONLY_SINCE):
|
|
129
|
+
return 'native (PostgreSQL)'
|
|
130
|
+
return 'not reported over REST before Midpoint 4.10 (see the About page of the web UI)'
|