Skip to content

Closes #209: Prevent stale branches from being synced #210

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

Merged
merged 1 commit into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)