Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: site decommissioning #43

Merged
merged 9 commits into from
Aug 22, 2024
106 changes: 87 additions & 19 deletions netbox_cmdb/netbox_cmdb/api/cmdb/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
from dcim.models import Device, Site
from django.db import transaction
from django.db.models import Q
from django.http import StreamingHttpResponse
from drf_yasg.utils import swagger_auto_schema
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView

from netbox_cmdb.models.bgp import BGPPeerGroup, BGPSession, DeviceBGPSession
from netbox_cmdb.models.bgp_community_list import BGPCommunityList
from netbox_cmdb.models.prefix_list import PrefixList
from netbox_cmdb.models.route_policy import RoutePolicy
from netbox_cmdb.models.snmp import SNMP
from netbox_cmdb.helpers import cleaning


class DeleteAllCMDBObjectsRelatedToDeviceSerializer(serializers.Serializer):
Expand All @@ -26,32 +23,103 @@ class DeleteAllCMDBObjectsRelatedToDevice(APIView):
responses={
status.HTTP_200_OK: "Objects related to device have been deleted successfully",
status.HTTP_400_BAD_REQUEST: "Bad Request: Device name is required",
status.HTTP_404_NOT_FOUND: "Bad Request: Device not found",
status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error: Something went wrong on the server",
},
)
def post(self, request):
def delete(self, request):
device_name = request.data.get("device_name", None)
if device_name is None:
return Response(
{"error": "Device name is required"}, status=status.HTTP_400_BAD_REQUEST
{"error": "device name is required"}, status=status.HTTP_400_BAD_REQUEST
)

devices = Device.objects.filter(name=device_name)
device_ids = [dev.id for dev in devices]
if not device_ids:
return Response(
{"error": "no matching devices found"}, status=status.HTTP_404_NOT_FOUND
)

try:
with transaction.atomic():
# Delete objects in reverse order of dependencies
BGPSession.objects.filter(
Q(peer_a__device__name=device_name) | Q(peer_b__device__name=device_name)
).delete()
DeviceBGPSession.objects.filter(device__name=device_name).delete()
BGPPeerGroup.objects.filter(device__name=device_name).delete()
RoutePolicy.objects.filter(device__name=device_name).delete()
PrefixList.objects.filter(device__name=device_name).delete()
BGPCommunityList.objects.filter(device_name=device_name).delete()
SNMP.objects.filter(device__name=device_name).delete()
deleted = cleaning.clean_cmdb_for_devices(device_ids)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

return Response(
{"message": f"Objects related to device {device_name} have been deleted successfully"},
{
"message": f"objects related to device {device_name} have been deleted successfully",
"deleted": deleted,
},
status=status.HTTP_200_OK,
)


class DecommissionSiteSerializer(serializers.Serializer):
site_name = serializers.CharField()


class DecommissionSite(APIView):

permission_classes = [IsAuthenticatedOrLoginNotRequired]

@swagger_auto_schema(
request_body=DecommissionSiteSerializer,
responses={
status.HTTP_200_OK: "Site have been deleted successfully",
status.HTTP_400_BAD_REQUEST: "Bad Request: Site name is required",
status.HTTP_404_NOT_FOUND: "Bad Request: Site not found",
status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error: Something went wrong on the server",
},
)
def delete(self, request):
site_name = request.data.get("site_name", None)
if site_name is None:
return Response({"error": "site name is required"}, status=status.HTTP_400_BAD_REQUEST)

