mreg-cli 1.0.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.
- mreg_cli/__about__.py +7 -0
- mreg_cli/__init__.py +5 -0
- mreg_cli/__main__.py +8 -0
- mreg_cli/_version.py +16 -0
- mreg_cli/api/__init__.py +7 -0
- mreg_cli/api/abstracts.py +486 -0
- mreg_cli/api/endpoints.py +130 -0
- mreg_cli/api/fields.py +104 -0
- mreg_cli/api/history.py +152 -0
- mreg_cli/api/models.py +3416 -0
- mreg_cli/cli.py +382 -0
- mreg_cli/commands/__init__.py +1 -0
- mreg_cli/commands/base.py +62 -0
- mreg_cli/commands/dhcp.py +136 -0
- mreg_cli/commands/group.py +326 -0
- mreg_cli/commands/help.py +77 -0
- mreg_cli/commands/host.py +54 -0
- mreg_cli/commands/host_submodules/__init__.py +28 -0
- mreg_cli/commands/host_submodules/a_aaaa.py +452 -0
- mreg_cli/commands/host_submodules/bacnet.py +126 -0
- mreg_cli/commands/host_submodules/cname.py +166 -0
- mreg_cli/commands/host_submodules/core.py +507 -0
- mreg_cli/commands/host_submodules/rr.py +973 -0
- mreg_cli/commands/label.py +146 -0
- mreg_cli/commands/logging.py +112 -0
- mreg_cli/commands/network.py +516 -0
- mreg_cli/commands/permission.py +202 -0
- mreg_cli/commands/policy.py +519 -0
- mreg_cli/commands/recording.py +59 -0
- mreg_cli/commands/registry.py +56 -0
- mreg_cli/commands/root.py +58 -0
- mreg_cli/commands/zone.py +288 -0
- mreg_cli/config.py +253 -0
- mreg_cli/errorbuilder.py +193 -0
- mreg_cli/exceptions.py +237 -0
- mreg_cli/help_formatter.py +38 -0
- mreg_cli/main.py +238 -0
- mreg_cli/outputmanager.py +466 -0
- mreg_cli/py.typed +0 -0
- mreg_cli/tags.txt +55 -0
- mreg_cli/tokenfile.py +89 -0
- mreg_cli/types.py +160 -0
- mreg_cli/utilities/__init__.py +5 -0
- mreg_cli/utilities/api.py +595 -0
- mreg_cli/utilities/shared.py +65 -0
- mreg_cli/utilities/validators.py +19 -0
- mreg_cli-1.0.0.dist-info/AUTHORS +12 -0
- mreg_cli-1.0.0.dist-info/LICENSE +674 -0
- mreg_cli-1.0.0.dist-info/METADATA +1079 -0
- mreg_cli-1.0.0.dist-info/RECORD +53 -0
- mreg_cli-1.0.0.dist-info/WHEEL +5 -0
- mreg_cli-1.0.0.dist-info/entry_points.txt +2 -0
- mreg_cli-1.0.0.dist-info/top_level.txt +1 -0
mreg_cli/cli.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Command line interface for mreg.
|
|
2
|
+
|
|
3
|
+
This file contains the main CLI class and the top level parser.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import html
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Generator
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
16
|
+
|
|
17
|
+
from prompt_toolkit import HTML, document, print_formatted_text
|
|
18
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
19
|
+
|
|
20
|
+
# Import all the commands
|
|
21
|
+
from mreg_cli.commands.dhcp import DHCPCommands
|
|
22
|
+
from mreg_cli.commands.group import GroupCommands
|
|
23
|
+
from mreg_cli.commands.help import HelpCommands
|
|
24
|
+
from mreg_cli.commands.host import HostCommands
|
|
25
|
+
from mreg_cli.commands.label import LabelCommands
|
|
26
|
+
from mreg_cli.commands.logging import LoggingCommmands
|
|
27
|
+
from mreg_cli.commands.network import NetworkCommands
|
|
28
|
+
from mreg_cli.commands.permission import PermissionCommands
|
|
29
|
+
from mreg_cli.commands.policy import PolicyCommands
|
|
30
|
+
from mreg_cli.commands.recording import RecordingCommmands
|
|
31
|
+
from mreg_cli.commands.root import RootCommmands
|
|
32
|
+
from mreg_cli.commands.zone import ZoneCommands
|
|
33
|
+
|
|
34
|
+
# Import other mreg_cli modules
|
|
35
|
+
from mreg_cli.exceptions import CliError, CliExit, CliWarning
|
|
36
|
+
from mreg_cli.help_formatter import CustomHelpFormatter
|
|
37
|
+
from mreg_cli.outputmanager import OutputManager
|
|
38
|
+
from mreg_cli.types import CommandFunc, Flag
|
|
39
|
+
from mreg_cli.utilities.api import create_and_set_corrolation_id
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
# Can't use _SubParsersAction as generic in Python <3.9
|
|
45
|
+
SubparserType = argparse._SubParsersAction[argparse.ArgumentParser] # type: ignore # private attribute
|
|
46
|
+
|
|
47
|
+
# Subclasses of BaseCommand change the constructor signature from
|
|
48
|
+
# 6 arguments to a single argument `cli`. That means we cannot
|
|
49
|
+
# declare the list of commands as a list of `type[BaseCommand]`
|
|
50
|
+
# and then instantiate them. Instead, we need to declare this interface
|
|
51
|
+
# which lets the type checker understand what kind of classes we are
|
|
52
|
+
# trying to instantiate.
|
|
53
|
+
class BaseCommandSubclass(Protocol): # noqa: D101 (undocumented-public-class)
|
|
54
|
+
def __init__(self, cli: "Command") -> None: ... # noqa: D107 (undocumented-public-init)
|
|
55
|
+
def register_all_commands(self) -> None: ... # noqa: D102 (undocumented-public-method)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _create_command_group(parent: argparse.ArgumentParser) -> SubparserType:
|
|
59
|
+
"""Create a sub parser for a command."""
|
|
60
|
+
parent_name = parent.prog.strip()
|
|
61
|
+
|
|
62
|
+
if parent_name:
|
|
63
|
+
title = "subcommands"
|
|
64
|
+
else:
|
|
65
|
+
title = "commands"
|
|
66
|
+
|
|
67
|
+
metavar = "<command>"
|
|
68
|
+
description = "Run '{}' for more details".format(
|
|
69
|
+
" ".join(word for word in (parent_name, metavar, "-h") if word)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return parent.add_subparsers(
|
|
73
|
+
title=title,
|
|
74
|
+
description=description,
|
|
75
|
+
metavar=metavar,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Command(Completer):
|
|
80
|
+
"""Command class for the CLI.
|
|
81
|
+
|
|
82
|
+
Wrapper around argparse.ArgumentParser and prompt_toolkit.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Used to detect an error when running commands from a source file.
|
|
86
|
+
last_errno: str | int | None = 0
|
|
87
|
+
|
|
88
|
+
def __init__(self, parser: argparse.ArgumentParser, flags: list[Flag], short_desc: str):
|
|
89
|
+
"""Initialize a Command object."""
|
|
90
|
+
self.parser = parser
|
|
91
|
+
# sub is an object used for creating sub parser for this command. A
|
|
92
|
+
# command/ArgParser can only have one of this object.
|
|
93
|
+
self.sub: SubparserType | None = None
|
|
94
|
+
|
|
95
|
+
self.short_desc = short_desc
|
|
96
|
+
self.children: dict[str, Command] = {}
|
|
97
|
+
self.flags: dict[str, Flag] = {}
|
|
98
|
+
for flag in flags:
|
|
99
|
+
if flag.name.startswith("-"):
|
|
100
|
+
self.flags[flag.name.lstrip("-")] = flag
|
|
101
|
+
|
|
102
|
+
def add_command(
|
|
103
|
+
self,
|
|
104
|
+
prog: str,
|
|
105
|
+
description: str,
|
|
106
|
+
short_desc: str = "",
|
|
107
|
+
epilog: str | None = None,
|
|
108
|
+
callback: CommandFunc | None = None,
|
|
109
|
+
flags: list[Flag] | None = None,
|
|
110
|
+
):
|
|
111
|
+
"""Add a command to the current parser.
|
|
112
|
+
|
|
113
|
+
:param flags: a list of Flag objects. NB: must be handled as read-only,
|
|
114
|
+
since the default value is [].
|
|
115
|
+
:return: the Command object of the new command.
|
|
116
|
+
"""
|
|
117
|
+
if flags is None:
|
|
118
|
+
flags = []
|
|
119
|
+
if self.sub is None:
|
|
120
|
+
self.sub = _create_command_group(self.parser)
|
|
121
|
+
parser = self.sub.add_parser(prog, description=description, epilog=epilog, help=short_desc)
|
|
122
|
+
parser.formatter_class = CustomHelpFormatter
|
|
123
|
+
for f in flags:
|
|
124
|
+
# Need to create a dict with the parameters so only used
|
|
125
|
+
# parameters are sent, or else exceptions are raised. Ex: if
|
|
126
|
+
# required is passed with an argument which doesn't accept the
|
|
127
|
+
# required option.
|
|
128
|
+
args: dict[str, Any] = {
|
|
129
|
+
"help": f.description,
|
|
130
|
+
}
|
|
131
|
+
if f.type:
|
|
132
|
+
args["type"] = f.type
|
|
133
|
+
if f.nargs:
|
|
134
|
+
args["nargs"] = f.nargs
|
|
135
|
+
if f.default:
|
|
136
|
+
args["default"] = f.default
|
|
137
|
+
if f.choices:
|
|
138
|
+
args["choices"] = f.choices
|
|
139
|
+
if f.required:
|
|
140
|
+
args["required"] = f.required
|
|
141
|
+
if f.metavar:
|
|
142
|
+
args["metavar"] = f.metavar
|
|
143
|
+
if f.action:
|
|
144
|
+
args["action"] = f.action
|
|
145
|
+
parser.add_argument(f.name, **args)
|
|
146
|
+
parser.set_defaults(func=callback)
|
|
147
|
+
|
|
148
|
+
new_cmd = Command(parser, flags, short_desc)
|
|
149
|
+
self.children[prog] = new_cmd
|
|
150
|
+
return new_cmd
|
|
151
|
+
|
|
152
|
+
def parse(self, command: str) -> None:
|
|
153
|
+
"""Parse and execute a command."""
|
|
154
|
+
logger.debug(f"Parsing command: {command}")
|
|
155
|
+
args = shlex.split(command, comments=True)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
parsed_args = self.parser.parse_args(args)
|
|
159
|
+
# If the command has a callback function, call it.
|
|
160
|
+
if hasattr(parsed_args, "func") and parsed_args.func:
|
|
161
|
+
parsed_args.func(parsed_args)
|
|
162
|
+
|
|
163
|
+
except SystemExit as e:
|
|
164
|
+
# This is a super-hacky workaround to implement a REPL app using
|
|
165
|
+
# argparse; Argparse calls sys.exit when it detects an error or
|
|
166
|
+
# after it prints a help msg.
|
|
167
|
+
self.last_errno = e.code
|
|
168
|
+
|
|
169
|
+
except (CliWarning, CliError) as exc:
|
|
170
|
+
exc.print_self()
|
|
171
|
+
|
|
172
|
+
except CliExit:
|
|
173
|
+
# If we have active recordings going on, save them before exiting
|
|
174
|
+
OutputManager().recording_stop()
|
|
175
|
+
sys.exit(0)
|
|
176
|
+
|
|
177
|
+
else:
|
|
178
|
+
# If no exception occurred make sure errno isn't set to an error
|
|
179
|
+
# code.
|
|
180
|
+
self.last_errno = 0
|
|
181
|
+
|
|
182
|
+
# We ignore ARG0002 (unused-argument) because the method signature is
|
|
183
|
+
# required by the Completer class.
|
|
184
|
+
def get_completions(
|
|
185
|
+
self,
|
|
186
|
+
document: document.Document,
|
|
187
|
+
complete_event: CompleteEvent, # noqa: ARG002
|
|
188
|
+
) -> Generator[Completion | Any, Any, None]:
|
|
189
|
+
"""Prepare completions for the current command.
|
|
190
|
+
|
|
191
|
+
:param document: The current document.
|
|
192
|
+
:param complete_event: The current complete event.
|
|
193
|
+
|
|
194
|
+
:yields: Completions for the current command.
|
|
195
|
+
"""
|
|
196
|
+
cur = document.get_word_before_cursor()
|
|
197
|
+
words = document.text.strip().split(" ")
|
|
198
|
+
yield from self.complete(cur, words)
|
|
199
|
+
|
|
200
|
+
def complete(self, cur: str, words: list[str]) -> Generator[Completion | Any, Any, None]:
|
|
201
|
+
"""Generate completions during typing.
|
|
202
|
+
|
|
203
|
+
:param cur: The current word.
|
|
204
|
+
:param words: The current line split into words.
|
|
205
|
+
"""
|
|
206
|
+
# if line is empty suggest all sub commands
|
|
207
|
+
if not words:
|
|
208
|
+
for name in self.children:
|
|
209
|
+
yield Completion(name, display_meta=self.children[name].short_desc)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# only suggest sub commands if there's only one word on the line.
|
|
213
|
+
# this behavior might cause a sub command to be suggested when typing
|
|
214
|
+
# an unflagged argument, but it avoids suggestion of sub commands
|
|
215
|
+
# purely based on current word, which would cause suggestions of sub
|
|
216
|
+
# commands at any time when typing a matching word.
|
|
217
|
+
# NOTE: must not return at end of block or else there'll be a bug when
|
|
218
|
+
# the first (and only) word is a flag.
|
|
219
|
+
if len(words) < 2:
|
|
220
|
+
for name in self.children:
|
|
221
|
+
if name.startswith(cur) and cur:
|
|
222
|
+
yield Completion(
|
|
223
|
+
name,
|
|
224
|
+
display_meta=self.children[name].short_desc,
|
|
225
|
+
start_position=-len(cur),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# if the line starts with one of the sub commands, pass it along
|
|
229
|
+
if words[0] in self.children:
|
|
230
|
+
yield from self.children[words[0]].complete(cur, words[1:])
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
# If none of the above then check if some of the flags match
|
|
234
|
+
|
|
235
|
+
# If current word is empty then no flag is suggested
|
|
236
|
+
if not cur:
|
|
237
|
+
return
|
|
238
|
+
# If the current word is - then it is the beginning of a flag
|
|
239
|
+
if cur == "-":
|
|
240
|
+
cur = ""
|
|
241
|
+
# If current word doesn't start with - then it isn't a flag being typed
|
|
242
|
+
elif ("-" + cur) not in words:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
# complete flags which aren't already used
|
|
246
|
+
for name in self.flags:
|
|
247
|
+
if ("-" + name) not in words:
|
|
248
|
+
if name.startswith(cur):
|
|
249
|
+
yield Completion(
|
|
250
|
+
name,
|
|
251
|
+
display_meta=self.flags[name].short_desc,
|
|
252
|
+
start_position=-len(cur),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def process_command_line(self, line: str) -> None:
|
|
256
|
+
"""Process a line containing a command."""
|
|
257
|
+
# OutputManager is a singleton class so we
|
|
258
|
+
# need to clear it before each command.
|
|
259
|
+
output = OutputManager()
|
|
260
|
+
output.clear()
|
|
261
|
+
# Set the command that generated the output
|
|
262
|
+
# Also remove filters and other noise.
|
|
263
|
+
try:
|
|
264
|
+
cmd = output.from_command(line)
|
|
265
|
+
except (CliWarning, CliError) as exc:
|
|
266
|
+
exc.print_self()
|
|
267
|
+
return
|
|
268
|
+
# Create and set the corrolation id, using the cleaned command
|
|
269
|
+
# as the suffix. This is used to track the command in the logs
|
|
270
|
+
# on the server side.
|
|
271
|
+
create_and_set_corrolation_id(cmd)
|
|
272
|
+
# Run the command
|
|
273
|
+
cli.parse(cmd)
|
|
274
|
+
# Render the output
|
|
275
|
+
output.render()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# Top parser is the root of all the command parsers
|
|
279
|
+
_top_parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter)
|
|
280
|
+
cli = Command(_top_parser, list(), "")
|
|
281
|
+
commands: list[type[BaseCommandSubclass]] = [
|
|
282
|
+
DHCPCommands,
|
|
283
|
+
GroupCommands,
|
|
284
|
+
HelpCommands,
|
|
285
|
+
HostCommands,
|
|
286
|
+
NetworkCommands,
|
|
287
|
+
PermissionCommands,
|
|
288
|
+
PolicyCommands,
|
|
289
|
+
ZoneCommands,
|
|
290
|
+
LabelCommands,
|
|
291
|
+
RecordingCommmands,
|
|
292
|
+
LoggingCommmands,
|
|
293
|
+
RootCommmands,
|
|
294
|
+
]
|
|
295
|
+
for command in commands:
|
|
296
|
+
command(cli).register_all_commands()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def source(files: list[str], ignore_errors: bool, verbose: bool) -> Generator[str, None, None]:
|
|
300
|
+
"""Read commands from one or more source files and yield them.
|
|
301
|
+
|
|
302
|
+
:param files: List of file paths to read commands from.
|
|
303
|
+
:param ignore_errors: If True, continue on errors.
|
|
304
|
+
:param verbose: If True, print commands before execution.
|
|
305
|
+
|
|
306
|
+
:yields: Command lines from the files.
|
|
307
|
+
"""
|
|
308
|
+
for filename in files:
|
|
309
|
+
if filename.startswith("~"):
|
|
310
|
+
filename = os.path.expanduser(filename)
|
|
311
|
+
try:
|
|
312
|
+
with open(filename) as f:
|
|
313
|
+
logger.info("Reading commands from %s", filename)
|
|
314
|
+
for i, line in enumerate(f):
|
|
315
|
+
# Shell commands can be called from scripts. They start with '!'
|
|
316
|
+
if line.startswith("!"):
|
|
317
|
+
os.system(line[1:])
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
if verbose:
|
|
321
|
+
print_formatted_text(HTML(f"<i>> {html.escape(line.strip())}</i>"))
|
|
322
|
+
|
|
323
|
+
yield line
|
|
324
|
+
|
|
325
|
+
if cli.last_errno != 0:
|
|
326
|
+
col = "ansired"
|
|
327
|
+
print_formatted_text(
|
|
328
|
+
HTML(f"<{col}><i>{filename}</i>: Error on line {i + 1}</{col}>")
|
|
329
|
+
)
|
|
330
|
+
OutputManager().add_error(f"{filename}: Error on line {i + 1}")
|
|
331
|
+
if not ignore_errors:
|
|
332
|
+
return
|
|
333
|
+
except FileNotFoundError:
|
|
334
|
+
print_formatted_text(f"No such file: '{filename}'")
|
|
335
|
+
logger.error(f"File not found during source: '{filename}'")
|
|
336
|
+
except PermissionError:
|
|
337
|
+
print_formatted_text(f"Permission denied: '{filename}'")
|
|
338
|
+
logger.error(f"Permission denied during source: '{filename}'")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Always need the source command. This is located here due to the deep interaction with
|
|
342
|
+
# the cli variable itself and the command processing. Moving this out to another file
|
|
343
|
+
# would lead to circular imports in all its joy.
|
|
344
|
+
def _source(args: argparse.Namespace):
|
|
345
|
+
"""Source command for the CLI.
|
|
346
|
+
|
|
347
|
+
:param args: The arguments passed to the command.
|
|
348
|
+
"""
|
|
349
|
+
for command in source(args.files, args.ignore_errors, args.verbose):
|
|
350
|
+
# Process each command here as needed, similar to the main loop
|
|
351
|
+
print(f"Processing command: {command}")
|
|
352
|
+
cli.process_command_line(command)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
cli.add_command(
|
|
356
|
+
prog="source",
|
|
357
|
+
description="Read and run commands from the given source files.",
|
|
358
|
+
short_desc="Run commands from file(s)",
|
|
359
|
+
callback=_source,
|
|
360
|
+
flags=[
|
|
361
|
+
Flag(
|
|
362
|
+
"files",
|
|
363
|
+
description=(
|
|
364
|
+
"Source files to read commands from. Commands are "
|
|
365
|
+
"separated with new lines and comments are started "
|
|
366
|
+
'with "#"'
|
|
367
|
+
),
|
|
368
|
+
short_desc="File names",
|
|
369
|
+
nargs="+",
|
|
370
|
+
metavar="SOURCE",
|
|
371
|
+
),
|
|
372
|
+
Flag(
|
|
373
|
+
"-ignore-errors",
|
|
374
|
+
description=(
|
|
375
|
+
"Continue command execution on error. Default is to stop execution on error."
|
|
376
|
+
),
|
|
377
|
+
short_desc="Stop on error.",
|
|
378
|
+
action="store_true",
|
|
379
|
+
),
|
|
380
|
+
Flag("-verbose", description="Verbose output.", action="store_true"),
|
|
381
|
+
],
|
|
382
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command modules for mreg_cli."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Base command class for mreg_cli.
|
|
2
|
+
|
|
3
|
+
This module provides the :py:class:`BaseCommands` class, which is used as the
|
|
4
|
+
base class for all CLI command classes.
|
|
5
|
+
|
|
6
|
+
Note: Logging in this file is hard as logging is not configured when this is loaded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mreg_cli.commands.registry import CommandRegistry
|
|
14
|
+
from mreg_cli.types import CommandFunc, Flag
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseCommand:
|
|
18
|
+
"""Base class for CLI commands."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
cli: Any,
|
|
23
|
+
command_registry: CommandRegistry,
|
|
24
|
+
command_name: str,
|
|
25
|
+
description: str,
|
|
26
|
+
short_desc: str | None,
|
|
27
|
+
callback: CommandFunc | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Initialize the command class."""
|
|
30
|
+
self.base_cli = cli
|
|
31
|
+
self.command_registry = command_registry
|
|
32
|
+
if not command_registry.root:
|
|
33
|
+
self.scope = cli.add_command(
|
|
34
|
+
prog=command_name,
|
|
35
|
+
description=description,
|
|
36
|
+
short_desc=short_desc or description,
|
|
37
|
+
callback=callback,
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
self.scope = cli
|
|
41
|
+
|
|
42
|
+
def register_all_commands(self) -> None:
|
|
43
|
+
"""Register all commands currently in the registry."""
|
|
44
|
+
for prog, description, short_desc, callback, flags in self.command_registry.get_commands():
|
|
45
|
+
self.add_command(prog, description, short_desc, callback, flags)
|
|
46
|
+
|
|
47
|
+
def add_command(
|
|
48
|
+
self,
|
|
49
|
+
prog: str,
|
|
50
|
+
description: str,
|
|
51
|
+
short_desc: str,
|
|
52
|
+
callback: CommandFunc,
|
|
53
|
+
flags: list[Flag] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Add a command to the registry."""
|
|
56
|
+
self.scope.add_command(
|
|
57
|
+
prog=prog,
|
|
58
|
+
description=description,
|
|
59
|
+
short_desc=short_desc,
|
|
60
|
+
callback=callback,
|
|
61
|
+
flags=flags,
|
|
62
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""DHCP commands for mreg_cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mreg_cli.api.fields import IPAddressField
|
|
9
|
+
from mreg_cli.api.models import Host, IPAddress
|
|
10
|
+
from mreg_cli.commands.base import BaseCommand
|
|
11
|
+
from mreg_cli.commands.registry import CommandRegistry
|
|
12
|
+
from mreg_cli.exceptions import EntityOwnershipMismatch, InputFailure
|
|
13
|
+
from mreg_cli.outputmanager import OutputManager
|
|
14
|
+
from mreg_cli.types import Flag
|
|
15
|
+
|
|
16
|
+
command_registry = CommandRegistry()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DHCPCommands(BaseCommand):
|
|
20
|
+
"""DHCP commands for the CLI."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, cli: Any) -> None:
|
|
23
|
+
"""Initialize the DHCP commands."""
|
|
24
|
+
super().__init__(cli, command_registry, "dhcp", "Manage DHCP associations.", "Manage DHCP")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ipaddress_from_ip_arg(arg: str) -> IPAddress | None:
|
|
28
|
+
"""Get an IPAddress object from an IP address argument.
|
|
29
|
+
|
|
30
|
+
:param arg: IP address argument.
|
|
31
|
+
|
|
32
|
+
:returns: IPAddress object if IP is valid and exists, None if IP is invalid.
|
|
33
|
+
|
|
34
|
+
:raises InputFailure: If the IP address is valid but does not exist.
|
|
35
|
+
:raises EntityOwnershipMismatch: If the IP address is in use by multiple hosts.
|
|
36
|
+
"""
|
|
37
|
+
if not IPAddressField.is_valid(arg):
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
addr = IPAddressField.from_string(arg)
|
|
41
|
+
except InputFailure:
|
|
42
|
+
return None
|
|
43
|
+
ipobjs = IPAddress.get_by_ip(addr.address)
|
|
44
|
+
if not ipobjs:
|
|
45
|
+
raise InputFailure(f"IP address {arg} does not exist.")
|
|
46
|
+
elif len(ipobjs) > 1:
|
|
47
|
+
raise EntityOwnershipMismatch(f"IP {arg} is in use by {len(ipobjs)} hosts.")
|
|
48
|
+
return ipobjs[0]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@command_registry.register_command(
|
|
52
|
+
prog="assoc",
|
|
53
|
+
description=(
|
|
54
|
+
"Associate MAC address with a host. If the host has multiple A/AAAA "
|
|
55
|
+
"records an IP must be given instead of name."
|
|
56
|
+
),
|
|
57
|
+
short_desc="Add MAC address to host.",
|
|
58
|
+
flags=[
|
|
59
|
+
Flag("name", "Name or IP of target host.", metavar="NAME/IP"),
|
|
60
|
+
Flag("mac", "Mac address.", metavar="MACADDRESS"),
|
|
61
|
+
Flag("-force", action="store_true", description="Enable force."),
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
def assoc(args: argparse.Namespace) -> None:
|
|
65
|
+
"""Associate MAC address with host.
|
|
66
|
+
|
|
67
|
+
If the host has multiple A/AAAA records an IP must be given instead of a name.
|
|
68
|
+
|
|
69
|
+
:param args: argparse.Namespace (name, mac, force)
|
|
70
|
+
"""
|
|
71
|
+
name: str = args.name
|
|
72
|
+
mac: str = args.mac
|
|
73
|
+
force: bool = args.force
|
|
74
|
+
|
|
75
|
+
in_use = IPAddress.get_by_mac(mac)
|
|
76
|
+
if in_use:
|
|
77
|
+
raise InputFailure(f"MAC {mac} is already in use by {in_use.ip()}.")
|
|
78
|
+
|
|
79
|
+
ipaddress = ipaddress_from_ip_arg(name)
|
|
80
|
+
if not ipaddress:
|
|
81
|
+
host = Host.get_by_any_means_or_raise(name)
|
|
82
|
+
ipaddress = host.get_associatable_ip()
|
|
83
|
+
|
|
84
|
+
ipaddress.associate_mac(mac, force=force)
|
|
85
|
+
OutputManager().add_ok(
|
|
86
|
+
f"Associated mac address {mac} with ip {ipaddress.ip()}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@command_registry.register_command(
|
|
91
|
+
prog="disassoc",
|
|
92
|
+
description=(
|
|
93
|
+
"Disassociate MAC address with a host or ip. If the host has multiple "
|
|
94
|
+
"A/AAAA records an IP must be given instead of a name."
|
|
95
|
+
),
|
|
96
|
+
short_desc="Disassociate MAC address.",
|
|
97
|
+
flags=[
|
|
98
|
+
Flag("name", description="Name or IP of host.", metavar="NAME/IP"),
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
def disassoc(args: argparse.Namespace) -> None:
|
|
102
|
+
"""Disassociate MAC address with host/ip.
|
|
103
|
+
|
|
104
|
+
If the host has multiple A/AAAA records an IP must be given instead of a name.
|
|
105
|
+
|
|
106
|
+
:param args: argparse.Namespace (name)
|
|
107
|
+
"""
|
|
108
|
+
name: str = args.name
|
|
109
|
+
|
|
110
|
+
ipaddress = ipaddress_from_ip_arg(name)
|
|
111
|
+
if not ipaddress:
|
|
112
|
+
host = Host.get_by_any_means_or_raise(name)
|
|
113
|
+
try:
|
|
114
|
+
ipaddress = host.has_ip_with_mac(name)
|
|
115
|
+
except ValueError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
if not ipaddress:
|
|
119
|
+
ips_with_mac = host.ips_with_macaddresses()
|
|
120
|
+
|
|
121
|
+
if not ips_with_mac:
|
|
122
|
+
raise InputFailure(
|
|
123
|
+
f"Host {host} does not have any IP addresses with MAC addresses."
|
|
124
|
+
) from None
|
|
125
|
+
|
|
126
|
+
if len(ips_with_mac) > 1:
|
|
127
|
+
raise InputFailure(
|
|
128
|
+
f"Host {host} has multiple IP addresses with MAC addresses."
|
|
129
|
+
) from None
|
|
130
|
+
|
|
131
|
+
ipaddress = ips_with_mac[0]
|
|
132
|
+
|
|
133
|
+
ipaddress.disassociate_mac()
|
|
134
|
+
OutputManager().add_ok(
|
|
135
|
+
f"Disassociated mac address {ipaddress.macaddress} from ip {ipaddress.ip()}"
|
|
136
|
+
)
|