From 9631bf4dd86a382d3b6460cf704d97086fd02f9a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Sep 2024 12:39:24 -0400 Subject: [PATCH 01/19] Don't mark new releases as pre-release --- .github/workflows/release.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fe11654..c53b832 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -112,4 +112,3 @@ jobs: release_name: ${{ needs.get-next-version.outputs.release-version }} body: ${{ needs.get-release-notes.outputs.release-notes }} draft: false - prerelease: true From e67588e6f5709a7f995a3f9028becea73e7476aa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Sep 2024 15:04:20 -0400 Subject: [PATCH 02/19] #52: Correct test method name --- netbox_branching/tests/test_branches.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_branching/tests/test_branches.py b/netbox_branching/tests/test_branches.py index efcfe28..9ba97b9 100644 --- a/netbox_branching/tests/test_branches.py +++ b/netbox_branching/tests/test_branches.py @@ -82,7 +82,7 @@ def test_branch_schema_id(self): 'max_branches': 2, } }) - def text_max_branches(self): + def test_max_branches(self): """ Verify that the max_branches config parameter is enforced. """ From a4a094631e835fcef745a27425253c017ca42b41 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Sep 2024 15:04:49 -0400 Subject: [PATCH 03/19] Correct BranchChangesMergedView name --- netbox_branching/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_branching/views.py b/netbox_branching/views.py index 6346210..7de7d16 100644 --- a/netbox_branching/views.py +++ b/netbox_branching/views.py @@ -141,7 +141,7 @@ def _get_change_count(obj): @register_model_view(Branch, 'changes-merged') -class BranchChangesAheadView(generic.ObjectChildrenView): +class BranchChangesMergedView(generic.ObjectChildrenView): queryset = Branch.objects.all() child_model = ObjectChange filterset = ObjectChangeFilterSet From 662dbcb23a96c14f155ccb9010928e59ac4d8b0c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 6 Sep 2024 14:45:00 -0400 Subject: [PATCH 04/19] Fixes #81: Fix event rule trigger for branch_reverted signal --- netbox_branching/signal_receivers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox_branching/signal_receivers.py b/netbox_branching/signal_receivers.py index da59578..f3d79c6 100644 --- a/netbox_branching/signal_receivers.py +++ b/netbox_branching/signal_receivers.py @@ -127,6 +127,7 @@ def handle_branch_event(event_type, branch, user=None, **kwargs): branch_provisioned.connect(partial(handle_branch_event, event_type=BRANCH_PROVISIONED)) branch_synced.connect(partial(handle_branch_event, event_type=BRANCH_SYNCED)) branch_merged.connect(partial(handle_branch_event, event_type=BRANCH_MERGED)) +branch_reverted.connect(partial(handle_branch_event, event_type=BRANCH_REVERTED)) branch_deprovisioned.connect(partial(handle_branch_event, event_type=BRANCH_DEPROVISIONED)) From f8816e73762353fead2abcfaf8812d9c059f70e8 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 9 Sep 2024 09:39:03 -0700 Subject: [PATCH 05/19] 91 ignore differences in alert for active branch --- netbox_branching/template_content.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox_branching/template_content.py b/netbox_branching/template_content.py index 2d2c154..0078a05 100644 --- a/netbox_branching/template_content.py +++ b/netbox_branching/template_content.py @@ -32,6 +32,8 @@ def alerts(self): object_id=instance.pk ).exclude( branch__status=BranchStatusChoices.MERGED + ).exclude( + branch=active_branch.get() ) branches = [ diff.branch for diff in relevant_changes.only('branch') From 807a6866063fe7fb4569d307e48af0efb7010314 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Sep 2024 10:35:06 -0400 Subject: [PATCH 06/19] Fixes #102: Record individual object actions in branch job logs --- netbox_branching/models/branches.py | 21 ++++++++++++--------- netbox_branching/models/changes.py | 11 ++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/netbox_branching/models/branches.py b/netbox_branching/models/branches.py index b337ab1..85a5ef6 100644 --- a/netbox_branching/models/branches.py +++ b/netbox_branching/models/branches.py @@ -242,7 +242,7 @@ def sync(self, user, commit=True): # Retrieve unsynced changes before we update the Branch's status if changes := self.get_unsynced_changes().order_by('time'): - logger.debug(f"Found {len(changes)} changes to sync") + logger.info(f"Found {len(changes)} changes to sync") else: logger.info(f"No changes found; aborting.") return @@ -256,12 +256,13 @@ def sync(self, user, commit=True): with transaction.atomic(using=self.connection_name): # Apply each change from the main schema for change in changes: - change.apply(using=self.connection_name) + change.apply(using=self.connection_name, logger=logger) if not commit: raise AbortTransaction() except Exception as e: - logger.error(e) + if err_message := str(e): + logger.error(err_message) # Restore original branch status Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.READY) raise e @@ -296,7 +297,7 @@ def merge(self, user, commit=True): # Retrieve staged changes before we update the Branch's status if changes := self.get_unmerged_changes().order_by('time'): - logger.debug(f"Found {len(changes)} changes to merge") + logger.info(f"Found {len(changes)} changes to merge") else: logger.info(f"No changes found; aborting.") return @@ -319,12 +320,13 @@ def merge(self, user, commit=True): with event_tracking(request): request.id = change.request_id request.user = change.user - change.apply(using=DEFAULT_DB_ALIAS) + change.apply(using=DEFAULT_DB_ALIAS, logger=logger) if not commit: raise AbortTransaction() except Exception as e: - logger.error(e) + if err_message := str(e): + logger.error(err_message) # Disconnect signal receiver & restore original branch status post_save.disconnect(handler, sender=ObjectChange_) Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.READY) @@ -364,7 +366,7 @@ def revert(self, user, commit=True): # Retrieve applied changes before we update the Branch's status if changes := self.get_changes().order_by('-time'): - logger.debug(f"Found {len(changes)} changes to revert") + logger.info(f"Found {len(changes)} changes to revert") else: logger.info(f"No changes found; aborting.") return @@ -387,12 +389,13 @@ def revert(self, user, commit=True): with event_tracking(request): request.id = change.request_id request.user = change.user - change.undo() + change.undo(logger=logger) if not commit: raise AbortTransaction() except Exception as e: - logger.error(e) + if err_message := str(e): + logger.error(err_message) # Disconnect signal receiver & restore original branch status post_save.disconnect(handler, sender=ObjectChange_) Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.MERGED) diff --git a/netbox_branching/models/changes.py b/netbox_branching/models/changes.py index 8c894db..1890823 100644 --- a/netbox_branching/models/changes.py +++ b/netbox_branching/models/changes.py @@ -27,13 +27,13 @@ class ObjectChange(ObjectChange_): class Meta: proxy = True - def apply(self, using=DEFAULT_DB_ALIAS): + def apply(self, using=DEFAULT_DB_ALIAS, logger=None): """ Apply the change using the specified database connection. """ - logger = logging.getLogger('netbox_branching.models.ObjectChange.apply') + logger = logger or logging.getLogger('netbox_branching.models.ObjectChange.apply') model = self.changed_object_type.model_class() - logger.debug(f'Applying change {self} using {using}') + logger.info(f'Applying change {self} using {using}') # Creating a new object if self.action == ObjectChangeActionChoices.ACTION_CREATE: @@ -62,12 +62,13 @@ def apply(self, using=DEFAULT_DB_ALIAS): apply.alters_data = True - def undo(self, using=DEFAULT_DB_ALIAS): + def undo(self, using=DEFAULT_DB_ALIAS, logger=None): """ Revert a previously applied change using the specified database connection. """ - logger = logging.getLogger('netbox_branching.models.ObjectChange.undo') + logger = logger or logging.getLogger('netbox_branching.models.ObjectChange.undo') model = self.changed_object_type.model_class() + logger.info(f'Undoing change {self} using {using}') # Deleting a previously created object if self.action == ObjectChangeActionChoices.ACTION_CREATE: From 94d75cb2c33d4a431d90b6f60f99f5a5e30229a2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Sep 2024 12:30:37 -0400 Subject: [PATCH 07/19] Closes #90: Implement branch archiving (#96) * Closes #90: Implement branch archiving * Exclude archived branch from nav dropdown * Exclude archived branch from nav dropdown * Fix translation support for branch action messages --- docs/models/branch.md | 1 + docs/using-branches/reverting-a-branch.md | 4 +- docs/using-branches/syncing-merging.md | 2 + netbox_branching/choices.py | 4 ++ netbox_branching/forms/misc.py | 8 +++ netbox_branching/models/branches.py | 14 ++++- netbox_branching/template_content.py | 4 +- .../templates/netbox_branching/branch.html | 9 +++ .../netbox_branching/branch_archive.html | 43 ++++++++++++++ netbox_branching/views.py | 56 +++++++++++++++++-- 10 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 netbox_branching/templates/netbox_branching/branch_archive.html diff --git a/docs/models/branch.md b/docs/models/branch.md index 560c87e..1ecbf59 100644 --- a/docs/models/branch.md +++ b/docs/models/branch.md @@ -29,6 +29,7 @@ The current status of the branch. This must be one of the following values. | Merging | A job is running to merge changes from the branch into main | | Reverting | A job is running to revert previously merged changes in main | | Merged | Changes from this branch have been successfully merged into main | +| Archived | A merged branch which has been deprovisioned in the database | | Failed | Provisioning the schema for this branch has failed | ### Last Sync diff --git a/docs/using-branches/reverting-a-branch.md b/docs/using-branches/reverting-a-branch.md index 5de33b5..68be470 100644 --- a/docs/using-branches/reverting-a-branch.md +++ b/docs/using-branches/reverting-a-branch.md @@ -2,8 +2,8 @@ Once a branch has been merged, it is generally no longer needed, and can no longer be activated. However, occasionally you may find it necessary to undo the changes from a branch (due to an error or an otherwise undesired state). This can be done by _reverting_ the branch. Only merged branches can be reverted. -!!! note - Only branches which have not yet been deleted can be reverted. Once a branch is deleted, reversion is no longer possible. +!!! warning + Only branches which have not yet been archived or deleted can be reverted. Once a branch's schema has been deprovisioned, it can no longer be reverted. Before reverting a branch, review the changes listed under its "Merged Changes" tab. NetBox will attempt to undo these specific changes when reverting the branch. diff --git a/docs/using-branches/syncing-merging.md b/docs/using-branches/syncing-merging.md index 27d5a55..af3c8f9 100644 --- a/docs/using-branches/syncing-merging.md +++ b/docs/using-branches/syncing-merging.md @@ -22,6 +22,8 @@ While a branch is being merged, its status will show "merging." !!! tip You can check on the status of the merging job under the "Jobs" tab of the branch view. +Once a branch has been merged, it can be [reverted](./reverting-a-branch.md), archived, or deleted. Archiving a branch removes its associated schema from the PostgreSQL database to deallocate space. An archived branch cannot be restored, however the branch record is retained for future reference. + ## Dealing with Conflicts In the event an object has been modified in both your branch _and_ in main in a diverging manner, this will be flagged as a conflict. For example, if both you and another user have modified the description of an interface to two different values in main and in the branch, this represents a conflict. diff --git a/netbox_branching/choices.py b/netbox_branching/choices.py index db8c9c8..6e49d2f 100644 --- a/netbox_branching/choices.py +++ b/netbox_branching/choices.py @@ -11,6 +11,7 @@ class BranchStatusChoices(ChoiceSet): MERGING = 'merging' REVERTING = 'reverting' MERGED = 'merged' + ARCHIVED = 'archived' FAILED = 'failed' CHOICES = ( @@ -21,6 +22,7 @@ class BranchStatusChoices(ChoiceSet): (MERGING, _('Merging'), 'orange'), (REVERTING, _('Reverting'), 'orange'), (MERGED, _('Merged'), 'blue'), + (ARCHIVED, _('Archived'), 'gray'), (FAILED, _('Failed'), 'red'), ) @@ -37,10 +39,12 @@ class BranchEventTypeChoices(ChoiceSet): SYNCED = 'synced' MERGED = 'merged' REVERTED = 'reverted' + ARCHIVED = 'archived' CHOICES = ( (PROVISIONED, _('Provisioned'), 'green'), (SYNCED, _('Synced'), 'cyan'), (MERGED, _('Merged'), 'blue'), (REVERTED, _('Reverted'), 'orange'), + (ARCHIVED, _('Archived'), 'gray'), ) diff --git a/netbox_branching/forms/misc.py b/netbox_branching/forms/misc.py index 2177f2f..ad895cb 100644 --- a/netbox_branching/forms/misc.py +++ b/netbox_branching/forms/misc.py @@ -5,6 +5,7 @@ __all__ = ( 'BranchActionForm', + 'ConfirmationForm', ) @@ -36,3 +37,10 @@ def clean(self): raise forms.ValidationError(_("All conflicts must be acknowledged in order to merge the branch.")) return self.cleaned_data + + +class ConfirmationForm(forms.Form): + confirm = forms.BooleanField( + required=True, + label=_('Confirm') + ) diff --git a/netbox_branching/models/branches.py b/netbox_branching/models/branches.py index 85a5ef6..8887f77 100644 --- a/netbox_branching/models/branches.py +++ b/netbox_branching/models/branches.py @@ -420,7 +420,7 @@ def revert(self, user, commit=True): # Disconnect the signal receiver post_save.disconnect(handler, sender=ObjectChange_) - merge.alters_data = True + revert.alters_data = True def provision(self, user): """ @@ -515,6 +515,18 @@ def provision(self, user): provision.alters_data = True + def archive(self, user): + """ + Deprovision the Branch and set its status to "archived." + """ + self.deprovision() + + # Update the branch's status to "archived" + Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.ARCHIVED) + BranchEvent.objects.create(branch=self, user=user, type=BranchEventTypeChoices.ARCHIVED) + + archive.alters_data = True + def deprovision(self): """ Delete the Branch's schema and all its tables from the database. diff --git a/netbox_branching/template_content.py b/netbox_branching/template_content.py index 0078a05..3ef7507 100644 --- a/netbox_branching/template_content.py +++ b/netbox_branching/template_content.py @@ -17,7 +17,9 @@ class BranchSelector(PluginTemplateExtension): def navbar(self): return self.render('netbox_branching/inc/branch_selector.html', extra_context={ 'active_branch': active_branch.get(), - 'branches': Branch.objects.exclude(status=BranchStatusChoices.MERGED), + 'branches': Branch.objects.exclude( + status__in=[BranchStatusChoices.MERGED, BranchStatusChoices.ARCHIVED] + ), }) diff --git a/netbox_branching/templates/netbox_branching/branch.html b/netbox_branching/templates/netbox_branching/branch.html index 5d8d6b1..75edc00 100644 --- a/netbox_branching/templates/netbox_branching/branch.html +++ b/netbox_branching/templates/netbox_branching/branch.html @@ -51,6 +51,15 @@ {% trans "Revert" %} {% endif %} + {% if perms.netbox_branching.archive_branch %} + + {% trans "Archive" %} + + {% else %} + + {% endif %} {% endif %} {% endblock %} diff --git a/netbox_branching/templates/netbox_branching/branch_archive.html b/netbox_branching/templates/netbox_branching/branch_archive.html new file mode 100644 index 0000000..8849acc --- /dev/null +++ b/netbox_branching/templates/netbox_branching/branch_archive.html @@ -0,0 +1,43 @@ +{% extends 'generic/_base.html' %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{% trans "Archive" %} {{ branch }}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} + {# Form tab #} +
+
+ {% csrf_token %} +
+
+
+ + {% blocktrans %} + Are you sure you want to archive the branch {{ branch }}? This will permanently deprovision + its database schema, and it will no longer be possible to automatically rever the branch. + {% endblocktrans %} +
+ {% render_field form.confirm %} +
+ {% trans "Cancel" %} + +
+
+
+
+
+ {# /Form tab #} +{% endblock content %} diff --git a/netbox_branching/views.py b/netbox_branching/views.py index 7de7d16..38fb44c 100644 --- a/netbox_branching/views.py +++ b/netbox_branching/views.py @@ -12,7 +12,7 @@ from . import filtersets, forms, tables from .choices import BranchStatusChoices from .jobs import MergeBranchJob, RevertBranchJob, SyncBranchJob -from .models import ChangeDiff, Branch +from .models import Branch, ChangeDiff # @@ -186,7 +186,7 @@ def do_action(self, branch, request, form): def get(self, request, **kwargs): branch = self.get_object(**kwargs) - form = forms.BranchActionForm(branch) + form = self.form(branch) return render(request, self.template_name, { 'branch': branch, @@ -197,7 +197,7 @@ def get(self, request, **kwargs): def post(self, request, **kwargs): branch = self.get_object(**kwargs) - form = forms.BranchActionForm(branch, request.POST) + form = self.form(branch, request.POST) if branch.status not in self.valid_states: messages.error(request, _( @@ -225,7 +225,7 @@ def do_action(self, branch, request, form): user=request.user, commit=form.cleaned_data['commit'] ) - messages.success(request, f"Syncing of branch {branch} in progress") + messages.success(request, _("Syncing of branch {branch} in progress").format(branch=branch)) return redirect(branch.get_absolute_url()) @@ -241,7 +241,7 @@ def do_action(self, branch, request, form): user=request.user, commit=form.cleaned_data['commit'] ) - messages.success(request, f"Merging of branch {branch} in progress") + messages.success(request, _("Merging of branch {branch} in progress").format(branch=branch)) return redirect(branch.get_absolute_url()) @@ -260,11 +260,55 @@ def do_action(self, branch, request, form): user=request.user, commit=form.cleaned_data['commit'] ) - messages.success(request, f"Reverting branch {branch}") + messages.success(request, _("Reverting branch {branch}").format(branch=branch)) return redirect(branch.get_absolute_url()) +@register_model_view(Branch, 'archive') +class BranchArchiveView(generic.ObjectView): + """ + Archive a merged Branch, deleting its database schema but retaining the Branch object. + """ + queryset = Branch.objects.all() + template_name = 'netbox_branching/branch_archive.html' + + def get_required_permission(self): + return f'netbox_branching.archive_branch' + + @staticmethod + def _enforce_status(request, branch): + if branch.status != BranchStatusChoices.MERGED: + messages.error(request, _("Only merged branches can be archived.")) + return redirect(branch.get_absolute_url()) + + def get(self, request, **kwargs): + branch = self.get_object(**kwargs) + self._enforce_status(request, branch) + form = forms.ConfirmationForm() + + return render(request, self.template_name, { + 'branch': branch, + 'form': form, + }) + + def post(self, request, **kwargs): + branch = self.get_object(**kwargs) + self._enforce_status(request, branch) + form = forms.ConfirmationForm(request.POST) + + if form.is_valid(): + branch.archive(user=request.user) + + messages.success(request, _("Branch {branch} has been archived.").format(branch=branch)) + return redirect(branch.get_absolute_url()) + + return render(request, self.template_name, { + 'branch': branch, + 'form': form, + }) + + class BranchBulkImportView(generic.BulkImportView): queryset = Branch.objects.all() model_form = forms.BranchImportForm From 0fa0955324d4af26c73a16b0ea614ef355c614c5 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 10 Sep 2024 11:00:29 -0700 Subject: [PATCH 08/19] 88 enable branching in graphql (#104) * 88 enable branching in graphql * Tweak formatting of conditional logic --------- Co-authored-by: Jeremy Stretch --- netbox_branching/middleware.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/netbox_branching/middleware.py b/netbox_branching/middleware.py index 73de3dd..79f9bbc 100644 --- a/netbox_branching/middleware.py +++ b/netbox_branching/middleware.py @@ -45,12 +45,13 @@ def get_active_branch(request): """ Return the active Branch (if any). """ - # The active Branch is specified by HTTP header for REST API requests. - if request.path_info.startswith(reverse('api-root')) and (schema_id := request.headers.get(BRANCH_HEADER)): - branch = Branch.objects.get(schema_id=schema_id) - if not branch.ready: - return HttpResponseBadRequest(f"Branch {branch} is not ready for use (status: {branch.status})") - return branch + # The active Branch is specified by HTTP header for REST & GraphQL API requests. + if request.path_info.startswith(reverse('api-root')) or request.path_info.startswith(reverse('graphql')): + if schema_id := request.headers.get(BRANCH_HEADER): + branch = Branch.objects.get(schema_id=schema_id) + if not branch.ready: + return HttpResponseBadRequest(f"Branch {branch} is not ready for use (status: {branch.status})") + return branch # Branch activated/deactivated by URL query parameter elif QUERY_PARAM in request.GET: From d8f1cfd2ac7664dae9f598e130a418cb78db2355 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Sep 2024 13:40:18 -0400 Subject: [PATCH 09/19] Fixes #94: Resolve model field name for custom field data --- netbox_branching/utilities.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox_branching/utilities.py b/netbox_branching/utilities.py index 539573b..74431b2 100644 --- a/netbox_branching/utilities.py +++ b/netbox_branching/utilities.py @@ -136,6 +136,10 @@ def update_object(instance, data, using): m2m_assignments = {} for attr, value in data.items(): + # Account for custom field data + if attr == 'custom_fields': + attr = 'custom_field_data' + model_field = instance._meta.get_field(attr) field_cls = model_field.__class__ From e5ba5d17a6cd903b903b0950aaf549f83f5069cf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Sep 2024 14:27:40 -0400 Subject: [PATCH 10/19] Closes #84: Introduce the `max_active_branches` config parameter (#100) * Closes #84: Introduce the max_active_branches parameter * Rename max_active_branches to max_working_branches * Update error message --- docs/configuration.md | 10 +++++++++- netbox_branching/__init__.py | 3 +++ netbox_branching/choices.py | 8 +++++++- netbox_branching/models/branches.py | 17 ++++++++++++++--- netbox_branching/template_content.py | 4 +--- netbox_branching/tests/test_branches.py | 25 +++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6e141aa..f70b842 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,10 +1,18 @@ # Configuration Parameters +## `max_working_branches` + +Default: None + +The maximum number of operational branches that can exist simultaneously. This count excludes branches which have been merged or archived. + +--- + ## `max_branches` Default: None -The maximum number of branches that can exist simultaneously, including merged branches that have not been deleted. It may be desirable to limit the total number of provisioned branches to safeguard against excessive database size. +The maximum total number of branches that can exist simultaneously, including merged branches that have not been deleted. It may be desirable to limit the total number of provisioned branches to safeguard against excessive database size. --- diff --git a/netbox_branching/__init__.py b/netbox_branching/__init__.py index 5174856..a17aa6c 100644 --- a/netbox_branching/__init__.py +++ b/netbox_branching/__init__.py @@ -16,6 +16,9 @@ class AppConfig(PluginConfig): 'netbox_branching.middleware.BranchMiddleware' ] default_settings = { + # The maximum number of working branches (excludes merged & archived branches) + 'max_working_branches': None, + # The maximum number of branches which can be provisioned simultaneously 'max_branches': None, diff --git a/netbox_branching/choices.py b/netbox_branching/choices.py index 6e49d2f..90c6f46 100644 --- a/netbox_branching/choices.py +++ b/netbox_branching/choices.py @@ -30,7 +30,13 @@ class BranchStatusChoices(ChoiceSet): PROVISIONING, SYNCING, MERGING, - REVERTING + REVERTING, + ) + + WORKING = ( + NEW, + READY, + *TRANSITIONAL, ) diff --git a/netbox_branching/models/branches.py b/netbox_branching/models/branches.py index 8887f77..23591d0 100644 --- a/netbox_branching/models/branches.py +++ b/netbox_branching/models/branches.py @@ -127,10 +127,10 @@ def synced_time(self): def clean(self): - # Check whether we're exceeding the maximum number of Branches + # Enforce the maximum number of total branches if not self.pk and (max_branches := get_plugin_config('netbox_branching', 'max_branches')): - branch_count = Branch.objects.count() - if branch_count >= max_branches: + total_branch_count = Branch.objects.count() + if total_branch_count >= max_branches: raise ValidationError( _( "The configured maximum number of branches ({max}) cannot be exceeded. One or more existing " @@ -138,6 +138,17 @@ def clean(self): ).format(max=max_branches) ) + # Enforce the maximum number of active branches + if not self.pk and (max_working_branches := get_plugin_config('netbox_branching', 'max_working_branches')): + working_branch_count = Branch.objects.filter(status__in=BranchStatusChoices.WORKING).count() + if working_branch_count >= max_working_branches: + raise ValidationError( + _( + "The configured maximum number of working branches ({max}) cannot be exceeded. One or more " + "working branches must be merged or archived before a new branch may be created." + ).format(max=max_working_branches) + ) + def save(self, provision=True, *args, **kwargs): """ Args: diff --git a/netbox_branching/template_content.py b/netbox_branching/template_content.py index 3ef7507..13bea68 100644 --- a/netbox_branching/template_content.py +++ b/netbox_branching/template_content.py @@ -17,9 +17,7 @@ class BranchSelector(PluginTemplateExtension): def navbar(self): return self.render('netbox_branching/inc/branch_selector.html', extra_context={ 'active_branch': active_branch.get(), - 'branches': Branch.objects.exclude( - status__in=[BranchStatusChoices.MERGED, BranchStatusChoices.ARCHIVED] - ), + 'branches': Branch.objects.filter(status__in=BranchStatusChoices.WORKING), }) diff --git a/netbox_branching/tests/test_branches.py b/netbox_branching/tests/test_branches.py index 9ba97b9..6cbd01d 100644 --- a/netbox_branching/tests/test_branches.py +++ b/netbox_branching/tests/test_branches.py @@ -4,6 +4,7 @@ from django.db import connection from django.test import TransactionTestCase, override_settings +from netbox_branching.choices import BranchStatusChoices from netbox_branching.constants import MAIN_SCHEMA from netbox_branching.models import Branch from netbox_branching.utilities import get_tables_to_replicate @@ -77,6 +78,30 @@ def test_branch_schema_id(self): branch.refresh_from_db() self.assertEqual(branch.schema_id, schema_id, msg="Schema ID was changed during save()") + @override_settings(PLUGINS_CONFIG={ + 'netbox_branching': { + 'max_working_branches': 2, + } + }) + def test_max_working_branches(self): + """ + Verify that the max_working_branches config parameter is enforced. + """ + Branch.objects.bulk_create(( + Branch(name='Branch 1', status=BranchStatusChoices.MERGED), + Branch(name='Branch 2', status=BranchStatusChoices.READY), + )) + + # Second active branch should be permitted (merged branches don't count) + branch = Branch(name='Branch 3') + branch.full_clean() + branch.save() + + # Attempting to create a third active branch should fail + branch = Branch(name='Branch 4') + with self.assertRaises(ValidationError): + branch.full_clean() + @override_settings(PLUGINS_CONFIG={ 'netbox_branching': { 'max_branches': 2, From ba0cb48ee8311251b004d0a1c01c84749ee83723 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 10 Sep 2024 14:52:48 -0400 Subject: [PATCH 11/19] Update changelog --- docs/changelog.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 79d43f9..264c4a7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,22 @@ # Change Log +## v0.5.0 + +### Enhancements + +* [#84](https://github.com/netboxlabs/nbl-netbox-branching/issues/84) - Introduce the `max_working_branches` configuration parameter +* [#88](https://github.com/netboxlabs/nbl-netbox-branching/issues/88) - Add branching support for NetBox's graphQL API +* [#90](https://github.com/netboxlabs/nbl-netbox-branching/issues/90) - Introduce the ability to archive & deprovision merged branches without deleting them + +### Bug Fixes + +* [#81](https://github.com/netboxlabs/nbl-netbox-branching/issues/81) - Fix event rule triggering for the `branch_reverted` event +* [#91](https://github.com/netboxlabs/nbl-netbox-branching/issues/91) - Disregard the active branch (if any) when alerting on changes under object views +* [#94](https://github.com/netboxlabs/nbl-netbox-branching/issues/94) - Fix branch merging after modifying an object with custom field data +* [#102](https://github.com/netboxlabs/nbl-netbox-branching/issues/102) - Record individual object actions in branch job logs + +--- + ## v0.4.0 ### Enhancements From d5193bd35b29e451d342011f66e55591074ff6a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Sep 2024 09:59:30 -0400 Subject: [PATCH 12/19] Closes #112: Document the need for create permissions in PostgreSQL --- README.md | 16 +++++++++++----- docs/index.md | 8 ++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1307b08..70b0fd2 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,25 @@ This [NetBox](http://netboxlabs.com/oss/netbox/) plugin introduces branching fun Brief installation instructions are provided below. For a complete installation guide, please refer to the included documentation. -1. Activate the NetBox virtual environment: +1. Grant PostgreSQL permission for the NetBox database user to create schemas: + +```postgresql +GRANT CREATE ON DATABASE $database TO $user; +``` + +2. Activate the NetBox virtual environment: ``` $ source /opt/netbox/venv/bin/activate ``` -2. Install the plugin from [PyPI](https://pypi.org/project/netboxlabs-netbox-branching/): +3. Install the plugin from [PyPI](https://pypi.org/project/netboxlabs-netbox-branching/): ``` $ pip install netboxlabs-netbox-branching ``` -3. Add `netbox_branching` to `PLUGINS` in `configuration.py`: +4. Add `netbox_branching` to `PLUGINS` in `configuration.py`: ```python PLUGINS = [ @@ -32,7 +38,7 @@ PLUGINS = [ ] ``` -4. Create `local_settings.py` to override the `DATABASES` & `DATABASE_ROUTERS` settings. This enables dynamic schema support. +5. Create `local_settings.py` to override the `DATABASES` & `DATABASE_ROUTERS` settings. This enables dynamic schema support. ```python from netbox_branching.utilities import DynamicSchemaDict @@ -49,7 +55,7 @@ DATABASE_ROUTERS = [ ] ``` -5. Run NetBox migrations: +6. Run NetBox migrations: ``` $ ./manage.py migrate diff --git a/docs/index.md b/docs/index.md index a3611f0..1a6939c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,6 +74,14 @@ sequenceDiagram ## Getting Started +### Database Preparation + +Before installing this plugin, ensure that the PostgreSQL user as which NetBox authenticates has permission to create new schemas in the database. This can be achieved by issuing the following command in the PostgreSQL shell (substituting `$database` and `$user` with their respective values): + +```postgresql +GRANT CREATE ON DATABASE $database TO $user; +``` + ### Plugin Installation #### 1. Virtual Environment From be5c2a4044d50b626d48852e94bafe85a37a42d0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Sep 2024 14:28:51 -0400 Subject: [PATCH 13/19] Closes #97: Introduce the `exempt_models` config parameter (#109) * Closes #97: Introduce the exempt_models config parameter to disable branching support for specific plugin models * Add warning --- docs/configuration.md | 28 ++++++++++++++++++++++++++++ netbox_branching/__init__.py | 24 ++++++++++++++++++------ netbox_branching/constants.py | 8 ++++---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f70b842..bc13a04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,5 +1,33 @@ # Configuration Parameters +## `exempt_models` + +Default: `[]` (empty list) + +A list of models provided by other plugins which should be exempt from branching support. (Only models which support change logging need be listed; all other models are ineligible for branching support.) + +!!! warning + A model may not be exempted from branching support if it has one or more relationships to models for which branching is supported. Branching **must** be supported consistently for all inter-related models; otherwise, data corruption can occur. Configure this setting only if you have a specific need to disable branching for certain models provided by plugins. + +Models must be specified by app label and model name, as such: + +```python +exempt_models = ( + 'my_plugin.foo', + 'my_plugin.bar', +) +``` + +It is also possible to exclude _all_ models from within a plugin by substituting an asterisk (`*`) for the model name: + +```python +exempt_models = ( + 'my_plugin.*', +) +``` + +--- + ## `max_working_branches` Default: None diff --git a/netbox_branching/__init__.py b/netbox_branching/__init__.py index a17aa6c..4aeb70c 100644 --- a/netbox_branching/__init__.py +++ b/netbox_branching/__init__.py @@ -1,7 +1,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from netbox.plugins import PluginConfig +from netbox.plugins import PluginConfig, get_plugin_config from netbox.registry import registry @@ -22,6 +22,9 @@ class AppConfig(PluginConfig): # The maximum number of branches which can be provisioned simultaneously 'max_branches': None, + # Models from other plugins which should be excluded from branching support + 'exempt_models': [], + # This string is prefixed to the name of each new branch schema during provisioning 'schema_prefix': 'branch_', } @@ -42,11 +45,20 @@ def ready(self): ) # Record all object types which support branching in the NetBox registry - if 'branching' not in registry['model_features']: - registry['model_features']['branching'] = { - k: v for k, v in registry['model_features']['change_logging'].items() - if k not in constants.EXCLUDED_APPS - } + exempt_models = ( + *constants.EXEMPT_MODELS, + *get_plugin_config('netbox_branching', 'exempt_models'), + ) + branching_models = {} + for app_label, models in registry['model_features']['change_logging'].items(): + # Wildcard exclusion for all models in this app + if f'{app_label}.*' in exempt_models: + continue + branching_models[app_label] = [ + model for model in models + if f'{app_label}.{model}' not in exempt_models + ] + registry['model_features']['branching'] = branching_models config = AppConfig diff --git a/netbox_branching/constants.py b/netbox_branching/constants.py index c7743b5..d641b63 100644 --- a/netbox_branching/constants.py +++ b/netbox_branching/constants.py @@ -10,8 +10,8 @@ # URL query parameter name QUERY_PARAM = '_branch' -# Apps which are explicitly excluded from branching -EXCLUDED_APPS = ( - 'netbox_branching', - 'netbox_changes', +# Models for which branching support is explicitly disabled +EXEMPT_MODELS = ( + 'netbox_branching.*', + 'netbox_changes.*', ) From ef6e16f06336068c87ed22bad946bd2a96bb2dda Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Sep 2024 14:03:31 -0400 Subject: [PATCH 14/19] Closes #101: Permit (but warn about) database queries issued before branching support has been initialized --- netbox_branching/database.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/netbox_branching/database.py b/netbox_branching/database.py index 161c6e7..842f881 100644 --- a/netbox_branching/database.py +++ b/netbox_branching/database.py @@ -1,3 +1,5 @@ +import warnings + from netbox.registry import registry from .contextvars import active_branch @@ -14,6 +16,11 @@ class BranchAwareRouter: the active branch (if any). """ def _get_db(self, model, **hints): + # Warn & exit if branching support has not yet been initialized + if 'branching' not in registry['model_features']: + warnings.warn(f"Routing database query for {model} before branching support is initialized.") + return + # Bail if the model does not support branching app_label, model_name = model._meta.label.lower().split('.') if model_name not in registry['model_features']['branching'].get(app_label, []): From 7ae83caae57e877dbd86553af91074c5d2ba7bc2 Mon Sep 17 00:00:00 2001 From: bctiemann Date: Wed, 11 Sep 2024 15:32:26 -0400 Subject: [PATCH 15/19] Fixes: #83 - Add Share button for object views when a branch is selected (#110) * Add Share button for object views when a branch is selected * Use {{ object.get_absolute_url }} to get link url explicitly * Add title attr to share button --- netbox_branching/template_content.py | 10 +++++++++- .../templates/netbox_branching/inc/share_button.html | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 netbox_branching/templates/netbox_branching/inc/share_button.html diff --git a/netbox_branching/template_content.py b/netbox_branching/template_content.py index 13bea68..063a9df 100644 --- a/netbox_branching/template_content.py +++ b/netbox_branching/template_content.py @@ -21,6 +21,14 @@ def navbar(self): }) +class ShareButton(PluginTemplateExtension): + + def buttons(self): + return self.render('netbox_branching/inc/share_button.html', extra_context={ + 'active_branch': active_branch.get(), + }) + + class BranchNotification(PluginTemplateExtension): def alerts(self): @@ -43,4 +51,4 @@ def alerts(self): }) -template_extensions = [BranchSelector, BranchNotification] +template_extensions = [BranchSelector, ShareButton, BranchNotification] diff --git a/netbox_branching/templates/netbox_branching/inc/share_button.html b/netbox_branching/templates/netbox_branching/inc/share_button.html new file mode 100644 index 0000000..79fe1d5 --- /dev/null +++ b/netbox_branching/templates/netbox_branching/inc/share_button.html @@ -0,0 +1,6 @@ +{% if active_branch %} + {% load i18n %} + + {% trans "Share" %} + +{% endif %} From dfc041d472480c36813e27a3cde3674c5105da5a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Sep 2024 15:41:18 -0400 Subject: [PATCH 16/19] Closes #115: Note that netbox_branching must be last entry in PLUGINS --- README.md | 2 +- docs/index.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 70b0fd2..350c238 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ $ source /opt/netbox/venv/bin/activate $ pip install netboxlabs-netbox-branching ``` -4. Add `netbox_branching` to `PLUGINS` in `configuration.py`: +4. Add `netbox_branching` to the end of `PLUGINS` in `configuration.py`. Note that `netbox_branching` **MUST** be the last plugin listed. ```python PLUGINS = [ diff --git a/docs/index.md b/docs/index.md index 1a6939c..162404f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -105,7 +105,7 @@ pip install netboxlabs-netbox-branching #### 3. Enable Plugin -Add `netbox_branching` to the list `PLUGINS` list in `configuration.py`. +Add `netbox_branching` to **the end** of the `PLUGINS` list in `configuration.py`. ```python PLUGINS = [ @@ -114,6 +114,9 @@ PLUGINS = [ ] ``` +!!! warning + `netbox_branching` must be the **last** (or only) plugin in the list. Branching support will not be registered for models provided by any plugin appearing later in the list. + !!! note If there are no plugins already installed, you might need to create this parameter. If so, be sure to define `PLUGINS` as a list _containing_ the plugin name as above, rather than just the name. From 9c40af6f754a4554327ecac9cb70bb6355b6ca4f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 11 Sep 2024 15:43:58 -0400 Subject: [PATCH 17/19] Update changelog --- docs/changelog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 264c4a7..dccddc5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,15 +4,18 @@ ### Enhancements +* [#83](https://github.com/netboxlabs/nbl-netbox-branching/issues/83) - Add a "share" button under object views when a branch is active * [#84](https://github.com/netboxlabs/nbl-netbox-branching/issues/84) - Introduce the `max_working_branches` configuration parameter * [#88](https://github.com/netboxlabs/nbl-netbox-branching/issues/88) - Add branching support for NetBox's graphQL API * [#90](https://github.com/netboxlabs/nbl-netbox-branching/issues/90) - Introduce the ability to archive & deprovision merged branches without deleting them +* [#97](https://github.com/netboxlabs/nbl-netbox-branching/issues/97) - Introduce the `exempt_models` config parameter to disable branching support for plugin models ### Bug Fixes * [#81](https://github.com/netboxlabs/nbl-netbox-branching/issues/81) - Fix event rule triggering for the `branch_reverted` event * [#91](https://github.com/netboxlabs/nbl-netbox-branching/issues/91) - Disregard the active branch (if any) when alerting on changes under object views * [#94](https://github.com/netboxlabs/nbl-netbox-branching/issues/94) - Fix branch merging after modifying an object with custom field data +* [#101](https://github.com/netboxlabs/nbl-netbox-branching/issues/101) - Permit (but warn about) database queries issued before branching support has been initialized * [#102](https://github.com/netboxlabs/nbl-netbox-branching/issues/102) - Record individual object actions in branch job logs --- From 00e34169d688578ac786f88be4d013bdd33907fd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Sep 2024 15:09:38 -0400 Subject: [PATCH 18/19] Closes #116: Disable branching support for applicable core models --- netbox_branching/__init__.py | 4 +++- netbox_branching/constants.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/netbox_branching/__init__.py b/netbox_branching/__init__.py index 4aeb70c..7190892 100644 --- a/netbox_branching/__init__.py +++ b/netbox_branching/__init__.py @@ -54,10 +54,12 @@ def ready(self): # Wildcard exclusion for all models in this app if f'{app_label}.*' in exempt_models: continue - branching_models[app_label] = [ + models = [ model for model in models if f'{app_label}.{model}' not in exempt_models ] + if models: + branching_models[app_label] = models registry['model_features']['branching'] = branching_models diff --git a/netbox_branching/constants.py b/netbox_branching/constants.py index d641b63..ea9e7fa 100644 --- a/netbox_branching/constants.py +++ b/netbox_branching/constants.py @@ -12,6 +12,19 @@ # Models for which branching support is explicitly disabled EXEMPT_MODELS = ( + # Exempt applicable core NetBox models + 'core.*', + 'extras.branch', + 'extras.customfield', + 'extras.customfieldchoiceset', + 'extras.customlink', + 'extras.eventrule', + 'extras.exporttemplate', + 'extras.notificationgroup', + 'extras.savedfilter', + 'extras.webhook', + + # Exempt all models from this plugin and from netbox-changes 'netbox_branching.*', 'netbox_changes.*', ) From b623e53749fbc86c8e372be10cd3cc949f7c9b84 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 12 Sep 2024 16:46:01 -0400 Subject: [PATCH 19/19] Release v0.5.0 --- docs/changelog.md | 1 + netbox_branching/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index dccddc5..a384c0a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,7 @@ * [#88](https://github.com/netboxlabs/nbl-netbox-branching/issues/88) - Add branching support for NetBox's graphQL API * [#90](https://github.com/netboxlabs/nbl-netbox-branching/issues/90) - Introduce the ability to archive & deprovision merged branches without deleting them * [#97](https://github.com/netboxlabs/nbl-netbox-branching/issues/97) - Introduce the `exempt_models` config parameter to disable branching support for plugin models +* [#116](https://github.com/netboxlabs/nbl-netbox-branching/issues/116) - Disable branching support for applicable core models ### Bug Fixes diff --git a/netbox_branching/__init__.py b/netbox_branching/__init__.py index 7190892..de903be 100644 --- a/netbox_branching/__init__.py +++ b/netbox_branching/__init__.py @@ -9,7 +9,7 @@ class AppConfig(PluginConfig): name = 'netbox_branching' verbose_name = 'NetBox Branching' description = 'A git-like branching implementation for NetBox' - version = '0.4.0' + version = '0.5.0' base_url = 'branching' min_version = '4.1' middleware = [ diff --git a/pyproject.toml b/pyproject.toml index af09f1c..f20d422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netboxlabs-netbox-branching" -version = "0.4.0" +version = "0.5.0" description = "A git-like branching implementation for NetBox" readme = "README.md" requires-python = ">=3.10"