try:
site = Site.objects.get(name=site_name)
except Site.DoesNotExist:
return Response({"error": "site not found"}, status=status.HTTP_404_NOT_FOUND)
except Exception:
return Response(
{"error": "internal server error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

devices = Device.objects.filter(site=site.id)

def _start():
CHUNK_SIZE = 20
device_ids = [dev.id for dev in devices]
for i in range(0, len(device_ids), CHUNK_SIZE):
chunk = device_ids[i : i + CHUNK_SIZE]
try:
with transaction.atomic():
cleaning.clean_cmdb_for_devices(chunk)
for dev in devices[i : i + CHUNK_SIZE]:
dev.delete()
yield f'{{"deleted": {[dev.name for dev in devices[i:i+CHUNK_SIZE]]}}}\n\n'

except Exception as e:
StreamingHttpResponse.status_code = 500
msg = {"error": str(e)}
yield f"{msg}\n\n"
return

try:
with transaction.atomic():
cleaning.clean_site_topology(site)
yield "{{'message': 'topology cleaned'}}\n\n"
except Exception as e:
StreamingHttpResponse.status_code = 500
msg = {"error": str(e)}
yield f"{msg}\n\n"
return

msg = {
"message": f"site {site_name} has been deleted successfully",
}
yield f"{msg}\n\n"

return StreamingHttpResponse(_start(), content_type="text/plain")
11 changes: 8 additions & 3 deletions netbox_cmdb/netbox_cmdb/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from netbox_cmdb.api.prefix_list.views import PrefixListViewSet
from netbox_cmdb.api.route_policy.views import RoutePolicyViewSet
from netbox_cmdb.api.snmp.views import SNMPCommunityViewSet, SNMPViewSet
from netbox_cmdb.api.cmdb.views import DeleteAllCMDBObjectsRelatedToDevice
from netbox_cmdb.api.cmdb.views import DeleteAllCMDBObjectsRelatedToDevice, DecommissionSite

router = NetBoxRouter()

Expand All @@ -35,9 +35,14 @@
name="asns-available-asn",
),
path(
"cmdb/delete-all-objects/",
"management/delete-all-objects/",
DeleteAllCMDBObjectsRelatedToDevice.as_view(),
name="asns-available-asn",
name="delete-all-objects",
),
path(
"management/decommission-site/",
DecommissionSite.as_view(),
name="decommission-site",
),
]
urlpatterns += router.urls
58 changes: 58 additions & 0 deletions netbox_cmdb/netbox_cmdb/helpers/cleaning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from dcim.models import Location, Rack
from django.db.models import Q

from netbox_cmdb.models.bgp import BGPPeerGroup, BGPSession, DeviceBGPSession
from netbox_cmdb.models.bgp_community_list import BGPCommunityList
from netbox_cmdb.models.prefix_list import PrefixList
from netbox_cmdb.models.route_policy import RoutePolicy
from netbox_cmdb.models.snmp import SNMP


def clean_cmdb_for_devices(device_ids: list[int]):
deleted_objects = {
"bgp_sessions": [],
"device_bgp_sessions": [],
"bgp_peer_groups": [],
"route_policies": [],
"prefix_lists": [],
"bgp_community_lists": [],
"snmp": [],
}

bgp_sessions = BGPSession.objects.filter(
Q(peer_a__device__id__in=device_ids) | Q(peer_b__device__id__in=device_ids)
)
device_bgp_sessions = DeviceBGPSession.objects.filter(device__id__in=device_ids)
bgp_peer_groups = BGPPeerGroup.objects.filter(device__id__in=device_ids)
route_policies = RoutePolicy.objects.filter(device__id__in=device_ids)
prefix_lists = PrefixList.objects.filter(device__id__in=device_ids)
bgp_community_lists = BGPCommunityList.objects.filter(device__id__in=device_ids)
snmp = SNMP.objects.filter(device__id__in=device_ids)

deleted_objects["bgp_sessions"] = [str(val) for val in list(bgp_sessions)]
deleted_objects["device_bgp_sessions"] = [str(val) for val in list(device_bgp_sessions)]
deleted_objects["bgp_peer_groups"] = [str(val) for val in list(bgp_peer_groups)]
deleted_objects["route_policies"] = [str(val) for val in list(route_policies)]
deleted_objects["prefix_lists"] = [str(val) for val in list(prefix_lists)]
deleted_objects["bgp_community_lists"] = [str(val) for val in list(bgp_community_lists)]
deleted_objects["snmp"] = [str(val) for val in list(snmp)]

