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.
Files changed (53) hide show
  1. mreg_cli/__about__.py +7 -0
  2. mreg_cli/__init__.py +5 -0
  3. mreg_cli/__main__.py +8 -0
  4. mreg_cli/_version.py +16 -0
  5. mreg_cli/api/__init__.py +7 -0
  6. mreg_cli/api/abstracts.py +486 -0
  7. mreg_cli/api/endpoints.py +130 -0
  8. mreg_cli/api/fields.py +104 -0
  9. mreg_cli/api/history.py +152 -0
  10. mreg_cli/api/models.py +3416 -0
  11. mreg_cli/cli.py +382 -0
  12. mreg_cli/commands/__init__.py +1 -0
  13. mreg_cli/commands/base.py +62 -0
  14. mreg_cli/commands/dhcp.py +136 -0
  15. mreg_cli/commands/group.py +326 -0
  16. mreg_cli/commands/help.py +77 -0
  17. mreg_cli/commands/host.py +54 -0
  18. mreg_cli/commands/host_submodules/__init__.py +28 -0
  19. mreg_cli/commands/host_submodules/a_aaaa.py +452 -0
  20. mreg_cli/commands/host_submodules/bacnet.py +126 -0
  21. mreg_cli/commands/host_submodules/cname.py +166 -0
  22. mreg_cli/commands/host_submodules/core.py +507 -0
  23. mreg_cli/commands/host_submodules/rr.py +973 -0
  24. mreg_cli/commands/label.py +146 -0
  25. mreg_cli/commands/logging.py +112 -0
  26. mreg_cli/commands/network.py +516 -0
  27. mreg_cli/commands/permission.py +202 -0
  28. mreg_cli/commands/policy.py +519 -0
  29. mreg_cli/commands/recording.py +59 -0
  30. mreg_cli/commands/registry.py +56 -0
  31. mreg_cli/commands/root.py +58 -0
  32. mreg_cli/commands/zone.py +288 -0
  33. mreg_cli/config.py +253 -0
  34. mreg_cli/errorbuilder.py +193 -0
  35. mreg_cli/exceptions.py +237 -0
  36. mreg_cli/help_formatter.py +38 -0
  37. mreg_cli/main.py +238 -0
  38. mreg_cli/outputmanager.py +466 -0
  39. mreg_cli/py.typed +0 -0
  40. mreg_cli/tags.txt +55 -0
  41. mreg_cli/tokenfile.py +89 -0
  42. mreg_cli/types.py +160 -0
  43. mreg_cli/utilities/__init__.py +5 -0
  44. mreg_cli/utilities/api.py +595 -0
  45. mreg_cli/utilities/shared.py +65 -0
  46. mreg_cli/utilities/validators.py +19 -0
  47. mreg_cli-1.0.0.dist-info/AUTHORS +12 -0
  48. mreg_cli-1.0.0.dist-info/LICENSE +674 -0
  49. mreg_cli-1.0.0.dist-info/METADATA +1079 -0
  50. mreg_cli-1.0.0.dist-info/RECORD +53 -0
  51. mreg_cli-1.0.0.dist-info/WHEEL +5 -0
  52. mreg_cli-1.0.0.dist-info/entry_points.txt +2 -0
  53. 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)