mreg-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mreg_cli/__about__.py +7 -0
- mreg_cli/__init__.py +5 -0
- mreg_cli/__main__.py +8 -0
- mreg_cli/_version.py +16 -0
- mreg_cli/api/__init__.py +7 -0
- mreg_cli/api/abstracts.py +486 -0
- mreg_cli/api/endpoints.py +130 -0
- mreg_cli/api/fields.py +104 -0
- mreg_cli/api/history.py +152 -0
- mreg_cli/api/models.py +3416 -0
- mreg_cli/cli.py +382 -0
- mreg_cli/commands/__init__.py +1 -0
- mreg_cli/commands/base.py +62 -0
- mreg_cli/commands/dhcp.py +136 -0
- mreg_cli/commands/group.py +326 -0
- mreg_cli/commands/help.py +77 -0
- mreg_cli/commands/host.py +54 -0
- mreg_cli/commands/host_submodules/__init__.py +28 -0
- mreg_cli/commands/host_submodules/a_aaaa.py +452 -0
- mreg_cli/commands/host_submodules/bacnet.py +126 -0
- mreg_cli/commands/host_submodules/cname.py +166 -0
- mreg_cli/commands/host_submodules/core.py +507 -0
- mreg_cli/commands/host_submodules/rr.py +973 -0
- mreg_cli/commands/label.py +146 -0
- mreg_cli/commands/logging.py +112 -0
- mreg_cli/commands/network.py +516 -0
- mreg_cli/commands/permission.py +202 -0
- mreg_cli/commands/policy.py +519 -0
- mreg_cli/commands/recording.py +59 -0
- mreg_cli/commands/registry.py +56 -0
- mreg_cli/commands/root.py +58 -0
- mreg_cli/commands/zone.py +288 -0
- mreg_cli/config.py +253 -0
- mreg_cli/errorbuilder.py +193 -0
- mreg_cli/exceptions.py +237 -0
- mreg_cli/help_formatter.py +38 -0
- mreg_cli/main.py +238 -0
- mreg_cli/outputmanager.py +466 -0
- mreg_cli/py.typed +0 -0
- mreg_cli/tags.txt +55 -0
- mreg_cli/tokenfile.py +89 -0
- mreg_cli/types.py +160 -0
- mreg_cli/utilities/__init__.py +5 -0
- mreg_cli/utilities/api.py +595 -0
- mreg_cli/utilities/shared.py +65 -0
- mreg_cli/utilities/validators.py +19 -0
- mreg_cli-1.0.0.dist-info/AUTHORS +12 -0
- mreg_cli-1.0.0.dist-info/LICENSE +674 -0
- mreg_cli-1.0.0.dist-info/METADATA +1079 -0
- mreg_cli-1.0.0.dist-info/RECORD +53 -0
- mreg_cli-1.0.0.dist-info/WHEEL +5 -0
- mreg_cli-1.0.0.dist-info/entry_points.txt +2 -0
- mreg_cli-1.0.0.dist-info/top_level.txt +1 -0
mreg_cli/__about__.py
ADDED
mreg_cli/__init__.py
ADDED
mreg_cli/__main__.py
ADDED
mreg_cli/_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '1.0.0'
|
|
16
|
+
__version_tuple__ = version_tuple = (1, 0, 0)
|
mreg_cli/api/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""API glue code for the mreg_cli package.
|
|
2
|
+
|
|
3
|
+
Originally the API code took whatever JSON data it received and returned it as a dictionary.
|
|
4
|
+
This led to horrible code that was hard to maintain and debug. This module is an attempt to
|
|
5
|
+
fix that by using pydantic models to validate incoming data so the client code has
|
|
6
|
+
guarantees about the data it is working with.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Abstract models for the API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Callable, Self, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
11
|
+
|
|
12
|
+
from mreg_cli.api.endpoints import Endpoint
|
|
13
|
+
from mreg_cli.exceptions import (
|
|
14
|
+
CreateError,
|
|
15
|
+
EntityAlreadyExists,
|
|
16
|
+
EntityNotFound,
|
|
17
|
+
GetError,
|
|
18
|
+
InternalError,
|
|
19
|
+
PatchError,
|
|
20
|
+
)
|
|
21
|
+
from mreg_cli.outputmanager import OutputManager
|
|
22
|
+
from mreg_cli.types import JsonMapping, QueryParams
|
|
23
|
+
from mreg_cli.utilities.api import (
|
|
24
|
+
delete,
|
|
25
|
+
get,
|
|
26
|
+
get_item_by_key_value,
|
|
27
|
+
get_list_unique,
|
|
28
|
+
get_typed,
|
|
29
|
+
patch,
|
|
30
|
+
post,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_field_aliases(field_info: FieldInfo) -> set[str]:
|
|
35
|
+
"""Get all aliases for a Pydantic field."""
|
|
36
|
+
aliases: set[str] = set()
|
|
37
|
+
|
|
38
|
+
if field_info.alias:
|
|
39
|
+
aliases.add(field_info.alias)
|
|
40
|
+
|
|
41
|
+
if field_info.validation_alias:
|
|
42
|
+
if isinstance(field_info.validation_alias, str):
|
|
43
|
+
aliases.add(field_info.validation_alias)
|
|
44
|
+
elif isinstance(field_info.validation_alias, AliasChoices):
|
|
45
|
+
for choice in field_info.validation_alias.choices:
|
|
46
|
+
if isinstance(choice, str):
|
|
47
|
+
aliases.add(choice)
|
|
48
|
+
return aliases
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_model_aliases(model: BaseModel) -> dict[str, str]:
|
|
52
|
+
"""Get a mapping of aliases to field names for a Pydantic model.
|
|
53
|
+
|
|
54
|
+
Includes field names, alias, and validation alias(es).
|
|
55
|
+
"""
|
|
56
|
+
fields: dict[str, str] = {}
|
|
57
|
+
for field_name, field_info in model.model_fields.items():
|
|
58
|
+
aliases = get_field_aliases(field_info)
|
|
59
|
+
if model.model_config.get("populate_by_name"):
|
|
60
|
+
aliases.add(field_name)
|
|
61
|
+
# Assign aliases to field name in mapping
|
|
62
|
+
for alias in aliases:
|
|
63
|
+
fields[alias] = field_name
|
|
64
|
+
return fields
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_patched_model(model: BaseModel, fields: dict[str, Any]) -> None:
|
|
68
|
+
"""Validate that model fields were patched correctly."""
|
|
69
|
+
aliases = get_model_aliases(model)
|
|
70
|
+
|
|
71
|
+
validators: dict[type, Callable[[Any, Any], bool]] = {
|
|
72
|
+
list: _validate_lists,
|
|
73
|
+
dict: _validate_dicts,
|
|
74
|
+
}
|
|
75
|
+
for key, value in fields.items():
|
|
76
|
+
field_name = key
|
|
77
|
+
if key in aliases:
|
|
78
|
+
field_name = aliases[key]
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
nval = getattr(model, field_name)
|
|
82
|
+
except AttributeError as e:
|
|
83
|
+
raise PatchError(f"Could not get value for {field_name} in patched object.") from e
|
|
84
|
+
|
|
85
|
+
# Ensure patched value is the one we tried to set
|
|
86
|
+
validator = validators.get(
|
|
87
|
+
type(nval), # type: ignore # dict.get call with unknown type (Any) is fine
|
|
88
|
+
_validate_default,
|
|
89
|
+
)
|
|
90
|
+
if not validator(nval, value):
|
|
91
|
+
raise PatchError(
|
|
92
|
+
f"Patch failure! Tried to set {key} to {value!r}, but server returned {nval!r}."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_lists(new: list[Any], old: list[Any]) -> bool:
|
|
97
|
+
"""Validate that two lists are equal."""
|
|
98
|
+
if len(new) != len(old):
|
|
99
|
+
return False
|
|
100
|
+
return all(x in old for x in new)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _validate_dicts(new: dict[str, Any], old: dict[str, Any]) -> bool:
|
|
104
|
+
"""Validate that two dictionaries are equal."""
|
|
105
|
+
if len(new) != len(old):
|
|
106
|
+
return False
|
|
107
|
+
return all(old.get(k) == v for k, v in new.items())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_default(new: Any, old: Any) -> bool:
|
|
111
|
+
"""Validate that two values are equal."""
|
|
112
|
+
return str(new) == str(old)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FrozenModel(BaseModel):
|
|
116
|
+
"""Model for an immutable object."""
|
|
117
|
+
|
|
118
|
+
def __setattr__(self, name: str, value: Any):
|
|
119
|
+
"""Raise an exception when trying to set an attribute."""
|
|
120
|
+
raise AttributeError("Cannot set attribute on a frozen object")
|
|
121
|
+
|
|
122
|
+
def __delattr__(self, name: str):
|
|
123
|
+
"""Raise an exception when trying to delete an attribute."""
|
|
124
|
+
raise AttributeError("Cannot delete attribute on a frozen object")
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(
|
|
127
|
+
# Freeze model to make it immutable and thus hashable.
|
|
128
|
+
frozen=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FrozenModelWithTimestamps(FrozenModel):
|
|
133
|
+
"""Model with created_at and updated_at fields."""
|
|
134
|
+
|
|
135
|
+
created_at: datetime
|
|
136
|
+
updated_at: datetime
|
|
137
|
+
|
|
138
|
+
def output_timestamps(self, padding: int = 14) -> None:
|
|
139
|
+
"""Output the created and updated timestamps to the console."""
|
|
140
|
+
output_manager = OutputManager()
|
|
141
|
+
output_manager.add_line(f"{'Created:':<{padding}}{self.created_at:%c}")
|
|
142
|
+
output_manager.add_line(f"{'Updated:':<{padding}}{self.updated_at:%c}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class APIMixin(ABC):
|
|
146
|
+
"""A mixin for API-related methods."""
|
|
147
|
+
|
|
148
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
149
|
+
"""Ensure that the subclass inherits from BaseModel."""
|
|
150
|
+
super().__init_subclass__(**kwargs)
|
|
151
|
+
if BaseModel not in cls.__mro__:
|
|
152
|
+
raise TypeError(
|
|
153
|
+
f"{cls.__name__} must be applied on classes inheriting from BaseModel."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def id_for_endpoint(self) -> int | str:
|
|
157
|
+
"""Return the appropriate id for the object for its endpoint.
|
|
158
|
+
|
|
159
|
+
:returns: The correct identifier for the endpoint.
|
|
160
|
+
"""
|
|
161
|
+
field = self.endpoint().external_id_field()
|
|
162
|
+
return getattr(self, field)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
@abstractmethod
|
|
166
|
+
def endpoint(cls) -> Endpoint:
|
|
167
|
+
"""Return the endpoint for the method."""
|
|
168
|
+
raise NotImplementedError("You must define an endpoint.")
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def get(cls, _id: int) -> Self | None:
|
|
172
|
+
"""Get an object.
|
|
173
|
+
|
|
174
|
+
This function is at its base a wrapper around the get_by_id function,
|
|
175
|
+
but it can be overridden to provide more specific functionality.
|
|
176
|
+
|
|
177
|
+
:param _id: The ID of the object.
|
|
178
|
+
:returns: The object if found, None otherwise.
|
|
179
|
+
"""
|
|
180
|
+
return cls.get_by_id(_id)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def get_by_id(cls, _id: int) -> Self | None:
|
|
184
|
+
"""Get an object by its ID.
|
|
185
|
+
|
|
186
|
+
Note that for Hosts, the ID is the name of the host.
|
|
187
|
+
|
|
188
|
+
:param _id: The ID of the object.
|
|
189
|
+
:returns: The object if found, None otherwise.
|
|
190
|
+
"""
|
|
191
|
+
endpoint = cls.endpoint()
|
|
192
|
+
|
|
193
|
+
# Some endpoints do not use the ID field as the endpoint identifier,
|
|
194
|
+
# and in these cases we need to search for the ID... Lovely.
|
|
195
|
+
if endpoint.requires_search_for_id():
|
|
196
|
+
data = get_item_by_key_value(cls.endpoint(), "id", str(_id))
|
|
197
|
+
else:
|
|
198
|
+
data = get(cls.endpoint().with_id(_id), ok404=True)
|
|
199
|
+
if not data:
|
|
200
|
+
return None
|
|
201
|
+
data = data.json()
|
|
202
|
+
|
|
203
|
+
if not data:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
return cls(**data)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def get_by_field(cls, field: str, value: str | int) -> Self | None:
|
|
210
|
+
"""Get an object by a field.
|
|
211
|
+
|
|
212
|
+
Note that some endpoints do not use the ID field for lookups. We do some
|
|
213
|
+
magic mapping via endpoint introspection to perform the following mapping for
|
|
214
|
+
classes and their endpoint "id" fields:
|
|
215
|
+
|
|
216
|
+
- Hosts -> name
|
|
217
|
+
- Networks -> network
|
|
218
|
+
|
|
219
|
+
This implies that doing a get_by_field("name", value) on Hosts will *not*
|
|
220
|
+
result in a search, but a direct lookup at ../endpoint/name which is what
|
|
221
|
+
the mreg server expects for Hosts (and similar for Network).
|
|
222
|
+
|
|
223
|
+
:param field: The field to search by.
|
|
224
|
+
:param value: The value to search for.
|
|
225
|
+
|
|
226
|
+
:returns: The object if found, None otherwise.
|
|
227
|
+
"""
|
|
228
|
+
endpoint = cls.endpoint()
|
|
229
|
+
|
|
230
|
+
if endpoint.requires_search_for_id() and field == endpoint.external_id_field():
|
|
231
|
+
data = get(endpoint.with_id(value), ok404=True)
|
|
232
|
+
if not data:
|
|
233
|
+
return None
|
|
234
|
+
data = data.json()
|
|
235
|
+
else:
|
|
236
|
+
data = get_item_by_key_value(cls.endpoint(), field, value, ok404=True)
|
|
237
|
+
|
|
238
|
+
if not data:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
return cls(**data)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def get_by_field_or_raise(
|
|
245
|
+
cls,
|
|
246
|
+
field: str,
|
|
247
|
+
value: str,
|
|
248
|
+
exc_type: type[Exception] = EntityNotFound,
|
|
249
|
+
exc_message: str | None = None,
|
|
250
|
+
) -> Self:
|
|
251
|
+
"""Get an object by a field and raise if not found.
|
|
252
|
+
|
|
253
|
+
Used for cases where the object must exist for the operation to continue.
|
|
254
|
+
|
|
255
|
+
:param field: The field to search by.
|
|
256
|
+
:param value: The value to search for.
|
|
257
|
+
:param exc_type: The exception type to raise.
|
|
258
|
+
:param exc_message: The exception message. Overrides the default message.
|
|
259
|
+
|
|
260
|
+
:returns: The object if found.
|
|
261
|
+
"""
|
|
262
|
+
obj = cls.get_by_field(field, value)
|
|
263
|
+
if not obj:
|
|
264
|
+
if not exc_message:
|
|
265
|
+
exc_message = f"{cls.__name__} with {field} {value!r} not found."
|
|
266
|
+
raise exc_type(exc_message)
|
|
267
|
+
return obj
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def get_by_field_and_raise(
|
|
271
|
+
cls,
|
|
272
|
+
field: str,
|
|
273
|
+
value: str,
|
|
274
|
+
exc_type: type[Exception] = EntityAlreadyExists,
|
|
275
|
+
exc_message: str | None = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Get an object by a field and raise if found.
|
|
278
|
+
|
|
279
|
+
Used for cases where the object must NOT exist for the operation to continue.
|
|
280
|
+
|
|
281
|
+
:param field: The field to search by.
|
|
282
|
+
:param value: The value to search for.
|
|
283
|
+
:param exc_type: The exception type to raise.
|
|
284
|
+
:param exc_message: The exception message. Overrides the default message.
|
|
285
|
+
|
|
286
|
+
:raises Exception: If the object is found.
|
|
287
|
+
"""
|
|
288
|
+
obj = cls.get_by_field(field, value)
|
|
289
|
+
if obj:
|
|
290
|
+
if not exc_message:
|
|
291
|
+
exc_message = f"{cls.__name__} with {field} {value!r} already exists."
|
|
292
|
+
raise exc_type(exc_message)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def get_list_by_field(
|
|
297
|
+
cls, field: str, value: str | int, ordering: str | None = None, limit: int = 500
|
|
298
|
+
) -> list[Self]:
|
|
299
|
+
"""Get a list of objects by a field.
|
|
300
|
+
|
|
301
|
+
:param field: The field to search by.
|
|
302
|
+
:param value: The value to search for.
|
|
303
|
+
:param ordering: The ordering to use when fetching the list.
|
|
304
|
+
:param limit: The maximum number of hits to allow (default 500)
|
|
305
|
+
|
|
306
|
+
:returns: A list of objects if found, an empty list otherwise.
|
|
307
|
+
"""
|
|
308
|
+
params: QueryParams = {field: value}
|
|
309
|
+
if ordering:
|
|
310
|
+
params["ordering"] = ordering
|
|
311
|
+
|
|
312
|
+
return get_typed(cls.endpoint(), list[cls], params=params, limit=limit)
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def get_by_query(
|
|
316
|
+
cls, query: QueryParams, ordering: str | None = None, limit: int | None = 500
|
|
317
|
+
) -> list[Self]:
|
|
318
|
+
"""Get a list of objects by a query.
|
|
319
|
+
|
|
320
|
+
:param query: The query to search by.
|
|
321
|
+
:param ordering: The ordering to use when fetching the list.
|
|
322
|
+
:param limit: The maximum number of hits to allow (default 500)
|
|
323
|
+
|
|
324
|
+
:returns: A list of objects if found, an empty list otherwise.
|
|
325
|
+
"""
|
|
326
|
+
if ordering:
|
|
327
|
+
query["ordering"] = ordering
|
|
328
|
+
|
|
329
|
+
return get_typed(cls.endpoint(), list[cls], query, limit=limit)
|
|
330
|
+
|
|
331
|
+
@classmethod
|
|
332
|
+
def get_by_query_unique_or_raise(
|
|
333
|
+
cls,
|
|
334
|
+
query: QueryParams,
|
|
335
|
+
exc_type: type[Exception] = EntityNotFound,
|
|
336
|
+
exc_message: str | None = None,
|
|
337
|
+
) -> Self:
|
|
338
|
+
"""Get an object by a query and raise if not found.
|
|
339
|
+
|
|
340
|
+
Used for cases where the object must exist for the operation to continue.
|
|
341
|
+
|
|
342
|
+
:param query: The query to search by.
|
|
343
|
+
:param exc_type: The exception type to raise.
|
|
344
|
+
:param exc_message: The exception message. Overrides the default message.
|
|
345
|
+
|
|
346
|
+
:returns: The object if found.
|
|
347
|
+
"""
|
|
348
|
+
obj = cls.get_by_query_unique(query)
|
|
349
|
+
if not obj:
|
|
350
|
+
if not exc_message:
|
|
351
|
+
exc_message = f"{cls.__name__} with query {query} not found."
|
|
352
|
+
raise exc_type(exc_message)
|
|
353
|
+
return obj
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def get_by_query_unique_and_raise(
|
|
357
|
+
cls,
|
|
358
|
+
query: QueryParams,
|
|
359
|
+
exc_type: type[Exception] = EntityAlreadyExists,
|
|
360
|
+
exc_message: str | None = None,
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Get an object by a query and raise if found.
|
|
363
|
+
|
|
364
|
+
Used for cases where the object must NOT exist for the operation to continue.
|
|
365
|
+
|
|
366
|
+
:param query: The query to search by.
|
|
367
|
+
:param exc_type: The exception type to raise.
|
|
368
|
+
:param exc_message: The exception message. Overrides the default message.
|
|
369
|
+
|
|
370
|
+
:raises Exception: If the object is found.
|
|
371
|
+
"""
|
|
372
|
+
obj = cls.get_by_query_unique(query)
|
|
373
|
+
if obj:
|
|
374
|
+
if not exc_message:
|
|
375
|
+
exc_message = f"{cls.__name__} with query {query} already exists."
|
|
376
|
+
raise exc_type(exc_message)
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
@classmethod
|
|
380
|
+
def get_by_query_unique(cls, data: QueryParams) -> Self | None:
|
|
381
|
+
"""Get an object with the given data.
|
|
382
|
+
|
|
383
|
+
:param data: The data to search for.
|
|
384
|
+
:returns: The object if found, None otherwise.
|
|
385
|
+
"""
|
|
386
|
+
obj_dict = get_list_unique(cls.endpoint(), params=data)
|
|
387
|
+
if not obj_dict:
|
|
388
|
+
return None
|
|
389
|
+
return cls(**obj_dict)
|
|
390
|
+
|
|
391
|
+
def refetch(self) -> Self:
|
|
392
|
+
"""Fetch an updated version of the object.
|
|
393
|
+
|
|
394
|
+
Note that the caller (self) of this method will remain unchanged and can contain
|
|
395
|
+
outdated information. The returned object will be the updated version.
|
|
396
|
+
|
|
397
|
+
:returns: The fetched object.
|
|
398
|
+
"""
|
|
399
|
+
id_field = self.endpoint().external_id_field()
|
|
400
|
+
identifier = getattr(self, id_field, None)
|
|
401
|
+
if not identifier:
|
|
402
|
+
raise InternalError(
|
|
403
|
+
f"Could not get identifier for {self.__class__.__name__} via {id_field}."
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
lookup = None
|
|
407
|
+
# If we have and ID field, a refetch based on that is cleaner as a rename
|
|
408
|
+
# will change the name or whatever other insane field that are used for lookups...
|
|
409
|
+
# Let this be a lesson to you all, don't use mutable fields as identifiers. :)
|
|
410
|
+
if hasattr(self, "id"):
|
|
411
|
+
lookup = getattr(self, "id", None)
|
|
412
|
+
if not lookup:
|
|
413
|
+
raise InternalError(f"Could not get ID for {self.__class__.__name__} via 'id'.")
|
|
414
|
+
else:
|
|
415
|
+
lookup = getattr(self, identifier)
|
|
416
|
+
|
|
417
|
+
obj = self.__class__.get_by_id(lookup)
|
|
418
|
+
if not obj:
|
|
419
|
+
raise GetError(f"Could not refresh {self.__class__.__name__} with ID {identifier}.")
|
|
420
|
+
|
|
421
|
+
return obj
|
|
422
|
+
|
|
423
|
+
def patch(self, fields: dict[str, Any], validate: bool = True) -> Self:
|
|
424
|
+
"""Patch the object with the given values.
|
|
425
|
+
|
|
426
|
+
Notes
|
|
427
|
+
-----
|
|
428
|
+
1. Depending on the endpoint, the server may not return the patched object.
|
|
429
|
+
2. Patching with None may not clear the field if it isn't nullable (which few fields
|
|
430
|
+
are). Odds are you want to pass an empty string instead.
|
|
431
|
+
|
|
432
|
+
:param fields: The values to patch.
|
|
433
|
+
:param validate: Whether to validate the patched object.
|
|
434
|
+
:returns: The object refetched from the server.
|
|
435
|
+
"""
|
|
436
|
+
patch(self.endpoint().with_id(self.id_for_endpoint()), **fields)
|
|
437
|
+
new_object = self.refetch()
|
|
438
|
+
|
|
439
|
+
if validate:
|
|
440
|
+
# __init_subclass__ guarantees we inherit from BaseModel
|
|
441
|
+
# but we can't signal this to the type checker, so we cast here.
|
|
442
|
+
validate_patched_model(cast(BaseModel, new_object), fields)
|
|
443
|
+
|
|
444
|
+
return new_object
|
|
445
|
+
|
|
446
|
+
def delete(self) -> bool:
|
|
447
|
+
"""Delete the object.
|
|
448
|
+
|
|
449
|
+
:returns: True if the object was deleted, False otherwise.
|
|
450
|
+
"""
|
|
451
|
+
response = delete(self.endpoint().with_id(self.id_for_endpoint()))
|
|
452
|
+
|
|
453
|
+
if response and response.ok:
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def create(cls, params: JsonMapping, fetch_after_create: bool = True) -> Self | None:
|
|
460
|
+
"""Create the object.
|
|
461
|
+
|
|
462
|
+
Note that several endpoints do not support location headers for created objects,
|
|
463
|
+
so we can't fetch the object after creation. In these cases, we return None even
|
|
464
|
+
if the object was created successfully...
|
|
465
|
+
|
|
466
|
+
:param params: The parameters to create the object with.
|
|
467
|
+
:raises CreateError: If the object could not be created.
|
|
468
|
+
:raises GetError: If the object could not be fetched after creation.
|
|
469
|
+
:returns: The object if created and its fetchable, None otherwise.
|
|
470
|
+
"""
|
|
471
|
+
response = post(cls.endpoint(), params=None, **params)
|
|
472
|
+
|
|
473
|
+
if response and response.ok:
|
|
474
|
+
location = response.headers.get("Location")
|
|
475
|
+
if location and fetch_after_create:
|
|
476
|
+
return get_typed(location, cls)
|
|
477
|
+
# else:
|
|
478
|
+
# Lots of endpoints don't give locations on creation,
|
|
479
|
+
# so we can't fetch the object, but it's not an error...
|
|
480
|
+
# Per se.
|
|
481
|
+
# raise APIError("No location header in response.")
|
|
482
|
+
|
|
483
|
+
else:
|
|
484
|
+
raise CreateError(f"Failed to create {cls} with {params} @ {cls.endpoint()}.")
|
|
485
|
+
|
|
486
|
+
return None
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""API endpoints for mreg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Literal
|
|
7
|
+
from urllib.parse import quote, urljoin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Endpoint(str, Enum):
|
|
11
|
+
"""API endpoints."""
|
|
12
|
+
|
|
13
|
+
Hosts = "/api/v1/hosts/"
|
|
14
|
+
Ipaddresses = "/api/v1/ipaddresses/"
|
|
15
|
+
Naptrs = "/api/v1/naptrs/"
|
|
16
|
+
Srvs = "/api/v1/srvs/"
|
|
17
|
+
Hinfos = "/api/v1/hinfos/"
|
|
18
|
+
Cnames = "/api/v1/cnames/"
|
|
19
|
+
Sshfps = "/api/v1/sshfps/"
|
|
20
|
+
Zones = "/api/v1/zones/"
|
|
21
|
+
History = "/api/v1/history/"
|
|
22
|
+
Txts = "/api/v1/txts/"
|
|
23
|
+
PTR_overrides = "/api/v1/ptroverrides/"
|
|
24
|
+
Locs = "/api/v1/locs/"
|
|
25
|
+
Mxs = "/api/v1/mxs/"
|
|
26
|
+
NAPTRs = "/api/v1/naptrs/"
|
|
27
|
+
Nameservers = "/api/v1/nameservers/"
|
|
28
|
+
|
|
29
|
+
HostGroups = "/api/v1/hostgroups/"
|
|
30
|
+
HostGroupsAddHostGroups = "/api/v1/hostgroups/{}/groups/"
|
|
31
|
+
HostGroupsRemoveHostGroups = "/api/v1/hostgroups/{}/groups/{}"
|
|
32
|
+
HostGroupsAddHosts = "/api/v1/hostgroups/{}/hosts/"
|
|
33
|
+
HostGroupsRemoveHosts = "/api/v1/hostgroups/{}/hosts/{}"
|
|
34
|
+
HostGroupsAddOwner = "/api/v1/hostgroups/{}/owners/"
|
|
35
|
+
HostGroupsRemoveOwner = "/api/v1/hostgroups/{}/owners/{}"
|
|
36
|
+
|
|
37
|
+
BacnetID = "/api/v1/bacnet/ids/"
|
|
38
|
+
|
|
39
|
+
Labels = "/api/v1/labels/"
|
|
40
|
+
LabelsByName = "/api/v1/labels/name/"
|
|
41
|
+
|
|
42
|
+
Networks = "/api/v1/networks/"
|
|
43
|
+
NetworksByIP = "/api/v1/networks/ip/"
|
|
44
|
+
NetworksUsedCount = "/api/v1/networks/{}/used_count"
|
|
45
|
+
NetworksUsedList = "/api/v1/networks/{}/used_list"
|
|
46
|
+
NetworksUnusedCount = "/api/v1/networks/{}/unused_count"
|
|
47
|
+
NetworksUnusedList = "/api/v1/networks/{}/unused_list"
|
|
48
|
+
NetworksFirstUnused = "/api/v1/networks/{}/first_unused"
|
|
49
|
+
NetworksReservedList = "/api/v1/networks/{}/reserved_list"
|
|
50
|
+
NetworksUsedHostList = "/api/v1/networks/{}/used_host_list"
|
|
51
|
+
NetworksPTROverrideHostList = "/api/v1/networks/{}/ptroverride_host_list"
|
|
52
|
+
NetworksAddExcludedRanges = "/api/v1/networks/{}/excluded_ranges/"
|
|
53
|
+
NetworksRemoveExcludedRanges = "/api/v1/networks/{}/excluded_ranges/{}"
|
|
54
|
+
|
|
55
|
+
HostPolicyRoles = "/api/v1/hostpolicy/roles/"
|
|
56
|
+
HostPolicyRolesAddAtom = "/api/v1/hostpolicy/roles/{}/atoms/"
|
|
57
|
+
HostPolicyRolesRemoveAtom = "/api/v1/hostpolicy/roles/{}/atoms/{}"
|
|
58
|
+
HostPolicyRolesAddHost = "/api/v1/hostpolicy/roles/{}/hosts/"
|
|
59
|
+
HostPolicyRolesRemoveHost = "/api/v1/hostpolicy/roles/{}/hosts/{}"
|
|
60
|
+
HostPolicyAtoms = "/api/v1/hostpolicy/atoms/"
|
|
61
|
+
|
|
62
|
+
PermissionNetgroupRegex = "/api/v1/permissions/netgroupregex/"
|
|
63
|
+
|
|
64
|
+
ForwardZones = f"{Zones}forward/"
|
|
65
|
+
ReverseZones = f"{Zones}reverse/"
|
|
66
|
+
|
|
67
|
+
# NOTE: Delegations endpoints MUST have a trailing slash
|
|
68
|
+
ForwardZonesDelegations = f"{ForwardZones}{{}}/delegations/"
|
|
69
|
+
ReverseZonesDelegations = f"{ReverseZones}{{}}/delegations/"
|
|
70
|
+
|
|
71
|
+
ForwardZonesDelegationsZone = f"{ForwardZones}{{}}/delegations/{{}}"
|
|
72
|
+
ReverseZonesDelegationsZone = f"{ReverseZones}{{}}/delegations/{{}}"
|
|
73
|
+
|
|
74
|
+
# NOTE: Nameservers endpoints must NOT have a trailing slash
|
|
75
|
+
ForwardZonesNameservers = f"{ForwardZones}{{}}/nameservers"
|
|
76
|
+
ReverseZonesNameservers = f"{ReverseZones}{{}}/nameservers"
|
|
77
|
+
|
|
78
|
+
ForwardZoneForHost = f"{ForwardZones}hostname/"
|
|
79
|
+
|
|
80
|
+
def __str__(self):
|
|
81
|
+
"""Prevent direct usage without parameters where needed."""
|
|
82
|
+
if "{}" in self.value:
|
|
83
|
+
raise ValueError(f"Endpoint {self.name} requires parameters. Use `with_params`.")
|
|
84
|
+
return self.value
|
|
85
|
+
|
|
86
|
+
def requires_search_for_id(self) -> bool:
|
|
87
|
+
"""Return True if this endpoint requires a search for an ID."""
|
|
88
|
+
return self.external_id_field() != "id"
|
|
89
|
+
|
|
90
|
+
def external_id_field(self) -> Literal["id", "name", "network", "host"]:
|
|
91
|
+
"""Return the name of the field that holds the external ID."""
|
|
92
|
+
if self in (
|
|
93
|
+
Endpoint.Hosts,
|
|
94
|
+
Endpoint.HostGroups,
|
|
95
|
+
Endpoint.Cnames,
|
|
96
|
+
Endpoint.ForwardZones,
|
|
97
|
+
Endpoint.ReverseZones,
|
|
98
|
+
Endpoint.ForwardZonesDelegations,
|
|
99
|
+
Endpoint.ReverseZonesDelegations,
|
|
100
|
+
Endpoint.HostPolicyRoles,
|
|
101
|
+
Endpoint.HostPolicyAtoms,
|
|
102
|
+
Endpoint.Nameservers,
|
|
103
|
+
):
|
|
104
|
+
return "name"
|
|
105
|
+
if self in (Endpoint.Networks,):
|
|
106
|
+
return "network"
|
|
107
|
+
if self in (Endpoint.Hinfos, Endpoint.Locs):
|
|
108
|
+
return "host"
|
|
109
|
+
return "id"
|
|
110
|
+
|
|
111
|
+
def with_id(self, identity: str | int) -> str:
|
|
112
|
+
"""Return the endpoint with an ID."""
|
|
113
|
+
id_field = quote(str(identity))
|
|
114
|
+
return urljoin(self.value, id_field)
|
|
115
|
+
|
|
116
|
+
def with_params(self, *params: str | int) -> str:
|
|
117
|
+
"""Construct and return an endpoint URL by inserting parameters.
|
|
118
|
+
|
|
119
|
+
:param params: A sequence of parameters to be inserted into the URL.
|
|
120
|
+
:raises ValueError: If the number of provided parameters does not match the
|
|
121
|
+
number of placeholders.
|
|
122
|
+
:returns: A fully constructed endpoint URL with parameters.
|
|
123
|
+
"""
|
|
124
|
+
placeholders_count = self.value.count("{}")
|
|
125
|
+
if placeholders_count != len(params):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{self.name} endpoint expects {placeholders_count} parameters, got {len(params)}."
|
|
128
|
+
)
|
|
129
|
+
encoded_params = (quote(str(param)) for param in params)
|
|
130
|
+
return self.value.format(*encoded_params)
|