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/models.py
ADDED
|
@@ -0,0 +1,3416 @@
|
|
|
1
|
+
"""Pydantic models for the mreg_cli package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from typing import Any, ClassVar, Self, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import (
|
|
11
|
+
AliasChoices,
|
|
12
|
+
BaseModel,
|
|
13
|
+
ConfigDict,
|
|
14
|
+
Field,
|
|
15
|
+
computed_field,
|
|
16
|
+
field_validator,
|
|
17
|
+
)
|
|
18
|
+
from typing_extensions import Unpack
|
|
19
|
+
|
|
20
|
+
from mreg_cli.api.abstracts import APIMixin, FrozenModel, FrozenModelWithTimestamps
|
|
21
|
+
from mreg_cli.api.endpoints import Endpoint
|
|
22
|
+
from mreg_cli.api.fields import IPAddressField, MACAddressField, NameList
|
|
23
|
+
from mreg_cli.api.history import HistoryItem, HistoryResource
|
|
24
|
+
from mreg_cli.config import MregCliConfig
|
|
25
|
+
from mreg_cli.exceptions import (
|
|
26
|
+
CreateError,
|
|
27
|
+
DeleteError,
|
|
28
|
+
EntityAlreadyExists,
|
|
29
|
+
EntityNotFound,
|
|
30
|
+
EntityOwnershipMismatch,
|
|
31
|
+
ForceMissing,
|
|
32
|
+
InputFailure,
|
|
33
|
+
InternalError,
|
|
34
|
+
InvalidIPAddress,
|
|
35
|
+
InvalidIPv4Address,
|
|
36
|
+
InvalidIPv6Address,
|
|
37
|
+
InvalidNetwork,
|
|
38
|
+
MultipleEntititesFound,
|
|
39
|
+
PatchError,
|
|
40
|
+
UnexpectedDataError,
|
|
41
|
+
ValidationError,
|
|
42
|
+
)
|
|
43
|
+
from mreg_cli.outputmanager import OutputManager
|
|
44
|
+
from mreg_cli.types import IP_AddressT, IP_NetworkT, IP_Version, QueryParams
|
|
45
|
+
from mreg_cli.utilities.api import (
|
|
46
|
+
delete,
|
|
47
|
+
get,
|
|
48
|
+
get_item_by_key_value,
|
|
49
|
+
get_list_in,
|
|
50
|
+
get_list_unique,
|
|
51
|
+
get_typed,
|
|
52
|
+
patch,
|
|
53
|
+
post,
|
|
54
|
+
)
|
|
55
|
+
from mreg_cli.utilities.shared import convert_wildcard_to_regex
|
|
56
|
+
from mreg_cli.utilities.validators import is_valid_category_tag, is_valid_location_tag
|
|
57
|
+
|
|
58
|
+
_mac_regex = re.compile(r"^([0-9A-Fa-f]{2}[.:-]){5}([0-9A-Fa-f]{2})$")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NetworkOrIP(BaseModel):
|
|
62
|
+
"""A model for either a network or an IP address."""
|
|
63
|
+
|
|
64
|
+
ip_or_network: str | IP_AddressT | IP_NetworkT
|
|
65
|
+
|
|
66
|
+
@field_validator("ip_or_network", mode="before")
|
|
67
|
+
@classmethod
|
|
68
|
+
def validate_ip_or_network(cls, value: Any) -> IP_AddressT | IP_NetworkT:
|
|
69
|
+
"""Validate and convert the input to an IP address or network."""
|
|
70
|
+
if not isinstance(value, str):
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
value = value.removesuffix("/")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
return ipaddress.ip_address(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return ipaddress.ip_network(value)
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
raise InputFailure(f"Invalid input for IP address or network: {value}")
|
|
86
|
+
|
|
87
|
+
def __str__(self) -> str:
|
|
88
|
+
"""Return the value as a string."""
|
|
89
|
+
return str(self.ip_or_network)
|
|
90
|
+
|
|
91
|
+
def is_ipv4(self) -> bool:
|
|
92
|
+
"""Return True if the value is an IPv4 address."""
|
|
93
|
+
return isinstance(self.ip_or_network, ipaddress.IPv4Address)
|
|
94
|
+
|
|
95
|
+
def as_ipv4(self) -> ipaddress.IPv4Address:
|
|
96
|
+
"""Return the value as an IPv4 address."""
|
|
97
|
+
if not self.is_ipv4():
|
|
98
|
+
raise InvalidIPv4Address("Value is not an IPv4 address.")
|
|
99
|
+
return cast(ipaddress.IPv4Address, self.ip_or_network)
|
|
100
|
+
|
|
101
|
+
def as_ipv6(self) -> ipaddress.IPv6Address:
|
|
102
|
+
"""Return the value as an IPv6 address."""
|
|
103
|
+
if not self.is_ipv6():
|
|
104
|
+
raise InvalidIPv6Address("Value is not an IPv6 address.")
|
|
105
|
+
return cast(ipaddress.IPv6Address, self.ip_or_network)
|
|
106
|
+
|
|
107
|
+
def as_ip(self) -> IP_AddressT:
|
|
108
|
+
"""Return the value as an IP address."""
|
|
109
|
+
if not self.is_ip():
|
|
110
|
+
raise InvalidIPAddress(f"{self.ip_or_network} is not an IP address.")
|
|
111
|
+
return cast(IP_AddressT, self.ip_or_network)
|
|
112
|
+
|
|
113
|
+
def as_network(self) -> IP_NetworkT:
|
|
114
|
+
"""Return the value as a network."""
|
|
115
|
+
if not self.is_network():
|
|
116
|
+
raise InvalidNetwork(f"{self.ip_or_network} is not a network.")
|
|
117
|
+
return cast(IP_NetworkT, self.ip_or_network)
|
|
118
|
+
|
|
119
|
+
def is_ipv6(self) -> bool:
|
|
120
|
+
"""Return True if the value is an IPv6 address."""
|
|
121
|
+
return isinstance(self.ip_or_network, ipaddress.IPv6Address)
|
|
122
|
+
|
|
123
|
+
def is_ipv4_network(self) -> bool:
|
|
124
|
+
"""Return True if the value is an IPv4 network."""
|
|
125
|
+
return isinstance(self.ip_or_network, ipaddress.IPv4Network)
|
|
126
|
+
|
|
127
|
+
def is_ipv6_network(self) -> bool:
|
|
128
|
+
"""Return True if the value is an IPv6 network."""
|
|
129
|
+
return isinstance(self.ip_or_network, ipaddress.IPv6Network)
|
|
130
|
+
|
|
131
|
+
def is_ip(self) -> bool:
|
|
132
|
+
"""Return True if the value is an IP address."""
|
|
133
|
+
return self.is_ipv4() or self.is_ipv6()
|
|
134
|
+
|
|
135
|
+
def is_network(self) -> bool:
|
|
136
|
+
"""Return True if the value is a network."""
|
|
137
|
+
return self.is_ipv4_network() or self.is_ipv6_network()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class HostT(BaseModel):
|
|
141
|
+
"""A type for a hostname."""
|
|
142
|
+
|
|
143
|
+
hostname: str
|
|
144
|
+
|
|
145
|
+
@field_validator("hostname")
|
|
146
|
+
@classmethod
|
|
147
|
+
def validate_hostname(cls, value: str) -> str:
|
|
148
|
+
"""Validate the hostname."""
|
|
149
|
+
value = value.lower()
|
|
150
|
+
|
|
151
|
+
if re.search(r"^(\*\.)?([a-z0-9_][a-z0-9\-]*\.?)+$", value) is None:
|
|
152
|
+
raise InputFailure(f"Invalid input for hostname: {value}")
|
|
153
|
+
|
|
154
|
+
# Assume user is happy with domain, but strip the dot.
|
|
155
|
+
if value.endswith("."):
|
|
156
|
+
return value[:-1]
|
|
157
|
+
|
|
158
|
+
# If a dot in name, assume long name.
|
|
159
|
+
if "." in value:
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
config = MregCliConfig()
|
|
163
|
+
default_domain = config.get("domain")
|
|
164
|
+
# Append domain name if in config and it does not end with it
|
|
165
|
+
if default_domain and not value.endswith(default_domain):
|
|
166
|
+
return f"{value}.{default_domain}"
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
def __str__(self) -> str:
|
|
170
|
+
"""Return the hostname as a string."""
|
|
171
|
+
return self.hostname
|
|
172
|
+
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
"""Return the hostname as a string."""
|
|
175
|
+
return self.hostname
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class WithHost(BaseModel):
|
|
179
|
+
"""Model for an object that has a host element."""
|
|
180
|
+
|
|
181
|
+
host: int
|
|
182
|
+
|
|
183
|
+
def resolve_host(self) -> Host | None:
|
|
184
|
+
"""Resolve the host ID to a Host object.
|
|
185
|
+
|
|
186
|
+
Notes
|
|
187
|
+
-----
|
|
188
|
+
- This method will call the API to resolve the host ID to a Host object.
|
|
189
|
+
- This assumes that there is a host attribute in the object.
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
data = get_item_by_key_value(Endpoint.Hosts, "id", str(self.host))
|
|
193
|
+
|
|
194
|
+
if not data:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
return Host.model_validate(data)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class WithZone(BaseModel, APIMixin):
|
|
201
|
+
"""Model for an object that has a zone element."""
|
|
202
|
+
|
|
203
|
+
zone: int
|
|
204
|
+
|
|
205
|
+
def resolve_zone(self) -> ForwardZone | None:
|
|
206
|
+
"""Resolve the zone ID to a (Forward)Zone object.
|
|
207
|
+
|
|
208
|
+
Notes
|
|
209
|
+
-----
|
|
210
|
+
- This method will call the API to resolve the zone ID to a Zone object.
|
|
211
|
+
- This assumes that there is a zone attribute in the object.
|
|
212
|
+
|
|
213
|
+
"""
|
|
214
|
+
data = get_item_by_key_value(Endpoint.ForwardZones, "id", str(self.zone))
|
|
215
|
+
|
|
216
|
+
if not data:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
return ForwardZone.model_validate(data)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class WithTTL(BaseModel, APIMixin):
|
|
223
|
+
"""Model for an object that needs to work with TTL values."""
|
|
224
|
+
|
|
225
|
+
_ttl_nullable: ClassVar[bool] = True
|
|
226
|
+
"""TTL field(s) of model are nullable."""
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def MAX_TTL(self) -> int:
|
|
230
|
+
"""Return the maximum TTL value."""
|
|
231
|
+
return 68400
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def MIN_TTL(self) -> int:
|
|
235
|
+
"""Return the minimum TTL value."""
|
|
236
|
+
return 300
|
|
237
|
+
|
|
238
|
+
def output_ttl(self, label: str = "TTL", field: str = "ttl", padding: int = 14) -> None:
|
|
239
|
+
"""Output a TTL value.
|
|
240
|
+
|
|
241
|
+
:param padding: Number of spaces for left-padding the output.
|
|
242
|
+
:param field: The field to output (defaults to 'ttl')
|
|
243
|
+
"""
|
|
244
|
+
if not hasattr(self, field):
|
|
245
|
+
raise InternalError(f"Outputting TTL field {field} failed, field not found in object.")
|
|
246
|
+
|
|
247
|
+
ttl_value = getattr(self, field)
|
|
248
|
+
label = f"{label.removesuffix(':')}:"
|
|
249
|
+
OutputManager().add_line("{1:<{0}}{2}".format(padding, label, ttl_value or "(Default)"))
|
|
250
|
+
|
|
251
|
+
def set_ttl(self, ttl: str | int | None, field: str | None = None) -> Self:
|
|
252
|
+
"""Set a new TTL for the object and returns the updated object.
|
|
253
|
+
|
|
254
|
+
Updates the `ttl` field of the object unless a different field name
|
|
255
|
+
is specified.
|
|
256
|
+
|
|
257
|
+
:param ttl: The TTL value to set. Can be an integer, "default", or None.
|
|
258
|
+
:param field: The field to set the TTL value in.
|
|
259
|
+
:raises InputFailure: If the TTL value is outside the bounds.
|
|
260
|
+
:returns: The updated object.
|
|
261
|
+
"""
|
|
262
|
+
# NOTE: could add some sort of validation that model has `field`
|
|
263
|
+
ttl_field = field or "ttl"
|
|
264
|
+
|
|
265
|
+
# str args can either be numeric or "default"
|
|
266
|
+
# Turn it into an int or None
|
|
267
|
+
if isinstance(ttl, str):
|
|
268
|
+
if self._ttl_nullable and ttl == "default":
|
|
269
|
+
ttl = None
|
|
270
|
+
else:
|
|
271
|
+
try:
|
|
272
|
+
ttl = int(ttl)
|
|
273
|
+
except ValueError as e:
|
|
274
|
+
raise InputFailure(f"Invalid TTL value: {ttl}") from e
|
|
275
|
+
|
|
276
|
+
if isinstance(ttl, int):
|
|
277
|
+
ttl = self.valid_numeric_ttl(ttl)
|
|
278
|
+
|
|
279
|
+
return self.patch({ttl_field: ttl})
|
|
280
|
+
|
|
281
|
+
def valid_numeric_ttl(self, ttl: int) -> int:
|
|
282
|
+
"""Return a valid TTL value.
|
|
283
|
+
|
|
284
|
+
Valid TTL values are: 300 - 68400.
|
|
285
|
+
|
|
286
|
+
:param ttl: The TTL target to set.
|
|
287
|
+
:raises InputFailure: If the TTL value is outside the bounds.
|
|
288
|
+
:returns: A valid TTL vale
|
|
289
|
+
"""
|
|
290
|
+
if ttl < self.MIN_TTL or ttl > self.MAX_TTL:
|
|
291
|
+
raise InputFailure(f"Invalid TTL value: {ttl} ({self.MIN_TTL}->{self.MAX_TTL})")
|
|
292
|
+
|
|
293
|
+
return ttl
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class WithName(BaseModel, APIMixin):
|
|
297
|
+
"""Mixin type for an object that has a name element."""
|
|
298
|
+
|
|
299
|
+
__name_field__: str = "name"
|
|
300
|
+
"""Name of the API field that holds the object's name."""
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def get_by_name(cls, name: str) -> Self | None:
|
|
304
|
+
"""Get a resource by name.
|
|
305
|
+
|
|
306
|
+
:param name: The resource name to search for.
|
|
307
|
+
:returns: The resource if found.
|
|
308
|
+
"""
|
|
309
|
+
return cls.get_by_field(cls.__name_field__, name)
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def get_by_name_and_raise(cls, name: str) -> None:
|
|
313
|
+
"""Get a resource by name, raising EntityAlreadyExists if found.
|
|
314
|
+
|
|
315
|
+
:param name: The resource name to search for.
|
|
316
|
+
:raises EntityAlreadyExists: If the resource is found.
|
|
317
|
+
"""
|
|
318
|
+
return cls.get_by_field_and_raise(cls.__name_field__, name)
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def get_by_name_or_raise(cls, name: str) -> Self:
|
|
322
|
+
"""Get a resource by name, raising EntityNotFound if not found.
|
|
323
|
+
|
|
324
|
+
:param name: The resource name to search for.
|
|
325
|
+
:returns: The resource.
|
|
326
|
+
:raises EntityNotFound: If the resource is not found.
|
|
327
|
+
"""
|
|
328
|
+
return cls.get_by_field_or_raise(cls.__name_field__, name)
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def get_list_by_name_regex(cls, name: str) -> list[Self]:
|
|
332
|
+
"""Get multiple resources by a name regex.
|
|
333
|
+
|
|
334
|
+
:param name: The regex pattern for names to search for.
|
|
335
|
+
:returns: A list of resource objects.
|
|
336
|
+
"""
|
|
337
|
+
param, value = convert_wildcard_to_regex(cls.__name_field__, name, True)
|
|
338
|
+
return get_typed(cls.endpoint(), list[cls], params={param: value})
|
|
339
|
+
|
|
340
|
+
def rename(self, new_name: str) -> Self:
|
|
341
|
+
"""Rename the resource.
|
|
342
|
+
|
|
343
|
+
:param new_name: The new name to set.
|
|
344
|
+
:returns: True if the rename was successful.
|
|
345
|
+
"""
|
|
346
|
+
return self.patch({self.__name_field__: new_name})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
ClassVarNotSet = object()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def AbstractClassVar() -> Any:
|
|
353
|
+
"""Hack to implement an abstract class variable on a Pydantic model."""
|
|
354
|
+
return ClassVarNotSet
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class WithHistory(BaseModel, APIMixin):
|
|
358
|
+
"""Resource that supports history lookups.
|
|
359
|
+
|
|
360
|
+
Subclasses must implement the `history_resource` class variable.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
history_resource: ClassVar[HistoryResource] = AbstractClassVar()
|
|
364
|
+
|
|
365
|
+
def __init_subclass__(cls, **kwargs: Unpack[ConfigDict]):
|
|
366
|
+
"""Ensure that subclasses implement the history_resource class var."""
|
|
367
|
+
# NOTE: Only works for Pydantic model subclasses!
|
|
368
|
+
for attr in cls.__class_vars__:
|
|
369
|
+
if getattr(cls, attr) == ClassVarNotSet:
|
|
370
|
+
raise NotImplementedError(
|
|
371
|
+
f"Subclass {cls.__name__} must implement abstract class var `{attr}`."
|
|
372
|
+
)
|
|
373
|
+
return super().__init_subclass__(**kwargs)
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def get_history(cls, name: str) -> list[HistoryItem]:
|
|
377
|
+
"""Get the history for the object."""
|
|
378
|
+
return HistoryItem.get(name, cls.history_resource)
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def output_history(cls, name: str) -> None:
|
|
382
|
+
"""Output the history for the object."""
|
|
383
|
+
history = cls.get_history(name)
|
|
384
|
+
HistoryItem.output_multiple(name, history)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class NameServer(FrozenModelWithTimestamps, WithTTL):
|
|
388
|
+
"""Model for representing a nameserver within a DNS zone."""
|
|
389
|
+
|
|
390
|
+
id: int # noqa: A003
|
|
391
|
+
name: str
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def endpoint(cls) -> Endpoint:
|
|
395
|
+
"""Return the endpoint for the class."""
|
|
396
|
+
return Endpoint.Nameservers
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class Permission(FrozenModelWithTimestamps, APIMixin):
|
|
400
|
+
"""Model for a permission object."""
|
|
401
|
+
|
|
402
|
+
id: int # noqa: A003
|
|
403
|
+
group: str
|
|
404
|
+
range: IP_NetworkT # noqa: A003
|
|
405
|
+
regex: str
|
|
406
|
+
labels: list[int]
|
|
407
|
+
|
|
408
|
+
@field_validator("range", mode="before")
|
|
409
|
+
@classmethod
|
|
410
|
+
def validate_ip_or_network(cls, value: Any) -> IP_NetworkT:
|
|
411
|
+
"""Validate and convert the input to a network."""
|
|
412
|
+
try:
|
|
413
|
+
return ipaddress.ip_network(value)
|
|
414
|
+
except ValueError as e:
|
|
415
|
+
raise InputFailure(f"Invalid input for network: {value}") from e
|
|
416
|
+
|
|
417
|
+
@classmethod
|
|
418
|
+
def endpoint(cls) -> Endpoint:
|
|
419
|
+
"""Return the endpoint for the class."""
|
|
420
|
+
return Endpoint.PermissionNetgroupRegex
|
|
421
|
+
|
|
422
|
+
@classmethod
|
|
423
|
+
def output_multiple(cls, permissions: list[Permission], indent: int = 4) -> None:
|
|
424
|
+
"""Print multiple permissions to the console."""
|
|
425
|
+
if not permissions:
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
OutputManager().add_formatted_table(
|
|
429
|
+
("IP range", "Group", "Reg.exp."),
|
|
430
|
+
("range", "group", "regex"),
|
|
431
|
+
permissions,
|
|
432
|
+
indent=indent,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def add_label(self, label_name: str) -> Self:
|
|
436
|
+
"""Add a label to the permission.
|
|
437
|
+
|
|
438
|
+
:param label_name: The name of the label to add.
|
|
439
|
+
:returns: The updated Permission object.
|
|
440
|
+
"""
|
|
441
|
+
label = Label.get_by_name_or_raise(label_name)
|
|
442
|
+
if label.id in self.labels:
|
|
443
|
+
raise EntityAlreadyExists(f"The permission already has the label {label_name!r}")
|
|
444
|
+
|
|
445
|
+
label_ids = self.labels.copy()
|
|
446
|
+
label_ids.append(label.id)
|
|
447
|
+
return self.patch({"labels": label_ids})
|
|
448
|
+
|
|
449
|
+
def remove_label(self, label_name: str) -> Self:
|
|
450
|
+
"""Remove a label from the permission.
|
|
451
|
+
|
|
452
|
+
:param label_name: The name of the label to remove.
|
|
453
|
+
:returns: The updated Permission object.
|
|
454
|
+
"""
|
|
455
|
+
label = Label.get_by_name_or_raise(label_name)
|
|
456
|
+
if label.id not in self.labels:
|
|
457
|
+
raise EntityNotFound(f"The permission does not have the label {label_name!r}")
|
|
458
|
+
|
|
459
|
+
label_ids = self.labels.copy()
|
|
460
|
+
label_ids.remove(label.id)
|
|
461
|
+
return self.patch({"labels": label_ids})
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def is_reverse_zone_name(name: str) -> bool:
|
|
465
|
+
"""Determine if a zone is a reverse zone by its name.
|
|
466
|
+
|
|
467
|
+
:param name: The name of the zone.
|
|
468
|
+
:returns: True if the zone is a reverse zone.
|
|
469
|
+
"""
|
|
470
|
+
return name.endswith(".arpa")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class Zone(FrozenModelWithTimestamps, WithTTL, APIMixin):
|
|
474
|
+
"""Model representing a DNS zone with various attributes and related nameservers."""
|
|
475
|
+
|
|
476
|
+
id: int # noqa: A003
|
|
477
|
+
nameservers: list[NameServer]
|
|
478
|
+
updated: bool
|
|
479
|
+
primary_ns: str
|
|
480
|
+
email: str
|
|
481
|
+
serialno: int
|
|
482
|
+
serialno_updated_at: datetime
|
|
483
|
+
refresh: int
|
|
484
|
+
retry: int
|
|
485
|
+
expire: int
|
|
486
|
+
soa_ttl: int
|
|
487
|
+
default_ttl: int
|
|
488
|
+
name: str
|
|
489
|
+
|
|
490
|
+
# Specify that TTL fields are NOT nullable for Zone objects
|
|
491
|
+
_ttl_nullable: ClassVar[bool] = False
|
|
492
|
+
|
|
493
|
+
def is_delegated(self) -> bool:
|
|
494
|
+
"""Return True if the zone is delegated."""
|
|
495
|
+
return False
|
|
496
|
+
|
|
497
|
+
def is_reverse(self) -> bool:
|
|
498
|
+
"""Return True if the zone is a reverse zone."""
|
|
499
|
+
return False
|
|
500
|
+
|
|
501
|
+
# Default to forward zone endpoints for the base class
|
|
502
|
+
# This can be overridden in the subclasses
|
|
503
|
+
@classmethod
|
|
504
|
+
def endpoint(cls) -> Endpoint:
|
|
505
|
+
"""Return the endpoint for the class."""
|
|
506
|
+
return Endpoint.ForwardZones
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def endpoint_nameservers(cls) -> Endpoint:
|
|
510
|
+
"""Return the endpoint for the class."""
|
|
511
|
+
return Endpoint.ForwardZonesNameservers
|
|
512
|
+
|
|
513
|
+
def output(self, padding: int = 20) -> None:
|
|
514
|
+
"""Output the zone to the console."""
|
|
515
|
+
manager = OutputManager()
|
|
516
|
+
|
|
517
|
+
def fmt(label: str, text: str) -> None:
|
|
518
|
+
manager.add_line("{1:<{0}}{2}".format(padding, label, text))
|
|
519
|
+
|
|
520
|
+
fmt("Name:", self.name)
|
|
521
|
+
self.output_nameservers(self.nameservers)
|
|
522
|
+
fmt("Primary NS:", self.primary_ns)
|
|
523
|
+
fmt("Email:", self.email)
|
|
524
|
+
fmt("Serial:", str(self.serialno))
|
|
525
|
+
fmt("Refresh:", str(self.refresh))
|
|
526
|
+
fmt("Retry:", str(self.retry))
|
|
527
|
+
fmt("Expire:", str(self.expire))
|
|
528
|
+
self.output_ttl("SOA TTL", "soa_ttl", padding)
|
|
529
|
+
self.output_ttl("Default TTL", "default_ttl", padding)
|
|
530
|
+
|
|
531
|
+
@classmethod
|
|
532
|
+
def output_zones(cls, forward: bool, reverse: bool) -> None:
|
|
533
|
+
"""Output all zones of the given type(s)."""
|
|
534
|
+
# Determine types of zones to list
|
|
535
|
+
zones_types: list[type[Zone]] = []
|
|
536
|
+
if forward:
|
|
537
|
+
zones_types.append(ForwardZone)
|
|
538
|
+
if reverse:
|
|
539
|
+
zones_types.append(ReverseZone)
|
|
540
|
+
|
|
541
|
+
# Fetch all zones of the given type(s)
|
|
542
|
+
zones: list[Zone] = []
|
|
543
|
+
for zone_type in zones_types:
|
|
544
|
+
zones.extend(zone_type.get_list())
|
|
545
|
+
|
|
546
|
+
manager = OutputManager()
|
|
547
|
+
if not zones:
|
|
548
|
+
manager.add_line("No zones found.")
|
|
549
|
+
return
|
|
550
|
+
manager.add_line("Zones:")
|
|
551
|
+
for zone in zones:
|
|
552
|
+
manager.add_line(f" {zone.name}")
|
|
553
|
+
|
|
554
|
+
@classmethod
|
|
555
|
+
def output_nameservers(cls, nameservers: list[NameServer], padding: int = 20) -> None:
|
|
556
|
+
"""Output the nameservers of the zone."""
|
|
557
|
+
manager = OutputManager()
|
|
558
|
+
|
|
559
|
+
def fmt_ns(label: str, hostname: str, ttl: str) -> None:
|
|
560
|
+
manager.add_line(
|
|
561
|
+
" {1:<{0}}{2:<{3}}{4}".format(padding, label, hostname, 20, ttl)
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
fmt_ns("Nameservers:", "hostname", "TTL")
|
|
565
|
+
for ns in nameservers:
|
|
566
|
+
# We don't have a TTL value for nameservers from the API
|
|
567
|
+
fmt_ns("", ns.name, "<not set>")
|
|
568
|
+
|
|
569
|
+
def output_delegations(self, padding: int = 20) -> None:
|
|
570
|
+
"""Output the delegations of the zone."""
|
|
571
|
+
delegations = self.get_delegations()
|
|
572
|
+
manager = OutputManager()
|
|
573
|
+
if not delegations:
|
|
574
|
+
manager.add_line(f"No delegations for {self.name}.")
|
|
575
|
+
return
|
|
576
|
+
manager.add_line("Delegations:")
|
|
577
|
+
for delegation in sorted(delegations, key=lambda d: d.name):
|
|
578
|
+
manager.add_line(f" {delegation.name}")
|
|
579
|
+
if delegation.comment:
|
|
580
|
+
manager.add_line(f" Comment: {delegation.comment}")
|
|
581
|
+
self.output_nameservers(delegation.nameservers, padding=padding)
|
|
582
|
+
|
|
583
|
+
@classmethod
|
|
584
|
+
def get_list(cls) -> list[Self]:
|
|
585
|
+
"""Get all zones of the given zone type.
|
|
586
|
+
|
|
587
|
+
:returns: A list of all zones.
|
|
588
|
+
"""
|
|
589
|
+
return get_typed(cls.endpoint(), list[cls])
|
|
590
|
+
|
|
591
|
+
def ensure_delegation_in_zone(self, name: str) -> None:
|
|
592
|
+
"""Ensure a delegation is in the zone.
|
|
593
|
+
|
|
594
|
+
:param name: The name of the delegation to check.
|
|
595
|
+
:returns: True if the delegation is in the zone.
|
|
596
|
+
"""
|
|
597
|
+
if not name.endswith(f".{self.name}"):
|
|
598
|
+
raise InputFailure(f"Delegation '{name}' is not in '{self.name}'")
|
|
599
|
+
|
|
600
|
+
@classmethod
|
|
601
|
+
def type_by_name(cls, name: str) -> type[ForwardZone | ReverseZone]:
|
|
602
|
+
"""Determine the zone type based on the name.
|
|
603
|
+
|
|
604
|
+
:param name: The name of the zone.
|
|
605
|
+
:returns: The zone type.
|
|
606
|
+
"""
|
|
607
|
+
if is_reverse_zone_name(name):
|
|
608
|
+
return ReverseZone
|
|
609
|
+
return ForwardZone
|
|
610
|
+
|
|
611
|
+
@classmethod
|
|
612
|
+
def verify_nameservers(cls, nameservers: list[str], force: bool = False) -> None:
|
|
613
|
+
"""Verify that nameservers are in mreg and have A-records."""
|
|
614
|
+
if not nameservers:
|
|
615
|
+
raise InputFailure("At least one nameserver is required")
|
|
616
|
+
|
|
617
|
+
errors: list[str] = []
|
|
618
|
+
for nameserver in nameservers:
|
|
619
|
+
try:
|
|
620
|
+
host = Host.get_by_any_means_or_raise(nameserver)
|
|
621
|
+
except EntityNotFound:
|
|
622
|
+
if not force:
|
|
623
|
+
errors.append(f"{nameserver} is not in mreg, must force")
|
|
624
|
+
else:
|
|
625
|
+
if host.zone is None:
|
|
626
|
+
if not host.ipaddresses and not force:
|
|
627
|
+
errors.append(f"{nameserver} has no A-record/glue, must force")
|
|
628
|
+
if errors:
|
|
629
|
+
raise ForceMissing("\n".join(errors))
|
|
630
|
+
|
|
631
|
+
@classmethod
|
|
632
|
+
def create_zone(
|
|
633
|
+
cls,
|
|
634
|
+
name: str,
|
|
635
|
+
email: str,
|
|
636
|
+
primary_ns: list[str],
|
|
637
|
+
force: bool,
|
|
638
|
+
) -> ForwardZone | ReverseZone | None:
|
|
639
|
+
"""Create a forward or reverse zone based on zone name.
|
|
640
|
+
|
|
641
|
+
:param name: The name of the zone to create.
|
|
642
|
+
:param email: The email address for the zone.
|
|
643
|
+
:param primary_ns: The primary nameserver for the zone.
|
|
644
|
+
:returns: The created zone object.
|
|
645
|
+
"""
|
|
646
|
+
cls.verify_nameservers(primary_ns, force=force)
|
|
647
|
+
zone_t = cls.type_by_name(name)
|
|
648
|
+
zone_t.get_zone_and_raise(name)
|
|
649
|
+
return zone_t.create({"name": name, "email": email, "primary_ns": primary_ns})
|
|
650
|
+
|
|
651
|
+
@classmethod
|
|
652
|
+
def get_zone(cls, name: str) -> ForwardZone | ReverseZone | None:
|
|
653
|
+
"""Get a zone by name.
|
|
654
|
+
|
|
655
|
+
:param name: The name of the zone to get.
|
|
656
|
+
:returns: The zone object.
|
|
657
|
+
"""
|
|
658
|
+
zone_t = cls.type_by_name(name)
|
|
659
|
+
return zone_t.get_by_name(name)
|
|
660
|
+
|
|
661
|
+
@classmethod
|
|
662
|
+
def get_zone_or_raise(cls, name: str) -> ForwardZone | ReverseZone:
|
|
663
|
+
"""Get a zone by name, and raise if not found.
|
|
664
|
+
|
|
665
|
+
:param name: The name of the zone to get.
|
|
666
|
+
:returns: The zone object.
|
|
667
|
+
"""
|
|
668
|
+
zone_t = cls.type_by_name(name)
|
|
669
|
+
return zone_t.get_by_name_or_raise(name)
|
|
670
|
+
|
|
671
|
+
@classmethod
|
|
672
|
+
def get_zone_and_raise(cls, name: str) -> None:
|
|
673
|
+
"""Get a zone by name, and raise if found.
|
|
674
|
+
|
|
675
|
+
:param name: The name of the zone to get.
|
|
676
|
+
"""
|
|
677
|
+
zone_t = cls.type_by_name(name)
|
|
678
|
+
return zone_t.get_by_name_and_raise(name)
|
|
679
|
+
|
|
680
|
+
def get_subzones(self) -> list[Self]:
|
|
681
|
+
"""Get subzones of the zone, excluding self.
|
|
682
|
+
|
|
683
|
+
:returns: A list of subzones.
|
|
684
|
+
"""
|
|
685
|
+
zones = self.get_list_by_field("name__endswith", f".{self.name}")
|
|
686
|
+
return [zone for zone in zones if zone.name != self.name]
|
|
687
|
+
|
|
688
|
+
def ensure_deletable(self) -> None:
|
|
689
|
+
"""Ensure the zone can be deleted. Raises exception if not.
|
|
690
|
+
|
|
691
|
+
:raises DeleteError: If zone has entries or subzones.
|
|
692
|
+
"""
|
|
693
|
+
# XXX: Not a fool proof check, as e.g. SRVs are not hosts. (yet.. ?)
|
|
694
|
+
hosts = Host.get_list_by_field("zone", self.id)
|
|
695
|
+
if hosts:
|
|
696
|
+
raise DeleteError(f"Zone has {len(hosts)} registered entries. Can not delete.")
|
|
697
|
+
|
|
698
|
+
zones = self.get_subzones()
|
|
699
|
+
if zones:
|
|
700
|
+
names = ", ".join(zone.name for zone in zones)
|
|
701
|
+
raise DeleteError(f"Zone has registered subzones: '{names}'. Can not delete")
|
|
702
|
+
|
|
703
|
+
def delete_zone(self, force: bool) -> bool:
|
|
704
|
+
"""Delete the zone.
|
|
705
|
+
|
|
706
|
+
:param force: Whether to force the deletion.
|
|
707
|
+
:returns: True if the deletion was successful.
|
|
708
|
+
"""
|
|
709
|
+
if not force:
|
|
710
|
+
self.ensure_deletable()
|
|
711
|
+
return self.delete()
|
|
712
|
+
|
|
713
|
+
def update_soa(
|
|
714
|
+
self,
|
|
715
|
+
primary_ns: str | None = None,
|
|
716
|
+
email: str | None = None,
|
|
717
|
+
serialno: int | None = None,
|
|
718
|
+
refresh: int | None = None,
|
|
719
|
+
retry: int | None = None,
|
|
720
|
+
expire: int | None = None,
|
|
721
|
+
soa_ttl: int | None = None,
|
|
722
|
+
) -> Self:
|
|
723
|
+
"""Update SOA (Start of Authority) record for the zone.
|
|
724
|
+
|
|
725
|
+
:param primary_ns: The primary nameserver for the zone.
|
|
726
|
+
:param email: The email address for the zone.
|
|
727
|
+
:param serialno: The serial number for the zone.
|
|
728
|
+
:param refresh: The refresh interval for the zone.
|
|
729
|
+
:param retry: The retry interval for the zone.
|
|
730
|
+
:param expire: The expire interval for the zone.
|
|
731
|
+
:param soa_ttl: The TTL for the zone.
|
|
732
|
+
"""
|
|
733
|
+
params: QueryParams = {
|
|
734
|
+
"primary_ns": primary_ns,
|
|
735
|
+
"email": email,
|
|
736
|
+
"serialno": serialno,
|
|
737
|
+
"refresh": refresh,
|
|
738
|
+
"retry": retry,
|
|
739
|
+
"expire": expire,
|
|
740
|
+
"soa_ttl": self.valid_numeric_ttl(soa_ttl) if soa_ttl is not None else None,
|
|
741
|
+
}
|
|
742
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
743
|
+
if not params:
|
|
744
|
+
raise InputFailure("No parameters to update")
|
|
745
|
+
return self.patch(params)
|
|
746
|
+
|
|
747
|
+
def create_delegation(
|
|
748
|
+
self,
|
|
749
|
+
delegation: str,
|
|
750
|
+
nameservers: list[str],
|
|
751
|
+
comment: str,
|
|
752
|
+
force: bool = False,
|
|
753
|
+
fetch_after_create: bool = True,
|
|
754
|
+
) -> Delegation | None:
|
|
755
|
+
"""Create a delegation for the zone.
|
|
756
|
+
|
|
757
|
+
:param delegation: The name of the delegation.
|
|
758
|
+
:param nameservers: The nameservers for the delegation.
|
|
759
|
+
:param comment: A comment for the delegation.
|
|
760
|
+
:param force: Force creation if ns/zone doesn't exist.
|
|
761
|
+
:returns: The created delegation object.
|
|
762
|
+
"""
|
|
763
|
+
self.ensure_delegation_in_zone(delegation)
|
|
764
|
+
self.verify_nameservers(nameservers, force=force)
|
|
765
|
+
|
|
766
|
+
if not force:
|
|
767
|
+
# Ensure delegated zone exists and is same type as parent zone
|
|
768
|
+
delegated_zone = Zone.get_zone(delegation)
|
|
769
|
+
if not delegated_zone:
|
|
770
|
+
raise InputFailure(f"Zone {delegation!r} does not exist. Must force.")
|
|
771
|
+
if delegated_zone.is_reverse() != self.is_reverse():
|
|
772
|
+
raise InputFailure(
|
|
773
|
+
f"Delegation '{delegation}' is not a {self.__class__.__name__} zone"
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
self.get_delegation_and_raise(delegation)
|
|
777
|
+
|
|
778
|
+
cls = Delegation.type_by_zone(self)
|
|
779
|
+
resp = post(
|
|
780
|
+
cls.endpoint().with_params(self.name),
|
|
781
|
+
name=delegation,
|
|
782
|
+
nameservers=nameservers,
|
|
783
|
+
comment=comment,
|
|
784
|
+
)
|
|
785
|
+
if not resp or not resp.ok:
|
|
786
|
+
raise CreateError(f"Failed to create delegation {delegation!r} in zone {self.name!r}")
|
|
787
|
+
|
|
788
|
+
if fetch_after_create:
|
|
789
|
+
return self.get_delegation_or_raise(delegation)
|
|
790
|
+
return None
|
|
791
|
+
|
|
792
|
+
def get_delegation(self, name: str) -> ForwardZoneDelegation | ReverseZoneDelegation | None:
|
|
793
|
+
"""Get a delegation for the zone by name.
|
|
794
|
+
|
|
795
|
+
:param name: The name of the delegation to get.
|
|
796
|
+
:returns: The delegation object if found.
|
|
797
|
+
"""
|
|
798
|
+
self.ensure_delegation_in_zone(name)
|
|
799
|
+
cls = Delegation.type_by_zone(self)
|
|
800
|
+
resp = get(cls.endpoint_with_id(self, name), ok404=True)
|
|
801
|
+
if not resp:
|
|
802
|
+
return None
|
|
803
|
+
return cls.model_validate_json(resp.text)
|
|
804
|
+
|
|
805
|
+
def get_delegation_or_raise(self, name: str) -> ForwardZoneDelegation | ReverseZoneDelegation:
|
|
806
|
+
"""Get a delegation for the zone by name, raising EntityNotFound if not found.
|
|
807
|
+
|
|
808
|
+
:param zone: The zone to search in.
|
|
809
|
+
:param name: The name of the delegation to get.
|
|
810
|
+
:returns: The delegation object.
|
|
811
|
+
:raises EntityNotFound: If the delegation is not found.
|
|
812
|
+
"""
|
|
813
|
+
delegation = self.get_delegation(name)
|
|
814
|
+
if not delegation:
|
|
815
|
+
raise EntityNotFound(f"Could not find delegation {name!r} in zone {name!r}")
|
|
816
|
+
return delegation
|
|
817
|
+
|
|
818
|
+
def get_delegation_and_raise(self, name: str) -> None:
|
|
819
|
+
"""Get a delegation for the zone by name, raising EntityAlreadyExists if found.
|
|
820
|
+
|
|
821
|
+
:param zone: The zone to search in.
|
|
822
|
+
:param name: The name of the delegation to get.
|
|
823
|
+
:raises EntityAlreadyExists: If the delegation is found.
|
|
824
|
+
"""
|
|
825
|
+
delegation = self.get_delegation(name)
|
|
826
|
+
if delegation:
|
|
827
|
+
raise EntityAlreadyExists(
|
|
828
|
+
f"Zone {self.name!r} already has a delegation named {name!r}"
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
def get_delegations(self) -> list[ForwardZoneDelegation | ReverseZoneDelegation]:
|
|
832
|
+
"""Get all delegations for a zone.
|
|
833
|
+
|
|
834
|
+
:param zone: The zone to search in.
|
|
835
|
+
:param name: The name of the delegation to get.
|
|
836
|
+
:returns: The delegation object.
|
|
837
|
+
"""
|
|
838
|
+
cls = Delegation.type_by_zone(self)
|
|
839
|
+
return get_typed(cls.endpoint().with_params(self.name), list[cls])
|
|
840
|
+
|
|
841
|
+
def delete_delegation(self, name: str) -> bool:
|
|
842
|
+
"""Delete a delegation from the zone.
|
|
843
|
+
|
|
844
|
+
:param delegation: The name of the delegation.
|
|
845
|
+
:returns: True if the deletion was successful.
|
|
846
|
+
"""
|
|
847
|
+
# Check if delegation exists
|
|
848
|
+
self.ensure_delegation_in_zone(name) # check name
|
|
849
|
+
delegation = self.get_delegation_or_raise(name)
|
|
850
|
+
resp = delete(delegation.endpoint_with_id(self, name))
|
|
851
|
+
return resp.ok if resp else False
|
|
852
|
+
|
|
853
|
+
def set_delegation_comment(self, name: str, comment: str) -> None:
|
|
854
|
+
"""Set the comment for a delegation.
|
|
855
|
+
|
|
856
|
+
:param name: The name of the delegation.
|
|
857
|
+
:param comment: The comment to set.
|
|
858
|
+
"""
|
|
859
|
+
delegation = self.get_delegation_or_raise(name)
|
|
860
|
+
resp = patch(delegation.endpoint_with_id(self, delegation.name), comment=comment)
|
|
861
|
+
if not resp or not resp.ok:
|
|
862
|
+
raise PatchError(f"Failed to update comment for delegation {delegation.name!r}")
|
|
863
|
+
|
|
864
|
+
def set_default_ttl(self, ttl: int) -> Self:
|
|
865
|
+
"""Set the default TTL for the zone.
|
|
866
|
+
|
|
867
|
+
:param ttl: The TTL to set.
|
|
868
|
+
"""
|
|
869
|
+
return self.set_ttl(ttl, "default_ttl")
|
|
870
|
+
|
|
871
|
+
def update_nameservers(self, nameservers: list[str], force: bool = False) -> None:
|
|
872
|
+
"""Update the nameservers of the zone.
|
|
873
|
+
|
|
874
|
+
:param nameservers: The new nameservers for the zone.
|
|
875
|
+
:param force: Whether to force the update.
|
|
876
|
+
:returns: True if the update was successful.
|
|
877
|
+
"""
|
|
878
|
+
self.verify_nameservers(nameservers, force=force)
|
|
879
|
+
path = self.endpoint_nameservers().with_params(self.name)
|
|
880
|
+
resp = patch(path, primary_ns=nameservers)
|
|
881
|
+
if not resp or not resp.ok:
|
|
882
|
+
raise PatchError(
|
|
883
|
+
f"Failed to update nameservers for {self.__class__.__name__} {self.name!r}"
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
class ForwardZone(Zone, WithName, APIMixin):
|
|
888
|
+
"""A forward zone."""
|
|
889
|
+
|
|
890
|
+
@classmethod
|
|
891
|
+
def endpoint(cls) -> Endpoint:
|
|
892
|
+
"""Return the endpoint for the class."""
|
|
893
|
+
return Endpoint.ForwardZones
|
|
894
|
+
|
|
895
|
+
@classmethod
|
|
896
|
+
def endpoint_nameservers(cls) -> Endpoint:
|
|
897
|
+
"""Return the endpoint for the class."""
|
|
898
|
+
return Endpoint.ForwardZonesNameservers
|
|
899
|
+
|
|
900
|
+
@classmethod
|
|
901
|
+
def get_from_hostname(cls, hostname: HostT) -> ForwardZoneDelegation | ForwardZone | None:
|
|
902
|
+
"""Get the zone from a hostname.
|
|
903
|
+
|
|
904
|
+
Note: This method may return either a ForwardZoneDelegation or a ForwardZone object.
|
|
905
|
+
|
|
906
|
+
:param hostname: The hostname to search for.
|
|
907
|
+
:returns: The zone if found, None otherwise.
|
|
908
|
+
"""
|
|
909
|
+
data = get(Endpoint.ForwardZoneForHost.with_id(hostname.hostname), ok404=True)
|
|
910
|
+
if not data:
|
|
911
|
+
return None
|
|
912
|
+
|
|
913
|
+
zoneblob = data.json()
|
|
914
|
+
|
|
915
|
+
if "delegate" in zoneblob:
|
|
916
|
+
return ForwardZoneDelegation.model_validate(zoneblob)
|
|
917
|
+
|
|
918
|
+
if "zone" in zoneblob:
|
|
919
|
+
return ForwardZone.model_validate(zoneblob["zone"])
|
|
920
|
+
|
|
921
|
+
if "delegation" in zoneblob:
|
|
922
|
+
return ForwardZoneDelegation.model_validate(zoneblob["delegation"])
|
|
923
|
+
|
|
924
|
+
raise UnexpectedDataError(f"Unexpected response from server: {zoneblob}")
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
class ReverseZone(Zone, WithName, APIMixin):
|
|
928
|
+
"""A reverse zone."""
|
|
929
|
+
|
|
930
|
+
@classmethod
|
|
931
|
+
def endpoint(cls) -> Endpoint:
|
|
932
|
+
"""Return the endpoint for the class."""
|
|
933
|
+
return Endpoint.ReverseZones
|
|
934
|
+
|
|
935
|
+
@classmethod
|
|
936
|
+
def endpoint_nameservers(cls) -> Endpoint:
|
|
937
|
+
"""Return the endpoint for the class."""
|
|
938
|
+
return Endpoint.ReverseZonesNameservers
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
class Delegation(FrozenModelWithTimestamps, WithZone):
|
|
942
|
+
"""A delegated zone."""
|
|
943
|
+
|
|
944
|
+
id: int # noqa: A003
|
|
945
|
+
nameservers: list[NameServer]
|
|
946
|
+
name: str
|
|
947
|
+
comment: str | None = None
|
|
948
|
+
|
|
949
|
+
# NOTE: Delegations are created through zone objects!
|
|
950
|
+
# Call Zone.create_delegation() on an existing zone to create one.
|
|
951
|
+
# We do not implement APIMixin here, since we cannot determine
|
|
952
|
+
# the path and type of a delegation to create without information
|
|
953
|
+
# about the zone in which to create it.
|
|
954
|
+
|
|
955
|
+
@classmethod
|
|
956
|
+
def endpoint(cls) -> Endpoint:
|
|
957
|
+
"""Return the endpoint for the class."""
|
|
958
|
+
return Endpoint.ForwardZonesDelegations
|
|
959
|
+
|
|
960
|
+
@classmethod
|
|
961
|
+
def endpoint_with_id(cls, zone: Zone, name: str) -> str:
|
|
962
|
+
"""Return the path to a delegation in a specific zone."""
|
|
963
|
+
if cls.is_reverse():
|
|
964
|
+
endpoint = Endpoint.ReverseZonesDelegationsZone
|
|
965
|
+
else:
|
|
966
|
+
endpoint = Endpoint.ForwardZonesDelegationsZone
|
|
967
|
+
return endpoint.with_params(zone.name, name)
|
|
968
|
+
|
|
969
|
+
def is_delegated(self) -> bool:
|
|
970
|
+
"""Return True if the zone is delegated."""
|
|
971
|
+
return True
|
|
972
|
+
|
|
973
|
+
@classmethod
|
|
974
|
+
def is_reverse(cls) -> bool:
|
|
975
|
+
"""Return True if the zone is a reverse zone."""
|
|
976
|
+
return False
|
|
977
|
+
|
|
978
|
+
@classmethod
|
|
979
|
+
def type_by_zone(cls, zone: Zone) -> type[ForwardZoneDelegation | ReverseZoneDelegation]:
|
|
980
|
+
"""Get the delegation type for a zone."""
|
|
981
|
+
if zone.is_reverse():
|
|
982
|
+
return ReverseZoneDelegation
|
|
983
|
+
return ForwardZoneDelegation
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
class ForwardZoneDelegation(Delegation, APIMixin):
|
|
987
|
+
"""A forward zone delegation."""
|
|
988
|
+
|
|
989
|
+
@classmethod
|
|
990
|
+
def endpoint(cls) -> Endpoint:
|
|
991
|
+
"""Return the endpoint for the class."""
|
|
992
|
+
return Endpoint.ForwardZonesDelegations
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
class ReverseZoneDelegation(Delegation, APIMixin):
|
|
996
|
+
"""A reverse zone delegation."""
|
|
997
|
+
|
|
998
|
+
@classmethod
|
|
999
|
+
def endpoint(cls) -> Endpoint:
|
|
1000
|
+
"""Return the endpoint for the class."""
|
|
1001
|
+
return Endpoint.ReverseZonesDelegations
|
|
1002
|
+
|
|
1003
|
+
@classmethod
|
|
1004
|
+
def is_reverse(cls) -> bool:
|
|
1005
|
+
"""Return True if the zone is a reverse zone."""
|
|
1006
|
+
return True
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
class HostPolicy(FrozenModel, WithName):
|
|
1010
|
+
"""Base model for Host Policy objects.
|
|
1011
|
+
|
|
1012
|
+
Note:
|
|
1013
|
+
----
|
|
1014
|
+
Host policy models in MREG have a different `created_at` field than
|
|
1015
|
+
other models. It is called `create_date` and is a date - not a datetime.
|
|
1016
|
+
|
|
1017
|
+
This model has a custom validator to validate and convert the `create_date`
|
|
1018
|
+
field to a datetime object with the expected `created_at` name.
|
|
1019
|
+
|
|
1020
|
+
"""
|
|
1021
|
+
|
|
1022
|
+
created_at_tz_naive: datetime = Field(
|
|
1023
|
+
...,
|
|
1024
|
+
validation_alias=AliasChoices("create_date", "created_at", "created_at_tz_naive"),
|
|
1025
|
+
exclude=True,
|
|
1026
|
+
repr=False,
|
|
1027
|
+
)
|
|
1028
|
+
"""Constructed datetime field from `create_date` in the API.
|
|
1029
|
+
|
|
1030
|
+
WARNING
|
|
1031
|
+
----
|
|
1032
|
+
DO NOT USE THIS FIELD FOR TIMEZONE-AWARE COMPARISONS!
|
|
1033
|
+
Always use `created_at` instead when comparing with timezone-aware
|
|
1034
|
+
fields such as `update_time`."""
|
|
1035
|
+
updated_at: datetime
|
|
1036
|
+
name: str
|
|
1037
|
+
description: str
|
|
1038
|
+
|
|
1039
|
+
@field_validator("created_at_tz_naive", mode="before")
|
|
1040
|
+
@classmethod
|
|
1041
|
+
def validate_created_at(cls, value: Any) -> datetime:
|
|
1042
|
+
"""Convert a datetime string to a datetime object.
|
|
1043
|
+
|
|
1044
|
+
:param value: The input value - should be a datetime string.
|
|
1045
|
+
:returns: The input value converted to a datetime object.
|
|
1046
|
+
"""
|
|
1047
|
+
# Fast path for str (most likely input type)
|
|
1048
|
+
if isinstance(value, str):
|
|
1049
|
+
return datetime.fromisoformat(value)
|
|
1050
|
+
# Re-validating model (field is already a datetime object)
|
|
1051
|
+
elif isinstance(value, datetime):
|
|
1052
|
+
return value
|
|
1053
|
+
# Date object is also valid input (parity with API)
|
|
1054
|
+
elif isinstance(value, date):
|
|
1055
|
+
return datetime.combine(value, datetime.min.time())
|
|
1056
|
+
return value # let pydantic throw the ValidationError
|
|
1057
|
+
|
|
1058
|
+
@computed_field # noqa: A003
|
|
1059
|
+
def created_at(self) -> datetime:
|
|
1060
|
+
"""Creation time."""
|
|
1061
|
+
return self.created_at_tz_naive.replace(tzinfo=self.updated_at.tzinfo)
|
|
1062
|
+
|
|
1063
|
+
# Fetching Host Policy objects is a special case where we cannot
|
|
1064
|
+
# re-use the methods defined in WithName, because we don't have an endpoint
|
|
1065
|
+
# defined on the class that can fetch both Roles and Atoms.
|
|
1066
|
+
# Thus, we need to define our own implementations of these methods.
|
|
1067
|
+
@classmethod
|
|
1068
|
+
def get_role_or_atom(cls, name: str) -> Atom | Role | None:
|
|
1069
|
+
"""Get an Atom or Role by name.
|
|
1070
|
+
|
|
1071
|
+
:param name: The name to search for.
|
|
1072
|
+
:returns: The Atom or Role if found, else None.
|
|
1073
|
+
"""
|
|
1074
|
+
for func in [Atom.get_by_name, Role.get_by_name]:
|
|
1075
|
+
role_or_atom = func(name)
|
|
1076
|
+
if role_or_atom:
|
|
1077
|
+
return role_or_atom
|
|
1078
|
+
return None
|
|
1079
|
+
|
|
1080
|
+
@classmethod
|
|
1081
|
+
def get_role_or_atom_or_raise(cls, name: str) -> Atom | Role:
|
|
1082
|
+
"""Get an Atom or Role by name and raise if not found.
|
|
1083
|
+
|
|
1084
|
+
:param name: The name to search for.
|
|
1085
|
+
:returns: The Atom or Role if found.
|
|
1086
|
+
:raises EntityNotFound: If the Atom or Role is not found.
|
|
1087
|
+
"""
|
|
1088
|
+
role_or_atom = cls.get_role_or_atom(name)
|
|
1089
|
+
if role_or_atom:
|
|
1090
|
+
return role_or_atom
|
|
1091
|
+
raise EntityNotFound(f"Could not find an atom or a role with name {name}")
|
|
1092
|
+
|
|
1093
|
+
@classmethod
|
|
1094
|
+
def get_role_or_atom_and_raise(cls, name: str) -> None:
|
|
1095
|
+
"""Get an Atom or Role by name and raise if found.
|
|
1096
|
+
|
|
1097
|
+
:param name: The name to search for.
|
|
1098
|
+
:returns: The Atom or Role if found.
|
|
1099
|
+
:raises EntityAlreadyExists: If the Atom or Role is found.
|
|
1100
|
+
"""
|
|
1101
|
+
role_or_atom = cls.get_role_or_atom(name)
|
|
1102
|
+
if role_or_atom:
|
|
1103
|
+
raise EntityAlreadyExists(f"An atom or a role with name {name} already exists.")
|
|
1104
|
+
|
|
1105
|
+
def set_description(self, description: str) -> Self:
|
|
1106
|
+
"""Set a new description."""
|
|
1107
|
+
return self.patch({"description": description})
|
|
1108
|
+
|
|
1109
|
+
def output_timestamps(self, padding: int = 14) -> None:
|
|
1110
|
+
"""Output the created and updated timestamps to the console."""
|
|
1111
|
+
output_manager = OutputManager()
|
|
1112
|
+
output_manager.add_line(f"{'Created:':<{padding}}{self.created_at:%c}")
|
|
1113
|
+
output_manager.add_line(f"{'Updated:':<{padding}}{self.updated_at:%c}")
|
|
1114
|
+
|
|
1115
|
+
def output(self, padding: int = 14) -> None:
|
|
1116
|
+
"""Output the host policy object to the console.
|
|
1117
|
+
|
|
1118
|
+
Subclasses should provide their own output method and call this method
|
|
1119
|
+
first to output the commmon fields.
|
|
1120
|
+
"""
|
|
1121
|
+
output_manager = OutputManager()
|
|
1122
|
+
output_manager.add_line(f"{'Name:':<{padding}}{self.name}")
|
|
1123
|
+
self.output_timestamps(padding=padding)
|
|
1124
|
+
output_manager.add_line(f"{'Description:':<{padding}}{self.description}")
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
class Role(HostPolicy, WithHistory):
|
|
1128
|
+
"""Model for a role."""
|
|
1129
|
+
|
|
1130
|
+
id: int # noqa: A003
|
|
1131
|
+
hosts: NameList
|
|
1132
|
+
atoms: NameList
|
|
1133
|
+
labels: list[int]
|
|
1134
|
+
|
|
1135
|
+
history_resource: ClassVar[HistoryResource] = HistoryResource.HostPolicy_Role
|
|
1136
|
+
|
|
1137
|
+
def __hash__(self) -> int:
|
|
1138
|
+
"""Hash the role by ID and name."""
|
|
1139
|
+
return hash(str(self.id) + self.name)
|
|
1140
|
+
|
|
1141
|
+
@classmethod
|
|
1142
|
+
def endpoint(cls) -> Endpoint:
|
|
1143
|
+
"""Return the endpoint for the class."""
|
|
1144
|
+
return Endpoint.HostPolicyRoles
|
|
1145
|
+
|
|
1146
|
+
def output(self, padding: int = 14) -> None:
|
|
1147
|
+
"""Output the role to the console.
|
|
1148
|
+
|
|
1149
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1150
|
+
"""
|
|
1151
|
+
super().output(padding=padding)
|
|
1152
|
+
output_manager = OutputManager()
|
|
1153
|
+
output_manager.add_line("Atom members:")
|
|
1154
|
+
for atom in self.atoms:
|
|
1155
|
+
output_manager.add_formatted_line("", atom, padding)
|
|
1156
|
+
labels = self.get_labels()
|
|
1157
|
+
output_manager.add_line("Labels:")
|
|
1158
|
+
for label in labels:
|
|
1159
|
+
output_manager.add_formatted_line("", label.name, padding)
|
|
1160
|
+
|
|
1161
|
+
def output_hosts(self, _padding: int = 14) -> None:
|
|
1162
|
+
"""Output the hosts that use the role.
|
|
1163
|
+
|
|
1164
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1165
|
+
"""
|
|
1166
|
+
manager = OutputManager()
|
|
1167
|
+
if self.hosts:
|
|
1168
|
+
manager.add_line("Name:")
|
|
1169
|
+
for host in self.hosts:
|
|
1170
|
+
manager.add_line(f" {host}")
|
|
1171
|
+
else:
|
|
1172
|
+
manager.add_line("No host uses this role")
|
|
1173
|
+
|
|
1174
|
+
def output_atoms(self, _padding: int = 14) -> None:
|
|
1175
|
+
"""Output the atoms that are members of the role.
|
|
1176
|
+
|
|
1177
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1178
|
+
"""
|
|
1179
|
+
manager = OutputManager()
|
|
1180
|
+
if self.atoms:
|
|
1181
|
+
manager.add_line("Name:")
|
|
1182
|
+
for atom in self.atoms:
|
|
1183
|
+
manager.add_line(f" {atom}")
|
|
1184
|
+
else:
|
|
1185
|
+
manager.add_line("No atom members")
|
|
1186
|
+
|
|
1187
|
+
@classmethod
|
|
1188
|
+
def output_multiple(cls, roles: list[Role], padding: int = 14) -> None:
|
|
1189
|
+
"""Output multiple roles to the console.
|
|
1190
|
+
|
|
1191
|
+
:param roles: List of roles to output.
|
|
1192
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1193
|
+
"""
|
|
1194
|
+
if not roles:
|
|
1195
|
+
return
|
|
1196
|
+
|
|
1197
|
+
OutputManager().add_line(
|
|
1198
|
+
"{1:<{0}}{2}".format(padding, "Roles:", ", ".join([role.name for role in roles]))
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
@classmethod
|
|
1202
|
+
def output_multiple_table(cls, roles: list[Role], _padding: int = 14) -> None:
|
|
1203
|
+
"""Output multiple roles to the console in a table.
|
|
1204
|
+
|
|
1205
|
+
:param roles: List of roles to output.
|
|
1206
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1207
|
+
"""
|
|
1208
|
+
if not roles:
|
|
1209
|
+
return
|
|
1210
|
+
|
|
1211
|
+
class RoleTableRow(BaseModel):
|
|
1212
|
+
name: str
|
|
1213
|
+
description: str
|
|
1214
|
+
labels: str
|
|
1215
|
+
|
|
1216
|
+
rows: list[RoleTableRow] = []
|
|
1217
|
+
for role in roles:
|
|
1218
|
+
labels = role.get_labels()
|
|
1219
|
+
row = RoleTableRow(
|
|
1220
|
+
name=role.name,
|
|
1221
|
+
description=role.description,
|
|
1222
|
+
labels=", ".join([label.name for label in labels]),
|
|
1223
|
+
)
|
|
1224
|
+
rows.append(row)
|
|
1225
|
+
|
|
1226
|
+
keys = list(RoleTableRow.model_fields.keys())
|
|
1227
|
+
headers = [h.capitalize() for h in keys]
|
|
1228
|
+
OutputManager().add_formatted_table(
|
|
1229
|
+
headers=headers,
|
|
1230
|
+
keys=keys,
|
|
1231
|
+
data=rows,
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
@classmethod
|
|
1235
|
+
def get_roles_with_atom(cls, name: str) -> list[Self]:
|
|
1236
|
+
"""Get all roles with a specific atom.
|
|
1237
|
+
|
|
1238
|
+
:param atom: Name of the atom to search for.
|
|
1239
|
+
:returns: A list of Role objects.
|
|
1240
|
+
"""
|
|
1241
|
+
return get_typed(cls.endpoint(), list[cls], params={"atoms__name__exact": name})
|
|
1242
|
+
|
|
1243
|
+
def add_atom(self, atom_name: str) -> bool:
|
|
1244
|
+
"""Add an atom to the role.
|
|
1245
|
+
|
|
1246
|
+
:param atom_name: The name of the atom to add.
|
|
1247
|
+
"""
|
|
1248
|
+
# Ensure the atom exists
|
|
1249
|
+
Atom.get_by_name_or_raise(atom_name)
|
|
1250
|
+
for atom in self.atoms:
|
|
1251
|
+
if atom_name == atom:
|
|
1252
|
+
raise EntityAlreadyExists(f"Atom {atom!r} already a member of role {self.name!r}")
|
|
1253
|
+
|
|
1254
|
+
resp = post(Endpoint.HostPolicyRolesAddAtom.with_params(self.name), name=atom_name)
|
|
1255
|
+
return resp.ok if resp else False
|
|
1256
|
+
|
|
1257
|
+
def remove_atom(self, atom_name: str) -> bool:
|
|
1258
|
+
"""Remove an atom from the role.
|
|
1259
|
+
|
|
1260
|
+
:param atom_name: The name of the atom to remove.
|
|
1261
|
+
"""
|
|
1262
|
+
for atom in self.atoms:
|
|
1263
|
+
if atom_name == atom:
|
|
1264
|
+
break
|
|
1265
|
+
else:
|
|
1266
|
+
raise EntityOwnershipMismatch(f"Atom {atom_name!r} not a member of {self.name!r}")
|
|
1267
|
+
|
|
1268
|
+
resp = delete(Endpoint.HostPolicyRolesRemoveAtom.with_params(self.name, atom))
|
|
1269
|
+
return resp.ok if resp else False
|
|
1270
|
+
|
|
1271
|
+
def get_labels(self) -> list[Label]:
|
|
1272
|
+
"""Get the labels associated with the role.
|
|
1273
|
+
|
|
1274
|
+
:returns: A list of Label objects.
|
|
1275
|
+
"""
|
|
1276
|
+
return [Label.get_by_id_or_raise(id_) for id_ in self.labels]
|
|
1277
|
+
|
|
1278
|
+
def add_label(self, label_name: str) -> Self:
|
|
1279
|
+
"""Add a label to the role.
|
|
1280
|
+
|
|
1281
|
+
:param label_name: The name of the label to add.
|
|
1282
|
+
|
|
1283
|
+
:returns: The updated Role object.
|
|
1284
|
+
"""
|
|
1285
|
+
label = Label.get_by_name_or_raise(label_name)
|
|
1286
|
+
if label.id in self.labels:
|
|
1287
|
+
raise EntityAlreadyExists(
|
|
1288
|
+
f"The role {self.name!r} already has the label {label_name!r}"
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
label_ids = self.labels.copy()
|
|
1292
|
+
label_ids.append(label.id)
|
|
1293
|
+
return self.patch({"labels": label_ids})
|
|
1294
|
+
|
|
1295
|
+
def remove_label(self, label_name: str) -> Self:
|
|
1296
|
+
"""Add a label to the role.
|
|
1297
|
+
|
|
1298
|
+
:param label_name: The name of the label to add.
|
|
1299
|
+
|
|
1300
|
+
:returns: The updated Role object.
|
|
1301
|
+
"""
|
|
1302
|
+
label = Label.get_by_name_or_raise(label_name)
|
|
1303
|
+
if label.id not in self.labels:
|
|
1304
|
+
raise EntityOwnershipMismatch(
|
|
1305
|
+
f"The role {self.name!r} doesn't have the label {label_name!r}"
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
label_ids = self.labels.copy()
|
|
1309
|
+
label_ids.remove(label.id)
|
|
1310
|
+
return self.patch({"labels": label_ids})
|
|
1311
|
+
|
|
1312
|
+
def add_host(self, name: str) -> bool:
|
|
1313
|
+
"""Add a host to the role by name.
|
|
1314
|
+
|
|
1315
|
+
:param name: The name of the host to add.
|
|
1316
|
+
"""
|
|
1317
|
+
resp = post(Endpoint.HostPolicyRolesAddHost.with_params(self.name), name=name)
|
|
1318
|
+
return resp.ok if resp else False
|
|
1319
|
+
|
|
1320
|
+
def remove_host(self, name: str) -> bool:
|
|
1321
|
+
"""Remove a host from the role by name.
|
|
1322
|
+
|
|
1323
|
+
:param name: The name of the host to remove.
|
|
1324
|
+
"""
|
|
1325
|
+
resp = delete(Endpoint.HostPolicyRolesRemoveHost.with_params(self.name, name))
|
|
1326
|
+
return resp.ok if resp else False
|
|
1327
|
+
|
|
1328
|
+
def delete(self) -> bool:
|
|
1329
|
+
"""Delete the role."""
|
|
1330
|
+
if self.hosts:
|
|
1331
|
+
hosts = ", ".join(self.hosts)
|
|
1332
|
+
raise DeleteError(f"Role {self.name!r} used on hosts: {hosts}")
|
|
1333
|
+
return super().delete()
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
class Atom(HostPolicy, WithHistory):
|
|
1337
|
+
"""Model for an atom."""
|
|
1338
|
+
|
|
1339
|
+
id: int # noqa: A003
|
|
1340
|
+
roles: NameList
|
|
1341
|
+
|
|
1342
|
+
history_resource: ClassVar[HistoryResource] = HistoryResource.HostPolicy_Atom
|
|
1343
|
+
|
|
1344
|
+
@classmethod
|
|
1345
|
+
def endpoint(cls) -> Endpoint:
|
|
1346
|
+
"""Return the endpoint for the class."""
|
|
1347
|
+
return Endpoint.HostPolicyAtoms
|
|
1348
|
+
|
|
1349
|
+
def output(self, padding: int = 14) -> None:
|
|
1350
|
+
"""Output the role to the console.
|
|
1351
|
+
|
|
1352
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1353
|
+
"""
|
|
1354
|
+
super().output(padding=padding)
|
|
1355
|
+
output_manager = OutputManager()
|
|
1356
|
+
output_manager.add_line("Roles where this atom is a member:")
|
|
1357
|
+
for role in self.roles:
|
|
1358
|
+
output_manager.add_formatted_line("", role, padding)
|
|
1359
|
+
|
|
1360
|
+
@classmethod
|
|
1361
|
+
def output_multiple(cls, atoms: list[Atom], padding: int = 14) -> None:
|
|
1362
|
+
"""Output multiple atoms to the console as a single formatted string.
|
|
1363
|
+
|
|
1364
|
+
:param atoms: List of atoms to output.
|
|
1365
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1366
|
+
"""
|
|
1367
|
+
if not atoms:
|
|
1368
|
+
return
|
|
1369
|
+
|
|
1370
|
+
OutputManager().add_line(
|
|
1371
|
+
"{1:<{0}}{2}".format(padding, "Atoms:", ", ".join([atom.name for atom in atoms]))
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
@classmethod
|
|
1375
|
+
def output_multiple_lines(cls, atoms: list[Atom], padding: int = 20) -> None:
|
|
1376
|
+
"""Output multiple atoms to the console, one atom per line.
|
|
1377
|
+
|
|
1378
|
+
:param atoms: List of atoms to output.
|
|
1379
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1380
|
+
"""
|
|
1381
|
+
manager = OutputManager()
|
|
1382
|
+
for atom in atoms:
|
|
1383
|
+
manager.add_formatted_line(atom.name, f"{atom.description!r}", padding)
|
|
1384
|
+
|
|
1385
|
+
def delete(self) -> bool:
|
|
1386
|
+
"""Delete the atom."""
|
|
1387
|
+
roles = Role.get_roles_with_atom(self.name)
|
|
1388
|
+
if self.roles:
|
|
1389
|
+
roles = ", ".join(self.roles)
|
|
1390
|
+
raise DeleteError(f"Atom {self.name!r} used in roles: {roles}")
|
|
1391
|
+
return super().delete()
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
class Label(FrozenModelWithTimestamps, WithName):
|
|
1395
|
+
"""Model for a label."""
|
|
1396
|
+
|
|
1397
|
+
id: int # noqa: A003
|
|
1398
|
+
name: str
|
|
1399
|
+
description: str
|
|
1400
|
+
|
|
1401
|
+
@classmethod
|
|
1402
|
+
def endpoint(cls) -> Endpoint:
|
|
1403
|
+
"""Return the endpoint for the class."""
|
|
1404
|
+
return Endpoint.Labels
|
|
1405
|
+
|
|
1406
|
+
@classmethod
|
|
1407
|
+
def get_all(cls) -> list[Self]:
|
|
1408
|
+
"""Get all labels.
|
|
1409
|
+
|
|
1410
|
+
:returns: A list of Label objects.
|
|
1411
|
+
"""
|
|
1412
|
+
return get_typed(cls.endpoint(), list[cls], params={"ordering": "name"})
|
|
1413
|
+
|
|
1414
|
+
@classmethod
|
|
1415
|
+
def get_by_id_or_raise(cls, _id: int) -> Self:
|
|
1416
|
+
"""Get a Label by ID.
|
|
1417
|
+
|
|
1418
|
+
:param _id: The Label ID to search for.
|
|
1419
|
+
:returns: The Label if found.
|
|
1420
|
+
:raises EntityNotFound: If the Label is not found.
|
|
1421
|
+
"""
|
|
1422
|
+
label = cls.get_by_id(_id)
|
|
1423
|
+
if not label:
|
|
1424
|
+
raise EntityNotFound(f"Label with ID {_id} not found.")
|
|
1425
|
+
return label
|
|
1426
|
+
|
|
1427
|
+
def set_description(self, description: str) -> Self:
|
|
1428
|
+
"""Set a new description."""
|
|
1429
|
+
return self.patch({"description": description})
|
|
1430
|
+
|
|
1431
|
+
def output(self, padding: int = 14) -> None:
|
|
1432
|
+
"""Output the label to the console.
|
|
1433
|
+
|
|
1434
|
+
:param padding: Number of spaces for left-padding the output.
|
|
1435
|
+
"""
|
|
1436
|
+
short_padding = 4
|
|
1437
|
+
output_manager = OutputManager()
|
|
1438
|
+
output_manager.add_line(f"{'Name:':<{padding}}{self.name}")
|
|
1439
|
+
output_manager.add_line(f"{'Description:':<{padding}}{self.description}")
|
|
1440
|
+
output_manager.add_line("Roles with this label:")
|
|
1441
|
+
|
|
1442
|
+
roles = Role.get_list_by_field("labels", self.id)
|
|
1443
|
+
if roles:
|
|
1444
|
+
for role in roles:
|
|
1445
|
+
output_manager.add_line(f"{'':<{short_padding}}{role.name}")
|
|
1446
|
+
else:
|
|
1447
|
+
output_manager.add_line(f"{'None':<{short_padding}}")
|
|
1448
|
+
|
|
1449
|
+
permission_list = Permission.get_list_by_field("labels", self.id)
|
|
1450
|
+
|
|
1451
|
+
output_manager.add_line("Permissions with this label:")
|
|
1452
|
+
if permission_list:
|
|
1453
|
+
Permission.output_multiple(permission_list, indent=4)
|
|
1454
|
+
else:
|
|
1455
|
+
output_manager.add_line(f"{'None':<{short_padding}}")
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
class ExcludedRange(FrozenModelWithTimestamps):
|
|
1459
|
+
"""Model for an excluded IP range for a network."""
|
|
1460
|
+
|
|
1461
|
+
id: int # noqa: A003
|
|
1462
|
+
network: int
|
|
1463
|
+
start_ip: IPAddressField
|
|
1464
|
+
end_ip: IPAddressField
|
|
1465
|
+
|
|
1466
|
+
@field_validator("start_ip", "end_ip", mode="before")
|
|
1467
|
+
@classmethod
|
|
1468
|
+
def convert_ip_address(cls, value: Any):
|
|
1469
|
+
"""Convert ipaddress string to IPAddressField if necessary."""
|
|
1470
|
+
if isinstance(value, str):
|
|
1471
|
+
try:
|
|
1472
|
+
return IPAddressField(address=ipaddress.ip_address(value))
|
|
1473
|
+
except ValueError as e:
|
|
1474
|
+
raise InputFailure(f"Invalid IP address: {value}") from e
|
|
1475
|
+
return value
|
|
1476
|
+
|
|
1477
|
+
def excluded_ips(self) -> int:
|
|
1478
|
+
"""Return the number of IP addresses in the excluded range."""
|
|
1479
|
+
return int(self.end_ip.address) - int(self.start_ip.address) + 1
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
class Network(FrozenModelWithTimestamps, APIMixin):
|
|
1483
|
+
"""Model for a network."""
|
|
1484
|
+
|
|
1485
|
+
id: int # noqa: A003
|
|
1486
|
+
excluded_ranges: list[ExcludedRange]
|
|
1487
|
+
network: str # for now
|
|
1488
|
+
description: str
|
|
1489
|
+
vlan: int | None = None
|
|
1490
|
+
dns_delegated: bool
|
|
1491
|
+
category: str
|
|
1492
|
+
location: str
|
|
1493
|
+
frozen: bool
|
|
1494
|
+
reserved: int
|
|
1495
|
+
|
|
1496
|
+
def __hash__(self):
|
|
1497
|
+
"""Return a hash of the network."""
|
|
1498
|
+
return hash((self.id, self.network))
|
|
1499
|
+
|
|
1500
|
+
@classmethod
|
|
1501
|
+
def endpoint(cls) -> Endpoint:
|
|
1502
|
+
"""Return the endpoint for the class."""
|
|
1503
|
+
return Endpoint.Networks
|
|
1504
|
+
|
|
1505
|
+
@classmethod
|
|
1506
|
+
def get_by_any_means(cls, identifier: str) -> Self | None:
|
|
1507
|
+
"""Get a network by the given identifier.
|
|
1508
|
+
|
|
1509
|
+
- If the identifier is numeric, it is treated as an ID.
|
|
1510
|
+
- If the identifier is a valid IP address, it is treated as an IP.
|
|
1511
|
+
- If the identifier is a valid network, it is treated as a network.
|
|
1512
|
+
|
|
1513
|
+
:param identifier: The identifier to search for.
|
|
1514
|
+
:returns: The network if found.
|
|
1515
|
+
:raises EntityNotFound: If the network is not found.
|
|
1516
|
+
"""
|
|
1517
|
+
# Check if identifier is IP or network
|
|
1518
|
+
try:
|
|
1519
|
+
net_or_ip = NetworkOrIP(ip_or_network=identifier)
|
|
1520
|
+
except InputFailure:
|
|
1521
|
+
pass
|
|
1522
|
+
else:
|
|
1523
|
+
# We (should) have a valid ip or network
|
|
1524
|
+
if net_or_ip.is_network():
|
|
1525
|
+
return cls.get_by_network(str(net_or_ip))
|
|
1526
|
+
elif net_or_ip.is_ip():
|
|
1527
|
+
return cls.get_by_ip(net_or_ip.as_ip())
|
|
1528
|
+
# Check if identifier is an ID
|
|
1529
|
+
if identifier.isdigit():
|
|
1530
|
+
try:
|
|
1531
|
+
return cls.get_by_id(int(identifier))
|
|
1532
|
+
except ValueError:
|
|
1533
|
+
pass
|
|
1534
|
+
return None
|
|
1535
|
+
|
|
1536
|
+
@classmethod
|
|
1537
|
+
def get_by_any_means_or_raise(cls, identifier: str) -> Self:
|
|
1538
|
+
"""Get a network by the given identifier, and raise if not found.
|
|
1539
|
+
|
|
1540
|
+
See `get_by_any_means` for details.
|
|
1541
|
+
"""
|
|
1542
|
+
net = cls.get_by_any_means(identifier)
|
|
1543
|
+
if not net:
|
|
1544
|
+
raise EntityNotFound(f"Network {identifier!r} not found.")
|
|
1545
|
+
return net
|
|
1546
|
+
|
|
1547
|
+
@classmethod
|
|
1548
|
+
def get_by_ip(cls, ip: IP_AddressT) -> Self | None:
|
|
1549
|
+
"""Get a network by IP address.
|
|
1550
|
+
|
|
1551
|
+
:param ip: The IP address to search for.
|
|
1552
|
+
:returns: The network if found, None otherwise.
|
|
1553
|
+
:raises EntityNotFound: If the network is not found.
|
|
1554
|
+
"""
|
|
1555
|
+
resp = get(Endpoint.NetworksByIP.with_id(str(ip)))
|
|
1556
|
+
if not resp:
|
|
1557
|
+
return None
|
|
1558
|
+
return cls.model_validate_json(resp.text)
|
|
1559
|
+
|
|
1560
|
+
@classmethod
|
|
1561
|
+
def get_by_ip_or_raise(cls, ip: IP_AddressT) -> Network:
|
|
1562
|
+
"""Get a network by IP address, and raise if not found.
|
|
1563
|
+
|
|
1564
|
+
:param ip: The IP address to search for.
|
|
1565
|
+
:returns: The network if found, None otherwise.
|
|
1566
|
+
:raises EntityNotFound: If the network is not found.
|
|
1567
|
+
"""
|
|
1568
|
+
network = cls.get_by_ip(ip)
|
|
1569
|
+
if not network:
|
|
1570
|
+
raise EntityNotFound(f"Network with IP address {ip} not found.")
|
|
1571
|
+
return network
|
|
1572
|
+
|
|
1573
|
+
@classmethod
|
|
1574
|
+
def get_by_network(cls, network: str) -> Self | None:
|
|
1575
|
+
"""Get a network by network address.
|
|
1576
|
+
|
|
1577
|
+
:param network: The network string to search for.
|
|
1578
|
+
:returns: The network if found.
|
|
1579
|
+
"""
|
|
1580
|
+
return cls.get_by_field("network", network)
|
|
1581
|
+
|
|
1582
|
+
@classmethod
|
|
1583
|
+
def get_by_network_or_raise(cls, network: str) -> Self:
|
|
1584
|
+
"""Get a network by its network address, and raise if not found.
|
|
1585
|
+
|
|
1586
|
+
:param network: The network string to search for.
|
|
1587
|
+
:returns: The network if found.
|
|
1588
|
+
:raises EntityNotFound: If the network is not found.
|
|
1589
|
+
"""
|
|
1590
|
+
net = cls.get_by_network(network)
|
|
1591
|
+
if not net:
|
|
1592
|
+
raise EntityNotFound(f"Network {network} not found.")
|
|
1593
|
+
return net
|
|
1594
|
+
|
|
1595
|
+
@classmethod
|
|
1596
|
+
def get_list(cls) -> list[Self]:
|
|
1597
|
+
"""Get all networks.
|
|
1598
|
+
|
|
1599
|
+
:returns: A list of all networks.
|
|
1600
|
+
"""
|
|
1601
|
+
return get_typed(cls.endpoint(), list[cls], limit=None)
|
|
1602
|
+
|
|
1603
|
+
@staticmethod
|
|
1604
|
+
def str_to_network(network: str) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
|
|
1605
|
+
"""Convert a network string to an ipaddress network object.
|
|
1606
|
+
|
|
1607
|
+
:param network: The network string to convert.
|
|
1608
|
+
:returns: The network object.
|
|
1609
|
+
"""
|
|
1610
|
+
try:
|
|
1611
|
+
return ipaddress.ip_network(network)
|
|
1612
|
+
except ValueError as e:
|
|
1613
|
+
raise InputFailure(f"Invalid network: {network}") from e
|
|
1614
|
+
|
|
1615
|
+
def output(self, padding: int = 25) -> None:
|
|
1616
|
+
"""Output the network to the console."""
|
|
1617
|
+
manager = OutputManager()
|
|
1618
|
+
|
|
1619
|
+
def fmt(label: str, value: Any) -> None:
|
|
1620
|
+
manager.add_line(f"{label:<{padding}}{value}")
|
|
1621
|
+
|
|
1622
|
+
ipnet = self.str_to_network(self.network)
|
|
1623
|
+
reserved_ips = self.get_reserved_ips()
|
|
1624
|
+
# Remove network address and broadcast address from reserved IPs
|
|
1625
|
+
reserved_ips_filtered = [
|
|
1626
|
+
ip for ip in reserved_ips if ip not in (ipnet.network_address, ipnet.broadcast_address)
|
|
1627
|
+
]
|
|
1628
|
+
|
|
1629
|
+
fmt("Network:", self.network)
|
|
1630
|
+
fmt("Netmask:", ipnet.netmask)
|
|
1631
|
+
fmt("Description:", self.description)
|
|
1632
|
+
fmt("Category:", self.category)
|
|
1633
|
+
fmt("Location:", self.location)
|
|
1634
|
+
fmt("VLAN:", self.vlan)
|
|
1635
|
+
fmt("DNS delegated:", str(self.dns_delegated))
|
|
1636
|
+
fmt("Frozen:", self.frozen)
|
|
1637
|
+
fmt("IP-range:", f"{ipnet.network_address} - {ipnet.broadcast_address}")
|
|
1638
|
+
fmt("Reserved host addresses:", self.reserved)
|
|
1639
|
+
fmt("", f"{ipnet.network_address} (net)")
|
|
1640
|
+
for ip in reserved_ips_filtered:
|
|
1641
|
+
fmt("", ip)
|
|
1642
|
+
if ipnet.broadcast_address in reserved_ips:
|
|
1643
|
+
fmt("", f"{ipnet.broadcast_address} (broadcast)")
|
|
1644
|
+
if self.excluded_ranges:
|
|
1645
|
+
excluded_ips = 0
|
|
1646
|
+
for ex_range in self.excluded_ranges:
|
|
1647
|
+
excluded_ips += ex_range.excluded_ips()
|
|
1648
|
+
fmt("Excluded ranges:", f"{excluded_ips} ipaddresses")
|
|
1649
|
+
self.output_excluded_ranges(padding=padding)
|
|
1650
|
+
fmt("Used addresses:", self.get_used_count())
|
|
1651
|
+
fmt("Unused addresses:", f"{self.get_unused_count()} (excluding reserved adr.)")
|
|
1652
|
+
|
|
1653
|
+
@classmethod
|
|
1654
|
+
def output_multiple(cls, networks: list[Network], padding: int = 25) -> None:
|
|
1655
|
+
"""Print multiple networks to the console."""
|
|
1656
|
+
for i, network in enumerate(networks, start=1):
|
|
1657
|
+
network.output(padding=padding)
|
|
1658
|
+
if i != len(networks): # add newline between networks (except last one)
|
|
1659
|
+
OutputManager().add_line("")
|
|
1660
|
+
|
|
1661
|
+
def output_unused_addresses(self, padding: int = 25) -> None:
|
|
1662
|
+
"""Output the unused addresses of the network."""
|
|
1663
|
+
unused = self.get_unused_list()
|
|
1664
|
+
|
|
1665
|
+
manager = OutputManager()
|
|
1666
|
+
if not unused:
|
|
1667
|
+
manager.add_line(f"No free addresses remaining on network {self.network}")
|
|
1668
|
+
return
|
|
1669
|
+
|
|
1670
|
+
for ip in unused:
|
|
1671
|
+
manager.add_line("{1:<{0}}".format(padding, str(ip)))
|
|
1672
|
+
|
|
1673
|
+
def output_used_addresses(self, padding: int = 46) -> None:
|
|
1674
|
+
"""Output the used addresses and their corresponding hosts."""
|
|
1675
|
+
# Reason for 46 padding:
|
|
1676
|
+
# https://stackoverflow.com/questions/166132/maximum-length-of-the-textual-representation-of-an-ipv6-address/166157#comment2055398_166157
|
|
1677
|
+
used = self.get_used_host_list()
|
|
1678
|
+
ptr_overrides = self.get_ptroverride_host_list()
|
|
1679
|
+
ips = set(list(used.keys()) + list(ptr_overrides.keys()))
|
|
1680
|
+
ips = sorted(ips, key=ipaddress.ip_address)
|
|
1681
|
+
|
|
1682
|
+
manager = OutputManager()
|
|
1683
|
+
if not ips:
|
|
1684
|
+
manager.add_line(f"No used addresses on network {self.network}")
|
|
1685
|
+
return
|
|
1686
|
+
|
|
1687
|
+
for ip in ips:
|
|
1688
|
+
if ip in ptr_overrides:
|
|
1689
|
+
manager.add_line(f"{ip:<{padding}}{ptr_overrides[ip]} (PTR override)")
|
|
1690
|
+
elif ip in used:
|
|
1691
|
+
hosts = used[ip]
|
|
1692
|
+
msg = f"{ip:<{padding}}{', '.join(hosts)}"
|
|
1693
|
+
if len(hosts) > 1:
|
|
1694
|
+
msg += " (NO ptr override!!)"
|
|
1695
|
+
manager.add_line(msg)
|
|
1696
|
+
|
|
1697
|
+
def output_excluded_ranges(self, padding: int = 32) -> None:
|
|
1698
|
+
"""Output the excluded ranges of the network."""
|
|
1699
|
+
manager = OutputManager()
|
|
1700
|
+
if not self.excluded_ranges:
|
|
1701
|
+
manager.add_line(f"No excluded ranges for network {self.network}")
|
|
1702
|
+
return
|
|
1703
|
+
|
|
1704
|
+
# manager.add_line(f"{'Start IP':<{padding}}End IP")
|
|
1705
|
+
for exrange in self.excluded_ranges:
|
|
1706
|
+
manager.add_line(f" {str(exrange.start_ip):<{padding}} -> {exrange.end_ip}")
|
|
1707
|
+
|
|
1708
|
+
def overlaps(self, other: Network | str | IP_NetworkT) -> bool:
|
|
1709
|
+
"""Check if the network overlaps with another network."""
|
|
1710
|
+
# Network -> str -> ipaddress.IPv{4,6}Network
|
|
1711
|
+
if isinstance(other, Network):
|
|
1712
|
+
other = other.network
|
|
1713
|
+
if isinstance(other, str):
|
|
1714
|
+
other = self.str_to_network(other)
|
|
1715
|
+
|
|
1716
|
+
self_net = self.str_to_network(self.network)
|
|
1717
|
+
return self_net.overlaps(other)
|
|
1718
|
+
|
|
1719
|
+
def get_first_available_ip(self) -> IP_AddressT:
|
|
1720
|
+
"""Return the first available IPv4 address of the network."""
|
|
1721
|
+
return ipaddress.ip_address(
|
|
1722
|
+
get_typed(Endpoint.NetworksFirstUnused.with_params(self.network), str)
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
def get_reserved_ips(self) -> list[IP_AddressT]:
|
|
1726
|
+
"""Return the reserved IP addresses of the network."""
|
|
1727
|
+
return get_typed(
|
|
1728
|
+
Endpoint.NetworksReservedList.with_params(self.network), list[IP_AddressT]
|
|
1729
|
+
)
|
|
1730
|
+
|
|
1731
|
+
def get_used_count(self) -> int:
|
|
1732
|
+
"""Return the number of used IP addresses in the network."""
|
|
1733
|
+
return get_typed(Endpoint.NetworksUsedCount.with_params(self.network), int)
|
|
1734
|
+
|
|
1735
|
+
def get_used_list(self) -> list[IP_AddressT]:
|
|
1736
|
+
"""Return the list of used IP addresses in the network."""
|
|
1737
|
+
return get_typed(Endpoint.NetworksUsedList.with_params(self.network), list[IP_AddressT])
|
|
1738
|
+
|
|
1739
|
+
def get_unused_count(self) -> int:
|
|
1740
|
+
"""Return the number of unused IP addresses in the network."""
|
|
1741
|
+
return get_typed(Endpoint.NetworksUnusedCount.with_params(self.network), int)
|
|
1742
|
+
|
|
1743
|
+
def get_unused_list(self) -> list[IP_AddressT]:
|
|
1744
|
+
"""Return the list of unused IP addresses in the network."""
|
|
1745
|
+
return get_typed(Endpoint.NetworksUnusedList.with_params(self.network), list[IP_AddressT])
|
|
1746
|
+
|
|
1747
|
+
def get_used_host_list(self) -> dict[str, list[str]]:
|
|
1748
|
+
"""Return a dict of used IP addresses and their associated hosts."""
|
|
1749
|
+
return get_typed(
|
|
1750
|
+
Endpoint.NetworksUsedHostList.with_params(self.network), dict[str, list[str]]
|
|
1751
|
+
)
|
|
1752
|
+
|
|
1753
|
+
def get_ptroverride_host_list(self) -> dict[str, str]:
|
|
1754
|
+
"""Return a dict of PTR override IP addresses and their associated hosts."""
|
|
1755
|
+
return get_typed(
|
|
1756
|
+
Endpoint.NetworksPTROverrideHostList.with_params(self.network), dict[str, str]
|
|
1757
|
+
)
|
|
1758
|
+
|
|
1759
|
+
def is_reserved_ip(self, ip: IP_AddressT) -> bool:
|
|
1760
|
+
"""Return True if the IP address is in the reserved list.
|
|
1761
|
+
|
|
1762
|
+
:param ip: The IP address to check.
|
|
1763
|
+
:returns: True if the IP address is in the reserved list.
|
|
1764
|
+
"""
|
|
1765
|
+
return ip in self.get_reserved_ips()
|
|
1766
|
+
|
|
1767
|
+
def add_excluded_range(self, start: str, end: str) -> None:
|
|
1768
|
+
"""Add an excluded range to the network.
|
|
1769
|
+
|
|
1770
|
+
:param start: The start of the excluded range.
|
|
1771
|
+
:param end: The end of the excluded range.
|
|
1772
|
+
|
|
1773
|
+
:returns: The new ExcludedRange object.
|
|
1774
|
+
"""
|
|
1775
|
+
start_ip = IPAddressField(address=start) # type: ignore # validator converts this
|
|
1776
|
+
end_ip = IPAddressField(address=end) # type: ignore # validator converts this
|
|
1777
|
+
if start_ip.address.version != end_ip.address.version:
|
|
1778
|
+
raise InputFailure("Start and end IP addresses must be of the same version")
|
|
1779
|
+
|
|
1780
|
+
resp = post(
|
|
1781
|
+
Endpoint.NetworksAddExcludedRanges.with_params(self.network),
|
|
1782
|
+
network=self.id,
|
|
1783
|
+
start_ip=str(start_ip.address),
|
|
1784
|
+
end_ip=str(end_ip.address),
|
|
1785
|
+
)
|
|
1786
|
+
if not resp or not resp.ok:
|
|
1787
|
+
raise CreateError(f"Failed to create excluded range for network {self.network}")
|
|
1788
|
+
|
|
1789
|
+
def remove_excluded_range(self, start: str, end: str) -> None:
|
|
1790
|
+
"""Remove an excluded range from the network.
|
|
1791
|
+
|
|
1792
|
+
:param start: The start of the excluded range.
|
|
1793
|
+
:param end: The end of the excluded range.
|
|
1794
|
+
"""
|
|
1795
|
+
# No need to validate IPs - if we find a match it's valid
|
|
1796
|
+
exrange: ExcludedRange | None = None
|
|
1797
|
+
for excluded_range in self.excluded_ranges:
|
|
1798
|
+
if str(excluded_range.start_ip) == start and str(excluded_range.end_ip) == end:
|
|
1799
|
+
exrange = excluded_range
|
|
1800
|
+
break
|
|
1801
|
+
else:
|
|
1802
|
+
raise EntityNotFound(f"Excluded range {start} - {end} not found")
|
|
1803
|
+
resp = delete(Endpoint.NetworksRemoveExcludedRanges.with_params(self.network, exrange.id))
|
|
1804
|
+
if not resp or not resp.ok:
|
|
1805
|
+
raise DeleteError(f"Failed to delete excluded range {start} - {end}")
|
|
1806
|
+
|
|
1807
|
+
def set_category(self, category: str) -> Self:
|
|
1808
|
+
"""Set the category tag of the network.
|
|
1809
|
+
|
|
1810
|
+
:param category: The new category tag.
|
|
1811
|
+
:returns: The updated Network object.
|
|
1812
|
+
"""
|
|
1813
|
+
if not is_valid_category_tag(category):
|
|
1814
|
+
raise InputFailure(f"Invalid category tag: {category}")
|
|
1815
|
+
return self.patch({"category": category})
|
|
1816
|
+
|
|
1817
|
+
def set_location(self, location: str) -> Self:
|
|
1818
|
+
"""Set the location tag of the network.
|
|
1819
|
+
|
|
1820
|
+
:param category: The new category.
|
|
1821
|
+
:returns: The updated Network object.
|
|
1822
|
+
"""
|
|
1823
|
+
if not is_valid_location_tag(location):
|
|
1824
|
+
raise InputFailure(f"Invalid location tag: {location}")
|
|
1825
|
+
return self.patch({"location": location})
|
|
1826
|
+
|
|
1827
|
+
def set_description(self, description: str) -> Self:
|
|
1828
|
+
"""Set the description of the network.
|
|
1829
|
+
|
|
1830
|
+
:param description: The new description.
|
|
1831
|
+
:returns: The updated Network object.
|
|
1832
|
+
"""
|
|
1833
|
+
return self.patch({"description": description})
|
|
1834
|
+
|
|
1835
|
+
def set_dns_delegation(self, delegated: bool) -> Self:
|
|
1836
|
+
"""Set the DNS delegation status of the network.
|
|
1837
|
+
|
|
1838
|
+
:param dns_delegated: The new DNS delegation status.
|
|
1839
|
+
:returns: The updated Network object.
|
|
1840
|
+
"""
|
|
1841
|
+
return self.patch({"dns_delegated": delegated})
|
|
1842
|
+
|
|
1843
|
+
def set_frozen(self, frozen: bool) -> Self:
|
|
1844
|
+
"""Set the frozen status of the network.
|
|
1845
|
+
|
|
1846
|
+
:param frozen: The new frozen status.
|
|
1847
|
+
:returns: The updated Network object.
|
|
1848
|
+
"""
|
|
1849
|
+
return self.patch({"frozen": frozen})
|
|
1850
|
+
|
|
1851
|
+
def set_reserved(self, reserved: int) -> Self:
|
|
1852
|
+
"""Set the number of reserved IP addresses.
|
|
1853
|
+
|
|
1854
|
+
:param reserved: The new number of reserved IP addresses.
|
|
1855
|
+
:returns: The updated Network object.
|
|
1856
|
+
"""
|
|
1857
|
+
return self.patch({"reserved": reserved})
|
|
1858
|
+
|
|
1859
|
+
def set_vlan(self, vlan: int) -> Self:
|
|
1860
|
+
"""Set the VLAN of the network.
|
|
1861
|
+
|
|
1862
|
+
:param vlan: The new VLAN.
|
|
1863
|
+
:returns: The updated Network object.
|
|
1864
|
+
"""
|
|
1865
|
+
return self.patch({"vlan": vlan})
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
class IPAddress(FrozenModelWithTimestamps, WithHost, APIMixin):
|
|
1869
|
+
"""Represents an IP address with associated details."""
|
|
1870
|
+
|
|
1871
|
+
id: int # noqa: A003
|
|
1872
|
+
macaddress: MACAddressField | None = None
|
|
1873
|
+
ipaddress: IPAddressField
|
|
1874
|
+
|
|
1875
|
+
@field_validator("macaddress", mode="before")
|
|
1876
|
+
@classmethod
|
|
1877
|
+
def create_valid_macadress_or_none(cls, v: Any) -> MACAddressField | None:
|
|
1878
|
+
"""Create macaddress or convert empty strings to None."""
|
|
1879
|
+
if v:
|
|
1880
|
+
return MACAddressField(address=v)
|
|
1881
|
+
return None
|
|
1882
|
+
|
|
1883
|
+
@field_validator("ipaddress", mode="before")
|
|
1884
|
+
@classmethod
|
|
1885
|
+
def create_valid_ipaddress(cls, v: Any) -> IPAddressField:
|
|
1886
|
+
"""Create macaddress or convert empty strings to None."""
|
|
1887
|
+
if isinstance(v, str):
|
|
1888
|
+
return IPAddressField.from_string(v)
|
|
1889
|
+
return v # let Pydantic handle it
|
|
1890
|
+
|
|
1891
|
+
@classmethod
|
|
1892
|
+
def get_by_ip(cls, ip: IP_AddressT) -> list[Self]:
|
|
1893
|
+
"""Get a list of IP address objects by IP address.
|
|
1894
|
+
|
|
1895
|
+
Note that the IP addresses can be duplicated across hosts,
|
|
1896
|
+
so this method may return multiple IP addresses.
|
|
1897
|
+
|
|
1898
|
+
:param ip: The IP address to search for.
|
|
1899
|
+
:returns: The IP address if found, None otherwise.
|
|
1900
|
+
"""
|
|
1901
|
+
return cls.get_list_by_field("ipaddress", str(ip))
|
|
1902
|
+
|
|
1903
|
+
@classmethod
|
|
1904
|
+
def get_by_mac(cls, mac: MACAddressField | str) -> IPAddress | None:
|
|
1905
|
+
"""Get the IP address objects by MAC address.
|
|
1906
|
+
|
|
1907
|
+
:param mac: The MAC address to search for.
|
|
1908
|
+
:returns: The IP address if found, None otherwise.
|
|
1909
|
+
"""
|
|
1910
|
+
if isinstance(mac, str):
|
|
1911
|
+
try:
|
|
1912
|
+
mac = MACAddressField(address=mac)
|
|
1913
|
+
except ValueError as e:
|
|
1914
|
+
raise InputFailure(f"Invalid MAC address: {mac}") from e
|
|
1915
|
+
|
|
1916
|
+
return cls.get_by_field("macaddress", mac.address)
|
|
1917
|
+
|
|
1918
|
+
@classmethod
|
|
1919
|
+
def endpoint(cls) -> Endpoint:
|
|
1920
|
+
"""Return the endpoint for the class."""
|
|
1921
|
+
return Endpoint.Ipaddresses
|
|
1922
|
+
|
|
1923
|
+
def __str__(self):
|
|
1924
|
+
"""Return the IP address as a string."""
|
|
1925
|
+
return self.ipaddress.__str__()
|
|
1926
|
+
|
|
1927
|
+
def is_ipv4(self) -> bool:
|
|
1928
|
+
"""Return True if the IP address is IPv4."""
|
|
1929
|
+
return self.ipaddress.is_ipv4()
|
|
1930
|
+
|
|
1931
|
+
def is_ipv6(self) -> bool:
|
|
1932
|
+
"""Return True if the IP address is IPv6."""
|
|
1933
|
+
return self.ipaddress.is_ipv6()
|
|
1934
|
+
|
|
1935
|
+
def network(self) -> Network:
|
|
1936
|
+
"""Return the network of the IP address."""
|
|
1937
|
+
data = get(Endpoint.NetworksByIP.with_id(str(self.ip())))
|
|
1938
|
+
return Network.model_validate(data.json())
|
|
1939
|
+
|
|
1940
|
+
def vlan(self) -> int | None:
|
|
1941
|
+
"""Return the VLAN of the IP address."""
|
|
1942
|
+
return self.network().vlan
|
|
1943
|
+
|
|
1944
|
+
def ip(self) -> IP_AddressT:
|
|
1945
|
+
"""Return the IP address."""
|
|
1946
|
+
return self.ipaddress.address
|
|
1947
|
+
|
|
1948
|
+
def associate_mac(self, mac: MACAddressField | str, force: bool = False) -> IPAddress:
|
|
1949
|
+
"""Associate a MAC address with the IP address.
|
|
1950
|
+
|
|
1951
|
+
:param mac: The MAC address to associate.
|
|
1952
|
+
:param force: If True, force the association even if the IP address already has
|
|
1953
|
+
a MAC address.
|
|
1954
|
+
|
|
1955
|
+
:returns: A new IPAddress object fetched from the API with the updated MAC address.
|
|
1956
|
+
"""
|
|
1957
|
+
if isinstance(mac, str):
|
|
1958
|
+
try:
|
|
1959
|
+
mac = MACAddressField(address=mac)
|
|
1960
|
+
except ValueError as e:
|
|
1961
|
+
raise InputFailure(f"Invalid MAC address: {mac}") from e
|
|
1962
|
+
|
|
1963
|
+
if self.macaddress and not force:
|
|
1964
|
+
raise EntityAlreadyExists(
|
|
1965
|
+
f"IP address {self.ipaddress} already has MAC address {self.macaddress}."
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
return self.patch(fields={"macaddress": mac.address})
|
|
1969
|
+
|
|
1970
|
+
def disassociate_mac(self) -> IPAddress:
|
|
1971
|
+
"""Disassociate the MAC address from the IP address.
|
|
1972
|
+
|
|
1973
|
+
Note that this does NOT validate the MAC address.
|
|
1974
|
+
|
|
1975
|
+
:returns: A new IPAddress object fetched from the API with the MAC address removed.
|
|
1976
|
+
"""
|
|
1977
|
+
# Model converts empty string to None so we must validate this ourselves.
|
|
1978
|
+
patched = self.patch(fields={"macaddress": ""}, validate=False)
|
|
1979
|
+
if patched.macaddress:
|
|
1980
|
+
raise PatchError(f"Failed to disassociate MAC address from {self.ipaddress}")
|
|
1981
|
+
return patched
|
|
1982
|
+
|
|
1983
|
+
def output(self, len_ip: int, len_names: int, names: bool = False):
|
|
1984
|
+
"""Output the IP address to the console."""
|
|
1985
|
+
ip = self.ipaddress.__str__()
|
|
1986
|
+
mac = self.macaddress if self.macaddress else "<not set>"
|
|
1987
|
+
|
|
1988
|
+
name = ""
|
|
1989
|
+
if names:
|
|
1990
|
+
name = Host.get_by_id(self.host)
|
|
1991
|
+
name = name.name if name else "<Not found>"
|
|
1992
|
+
|
|
1993
|
+
OutputManager().add_line(f"{name:<{len_names}}{ip:<{len_ip}}{mac}")
|
|
1994
|
+
|
|
1995
|
+
@classmethod
|
|
1996
|
+
def output_multiple(cls, ips: list[IPAddress], padding: int = 14, names: bool = False):
|
|
1997
|
+
"""Output IP addresses to the console."""
|
|
1998
|
+
output_manager = OutputManager()
|
|
1999
|
+
len_ip = max(padding, max([len(str(ip.ipaddress)) for ip in ips], default=0) + 2)
|
|
2000
|
+
|
|
2001
|
+
# This seems completely broken, we need to look up all the hosts and get their names.
|
|
2002
|
+
# This again requires a fetch_hosts() call that takes a series of identifiers using
|
|
2003
|
+
# id__in.
|
|
2004
|
+
len_names = (
|
|
2005
|
+
padding
|
|
2006
|
+
if not names
|
|
2007
|
+
else max(padding, max([len(str(ip.host)) for ip in ips], default=0) + 2)
|
|
2008
|
+
)
|
|
2009
|
+
|
|
2010
|
+
# Separate and output A and AAAA records
|
|
2011
|
+
for record_type, records in (
|
|
2012
|
+
("A_Records", [ip for ip in ips if ip.is_ipv4()]),
|
|
2013
|
+
("AAAA_Records", [ip for ip in ips if ip.is_ipv6()]),
|
|
2014
|
+
):
|
|
2015
|
+
if records:
|
|
2016
|
+
output_manager.add_line(f"{record_type:<{len_names}}IP{' ' * (len_ip - 2)}MAC")
|
|
2017
|
+
for record in records:
|
|
2018
|
+
record.output(len_ip=len_ip, len_names=len_names, names=names)
|
|
2019
|
+
|
|
2020
|
+
def __hash__(self):
|
|
2021
|
+
"""Return a hash of the IP address."""
|
|
2022
|
+
return hash((self.id, self.ipaddress.address, self.macaddress))
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
class HInfo(FrozenModelWithTimestamps, WithHost, APIMixin):
|
|
2026
|
+
"""Represents a HINFO record."""
|
|
2027
|
+
|
|
2028
|
+
cpu: str
|
|
2029
|
+
os: str
|
|
2030
|
+
|
|
2031
|
+
@classmethod
|
|
2032
|
+
def endpoint(cls) -> Endpoint:
|
|
2033
|
+
"""Return the endpoint for the class."""
|
|
2034
|
+
return Endpoint.Hinfos
|
|
2035
|
+
|
|
2036
|
+
def output(self, padding: int = 14):
|
|
2037
|
+
"""Output the HINFO record to the console."""
|
|
2038
|
+
OutputManager().add_line(
|
|
2039
|
+
"{1:<{0}}cpu={2} os={3}".format(padding, "Hinfo:", self.cpu, self.os)
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
class CNAME(FrozenModelWithTimestamps, WithHost, WithZone, WithTTL, APIMixin):
|
|
2044
|
+
"""Represents a CNAME record."""
|
|
2045
|
+
|
|
2046
|
+
id: int # noqa: A003
|
|
2047
|
+
name: HostT
|
|
2048
|
+
ttl: int | None = None
|
|
2049
|
+
|
|
2050
|
+
@field_validator("name", mode="before")
|
|
2051
|
+
@classmethod
|
|
2052
|
+
def validate_name(cls, value: Any) -> HostT:
|
|
2053
|
+
"""Validate the hostname."""
|
|
2054
|
+
return HostT(hostname=value)
|
|
2055
|
+
|
|
2056
|
+
@classmethod
|
|
2057
|
+
def endpoint(cls) -> Endpoint:
|
|
2058
|
+
"""Return the endpoint for the class."""
|
|
2059
|
+
return Endpoint.Cnames
|
|
2060
|
+
|
|
2061
|
+
@classmethod
|
|
2062
|
+
def get_by_name(cls, name: HostT) -> CNAME:
|
|
2063
|
+
"""Get a CNAME record by name.
|
|
2064
|
+
|
|
2065
|
+
:param name: The name to search for.
|
|
2066
|
+
:returns: The CNAME record if found, None otherwise.
|
|
2067
|
+
"""
|
|
2068
|
+
data = get_item_by_key_value(Endpoint.Cnames, "name", name.hostname)
|
|
2069
|
+
if not data:
|
|
2070
|
+
raise EntityNotFound(f"CNAME record for {name} not found.")
|
|
2071
|
+
return CNAME.model_validate(data)
|
|
2072
|
+
|
|
2073
|
+
@classmethod
|
|
2074
|
+
def get_by_host_and_name(cls, host: HostT | int, name: HostT) -> CNAME:
|
|
2075
|
+
"""Get a CNAME record by host and name.
|
|
2076
|
+
|
|
2077
|
+
:param host: The host to search for, either a hostname or an ID.
|
|
2078
|
+
:param name: The name to search for.
|
|
2079
|
+
:returns: The CNAME record if found, None otherwise.
|
|
2080
|
+
"""
|
|
2081
|
+
target_hostname = None
|
|
2082
|
+
if isinstance(host, HostT):
|
|
2083
|
+
hostobj = Host.get_by_any_means(host, inform_as_cname=False)
|
|
2084
|
+
if not hostobj:
|
|
2085
|
+
raise EntityNotFound(f"Host with name {host.hostname} not found.")
|
|
2086
|
+
|
|
2087
|
+
host = hostobj.id
|
|
2088
|
+
target_hostname = hostobj.name.hostname
|
|
2089
|
+
else:
|
|
2090
|
+
hostobj = Host.get_by_id(host)
|
|
2091
|
+
if not hostobj:
|
|
2092
|
+
raise EntityNotFound(f"Host with ID {host} not found.")
|
|
2093
|
+
target_hostname = hostobj.name.hostname
|
|
2094
|
+
|
|
2095
|
+
results = cls.get_by_query({"host": str(host), "name": name.hostname})
|
|
2096
|
+
|
|
2097
|
+
if not results or len(results) == 0:
|
|
2098
|
+
raise EntityNotFound(f"CNAME record for {name} not found for {target_hostname}.")
|
|
2099
|
+
|
|
2100
|
+
if len(results) > 1:
|
|
2101
|
+
raise MultipleEntititesFound(f"Multiple CNAME records found for {host} with {name}!")
|
|
2102
|
+
|
|
2103
|
+
return results[0]
|
|
2104
|
+
|
|
2105
|
+
def output(self, padding: int = 14) -> None:
|
|
2106
|
+
"""Output the CNAME record to the console.
|
|
2107
|
+
|
|
2108
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2109
|
+
"""
|
|
2110
|
+
actual_host = self.resolve_host()
|
|
2111
|
+
host = actual_host.name if actual_host else "<Not found>"
|
|
2112
|
+
|
|
2113
|
+
OutputManager().add_line(f"{'Cname:':<{padding}}{self.name} -> {host}")
|
|
2114
|
+
|
|
2115
|
+
@classmethod
|
|
2116
|
+
def output_multiple(cls, cnames: list[CNAME], padding: int = 14) -> None:
|
|
2117
|
+
"""Output multiple CNAME records to the console.
|
|
2118
|
+
|
|
2119
|
+
:param cnames: List of CNAME records to output.
|
|
2120
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2121
|
+
"""
|
|
2122
|
+
for cname in cnames:
|
|
2123
|
+
cname.output(padding=padding)
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
class TXT(FrozenModelWithTimestamps, WithHost, APIMixin):
|
|
2127
|
+
"""Represents a TXT record."""
|
|
2128
|
+
|
|
2129
|
+
id: int # noqa: A003
|
|
2130
|
+
txt: str
|
|
2131
|
+
|
|
2132
|
+
@classmethod
|
|
2133
|
+
def endpoint(cls) -> Endpoint:
|
|
2134
|
+
"""Return the endpoint for the class."""
|
|
2135
|
+
return Endpoint.Txts
|
|
2136
|
+
|
|
2137
|
+
def output(self, padding: int = 14) -> None:
|
|
2138
|
+
"""Output the TXT record to the console.
|
|
2139
|
+
|
|
2140
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2141
|
+
"""
|
|
2142
|
+
OutputManager().add_line(f"{'TXT:':<{padding}}{self.txt}")
|
|
2143
|
+
|
|
2144
|
+
@classmethod
|
|
2145
|
+
def output_multiple(cls, txts: list[TXT], padding: int = 14) -> None:
|
|
2146
|
+
"""Output multiple TXT records to the console.
|
|
2147
|
+
|
|
2148
|
+
:param txts: List of TXT records to output.
|
|
2149
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2150
|
+
"""
|
|
2151
|
+
for txt in txts:
|
|
2152
|
+
txt.output(padding=padding)
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
class MX(FrozenModelWithTimestamps, WithHost, APIMixin):
|
|
2156
|
+
"""Represents a MX record."""
|
|
2157
|
+
|
|
2158
|
+
id: int # noqa: A003
|
|
2159
|
+
mx: str
|
|
2160
|
+
priority: int
|
|
2161
|
+
|
|
2162
|
+
@classmethod
|
|
2163
|
+
def endpoint(cls) -> Endpoint:
|
|
2164
|
+
"""Return the endpoint for the class."""
|
|
2165
|
+
return Endpoint.Mxs
|
|
2166
|
+
|
|
2167
|
+
@classmethod
|
|
2168
|
+
def get_by_all(cls, host: int, mx: str, priority: int) -> MX:
|
|
2169
|
+
"""Get an MX record by all fields.
|
|
2170
|
+
|
|
2171
|
+
:param host: The host ID.
|
|
2172
|
+
:param mx: The MX record.
|
|
2173
|
+
:param priority: The priority.
|
|
2174
|
+
:returns: The MX record if found, None otherwise.
|
|
2175
|
+
"""
|
|
2176
|
+
data = get_list_unique(
|
|
2177
|
+
Endpoint.Mxs, params={"host": str(host), "mx": mx, "priority": str(priority)}
|
|
2178
|
+
)
|
|
2179
|
+
if not data:
|
|
2180
|
+
raise EntityNotFound(f"MX record for {mx} not found.")
|
|
2181
|
+
return MX.model_validate(data)
|
|
2182
|
+
|
|
2183
|
+
def has_mx_with_priority(self, mx: str, priority: int) -> bool:
|
|
2184
|
+
"""Return True if the MX record has the given MX and priority.
|
|
2185
|
+
|
|
2186
|
+
:param mx: The MX record to check.
|
|
2187
|
+
:param priority: The priority to check.
|
|
2188
|
+
:returns: True if the MX record has the given MX and priority.
|
|
2189
|
+
"""
|
|
2190
|
+
return self.mx == mx and self.priority == priority
|
|
2191
|
+
|
|
2192
|
+
def output(self, padding: int = 14) -> None:
|
|
2193
|
+
"""Output the MX record to the console.
|
|
2194
|
+
|
|
2195
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2196
|
+
"""
|
|
2197
|
+
len_pri = len("Priority")
|
|
2198
|
+
OutputManager().add_line(
|
|
2199
|
+
"{1:<{0}}{2:>{3}} {4}".format(padding, "", self.priority, len_pri, self.mx)
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
@classmethod
|
|
2203
|
+
def output_multiple(cls, mxs: list[MX], padding: int = 14) -> None:
|
|
2204
|
+
"""Output MX records to the console."""
|
|
2205
|
+
if not mxs:
|
|
2206
|
+
return
|
|
2207
|
+
|
|
2208
|
+
OutputManager().add_line("{1:<{0}}{2} {3}".format(padding, "MX:", "Priority", "Server"))
|
|
2209
|
+
for mx in sorted(mxs, key=lambda i: i.priority):
|
|
2210
|
+
mx.output(padding=padding)
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
class NAPTR(FrozenModelWithTimestamps, WithHost, APIMixin):
|
|
2214
|
+
"""Represents a NAPTR record."""
|
|
2215
|
+
|
|
2216
|
+
id: int # noqa: A003
|
|
2217
|
+
preference: int
|
|
2218
|
+
order: int
|
|
2219
|
+
flag: str | None = None
|
|
2220
|
+
service: str | None = None
|
|
2221
|
+
regex: str | None = None
|
|
2222
|
+
replacement: str
|
|
2223
|
+
|
|
2224
|
+
def output(self, padding: int = 14) -> None:
|
|
2225
|
+
"""Output the NAPTR record to the console.
|
|
2226
|
+
|
|
2227
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2228
|
+
"""
|
|
2229
|
+
row_format = f"{{:<{padding}}}" * len(NAPTR.headers())
|
|
2230
|
+
OutputManager().add_line(
|
|
2231
|
+
row_format.format(
|
|
2232
|
+
"",
|
|
2233
|
+
self.preference,
|
|
2234
|
+
self.order,
|
|
2235
|
+
self.flag,
|
|
2236
|
+
self.service,
|
|
2237
|
+
self.regex or '""',
|
|
2238
|
+
self.replacement,
|
|
2239
|
+
)
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
@classmethod
|
|
2243
|
+
def endpoint(cls) -> Endpoint:
|
|
2244
|
+
"""Return the endpoint for the class."""
|
|
2245
|
+
return Endpoint.Naptrs
|
|
2246
|
+
|
|
2247
|
+
@classmethod
|
|
2248
|
+
def headers(cls) -> list[str]:
|
|
2249
|
+
"""Return the headers for the NAPTR record."""
|
|
2250
|
+
return [
|
|
2251
|
+
"NAPTRs:",
|
|
2252
|
+
"Preference",
|
|
2253
|
+
"Order",
|
|
2254
|
+
"Flag",
|
|
2255
|
+
"Service",
|
|
2256
|
+
"Regex",
|
|
2257
|
+
"Replacement",
|
|
2258
|
+
]
|
|
2259
|
+
|
|
2260
|
+
@classmethod
|
|
2261
|
+
def output_multiple(cls, naptrs: list[NAPTR], padding: int = 14) -> None:
|
|
2262
|
+
"""Output multiple NAPTR records to the console."""
|
|
2263
|
+
headers = cls.headers()
|
|
2264
|
+
row_format = f"{{:<{padding}}}" * len(headers)
|
|
2265
|
+
manager = OutputManager()
|
|
2266
|
+
if naptrs:
|
|
2267
|
+
manager.add_line(row_format.format(*headers))
|
|
2268
|
+
for naptr in naptrs:
|
|
2269
|
+
naptr.output(padding=padding)
|
|
2270
|
+
|
|
2271
|
+
|
|
2272
|
+
class Srv(FrozenModelWithTimestamps, WithHost, WithZone, WithTTL, APIMixin):
|
|
2273
|
+
"""Represents a SRV record."""
|
|
2274
|
+
|
|
2275
|
+
id: int # noqa: A003
|
|
2276
|
+
name: str
|
|
2277
|
+
priority: int
|
|
2278
|
+
weight: int
|
|
2279
|
+
port: int
|
|
2280
|
+
ttl: int | None = None
|
|
2281
|
+
|
|
2282
|
+
@classmethod
|
|
2283
|
+
def endpoint(cls) -> Endpoint:
|
|
2284
|
+
"""Return the endpoint for the class."""
|
|
2285
|
+
return Endpoint.Srvs
|
|
2286
|
+
|
|
2287
|
+
def output(self, padding: int = 14, host_id_name_map: dict[int, str] | None = None) -> None:
|
|
2288
|
+
"""Output the SRV record to the console.
|
|
2289
|
+
|
|
2290
|
+
The output will include the record name, priority, weight, port,
|
|
2291
|
+
and the associated host name. Optionally uses a mapping of host IDs
|
|
2292
|
+
to host names to avoid repeated lookups.
|
|
2293
|
+
|
|
2294
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2295
|
+
:param host_names: Optional dictionary mapping host IDs to host names.
|
|
2296
|
+
"""
|
|
2297
|
+
host_name = "<Not found>"
|
|
2298
|
+
if host_id_name_map and self.host in host_id_name_map:
|
|
2299
|
+
host_name = host_id_name_map[self.host]
|
|
2300
|
+
elif not host_id_name_map or self.host not in host_id_name_map:
|
|
2301
|
+
host = self.resolve_host()
|
|
2302
|
+
if host:
|
|
2303
|
+
host_name = host.name
|
|
2304
|
+
|
|
2305
|
+
# Format the output string to include padding and center alignment
|
|
2306
|
+
# for priority, weight, and port.
|
|
2307
|
+
output_manager = OutputManager()
|
|
2308
|
+
format_str = "SRV: {:<{padding}} {:^6} {:^6} {:^6} {}"
|
|
2309
|
+
output_manager.add_line(
|
|
2310
|
+
format_str.format(
|
|
2311
|
+
self.name,
|
|
2312
|
+
str(self.priority),
|
|
2313
|
+
str(self.weight),
|
|
2314
|
+
str(self.port),
|
|
2315
|
+
host_name,
|
|
2316
|
+
padding=padding,
|
|
2317
|
+
)
|
|
2318
|
+
)
|
|
2319
|
+
|
|
2320
|
+
@classmethod
|
|
2321
|
+
def output_multiple(cls, srvs: list[Srv], padding: int = 14) -> None:
|
|
2322
|
+
"""Output multiple SRV records.
|
|
2323
|
+
|
|
2324
|
+
This method adjusts the padding dynamically based on the longest record name.
|
|
2325
|
+
|
|
2326
|
+
:param srvs: List of Srv records to output.
|
|
2327
|
+
:param padding: Minimum number of spaces for left-padding the output.
|
|
2328
|
+
"""
|
|
2329
|
+
if not srvs:
|
|
2330
|
+
return
|
|
2331
|
+
|
|
2332
|
+
host_ids = {srv.host for srv in srvs}
|
|
2333
|
+
|
|
2334
|
+
host_data = get_list_in(Endpoint.Hosts, "id", list(host_ids))
|
|
2335
|
+
hosts = [Host.model_validate(host) for host in host_data]
|
|
2336
|
+
|
|
2337
|
+
host_id_name_map = {host.id: str(host.name) for host in hosts}
|
|
2338
|
+
|
|
2339
|
+
host_id_name_map.update(
|
|
2340
|
+
{host_id: host_id_name_map.get(host_id, "<Not found>") for host_id in host_ids}
|
|
2341
|
+
)
|
|
2342
|
+
|
|
2343
|
+
padding = max((len(srv.name) for srv in srvs), default=padding)
|
|
2344
|
+
|
|
2345
|
+
# Output each SRV record with the optimized host name lookup
|
|
2346
|
+
for srv in srvs:
|
|
2347
|
+
srv.output(padding=padding, host_id_name_map=host_id_name_map)
|
|
2348
|
+
|
|
2349
|
+
def __str__(self) -> str:
|
|
2350
|
+
"""Return a string representation of the SRV record."""
|
|
2351
|
+
return self.name
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
class PTR_override(FrozenModelWithTimestamps, WithHost, APIMixin):
|
|
2355
|
+
"""Represents a PTR override record."""
|
|
2356
|
+
|
|
2357
|
+
id: int # noqa: A003
|
|
2358
|
+
ipaddress: IP_AddressT
|
|
2359
|
+
|
|
2360
|
+
@classmethod
|
|
2361
|
+
def endpoint(cls) -> Endpoint:
|
|
2362
|
+
"""Return the endpoint for the class."""
|
|
2363
|
+
return Endpoint.PTR_overrides
|
|
2364
|
+
|
|
2365
|
+
@classmethod
|
|
2366
|
+
def output_multiple(cls, ptrs: list[PTR_override], padding: int = 14):
|
|
2367
|
+
"""Output multiple PTR override records to the console.
|
|
2368
|
+
|
|
2369
|
+
:param ptrs: List of PTR override records to output.
|
|
2370
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2371
|
+
"""
|
|
2372
|
+
if not ptrs:
|
|
2373
|
+
return
|
|
2374
|
+
|
|
2375
|
+
for ptr in ptrs:
|
|
2376
|
+
ptr.output(padding=padding)
|
|
2377
|
+
|
|
2378
|
+
def output(self, padding: int = 14):
|
|
2379
|
+
"""Output the PTR override record to the console.
|
|
2380
|
+
|
|
2381
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2382
|
+
"""
|
|
2383
|
+
host = self.resolve_host()
|
|
2384
|
+
hostname = host.name if host else "<Not found>"
|
|
2385
|
+
|
|
2386
|
+
OutputManager().add_line(f"{'PTR override:':<{padding}}{self.ipaddress} -> {hostname}")
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
class SSHFP(FrozenModelWithTimestamps, WithHost, WithTTL, APIMixin):
|
|
2390
|
+
"""Represents a SSHFP record."""
|
|
2391
|
+
|
|
2392
|
+
id: int # noqa: A003
|
|
2393
|
+
algorithm: int
|
|
2394
|
+
hash_type: int
|
|
2395
|
+
fingerprint: str
|
|
2396
|
+
ttl: int | None = None
|
|
2397
|
+
|
|
2398
|
+
@classmethod
|
|
2399
|
+
def endpoint(cls) -> Endpoint:
|
|
2400
|
+
"""Return the endpoint for the class."""
|
|
2401
|
+
return Endpoint.Sshfps
|
|
2402
|
+
|
|
2403
|
+
@classmethod
|
|
2404
|
+
def output_multiple(cls, sshfps: list[SSHFP], padding: int = 14):
|
|
2405
|
+
"""Output multiple SSHFP records to the console.
|
|
2406
|
+
|
|
2407
|
+
:param sshfps: List of SSHFP records to output.
|
|
2408
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2409
|
+
"""
|
|
2410
|
+
headers = cls.headers()
|
|
2411
|
+
row_format = f"{{:<{padding}}}" * len(headers)
|
|
2412
|
+
manager = OutputManager()
|
|
2413
|
+
if sshfps:
|
|
2414
|
+
manager.add_line(row_format.format(*headers))
|
|
2415
|
+
for sshfp in sshfps:
|
|
2416
|
+
sshfp.output(padding=padding)
|
|
2417
|
+
|
|
2418
|
+
@classmethod
|
|
2419
|
+
def headers(cls) -> list[str]:
|
|
2420
|
+
"""Return the headers for the SSHFP record."""
|
|
2421
|
+
return ["SSHFPs:", "Algorithm", "Hash Type", "Fingerprint"]
|
|
2422
|
+
|
|
2423
|
+
def output(self, padding: int = 14):
|
|
2424
|
+
"""Output the SSHFP record to the console.
|
|
2425
|
+
|
|
2426
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2427
|
+
"""
|
|
2428
|
+
row_format = f"{{:<{padding}}}" * len(SSHFP.headers())
|
|
2429
|
+
OutputManager().add_line(
|
|
2430
|
+
row_format.format("", self.algorithm, self.hash_type, self.fingerprint)
|
|
2431
|
+
)
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
class BacnetID(FrozenModel, WithHost, APIMixin):
|
|
2435
|
+
"""Represents a Bacnet ID record."""
|
|
2436
|
+
|
|
2437
|
+
id: int # noqa: A003
|
|
2438
|
+
hostname: str
|
|
2439
|
+
|
|
2440
|
+
@classmethod
|
|
2441
|
+
def MAX_ID(cls) -> int:
|
|
2442
|
+
"""Return the maximum ID for a Bacnet ID."""
|
|
2443
|
+
return 4194302
|
|
2444
|
+
|
|
2445
|
+
@classmethod
|
|
2446
|
+
def endpoint(cls) -> Endpoint:
|
|
2447
|
+
"""Return the endpoint for the class."""
|
|
2448
|
+
return Endpoint.BacnetID
|
|
2449
|
+
|
|
2450
|
+
@classmethod
|
|
2451
|
+
def get_in_range(cls, start: int, end: int) -> list[Self]:
|
|
2452
|
+
"""Get Bacnet IDs in a range.
|
|
2453
|
+
|
|
2454
|
+
:param start: The start of the range.
|
|
2455
|
+
:param end: The end of the range.
|
|
2456
|
+
:returns: List of BacnetID objects in the range.
|
|
2457
|
+
"""
|
|
2458
|
+
params: QueryParams = {"id__range": f"{start},{end}"}
|
|
2459
|
+
return get_typed(Endpoint.BacnetID, list[cls], params=params)
|
|
2460
|
+
|
|
2461
|
+
@classmethod
|
|
2462
|
+
def output_multiple(cls, bacnetids: list[BacnetID]):
|
|
2463
|
+
"""Output multiple Bacnet ID records to the console.
|
|
2464
|
+
|
|
2465
|
+
:param bacnetids: List of Bacnet ID records to output.
|
|
2466
|
+
"""
|
|
2467
|
+
if not bacnetids:
|
|
2468
|
+
return
|
|
2469
|
+
|
|
2470
|
+
OutputManager().add_formatted_table(("ID", "Hostname"), ("id", "hostname"), bacnetids)
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
class Location(FrozenModelWithTimestamps, WithHost, APIMixin):
|
|
2474
|
+
"""Represents a LOC record."""
|
|
2475
|
+
|
|
2476
|
+
loc: str
|
|
2477
|
+
|
|
2478
|
+
@classmethod
|
|
2479
|
+
def endpoint(cls) -> Endpoint:
|
|
2480
|
+
"""Return the endpoint for the class."""
|
|
2481
|
+
return Endpoint.Locs
|
|
2482
|
+
|
|
2483
|
+
def output(self, padding: int = 14):
|
|
2484
|
+
"""Output the LOC record to the console.
|
|
2485
|
+
|
|
2486
|
+
:param padding: Number of spaces for left-padding the output.
|
|
2487
|
+
"""
|
|
2488
|
+
OutputManager().add_line(f"{'LOC:':<{padding}}{self.loc}")
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
class Host(FrozenModelWithTimestamps, WithTTL, WithHistory, APIMixin):
|
|
2492
|
+
"""Model for an individual host."""
|
|
2493
|
+
|
|
2494
|
+
id: int # noqa: A003
|
|
2495
|
+
name: HostT
|
|
2496
|
+
ipaddresses: list[IPAddress]
|
|
2497
|
+
cnames: list[CNAME] = []
|
|
2498
|
+
mxs: list[MX] = []
|
|
2499
|
+
txts: list[TXT] = []
|
|
2500
|
+
ptr_overrides: list[PTR_override] = []
|
|
2501
|
+
hinfo: HInfo | None = None
|
|
2502
|
+
loc: Location | None = None
|
|
2503
|
+
bacnetid: int | None = None
|
|
2504
|
+
contact: str
|
|
2505
|
+
ttl: int | None = None
|
|
2506
|
+
comment: str
|
|
2507
|
+
|
|
2508
|
+
# Note, we do not use WithZone here as this is optional and we resolve it differently.
|
|
2509
|
+
zone: int | None = None
|
|
2510
|
+
|
|
2511
|
+
history_resource: ClassVar[HistoryResource] = HistoryResource.Host
|
|
2512
|
+
|
|
2513
|
+
@field_validator("name", mode="before")
|
|
2514
|
+
@classmethod
|
|
2515
|
+
def validate_name(cls, value: Any) -> HostT:
|
|
2516
|
+
"""Validate the hostname."""
|
|
2517
|
+
return HostT(hostname=value)
|
|
2518
|
+
|
|
2519
|
+
@field_validator("bacnetid", mode="before")
|
|
2520
|
+
@classmethod
|
|
2521
|
+
def convert_bacnetid(cls, v: Any) -> Any:
|
|
2522
|
+
"""Use nested ID value in bacnetid value."""
|
|
2523
|
+
if isinstance(v, dict):
|
|
2524
|
+
return v.get("id") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
|
|
2525
|
+
return None
|
|
2526
|
+
|
|
2527
|
+
@classmethod
|
|
2528
|
+
def endpoint(cls) -> Endpoint:
|
|
2529
|
+
"""Return the endpoint for the class."""
|
|
2530
|
+
return Endpoint.Hosts
|
|
2531
|
+
|
|
2532
|
+
@classmethod
|
|
2533
|
+
def get_by_any_means_or_raise(
|
|
2534
|
+
cls, identifier: str | HostT, inform_as_cname: bool = True, inform_as_ptr: bool = True
|
|
2535
|
+
) -> Host:
|
|
2536
|
+
"""Get a host by the given identifier or raise EntityNotFound.
|
|
2537
|
+
|
|
2538
|
+
See also `get_by_any_means`.
|
|
2539
|
+
|
|
2540
|
+
:param identifier: The identifier to search for.
|
|
2541
|
+
:param inform_as_cname: If True, inform the user if the host is a CNAME.
|
|
2542
|
+
:param inform_as_ptr: If True, inform the user if the host is a PTR override.
|
|
2543
|
+
|
|
2544
|
+
:raises EntityNotFound: If the host is not found.
|
|
2545
|
+
|
|
2546
|
+
:returns: A Host object if the host was found.
|
|
2547
|
+
"""
|
|
2548
|
+
host = cls.get_by_any_means(
|
|
2549
|
+
identifier, inform_as_cname=inform_as_cname, inform_as_ptr=inform_as_ptr
|
|
2550
|
+
)
|
|
2551
|
+
if not host:
|
|
2552
|
+
raise EntityNotFound(f"Host {identifier} not found.")
|
|
2553
|
+
return host
|
|
2554
|
+
|
|
2555
|
+
@classmethod
|
|
2556
|
+
def get_by_any_means(
|
|
2557
|
+
cls, identifier: str | HostT, inform_as_cname: bool = True, inform_as_ptr: bool = True
|
|
2558
|
+
) -> Host | None:
|
|
2559
|
+
"""Get a host by the given identifier.
|
|
2560
|
+
|
|
2561
|
+
- If the identifier is numeric, it will be treated as an ID.
|
|
2562
|
+
- If the identifier is an IP address, it will be treated as an IP address (v4 or v6).
|
|
2563
|
+
- If the identifier is a MAC address, it will be treated as a MAC address.
|
|
2564
|
+
- Otherwise, it will be treated as a hostname. If the hostname is a CNAME,
|
|
2565
|
+
the host it points to will be returned.
|
|
2566
|
+
|
|
2567
|
+
To check if a returned host is a cname, one can do the following:
|
|
2568
|
+
|
|
2569
|
+
```python
|
|
2570
|
+
hostname = "host.example.com"
|
|
2571
|
+
host = get_host(hostname, ok404=True)
|
|
2572
|
+
if host is None:
|
|
2573
|
+
print("Host not found.")
|
|
2574
|
+
elif host.name != hostname:
|
|
2575
|
+
print(f"{hostname} is a CNAME pointing to {host.name}")
|
|
2576
|
+
else:
|
|
2577
|
+
print(f"{host.name} is a host.")
|
|
2578
|
+
```
|
|
2579
|
+
|
|
2580
|
+
Note that get_host will perform a case-insensitive search for a fully qualified version
|
|
2581
|
+
of the hostname, so the comparison above may fail.
|
|
2582
|
+
|
|
2583
|
+
:param identifier: The identifier to search for.
|
|
2584
|
+
:param ok404: If True, don't raise a EntityNotFound if the host is not found.
|
|
2585
|
+
:param inform_as_cname: If True, inform the user if the host is a CNAME.
|
|
2586
|
+
:param inform_as_ptr: If True, inform the user if the host is a PTR override.
|
|
2587
|
+
|
|
2588
|
+
:raises EntityNotFound: If we don't find the host and `ok404` is False.
|
|
2589
|
+
|
|
2590
|
+
:returns: A Host object if the host was found, otherwise None.
|
|
2591
|
+
"""
|
|
2592
|
+
host = None
|
|
2593
|
+
if not isinstance(identifier, HostT):
|
|
2594
|
+
if identifier.isdigit():
|
|
2595
|
+
return Host.get_by_id(int(identifier))
|
|
2596
|
+
|
|
2597
|
+
try:
|
|
2598
|
+
ptr = False
|
|
2599
|
+
ipaddress.ip_address(identifier)
|
|
2600
|
+
|
|
2601
|
+
hosts = Host.get_list_by_field(
|
|
2602
|
+
"ipaddresses__ipaddress", identifier, ordering="name"
|
|
2603
|
+
)
|
|
2604
|
+
|
|
2605
|
+
if not hosts:
|
|
2606
|
+
hosts = Host.get_list_by_field("ptr_overrides__ipaddress", identifier)
|
|
2607
|
+
ptr = True
|
|
2608
|
+
|
|
2609
|
+
if len(hosts) == 1:
|
|
2610
|
+
if ptr and inform_as_ptr:
|
|
2611
|
+
OutputManager().add_line(
|
|
2612
|
+
f"{identifier} is a PTR override for {hosts[0].name}"
|
|
2613
|
+
)
|
|
2614
|
+
return hosts[0]
|
|
2615
|
+
|
|
2616
|
+
if len(hosts) > 1:
|
|
2617
|
+
raise MultipleEntititesFound(
|
|
2618
|
+
f"Multiple hosts found with IP address or PTR {identifier}."
|
|
2619
|
+
)
|
|
2620
|
+
|
|
2621
|
+
except ValueError:
|
|
2622
|
+
pass
|
|
2623
|
+
|
|
2624
|
+
try:
|
|
2625
|
+
mac = MACAddressField(address=identifier)
|
|
2626
|
+
return Host.get_by_field("ipaddresses__macaddress", mac.address)
|
|
2627
|
+
except ValueError:
|
|
2628
|
+
pass
|
|
2629
|
+
|
|
2630
|
+
# Let us try to find the host by name...
|
|
2631
|
+
name = HostT(hostname=identifier)
|
|
2632
|
+
else:
|
|
2633
|
+
name = identifier
|
|
2634
|
+
|
|
2635
|
+
host = Host.get_by_field("name", name.hostname)
|
|
2636
|
+
|
|
2637
|
+
if host:
|
|
2638
|
+
return host
|
|
2639
|
+
|
|
2640
|
+
cname = CNAME.get_by_field("name", name.hostname)
|
|
2641
|
+
# If we found a CNAME, get the host it points to. We're not interested in the
|
|
2642
|
+
# CNAME itself.
|
|
2643
|
+
if cname is not None:
|
|
2644
|
+
host = Host.get_by_id(cname.host)
|
|
2645
|
+
|
|
2646
|
+
if host and inform_as_cname:
|
|
2647
|
+
OutputManager().add_line(f"{name} is a CNAME for {host.name}")
|
|
2648
|
+
|
|
2649
|
+
return host
|
|
2650
|
+
|
|
2651
|
+
def delete(self) -> bool:
|
|
2652
|
+
"""Delete the host.
|
|
2653
|
+
|
|
2654
|
+
:raises DeleteError: If the operation to delete the host fails.
|
|
2655
|
+
|
|
2656
|
+
:returns: True if the host was deleted successfully, False otherwise.
|
|
2657
|
+
"""
|
|
2658
|
+
# Note, we can't use .id as the identifier here, as the host name is used
|
|
2659
|
+
# in the endpoint URL...
|
|
2660
|
+
op = delete(Endpoint.Hosts.with_id(str(self.name)))
|
|
2661
|
+
if not op:
|
|
2662
|
+
raise DeleteError(f"Failed to delete host {self.name}, operation failed.")
|
|
2663
|
+
|
|
2664
|
+
return op.status_code >= 200 and op.status_code < 300
|
|
2665
|
+
|
|
2666
|
+
def rename(self, new_name: HostT) -> Host:
|
|
2667
|
+
"""Rename the host.
|
|
2668
|
+
|
|
2669
|
+
:param new_name: The new name for the host.
|
|
2670
|
+
|
|
2671
|
+
:returns: A new Host object fetched from the API with the updated name.
|
|
2672
|
+
"""
|
|
2673
|
+
return self.patch(fields={"name": new_name.hostname})
|
|
2674
|
+
|
|
2675
|
+
def set_comment(self, comment: str) -> Host:
|
|
2676
|
+
"""Set the comment for the host.
|
|
2677
|
+
|
|
2678
|
+
:param comment: The comment to set.
|
|
2679
|
+
|
|
2680
|
+
:returns: A new Host object fetched from the API with the updated comment.
|
|
2681
|
+
"""
|
|
2682
|
+
return self.patch(fields={"comment": comment})
|
|
2683
|
+
|
|
2684
|
+
def set_contact(self, contact: str) -> Host:
|
|
2685
|
+
"""Set the contact for the host.
|
|
2686
|
+
|
|
2687
|
+
:param contact: The contact to set. Should be a valid email, but we leave it to the
|
|
2688
|
+
server to validate the data.
|
|
2689
|
+
|
|
2690
|
+
:returns: A new Host object fetched from the API with the updated contact.
|
|
2691
|
+
"""
|
|
2692
|
+
return self.patch(fields={"contact": contact})
|
|
2693
|
+
|
|
2694
|
+
def add_ip(self, ip: IP_AddressT, mac: MACAddressField | None = None) -> Host:
|
|
2695
|
+
"""Add an IP address to the host.
|
|
2696
|
+
|
|
2697
|
+
:param ip: The IP address to add. IPv4 or IPv6.
|
|
2698
|
+
|
|
2699
|
+
:returns: A new Host object fetched from the API with the updated IP address.
|
|
2700
|
+
"""
|
|
2701
|
+
params: QueryParams = {"ipaddress": str(ip), "host": str(self.id)}
|
|
2702
|
+
if mac:
|
|
2703
|
+
params["macaddress"] = mac.address
|
|
2704
|
+
|
|
2705
|
+
IPAddress.create(params=params)
|
|
2706
|
+
return self.refetch()
|
|
2707
|
+
|
|
2708
|
+
def has_ip(self, arg_ip: IP_AddressT) -> bool:
|
|
2709
|
+
"""Check if the host has the given IP address.
|
|
2710
|
+
|
|
2711
|
+
:param ip: The IP address to check for.
|
|
2712
|
+
|
|
2713
|
+
:returns: True if the host has the IP address, False otherwise.
|
|
2714
|
+
"""
|
|
2715
|
+
return any([ip.ipaddress.address == arg_ip for ip in self.ipaddresses])
|
|
2716
|
+
|
|
2717
|
+
def has_ip_with_mac(self, arg_mac: MACAddressField | str) -> IPAddress | None:
|
|
2718
|
+
"""Check if the host has the given MAC address.
|
|
2719
|
+
|
|
2720
|
+
:param mac: The MAC address to check for.
|
|
2721
|
+
|
|
2722
|
+
:returns: The IP address object if found, None otherwise.
|
|
2723
|
+
"""
|
|
2724
|
+
if not isinstance(arg_mac, MACAddressField):
|
|
2725
|
+
arg_mac = MACAddressField(address=arg_mac)
|
|
2726
|
+
return next((ip for ip in self.ipaddresses if ip.macaddress == arg_mac), None)
|
|
2727
|
+
|
|
2728
|
+
def ips_with_macaddresses(self) -> list[IPAddress]:
|
|
2729
|
+
"""Return a list of IP addresses with a MAC address."""
|
|
2730
|
+
return [ip for ip in self.ipaddresses if ip.macaddress]
|
|
2731
|
+
|
|
2732
|
+
def get_associatable_ip(self) -> IPAddress:
|
|
2733
|
+
"""Get the only IP address of the host that can have a MAC associated to it.
|
|
2734
|
+
|
|
2735
|
+
Constraints:
|
|
2736
|
+
- If the host only has one IP, return that IP.
|
|
2737
|
+
- If the host has two IPs of different versions and they are on the same VLAN,
|
|
2738
|
+
return the IPv4 address.
|
|
2739
|
+
|
|
2740
|
+
:raises EntityNotFound: If the host has no IP addresses.
|
|
2741
|
+
:raises EntityOwnershipMismatch: If the host multiple IPs and the constriants aren't met.
|
|
2742
|
+
|
|
2743
|
+
:returns: An IP address that can be associated with the host.
|
|
2744
|
+
"""
|
|
2745
|
+
if len(self.ipaddresses) == 0:
|
|
2746
|
+
raise EntityNotFound(f"Host {self} has no IP addresses.")
|
|
2747
|
+
|
|
2748
|
+
if len(self.ipaddresses) == 1:
|
|
2749
|
+
return self.ipaddresses[0]
|
|
2750
|
+
|
|
2751
|
+
ipv4s = self.ipv4_addresses()
|
|
2752
|
+
ipv6s = self.ipv6_addresses()
|
|
2753
|
+
|
|
2754
|
+
if len(ipv4s) == 1 and len(ipv6s) == 1:
|
|
2755
|
+
vlan4 = ipv4s[0].network().vlan
|
|
2756
|
+
vlan6 = ipv6s[0].network().vlan
|
|
2757
|
+
|
|
2758
|
+
if vlan4 == vlan6:
|
|
2759
|
+
return ipv4s[0]
|
|
2760
|
+
|
|
2761
|
+
raise EntityOwnershipMismatch(
|
|
2762
|
+
f"Host {self} has multiple IPs, cannot determine which one to use."
|
|
2763
|
+
)
|
|
2764
|
+
|
|
2765
|
+
def has_ptr_override(self, arg_ip: IP_AddressT) -> bool:
|
|
2766
|
+
"""Check if the host has a PTR override for the given IP address.
|
|
2767
|
+
|
|
2768
|
+
:param ip: The IP address to check for.
|
|
2769
|
+
|
|
2770
|
+
:returns: True if the host has a PTR override for the IP address, False otherwise.
|
|
2771
|
+
"""
|
|
2772
|
+
return any([ptr.ipaddress == arg_ip for ptr in self.ptr_overrides])
|
|
2773
|
+
|
|
2774
|
+
def has_txt(self, arg_txt: str) -> bool:
|
|
2775
|
+
"""Check if the host has the given TXT record.
|
|
2776
|
+
|
|
2777
|
+
:param txt: The TXT record to check for.
|
|
2778
|
+
|
|
2779
|
+
:returns: True if the host has the TXT record, False otherwise.
|
|
2780
|
+
"""
|
|
2781
|
+
return any([txt.txt == arg_txt for txt in self.txts])
|
|
2782
|
+
|
|
2783
|
+
def get_ip(self, arg_ip: IP_AddressT) -> IPAddress | None:
|
|
2784
|
+
"""Get the IP address object for the given IP address.
|
|
2785
|
+
|
|
2786
|
+
:param ip: The IP address to search for.
|
|
2787
|
+
|
|
2788
|
+
:returns: The IP address object if found, None otherwise.
|
|
2789
|
+
"""
|
|
2790
|
+
return next((ip for ip in self.ipaddresses if ip.ipaddress.address == arg_ip), None)
|
|
2791
|
+
|
|
2792
|
+
def get_ptr_override(self, ip: IP_AddressT) -> PTR_override | None:
|
|
2793
|
+
"""Get the PTR override for the given IP address.
|
|
2794
|
+
|
|
2795
|
+
:param ip: The IP address to search for.
|
|
2796
|
+
|
|
2797
|
+
:returns: The PTR override object if found, None otherwise.
|
|
2798
|
+
"""
|
|
2799
|
+
return next((ptr for ptr in self.ptr_overrides if ptr.ipaddress == ip), None)
|
|
2800
|
+
|
|
2801
|
+
def ipv4_addresses(self):
|
|
2802
|
+
"""Return a list of IPv4 addresses."""
|
|
2803
|
+
return [ip for ip in self.ipaddresses if ip.is_ipv4()]
|
|
2804
|
+
|
|
2805
|
+
def ipv6_addresses(self):
|
|
2806
|
+
"""Return a list of IPv6 addresses."""
|
|
2807
|
+
return [ip for ip in self.ipaddresses if ip.is_ipv6()]
|
|
2808
|
+
|
|
2809
|
+
def associate_mac_to_ip(
|
|
2810
|
+
self, mac: MACAddressField | str, ip: IPAddressField | str, force: bool = False
|
|
2811
|
+
) -> Host:
|
|
2812
|
+
"""Associate a MAC address to an IP address.
|
|
2813
|
+
|
|
2814
|
+
:param mac: The MAC address to associate.
|
|
2815
|
+
:param ip: The IP address to associate.
|
|
2816
|
+
|
|
2817
|
+
:returns: A new Host object fetched from the API after updating the IP address.
|
|
2818
|
+
"""
|
|
2819
|
+
if isinstance(mac, str):
|
|
2820
|
+
mac = MACAddressField(address=mac)
|
|
2821
|
+
|
|
2822
|
+
if isinstance(ip, str):
|
|
2823
|
+
ip = IPAddressField(address=ipaddress.ip_address(ip))
|
|
2824
|
+
|
|
2825
|
+
params: QueryParams = {
|
|
2826
|
+
"macaddress": mac.address,
|
|
2827
|
+
"ordering": "ipaddress",
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
ipadresses = get_typed(Endpoint.Ipaddresses, list[IPAddress], params=params)
|
|
2831
|
+
|
|
2832
|
+
if ip in [ip.ipaddress for ip in ipadresses]:
|
|
2833
|
+
raise EntityAlreadyExists(f"IP address {ip} already has MAC address {mac} associated.")
|
|
2834
|
+
|
|
2835
|
+
if len(ipadresses) and not force:
|
|
2836
|
+
raise EntityOwnershipMismatch(
|
|
2837
|
+
"mac {} already in use by: {}. Use force to add {} -> {} as well.".format(
|
|
2838
|
+
mac, ipadresses, ip.address, mac
|
|
2839
|
+
)
|
|
2840
|
+
)
|
|
2841
|
+
|
|
2842
|
+
ip_found_in_host = False
|
|
2843
|
+
for myip in self.ipaddresses:
|
|
2844
|
+
if myip.ipaddress.address == ip.address:
|
|
2845
|
+
myip.associate_mac(mac, force=force)
|
|
2846
|
+
ip_found_in_host = True
|
|
2847
|
+
|
|
2848
|
+
if not ip_found_in_host:
|
|
2849
|
+
raise EntityNotFound(f"IP address {ip} not found in host {self.name}.")
|
|
2850
|
+
|
|
2851
|
+
return self.refetch()
|
|
2852
|
+
|
|
2853
|
+
def disassociate_mac_from_ip(self, ip: IPAddressField | str) -> Host:
|
|
2854
|
+
"""Disassociate a MAC address from an IP address.
|
|
2855
|
+
|
|
2856
|
+
Note: This method blindly disassociates the current MAC address
|
|
2857
|
+
from associated the given IP address.
|
|
2858
|
+
|
|
2859
|
+
:param ip: The IP address to disassociate.
|
|
2860
|
+
|
|
2861
|
+
:returns: A new Host object fetched from the API after updating the IP address.
|
|
2862
|
+
"""
|
|
2863
|
+
if isinstance(ip, str):
|
|
2864
|
+
ip = IPAddressField(address=ipaddress.ip_address(ip))
|
|
2865
|
+
|
|
2866
|
+
ip_found_in_host = False
|
|
2867
|
+
for myip in self.ipaddresses:
|
|
2868
|
+
if myip.ipaddress.address == ip.address:
|
|
2869
|
+
myip.disassociate_mac()
|
|
2870
|
+
ip_found_in_host = True
|
|
2871
|
+
|
|
2872
|
+
if not ip_found_in_host:
|
|
2873
|
+
raise EntityNotFound(f"IP address {ip} not found in host {self.name}.")
|
|
2874
|
+
|
|
2875
|
+
return self.refetch()
|
|
2876
|
+
|
|
2877
|
+
def networks(self) -> dict[Network, list[IPAddress]]:
|
|
2878
|
+
"""Return a dict of unique networks and a list of associated IP addresses for the host.
|
|
2879
|
+
|
|
2880
|
+
:returns: A dictionary of networks and the associated IP addresses.
|
|
2881
|
+
"""
|
|
2882
|
+
ret_dict: dict[Network, list[IPAddress]] = {}
|
|
2883
|
+
|
|
2884
|
+
for ip in self.ipaddresses:
|
|
2885
|
+
network = ip.network()
|
|
2886
|
+
if network not in ret_dict:
|
|
2887
|
+
ret_dict[network] = []
|
|
2888
|
+
|
|
2889
|
+
ret_dict[network].append(ip)
|
|
2890
|
+
|
|
2891
|
+
return ret_dict
|
|
2892
|
+
|
|
2893
|
+
def vlans(self) -> dict[int, list[IPAddress]]:
|
|
2894
|
+
"""Return a dict of unique VLANs ID and a list of associated IP addresses for the host.
|
|
2895
|
+
|
|
2896
|
+
IP addresses without a VLAN are assigned to VLAN 0.
|
|
2897
|
+
|
|
2898
|
+
Note that this method will call self.networks() to determine the networks associated with
|
|
2899
|
+
the IP addresses. If you wish to report more details about what networks the IP addresses
|
|
2900
|
+
(ie, beyond simply the VLAN ID), use self.networks() and parse the VLAN from the network
|
|
2901
|
+
manually.
|
|
2902
|
+
|
|
2903
|
+
:returns: A dictionary of VLAN ID and the associated IP addresses.
|
|
2904
|
+
"""
|
|
2905
|
+
ret_dict: dict[int, list[IPAddress]] = {}
|
|
2906
|
+
|
|
2907
|
+
for network, ips in self.networks().items():
|
|
2908
|
+
vlan = network.vlan or 0
|
|
2909
|
+
if vlan not in ret_dict:
|
|
2910
|
+
ret_dict[vlan] = []
|
|
2911
|
+
|
|
2912
|
+
ret_dict[vlan].extend(ips)
|
|
2913
|
+
|
|
2914
|
+
return ret_dict
|
|
2915
|
+
|
|
2916
|
+
def resolve_zone(
|
|
2917
|
+
self, accept_delegation: bool = False, validate_zone_resolution: bool = False
|
|
2918
|
+
) -> Zone | Delegation | None:
|
|
2919
|
+
"""Return the zone for the host.
|
|
2920
|
+
|
|
2921
|
+
:param accept_delegation: If True, accept delegation and return a Delegation object if the
|
|
2922
|
+
zone of the host is delegated. Otherwise raise EntityOwnershipMismatch.
|
|
2923
|
+
:param validate_zone_resolution: If True, validate that the resolved zone matches the
|
|
2924
|
+
expected zone ID. Fail with ValidationFailure if it does not.
|
|
2925
|
+
"""
|
|
2926
|
+
if not self.zone:
|
|
2927
|
+
return None
|
|
2928
|
+
|
|
2929
|
+
data = get(Endpoint.ForwardZoneForHost.with_id(str(self.name)))
|
|
2930
|
+
data_as_dict = data.json()
|
|
2931
|
+
|
|
2932
|
+
if data_as_dict["zone"]:
|
|
2933
|
+
zone = ForwardZone.model_validate(data_as_dict["zone"])
|
|
2934
|
+
if validate_zone_resolution and zone.id != self.zone:
|
|
2935
|
+
raise ValidationError(f"Expected zone ID {self.zone} but resolved as {zone.id}.")
|
|
2936
|
+
return zone
|
|
2937
|
+
|
|
2938
|
+
if data_as_dict["delegation"]:
|
|
2939
|
+
if not accept_delegation:
|
|
2940
|
+
raise EntityOwnershipMismatch(
|
|
2941
|
+
f"Host {self.name} is delegated to zone {data_as_dict['delegation']['name']}."
|
|
2942
|
+
)
|
|
2943
|
+
return ForwardZoneDelegation.model_validate(data_as_dict["delegation"])
|
|
2944
|
+
|
|
2945
|
+
raise EntityNotFound(f"Failed to resolve zone for host {self.name}.")
|
|
2946
|
+
|
|
2947
|
+
# This would be greatly improved by having a proper error returned to avoid the need for
|
|
2948
|
+
# manually calling networks() or vlans() to determine the issue. One option is to use
|
|
2949
|
+
# a custom exception, or to return a tuple of (bool, str) where the str is the error message.
|
|
2950
|
+
def all_ips_on_same_vlan(self) -> bool:
|
|
2951
|
+
"""Return True if all IP addresses are on the same VLAN.
|
|
2952
|
+
|
|
2953
|
+
- If there are no IP addresses, return True.
|
|
2954
|
+
- If there is only one IP address, return True.
|
|
2955
|
+
- If there are multiple IP addresses and they are all on the same VLAN, return True.
|
|
2956
|
+
|
|
2957
|
+
Note that this method will call self.vlans() to determine if all IP addresses are on the
|
|
2958
|
+
same VLAN, which in turn calls self.networks() to determine the networks associated with
|
|
2959
|
+
the IP addresses.
|
|
2960
|
+
|
|
2961
|
+
If you wish to report more details about what VLANs the IP addresses are on, use
|
|
2962
|
+
self.vlans() or self.networks().
|
|
2963
|
+
|
|
2964
|
+
:returns: True if all IP addresses are on the same VLAN, False otherwise.
|
|
2965
|
+
"""
|
|
2966
|
+
vlans = self.vlans()
|
|
2967
|
+
if not vlans:
|
|
2968
|
+
return True
|
|
2969
|
+
|
|
2970
|
+
if len(vlans) == 1:
|
|
2971
|
+
return True
|
|
2972
|
+
|
|
2973
|
+
return False
|
|
2974
|
+
|
|
2975
|
+
def naptrs(self) -> list[NAPTR]:
|
|
2976
|
+
"""Return a list of NAPTR records."""
|
|
2977
|
+
return NAPTR.get_list_by_field("host", self.id)
|
|
2978
|
+
|
|
2979
|
+
def srvs(self) -> list[Srv]:
|
|
2980
|
+
"""Return a list of SRV records."""
|
|
2981
|
+
return Srv.get_list_by_field("host", self.id)
|
|
2982
|
+
|
|
2983
|
+
def sshfps(self) -> list[SSHFP]:
|
|
2984
|
+
"""Return a list of SSHFP records."""
|
|
2985
|
+
return SSHFP.get_list_by_field("host", self.id)
|
|
2986
|
+
|
|
2987
|
+
def roles(self) -> list[Role]:
|
|
2988
|
+
"""List all roles for the host."""
|
|
2989
|
+
return Role.get_list_by_field("hosts", self.id)
|
|
2990
|
+
|
|
2991
|
+
def bacnet(self) -> BacnetID | None:
|
|
2992
|
+
"""Return the BacnetID for the host."""
|
|
2993
|
+
if not self.bacnetid:
|
|
2994
|
+
return None
|
|
2995
|
+
|
|
2996
|
+
return BacnetID.get_by_id(self.bacnetid)
|
|
2997
|
+
|
|
2998
|
+
def has_mx_with_priority(self, mx_arg: str, priority: int) -> MX | None:
|
|
2999
|
+
"""Check if the host has an MX record.
|
|
3000
|
+
|
|
3001
|
+
:param mx: The MX record to check for.
|
|
3002
|
+
:param priority: The priority of the MX record.
|
|
3003
|
+
|
|
3004
|
+
:returns: True if the host has the MX record, False otherwise.
|
|
3005
|
+
"""
|
|
3006
|
+
return next((mx for mx in self.mxs if mx.has_mx_with_priority(mx_arg, priority)), None)
|
|
3007
|
+
|
|
3008
|
+
def hostgroups(self, traverse: bool = False) -> list[HostGroup]:
|
|
3009
|
+
"""Return all hostgroups for the host.
|
|
3010
|
+
|
|
3011
|
+
:param traverse: If True, traverse the parent groups and include them in the list.
|
|
3012
|
+
|
|
3013
|
+
:returns: A list of HostGroup objects sorted by name.
|
|
3014
|
+
"""
|
|
3015
|
+
groups: list[HostGroup] = []
|
|
3016
|
+
direct = HostGroup.get_list_by_field("hosts", self.id)
|
|
3017
|
+
groups.extend(direct)
|
|
3018
|
+
|
|
3019
|
+
if traverse:
|
|
3020
|
+
for group in direct:
|
|
3021
|
+
groups.extend(group.get_all_parents())
|
|
3022
|
+
|
|
3023
|
+
return sorted(groups, key=lambda group: group.name)
|
|
3024
|
+
|
|
3025
|
+
def output(self, names: bool = False, traverse_hostgroups: bool = False):
|
|
3026
|
+
"""Output host information to the console with padding."""
|
|
3027
|
+
padding = 14
|
|
3028
|
+
|
|
3029
|
+
output_manager = OutputManager()
|
|
3030
|
+
output_manager.add_line(f"{'Name:':<{padding}}{self.name}")
|
|
3031
|
+
output_manager.add_line(f"{'Contact:':<{padding}}{self.contact}")
|
|
3032
|
+
|
|
3033
|
+
if self.comment:
|
|
3034
|
+
output_manager.add_line(f"{'Comment:':<{padding}}{self.comment}")
|
|
3035
|
+
|
|
3036
|
+
self.output_ipaddresses(padding=padding, names=names)
|
|
3037
|
+
PTR_override.output_multiple(self.ptr_overrides, padding=padding)
|
|
3038
|
+
|
|
3039
|
+
self.output_ttl(padding=padding)
|
|
3040
|
+
|
|
3041
|
+
MX.output_multiple(self.mxs, padding=padding)
|
|
3042
|
+
|
|
3043
|
+
if self.hinfo:
|
|
3044
|
+
self.hinfo.output(padding=padding)
|
|
3045
|
+
|
|
3046
|
+
if self.loc:
|
|
3047
|
+
self.loc.output(padding=padding)
|
|
3048
|
+
|
|
3049
|
+
self.output_cnames(padding=padding)
|
|
3050
|
+
|
|
3051
|
+
TXT.output_multiple(self.txts, padding=padding)
|
|
3052
|
+
Srv.output_multiple(self.srvs(), padding=padding)
|
|
3053
|
+
NAPTR.output_multiple(self.naptrs(), padding=padding)
|
|
3054
|
+
SSHFP.output_multiple(self.sshfps(), padding=padding)
|
|
3055
|
+
|
|
3056
|
+
if self.bacnetid is not None: # This may be zero.
|
|
3057
|
+
output_manager.add_line(f"{'Bacnet ID:':<{padding}}{self.bacnetid}")
|
|
3058
|
+
|
|
3059
|
+
Role.output_multiple(self.roles(), padding=padding)
|
|
3060
|
+
HostGroup.output_multiple(self.hostgroups(traverse=traverse_hostgroups), padding=padding)
|
|
3061
|
+
|
|
3062
|
+
self.output_timestamps()
|
|
3063
|
+
|
|
3064
|
+
def output_ipaddresses(
|
|
3065
|
+
self, padding: int = 14, names: bool = False, only: IP_Version | None = None
|
|
3066
|
+
):
|
|
3067
|
+
"""Output the IP addresses for the host."""
|
|
3068
|
+
if not self.ipaddresses:
|
|
3069
|
+
return
|
|
3070
|
+
|
|
3071
|
+
if only and only == 4:
|
|
3072
|
+
IPAddress.output_multiple(self.ipv4_addresses(), padding=padding, names=names)
|
|
3073
|
+
elif only and only == 6:
|
|
3074
|
+
IPAddress.output_multiple(self.ipv6_addresses(), padding=padding, names=names)
|
|
3075
|
+
else:
|
|
3076
|
+
IPAddress.output_multiple(self.ipaddresses, padding=padding, names=names)
|
|
3077
|
+
|
|
3078
|
+
def output_cnames(self, padding: int = 14):
|
|
3079
|
+
"""Output the CNAME records for the host."""
|
|
3080
|
+
if not self.cnames:
|
|
3081
|
+
return
|
|
3082
|
+
|
|
3083
|
+
CNAME.output_multiple(self.cnames, padding=padding)
|
|
3084
|
+
|
|
3085
|
+
def output_roles(self, _padding: int = 14) -> None:
|
|
3086
|
+
"""Output the roles for the host."""
|
|
3087
|
+
roles = self.roles()
|
|
3088
|
+
manager = OutputManager()
|
|
3089
|
+
if not roles:
|
|
3090
|
+
manager.add_line(f"Host {self.name!r} has no roles")
|
|
3091
|
+
else:
|
|
3092
|
+
manager.add_line(f"Roles for {self.name!r}:")
|
|
3093
|
+
for role in roles:
|
|
3094
|
+
manager.add_line(f" {role.name}")
|
|
3095
|
+
|
|
3096
|
+
def __str__(self) -> str:
|
|
3097
|
+
"""Return the host name as a string."""
|
|
3098
|
+
return self.name.hostname
|
|
3099
|
+
|
|
3100
|
+
def __hash__(self):
|
|
3101
|
+
"""Return a hash of the host."""
|
|
3102
|
+
return hash((self.id, self.name))
|
|
3103
|
+
|
|
3104
|
+
|
|
3105
|
+
class HostList(FrozenModel):
|
|
3106
|
+
"""Model for a list of hosts.
|
|
3107
|
+
|
|
3108
|
+
This is the endpoint at /api/v1/hosts/.
|
|
3109
|
+
"""
|
|
3110
|
+
|
|
3111
|
+
results: list[Host]
|
|
3112
|
+
|
|
3113
|
+
@classmethod
|
|
3114
|
+
def endpoint(cls) -> Endpoint:
|
|
3115
|
+
"""Return the endpoint for the class."""
|
|
3116
|
+
return Endpoint.Hosts
|
|
3117
|
+
|
|
3118
|
+
@classmethod
|
|
3119
|
+
def get(cls, params: QueryParams | None = None) -> HostList:
|
|
3120
|
+
"""Get a list of hosts.
|
|
3121
|
+
|
|
3122
|
+
:param params: Optional parameters to pass to the API.
|
|
3123
|
+
|
|
3124
|
+
:returns: A HostList object.
|
|
3125
|
+
"""
|
|
3126
|
+
if params is None:
|
|
3127
|
+
params = {}
|
|
3128
|
+
|
|
3129
|
+
if "ordering" not in params:
|
|
3130
|
+
params["ordering"] = "name"
|
|
3131
|
+
|
|
3132
|
+
hosts = get_typed(cls.endpoint(), list[Host], params=params)
|
|
3133
|
+
return cls(results=hosts)
|
|
3134
|
+
|
|
3135
|
+
@classmethod
|
|
3136
|
+
def get_by_ip(cls, ip: IP_AddressT) -> HostList:
|
|
3137
|
+
"""Get a list of hosts by IP address.
|
|
3138
|
+
|
|
3139
|
+
:param ip: The IP address to search for.
|
|
3140
|
+
|
|
3141
|
+
:returns: A HostList object.
|
|
3142
|
+
"""
|
|
3143
|
+
return cls.get(params={"ipaddresses__ipaddress": str(ip), "ordering": "name"})
|
|
3144
|
+
|
|
3145
|
+
def __len__(self):
|
|
3146
|
+
"""Return the number of results."""
|
|
3147
|
+
return len(self.results)
|
|
3148
|
+
|
|
3149
|
+
def __getitem__(self, key: int) -> Host:
|
|
3150
|
+
"""Get a result by index."""
|
|
3151
|
+
return self.results[key]
|
|
3152
|
+
|
|
3153
|
+
def __str__(self):
|
|
3154
|
+
"""Return a string representation of the results."""
|
|
3155
|
+
return str(self.results)
|
|
3156
|
+
|
|
3157
|
+
def __repr__(self):
|
|
3158
|
+
"""Return a string representation of the results."""
|
|
3159
|
+
return repr(self.results)
|
|
3160
|
+
|
|
3161
|
+
def hostnames(self) -> list[str]:
|
|
3162
|
+
"""Return a list of hostnames."""
|
|
3163
|
+
return [host.name.hostname for host in self.results]
|
|
3164
|
+
|
|
3165
|
+
def count(self):
|
|
3166
|
+
"""Return the number of results."""
|
|
3167
|
+
return len(self.results)
|
|
3168
|
+
|
|
3169
|
+
def output(self):
|
|
3170
|
+
"""Output a list of hosts to the console."""
|
|
3171
|
+
if not self.results:
|
|
3172
|
+
raise EntityNotFound("No hosts found.")
|
|
3173
|
+
|
|
3174
|
+
max_name = max_contact = 20
|
|
3175
|
+
for i in self.results:
|
|
3176
|
+
max_name = max(max_name, len(str(i.name)))
|
|
3177
|
+
max_contact = max(max_contact, len(i.contact))
|
|
3178
|
+
|
|
3179
|
+
def _format(name: str, contact: str, comment: str) -> None:
|
|
3180
|
+
OutputManager().add_line(
|
|
3181
|
+
"{0:<{1}} {2:<{3}} {4}".format(name, max_name, contact, max_contact, comment)
|
|
3182
|
+
)
|
|
3183
|
+
|
|
3184
|
+
_format("Name", "Contact", "Comment")
|
|
3185
|
+
for i in self.results:
|
|
3186
|
+
_format(str(i.name), i.contact, i.comment)
|
|
3187
|
+
|
|
3188
|
+
|
|
3189
|
+
class HostGroup(FrozenModelWithTimestamps, WithName, WithHistory, APIMixin):
|
|
3190
|
+
"""Model for a hostgroup."""
|
|
3191
|
+
|
|
3192
|
+
id: int # noqa: A003
|
|
3193
|
+
name: str
|
|
3194
|
+
description: str | None = None
|
|
3195
|
+
parent: NameList
|
|
3196
|
+
groups: NameList
|
|
3197
|
+
hosts: NameList
|
|
3198
|
+
owners: NameList
|
|
3199
|
+
|
|
3200
|
+
history_resource: ClassVar[HistoryResource] = HistoryResource.Group
|
|
3201
|
+
|
|
3202
|
+
@classmethod
|
|
3203
|
+
def endpoint(cls) -> Endpoint:
|
|
3204
|
+
"""Return the endpoint for the class."""
|
|
3205
|
+
return Endpoint.HostGroups
|
|
3206
|
+
|
|
3207
|
+
@classmethod
|
|
3208
|
+
def output_multiple(
|
|
3209
|
+
cls, hostgroups: list[HostGroup], padding: int = 14, multiline: bool = False
|
|
3210
|
+
) -> None:
|
|
3211
|
+
"""Output multiple hostgroups to the console.
|
|
3212
|
+
|
|
3213
|
+
:param hostgroups: List of HostGroup records to output.
|
|
3214
|
+
:param multiline: If True, output each group on a new line.
|
|
3215
|
+
:param padding: Number of spaces for left-padding the output.
|
|
3216
|
+
"""
|
|
3217
|
+
manager = OutputManager()
|
|
3218
|
+
if not hostgroups:
|
|
3219
|
+
return
|
|
3220
|
+
|
|
3221
|
+
if multiline:
|
|
3222
|
+
manager.add_line("Groups:")
|
|
3223
|
+
for group in hostgroups:
|
|
3224
|
+
manager.add_line(f" {group.name}")
|
|
3225
|
+
else:
|
|
3226
|
+
groups = ", ".join(sorted([group.name for group in hostgroups]))
|
|
3227
|
+
manager.add_line("{1:<{0}}{2}".format(padding, "Groups:", groups))
|
|
3228
|
+
|
|
3229
|
+
def set_description(self, description: str) -> Self:
|
|
3230
|
+
"""Set the description for the hostgroup.
|
|
3231
|
+
|
|
3232
|
+
:param description: The description to set.
|
|
3233
|
+
|
|
3234
|
+
:returns: A new HostGroup object fetched from the API with the updated description.
|
|
3235
|
+
"""
|
|
3236
|
+
return self.patch(fields={"description": description})
|
|
3237
|
+
|
|
3238
|
+
def has_group(self, groupname: str) -> bool:
|
|
3239
|
+
"""Check if the hostgroup has the given group.
|
|
3240
|
+
|
|
3241
|
+
:param groupname: The group to check for.
|
|
3242
|
+
|
|
3243
|
+
:returns: True if the hostgroup has the group, False otherwise.
|
|
3244
|
+
"""
|
|
3245
|
+
return groupname in self.groups
|
|
3246
|
+
|
|
3247
|
+
def add_group(self, groupname: str) -> Self:
|
|
3248
|
+
"""Add a group to the hostgroup.
|
|
3249
|
+
|
|
3250
|
+
:param group: The group to add.
|
|
3251
|
+
|
|
3252
|
+
:returns: A new HostGroup object fetched from the API with the updated groups.
|
|
3253
|
+
"""
|
|
3254
|
+
resp = post(Endpoint.HostGroupsAddHostGroups.with_params(self.name), name=groupname)
|
|
3255
|
+
if resp and resp.ok:
|
|
3256
|
+
return self.refetch()
|
|
3257
|
+
else:
|
|
3258
|
+
raise CreateError(f"Failed to add group {groupname} to hostgroup {self.name}.")
|
|
3259
|
+
|
|
3260
|
+
def remove_group(self, groupname: str) -> Self:
|
|
3261
|
+
"""Remove a group from the hostgroup.
|
|
3262
|
+
|
|
3263
|
+
:param group: The group to remove.
|
|
3264
|
+
|
|
3265
|
+
:returns: A new HostGroup object fetched from the API with the updated groups.
|
|
3266
|
+
"""
|
|
3267
|
+
resp = delete(Endpoint.HostGroupsRemoveHostGroups.with_params(self.name, groupname))
|
|
3268
|
+
if resp and resp.ok:
|
|
3269
|
+
return self.refetch()
|
|
3270
|
+
else:
|
|
3271
|
+
raise DeleteError(f"Failed to remove group {groupname} from hostgroup {self.name}.")
|
|
3272
|
+
|
|
3273
|
+
def has_host(self, hostname: str) -> bool:
|
|
3274
|
+
"""Check if the hostgroup has the given host.
|
|
3275
|
+
|
|
3276
|
+
:param hostname: The host to check for.
|
|
3277
|
+
|
|
3278
|
+
:returns: True if the hostgroup has the host, False otherwise.
|
|
3279
|
+
"""
|
|
3280
|
+
return hostname in self.hosts
|
|
3281
|
+
|
|
3282
|
+
def add_host(self, hostname: str) -> Self:
|
|
3283
|
+
"""Add a host to the hostgroup.
|
|
3284
|
+
|
|
3285
|
+
:param hostname: The host to add.
|
|
3286
|
+
|
|
3287
|
+
:returns: A new HostGroup object fetched from the API with the updated hosts.
|
|
3288
|
+
"""
|
|
3289
|
+
resp = post(Endpoint.HostGroupsAddHosts.with_params(self.name), name=hostname)
|
|
3290
|
+
if resp and resp.ok:
|
|
3291
|
+
return self.refetch()
|
|
3292
|
+
else:
|
|
3293
|
+
raise CreateError(f"Failed to add host {hostname} to hostgroup {self.name}.")
|
|
3294
|
+
|
|
3295
|
+
def remove_host(self, hostname: str) -> Self:
|
|
3296
|
+
"""Remove a host from the hostgroup.
|
|
3297
|
+
|
|
3298
|
+
:param hostname: The host to remove.
|
|
3299
|
+
|
|
3300
|
+
:returns: A new HostGroup object fetched from the API with the updated hosts.
|
|
3301
|
+
"""
|
|
3302
|
+
resp = delete(Endpoint.HostGroupsRemoveHosts.with_params(self.name, hostname))
|
|
3303
|
+
if resp and resp.ok:
|
|
3304
|
+
return self.refetch()
|
|
3305
|
+
else:
|
|
3306
|
+
raise DeleteError(f"Failed to remove host {hostname} from hostgroup {self.name}.")
|
|
3307
|
+
|
|
3308
|
+
def has_owner(self, ownername: str) -> bool:
|
|
3309
|
+
"""Check if the hostgroup has the given owner.
|
|
3310
|
+
|
|
3311
|
+
:param ownername: The owner to check for.
|
|
3312
|
+
|
|
3313
|
+
:returns: True if the hostgroup has the owner, False otherwise.
|
|
3314
|
+
"""
|
|
3315
|
+
return ownername in self.owners
|
|
3316
|
+
|
|
3317
|
+
def add_owner(self, ownername: str) -> Self:
|
|
3318
|
+
"""Add an owner to the hostgroup.
|
|
3319
|
+
|
|
3320
|
+
:param ownername: The owner to add.
|
|
3321
|
+
|
|
3322
|
+
:returns: A new HostGroup object fetched from the API with the updated owners.
|
|
3323
|
+
"""
|
|
3324
|
+
resp = post(Endpoint.HostGroupsAddOwner.with_params(self.name), name=ownername)
|
|
3325
|
+
if resp and resp.ok:
|
|
3326
|
+
return self.refetch()
|
|
3327
|
+
else:
|
|
3328
|
+
raise CreateError(f"Failed to add owner {ownername} to hostgroup {self.name}.")
|
|
3329
|
+
|
|
3330
|
+
def remove_owner(self, ownername: str) -> Self:
|
|
3331
|
+
"""Remove an owner from the hostgroup.
|
|
3332
|
+
|
|
3333
|
+
:param ownername: The owner to remove.
|
|
3334
|
+
|
|
3335
|
+
:returns: A new HostGroup object fetched from the API with the updated owners.
|
|
3336
|
+
"""
|
|
3337
|
+
resp = delete(Endpoint.HostGroupsRemoveOwner.with_params(self.name, ownername))
|
|
3338
|
+
if resp and resp.ok:
|
|
3339
|
+
return self.refetch()
|
|
3340
|
+
else:
|
|
3341
|
+
raise DeleteError(f"Failed to remove owner {ownername} from hostgroup {self.name}.")
|
|
3342
|
+
|
|
3343
|
+
def get_all_parents(self) -> list[HostGroup]:
|
|
3344
|
+
"""Return a list of all parent groups."""
|
|
3345
|
+
parents: list[HostGroup] = []
|
|
3346
|
+
for parent in self.parent:
|
|
3347
|
+
pobj = HostGroup.get_by_field("name", parent)
|
|
3348
|
+
if pobj:
|
|
3349
|
+
parents.append(pobj)
|
|
3350
|
+
parents.extend(pobj.get_all_parents())
|
|
3351
|
+
|
|
3352
|
+
return parents
|
|
3353
|
+
|
|
3354
|
+
def output(self, padding: int = 14) -> None:
|
|
3355
|
+
"""Output the hostgroup to the console.
|
|
3356
|
+
|
|
3357
|
+
:param padding: Number of spaces for left-padding the output.
|
|
3358
|
+
"""
|
|
3359
|
+
outputmanager = OutputManager()
|
|
3360
|
+
|
|
3361
|
+
parents = self.parent
|
|
3362
|
+
inherited: list[str] = []
|
|
3363
|
+
|
|
3364
|
+
for p in self.get_all_parents():
|
|
3365
|
+
if p.name not in parents:
|
|
3366
|
+
inherited.append(p.name)
|
|
3367
|
+
|
|
3368
|
+
parentlist = ", ".join(parents)
|
|
3369
|
+
if inherited:
|
|
3370
|
+
parentlist += f" (Inherits: {', '.join(inherited)})"
|
|
3371
|
+
|
|
3372
|
+
output_tuples = (
|
|
3373
|
+
("Name:", self.name),
|
|
3374
|
+
("Description:", self.description or ""),
|
|
3375
|
+
("Owners:", ", ".join(self.owners if self.owners else [])),
|
|
3376
|
+
("Parents:", parentlist),
|
|
3377
|
+
("Groups:", ", ".join(self.groups if self.groups else [])),
|
|
3378
|
+
("Hosts:", len(self.hosts)),
|
|
3379
|
+
)
|
|
3380
|
+
for key, value in output_tuples:
|
|
3381
|
+
outputmanager.add_line(f"{key:<{padding}}{value}")
|
|
3382
|
+
|
|
3383
|
+
self.output_timestamps()
|
|
3384
|
+
|
|
3385
|
+
def output_members(self, expand: bool = False) -> None:
|
|
3386
|
+
"""Output the members of the hostgroup to the console.
|
|
3387
|
+
|
|
3388
|
+
:param expand: If True, expand the members to include all hosts in all parent groups.
|
|
3389
|
+
"""
|
|
3390
|
+
if expand:
|
|
3391
|
+
self._output_members_expanded()
|
|
3392
|
+
else:
|
|
3393
|
+
self._output_members()
|
|
3394
|
+
|
|
3395
|
+
def _output_members(self) -> None:
|
|
3396
|
+
"""Output the members of the hostgroup to the console, not expanded."""
|
|
3397
|
+
manager = OutputManager()
|
|
3398
|
+
manager.add_formatted_line("Type", "Name")
|
|
3399
|
+
|
|
3400
|
+
for group in self.groups:
|
|
3401
|
+
manager.add_formatted_line("group", group)
|
|
3402
|
+
|
|
3403
|
+
for host in self.hosts:
|
|
3404
|
+
manager.add_formatted_line("host", host)
|
|
3405
|
+
|
|
3406
|
+
def _output_members_expanded(self):
|
|
3407
|
+
"""Output the members of the hostgroup to the console, expanded."""
|
|
3408
|
+
manager = OutputManager()
|
|
3409
|
+
manager.add_formatted_line_with_source("Type", "Name", "Source")
|
|
3410
|
+
|
|
3411
|
+
for parent in self.get_all_parents():
|
|
3412
|
+
for host in parent.hosts:
|
|
3413
|
+
manager.add_formatted_line_with_source("host", host, parent.name)
|
|
3414
|
+
|
|
3415
|
+
for host in self.hosts:
|
|
3416
|
+
manager.add_formatted_line_with_source("host", host, self.name)
|