bgp_sessions.delete()
device_bgp_sessions.delete()
bgp_peer_groups.delete()
route_policies.delete()
prefix_lists.delete()
bgp_community_lists.delete()
snmp.delete()

return deleted_objects


def clean_site_topology(site):
racks = Rack.objects.filter(site=site.id)
racks.delete()

locations = Location.objects.filter(site=site.id)
locations.delete()

site.delete()
19 changes: 13 additions & 6 deletions netbox_cmdb/netbox_cmdb/template_content.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
from extras.plugins import PluginTemplateExtension


class Decommisioning(PluginTemplateExtension):
model = "dcim.device"

class DecommisioningBase(PluginTemplateExtension):
def buttons(self):
return (
f'<a href="#" hx-get="/plugins/cmdb/decommisioning/{self.context["object"].id}/delete" '
'hx-target="#htmx-modal-content" class="btn btn-sm btn-danger" data-bs-toggle="modal" data-bs-target="#htmx-modal" '
f'<a href="/plugins/cmdb/decommisioning/{self.obj}/{self.context["object"].id}/delete" '
kpetremann marked this conversation as resolved.
Show resolved Hide resolved
'class="btn btn-sm btn-danger">Decommission</a>'
)


template_extensions = [Decommisioning]
class DeviceDecommisioning(DecommisioningBase):
model = "dcim.device"
obj = "device"


class SiteDecommisioning(DecommisioningBase):
model = "dcim.site"
obj = "site"


template_extensions = [DeviceDecommisioning, SiteDecommisioning]
43 changes: 0 additions & 43 deletions netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning.html

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% extends "base/layout.html" %}
{% block title %}
{{ object.name }} decommissioning
{% endblock %}

{% block content-wrapper %}
<div class="border-top">
<div class="container mt-5 mb-3 border rounded">

<div id="_status" class="mt-3 mb-3 fw-bold"></div>
<div id="_error"></div>
<div id="_history" class="ps-3"></div>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% else %}
<div id="_content" class="mb-3">
<p class="fw-bold">
<span class="text-danger">Warning:</span> this action will remove both
CMDB assets and DCIM of concerned asset(s)
</p>
<form
hx-target="#_content"
hx-post="/plugins/cmdb/decommisioning/{{ object_type }}/{{ object.id }}/delete"
>
{% csrf_token %}
<button class="btn btn-sm btn-danger">Confirm</button>
</form>
</div>
{% endif %}

</div>
</div>
{% endblock content-wrapper%}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<div class="mt-3 mb-2">
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}

<h5 class="mb-2">Deleted objects</h5>

<div class="card-header border-0">
<h6 class="card-title">Device</h6>
<div class="card-body table-responsive">
<table class="table table-hover object-list">
<tr>
<td>{{ deleted_device }}</td>
</tr>
</table>
</div>
</div>

{% for key, objects in deleted_objects.items %}
{% if objects %}
<div class="card-header border-0">
<h6 class="card-title">{{ key }}</h6>
<div class="card-body table-responsive">
<table class="table table-hover object-list">
{% for obj in objects %}
<tr>
<td>{{ obj }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endif %}
{% endfor %}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% if not stop %}
<form
hx-target="#_content"
hx-trigger="load"
hx-include="*"
hx-post="/plugins/cmdb/decommisioning/{{ object_type }}/{{ object.id }}/delete"
>
{% csrf_token %}
<input type="hidden" name="chunks" value="{{ chunks }}" />
</form>
{% endif %}

{# --- #}
{# OOB components #}
{# -- #}

<div id="_status" hx-swap-oob="innerHTML" hx-target="#_status">
{{ status | safe }}
</div>

{% if error %}
<div id="_error" class="alert alert-danger" hx-swap-oob="outerHTML" hx-target="#_error">
{{ error | safe }}
</div>
{% endif %}

<div id="_history" hx-swap-oob="afterbegin" hx-target="#_history">
{{ message | safe }}
</div>
Loading
Loading