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,291 @@
|
|
|
1
|
+
"""Tab completion for the interactive prompt.
|
|
2
|
+
|
|
3
|
+
The completer derives everything from the command classes and their argparse parsers, so
|
|
4
|
+
completion cannot drift from what the commands accept:
|
|
5
|
+
|
|
6
|
+
* command names and descriptions come from the ``do_*`` methods and their docstrings;
|
|
7
|
+
* subcommands come from the ``subparsers_<command>`` mapping each command dispatches with;
|
|
8
|
+
* options come from each parser's option strings;
|
|
9
|
+
* argument values (object types, task/user/role/org/resource/report names, file paths) come from
|
|
10
|
+
the ``completer`` kind attached to a parser argument with :func:`completes`, or from its
|
|
11
|
+
``choices`` (e.g. logging levels); an ``OBJECT_OF_TYPE`` argument completes the names of the
|
|
12
|
+
type given in the ``OBJECT_TYPE`` argument before it (``get user <name>``).
|
|
13
|
+
|
|
14
|
+
Object names are searched on the server by name prefix, at most :data:`COMPLETION_LIMIT` at a
|
|
15
|
+
time, so completion does not download whole collections.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import glob
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import time
|
|
22
|
+
from argparse import Action, ArgumentParser
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
|
|
25
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
26
|
+
from prompt_toolkit.document import Document
|
|
27
|
+
|
|
28
|
+
from midpoint_cli.client import MidpointClient
|
|
29
|
+
from midpoint_cli.client.objects import MidpointObjectList, MidpointObjectTypes, MidpointTypeNotFound
|
|
30
|
+
|
|
31
|
+
# Argument value kinds understood by MidpointCompleter.
|
|
32
|
+
OBJECT_TYPE = 'object type'
|
|
33
|
+
TASK = 'task'
|
|
34
|
+
USER = 'user'
|
|
35
|
+
ROLE = 'role'
|
|
36
|
+
ORG = 'org'
|
|
37
|
+
RESOURCE = 'resource'
|
|
38
|
+
REPORT = 'report'
|
|
39
|
+
FILE = 'file'
|
|
40
|
+
# The name of an object whose type is the value of the OBJECT_TYPE argument (get, delete).
|
|
41
|
+
OBJECT_OF_TYPE = 'object of type'
|
|
42
|
+
|
|
43
|
+
# The object type whose names complete each object kind.
|
|
44
|
+
_OBJECT_KIND_TYPES = {
|
|
45
|
+
TASK: MidpointObjectTypes.TASK,
|
|
46
|
+
USER: MidpointObjectTypes.USER,
|
|
47
|
+
ROLE: MidpointObjectTypes.ROLE,
|
|
48
|
+
ORG: MidpointObjectTypes.ORG,
|
|
49
|
+
RESOURCE: MidpointObjectTypes.RESOURCE,
|
|
50
|
+
REPORT: MidpointObjectTypes.REPORT,
|
|
51
|
+
}
|
|
52
|
+
_OBJECT_KINDS = tuple(_OBJECT_KIND_TYPES)
|
|
53
|
+
|
|
54
|
+
# Most names offered per completion request: the server returns the first ones by name.
|
|
55
|
+
COMPLETION_LIMIT = 50
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def completes(action: Action, kind: str) -> Action:
|
|
59
|
+
"""Tag a parser argument with the kind of value the completer should offer for it."""
|
|
60
|
+
action.completer = kind # type: ignore[attr-defined]
|
|
61
|
+
return action
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _append_slash_if_dir(p: str) -> str:
|
|
65
|
+
if p and os.path.isdir(p) and p[-1] != os.sep:
|
|
66
|
+
return p + os.sep
|
|
67
|
+
else:
|
|
68
|
+
return p
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def split_command_line(text: str) -> tuple[list[str], str, int]:
|
|
72
|
+
"""Split the text before the cursor the way shlex would, tolerating an unclosed quote.
|
|
73
|
+
|
|
74
|
+
Returns the completed words, the (unquoted) word under the cursor, and the length of
|
|
75
|
+
that word as typed, quotes included, so a completion can replace it.
|
|
76
|
+
"""
|
|
77
|
+
words: list[str] = []
|
|
78
|
+
current = ''
|
|
79
|
+
in_word = False
|
|
80
|
+
quote: str | None = None
|
|
81
|
+
start = len(text)
|
|
82
|
+
for index, char in enumerate(text):
|
|
83
|
+
if quote is not None:
|
|
84
|
+
if char == quote:
|
|
85
|
+
quote = None
|
|
86
|
+
else:
|
|
87
|
+
current += char
|
|
88
|
+
elif char.isspace():
|
|
89
|
+
if in_word:
|
|
90
|
+
words.append(current)
|
|
91
|
+
current = ''
|
|
92
|
+
in_word = False
|
|
93
|
+
else:
|
|
94
|
+
if not in_word:
|
|
95
|
+
in_word = True
|
|
96
|
+
start = index
|
|
97
|
+
if char in '\'"':
|
|
98
|
+
quote = char
|
|
99
|
+
else:
|
|
100
|
+
current += char
|
|
101
|
+
if not in_word:
|
|
102
|
+
return words, '', 0
|
|
103
|
+
return words, current, len(text) - start
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _object_kind_of_type(type_name: str) -> str | None:
|
|
107
|
+
"""The object kind completing names of the type the user typed, or None for other types."""
|
|
108
|
+
try:
|
|
109
|
+
object_type = MidpointObjectTypes.find_by_name(type_name)
|
|
110
|
+
except MidpointTypeNotFound:
|
|
111
|
+
return None
|
|
112
|
+
for kind, kind_type in _OBJECT_KIND_TYPES.items():
|
|
113
|
+
if kind_type.value == object_type:
|
|
114
|
+
return kind
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _typed_object_type(parser: ArgumentParser, args: list[str]) -> str | None:
|
|
119
|
+
"""The word given for the parser's OBJECT_TYPE positional, if already typed."""
|
|
120
|
+
positionals: list[str] = []
|
|
121
|
+
pending = False
|
|
122
|
+
for word in args:
|
|
123
|
+
if pending:
|
|
124
|
+
pending = False
|
|
125
|
+
elif word.startswith('-') and word != '-':
|
|
126
|
+
action = parser._option_string_actions.get(word)
|
|
127
|
+
pending = action is not None and action.nargs != 0
|
|
128
|
+
else:
|
|
129
|
+
positionals.append(word)
|
|
130
|
+
|
|
131
|
+
index = 0
|
|
132
|
+
for action in parser._actions:
|
|
133
|
+
if action.option_strings:
|
|
134
|
+
continue
|
|
135
|
+
if getattr(action, 'completer', None) == OBJECT_TYPE:
|
|
136
|
+
return positionals[index] if index < len(positionals) else None
|
|
137
|
+
index += action.nargs if isinstance(action.nargs, int) else 1
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _positional_for(parser: ArgumentParser, args: list[str]) -> Action | None:
|
|
142
|
+
"""Return the action that the next word completes: an option value or a positional."""
|
|
143
|
+
pending: Action | None = None
|
|
144
|
+
index = 0
|
|
145
|
+
for word in args:
|
|
146
|
+
if pending is not None:
|
|
147
|
+
pending = None
|
|
148
|
+
elif word.startswith('-') and word != '-':
|
|
149
|
+
action = parser._option_string_actions.get(word)
|
|
150
|
+
if action is not None and action.nargs != 0:
|
|
151
|
+
pending = action
|
|
152
|
+
else:
|
|
153
|
+
index += 1
|
|
154
|
+
if pending is not None:
|
|
155
|
+
return pending
|
|
156
|
+
|
|
157
|
+
for action in parser._actions:
|
|
158
|
+
if action.option_strings:
|
|
159
|
+
continue
|
|
160
|
+
if action.nargs in ('*', '+'):
|
|
161
|
+
return action
|
|
162
|
+
count = action.nargs if isinstance(action.nargs, int) else 1
|
|
163
|
+
if index < count:
|
|
164
|
+
return action
|
|
165
|
+
index -= count
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class MidpointCompleter(Completer):
|
|
170
|
+
"""prompt_toolkit completer for the commands of ``command_class``."""
|
|
171
|
+
|
|
172
|
+
cache_ttl = 5 # Seconds a search result is reused, to avoid a server call per keystroke.
|
|
173
|
+
|
|
174
|
+
def __init__(self, command_class: type, client: MidpointClient | None = None) -> None:
|
|
175
|
+
self.command_class = command_class
|
|
176
|
+
self.client = client
|
|
177
|
+
|
|
178
|
+
# Main commands, with the first docstring line as description.
|
|
179
|
+
self.commands: dict[str, str] = {}
|
|
180
|
+
for name in sorted(dir(command_class)):
|
|
181
|
+
if name.startswith('do_') and name != 'do_EOF':
|
|
182
|
+
doc = getattr(command_class, name).__doc__
|
|
183
|
+
self.commands[name[3:]] = doc.strip().splitlines()[0] if doc else ''
|
|
184
|
+
|
|
185
|
+
# Completion candidates per (kind, name prefix), searched on the server.
|
|
186
|
+
self._cache: dict[tuple[str, str], tuple[float, MidpointObjectList]] = {}
|
|
187
|
+
|
|
188
|
+
def _get_objects(self, kind: str, prefix: str = '') -> MidpointObjectList:
|
|
189
|
+
"""The objects of one kind whose name starts with prefix (case-insensitive), at most
|
|
190
|
+
COMPLETION_LIMIT, cached per (kind, prefix) for ``cache_ttl`` seconds; empty on any failure."""
|
|
191
|
+
if self.client is None:
|
|
192
|
+
return MidpointObjectList()
|
|
193
|
+
|
|
194
|
+
key = (kind, prefix)
|
|
195
|
+
now = time.time()
|
|
196
|
+
cached = self._cache.get(key)
|
|
197
|
+
if cached is not None and now - cached[0] < self.cache_ttl:
|
|
198
|
+
return cached[1]
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
objects: MidpointObjectList = self.client.complete_names(
|
|
202
|
+
_OBJECT_KIND_TYPES[kind].value, prefix, COMPLETION_LIMIT
|
|
203
|
+
)
|
|
204
|
+
except Exception:
|
|
205
|
+
# A failing server must not break the prompt, and the failure is not cached:
|
|
206
|
+
# the next keystroke retries.
|
|
207
|
+
return MidpointObjectList()
|
|
208
|
+
self._cache[key] = (now, objects)
|
|
209
|
+
return objects
|
|
210
|
+
|
|
211
|
+
def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterator[Completion]:
|
|
212
|
+
words, current, typed = split_command_line(document.text_before_cursor)
|
|
213
|
+
|
|
214
|
+
if not words:
|
|
215
|
+
yield from self._complete_words(self.commands, current, typed)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
command = words[0]
|
|
219
|
+
if command == 'help':
|
|
220
|
+
if len(words) == 1:
|
|
221
|
+
yield from self._complete_words(self.commands, current, typed)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
subparsers = getattr(self.command_class, f'subparsers_{command}', None)
|
|
225
|
+
if subparsers is not None:
|
|
226
|
+
if len(words) == 1:
|
|
227
|
+
yield from self._complete_words(dict.fromkeys(subparsers, ''), current, typed)
|
|
228
|
+
return
|
|
229
|
+
parser = subparsers.get(words[1])
|
|
230
|
+
args = words[2:]
|
|
231
|
+
else:
|
|
232
|
+
parser = getattr(self.command_class, f'parser_{command}', None)
|
|
233
|
+
args = words[1:]
|
|
234
|
+
|
|
235
|
+
if parser is not None:
|
|
236
|
+
yield from self._complete_arguments(parser, args, current, typed)
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _complete_words(candidates: dict[str, str], current: str, typed: int) -> Iterator[Completion]:
|
|
240
|
+
for word, description in candidates.items():
|
|
241
|
+
if word.startswith(current.lower()):
|
|
242
|
+
yield Completion(word, start_position=-typed, display_meta=description or None)
|
|
243
|
+
|
|
244
|
+
def _complete_arguments(
|
|
245
|
+
self, parser: ArgumentParser, args: list[str], current: str, typed: int
|
|
246
|
+
) -> Iterator[Completion]:
|
|
247
|
+
if current.startswith('-'):
|
|
248
|
+
for option, option_action in parser._option_string_actions.items():
|
|
249
|
+
if option.startswith(current):
|
|
250
|
+
yield Completion(option, start_position=-typed, display_meta=option_action.help)
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
action = _positional_for(parser, args)
|
|
254
|
+
kind = getattr(action, 'completer', None)
|
|
255
|
+
if kind == OBJECT_TYPE:
|
|
256
|
+
yield from self._complete_object_types(current, typed)
|
|
257
|
+
elif kind == FILE:
|
|
258
|
+
yield from self._complete_files(current, typed)
|
|
259
|
+
elif kind in _OBJECT_KINDS:
|
|
260
|
+
yield from self._complete_objects(kind, current, typed)
|
|
261
|
+
elif kind == OBJECT_OF_TYPE:
|
|
262
|
+
type_name = _typed_object_type(parser, args)
|
|
263
|
+
object_kind = _object_kind_of_type(type_name) if type_name else None
|
|
264
|
+
if object_kind is not None:
|
|
265
|
+
yield from self._complete_objects(object_kind, current, typed)
|
|
266
|
+
elif action is not None and action.choices:
|
|
267
|
+
# Choices such as logging levels, matched ignoring case.
|
|
268
|
+
for choice in map(str, action.choices):
|
|
269
|
+
if choice.lower().startswith(current.lower()):
|
|
270
|
+
yield Completion(choice, start_position=-typed)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _complete_object_types(current: str, typed: int) -> Iterator[Completion]:
|
|
274
|
+
for object_type in MidpointObjectTypes:
|
|
275
|
+
tagname = object_type.value.tagname
|
|
276
|
+
if object_type.value.endpoint is not None and tagname.lower().startswith(current.lower()):
|
|
277
|
+
yield Completion(tagname, start_position=-typed, display_meta=object_type.value.object_type)
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _complete_files(current: str, typed: int) -> Iterator[Completion]:
|
|
281
|
+
for path in sorted(glob.glob(glob.escape(current) + '*')):
|
|
282
|
+
path = _append_slash_if_dir(path)
|
|
283
|
+
yield Completion(shlex.quote(path), start_position=-typed, display=os.path.basename(path.rstrip(os.sep)))
|
|
284
|
+
|
|
285
|
+
def _complete_objects(self, kind: str, current: str, typed: int) -> Iterator[Completion]:
|
|
286
|
+
"""Offer the names the server finds starting with the word, with the OID as metadata."""
|
|
287
|
+
for obj in self._get_objects(kind, current):
|
|
288
|
+
name = obj.get_name()
|
|
289
|
+
oid = obj.get_oid()
|
|
290
|
+
if name:
|
|
291
|
+
yield Completion(shlex.quote(name), start_position=-typed, display=name, display_meta=f'OID: {oid}')
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from configparser import ConfigParser
|
|
7
|
+
from configparser import Error as ConfigParserError
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from midpoint_cli.client import MidpointClientConfiguration
|
|
12
|
+
from midpoint_cli.client.session import (
|
|
13
|
+
READ_TIMEOUT_ENV_VAR,
|
|
14
|
+
READ_TIMEOUT_SECONDS,
|
|
15
|
+
RETRY_TIMEOUT_ENV_VAR,
|
|
16
|
+
RETRY_TIMEOUT_SECONDS,
|
|
17
|
+
parse_retry_timeout,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_LOOPBACK_HOSTNAMES = frozenset({'localhost', 'localhost.localdomain'})
|
|
23
|
+
|
|
24
|
+
HOME_CONFIGURATION_FILE = '~/.midpoint-cli.cfg'
|
|
25
|
+
CONFIGURATION_FILE_VARIABLE = 'MIDPOINT_CLI_CONFIG'
|
|
26
|
+
LEGACY_WORKING_DIRECTORY_FILE = 'midpoint-cli.cfg'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigurationFileError(Exception):
|
|
30
|
+
"""An explicitly requested configuration file is missing, unreadable or malformed."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def locate_configuration_file(cli_path: str | None, env_path: str | None) -> tuple[str | None, bool]:
|
|
34
|
+
"""Return the configuration file to read and whether it was requested explicitly.
|
|
35
|
+
|
|
36
|
+
``--config`` wins over ``MIDPOINT_CLI_CONFIG``; without either, only the home file is
|
|
37
|
+
read. A ``midpoint-cli.cfg`` in the working directory is never picked up implicitly: it
|
|
38
|
+
could come from any checkout and redirect the server URL that credentials are sent to.
|
|
39
|
+
"""
|
|
40
|
+
explicit_path = cli_path or env_path
|
|
41
|
+
if explicit_path:
|
|
42
|
+
return os.path.expanduser(explicit_path), True
|
|
43
|
+
|
|
44
|
+
home_path = os.path.expanduser(HOME_CONFIGURATION_FILE)
|
|
45
|
+
if os.path.isfile(home_path):
|
|
46
|
+
return home_path, False
|
|
47
|
+
return None, False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_configuration_file(path: str | None) -> ConfigParser:
|
|
51
|
+
"""Parse *path* into a ConfigParser; an empty parser when *path* is None.
|
|
52
|
+
|
|
53
|
+
Raises ConfigurationFileError when the file cannot be read or parsed.
|
|
54
|
+
"""
|
|
55
|
+
parser = ConfigParser()
|
|
56
|
+
if path is None:
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
if not os.path.isfile(path):
|
|
60
|
+
raise ConfigurationFileError(f'configuration file not found: {path}')
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
read = parser.read(path)
|
|
64
|
+
except (ConfigParserError, UnicodeDecodeError) as e:
|
|
65
|
+
raise ConfigurationFileError(f'cannot parse configuration file {path}: {e}') from e
|
|
66
|
+
|
|
67
|
+
if not read:
|
|
68
|
+
raise ConfigurationFileError(f'cannot read configuration file: {path}')
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_unencrypted_remote_url(url: str) -> bool:
|
|
73
|
+
"""True when *url* would carry HTTP Basic credentials in clear text off the local machine.
|
|
74
|
+
|
|
75
|
+
Only plain ``http://`` to a non-loopback host qualifies: ``https://`` is encrypted and
|
|
76
|
+
a loopback address never leaves the host, which is the normal sandbox setup.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
parts = urlsplit(url)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
if parts.scheme.lower() != 'http':
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
host = (parts.hostname or '').lower()
|
|
87
|
+
if not host:
|
|
88
|
+
return False
|
|
89
|
+
return not _is_loopback_host(host)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_loopback_host(host: str) -> bool:
|
|
93
|
+
if host in _LOOPBACK_HOSTNAMES:
|
|
94
|
+
return True
|
|
95
|
+
try:
|
|
96
|
+
return ipaddress.ip_address(host).is_loopback
|
|
97
|
+
except ValueError:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_loopback_url(url: str) -> bool:
|
|
102
|
+
"""True when *url* points at this machine (``localhost`` or a loopback address)."""
|
|
103
|
+
try:
|
|
104
|
+
host = (urlsplit(url).hostname or '').lower()
|
|
105
|
+
except ValueError:
|
|
106
|
+
return False
|
|
107
|
+
return bool(host) and _is_loopback_host(host)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def configuration_file_permission_warning(path: str | None, config: ConfigParser, url: str) -> str | None:
|
|
111
|
+
"""Warn, SSH-style, when *path* holds a password and other local users can access it.
|
|
112
|
+
|
|
113
|
+
A file without a password exposes nothing, and a password for a server on this machine
|
|
114
|
+
is the sandbox case (the committed midpoint-cli.cfg is 0644 by design), so both are
|
|
115
|
+
skipped to keep the warning meaningful. POSIX permission bits do not apply on Windows.
|
|
116
|
+
"""
|
|
117
|
+
if path is None or os.name == 'nt':
|
|
118
|
+
return None
|
|
119
|
+
if 'Midpoint' not in config or 'password' not in config['Midpoint']:
|
|
120
|
+
return None
|
|
121
|
+
if is_loopback_url(url):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
mode = os.stat(path).st_mode & 0o777
|
|
126
|
+
except OSError:
|
|
127
|
+
return None
|
|
128
|
+
if not mode & 0o077:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
f'Configuration file {path} holds a password and is accessible by other users '
|
|
133
|
+
f'(mode {mode:04o}). Restrict it with: chmod 600 {shlex.quote(path)}'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def ignored_working_directory_file(config_path: str | None) -> str | None:
|
|
138
|
+
"""Name ./midpoint-cli.cfg when it exists but is not the configuration file in use."""
|
|
139
|
+
if not os.path.isfile(LEGACY_WORKING_DIRECTORY_FILE):
|
|
140
|
+
return None
|
|
141
|
+
if (
|
|
142
|
+
config_path is not None
|
|
143
|
+
and os.path.isfile(config_path)
|
|
144
|
+
and os.path.samefile(config_path, LEGACY_WORKING_DIRECTORY_FILE)
|
|
145
|
+
):
|
|
146
|
+
return None
|
|
147
|
+
return LEGACY_WORKING_DIRECTORY_FILE
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
SOURCE_DEFAULT = 'default'
|
|
151
|
+
|
|
152
|
+
_SETTINGS = ('url', 'username', 'password', 'timeout', 'read_timeout')
|
|
153
|
+
_ENVIRONMENT_VARIABLES = (
|
|
154
|
+
'MIDPOINT_URL',
|
|
155
|
+
'MIDPOINT_USERNAME',
|
|
156
|
+
'MIDPOINT_PASSWORD',
|
|
157
|
+
RETRY_TIMEOUT_ENV_VAR,
|
|
158
|
+
READ_TIMEOUT_ENV_VAR,
|
|
159
|
+
)
|
|
160
|
+
_OPTIONS = ('--url', '--username', '--password', '--timeout', '--read-timeout')
|
|
161
|
+
_TIMEOUT_SETTINGS = frozenset({'timeout', 'read_timeout'})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class ResolvedConfiguration:
|
|
166
|
+
"""The effective client configuration and, for each setting, where its value came from."""
|
|
167
|
+
|
|
168
|
+
configuration: MidpointClientConfiguration
|
|
169
|
+
sources: dict[str, str] = field(default_factory=lambda: dict.fromkeys(_SETTINGS, SOURCE_DEFAULT))
|
|
170
|
+
|
|
171
|
+
def set(self, setting: str, value: str | float | None, source: str) -> None:
|
|
172
|
+
"""Use *value* for *setting*, recording *source*; an empty value leaves the setting unchanged."""
|
|
173
|
+
if value:
|
|
174
|
+
setattr(self.configuration, setting, value)
|
|
175
|
+
self.sources[setting] = source
|
|
176
|
+
|
|
177
|
+
def describe(self) -> str:
|
|
178
|
+
"""One line naming the server, the user, the timeouts and where each setting came from."""
|
|
179
|
+
timeout = self.configuration.timeout if self.configuration.timeout is not None else RETRY_TIMEOUT_SECONDS
|
|
180
|
+
read_timeout = self.configuration.read_timeout
|
|
181
|
+
if read_timeout is None:
|
|
182
|
+
read_timeout = READ_TIMEOUT_SECONDS
|
|
183
|
+
return (
|
|
184
|
+
f'Server {self.configuration.url} (from {self.sources["url"]}), '
|
|
185
|
+
f'user {self.configuration.username} (from {self.sources["username"]}), '
|
|
186
|
+
f'password from {self.sources["password"]}, '
|
|
187
|
+
f'retry timeout {timeout:g} s (from {self.sources["timeout"]}), '
|
|
188
|
+
f'read timeout {read_timeout:g} s (from {self.sources["read_timeout"]})'
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _file_timeout(setting: str, raw: str, file_source: str) -> float:
|
|
193
|
+
value = parse_retry_timeout(raw)
|
|
194
|
+
if value is None:
|
|
195
|
+
raise ConfigurationFileError(
|
|
196
|
+
f'invalid {setting} {raw!r} in {file_source}: expected a positive number of seconds'
|
|
197
|
+
)
|
|
198
|
+
return value
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _environment_timeout(variable: str, raw: str | None) -> float | None:
|
|
202
|
+
if raw is None or raw.strip() == '':
|
|
203
|
+
return None
|
|
204
|
+
value = parse_retry_timeout(raw)
|
|
205
|
+
if value is None:
|
|
206
|
+
logger.warning('Ignoring %s=%r: expected a positive number of seconds.', variable, raw)
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def resolve_client_configuration(
|
|
211
|
+
ns: Namespace, config: ConfigParser, env: list[str | None], config_file: str | None = None
|
|
212
|
+
) -> ResolvedConfiguration:
|
|
213
|
+
"""Merge defaults, the configuration file, the environment and the command line, in that order.
|
|
214
|
+
|
|
215
|
+
*env* holds the values of MIDPOINT_URL, MIDPOINT_USERNAME, MIDPOINT_PASSWORD,
|
|
216
|
+
MIDPOINT_TIMEOUT and MIDPOINT_READ_TIMEOUT, in that order; missing trailing values count
|
|
217
|
+
as unset. *config_file* only names the file in the recorded sources.
|
|
218
|
+
|
|
219
|
+
Raises ConfigurationFileError when the configuration file holds an invalid timeout; an
|
|
220
|
+
invalid MIDPOINT_TIMEOUT or MIDPOINT_READ_TIMEOUT is ignored with a warning.
|
|
221
|
+
"""
|
|
222
|
+
if len(env) > len(_ENVIRONMENT_VARIABLES):
|
|
223
|
+
raise ValueError(f'expected at most {len(_ENVIRONMENT_VARIABLES)} environment values, got {len(env)}')
|
|
224
|
+
env = [*env, *[None] * (len(_ENVIRONMENT_VARIABLES) - len(env))]
|
|
225
|
+
resolved = ResolvedConfiguration(
|
|
226
|
+
MidpointClientConfiguration(timeout=RETRY_TIMEOUT_SECONDS, read_timeout=READ_TIMEOUT_SECONDS)
|
|
227
|
+
)
|
|
228
|
+
apply = resolved.set
|
|
229
|
+
|
|
230
|
+
# Configuration file
|
|
231
|
+
|
|
232
|
+
if 'Midpoint' in config:
|
|
233
|
+
section_mp = config['Midpoint']
|
|
234
|
+
file_source = f'configuration file {config_file}' if config_file else 'configuration file'
|
|
235
|
+
for setting in _SETTINGS:
|
|
236
|
+
if setting in section_mp:
|
|
237
|
+
raw = section_mp[setting]
|
|
238
|
+
file_value: float | str = (
|
|
239
|
+
_file_timeout(setting, raw, file_source) if setting in _TIMEOUT_SETTINGS else raw
|
|
240
|
+
)
|
|
241
|
+
apply(setting, file_value, file_source)
|
|
242
|
+
|
|
243
|
+
# Environment variables
|
|
244
|
+
|
|
245
|
+
for setting, variable, raw_value in zip(_SETTINGS, _ENVIRONMENT_VARIABLES, env, strict=True):
|
|
246
|
+
value = _environment_timeout(variable, raw_value) if setting in _TIMEOUT_SETTINGS else raw_value
|
|
247
|
+
apply(setting, value, f'environment variable {variable}')
|
|
248
|
+
|
|
249
|
+
# Command-line parameters (argparse has already validated --timeout and --read-timeout)
|
|
250
|
+
|
|
251
|
+
for setting, option in zip(_SETTINGS, _OPTIONS, strict=True):
|
|
252
|
+
if setting == 'password' and getattr(ns, 'ask_password', False):
|
|
253
|
+
source = 'password prompt'
|
|
254
|
+
else:
|
|
255
|
+
source = f'command-line option {option}'
|
|
256
|
+
apply(setting, getattr(ns, setting, None), source)
|
|
257
|
+
|
|
258
|
+
return resolved
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from argparse import ArgumentParser, RawTextHelpFormatter
|
|
2
|
+
|
|
3
|
+
from midpoint_cli.prompt.base import PromptBase, add_listing_arguments
|
|
4
|
+
from midpoint_cli.prompt.console import print_rows
|
|
5
|
+
|
|
6
|
+
connector_parser = ArgumentParser(
|
|
7
|
+
formatter_class=RawTextHelpFormatter,
|
|
8
|
+
prog='connector',
|
|
9
|
+
description='Inspect the connectors known to the server.',
|
|
10
|
+
epilog="""
|
|
11
|
+
Available commands:
|
|
12
|
+
ls List connectors with their type, version and bundle.
|
|
13
|
+
""",
|
|
14
|
+
)
|
|
15
|
+
connector_parser.add_argument('command', help='Connector command to execute.')
|
|
16
|
+
connector_parser.add_argument('arg', help='Optional command arguments.', nargs='*')
|
|
17
|
+
|
|
18
|
+
connector_ls_parser = add_listing_arguments(
|
|
19
|
+
ArgumentParser(
|
|
20
|
+
formatter_class=RawTextHelpFormatter,
|
|
21
|
+
prog='connector ls',
|
|
22
|
+
description='List the connectors known to the server.',
|
|
23
|
+
),
|
|
24
|
+
'connectors',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Each subcommand with its parser; None for those taking no arguments.
|
|
28
|
+
connector_subparsers: dict[str, ArgumentParser | None] = {'ls': connector_ls_parser}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConnectorClientPrompt(PromptBase):
|
|
32
|
+
subparsers_connector = connector_subparsers
|
|
33
|
+
|
|
34
|
+
def do_connector(self, inp: str) -> None:
|
|
35
|
+
"""Inspect connectors (subcommand: ls)."""
|
|
36
|
+
self._dispatch_subcommand(inp, connector_parser, connector_subparsers, {'ls': self._connector_ls})
|
|
37
|
+
|
|
38
|
+
def _connector_ls(self, args: list[str]) -> None:
|
|
39
|
+
ls_ns = connector_ls_parser.parse_args(args)
|
|
40
|
+
connectors = self.client.get_connectors(limit=ls_ns.limit, name_filter=ls_ns.filter)
|
|
41
|
+
print_rows(connectors, title='Connectors', noun='connectors')
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def help_connector() -> None:
|
|
45
|
+
connector_parser.print_help()
|