Skip to content

Commit

Permalink
Closes #209: Prevent stale branches from being synced
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremystretch committed Feb 4, 2025
1 parent 0a98391 commit f5d40a3
Show file tree
Hide file tree
Showing 6 changed files with 63 additions and 8 deletions.
3 changes: 3 additions & 0 deletions docs/using-branches/syncing-merging.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Synchronizing a branch replicates all recent changes from main into the branch.

To synchronize a branch, click the "Sync" button. (If this button is not visible, verify that the branch status shows "ready" and that you have permission to synchronize the branch.)

!!! warning
A branch must be synchronized frequently enough to avoid exceeding NetBox's configured [changelog retention period](https://netboxlabs.com/docs/netbox/en/stable/configuration/miscellaneous/#changelog_retention) (which defaults to 90 days). This is to protect against data loss when replicating changes from main. A branch whose `last_sync` time exceeds the configured retention window can no longer be synced.

While a branch is being synchronized, its status will show "synchronizing."

!!! tip
Expand Down
16 changes: 15 additions & 1 deletion netbox_branching/models/branches.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import random
import string
from datetime import timedelta
from functools import cached_property, partial

from django.conf import settings
Expand All @@ -15,6 +16,7 @@
from django.utils.translation import gettext_lazy as _

from core.models import ObjectChange as ObjectChange_
from netbox.config import get_config
from netbox.context import current_request
from netbox.context_managers import event_tracking
from netbox.models import PrimaryModel
Expand Down Expand Up @@ -121,7 +123,7 @@ def schema_name(self):
def connection_name(self):
return f'schema_{self.schema_name}'

@cached_property
@property
def synced_time(self):
return self.last_sync or self.created

Expand Down Expand Up @@ -240,6 +242,16 @@ def get_event_history(self):
last_time = event.time
return history

@property
def is_stale(self):
"""
Indicates whether the branch is too far out of date to be synced.
"""
if not (changelog_retention := get_config().CHANGELOG_RETENTION):
# Changelog retention is disabled
return False
return self.synced_time < timezone.now() - timedelta(days=changelog_retention)

def sync(self, user, commit=True):
"""
Apply changes from the main schema onto the Branch's schema.
Expand All @@ -249,6 +261,8 @@ def sync(self, user, commit=True):

if not self.ready:
raise Exception(f"Branch {self} is not ready to sync")
if self.is_stale:
raise Exception(f"Branch {self} is stale and can no longer be synced")

# Emit pre-sync signal
pre_sync.send(sender=self.__class__, branch=self, user=user)
Expand Down
16 changes: 12 additions & 4 deletions netbox_branching/tables/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,16 @@ class BranchTable(NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
is_active = columns.BooleanColumn(
verbose_name=_('Active')
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
verbose_name=_('Status')
)
is_stale = columns.BooleanColumn(
true_mark=mark_safe('<span class="text-danger"><i class="mdi mdi-alert-circle"></i></span>'),
false_mark=None,
verbose_name=_('Stale')
)
conflicts = ConflictsColumn(
verbose_name=_('Conflicts')
Expand All @@ -72,11 +80,11 @@ class BranchTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Branch
fields = (
'pk', 'id', 'name', 'is_active', 'status', 'conflicts', 'schema_id', 'description', 'owner', 'tags',
'created', 'last_updated',
'pk', 'id', 'name', 'is_active', 'status', 'is_stale', 'conflicts', 'schema_id', 'description', 'owner',
'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'is_active', 'status', 'owner', 'conflicts', 'schema_id', 'description',
'pk', 'name', 'is_active', 'status', 'is_stale', 'owner', 'conflicts', 'schema_id', 'description',
)

def render_is_active(self, value):
Expand Down
17 changes: 15 additions & 2 deletions netbox_branching/templates/netbox_branching/branch.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,24 @@ <h5 class="card-header">{% trans "Branch" %}</h5>
</tr>
<tr>
<th scope="row">{% trans "Last synced" %}</th>
<td>{{ object.synced_time|isodatetime }}</td>
<td>
{{ object.synced_time|isodatetime }}
{% if object.is_stale %}
<span class="text-danger" title="{% trans "Branch is stale and can no longer be synced" %}">
<i class="mdi mdi-alert-circle"></i>
</span>
{% endif %}
<div class="small text-muted">{{ object.synced_time|timesince }} {% trans "ago" %}</div>
</td>
</tr>
<tr>
<th scope="row">{% trans "Last activity" %}</th>
<td>{{ latest_change.time|isodatetime|placeholder }}</td>
<td>
{{ latest_change.time|isodatetime|placeholder }}
{% if latest_change %}
<div class="small text-muted">{{ latest_change.time|timesince }} {% trans "ago" %}</div>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Conflicts" %}</th>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% load i18n %}
{% if perms.netbox_branching.sync_branch %}
{% if perms.netbox_branching.sync_branch and not branch.is_stale %}
<a href="{% url 'plugins:netbox_branching:branch_sync' pk=branch.pk %}" class="btn btn-primary">
<i class="mdi mdi-sync"></i> {% trans "Sync" %}
</a>
Expand Down
17 changes: 17 additions & 0 deletions netbox_branching/tests/test_branches.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import re
from datetime import timedelta

from django.core.exceptions import ValidationError
from django.db import connection
from django.test import TransactionTestCase, override_settings
from django.utils import timezone

from netbox_branching.choices import BranchStatusChoices
from netbox_branching.constants import MAIN_SCHEMA
Expand Down Expand Up @@ -125,3 +127,18 @@ def test_max_branches(self):
branch = Branch(name='Branch 4')
with self.assertRaises(ValidationError):
branch.full_clean()

@override_settings(CHANGELOG_RETENTION=10)
def test_is_stale(self):
branch = Branch(name='Branch 1')
branch.save(provision=False)

# Set creation time to 9 days in the past
branch.last_sync = timezone.now() - timedelta(days=9)
branch.save()
self.assertFalse(branch.is_stale)

# Set creation time to 11 days in the past
branch.last_sync = timezone.now() - timedelta(days=11)
branch.save()
self.assertTrue(branch.is_stale)

0 comments on commit f5d40a3

Please sign in to comment.