nautobot 3.0.3__py3-none-any.whl → 3.0.4__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.
- nautobot/core/authentication.py +0 -1
- nautobot/core/celery/schedulers.py +1 -3
- nautobot/core/cli/__init__.py +81 -39
- nautobot/core/settings.yaml +12 -4
- nautobot/core/tests/test_cli.py +120 -1
- nautobot/dcim/forms.py +1 -0
- nautobot/dcim/tables/devices.py +2 -1
- nautobot/dcim/templates/dcim/platform_create.html +3 -4
- nautobot/extras/models/jobs.py +7 -1
- nautobot/extras/signals.py +143 -113
- nautobot/extras/tests/test_utils.py +116 -1
- nautobot/extras/utils.py +18 -16
- nautobot/extras/views.py +2 -14
- nautobot/ipam/apps.py +1 -0
- nautobot/project-static/docs/development/core/release-checklist.html +2 -0
- nautobot/project-static/docs/release-notes/version-3.0.html +235 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +329 -329
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +11 -4
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +11 -4
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +21 -9
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/12-add-tenant-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/12-add-tenant-light.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/13-assign-tenant-to-device-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/13-assign-tenant-to-device-light.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/14-assign-tenant-to-device-2-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/14-assign-tenant-to-device-2-light.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/22-create-vlans-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/22-create-vlans-light.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/23-create-vlans-2-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/23-create-vlans-2-light.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/24-vlan-main-page-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/24-vlan-main-page-light.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/25-add-vlan-to-interface-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/25-add-vlan-to-interface-light.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/26-add-vlan-to-interface-2-dark.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/26-add-vlan-to-interface-2-light.png +0 -0
- nautobot/tenancy/tables.py +1 -1
- nautobot/ui/package-lock.json +36 -36
- nautobot/ui/package.json +3 -3
- nautobot/users/models.py +33 -0
- nautobot/users/tests/test_models.py +83 -0
- {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/METADATA +4 -4
- {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/RECORD +49 -41
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/12-add-tenant.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/13-assign-tenant-to-device.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/14-assign-tenant-to-device-2.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/22-create-vlans.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/23-create-vlans-2.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/24-vlan-main-page.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/25-add-vlan-to-interface.png +0 -0
- nautobot/project-static/docs/user-guide/feature-guides/images/getting-started-nautobot-ui/26-add-vlan-to-interface-2.png +0 -0
- {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/LICENSE.txt +0 -0
- {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/NOTICE +0 -0
- {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/WHEEL +0 -0
- {nautobot-3.0.3.dist-info → nautobot-3.0.4.dist-info}/entry_points.txt +0 -0
nautobot/extras/signals.py
CHANGED
|
@@ -8,6 +8,7 @@ import uuid
|
|
|
8
8
|
|
|
9
9
|
from db_file_storage.model_utils import delete_file
|
|
10
10
|
from db_file_storage.storage import DatabaseFileStorage
|
|
11
|
+
from django.conf import settings
|
|
11
12
|
from django.contrib.contenttypes.models import ContentType
|
|
12
13
|
from django.core.cache import cache
|
|
13
14
|
from django.core.exceptions import ValidationError
|
|
@@ -19,6 +20,7 @@ from django.utils import timezone
|
|
|
19
20
|
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
|
20
21
|
import redis.exceptions
|
|
21
22
|
|
|
23
|
+
from nautobot.core.branching import BranchContext
|
|
22
24
|
from nautobot.core.celery import app, import_jobs
|
|
23
25
|
from nautobot.core.models import BaseModel
|
|
24
26
|
from nautobot.core.utils.cache import construct_cache_key
|
|
@@ -214,6 +216,29 @@ def invalidate_gitrepository_provided_contents_cache(sender, **kwargs):
|
|
|
214
216
|
cache.delete_pattern(f"{cache_key}(*)")
|
|
215
217
|
|
|
216
218
|
|
|
219
|
+
def _object_change_branch_name(instance):
|
|
220
|
+
"""
|
|
221
|
+
Get the version-control branch name (if any) that needs to be switched to for ObjectChanges on a given instance.
|
|
222
|
+
"""
|
|
223
|
+
if "nautobot_version_control" not in settings.PLUGINS:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
# When modifying non-version-controlled models, which only get committed to DOLT_DEFAULT_BRANCH,
|
|
227
|
+
# we need to ensure that the corresponding ObjectChange also is created there, even if we're otherwise working
|
|
228
|
+
# in a non-default branch at the moment. Failing to do so would result in a Dolt error on transaction commit:
|
|
229
|
+
# "Cannot commit changes on more than one branch / database"
|
|
230
|
+
from nautobot_version_control.constants import DOLT_DEFAULT_BRANCH # pylint: disable=import-error
|
|
231
|
+
from nautobot_version_control.utils import ( # pylint: disable=import-error
|
|
232
|
+
active_branch,
|
|
233
|
+
is_version_controlled_model,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if is_version_controlled_model(instance.__class__) or active_branch() == DOLT_DEFAULT_BRANCH:
|
|
237
|
+
return None # no need to switch branches
|
|
238
|
+
|
|
239
|
+
return DOLT_DEFAULT_BRANCH # need to switch temporarily to the default `main` branch for this record
|
|
240
|
+
|
|
241
|
+
|
|
217
242
|
@receiver(post_save)
|
|
218
243
|
@receiver(m2m_changed)
|
|
219
244
|
def _handle_changed_object(sender, instance, raw=False, **kwargs):
|
|
@@ -242,60 +267,63 @@ def _handle_changed_object(sender, instance, raw=False, **kwargs):
|
|
|
242
267
|
|
|
243
268
|
# Record an ObjectChange if applicable
|
|
244
269
|
if hasattr(instance, "to_objectchange"):
|
|
270
|
+
branch_name = _object_change_branch_name(instance)
|
|
245
271
|
user = change_context.get_user(instance)
|
|
246
|
-
# save a copy of this instance's field cache so it can be restored after serialization
|
|
247
|
-
# to prevent unexpected behavior when chaining multiple signal handlers
|
|
248
|
-
original_cache = instance._state.fields_cache.copy()
|
|
249
|
-
|
|
250
|
-
changed_object_type = ContentType.objects.get_for_model(instance)
|
|
251
|
-
changed_object_id = instance.id
|
|
252
272
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
273
|
+
with BranchContext(branch_name=branch_name, user=user, autocommit=False):
|
|
274
|
+
# save a copy of this instance's field cache so it can be restored after serialization
|
|
275
|
+
# to prevent unexpected behavior when chaining multiple signal handlers
|
|
276
|
+
original_cache = instance._state.fields_cache.copy()
|
|
277
|
+
|
|
278
|
+
changed_object_type = ContentType.objects.get_for_model(instance)
|
|
279
|
+
changed_object_id = instance.id
|
|
280
|
+
|
|
281
|
+
# Generate a unique identifier for this change to stash in the change context
|
|
282
|
+
# This is used for deferred change logging and for looking up related changes without querying the database
|
|
283
|
+
unique_object_change_id = None
|
|
284
|
+
if user is not None:
|
|
285
|
+
unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
|
|
286
|
+
else:
|
|
287
|
+
unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}"
|
|
288
|
+
|
|
289
|
+
# If a change already exists for this change_id, user, and object, update it instead of creating a new one.
|
|
290
|
+
# If the object was deleted then recreated with the same pk (don't do this), change the action to update.
|
|
291
|
+
if unique_object_change_id in change_context.deferred_object_changes:
|
|
292
|
+
related_changes = ObjectChange.objects.filter(
|
|
293
|
+
changed_object_type=changed_object_type,
|
|
294
|
+
changed_object_id=changed_object_id,
|
|
295
|
+
user=user,
|
|
296
|
+
request_id=change_context.change_id,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Skip the database check when deferring object changes
|
|
300
|
+
if not change_context.defer_object_changes and related_changes.exists():
|
|
301
|
+
objectchange = instance.to_objectchange(action)
|
|
302
|
+
if objectchange is not None:
|
|
303
|
+
most_recent_change = related_changes.order_by("-time").first()
|
|
304
|
+
if most_recent_change.action == ObjectChangeActionChoices.ACTION_DELETE:
|
|
305
|
+
most_recent_change.action = ObjectChangeActionChoices.ACTION_UPDATE
|
|
306
|
+
most_recent_change.object_data = objectchange.object_data
|
|
307
|
+
most_recent_change.object_data_v2 = objectchange.object_data_v2
|
|
308
|
+
most_recent_change.save()
|
|
281
309
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
310
|
+
else:
|
|
311
|
+
change_context.deferred_object_changes[unique_object_change_id] = [
|
|
312
|
+
{"action": action, "instance": instance, "user": user}
|
|
313
|
+
]
|
|
314
|
+
if not change_context.defer_object_changes:
|
|
315
|
+
objectchange = instance.to_objectchange(action)
|
|
316
|
+
if objectchange is not None:
|
|
317
|
+
objectchange.user = user
|
|
318
|
+
objectchange.request_id = change_context.change_id
|
|
319
|
+
objectchange.change_context = change_context.context
|
|
320
|
+
objectchange.change_context_detail = change_context.context_detail[
|
|
321
|
+
:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
|
|
322
|
+
]
|
|
323
|
+
objectchange.save()
|
|
324
|
+
|
|
325
|
+
# restore field cache
|
|
326
|
+
instance._state.fields_cache = original_cache
|
|
299
327
|
|
|
300
328
|
# Increment metric counters
|
|
301
329
|
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
|
@@ -326,71 +354,73 @@ def _handle_deleted_object(sender, instance, **kwargs):
|
|
|
326
354
|
|
|
327
355
|
# Record an ObjectChange if applicable
|
|
328
356
|
if hasattr(instance, "to_objectchange"):
|
|
357
|
+
branch_name = _object_change_branch_name(instance)
|
|
329
358
|
user = change_context.get_user(instance)
|
|
330
359
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
changed_object_type = ContentType.objects.get_for_model(instance)
|
|
336
|
-
changed_object_id = instance.id
|
|
337
|
-
|
|
338
|
-
# Generate a unique identifier for this change to stash in the change context
|
|
339
|
-
# This is used for deferred change logging and for looking up related changes without querying the database
|
|
340
|
-
unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
|
|
341
|
-
save_new_objectchange = True
|
|
342
|
-
|
|
343
|
-
# if a change already exists for this change_id, user, and object, update it instead of creating a new one
|
|
344
|
-
# except in the case that the object was created and deleted in the same change_id
|
|
345
|
-
# we don't want to create a delete change for an object that never existed
|
|
346
|
-
if unique_object_change_id in change_context.deferred_object_changes:
|
|
347
|
-
cached_related_change = change_context.deferred_object_changes[unique_object_change_id][-1]
|
|
348
|
-
if cached_related_change["action"] != ObjectChangeActionChoices.ACTION_CREATE:
|
|
349
|
-
cached_related_change["action"] = ObjectChangeActionChoices.ACTION_DELETE
|
|
350
|
-
save_new_objectchange = False
|
|
351
|
-
|
|
352
|
-
related_changes = ObjectChange.objects.filter(
|
|
353
|
-
changed_object_type=changed_object_type,
|
|
354
|
-
changed_object_id=changed_object_id,
|
|
355
|
-
user=user,
|
|
356
|
-
request_id=change_context.change_id,
|
|
357
|
-
)
|
|
360
|
+
with BranchContext(branch_name=branch_name, user=user, autocommit=False):
|
|
361
|
+
# save a copy of this instance's field cache so it can be restored after serialization
|
|
362
|
+
# to prevent unexpected behavior when chaining multiple signal handlers
|
|
363
|
+
original_cache = instance._state.fields_cache.copy()
|
|
358
364
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
objectchange
|
|
386
|
-
objectchange
|
|
387
|
-
|
|
388
|
-
:
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
365
|
+
changed_object_type = ContentType.objects.get_for_model(instance)
|
|
366
|
+
changed_object_id = instance.id
|
|
367
|
+
|
|
368
|
+
# Generate a unique identifier for this change to stash in the change context
|
|
369
|
+
# This is used for deferred change logging and for looking up related changes without querying the database
|
|
370
|
+
unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
|
|
371
|
+
save_new_objectchange = True
|
|
372
|
+
|
|
373
|
+
# if a change already exists for this change_id, user, and object, update it instead of creating a new one
|
|
374
|
+
# except in the case that the object was created and deleted in the same change_id
|
|
375
|
+
# we don't want to create a delete change for an object that never existed
|
|
376
|
+
if unique_object_change_id in change_context.deferred_object_changes:
|
|
377
|
+
cached_related_change = change_context.deferred_object_changes[unique_object_change_id][-1]
|
|
378
|
+
if cached_related_change["action"] != ObjectChangeActionChoices.ACTION_CREATE:
|
|
379
|
+
cached_related_change["action"] = ObjectChangeActionChoices.ACTION_DELETE
|
|
380
|
+
save_new_objectchange = False
|
|
381
|
+
|
|
382
|
+
related_changes = ObjectChange.objects.filter(
|
|
383
|
+
changed_object_type=changed_object_type,
|
|
384
|
+
changed_object_id=changed_object_id,
|
|
385
|
+
user=user,
|
|
386
|
+
request_id=change_context.change_id,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Skip the database check when deferring object changes
|
|
390
|
+
if not change_context.defer_object_changes and related_changes.exists():
|
|
391
|
+
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
|
392
|
+
if objectchange is not None:
|
|
393
|
+
most_recent_change = related_changes.order_by("-time").first()
|
|
394
|
+
if most_recent_change.action != ObjectChangeActionChoices.ACTION_CREATE:
|
|
395
|
+
most_recent_change.action = ObjectChangeActionChoices.ACTION_DELETE
|
|
396
|
+
most_recent_change.object_data = objectchange.object_data
|
|
397
|
+
most_recent_change.object_data_v2 = objectchange.object_data_v2
|
|
398
|
+
most_recent_change.save()
|
|
399
|
+
save_new_objectchange = False
|
|
400
|
+
|
|
401
|
+
if save_new_objectchange:
|
|
402
|
+
change_context.deferred_object_changes.setdefault(unique_object_change_id, []).append(
|
|
403
|
+
{
|
|
404
|
+
"action": ObjectChangeActionChoices.ACTION_DELETE,
|
|
405
|
+
"instance": instance,
|
|
406
|
+
"user": user,
|
|
407
|
+
"changed_object_id": changed_object_id,
|
|
408
|
+
"changed_object_type": changed_object_type,
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
if not change_context.defer_object_changes:
|
|
412
|
+
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
|
413
|
+
if objectchange is not None:
|
|
414
|
+
objectchange.user = user
|
|
415
|
+
objectchange.request_id = change_context.change_id
|
|
416
|
+
objectchange.change_context = change_context.context
|
|
417
|
+
objectchange.change_context_detail = change_context.context_detail[
|
|
418
|
+
:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
|
|
419
|
+
]
|
|
420
|
+
objectchange.save()
|
|
421
|
+
|
|
422
|
+
# restore field cache
|
|
423
|
+
instance._state.fields_cache = original_cache
|
|
394
424
|
|
|
395
425
|
# Increment metric counters
|
|
396
426
|
model_deletes.labels(instance._meta.model_name).inc()
|
|
@@ -2,22 +2,26 @@ from unittest import mock
|
|
|
2
2
|
import uuid
|
|
3
3
|
|
|
4
4
|
from django.core.cache import cache
|
|
5
|
+
from django.test import override_settings
|
|
5
6
|
|
|
6
7
|
from nautobot.core.testing import TestCase
|
|
7
8
|
from nautobot.dcim.models import Cable, Device, PowerPort
|
|
8
9
|
from nautobot.extras.choices import JobQueueTypeChoices
|
|
9
|
-
from nautobot.extras.models import JobQueue
|
|
10
|
+
from nautobot.extras.models import JobQueue, JobResult
|
|
10
11
|
from nautobot.extras.registry import registry
|
|
11
12
|
from nautobot.extras.utils import (
|
|
12
13
|
get_base_template,
|
|
13
14
|
get_celery_queues,
|
|
14
15
|
get_worker_count,
|
|
15
16
|
populate_model_features_registry,
|
|
17
|
+
run_kubernetes_job_and_return_job_result,
|
|
16
18
|
)
|
|
17
19
|
from nautobot.users.models import Token
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class UtilsTestCase(TestCase):
|
|
23
|
+
databases = ("default", "job_logs")
|
|
24
|
+
|
|
21
25
|
def test_get_base_template(self):
|
|
22
26
|
with self.subTest("explicitly specified base_template always wins"):
|
|
23
27
|
self.assertEqual(get_base_template("dcim/device/base.html", Device), "dcim/device/base.html")
|
|
@@ -125,3 +129,114 @@ class UtilsTestCase(TestCase):
|
|
|
125
129
|
original_custom_fields_registry,
|
|
126
130
|
"Registry should be restored to original state",
|
|
127
131
|
)
|
|
132
|
+
|
|
133
|
+
@override_settings(
|
|
134
|
+
KUBERNETES_JOB_POD_NAME="test-pod",
|
|
135
|
+
KUBERNETES_JOB_POD_NAMESPACE="test-namespace",
|
|
136
|
+
KUBERNETES_JOB_MANIFEST={
|
|
137
|
+
"metadata": {"name": "test-job"},
|
|
138
|
+
"spec": {
|
|
139
|
+
"template": {
|
|
140
|
+
"spec": {
|
|
141
|
+
"containers": [
|
|
142
|
+
{
|
|
143
|
+
"command": [],
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
KUBERNETES_SSL_CA_CERT_PATH="/path/to/ca.crt",
|
|
151
|
+
KUBERNETES_TOKEN_PATH="/path/to/token", # noqa: S106
|
|
152
|
+
KUBERNETES_DEFAULT_SERVICE_ADDRESS="https://kubernetes.default.svc",
|
|
153
|
+
)
|
|
154
|
+
@mock.patch("nautobot.extras.utils.transaction.on_commit")
|
|
155
|
+
@mock.patch("builtins.open", new_callable=mock.mock_open, read_data="test-token\n")
|
|
156
|
+
@mock.patch("nautobot.extras.utils.kubernetes.client.BatchV1Api")
|
|
157
|
+
@mock.patch("nautobot.extras.utils.kubernetes.client.ApiClient")
|
|
158
|
+
@mock.patch("nautobot.extras.utils.kubernetes.client.Configuration")
|
|
159
|
+
def test_run_kubernetes_job_and_return_job_result(
|
|
160
|
+
self,
|
|
161
|
+
mock_configuration,
|
|
162
|
+
mock_api_client,
|
|
163
|
+
mock_batch_api,
|
|
164
|
+
mock_open,
|
|
165
|
+
mock_on_commit,
|
|
166
|
+
):
|
|
167
|
+
"""Test run_kubernetes_job_and_return_job_result function."""
|
|
168
|
+
# Setup test data
|
|
169
|
+
job_result = JobResult.objects.create(
|
|
170
|
+
name="Test Job",
|
|
171
|
+
user=self.user,
|
|
172
|
+
)
|
|
173
|
+
# Mock the log method to avoid database writes during test
|
|
174
|
+
job_result.log = mock.Mock()
|
|
175
|
+
job_kwargs = '{"key": "value"}'
|
|
176
|
+
|
|
177
|
+
# Setup kubernetes client mocks
|
|
178
|
+
mock_config_instance = mock.MagicMock()
|
|
179
|
+
mock_config_instance.api_key_prefix = {}
|
|
180
|
+
mock_config_instance.api_key = {}
|
|
181
|
+
mock_configuration.return_value = mock_config_instance
|
|
182
|
+
|
|
183
|
+
mock_api_client_instance = mock.Mock()
|
|
184
|
+
mock_api_client.return_value.__enter__.return_value = mock_api_client_instance
|
|
185
|
+
mock_api_client.return_value.__exit__.return_value = None
|
|
186
|
+
|
|
187
|
+
mock_api_instance = mock.Mock()
|
|
188
|
+
mock_batch_api.return_value = mock_api_instance
|
|
189
|
+
|
|
190
|
+
# Capture the callback passed to transaction.on_commit
|
|
191
|
+
commit_callback = None
|
|
192
|
+
|
|
193
|
+
def capture_callback(callback):
|
|
194
|
+
nonlocal commit_callback
|
|
195
|
+
commit_callback = callback
|
|
196
|
+
# Execute immediately for testing
|
|
197
|
+
callback()
|
|
198
|
+
|
|
199
|
+
mock_on_commit.side_effect = capture_callback
|
|
200
|
+
|
|
201
|
+
# Execute the function
|
|
202
|
+
result = run_kubernetes_job_and_return_job_result(job_result, job_kwargs)
|
|
203
|
+
|
|
204
|
+
# Verify job_result was updated and saved
|
|
205
|
+
job_result.refresh_from_db()
|
|
206
|
+
self.assertEqual(job_result.task_kwargs, job_kwargs)
|
|
207
|
+
self.assertEqual(result, job_result)
|
|
208
|
+
|
|
209
|
+
# Verify transaction.on_commit was called
|
|
210
|
+
mock_on_commit.assert_called_once()
|
|
211
|
+
self.assertIsNotNone(commit_callback)
|
|
212
|
+
|
|
213
|
+
# Verify kubernetes configuration was set up correctly
|
|
214
|
+
mock_configuration.assert_called_once()
|
|
215
|
+
self.assertEqual(mock_config_instance.host, "https://kubernetes.default.svc")
|
|
216
|
+
self.assertEqual(mock_config_instance.ssl_ca_cert, "/path/to/ca.crt")
|
|
217
|
+
self.assertEqual(mock_config_instance.api_key_prefix["authorization"], "Bearer")
|
|
218
|
+
self.assertEqual(mock_config_instance.api_key["authorization"], "test-token")
|
|
219
|
+
|
|
220
|
+
# Verify ApiClient was used as context manager
|
|
221
|
+
mock_api_client.assert_called_once_with(mock_config_instance)
|
|
222
|
+
|
|
223
|
+
# Verify BatchV1Api was created with the api_client_instance
|
|
224
|
+
mock_batch_api.assert_called_once_with(mock_api_client_instance)
|
|
225
|
+
|
|
226
|
+
# Verify the pod manifest was modified correctly
|
|
227
|
+
mock_api_instance.create_namespaced_job.assert_called_once()
|
|
228
|
+
create_call = mock_api_instance.create_namespaced_job.call_args
|
|
229
|
+
body = create_call[1]["body"]
|
|
230
|
+
self.assertEqual(body["metadata"]["name"], f"nautobot-job-{job_result.pk}")
|
|
231
|
+
self.assertEqual(
|
|
232
|
+
body["spec"]["template"]["spec"]["containers"][0]["command"],
|
|
233
|
+
["nautobot-server", "runjob_with_job_result", str(job_result.pk)],
|
|
234
|
+
)
|
|
235
|
+
self.assertEqual(create_call[1]["namespace"], "test-namespace")
|
|
236
|
+
|
|
237
|
+
# Verify token file was opened
|
|
238
|
+
mock_open.assert_called_once_with("/path/to/token", "r", encoding="utf-8")
|
|
239
|
+
|
|
240
|
+
# Verify job_result.log was called (checking for log messages)
|
|
241
|
+
self.assertEqual(job_result.log.call_count, 1)
|
|
242
|
+
self.assertIn("Creating job pod", str(job_result.log.call_args_list[0]))
|
nautobot/extras/utils.py
CHANGED
|
@@ -668,7 +668,7 @@ def refresh_job_model_from_job_class(job_model_class, job_class, job_queue_class
|
|
|
668
668
|
return (job_model, created)
|
|
669
669
|
|
|
670
670
|
|
|
671
|
-
def run_kubernetes_job_and_return_job_result(
|
|
671
|
+
def run_kubernetes_job_and_return_job_result(job_result, job_kwargs):
|
|
672
672
|
"""
|
|
673
673
|
Pass the job to a kubernetes pod and execute it there.
|
|
674
674
|
"""
|
|
@@ -678,17 +678,6 @@ def run_kubernetes_job_and_return_job_result(job_queue, job_result, job_kwargs):
|
|
|
678
678
|
pod_ssl_ca_cert = settings.KUBERNETES_SSL_CA_CERT_PATH
|
|
679
679
|
pod_token = settings.KUBERNETES_TOKEN_PATH
|
|
680
680
|
|
|
681
|
-
configuration = kubernetes.client.Configuration()
|
|
682
|
-
configuration.host = settings.KUBERNETES_DEFAULT_SERVICE_ADDRESS
|
|
683
|
-
configuration.ssl_ca_cert = pod_ssl_ca_cert
|
|
684
|
-
with open(pod_token, "r") as token_file:
|
|
685
|
-
token = token_file.read().strip()
|
|
686
|
-
# configure API Key authorization: BearerToken
|
|
687
|
-
configuration.api_key_prefix["authorization"] = "Bearer"
|
|
688
|
-
configuration.api_key["authorization"] = token
|
|
689
|
-
with kubernetes.client.ApiClient(configuration) as api_client:
|
|
690
|
-
api_instance = kubernetes.client.BatchV1Api(api_client)
|
|
691
|
-
|
|
692
681
|
job_result.task_kwargs = job_kwargs
|
|
693
682
|
job_result.save()
|
|
694
683
|
pod_manifest["metadata"]["name"] = "nautobot-job-" + str(job_result.pk)
|
|
@@ -697,10 +686,23 @@ def run_kubernetes_job_and_return_job_result(job_queue, job_result, job_kwargs):
|
|
|
697
686
|
"runjob_with_job_result",
|
|
698
687
|
f"{job_result.pk}",
|
|
699
688
|
]
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
689
|
+
|
|
690
|
+
def create_kubernetes_job():
|
|
691
|
+
"""Create and read the Kubernetes job after the transaction commits."""
|
|
692
|
+
configuration = kubernetes.client.Configuration()
|
|
693
|
+
configuration.host = settings.KUBERNETES_DEFAULT_SERVICE_ADDRESS
|
|
694
|
+
configuration.ssl_ca_cert = pod_ssl_ca_cert
|
|
695
|
+
with open(pod_token, "r", encoding="utf-8") as token_file:
|
|
696
|
+
token = token_file.read().strip()
|
|
697
|
+
# configure API Key authorization: BearerToken
|
|
698
|
+
configuration.api_key_prefix["authorization"] = "Bearer"
|
|
699
|
+
configuration.api_key["authorization"] = token
|
|
700
|
+
with kubernetes.client.ApiClient(configuration) as api_client:
|
|
701
|
+
api_instance = kubernetes.client.BatchV1Api(api_client)
|
|
702
|
+
job_result.log(f"Creating job pod {pod_name} in namespace {pod_namespace}")
|
|
703
|
+
api_instance.create_namespaced_job(body=pod_manifest, namespace=pod_namespace)
|
|
704
|
+
|
|
705
|
+
transaction.on_commit(create_kubernetes_job)
|
|
704
706
|
return job_result
|
|
705
707
|
|
|
706
708
|
|
nautobot/extras/views.py
CHANGED
|
@@ -820,7 +820,6 @@ class ComputedFieldUIViewSet(NautobotUIViewSet):
|
|
|
820
820
|
serializer_class = serializers.ComputedFieldSerializer
|
|
821
821
|
table_class = tables.ComputedFieldTable
|
|
822
822
|
queryset = ComputedField.objects.all()
|
|
823
|
-
action_buttons = ("add",)
|
|
824
823
|
object_detail_content = object_detail.ObjectDetailContent(
|
|
825
824
|
panels=(
|
|
826
825
|
object_detail.ObjectFieldsPanel(
|
|
@@ -1246,7 +1245,6 @@ class CustomFieldUIViewSet(NautobotUIViewSet):
|
|
|
1246
1245
|
form_class = forms.CustomFieldForm
|
|
1247
1246
|
table_class = tables.CustomFieldTable
|
|
1248
1247
|
template_name = "extras/customfield_update.html"
|
|
1249
|
-
action_buttons = ("add",)
|
|
1250
1248
|
|
|
1251
1249
|
class CustomFieldObjectFieldsPanel(object_detail.ObjectFieldsPanel):
|
|
1252
1250
|
def render_value(self, key, value, context):
|
|
@@ -1392,7 +1390,6 @@ class DynamicGroupUIViewSet(NautobotUIViewSet):
|
|
|
1392
1390
|
queryset = DynamicGroup.objects.all()
|
|
1393
1391
|
serializer_class = serializers.DynamicGroupSerializer
|
|
1394
1392
|
table_class = tables.DynamicGroupTable
|
|
1395
|
-
action_buttons = ("add",)
|
|
1396
1393
|
|
|
1397
1394
|
def get_extra_context(self, request, instance):
|
|
1398
1395
|
context = super().get_extra_context(request, instance)
|
|
@@ -1896,23 +1893,14 @@ class GitRepositoryUIViewSet(NautobotUIViewSet):
|
|
|
1896
1893
|
#
|
|
1897
1894
|
|
|
1898
1895
|
|
|
1899
|
-
class GraphQLQueryUIViewSet(
|
|
1900
|
-
ObjectDetailViewMixin,
|
|
1901
|
-
ObjectListViewMixin,
|
|
1902
|
-
ObjectEditViewMixin,
|
|
1903
|
-
ObjectDestroyViewMixin,
|
|
1904
|
-
ObjectBulkDestroyViewMixin,
|
|
1905
|
-
ObjectChangeLogViewMixin,
|
|
1906
|
-
ObjectDataComplianceViewMixin,
|
|
1907
|
-
ObjectNotesViewMixin,
|
|
1908
|
-
):
|
|
1896
|
+
class GraphQLQueryUIViewSet(NautobotUIViewSet):
|
|
1909
1897
|
filterset_form_class = forms.GraphQLQueryFilterForm
|
|
1910
1898
|
queryset = GraphQLQuery.objects.all()
|
|
1911
1899
|
form_class = forms.GraphQLQueryForm
|
|
1912
1900
|
filterset_class = filters.GraphQLQueryFilterSet
|
|
1913
1901
|
serializer_class = serializers.GraphQLQuerySerializer
|
|
1914
1902
|
table_class = tables.GraphQLQueryTable
|
|
1915
|
-
action_buttons = ("add",)
|
|
1903
|
+
action_buttons = ("add", "export", "import")
|
|
1916
1904
|
|
|
1917
1905
|
object_detail_content = object_detail.ObjectDetailContent(
|
|
1918
1906
|
panels=(
|
nautobot/ipam/apps.py
CHANGED
|
@@ -13736,6 +13736,8 @@
|
|
|
13736
13736
|
</div>
|
|
13737
13737
|
<h3 id="sync-changes-into-next">Sync Changes into <code>next</code><a class="headerlink" href="#sync-changes-into-next" title="Permanent link">¶</a></h3>
|
|
13738
13738
|
<p>After the main-to-develop pull request is merged into <code>develop</code>, create a new branch off of <code>next</code> (typically named <code>develop-to-next-post-<x.y.z></code>) and <code>git merge develop</code>. Resolve any merge conflicts as appropriate (if you're lucky, there may only be one, a version number clash in <code>pyproject.toml</code>), then open a pull request to <code>next</code>.</p>
|
|
13739
|
+
<p>When resolving conflicts, always keep the Nautobot app version number from the <strong>current branch</strong> (not the incoming one). Once conflicts are resolved, open a pull request to <code>next</code>.</p>
|
|
13740
|
+
<p>During the pull request, the CI pipeline may fail at the <strong>changelog</strong> step. This is expected in this case and should not be a cause for concern (the error can be safely ignored).</p>
|
|
13739
13741
|
<div class="admonition important">
|
|
13740
13742
|
<p class="admonition-title">Important</p>
|
|
13741
13743
|
<p>Do not squash merge this branch into <code>next</code>. Make sure to select <code>Create a merge commit</code> when merging in GitHub.</p>
|