midpoint-cli 1.6.0.dev3__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.dev3.dist-info/METADATA +1334 -0
- midpoint_cli-1.6.0.dev3.dist-info/RECORD +47 -0
- midpoint_cli-1.6.0.dev3.dist-info/WHEEL +4 -0
- midpoint_cli-1.6.0.dev3.dist-info/entry_points.txt +3 -0
- midpoint_cli-1.6.0.dev3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
"""MidpointClient: the high-level operations of the CLI on a Midpoint server."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
from xml.etree import ElementTree
|
|
12
|
+
from xml.etree.ElementTree import Element
|
|
13
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
14
|
+
from xml.sax.saxutils import quoteattr
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from midpoint_cli.client.audit import (
|
|
19
|
+
as_utc,
|
|
20
|
+
audit_script_error,
|
|
21
|
+
initial_audit_cursor,
|
|
22
|
+
parse_audit_events_from_script_response,
|
|
23
|
+
parse_audit_timestamp,
|
|
24
|
+
)
|
|
25
|
+
from midpoint_cli.client.audit_query import AUDIT_EXEC_SCRIPT_PATH, AuditQuery, build_execute_script_body
|
|
26
|
+
from midpoint_cli.client.configuration import MidpointClientConfiguration
|
|
27
|
+
from midpoint_cli.client.constants import API_TYPES_NS, COMMON_NS, TYPES_NS, ResultStatus, TaskExecutionState
|
|
28
|
+
from midpoint_cli.client.errors import (
|
|
29
|
+
MidpointAmbiguousName,
|
|
30
|
+
MidpointClientNotConfigured,
|
|
31
|
+
MidpointMissingPassword,
|
|
32
|
+
MidpointObjectNotFound,
|
|
33
|
+
MidpointServerError,
|
|
34
|
+
TaskExecutionFailure,
|
|
35
|
+
)
|
|
36
|
+
from midpoint_cli.client.logging_config import (
|
|
37
|
+
LoggingConfiguration,
|
|
38
|
+
check_logger_name,
|
|
39
|
+
class_logger_modification,
|
|
40
|
+
normalize_level,
|
|
41
|
+
parse_logging,
|
|
42
|
+
)
|
|
43
|
+
from midpoint_cli.client.object_query import (
|
|
44
|
+
ORG_SEARCH_PATHS,
|
|
45
|
+
ROLE_SEARCH_PATHS,
|
|
46
|
+
USER_SEARCH_PATHS,
|
|
47
|
+
ObjectQuery,
|
|
48
|
+
and_filter,
|
|
49
|
+
equal_filter,
|
|
50
|
+
in_oid_filter,
|
|
51
|
+
is_oid,
|
|
52
|
+
ref_filter,
|
|
53
|
+
task_status_filter,
|
|
54
|
+
)
|
|
55
|
+
from midpoint_cli.client.objects import (
|
|
56
|
+
MidpointConnector,
|
|
57
|
+
MidpointObject,
|
|
58
|
+
MidpointObjectList,
|
|
59
|
+
MidpointObjectType,
|
|
60
|
+
MidpointObjectTypes,
|
|
61
|
+
MidpointOrganization,
|
|
62
|
+
MidpointResource,
|
|
63
|
+
MidpointRole,
|
|
64
|
+
MidpointShadow,
|
|
65
|
+
MidpointTask,
|
|
66
|
+
MidpointUser,
|
|
67
|
+
namespaces,
|
|
68
|
+
safe_get_text,
|
|
69
|
+
)
|
|
70
|
+
from midpoint_cli.client.observer import MidpointCommunicationObserver
|
|
71
|
+
from midpoint_cli.client.progress import ProgressMonitor
|
|
72
|
+
from midpoint_cli.client.rest import PutResult, RestApiClient
|
|
73
|
+
from midpoint_cli.client.safe_xml import fromstring as safe_fromstring
|
|
74
|
+
from midpoint_cli.client.server_info import (
|
|
75
|
+
ACTUATOR_FIELDS,
|
|
76
|
+
SYSTEM_CONFIGURATION_OID,
|
|
77
|
+
ServerInformation,
|
|
78
|
+
describe_repository,
|
|
79
|
+
parse_deployment,
|
|
80
|
+
parse_logged_in_user,
|
|
81
|
+
parse_nodes,
|
|
82
|
+
)
|
|
83
|
+
from midpoint_cli.client.server_version import MidpointVersion, parse_server_version
|
|
84
|
+
from midpoint_cli.client.task_definitions import (
|
|
85
|
+
report_task_name,
|
|
86
|
+
report_task_xml,
|
|
87
|
+
resource_task_name,
|
|
88
|
+
resource_task_xml,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
logger = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
# SynchronizationSituationType values, the same from 4.4.11 to 4.11.0.
|
|
94
|
+
SHADOW_SITUATIONS = ('linked', 'unlinked', 'unmatched', 'disputed', 'deleted')
|
|
95
|
+
# Shadow counts are read from the repository only: no resource is contacted and no
|
|
96
|
+
# definitions are applied, so a resource that is down or has no schema can still be counted.
|
|
97
|
+
_SHADOW_COUNT_OPTIONS = ('raw',)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Local classes of the object types the CLI displays; other types (archetypes, services, cases,
|
|
101
|
+
# lookup tables, nodes, object collections, dashboards, sequences, ...) are parsed to OID and name only.
|
|
102
|
+
# Tasks are parsed by _parse_object itself, as they need the server version.
|
|
103
|
+
_LOCAL_CLASSES: tuple[tuple[MidpointObjectTypes, type[MidpointObject]], ...] = (
|
|
104
|
+
(MidpointObjectTypes.USER, MidpointUser),
|
|
105
|
+
(MidpointObjectTypes.ROLE, MidpointRole),
|
|
106
|
+
(MidpointObjectTypes.ORG, MidpointOrganization),
|
|
107
|
+
(MidpointObjectTypes.RESOURCE, MidpointResource),
|
|
108
|
+
(MidpointObjectTypes.CONNECTOR, MidpointConnector),
|
|
109
|
+
(MidpointObjectTypes.SHADOW, MidpointShadow),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_object(
|
|
114
|
+
object_type: MidpointObjectType, entity: Element, server_version: MidpointVersion | None = None
|
|
115
|
+
) -> MidpointObject:
|
|
116
|
+
"""Parse an object element with the local class of object_type, or to OID and name for other types.
|
|
117
|
+
|
|
118
|
+
Tasks are parsed for server_version, whose task schema they follow.
|
|
119
|
+
"""
|
|
120
|
+
if object_type == MidpointObjectTypes.TASK.value:
|
|
121
|
+
return MidpointTask(entity, server_version)
|
|
122
|
+
for known_type, local_class in _LOCAL_CLASSES:
|
|
123
|
+
if known_type.value == object_type:
|
|
124
|
+
return local_class(entity)
|
|
125
|
+
obj = MidpointObject()
|
|
126
|
+
obj['OID'] = entity.attrib.get('oid')
|
|
127
|
+
obj['Name'] = safe_get_text(entity.find('c:name', namespaces))
|
|
128
|
+
return obj
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _expected_total(task: MidpointTask) -> int | None:
|
|
132
|
+
"""The number of items the task expects to process, or None when the server does not tell."""
|
|
133
|
+
try:
|
|
134
|
+
total = int(task['Expected Total'])
|
|
135
|
+
except (TypeError, ValueError):
|
|
136
|
+
return None
|
|
137
|
+
return total if total > 0 else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# A task the CLI creates is started by the server's scheduler, usually within a second or two.
|
|
141
|
+
# Until then it has no result; waiting stops after TASK_START_ATTEMPTS polls.
|
|
142
|
+
TASK_START_POLL_SECONDS = 1
|
|
143
|
+
TASK_START_ATTEMPTS = 120
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class MidpointClient:
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
client_configuration: MidpointClientConfiguration | None = None,
|
|
150
|
+
api_client: RestApiClient | None = None,
|
|
151
|
+
observer: MidpointCommunicationObserver | None = None,
|
|
152
|
+
):
|
|
153
|
+
self._api_client: RestApiClient | None = None
|
|
154
|
+
|
|
155
|
+
if client_configuration is not None:
|
|
156
|
+
self._api_client = RestApiClient(client_configuration, observer=observer)
|
|
157
|
+
|
|
158
|
+
if api_client is not None:
|
|
159
|
+
self._api_client = api_client
|
|
160
|
+
|
|
161
|
+
# Server version, detected on first use (get_server_version); None until then or if unknown.
|
|
162
|
+
self._server_version: MidpointVersion | None = None
|
|
163
|
+
self._server_version_detected = False
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def api_client(self) -> RestApiClient:
|
|
167
|
+
if self._api_client is None:
|
|
168
|
+
raise MidpointClientNotConfigured(
|
|
169
|
+
'MidpointClient was created without a client configuration or a REST API client'
|
|
170
|
+
)
|
|
171
|
+
return self._api_client
|
|
172
|
+
|
|
173
|
+
@api_client.setter
|
|
174
|
+
def api_client(self, api_client: RestApiClient) -> None:
|
|
175
|
+
self._api_client = api_client
|
|
176
|
+
|
|
177
|
+
def __get_object(self, midpoint_type: MidpointObjectType, oid: str) -> Element:
|
|
178
|
+
response = self.api_client.get_element(midpoint_type, oid)
|
|
179
|
+
tree = safe_fromstring(response)
|
|
180
|
+
|
|
181
|
+
status = tree.find('c:status', namespaces)
|
|
182
|
+
|
|
183
|
+
if status is not None and status.text == ResultStatus.FATAL_ERROR:
|
|
184
|
+
message_node = tree.find('c:partialResults/c:message', namespaces)
|
|
185
|
+
message = message_node.text if message_node is not None else 'Unknown error'
|
|
186
|
+
raise MidpointServerError(message)
|
|
187
|
+
|
|
188
|
+
return tree
|
|
189
|
+
|
|
190
|
+
def __get_collection(
|
|
191
|
+
self,
|
|
192
|
+
mp_class: MidpointObjectType,
|
|
193
|
+
local_class: type,
|
|
194
|
+
limit: int | None = None,
|
|
195
|
+
name_filter: str | None = None,
|
|
196
|
+
) -> MidpointObjectList:
|
|
197
|
+
"""List a collection; with a limit or a name filter, the server filters and pages it."""
|
|
198
|
+
if limit is None and not name_filter:
|
|
199
|
+
tree = self.api_client.get_elements(mp_class)
|
|
200
|
+
return MidpointObjectList([local_class(entity) for entity in tree])
|
|
201
|
+
return self.search_objects(mp_class, ObjectQuery.listing(limit=limit, name_filter=name_filter))
|
|
202
|
+
|
|
203
|
+
def search_objects(
|
|
204
|
+
self, object_type: MidpointObjectType, query: ObjectQuery, options: Sequence[str] = ()
|
|
205
|
+
) -> MidpointObjectList:
|
|
206
|
+
"""Run a server-side search (POST ws/rest/<collection>/search) and parse the matching objects.
|
|
207
|
+
|
|
208
|
+
options are REST GetOperationOptions such as 'raw' (see RestApiClient.search_elements).
|
|
209
|
+
"""
|
|
210
|
+
if options:
|
|
211
|
+
tree = self.api_client.search_elements(object_type, query.to_xml(), options)
|
|
212
|
+
else:
|
|
213
|
+
tree = self.api_client.search_elements(object_type, query.to_xml())
|
|
214
|
+
server_version = self._server_version_for(object_type)
|
|
215
|
+
return MidpointObjectList(
|
|
216
|
+
[
|
|
217
|
+
_parse_object(object_type, entity, server_version)
|
|
218
|
+
for entity in tree
|
|
219
|
+
if entity.attrib.get('oid') is not None
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _server_version_for(self, object_type: MidpointObjectType) -> MidpointVersion | None:
|
|
224
|
+
"""The server version for parsing objects of object_type: only tasks need it."""
|
|
225
|
+
return self.get_server_version() if object_type == MidpointObjectTypes.TASK.value else None
|
|
226
|
+
|
|
227
|
+
def get_object(self, object_type: MidpointObjectType, oid: str) -> MidpointObject:
|
|
228
|
+
"""Fetch one object by OID, parsed with the local class of its type."""
|
|
229
|
+
return _parse_object(object_type, self.__get_object(object_type, oid), self._server_version_for(object_type))
|
|
230
|
+
|
|
231
|
+
def find_objects(self, object_type: MidpointObjectType, reference: str) -> MidpointObjectList:
|
|
232
|
+
"""Every object a CLI reference designates: the object with that OID, or else all objects with that name.
|
|
233
|
+
|
|
234
|
+
A reference in OID form is fetched directly. Otherwise the server is searched by exact name;
|
|
235
|
+
when nothing has that name, the reference is tried as an OID (the generic repository allows
|
|
236
|
+
OIDs that are not UUIDs). An empty list means nothing matched.
|
|
237
|
+
"""
|
|
238
|
+
if not is_oid(reference):
|
|
239
|
+
matches = self.search_objects(object_type, ObjectQuery.name_equals(reference))
|
|
240
|
+
if matches:
|
|
241
|
+
return matches
|
|
242
|
+
try:
|
|
243
|
+
return MidpointObjectList([self.get_object(object_type, reference)])
|
|
244
|
+
except MidpointObjectNotFound:
|
|
245
|
+
return MidpointObjectList()
|
|
246
|
+
except MidpointServerError:
|
|
247
|
+
# The search above reached the server, so this is the reference being refused as an
|
|
248
|
+
# OID (unsafe characters, or the native repository rejecting a non-UUID OID).
|
|
249
|
+
if is_oid(reference):
|
|
250
|
+
raise
|
|
251
|
+
return MidpointObjectList()
|
|
252
|
+
|
|
253
|
+
def find_object(self, object_type: MidpointObjectType, reference: str) -> MidpointObject | None:
|
|
254
|
+
"""The single object a CLI reference (OID or name) designates, or None when nothing matches.
|
|
255
|
+
|
|
256
|
+
Raises MidpointAmbiguousName when the reference is a name shared by several objects,
|
|
257
|
+
instead of silently acting on one of them.
|
|
258
|
+
"""
|
|
259
|
+
matches = self.find_objects(object_type, reference)
|
|
260
|
+
if len(matches) > 1:
|
|
261
|
+
raise MidpointAmbiguousName(object_type, reference, [str(m.get_oid()) for m in matches])
|
|
262
|
+
return matches[0] if matches else None
|
|
263
|
+
|
|
264
|
+
def resolve_oid(self, object_type: MidpointObjectType, reference: str) -> str:
|
|
265
|
+
"""The OID a CLI reference designates: a canonical OID as is, else the OID of the object so named.
|
|
266
|
+
|
|
267
|
+
Raises MidpointObjectNotFound when nothing has that name or OID, and MidpointAmbiguousName
|
|
268
|
+
when several objects have that name.
|
|
269
|
+
"""
|
|
270
|
+
if is_oid(reference):
|
|
271
|
+
return reference
|
|
272
|
+
found = self.find_object(object_type, reference)
|
|
273
|
+
oid = found.get_oid() if found is not None else None
|
|
274
|
+
if oid is None:
|
|
275
|
+
raise MidpointObjectNotFound(f'No {object_type.tagname} has the name or OID {reference!r}')
|
|
276
|
+
return oid
|
|
277
|
+
|
|
278
|
+
def complete_names(self, object_type: MidpointObjectType, prefix: str, limit: int = 50) -> MidpointObjectList:
|
|
279
|
+
"""At most limit objects whose name starts with prefix, for tab completion."""
|
|
280
|
+
return self.search_objects(object_type, ObjectQuery.name_prefix(prefix, limit))
|
|
281
|
+
|
|
282
|
+
def get_tasks(
|
|
283
|
+
self, limit: int | None = None, name_filter: str | None = None, statuses: Sequence[str] = ()
|
|
284
|
+
) -> MidpointObjectList:
|
|
285
|
+
"""List tasks; statuses keeps those in any of the `task ls --status` states (TASK_STATUS_FILTERS)."""
|
|
286
|
+
if limit is None and not name_filter and not statuses:
|
|
287
|
+
server_version = self.get_server_version()
|
|
288
|
+
tree = self.api_client.get_elements(MidpointObjectTypes.TASK.value)
|
|
289
|
+
return MidpointObjectList([MidpointTask(entity, server_version) for entity in tree])
|
|
290
|
+
status_filter = task_status_filter(statuses)
|
|
291
|
+
return self.search_objects(
|
|
292
|
+
MidpointObjectTypes.TASK.value,
|
|
293
|
+
ObjectQuery.listing(
|
|
294
|
+
limit=limit, name_filter=name_filter, filters=[status_filter] if status_filter is not None else []
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def get_resources(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
|
|
299
|
+
return self.__get_collection(MidpointObjectTypes.RESOURCE.value, MidpointResource, limit, name_filter)
|
|
300
|
+
|
|
301
|
+
def get_resource_shadow_counts(self, resource_oid: str) -> dict[str, int]:
|
|
302
|
+
"""Count the repository shadows of a resource by synchronization situation.
|
|
303
|
+
|
|
304
|
+
Returns a count for each of SHADOW_SITUATIONS, plus 'none' for shadows without a
|
|
305
|
+
situation and 'total'. Midpoint 4.10+ counts on the server (6 searches answering one
|
|
306
|
+
shadow each); earlier versions cannot count, so all shadows of the resource are
|
|
307
|
+
downloaded in one search and counted here, which is slow on large resources.
|
|
308
|
+
"""
|
|
309
|
+
by_resource = ref_filter('resourceRef', resource_oid)
|
|
310
|
+
version = self.get_server_version()
|
|
311
|
+
if version is None or version.at_least(4, 10):
|
|
312
|
+
total = self.api_client.count_elements(
|
|
313
|
+
MidpointObjectTypes.SHADOW.value,
|
|
314
|
+
ObjectQuery(filter=by_resource, limit=1).to_xml(),
|
|
315
|
+
_SHADOW_COUNT_OPTIONS,
|
|
316
|
+
)
|
|
317
|
+
if total is not None:
|
|
318
|
+
counts = dict.fromkeys(SHADOW_SITUATIONS, 0)
|
|
319
|
+
for situation in SHADOW_SITUATIONS if total else ():
|
|
320
|
+
situation_filter = and_filter([by_resource, equal_filter('synchronizationSituation', situation)])
|
|
321
|
+
counts[situation] = (
|
|
322
|
+
self.api_client.count_elements(
|
|
323
|
+
MidpointObjectTypes.SHADOW.value,
|
|
324
|
+
ObjectQuery(filter=situation_filter, limit=1).to_xml(),
|
|
325
|
+
_SHADOW_COUNT_OPTIONS,
|
|
326
|
+
)
|
|
327
|
+
or 0
|
|
328
|
+
)
|
|
329
|
+
counts['none'] = max(total - sum(counts.values()), 0)
|
|
330
|
+
counts['total'] = total
|
|
331
|
+
return counts
|
|
332
|
+
|
|
333
|
+
shadows = self.api_client.search_elements(
|
|
334
|
+
MidpointObjectTypes.SHADOW.value, ObjectQuery(filter=by_resource).to_xml(), _SHADOW_COUNT_OPTIONS
|
|
335
|
+
)
|
|
336
|
+
counts = dict.fromkeys((*SHADOW_SITUATIONS, 'none', 'total'), 0)
|
|
337
|
+
for shadow in shadows:
|
|
338
|
+
if shadow.attrib.get('oid') is None:
|
|
339
|
+
continue
|
|
340
|
+
situation = safe_get_text(shadow.find('c:synchronizationSituation', namespaces)).strip()
|
|
341
|
+
counts[situation if situation in SHADOW_SITUATIONS else 'none'] += 1
|
|
342
|
+
counts['total'] += 1
|
|
343
|
+
return counts
|
|
344
|
+
|
|
345
|
+
def get_connectors(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
|
|
346
|
+
return self.__get_collection(MidpointObjectTypes.CONNECTOR.value, MidpointConnector, limit, name_filter)
|
|
347
|
+
|
|
348
|
+
def get_reports(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
|
|
349
|
+
return self.search_objects(
|
|
350
|
+
MidpointObjectTypes.REPORT.value, ObjectQuery.listing(limit=limit, name_filter=name_filter)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def get_shadows(
|
|
354
|
+
self,
|
|
355
|
+
resource_oid: str | None = None,
|
|
356
|
+
kind: str | None = None,
|
|
357
|
+
intent: str | None = None,
|
|
358
|
+
limit: int | None = None,
|
|
359
|
+
name_filter: str | None = None,
|
|
360
|
+
) -> MidpointObjectList:
|
|
361
|
+
"""Shadows from the repository, optionally of one resource, kind and intent.
|
|
362
|
+
|
|
363
|
+
The search is raw: without it the model would hand a shadow search to provisioning,
|
|
364
|
+
which reads the resource itself and wants an object class or kind. A raw search
|
|
365
|
+
reads the repository only, so it lists what Midpoint knows without contacting the resource.
|
|
366
|
+
"""
|
|
367
|
+
filters = []
|
|
368
|
+
if resource_oid is not None:
|
|
369
|
+
filters.append(ref_filter('resourceRef', resource_oid))
|
|
370
|
+
if kind:
|
|
371
|
+
filters.append(equal_filter('kind', kind))
|
|
372
|
+
if intent:
|
|
373
|
+
filters.append(equal_filter('intent', intent))
|
|
374
|
+
query = ObjectQuery.listing(limit=limit, name_filter=name_filter, filters=filters)
|
|
375
|
+
return self.search_objects(MidpointObjectTypes.SHADOW.value, query, options=['raw'])
|
|
376
|
+
|
|
377
|
+
def get_objects_by_oid(self, object_type: MidpointObjectType, oids: Sequence[str]) -> MidpointObjectList:
|
|
378
|
+
"""The objects of one type with the given OIDs, in one search; unknown OIDs are skipped."""
|
|
379
|
+
if not oids:
|
|
380
|
+
return MidpointObjectList()
|
|
381
|
+
return self.search_objects(object_type, ObjectQuery(filter=in_oid_filter(oids)))
|
|
382
|
+
|
|
383
|
+
def search_text(
|
|
384
|
+
self,
|
|
385
|
+
object_type: MidpointObjectType,
|
|
386
|
+
query_text: str | None,
|
|
387
|
+
limit: int | None = None,
|
|
388
|
+
raw: bool = False,
|
|
389
|
+
) -> MidpointObjectList:
|
|
390
|
+
"""Objects of one type matching a Midpoint Query Language filter (all objects without one), by name.
|
|
391
|
+
|
|
392
|
+
raw searches the repository only (see get_shadows), without contacting resources.
|
|
393
|
+
"""
|
|
394
|
+
query = ObjectQuery.text(query_text, limit=limit) if query_text else ObjectQuery.listing(limit=limit)
|
|
395
|
+
return self.search_objects(object_type, query, options=['raw'] if raw else [])
|
|
396
|
+
|
|
397
|
+
def get_user(self, oid: str) -> MidpointUser:
|
|
398
|
+
user_root = self.__get_object(MidpointObjectTypes.USER.value, oid)
|
|
399
|
+
user = MidpointUser(user_root)
|
|
400
|
+
return user
|
|
401
|
+
|
|
402
|
+
def get_users(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
|
|
403
|
+
return self.__get_collection(MidpointObjectTypes.USER.value, MidpointUser, limit, name_filter)
|
|
404
|
+
|
|
405
|
+
def get_orgs(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
|
|
406
|
+
return self.__get_collection(MidpointObjectTypes.ORG.value, MidpointOrganization, limit, name_filter)
|
|
407
|
+
|
|
408
|
+
def get_orgs_by_oid(self, oids: list[str]) -> MidpointObjectList:
|
|
409
|
+
"""The organizations with the given OIDs, in one search; unknown OIDs are skipped."""
|
|
410
|
+
if not oids:
|
|
411
|
+
return MidpointObjectList()
|
|
412
|
+
return self.search_objects(MidpointObjectTypes.ORG.value, ObjectQuery.oids(oids))
|
|
413
|
+
|
|
414
|
+
def get_roles(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
|
|
415
|
+
return self.__get_collection(MidpointObjectTypes.ROLE.value, MidpointRole, limit, name_filter)
|
|
416
|
+
|
|
417
|
+
def search_users(self, terms: list[str]) -> MidpointObjectList:
|
|
418
|
+
"""Users whose name, full name or email address contains the terms (see ObjectQuery.terms)."""
|
|
419
|
+
return self.search_objects(MidpointObjectTypes.USER.value, ObjectQuery.terms(terms, USER_SEARCH_PATHS))
|
|
420
|
+
|
|
421
|
+
def search_roles(self, terms: list[str]) -> MidpointObjectList:
|
|
422
|
+
"""Roles whose name or display name contains the terms (see ObjectQuery.terms)."""
|
|
423
|
+
return self.search_objects(MidpointObjectTypes.ROLE.value, ObjectQuery.terms(terms, ROLE_SEARCH_PATHS))
|
|
424
|
+
|
|
425
|
+
def search_orgs(self, terms: list[str]) -> MidpointObjectList:
|
|
426
|
+
"""Organizations whose name or display name contains the terms (see ObjectQuery.terms)."""
|
|
427
|
+
return self.search_objects(MidpointObjectTypes.ORG.value, ObjectQuery.terms(terms, ORG_SEARCH_PATHS))
|
|
428
|
+
|
|
429
|
+
def _modify(
|
|
430
|
+
self, object_type: MidpointObjectType, oid: str, modification_type: str, path: str, value_xml: str
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Apply one item delta to an object: PATCH ws/rest/<collection>/<oid> with an ObjectModificationType."""
|
|
433
|
+
xml_body = (
|
|
434
|
+
'<objectModification'
|
|
435
|
+
f" xmlns='{API_TYPES_NS}'"
|
|
436
|
+
f" xmlns:c='{COMMON_NS}'"
|
|
437
|
+
f" xmlns:t='{TYPES_NS}'>"
|
|
438
|
+
'<itemDelta>'
|
|
439
|
+
f'<t:modificationType>{modification_type}</t:modificationType>'
|
|
440
|
+
f'<t:path>{path}</t:path>'
|
|
441
|
+
f'<t:value>{value_xml}</t:value>'
|
|
442
|
+
'</itemDelta>'
|
|
443
|
+
'</objectModification>'
|
|
444
|
+
)
|
|
445
|
+
self.api_client.patch_element(object_type, oid, xml_body)
|
|
446
|
+
|
|
447
|
+
@staticmethod
|
|
448
|
+
def _target_ref(target_type: MidpointObjectType, target_oid: str) -> str:
|
|
449
|
+
return f"<c:targetRef oid={quoteattr(target_oid)} type='c:{target_type.object_type}'/>"
|
|
450
|
+
|
|
451
|
+
def assign(self, user_oid: str, target_type: MidpointObjectType, target_oid: str) -> None:
|
|
452
|
+
"""Add an assignment of a role, org, service or archetype (target_type) to a user."""
|
|
453
|
+
self._modify(
|
|
454
|
+
MidpointObjectTypes.USER.value, user_oid, 'add', 'c:assignment', self._target_ref(target_type, target_oid)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def unassign(self, user_oid: str, target_type: MidpointObjectType, target_oid: str) -> None:
|
|
458
|
+
"""Remove a user's assignment of a role, org, service or archetype, matched by its target."""
|
|
459
|
+
self._modify(
|
|
460
|
+
MidpointObjectTypes.USER.value,
|
|
461
|
+
user_oid,
|
|
462
|
+
'delete',
|
|
463
|
+
'c:assignment',
|
|
464
|
+
self._target_ref(target_type, target_oid),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def assign_role(self, user_oid: str, role_oid: str) -> None:
|
|
468
|
+
self.assign(user_oid, MidpointObjectTypes.ROLE.value, role_oid)
|
|
469
|
+
|
|
470
|
+
def unassign_role(self, user_oid: str, role_oid: str) -> None:
|
|
471
|
+
self.unassign(user_oid, MidpointObjectTypes.ROLE.value, role_oid)
|
|
472
|
+
|
|
473
|
+
def set_user_enabled(self, user_oid: str, enabled: bool) -> None:
|
|
474
|
+
"""Enable or disable a user by replacing activation/administrativeStatus."""
|
|
475
|
+
status = 'enabled' if enabled else 'disabled'
|
|
476
|
+
self._modify(MidpointObjectTypes.USER.value, user_oid, 'replace', 'c:activation/c:administrativeStatus', status)
|
|
477
|
+
|
|
478
|
+
def set_user_password(self, user_oid: str, password: str) -> None:
|
|
479
|
+
"""Replace a user's password with a protected string in clear (t:clearValue); the server encrypts it.
|
|
480
|
+
|
|
481
|
+
The password only travels in the request body: it is never logged, and errors carry the
|
|
482
|
+
server's message, not the request.
|
|
483
|
+
"""
|
|
484
|
+
self._modify(
|
|
485
|
+
MidpointObjectTypes.USER.value,
|
|
486
|
+
user_oid,
|
|
487
|
+
'replace',
|
|
488
|
+
'c:credentials/c:password/c:value',
|
|
489
|
+
f'<t:clearValue>{xml_escape(password)}</t:clearValue>',
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def get_resource(self, oid: str) -> MidpointResource:
|
|
493
|
+
return MidpointResource(self.__get_object(MidpointObjectTypes.RESOURCE.value, oid))
|
|
494
|
+
|
|
495
|
+
def get_role(self, oid: str) -> MidpointRole:
|
|
496
|
+
return MidpointRole(self.__get_object(MidpointObjectTypes.ROLE.value, oid))
|
|
497
|
+
|
|
498
|
+
def get_org(self, oid: str) -> MidpointOrganization:
|
|
499
|
+
return MidpointOrganization(self.__get_object(MidpointObjectTypes.ORG.value, oid))
|
|
500
|
+
|
|
501
|
+
def get_shadow(self, oid: str) -> MidpointShadow:
|
|
502
|
+
return MidpointShadow(self.__get_object(MidpointObjectTypes.SHADOW.value, oid))
|
|
503
|
+
|
|
504
|
+
def task_action(
|
|
505
|
+
self,
|
|
506
|
+
task_oid: str,
|
|
507
|
+
task_action: str,
|
|
508
|
+
progress_monitor: ProgressMonitor,
|
|
509
|
+
show_changes: bool = True,
|
|
510
|
+
) -> MidpointTask | None:
|
|
511
|
+
run_started_at = datetime.now(UTC)
|
|
512
|
+
self.api_client.execute_action(MidpointObjectTypes.TASK.value, task_oid, task_action)
|
|
513
|
+
|
|
514
|
+
if task_action == 'run':
|
|
515
|
+
return self.task_wait(task_oid, progress_monitor, show_changes=show_changes, run_started_at=run_started_at)
|
|
516
|
+
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
def task_wait(
|
|
520
|
+
self,
|
|
521
|
+
task_oid: str,
|
|
522
|
+
progress_monitor: ProgressMonitor,
|
|
523
|
+
show_changes: bool = True,
|
|
524
|
+
run_started_at: datetime | None = None,
|
|
525
|
+
) -> MidpointTask:
|
|
526
|
+
server_version = self.get_server_version()
|
|
527
|
+
# First, get the initial task state to extract start time
|
|
528
|
+
task_root = self.__get_object(MidpointObjectTypes.TASK.value, task_oid)
|
|
529
|
+
initial_task = MidpointTask(task_root, server_version)
|
|
530
|
+
|
|
531
|
+
# Set the task start time on the progress monitor before entering
|
|
532
|
+
task_start_time = initial_task.get_start_time()
|
|
533
|
+
progress_monitor.set_task_start_time(task_start_time)
|
|
534
|
+
|
|
535
|
+
# Audit-stream state — only used when show_changes is on and the task
|
|
536
|
+
# advertises a taskIdentifier (the join key into audit records).
|
|
537
|
+
task_identifier = initial_task.get_task_identifier() if show_changes else None
|
|
538
|
+
audit_cursor = initial_audit_cursor(run_started_at, task_start_time)
|
|
539
|
+
seen_event_ids: set[str] = set()
|
|
540
|
+
# Set to False on the first MidpointServerError so we stop retrying on
|
|
541
|
+
# servers that refuse or cannot run the audit script.
|
|
542
|
+
audit_available: bool = True
|
|
543
|
+
|
|
544
|
+
with progress_monitor as progress:
|
|
545
|
+
# Update with initial progress and statistics
|
|
546
|
+
initial_progress = int(initial_task['Progress'] or '0')
|
|
547
|
+
initial_success = initial_task.get_success_count()
|
|
548
|
+
initial_failure = initial_task.get_failure_count()
|
|
549
|
+
progress.set_expected_total(_expected_total(initial_task))
|
|
550
|
+
progress.update_with_stats(initial_progress, initial_success, initial_failure)
|
|
551
|
+
|
|
552
|
+
# Drain any audit events that already happened before we attached.
|
|
553
|
+
if task_identifier and audit_available:
|
|
554
|
+
try:
|
|
555
|
+
audit_cursor = self._stream_task_audit_events(
|
|
556
|
+
task_identifier, audit_cursor, seen_event_ids, progress
|
|
557
|
+
)
|
|
558
|
+
except MidpointServerError:
|
|
559
|
+
audit_available = False
|
|
560
|
+
|
|
561
|
+
# Check if task is already completed
|
|
562
|
+
rstatus = initial_task['Result Status']
|
|
563
|
+
if rstatus != ResultStatus.IN_PROGRESS:
|
|
564
|
+
print(file=sys.stderr) # ends the progress line, which is on stderr
|
|
565
|
+
if rstatus != ResultStatus.SUCCESS:
|
|
566
|
+
raise TaskExecutionFailure(
|
|
567
|
+
'Failed execution of task ' + task_oid + ' with status ' + rstatus, initial_task
|
|
568
|
+
)
|
|
569
|
+
return initial_task
|
|
570
|
+
|
|
571
|
+
# Continue polling for updates
|
|
572
|
+
while True:
|
|
573
|
+
time.sleep(2)
|
|
574
|
+
task_root = self.__get_object(MidpointObjectTypes.TASK.value, task_oid)
|
|
575
|
+
task = MidpointTask(task_root, server_version)
|
|
576
|
+
|
|
577
|
+
# Update with progress and statistics
|
|
578
|
+
current_progress = int(task['Progress'] or '0')
|
|
579
|
+
current_success = task.get_success_count()
|
|
580
|
+
current_failure = task.get_failure_count()
|
|
581
|
+
progress.set_expected_total(_expected_total(task))
|
|
582
|
+
progress.update_with_stats(current_progress, current_success, current_failure)
|
|
583
|
+
|
|
584
|
+
if task_identifier and audit_available:
|
|
585
|
+
try:
|
|
586
|
+
audit_cursor = self._stream_task_audit_events(
|
|
587
|
+
task_identifier, audit_cursor, seen_event_ids, progress
|
|
588
|
+
)
|
|
589
|
+
except MidpointServerError:
|
|
590
|
+
audit_available = False
|
|
591
|
+
|
|
592
|
+
rstatus = task['Result Status']
|
|
593
|
+
|
|
594
|
+
if rstatus != ResultStatus.IN_PROGRESS:
|
|
595
|
+
# One last drain so trailing events that landed between the
|
|
596
|
+
# final fetch and the status flip aren't lost.
|
|
597
|
+
if task_identifier and audit_available:
|
|
598
|
+
with contextlib.suppress(MidpointServerError):
|
|
599
|
+
self._stream_task_audit_events(task_identifier, audit_cursor, seen_event_ids, progress)
|
|
600
|
+
print(file=sys.stderr) # ends the progress line, which is on stderr
|
|
601
|
+
if rstatus != ResultStatus.SUCCESS:
|
|
602
|
+
raise TaskExecutionFailure(
|
|
603
|
+
'Failed execution of task ' + task_oid + ' with status ' + rstatus, task
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return task
|
|
607
|
+
|
|
608
|
+
def _stream_task_audit_events(
|
|
609
|
+
self,
|
|
610
|
+
task_identifier: str,
|
|
611
|
+
cursor: datetime | None,
|
|
612
|
+
seen: set[str],
|
|
613
|
+
progress: ProgressMonitor,
|
|
614
|
+
) -> datetime | None:
|
|
615
|
+
"""Fetch new audit events for the running task and push each through the monitor.
|
|
616
|
+
|
|
617
|
+
The cursor is inclusive, so events sharing the newest timestamp already seen are
|
|
618
|
+
fetched again; ``seen`` drops the ones already shown. Returns the advanced cursor.
|
|
619
|
+
"""
|
|
620
|
+
if cursor is not None:
|
|
621
|
+
cursor = as_utc(cursor)
|
|
622
|
+
events = self.search_audit(
|
|
623
|
+
AuditQuery(task_identifier=task_identifier, since=cursor, since_inclusive=True, limit=0)
|
|
624
|
+
)
|
|
625
|
+
# Audit search returns descending by timestamp; replay chronologically.
|
|
626
|
+
for ev in reversed(events):
|
|
627
|
+
event_id = ev.get('EventIdentifier')
|
|
628
|
+
if not event_id or event_id in seen:
|
|
629
|
+
continue
|
|
630
|
+
seen.add(event_id)
|
|
631
|
+
progress.log_event(ev)
|
|
632
|
+
ts_text = ev.get('Timestamp')
|
|
633
|
+
ts = parse_audit_timestamp(ts_text) if ts_text else None
|
|
634
|
+
if ts and (cursor is None or ts > cursor):
|
|
635
|
+
cursor = ts
|
|
636
|
+
return cursor
|
|
637
|
+
|
|
638
|
+
def create_task(self, task_xml: str) -> str:
|
|
639
|
+
"""Create a task (POST ws/rest/tasks) and return its OID; a runnable task is started by the server."""
|
|
640
|
+
oid = self.api_client.create_element(MidpointObjectTypes.TASK.value, task_xml)
|
|
641
|
+
if oid is None:
|
|
642
|
+
raise MidpointServerError('The server accepted the task but did not report its OID')
|
|
643
|
+
return oid
|
|
644
|
+
|
|
645
|
+
def start_resource_task(
|
|
646
|
+
self,
|
|
647
|
+
operation: str,
|
|
648
|
+
resource: MidpointObject,
|
|
649
|
+
kind: str | None = None,
|
|
650
|
+
intent: str | None = None,
|
|
651
|
+
object_class: str | None = None,
|
|
652
|
+
) -> tuple[str, str]:
|
|
653
|
+
"""Create and start an import ('import') or reconciliation ('reconcile') task for a resource.
|
|
654
|
+
|
|
655
|
+
Returns the OID and name of the new task.
|
|
656
|
+
"""
|
|
657
|
+
resource_oid = resource.get_oid()
|
|
658
|
+
if resource_oid is None:
|
|
659
|
+
raise AssertionError('Resource OID cannot be None')
|
|
660
|
+
name = resource_task_name(operation, resource.get_name() or resource_oid, kind, intent, object_class)
|
|
661
|
+
task_xml = resource_task_xml(operation, name, resource_oid, kind, intent, object_class)
|
|
662
|
+
return self.create_task(task_xml), name
|
|
663
|
+
|
|
664
|
+
def start_report_task(self, report: MidpointObject) -> tuple[str, str]:
|
|
665
|
+
"""Create and start a classic export task for a report; returns the OID and name of the new task."""
|
|
666
|
+
report_oid = report.get_oid()
|
|
667
|
+
if report_oid is None:
|
|
668
|
+
raise AssertionError('Report OID cannot be None')
|
|
669
|
+
name = report_task_name(report.get_name() or report_oid)
|
|
670
|
+
return self.create_task(report_task_xml(name, report_oid)), name
|
|
671
|
+
|
|
672
|
+
def wait_for_task_start(self, task_oid: str) -> None:
|
|
673
|
+
"""Wait until a newly created task has started (it has a result) or was stopped.
|
|
674
|
+
|
|
675
|
+
task_wait reads a task without a result as failed, so a new task is only handed to it
|
|
676
|
+
once the scheduler has picked it up.
|
|
677
|
+
"""
|
|
678
|
+
for attempt in range(TASK_START_ATTEMPTS):
|
|
679
|
+
task = self.get_object(MidpointObjectTypes.TASK.value, task_oid)
|
|
680
|
+
if task.get('Result Status') or task.get('Execution Status') in (
|
|
681
|
+
TaskExecutionState.SUSPENDED,
|
|
682
|
+
TaskExecutionState.CLOSED,
|
|
683
|
+
):
|
|
684
|
+
return
|
|
685
|
+
if attempt + 1 < TASK_START_ATTEMPTS:
|
|
686
|
+
time.sleep(TASK_START_POLL_SECONDS)
|
|
687
|
+
raise TaskExecutionFailure(
|
|
688
|
+
f'Task {task_oid} did not start within {TASK_START_ATTEMPTS * TASK_START_POLL_SECONDS} seconds; '
|
|
689
|
+
f'it is still waiting for the server to run it'
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
def run_new_task(
|
|
693
|
+
self,
|
|
694
|
+
task_oid: str,
|
|
695
|
+
progress_monitor: ProgressMonitor,
|
|
696
|
+
show_changes: bool = True,
|
|
697
|
+
run_started_at: datetime | None = None,
|
|
698
|
+
) -> MidpointTask:
|
|
699
|
+
"""Wait for a task created by create_task to start, then follow it to completion like task_wait."""
|
|
700
|
+
self.wait_for_task_start(task_oid)
|
|
701
|
+
return self.task_wait(task_oid, progress_monitor, show_changes=show_changes, run_started_at=run_started_at)
|
|
702
|
+
|
|
703
|
+
def test_resource(self, resource_oid: str) -> str:
|
|
704
|
+
response = self.api_client.execute_action(MidpointObjectTypes.RESOURCE.value, resource_oid, 'test')
|
|
705
|
+
tree = safe_fromstring(response)
|
|
706
|
+
status_node = tree.find('c:status', namespaces)
|
|
707
|
+
return status_node.text if status_node is not None and status_node.text is not None else 'unknown'
|
|
708
|
+
|
|
709
|
+
def get_xml(self, midpoint_type: MidpointObjectType, oid: str) -> str:
|
|
710
|
+
return self.api_client.get_element(midpoint_type, oid)
|
|
711
|
+
|
|
712
|
+
def put_xml(self, xml_file: str, patch_file: str | None = None, patch_write: bool = False) -> PutResult:
|
|
713
|
+
return self.api_client.put_element(xml_file, patch_file, patch_write)
|
|
714
|
+
|
|
715
|
+
def delete(self, midpoint_type: MidpointObjectType, oid: str) -> str:
|
|
716
|
+
return self.api_client.delete(midpoint_type, oid)
|
|
717
|
+
|
|
718
|
+
def search_audit(self, query: AuditQuery) -> MidpointObjectList:
|
|
719
|
+
"""Run an audit search and return the matching events as MidpointAuditEvent objects.
|
|
720
|
+
|
|
721
|
+
Tunnels through ``rpc/executeScript`` because Midpoint has no REST endpoint
|
|
722
|
+
for audit search. Midpoint 4.11+ refuses that Groovy script by default; such
|
|
723
|
+
refusals are raised as ``MidpointAuditScriptRefused`` with the settings to change.
|
|
724
|
+
A script that does not compile or link against the server's internal classes
|
|
725
|
+
is raised as ``MidpointAuditScriptIncompatible``, naming the server version.
|
|
726
|
+
"""
|
|
727
|
+
server_version = self.get_server_version()
|
|
728
|
+
try:
|
|
729
|
+
response_xml = self.api_client.post_xml(
|
|
730
|
+
AUDIT_EXEC_SCRIPT_PATH, build_execute_script_body(query, server_version)
|
|
731
|
+
)
|
|
732
|
+
except MidpointServerError as e:
|
|
733
|
+
replacement = audit_script_error(e, server_version)
|
|
734
|
+
if replacement is not None:
|
|
735
|
+
raise replacement from e
|
|
736
|
+
raise
|
|
737
|
+
return MidpointObjectList(parse_audit_events_from_script_response(response_xml))
|
|
738
|
+
|
|
739
|
+
def get_server_version(self) -> MidpointVersion | None:
|
|
740
|
+
"""Return the server's Midpoint version, read once from ``actuator/info`` and cached.
|
|
741
|
+
|
|
742
|
+
Returns None when the server does not tell: callers then fall back to heuristics.
|
|
743
|
+
A failure to reach the server is not cached, so a later call tries again.
|
|
744
|
+
"""
|
|
745
|
+
if not self._server_version_detected:
|
|
746
|
+
try:
|
|
747
|
+
self._remember_server_version(self._get_actuator_info())
|
|
748
|
+
except (MidpointServerError, ValueError) as e:
|
|
749
|
+
logger.debug('Could not detect the Midpoint server version: %s', e)
|
|
750
|
+
unreachable = isinstance(e, MidpointMissingPassword) or isinstance(
|
|
751
|
+
e.__cause__, requests.RequestException
|
|
752
|
+
)
|
|
753
|
+
self._server_version_detected = not unreachable
|
|
754
|
+
return self._server_version
|
|
755
|
+
|
|
756
|
+
def _remember_server_version(self, actuator_info: dict[str, Any]) -> None:
|
|
757
|
+
self._server_version = parse_server_version(actuator_info.get('version'))
|
|
758
|
+
self._server_version_detected = True
|
|
759
|
+
|
|
760
|
+
def _get_actuator_info(self) -> dict[str, Any]:
|
|
761
|
+
raw = self.api_client.get_endpoint('actuator/info')
|
|
762
|
+
data = json.loads(raw)
|
|
763
|
+
if not isinstance(data, dict):
|
|
764
|
+
raise ValueError(f'Unexpected actuator/info content: {raw[:100]!r}')
|
|
765
|
+
return data
|
|
766
|
+
|
|
767
|
+
def get_logging_configuration(self) -> LoggingConfiguration:
|
|
768
|
+
"""The root logger level and the class loggers of the system configuration."""
|
|
769
|
+
return parse_logging(
|
|
770
|
+
self.__get_object(MidpointObjectTypes.SYSTEM_CONFIGURATION.value, SYSTEM_CONFIGURATION_OID)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
def set_class_logger(self, package: str, level: str) -> str:
|
|
774
|
+
"""Set the level of a class logger in the system configuration, adding the logger if needed.
|
|
775
|
+
|
|
776
|
+
Only the logging items are patched, never the whole system configuration. Returns
|
|
777
|
+
'added', 'updated', or 'unchanged' when the logger already had that level (nothing is
|
|
778
|
+
sent then). Raises ValueError for an invalid level or logger name.
|
|
779
|
+
"""
|
|
780
|
+
level = normalize_level(level)
|
|
781
|
+
package = check_logger_name(package)
|
|
782
|
+
existing = self.get_logging_configuration().loggers_of(package)
|
|
783
|
+
if existing and all(logger.level == level for logger in existing):
|
|
784
|
+
return 'unchanged'
|
|
785
|
+
self.api_client.patch_element(
|
|
786
|
+
MidpointObjectTypes.SYSTEM_CONFIGURATION.value,
|
|
787
|
+
SYSTEM_CONFIGURATION_OID,
|
|
788
|
+
class_logger_modification(existing, package, level),
|
|
789
|
+
)
|
|
790
|
+
return 'updated' if existing else 'added'
|
|
791
|
+
|
|
792
|
+
def get_system_info(self) -> dict[str, str]:
|
|
793
|
+
"""The properties `info` shows, by name (see get_server_information)."""
|
|
794
|
+
return self.get_server_information().properties
|
|
795
|
+
|
|
796
|
+
def get_server_information(self) -> ServerInformation:
|
|
797
|
+
"""Gather what `info` shows from actuator/info, the nodes, the system configuration and ws/rest/self.
|
|
798
|
+
|
|
799
|
+
Each source is read on its own, so one the account may not read (403) or a proxy hides
|
|
800
|
+
(404) only leaves its properties out and is listed in ``unavailable``. A failure every
|
|
801
|
+
other call would repeat (no answer, no password, 401) is raised at once, as is the
|
|
802
|
+
first error when no source could be read.
|
|
803
|
+
"""
|
|
804
|
+
info = ServerInformation()
|
|
805
|
+
failures: list[MidpointServerError] = []
|
|
806
|
+
|
|
807
|
+
def read(source: str, reader: Callable[[], Any]) -> Any:
|
|
808
|
+
try:
|
|
809
|
+
return reader()
|
|
810
|
+
except (MidpointServerError, ValueError, ElementTree.ParseError) as e:
|
|
811
|
+
if isinstance(e, MidpointServerError) and _repeats_on_every_call(e):
|
|
812
|
+
raise
|
|
813
|
+
logger.debug('Could not read %s: %s', source, e)
|
|
814
|
+
info.unavailable.append(f'{source}: {e}')
|
|
815
|
+
failures.append(e if isinstance(e, MidpointServerError) else MidpointServerError(f'{source}: {e}'))
|
|
816
|
+
return None
|
|
817
|
+
|
|
818
|
+
actuator = read('actuator/info', self._get_actuator_info)
|
|
819
|
+
nodes = read('nodes', lambda: parse_nodes(self.api_client.get_elements(MidpointObjectTypes.NODE.value)))
|
|
820
|
+
system_configuration = read(
|
|
821
|
+
'system configuration',
|
|
822
|
+
lambda: self.__get_object(MidpointObjectTypes.SYSTEM_CONFIGURATION.value, SYSTEM_CONFIGURATION_OID),
|
|
823
|
+
)
|
|
824
|
+
logged_in = read(
|
|
825
|
+
'ws/rest/self', lambda: parse_logged_in_user(safe_fromstring(self.api_client.get_endpoint('ws/rest/self')))
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
if actuator is not None:
|
|
829
|
+
if not self._server_version_detected:
|
|
830
|
+
self._remember_server_version(actuator)
|
|
831
|
+
for key, label in ACTUATOR_FIELDS:
|
|
832
|
+
if actuator.get(key):
|
|
833
|
+
info.properties[label] = str(actuator[key])
|
|
834
|
+
|
|
835
|
+
# Without actuator/info, the build of the nodes still tells the version.
|
|
836
|
+
node_versions = sorted({node.version for node in nodes or [] if node.version})
|
|
837
|
+
version = self._server_version
|
|
838
|
+
if 'Midpoint Version' not in info.properties and node_versions:
|
|
839
|
+
info.properties['Midpoint Version'] = ', '.join(node_versions)
|
|
840
|
+
if len(node_versions) == 1:
|
|
841
|
+
version = parse_server_version(node_versions[0])
|
|
842
|
+
|
|
843
|
+
repository = describe_repository(version)
|
|
844
|
+
if repository is not None:
|
|
845
|
+
info.properties['Repository'] = repository
|
|
846
|
+
if system_configuration is not None:
|
|
847
|
+
info.properties.update(parse_deployment(system_configuration))
|
|
848
|
+
if nodes is not None:
|
|
849
|
+
info.properties['Nodes'] = str(len(nodes))
|
|
850
|
+
for node in nodes:
|
|
851
|
+
info.properties[f'Node {node.identifier}'] = node.describe()
|
|
852
|
+
if logged_in is not None:
|
|
853
|
+
info.properties['Logged In As'] = logged_in
|
|
854
|
+
|
|
855
|
+
if not info.properties and failures:
|
|
856
|
+
raise failures[0]
|
|
857
|
+
return info
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _repeats_on_every_call(error: MidpointServerError) -> bool:
|
|
861
|
+
"""Whether every further REST call would fail the same way: no password, no answer, or rejected credentials."""
|
|
862
|
+
return (
|
|
863
|
+
isinstance(error, MidpointMissingPassword)
|
|
864
|
+
or isinstance(error.__cause__, requests.RequestException)
|
|
865
|
+
or error.status_code == 401
|
|
866
|
+
)
|