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/api/fields.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Fields for models of the API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BeforeValidator, field_validator
|
|
10
|
+
|
|
11
|
+
from mreg_cli.api.abstracts import FrozenModel
|
|
12
|
+
from mreg_cli.exceptions import InputFailure
|
|
13
|
+
from mreg_cli.types import IP_AddressT
|
|
14
|
+
|
|
15
|
+
_mac_regex = re.compile(r"^([0-9A-Fa-f]{2}[.:-]){5}([0-9A-Fa-f]{2})$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MACAddressField(FrozenModel):
|
|
19
|
+
"""Represents a MAC address."""
|
|
20
|
+
|
|
21
|
+
address: str
|
|
22
|
+
|
|
23
|
+
@field_validator("address", mode="after")
|
|
24
|
+
@classmethod
|
|
25
|
+
def validate_and_format_mac(cls, v: str) -> str:
|
|
26
|
+
"""Validate and normalize MAC address to 'aa:bb:cc:dd:ee:ff' format.
|
|
27
|
+
|
|
28
|
+
:param v: The input MAC address string.
|
|
29
|
+
:raises ValueError: If the input does not match the expected MAC address pattern.
|
|
30
|
+
:returns: The normalized MAC address.
|
|
31
|
+
"""
|
|
32
|
+
# Validate input format
|
|
33
|
+
if not _mac_regex.match(v):
|
|
34
|
+
raise ValueError("Invalid MAC address format")
|
|
35
|
+
|
|
36
|
+
# Normalize MAC address
|
|
37
|
+
v = re.sub(r"[.:-]", "", v).lower()
|
|
38
|
+
return ":".join(v[i : i + 2] for i in range(0, 12, 2))
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
"""Return the MAC address as a string."""
|
|
42
|
+
return self.address
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class IPAddressField(FrozenModel):
|
|
46
|
+
"""Represents an IP address, automatically determines if it's IPv4 or IPv6."""
|
|
47
|
+
|
|
48
|
+
address: IP_AddressT
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_string(cls, address: str) -> IPAddressField:
|
|
52
|
+
"""Create an IPAddressField from a string.
|
|
53
|
+
|
|
54
|
+
Shortcut for creating an IPAddressField from a string,
|
|
55
|
+
without having to convince the type checker that we can
|
|
56
|
+
pass in a string to the address field each time.
|
|
57
|
+
"""
|
|
58
|
+
return cls(address=address) # pyright: ignore[reportArgumentType] # validator handles this
|
|
59
|
+
|
|
60
|
+
@field_validator("address", mode="before")
|
|
61
|
+
@classmethod
|
|
62
|
+
def parse_ip_address(cls, value: Any) -> IP_AddressT:
|
|
63
|
+
"""Parse and validate the IP address."""
|
|
64
|
+
try:
|
|
65
|
+
return ipaddress.ip_address(value)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
raise InputFailure(f"Invalid IP address '{value}'.") from e
|
|
68
|
+
|
|
69
|
+
def is_ipv4(self) -> bool:
|
|
70
|
+
"""Check if the IP address is IPv4."""
|
|
71
|
+
return isinstance(self.address, ipaddress.IPv4Address)
|
|
72
|
+
|
|
73
|
+
def is_ipv6(self) -> bool:
|
|
74
|
+
"""Check if the IP address is IPv6."""
|
|
75
|
+
return isinstance(self.address, ipaddress.IPv6Address)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def is_valid(value: str) -> bool:
|
|
79
|
+
"""Check if the value is a valid IP address."""
|
|
80
|
+
try:
|
|
81
|
+
ipaddress.ip_address(value)
|
|
82
|
+
return True
|
|
83
|
+
except ValueError:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
"""Return the IP address as a string."""
|
|
88
|
+
return str(self.address)
|
|
89
|
+
|
|
90
|
+
def __hash__(self):
|
|
91
|
+
"""Return a hash of the IP address."""
|
|
92
|
+
return hash(self.address)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_name(value: dict[str, Any]) -> str:
|
|
96
|
+
"""Extract the name from the dictionary.
|
|
97
|
+
|
|
98
|
+
:param v: Dictionary containing the name.
|
|
99
|
+
:returns: Extracted name as a string.
|
|
100
|
+
"""
|
|
101
|
+
return value["name"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
NameList = list[Annotated[str, BeforeValidator(_extract_name)]]
|
mreg_cli/api/history.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""History abstractions for mreg-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Self
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
|
|
12
|
+
from mreg_cli.api.endpoints import Endpoint
|
|
13
|
+
from mreg_cli.exceptions import EntityNotFound, InternalError
|
|
14
|
+
from mreg_cli.outputmanager import OutputManager
|
|
15
|
+
from mreg_cli.types import QueryParams
|
|
16
|
+
from mreg_cli.utilities.api import get_typed
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HistoryResource(str, Enum):
|
|
20
|
+
"""History resources for the API.
|
|
21
|
+
|
|
22
|
+
Names represent resource names.
|
|
23
|
+
Values represent resource relations.
|
|
24
|
+
|
|
25
|
+
Access resource names and relation with the `resource()` and `relation()` methods.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
Host = "hosts"
|
|
29
|
+
Group = "groups"
|
|
30
|
+
HostPolicy_Role = "roles"
|
|
31
|
+
HostPolicy_Atom = "atoms"
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def _missing_(cls, value: Any) -> HistoryResource:
|
|
35
|
+
v = str(value).lower()
|
|
36
|
+
for resource in cls:
|
|
37
|
+
if resource.value == v:
|
|
38
|
+
return resource
|
|
39
|
+
elif resource.name.lower() == v:
|
|
40
|
+
return resource
|
|
41
|
+
raise ValueError(f"Unknown resource {value}")
|
|
42
|
+
|
|
43
|
+
def relation(self) -> str:
|
|
44
|
+
"""Get the resource relation."""
|
|
45
|
+
return self.value
|
|
46
|
+
|
|
47
|
+
def resource(self) -> str:
|
|
48
|
+
"""Get the resource name."""
|
|
49
|
+
return self.name.lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HistoryItem(BaseModel):
|
|
53
|
+
"""Represents a history item."""
|
|
54
|
+
|
|
55
|
+
id: int # noqa: A003
|
|
56
|
+
timestamp: datetime.datetime
|
|
57
|
+
user: str
|
|
58
|
+
resource: HistoryResource
|
|
59
|
+
name: str
|
|
60
|
+
mid: int = Field(alias="model_id") # model_ is an internal pydantic namespace.
|
|
61
|
+
model: str
|
|
62
|
+
action: str
|
|
63
|
+
data: dict[str, Any]
|
|
64
|
+
|
|
65
|
+
@field_validator("data", mode="before")
|
|
66
|
+
def parse_json_data(cls, v: Any) -> Any:
|
|
67
|
+
"""Ensure that non-dict values are treated as JSON."""
|
|
68
|
+
if isinstance(v, dict):
|
|
69
|
+
return v # pyright: ignore[reportUnknownVariableType]
|
|
70
|
+
try:
|
|
71
|
+
return json.loads(v)
|
|
72
|
+
except json.JSONDecodeError as e:
|
|
73
|
+
raise ValueError("Failed to parse history data as JSON") from e
|
|
74
|
+
|
|
75
|
+
def clean_timestamp(self) -> str:
|
|
76
|
+
"""Clean up the timestamp for output."""
|
|
77
|
+
return self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
78
|
+
|
|
79
|
+
def msg(self, basename: str) -> str:
|
|
80
|
+
"""Attempt to make a history item human readable."""
|
|
81
|
+
msg = ""
|
|
82
|
+
action = self.action
|
|
83
|
+
model = self.model
|
|
84
|
+
if action in ("add", "remove"):
|
|
85
|
+
if action == "add":
|
|
86
|
+
direction = "to"
|
|
87
|
+
elif action == "remove":
|
|
88
|
+
direction = "from"
|
|
89
|
+
else:
|
|
90
|
+
raise InternalError(f"Unhandled history entry: {action}")
|
|
91
|
+
rel = self.data["relation"][:-1]
|
|
92
|
+
cls = str(self.resource)
|
|
93
|
+
if "." in cls:
|
|
94
|
+
cls = cls[cls.rindex(".") + 1 :]
|
|
95
|
+
cls = cls.replace("HostPolicy_", "")
|
|
96
|
+
cls = cls.lower()
|
|
97
|
+
msg = f"{rel} {self.data['name']} {direction} {cls} {self.name}"
|
|
98
|
+
elif action == "create":
|
|
99
|
+
msg = ", ".join(f"{k} = '{v}'" for k, v in self.data.items())
|
|
100
|
+
elif action == "update":
|
|
101
|
+
if model in ("Ipaddress",):
|
|
102
|
+
msg = self.data["current_data"]["ipaddress"] + ", "
|
|
103
|
+
changes: list[str] = []
|
|
104
|
+
for key, newval in self.data["update"].items():
|
|
105
|
+
oldval = self.data["current_data"][key] or "not set"
|
|
106
|
+
newval = newval or "not set"
|
|
107
|
+
changes.append(f"{key}: {oldval} -> {newval}")
|
|
108
|
+
msg += ",".join(changes)
|
|
109
|
+
elif action == "destroy":
|
|
110
|
+
if model == "Host":
|
|
111
|
+
msg = "deleted " + self.name
|
|
112
|
+
else:
|
|
113
|
+
msg = ", ".join(f"{k} = '{v}'" for k, v in self.data.items())
|
|
114
|
+
else:
|
|
115
|
+
raise InternalError(f"Unhandled history entry: {action}")
|
|
116
|
+
|
|
117
|
+
return msg
|
|
118
|
+
|
|
119
|
+
def output(self, basename: str) -> None:
|
|
120
|
+
"""Output the history item."""
|
|
121
|
+
ts = self.clean_timestamp()
|
|
122
|
+
msg = self.msg(basename)
|
|
123
|
+
OutputManager().add_line(f"{ts} [{self.user}]: {self.model} {self.action}: {msg}")
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def output_multiple(cls, basename: str, items: list[HistoryItem]) -> None:
|
|
127
|
+
"""Output multiple history items."""
|
|
128
|
+
for item in sorted(items, key=lambda i: i.timestamp):
|
|
129
|
+
item.output(basename)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def get(cls, name: str, resource: HistoryResource) -> list[Self]:
|
|
133
|
+
"""Get history items for a resource."""
|
|
134
|
+
params: QueryParams = {"resource": resource.resource(), "name": name}
|
|
135
|
+
ret = get_typed(Endpoint.History, list[cls], params=params)
|
|
136
|
+
if len(ret) == 0:
|
|
137
|
+
raise EntityNotFound(f"No history found for {name}")
|
|
138
|
+
|
|
139
|
+
model_ids = ",".join({str(i.mid) for i in ret})
|
|
140
|
+
params = {
|
|
141
|
+
"resource": resource.resource(),
|
|
142
|
+
"model_id__in": model_ids,
|
|
143
|
+
}
|
|
144
|
+
ret = get_typed(Endpoint.History, list[cls], params=params)
|
|
145
|
+
|
|
146
|
+
params = {
|
|
147
|
+
"data__relation": resource.relation(),
|
|
148
|
+
"data__id__in": model_ids,
|
|
149
|
+
}
|
|
150
|
+
ret.extend(get_typed(Endpoint.History, list[cls], params=params))
|
|
151
|
+
|
|
152
|
+
return ret
|