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,247 @@
|
|
|
1
|
+
"""Audit query construction for Midpoint REST audit search.
|
|
2
|
+
|
|
3
|
+
Midpoint (up to 4.11 at least) does not expose a dedicated REST endpoint for
|
|
4
|
+
audit search. We tunnel the search through the bulk-actions ``executeScript``
|
|
5
|
+
endpoint: a tiny Groovy snippet looks up the ``AuditService`` Spring bean, parses
|
|
6
|
+
the QueryType XML built here, runs ``searchObjects``, and returns each
|
|
7
|
+
record as a serialized XML string. The CLI then decodes that string and
|
|
8
|
+
parses each ``<aud:auditEventRecord>`` back into ``MidpointAuditEvent``.
|
|
9
|
+
|
|
10
|
+
Midpoint 4.11 made MEL the default script language and denies Groovy to every
|
|
11
|
+
expression profile out of the box, and MEL cannot reach the audit service. The
|
|
12
|
+
script therefore names Groovy explicitly, and 4.11 servers must opt in to it.
|
|
13
|
+
|
|
14
|
+
The script uses Midpoint-internal classes, not a public API, so a release that
|
|
15
|
+
moves or changes them breaks it: the client reports such compile and linkage
|
|
16
|
+
errors with the server version (see ``MidpointAuditScriptIncompatible``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import UTC, datetime, timedelta
|
|
23
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
24
|
+
|
|
25
|
+
from midpoint_cli.client.constants import COMMON_NS, QUERY_NS, SCRIPTING_NS
|
|
26
|
+
from midpoint_cli.client.server_version import MidpointVersion, audit_context_holder_class
|
|
27
|
+
|
|
28
|
+
AUDIT_EXEC_SCRIPT_PATH = 'ws/rest/rpc/executeScript'
|
|
29
|
+
GROOVY_LANGUAGE_URI = 'http://midpoint.evolveum.com/xml/ns/public/expression/language#Groovy'
|
|
30
|
+
|
|
31
|
+
_DURATION_RE = re.compile(r'^\s*(\d+)\s*([smhdw])\s*$', re.IGNORECASE)
|
|
32
|
+
_UNIT_SECONDS = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400, 'w': 604800}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_duration(spec: str) -> datetime:
|
|
36
|
+
"""Resolve a duration spec or absolute ISO-8601 timestamp to an aware UTC datetime.
|
|
37
|
+
|
|
38
|
+
Relative forms ``Ns``/``Nm``/``Nh``/``Nd``/``Nw`` are subtracted from now.
|
|
39
|
+
"""
|
|
40
|
+
if not spec:
|
|
41
|
+
raise ValueError('Empty duration specification')
|
|
42
|
+
|
|
43
|
+
match = _DURATION_RE.match(spec)
|
|
44
|
+
if match:
|
|
45
|
+
value = int(match.group(1))
|
|
46
|
+
unit = match.group(2).lower()
|
|
47
|
+
delta = timedelta(seconds=value * _UNIT_SECONDS[unit])
|
|
48
|
+
return datetime.now(UTC) - delta
|
|
49
|
+
|
|
50
|
+
iso = spec.strip()
|
|
51
|
+
if iso.endswith('Z'):
|
|
52
|
+
iso = iso[:-1] + '+00:00'
|
|
53
|
+
try:
|
|
54
|
+
dt = datetime.fromisoformat(iso)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f'Invalid duration or timestamp: {spec!r}. Expected forms like "1h", "30m", "2d", "1w" '
|
|
58
|
+
'or an ISO-8601 timestamp such as "2026-05-09T08:00:00Z".'
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
if dt.tzinfo is None:
|
|
62
|
+
dt = dt.replace(tzinfo=UTC)
|
|
63
|
+
return dt.astimezone(UTC)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_xsd_datetime(dt: datetime) -> str:
|
|
67
|
+
aware = dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
|
|
68
|
+
aware = aware.astimezone(UTC)
|
|
69
|
+
return aware.strftime('%Y-%m-%dT%H:%M:%S.') + f'{aware.microsecond // 1000:03d}Z'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class AuditQuery:
|
|
74
|
+
"""Filter parameters for an audit search request.
|
|
75
|
+
|
|
76
|
+
``since`` is exclusive (``q:greater``) unless ``since_inclusive`` is set, which
|
|
77
|
+
switches it to ``q:greaterOrEqual``. Streaming cursors need the inclusive form:
|
|
78
|
+
an event committed later with the same timestamp as the newest one already
|
|
79
|
+
seen must still match, and the caller drops the repeats by event identifier.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
since: datetime | None = None
|
|
83
|
+
since_inclusive: bool = False
|
|
84
|
+
until: datetime | None = None
|
|
85
|
+
initiator_oid: str | None = None
|
|
86
|
+
target_oid: str | None = None
|
|
87
|
+
target_type: str | None = None
|
|
88
|
+
channel: str | None = None
|
|
89
|
+
outcome: str | None = None
|
|
90
|
+
event_type: str | None = None
|
|
91
|
+
event_stage: str | None = None
|
|
92
|
+
event_identifier: str | None = None
|
|
93
|
+
task_identifier: str | None = None
|
|
94
|
+
limit: int = 50
|
|
95
|
+
_equal_filters: list[tuple[str, str]] = field(default_factory=list, init=False, repr=False)
|
|
96
|
+
|
|
97
|
+
def to_xml(self) -> str:
|
|
98
|
+
"""Render the query as a Midpoint ``<q:query>`` XML document."""
|
|
99
|
+
filters: list[str] = []
|
|
100
|
+
|
|
101
|
+
if self.since is not None:
|
|
102
|
+
since = _format_xsd_datetime(self.since)
|
|
103
|
+
if self.since_inclusive:
|
|
104
|
+
filters.append(self._greater_or_equal_filter('timestamp', since))
|
|
105
|
+
else:
|
|
106
|
+
filters.append(self._greater_filter('timestamp', since))
|
|
107
|
+
if self.until is not None:
|
|
108
|
+
filters.append(self._less_filter('timestamp', _format_xsd_datetime(self.until)))
|
|
109
|
+
if self.event_identifier is not None:
|
|
110
|
+
filters.append(self._equal_filter('eventIdentifier', self.event_identifier))
|
|
111
|
+
if self.task_identifier is not None:
|
|
112
|
+
filters.append(self._equal_filter('taskIdentifier', self.task_identifier))
|
|
113
|
+
if self.event_type is not None:
|
|
114
|
+
filters.append(self._equal_filter('eventType', self.event_type))
|
|
115
|
+
if self.event_stage is not None:
|
|
116
|
+
filters.append(self._equal_filter('eventStage', self.event_stage))
|
|
117
|
+
if self.channel is not None:
|
|
118
|
+
filters.append(self._equal_filter('channel', self.channel))
|
|
119
|
+
if self.outcome is not None:
|
|
120
|
+
filters.append(self._equal_filter('outcome', self.outcome))
|
|
121
|
+
if self.initiator_oid is not None:
|
|
122
|
+
filters.append(self._ref_filter('initiatorRef', self.initiator_oid))
|
|
123
|
+
if self.target_oid is not None:
|
|
124
|
+
filters.append(self._ref_filter('targetRef', self.target_oid, self.target_type))
|
|
125
|
+
elif self.target_type is not None:
|
|
126
|
+
filters.append(self._ref_filter('targetRef', None, self.target_type))
|
|
127
|
+
|
|
128
|
+
for path, value in self._equal_filters:
|
|
129
|
+
filters.append(self._equal_filter(path, value))
|
|
130
|
+
|
|
131
|
+
if not filters:
|
|
132
|
+
filter_block = ''
|
|
133
|
+
elif len(filters) == 1:
|
|
134
|
+
filter_block = f'<q:filter>{filters[0]}</q:filter>'
|
|
135
|
+
else:
|
|
136
|
+
filter_block = '<q:filter><q:and>' + ''.join(filters) + '</q:and></q:filter>'
|
|
137
|
+
|
|
138
|
+
if self.limit and self.limit > 0:
|
|
139
|
+
paging = (
|
|
140
|
+
'<q:paging>'
|
|
141
|
+
f'<q:maxSize>{int(self.limit)}</q:maxSize>'
|
|
142
|
+
'<q:orderBy>timestamp</q:orderBy>'
|
|
143
|
+
'<q:orderDirection>descending</q:orderDirection>'
|
|
144
|
+
'</q:paging>'
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
paging = (
|
|
148
|
+
'<q:paging><q:orderBy>timestamp</q:orderBy><q:orderDirection>descending</q:orderDirection></q:paging>'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return f'<q:query xmlns:q="{QUERY_NS}" xmlns:c="{COMMON_NS}">{filter_block}{paging}</q:query>'
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _equal_filter(path: str, value: str) -> str:
|
|
155
|
+
return f'<q:equal><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:equal>'
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _greater_filter(path: str, value: str) -> str:
|
|
159
|
+
return f'<q:greater><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:greater>'
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _greater_or_equal_filter(path: str, value: str) -> str:
|
|
163
|
+
return f'<q:greaterOrEqual><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:greaterOrEqual>'
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _less_filter(path: str, value: str) -> str:
|
|
167
|
+
return f'<q:less><q:path>{path}</q:path><q:value>{xml_escape(value)}</q:value></q:less>'
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _ref_filter(path: str, oid: str | None, type_name: str | None = None) -> str:
|
|
171
|
+
attrs = ''
|
|
172
|
+
if oid:
|
|
173
|
+
attrs += f' oid="{xml_escape(oid, {chr(34): """})}"'
|
|
174
|
+
if type_name:
|
|
175
|
+
attrs += f' type="c:{xml_escape(type_name, {chr(34): """})}"'
|
|
176
|
+
return f'<q:ref><q:path>{path}</q:path><q:value{attrs}/></q:ref>'
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_GROOVY_TEMPLATE = """\
|
|
180
|
+
import com.evolveum.midpoint.audit.api.AuditService
|
|
181
|
+
import com.evolveum.midpoint.schema.result.OperationResult
|
|
182
|
+
import com.evolveum.midpoint.prism.PrismContext
|
|
183
|
+
import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType
|
|
184
|
+
import com.evolveum.prism.xml.ns._public.query_3.QueryType
|
|
185
|
+
import __CONTEXT_HOLDER_CLASS__
|
|
186
|
+
import javax.xml.namespace.QName
|
|
187
|
+
import java.nio.charset.StandardCharsets
|
|
188
|
+
import java.util.Base64
|
|
189
|
+
|
|
190
|
+
def QUERY_XML = new String(Base64.getDecoder().decode('__QUERY_B64__'), StandardCharsets.UTF_8)
|
|
191
|
+
def ctx = SpringApplicationContextHolder.getApplicationContext()
|
|
192
|
+
def audit = ctx.getBean(AuditService)
|
|
193
|
+
def prismCtx = ctx.getBean(PrismContext)
|
|
194
|
+
def result = new OperationResult("midpoint-cli-audit-search")
|
|
195
|
+
def queryType = prismCtx.parserFor(QUERY_XML).xml().parseRealValue(QueryType.class)
|
|
196
|
+
def query = prismCtx.getQueryConverter().createObjectQuery(AuditEventRecordType.class, queryType)
|
|
197
|
+
def records = audit.searchObjects(query, null, result)
|
|
198
|
+
def serializer = prismCtx.xmlSerializer()
|
|
199
|
+
def buf = new StringBuilder()
|
|
200
|
+
buf.append("<auditEventRecords>")
|
|
201
|
+
records.each { r ->
|
|
202
|
+
buf.append(serializer.serializeRealValue(r,
|
|
203
|
+
new QName("http://midpoint.evolveum.com/xml/ns/public/common/audit-3", "auditEventRecord")))
|
|
204
|
+
}
|
|
205
|
+
buf.append("</auditEventRecords>")
|
|
206
|
+
return buf.toString()
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_execute_script_body(query: AuditQuery, server_version: MidpointVersion | None = None) -> str:
|
|
211
|
+
"""Wrap the AuditQuery in the Groovy ``executeScript`` envelope used by the CLI.
|
|
212
|
+
|
|
213
|
+
Filter values come from the command line and may contain anything, so the
|
|
214
|
+
query XML never appears in the Groovy source: it is embedded as a base64
|
|
215
|
+
string, whose alphabet cannot close a Groovy literal or the surrounding
|
|
216
|
+
CDATA section, and decoded on the server. The Midpoint-internal class used
|
|
217
|
+
to reach the Spring context moved in 4.5, so the import follows the server
|
|
218
|
+
version (the 4.5+ location when the version is unknown).
|
|
219
|
+
"""
|
|
220
|
+
query_b64 = base64.b64encode(query.to_xml().encode('utf-8')).decode('ascii')
|
|
221
|
+
groovy = _GROOVY_TEMPLATE.replace('__CONTEXT_HOLDER_CLASS__', audit_context_holder_class(server_version))
|
|
222
|
+
groovy = groovy.replace('__QUERY_B64__', query_b64)
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
226
|
+
f'<s:executeScript xmlns:s="{SCRIPTING_NS}"'
|
|
227
|
+
f' xmlns:c="{COMMON_NS}"'
|
|
228
|
+
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
|
229
|
+
'<s:pipeline>'
|
|
230
|
+
'<s:action>'
|
|
231
|
+
'<s:type>execute-script</s:type>'
|
|
232
|
+
'<s:parameter>'
|
|
233
|
+
'<s:name>script</s:name>'
|
|
234
|
+
'<c:value xsi:type="c:ScriptExpressionEvaluatorType">'
|
|
235
|
+
# Groovy was the implicit default until Midpoint 4.11 switched to MEL
|
|
236
|
+
f'<c:language>{GROOVY_LANGUAGE_URI}</c:language>'
|
|
237
|
+
f'<c:code><![CDATA[\n{groovy}]]></c:code>'
|
|
238
|
+
'</c:value>'
|
|
239
|
+
'</s:parameter>'
|
|
240
|
+
'<s:parameter>'
|
|
241
|
+
'<s:name>forWholeInput</s:name>'
|
|
242
|
+
'<c:value>true</c:value>'
|
|
243
|
+
'</s:parameter>'
|
|
244
|
+
'</s:action>'
|
|
245
|
+
'</s:pipeline>'
|
|
246
|
+
'</s:executeScript>'
|
|
247
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Connection settings of a Midpoint client."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class MidpointClientConfiguration:
|
|
8
|
+
url: str = 'http://localhost:8080/midpoint/'
|
|
9
|
+
username: str = 'administrator'
|
|
10
|
+
# No default: a client without a password refuses to call the server (MidpointMissingPassword).
|
|
11
|
+
password: str = ''
|
|
12
|
+
# Seconds to keep retrying a request that gets no answer; None reads MIDPOINT_TIMEOUT (default 120).
|
|
13
|
+
timeout: float | None = None
|
|
14
|
+
# Seconds to wait for the answer to one REST call; None reads MIDPOINT_READ_TIMEOUT (default 300).
|
|
15
|
+
read_timeout: float | None = None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Schema constants shared by the client modules: XML namespaces and status values.
|
|
2
|
+
|
|
3
|
+
Kept free of imports so every client module, including the dependency-free query builders,
|
|
4
|
+
can use it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
# XML namespaces of the Midpoint schemas.
|
|
10
|
+
COMMON_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/common/common-3'
|
|
11
|
+
AUDIT_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/common/audit-3'
|
|
12
|
+
API_TYPES_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/common/api-types-3'
|
|
13
|
+
SCRIPTING_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/model/scripting-3'
|
|
14
|
+
RESOURCE_INSTANCE_NS: Final = 'http://midpoint.evolveum.com/xml/ns/public/resource/instance-3'
|
|
15
|
+
QUERY_NS: Final = 'http://prism.evolveum.com/xml/ns/public/query-3'
|
|
16
|
+
TYPES_NS: Final = 'http://prism.evolveum.com/xml/ns/public/types-3'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResultStatus:
|
|
20
|
+
"""Values of OperationResultStatusType: a task's last run result, an operation's outcome."""
|
|
21
|
+
|
|
22
|
+
SUCCESS: Final = 'success'
|
|
23
|
+
WARNING: Final = 'warning'
|
|
24
|
+
PARTIAL_ERROR: Final = 'partial_error'
|
|
25
|
+
HANDLED_ERROR: Final = 'handled_error'
|
|
26
|
+
FATAL_ERROR: Final = 'fatal_error'
|
|
27
|
+
IN_PROGRESS: Final = 'in_progress'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskExecutionState:
|
|
31
|
+
"""Values of TaskExecutionStateType, a task's executionState (Midpoint 4.4+)."""
|
|
32
|
+
|
|
33
|
+
RUNNING: Final = 'running'
|
|
34
|
+
RUNNABLE: Final = 'runnable'
|
|
35
|
+
WAITING: Final = 'waiting'
|
|
36
|
+
SUSPENDED: Final = 'suspended'
|
|
37
|
+
CLOSED: Final = 'closed'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Exceptions raised by the Midpoint client."""
|
|
2
|
+
|
|
3
|
+
from midpoint_cli.client.objects import MidpointObjectType, MidpointTask
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MidpointServerError(Exception):
|
|
7
|
+
# HTTP status of the failed REST call, when the server answered one.
|
|
8
|
+
status_code: int | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MidpointUnsupportedOperation(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MidpointInvalidOid(MidpointServerError):
|
|
16
|
+
"""An OID or path segment was refused before being joined into a REST URL.
|
|
17
|
+
|
|
18
|
+
Subclasses MidpointServerError so command handlers report it like any other failed call.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MidpointObjectNotFound(MidpointServerError):
|
|
23
|
+
"""The server answered 404: the requested object does not exist (or is not visible to the user)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MidpointAmbiguousName(MidpointServerError):
|
|
27
|
+
"""A name given instead of an OID matches several objects, so the CLI refuses to pick one.
|
|
28
|
+
|
|
29
|
+
Names are only unique per type for some types: task names, for instance, often repeat.
|
|
30
|
+
Subclasses MidpointServerError so command handlers report it like any other failed lookup.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, object_type: MidpointObjectType, name: str, oids: list[str]):
|
|
34
|
+
self.name = name
|
|
35
|
+
self.oids = oids
|
|
36
|
+
super().__init__(
|
|
37
|
+
f'Ambiguous {object_type.tagname} name {name!r}: {len(oids)} objects have it '
|
|
38
|
+
f'(OIDs: {", ".join(oids)}). Use an OID instead.'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MidpointMissingPassword(MidpointServerError):
|
|
43
|
+
"""The client configuration holds no password, so no REST call is sent.
|
|
44
|
+
|
|
45
|
+
Subclasses MidpointServerError so command handlers report it like any other failed call.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MidpointClientNotConfigured(Exception):
|
|
50
|
+
"""Raised when a MidpointClient built without a configuration or REST client tries to talk to a server."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MidpointAuditScriptRefused(MidpointServerError):
|
|
54
|
+
"""The server refused the Groovy bulk action that audit search relies on (Midpoint 4.11+ defaults)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MidpointAuditScriptIncompatible(MidpointServerError):
|
|
58
|
+
"""The audit Groovy script did not compile or link against the server's internal classes."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TaskExecutionFailure(Exception):
|
|
62
|
+
"""A task run that ended with a result other than success.
|
|
63
|
+
|
|
64
|
+
``task`` is the task as last read from the server, so its statistics (error counts,
|
|
65
|
+
executed actions) can be shown with the failure; None when it is not known.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str, task: MidpointTask | None = None):
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.message = message
|
|
71
|
+
self.task = task
|
|
72
|
+
|
|
73
|
+
def __repr__(self):
|
|
74
|
+
return self.message
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Class loggers of the system configuration: reading them, and the REST patch that sets one.
|
|
2
|
+
|
|
3
|
+
The logging configuration lives in SystemConfigurationType/logging. Its shape is the same in
|
|
4
|
+
the 4.4.11 to 4.11.0 schemas (common-core-3.xsd):
|
|
5
|
+
|
|
6
|
+
* ``logging/rootLoggerLevel``: the level of the root logger;
|
|
7
|
+
* ``logging/classLogger`` (multi-valued container, one value per logger, each with an ``id``):
|
|
8
|
+
``level`` (LoggingLevelType), ``appender`` (zero or more names) and ``package``, the logger
|
|
9
|
+
name (a package or a class).
|
|
10
|
+
|
|
11
|
+
A level is set with a PATCH carrying an ObjectModificationType, so the rest of the system
|
|
12
|
+
configuration is left alone: a ``replace`` of ``logging/classLogger[<id>]/level`` for an
|
|
13
|
+
existing logger, an ``add`` of a ``logging/classLogger`` value for a new one. Midpoint applies
|
|
14
|
+
a changed logging configuration as soon as the system configuration is saved.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from xml.etree.ElementTree import Element
|
|
19
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
20
|
+
|
|
21
|
+
from midpoint_cli.client.constants import API_TYPES_NS, COMMON_NS, TYPES_NS
|
|
22
|
+
from midpoint_cli.client.objects import namespaces
|
|
23
|
+
|
|
24
|
+
# LoggingLevelType values, the same from 4.4.11 to 4.11.0.
|
|
25
|
+
LOGGING_LEVELS = ('ALL', 'OFF', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ClassLogger:
|
|
30
|
+
package: str
|
|
31
|
+
level: str | None = None
|
|
32
|
+
appenders: list[str] = field(default_factory=list)
|
|
33
|
+
# Container value id, which a patch needs to address this logger.
|
|
34
|
+
container_id: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LoggingConfiguration:
|
|
39
|
+
root_level: str | None = None
|
|
40
|
+
class_loggers: list[ClassLogger] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def loggers_of(self, package: str) -> list[ClassLogger]:
|
|
43
|
+
"""The class loggers configured for exactly this logger name (normally at most one)."""
|
|
44
|
+
return [logger for logger in self.class_loggers if logger.package == package]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_level(level: str) -> str:
|
|
48
|
+
"""The LoggingLevelType value for level, ignoring case; ValueError for an unknown level."""
|
|
49
|
+
normalized = level.strip().upper()
|
|
50
|
+
if normalized not in LOGGING_LEVELS:
|
|
51
|
+
raise ValueError(f'Invalid logging level {level!r}, expected one of: {", ".join(LOGGING_LEVELS)}')
|
|
52
|
+
return normalized
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_logger_name(package: str) -> str:
|
|
56
|
+
"""A logger name as given, refusing an empty one or one containing whitespace."""
|
|
57
|
+
if not package or any(char.isspace() for char in package):
|
|
58
|
+
raise ValueError(f'Invalid logger name {package!r}: expected a package or class name such as com.example')
|
|
59
|
+
return package
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _text(parent: Element, path: str) -> str | None:
|
|
63
|
+
node = parent.find(path, namespaces)
|
|
64
|
+
text = node.text.strip() if node is not None and node.text is not None else ''
|
|
65
|
+
return text or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_logging(system_configuration: Element) -> LoggingConfiguration:
|
|
69
|
+
"""The root level and class loggers of a SystemConfigurationType document."""
|
|
70
|
+
logging = system_configuration.find('c:logging', namespaces)
|
|
71
|
+
if logging is None:
|
|
72
|
+
return LoggingConfiguration()
|
|
73
|
+
loggers = []
|
|
74
|
+
for node in logging.findall('c:classLogger', namespaces):
|
|
75
|
+
package = _text(node, 'c:package')
|
|
76
|
+
if package is None:
|
|
77
|
+
continue
|
|
78
|
+
loggers.append(
|
|
79
|
+
ClassLogger(
|
|
80
|
+
package=package,
|
|
81
|
+
level=_text(node, 'c:level'),
|
|
82
|
+
appenders=[a.text.strip() for a in node.findall('c:appender', namespaces) if a.text and a.text.strip()],
|
|
83
|
+
container_id=node.attrib.get('id'),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return LoggingConfiguration(root_level=_text(logging, 'c:rootLoggerLevel'), class_loggers=loggers)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_MODIFICATION_START = f"<objectModification xmlns='{API_TYPES_NS}' xmlns:c='{COMMON_NS}' xmlns:t='{TYPES_NS}'>"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def class_logger_modification(existing: list[ClassLogger], package: str, level: str) -> str:
|
|
93
|
+
"""The ObjectModificationType setting package to level.
|
|
94
|
+
|
|
95
|
+
existing are the class loggers already configured for package: each gets its level
|
|
96
|
+
replaced, addressed by its container id. With none, a new class logger is added.
|
|
97
|
+
"""
|
|
98
|
+
if existing:
|
|
99
|
+
deltas = []
|
|
100
|
+
for logger in existing:
|
|
101
|
+
if logger.container_id is None or not logger.container_id.isdigit():
|
|
102
|
+
raise ValueError(f'The class logger of {package} has no container id, so it cannot be modified')
|
|
103
|
+
deltas.append(
|
|
104
|
+
'<itemDelta>'
|
|
105
|
+
'<t:modificationType>replace</t:modificationType>'
|
|
106
|
+
f'<t:path>c:logging/c:classLogger[{logger.container_id}]/c:level</t:path>'
|
|
107
|
+
f'<t:value>{level}</t:value>'
|
|
108
|
+
'</itemDelta>'
|
|
109
|
+
)
|
|
110
|
+
body = ''.join(deltas)
|
|
111
|
+
else:
|
|
112
|
+
body = (
|
|
113
|
+
'<itemDelta>'
|
|
114
|
+
'<t:modificationType>add</t:modificationType>'
|
|
115
|
+
'<t:path>c:logging/c:classLogger</t:path>'
|
|
116
|
+
'<t:value>'
|
|
117
|
+
f'<c:level>{level}</c:level>'
|
|
118
|
+
f'<c:package>{xml_escape(package)}</c:package>'
|
|
119
|
+
'</t:value>'
|
|
120
|
+
'</itemDelta>'
|
|
121
|
+
)
|
|
122
|
+
return _MODIFICATION_START + body + '</objectModification>'
|