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,253 @@
|
|
|
1
|
+
"""`audit` command — query Midpoint's audit log to track all server-side changes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
from argparse import ArgumentParser, RawTextHelpFormatter
|
|
6
|
+
from typing import cast
|
|
7
|
+
from xml.etree import ElementTree
|
|
8
|
+
from xml.etree.ElementTree import Element
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.tree import Tree
|
|
12
|
+
|
|
13
|
+
from midpoint_cli.client import (
|
|
14
|
+
MidpointAuditEvent,
|
|
15
|
+
MidpointObjectList,
|
|
16
|
+
MidpointObjectTypes,
|
|
17
|
+
MidpointServerError,
|
|
18
|
+
namespaces,
|
|
19
|
+
)
|
|
20
|
+
from midpoint_cli.client.audit_query import AuditQuery, parse_duration
|
|
21
|
+
from midpoint_cli.prompt.base import PromptBase
|
|
22
|
+
from midpoint_cli.prompt.complete import USER, completes
|
|
23
|
+
from midpoint_cli.prompt.console import OUTCOME_STYLE, get_console, is_machine_output, print_rows
|
|
24
|
+
|
|
25
|
+
# OIDs are UUIDs; anything matching this pattern is treated as a literal OID,
|
|
26
|
+
# everything else is treated as a name to resolve via the users endpoint.
|
|
27
|
+
_OID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
audit_parser = ArgumentParser(
|
|
31
|
+
formatter_class=RawTextHelpFormatter,
|
|
32
|
+
prog='audit',
|
|
33
|
+
description='Query the Midpoint audit log.',
|
|
34
|
+
epilog="""
|
|
35
|
+
Available commands:
|
|
36
|
+
ls List recent audit events (with filters).
|
|
37
|
+
object Show change history for a single object.
|
|
38
|
+
get Show full details (including deltas) for one event.
|
|
39
|
+
""",
|
|
40
|
+
)
|
|
41
|
+
audit_parser.add_argument('command', help='Audit subcommand to execute.')
|
|
42
|
+
audit_parser.add_argument('arg', help='Subcommand arguments.', nargs='*')
|
|
43
|
+
|
|
44
|
+
audit_ls_parser = ArgumentParser(
|
|
45
|
+
formatter_class=RawTextHelpFormatter,
|
|
46
|
+
prog='audit ls',
|
|
47
|
+
description='List recent audit events. All filters are ANDed together.',
|
|
48
|
+
)
|
|
49
|
+
audit_ls_parser.add_argument('--since', help='Earliest timestamp, e.g. 1h, 30m, 2d, 1w, or ISO-8601.')
|
|
50
|
+
audit_ls_parser.add_argument('--until', help='Latest timestamp, same syntax as --since.')
|
|
51
|
+
completes(audit_ls_parser.add_argument('--user', help='Initiator OID or name.'), USER)
|
|
52
|
+
audit_ls_parser.add_argument('--type', dest='event_type', help='Audit event type, e.g. modifyObject, addObject.')
|
|
53
|
+
audit_ls_parser.add_argument('--stage', help='Audit event stage, e.g. request, execution.')
|
|
54
|
+
audit_ls_parser.add_argument('--channel', help='Channel filter (substring of channel URI fragment).')
|
|
55
|
+
audit_ls_parser.add_argument('--outcome', help='Outcome filter, e.g. success, fatalError.')
|
|
56
|
+
audit_ls_parser.add_argument('--limit', type=int, default=50, help='Max events to return (0 = no limit).')
|
|
57
|
+
audit_ls_parser.add_argument('--show-deltas', action='store_true', help='Render full deltas under each row.')
|
|
58
|
+
|
|
59
|
+
audit_object_parser = ArgumentParser(
|
|
60
|
+
formatter_class=RawTextHelpFormatter,
|
|
61
|
+
prog='audit object',
|
|
62
|
+
description='Show audit events targeting a single object.',
|
|
63
|
+
)
|
|
64
|
+
audit_object_parser.add_argument('oid', help='Target object OID.')
|
|
65
|
+
audit_object_parser.add_argument('--limit', type=int, default=50, help='Max events to return (0 = no limit).')
|
|
66
|
+
audit_object_parser.add_argument('--show-deltas', action='store_true', help='Render full deltas under each row.')
|
|
67
|
+
|
|
68
|
+
audit_get_parser = ArgumentParser(
|
|
69
|
+
formatter_class=RawTextHelpFormatter,
|
|
70
|
+
prog='audit get',
|
|
71
|
+
description='Show full details and deltas for one audit event by event identifier.',
|
|
72
|
+
)
|
|
73
|
+
audit_get_parser.add_argument(
|
|
74
|
+
'event_identifier', help='Value of the audit eventIdentifier field: the EventIdentifier column of audit ls.'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
audit_subparsers: dict[str, ArgumentParser | None] = {
|
|
78
|
+
'ls': audit_ls_parser,
|
|
79
|
+
'object': audit_object_parser,
|
|
80
|
+
'get': audit_get_parser,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_EVENT_COLUMN_STYLES = {
|
|
85
|
+
'Timestamp': 'white',
|
|
86
|
+
'Stage': 'cyan',
|
|
87
|
+
'Type': 'bright_cyan',
|
|
88
|
+
'Initiator': 'blue',
|
|
89
|
+
'Channel': 'white',
|
|
90
|
+
'Target': 'blue',
|
|
91
|
+
'Outcome': 'white',
|
|
92
|
+
# What `audit get` takes. Last, so the columns before it keep their positions.
|
|
93
|
+
'EventIdentifier': 'blue',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _event_cell_style(column: str, value: str) -> str | None:
|
|
98
|
+
if column == 'Outcome':
|
|
99
|
+
return OUTCOME_STYLE.get(value.replace('-', '_').lower(), 'white')
|
|
100
|
+
return _EVENT_COLUMN_STYLES.get(column)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AuditClientPrompt(PromptBase):
|
|
104
|
+
subparsers_audit = audit_subparsers
|
|
105
|
+
|
|
106
|
+
def do_audit(self, inp: str) -> None:
|
|
107
|
+
"""Query the Midpoint audit log (subcommands: ls, object, get)."""
|
|
108
|
+
args = shlex.split(inp)
|
|
109
|
+
if not args or args[0] in ('-h', '--help'):
|
|
110
|
+
audit_parser.print_help()
|
|
111
|
+
return
|
|
112
|
+
self._dispatch_subcommand(
|
|
113
|
+
inp,
|
|
114
|
+
None,
|
|
115
|
+
audit_subparsers,
|
|
116
|
+
{'ls': self._audit_ls, 'object': self._audit_object, 'get': self._audit_get},
|
|
117
|
+
errors=(MidpointServerError, ValueError),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def help_audit() -> None:
|
|
122
|
+
audit_parser.print_help()
|
|
123
|
+
|
|
124
|
+
def _audit_ls(self, argv: list[str]) -> None:
|
|
125
|
+
ns = audit_ls_parser.parse_args(argv)
|
|
126
|
+
query = AuditQuery(limit=ns.limit)
|
|
127
|
+
if ns.since:
|
|
128
|
+
query.since = parse_duration(ns.since)
|
|
129
|
+
if ns.until:
|
|
130
|
+
query.until = parse_duration(ns.until)
|
|
131
|
+
if ns.user:
|
|
132
|
+
query.initiator_oid = self._resolve_user_oid(ns.user)
|
|
133
|
+
if ns.event_type:
|
|
134
|
+
query.event_type = ns.event_type
|
|
135
|
+
if ns.stage:
|
|
136
|
+
query.event_stage = ns.stage
|
|
137
|
+
if ns.channel:
|
|
138
|
+
query.channel = ns.channel
|
|
139
|
+
if ns.outcome:
|
|
140
|
+
query.outcome = ns.outcome
|
|
141
|
+
|
|
142
|
+
events = self.client.search_audit(query)
|
|
143
|
+
# Server returns newest-first so --limit keeps the most recent N;
|
|
144
|
+
# reverse for display so the most recent ends up at the bottom.
|
|
145
|
+
events.reverse()
|
|
146
|
+
self._print_events(events, show_deltas=ns.show_deltas)
|
|
147
|
+
|
|
148
|
+
def _audit_object(self, argv: list[str]) -> None:
|
|
149
|
+
ns = audit_object_parser.parse_args(argv)
|
|
150
|
+
query = AuditQuery(limit=ns.limit, target_oid=ns.oid)
|
|
151
|
+
events = self.client.search_audit(query)
|
|
152
|
+
events.reverse()
|
|
153
|
+
self._print_events(events, title=f'Audit history for {ns.oid}', show_deltas=ns.show_deltas)
|
|
154
|
+
|
|
155
|
+
def _audit_get(self, argv: list[str]) -> None:
|
|
156
|
+
ns = audit_get_parser.parse_args(argv)
|
|
157
|
+
query = AuditQuery(limit=0, event_identifier=ns.event_identifier)
|
|
158
|
+
events = self.client.search_audit(query)
|
|
159
|
+
if not events:
|
|
160
|
+
self._fail(f'No audit event found with identifier {ns.event_identifier}')
|
|
161
|
+
return
|
|
162
|
+
self._print_events(events, title=f'Audit event {ns.event_identifier}', show_deltas=True)
|
|
163
|
+
|
|
164
|
+
def _resolve_user_oid(self, ref: str) -> str:
|
|
165
|
+
"""Resolve a CLI --user value (OID or name) to a user OID."""
|
|
166
|
+
if _OID_RE.match(ref):
|
|
167
|
+
return ref
|
|
168
|
+
match = self.client.find_object(MidpointObjectTypes.USER.value, ref)
|
|
169
|
+
if match is None:
|
|
170
|
+
raise ValueError(f'User not found: {ref}')
|
|
171
|
+
oid = match.get_oid()
|
|
172
|
+
if oid is None:
|
|
173
|
+
raise AssertionError
|
|
174
|
+
return oid
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def _print_events(cls, events: MidpointObjectList, title: str = 'Audit events', show_deltas: bool = False) -> None:
|
|
178
|
+
"""Print audit events in the selected output format.
|
|
179
|
+
|
|
180
|
+
With deltas, a table is followed by one tree of deltas per event, while the machine
|
|
181
|
+
formats carry each event's deltas as XML in a Deltas column.
|
|
182
|
+
"""
|
|
183
|
+
rows = []
|
|
184
|
+
for event in events:
|
|
185
|
+
ev = cast(MidpointAuditEvent, event)
|
|
186
|
+
row = {
|
|
187
|
+
'Timestamp': ev.get('Timestamp'),
|
|
188
|
+
'Stage': ev.get('Stage'),
|
|
189
|
+
'Type': ev.get('EventType'),
|
|
190
|
+
'Initiator': ev.get('Initiator'),
|
|
191
|
+
'Channel': ev.get('Channel'),
|
|
192
|
+
'Target': ev.get('Target'),
|
|
193
|
+
'Outcome': ev.get('Outcome'),
|
|
194
|
+
'EventIdentifier': ev.get('EventIdentifier'),
|
|
195
|
+
}
|
|
196
|
+
if show_deltas and is_machine_output():
|
|
197
|
+
row['Deltas'] = '\n'.join(
|
|
198
|
+
ElementTree.tostring(delta, encoding='unicode').strip() for delta in ev.get_deltas()
|
|
199
|
+
)
|
|
200
|
+
rows.append(row)
|
|
201
|
+
|
|
202
|
+
columns = list(_EVENT_COLUMN_STYLES)
|
|
203
|
+
if show_deltas and is_machine_output():
|
|
204
|
+
columns.append('Deltas')
|
|
205
|
+
print_rows(rows, title=title, noun='audit events', columns=columns, cell_style=_event_cell_style)
|
|
206
|
+
if show_deltas and not is_machine_output():
|
|
207
|
+
cls._print_event_deltas(events)
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def _print_event_deltas(cls, events: MidpointObjectList) -> None:
|
|
211
|
+
console = get_console()
|
|
212
|
+
for event in events:
|
|
213
|
+
ev = cast(MidpointAuditEvent, event)
|
|
214
|
+
deltas = ev.get_deltas()
|
|
215
|
+
if not deltas:
|
|
216
|
+
continue
|
|
217
|
+
label = ev.get('EventIdentifier') or ev.get('Timestamp') or ''
|
|
218
|
+
tree = Tree(Text(f'Deltas for {label}', style='bold'))
|
|
219
|
+
for delta_node in deltas:
|
|
220
|
+
cls._render_delta(tree, delta_node)
|
|
221
|
+
console.print(tree)
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _render_delta(parent: Tree, delta_node: Element) -> None:
|
|
225
|
+
object_delta = delta_node.find('c:objectDelta', namespaces)
|
|
226
|
+
if object_delta is None:
|
|
227
|
+
# Fall back to the delta node itself if structure is different.
|
|
228
|
+
object_delta = delta_node
|
|
229
|
+
|
|
230
|
+
change_type_el = object_delta.find('c:changeType', namespaces)
|
|
231
|
+
oid_el = object_delta.find('c:oid', namespaces)
|
|
232
|
+
type_el = object_delta.find('c:objectType', namespaces)
|
|
233
|
+
|
|
234
|
+
change_type = change_type_el.text if change_type_el is not None else 'modify'
|
|
235
|
+
target_oid = oid_el.text if oid_el is not None else ''
|
|
236
|
+
target_type = type_el.text.split('}')[-1] if type_el is not None and type_el.text else ''
|
|
237
|
+
header = Text.assemble((change_type or '', 'yellow'), f' {target_type} {target_oid}'.rstrip())
|
|
238
|
+
delta_branch = parent.add(header)
|
|
239
|
+
|
|
240
|
+
for item_delta in object_delta.findall('c:itemDelta', namespaces):
|
|
241
|
+
mod_type = item_delta.find('c:modificationType', namespaces)
|
|
242
|
+
path_el = item_delta.find('c:path', namespaces)
|
|
243
|
+
mod = mod_type.text if mod_type is not None else '?'
|
|
244
|
+
path_text: str | None
|
|
245
|
+
path_text = path_el.text if path_el is not None else None
|
|
246
|
+
label = Text.assemble((mod or '', 'cyan'), ' ', (path_text or '', 'bright_cyan'))
|
|
247
|
+
item_branch = delta_branch.add(label)
|
|
248
|
+
for value in item_delta.findall('c:value', namespaces):
|
|
249
|
+
rendered = ElementTree.tostring(value, encoding='unicode').strip()
|
|
250
|
+
# Truncate very long values for readability.
|
|
251
|
+
if len(rendered) > 400:
|
|
252
|
+
rendered = rendered[:397] + '...'
|
|
253
|
+
item_branch.add(Text(rendered))
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import shlex
|
|
3
|
+
from argparse import ArgumentParser, ArgumentTypeError
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from midpoint_cli.client import MidpointAmbiguousName, MidpointServerError, TaskExecutionFailure
|
|
8
|
+
from midpoint_cli.client.objects import MidpointObject, MidpointObjectType, MidpointObjectTypes, MidpointTask
|
|
9
|
+
from midpoint_cli.prompt.console import (
|
|
10
|
+
create_progress_monitor,
|
|
11
|
+
create_table,
|
|
12
|
+
get_console,
|
|
13
|
+
is_machine_output,
|
|
14
|
+
print_details,
|
|
15
|
+
print_error,
|
|
16
|
+
print_info,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from midpoint_cli.client import MidpointClient
|
|
21
|
+
from midpoint_cli.prompt.configuration import ResolvedConfiguration
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _positive_int(text: str) -> int:
|
|
25
|
+
try:
|
|
26
|
+
value = int(text)
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise ArgumentTypeError(f'invalid positive integer: {text!r}') from None
|
|
29
|
+
if value < 1:
|
|
30
|
+
raise ArgumentTypeError(f'must be at least 1, got {value}')
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_listing_arguments(parser: ArgumentParser, what: str) -> ArgumentParser:
|
|
35
|
+
"""Add the server-side --limit and --filter options shared by the ls subcommands."""
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
'--limit',
|
|
38
|
+
type=_positive_int,
|
|
39
|
+
metavar='N',
|
|
40
|
+
help=f'List at most N {what}, sorted by name (paged on the server).',
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
'--filter',
|
|
44
|
+
metavar='TEXT',
|
|
45
|
+
help=f'Only list {what} whose name contains TEXT, ignoring case (filtered on the server).',
|
|
46
|
+
)
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def add_new_task_arguments(parser: ArgumentParser) -> ArgumentParser:
|
|
51
|
+
"""Add the --no-wait and --no-changes options of the commands that create and start a task."""
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
'--no-wait',
|
|
54
|
+
action='store_true',
|
|
55
|
+
help='Start the task and print its OID instead of waiting for it to finish.',
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
'--no-changes',
|
|
59
|
+
action='store_true',
|
|
60
|
+
help="Don't stream audit events while the task runs (silences the inline change log).",
|
|
61
|
+
)
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# A subcommand handler: called with the arguments that follow the subcommand.
|
|
66
|
+
SubcommandHandler = Callable[[list[str]], None]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def print_object_detail(obj: Mapping[str, Any], title: str) -> None:
|
|
70
|
+
"""Print one object's properties in the selected output format (role get, org get).
|
|
71
|
+
|
|
72
|
+
The table lists the non-empty properties; the machine formats every one, empty ones
|
|
73
|
+
included, so scripts see the same keys for every object.
|
|
74
|
+
"""
|
|
75
|
+
if is_machine_output():
|
|
76
|
+
print_details([obj], as_list=False)
|
|
77
|
+
return
|
|
78
|
+
table = create_table(title=title)
|
|
79
|
+
table.add_column('Attribute', style='cyan')
|
|
80
|
+
table.add_column('Value', style='white')
|
|
81
|
+
for attr, value in obj.items():
|
|
82
|
+
if value is not None and str(value).strip():
|
|
83
|
+
table.add_row(str(attr), str(value))
|
|
84
|
+
get_console().print(table)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PromptBase:
|
|
88
|
+
"""Base of the command mixins.
|
|
89
|
+
|
|
90
|
+
Besides cmd.Cmd's ``do_<command>``/``help_<command>`` methods, a mixin exposes its parsers
|
|
91
|
+
as class attributes, which the tab completer reads:
|
|
92
|
+
|
|
93
|
+
* ``parser_<command>``: the parser of a command without subcommands;
|
|
94
|
+
* ``subparsers_<command>``: a mapping of each subcommand to its parser, or to None when it
|
|
95
|
+
takes no arguments. The command uses the same mapping for dispatch and ``-h``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
# Note: self.client gets set immediately in MidpointClientPrompt.__init__()
|
|
100
|
+
# This is just a type declaration - the actual assignment happens in the subclass
|
|
101
|
+
self.client: MidpointClient
|
|
102
|
+
# Effective settings and their sources, when started from main(); shown by `info`.
|
|
103
|
+
self.configuration: ResolvedConfiguration | None = None
|
|
104
|
+
self.error_code: int | None = None
|
|
105
|
+
self.error_message: str | None = None
|
|
106
|
+
|
|
107
|
+
def _fail(self, message: str | None = None) -> None:
|
|
108
|
+
"""Mark the command as failed (exit code 1); onecmd prints the message, if any, as its error."""
|
|
109
|
+
self.error_code = 1
|
|
110
|
+
if message is not None:
|
|
111
|
+
self.error_message = message
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _subcommand_help(args: list[str], subparsers: Mapping[str, ArgumentParser | None]) -> bool:
|
|
115
|
+
"""If -h/--help follows a known subcommand, print that subcommand's help and return True."""
|
|
116
|
+
if args and subparsers.get(args[0]) is not None and any(a in ('-h', '--help') for a in args[1:]):
|
|
117
|
+
subparser = subparsers[args[0]]
|
|
118
|
+
if subparser is None:
|
|
119
|
+
raise AssertionError
|
|
120
|
+
subparser.print_help()
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def _dispatch_subcommand(
|
|
125
|
+
self,
|
|
126
|
+
inp: str,
|
|
127
|
+
parser: ArgumentParser | None,
|
|
128
|
+
subparsers: Mapping[str, ArgumentParser | None],
|
|
129
|
+
handlers: Mapping[str, SubcommandHandler],
|
|
130
|
+
*,
|
|
131
|
+
errors: tuple[type[Exception], ...] = (MidpointServerError,),
|
|
132
|
+
strict: bool = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Run the handler of the subcommand that inp starts with, on the arguments after it.
|
|
135
|
+
|
|
136
|
+
-h after a known subcommand prints that subcommand's help. parser, a command parser
|
|
137
|
+
with ``command`` and ``arg`` positionals, prints the command's help for -h and reports
|
|
138
|
+
a missing subcommand as a usage error; without one, the first word is the subcommand.
|
|
139
|
+
An unknown subcommand fails with ``Unknown command <name>``; with strict, arguments
|
|
140
|
+
after it are a usage error instead. An exception of one of errors raised by the
|
|
141
|
+
handler fails the command with its message.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
args = shlex.split(inp)
|
|
145
|
+
if self._subcommand_help(args, subparsers):
|
|
146
|
+
return
|
|
147
|
+
if parser is None:
|
|
148
|
+
command, extra = (args[0], args[1:]) if args else ('', [])
|
|
149
|
+
else:
|
|
150
|
+
# Each subcommand parses its own options: they pass through here.
|
|
151
|
+
ns, unknown = parser.parse_known_args(args)
|
|
152
|
+
command, extra = ns.command, ns.arg + unknown
|
|
153
|
+
handler = handlers.get(command)
|
|
154
|
+
if handler is None:
|
|
155
|
+
if strict and extra and parser is not None:
|
|
156
|
+
parser.error(f'unrecognized arguments: {" ".join(extra)}')
|
|
157
|
+
self._fail(f'Unknown command {command}')
|
|
158
|
+
return
|
|
159
|
+
handler(args[1:])
|
|
160
|
+
except errors as e:
|
|
161
|
+
self._fail(str(e))
|
|
162
|
+
|
|
163
|
+
def _list_shortcut(self, inp: str, shortcut: str, command: str, run: Callable[[str], object]) -> None:
|
|
164
|
+
"""A no-argument shortcut for `<command> ls` (users, roles, ...): run('ls'), or fail on arguments."""
|
|
165
|
+
if inp.strip():
|
|
166
|
+
self._fail(f'"{shortcut}" takes no arguments. Did you mean "{command} {inp.strip()}"?')
|
|
167
|
+
return
|
|
168
|
+
run('ls')
|
|
169
|
+
|
|
170
|
+
def _find_reference(self, object_type: MidpointObjectType, reference: str, label: str) -> MidpointObject | None:
|
|
171
|
+
"""The object a CLI reference (OID or name) designates, or None after printing why there is none.
|
|
172
|
+
|
|
173
|
+
Used by commands that loop over several references, so one bad reference (not found, or a
|
|
174
|
+
name shared by several objects) is reported without stopping the others; the command
|
|
175
|
+
still fails (exit code 1).
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
found = self.client.find_object(object_type, reference)
|
|
179
|
+
except MidpointAmbiguousName as e:
|
|
180
|
+
self._fail()
|
|
181
|
+
print_error(str(e))
|
|
182
|
+
return None
|
|
183
|
+
if found is None:
|
|
184
|
+
self._fail()
|
|
185
|
+
print_error(f'{label} reference not found: {reference}')
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
def _require_object(self, object_type: MidpointObjectType, reference: str, label: str) -> MidpointObject | None:
|
|
189
|
+
"""The object a CLI reference (OID or name) designates, or None after failing with `<label> not found`.
|
|
190
|
+
|
|
191
|
+
A name shared by several objects raises MidpointAmbiguousName, a MidpointServerError.
|
|
192
|
+
"""
|
|
193
|
+
found = self.client.find_object(object_type, reference)
|
|
194
|
+
if found is None:
|
|
195
|
+
self._fail(f'{label} not found: {reference}')
|
|
196
|
+
return found
|
|
197
|
+
|
|
198
|
+
def _follow_new_task(self, task_oid: str, task_name: str, no_wait: bool, show_changes: bool) -> None:
|
|
199
|
+
"""Report a task the command created, then follow it to completion like `task run`.
|
|
200
|
+
|
|
201
|
+
With no_wait, the task OID is printed on standard output, so a script can capture it.
|
|
202
|
+
"""
|
|
203
|
+
print_info(f"Task '{task_name}' started: {task_oid}")
|
|
204
|
+
if no_wait:
|
|
205
|
+
print(task_oid)
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
task = self.client.run_new_task(task_oid, create_progress_monitor(), show_changes=show_changes)
|
|
209
|
+
get_console().print(task.get_full_description())
|
|
210
|
+
except TaskExecutionFailure as e:
|
|
211
|
+
# The task as last read while waiting, with its statistics; fetched when not known.
|
|
212
|
+
failed: MidpointObject | None = e.task
|
|
213
|
+
if failed is None:
|
|
214
|
+
with contextlib.suppress(MidpointServerError): # The failure itself is reported below.
|
|
215
|
+
failed = self.client.get_object(MidpointObjectTypes.TASK.value, task_oid)
|
|
216
|
+
if isinstance(failed, MidpointTask):
|
|
217
|
+
get_console().print(failed.get_full_description())
|
|
218
|
+
self._fail(e.message)
|