├── netbox_config_backup ├── tasks.py ├── tests │ ├── __init__.py │ ├── test_forms.py │ ├── test_api.py │ ├── test_models.py │ ├── test_views.py │ └── test_filtersets.py ├── backup │ ├── __init__.py │ └── processing.py ├── graphql │ ├── __init__.py │ ├── schema.py │ ├── types.py │ └── filters.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── listbackups.py │ │ ├── runbackup.py │ │ ├── fix_missing.py │ │ └── rebuild.py ├── migrations │ ├── __init__.py │ ├── 0011_alter_backup_name.py │ ├── 0005_commit_add_time_field.py │ ├── 0012_backup_status.py │ ├── 0021_backupfile_last_change.py │ ├── 0014_backup_config_status.py │ ├── 0008_backupjob_scheduled_nullable.py │ ├── 0016_add_pid_to_backup_job.py │ ├── 0004_custom_constraints.py │ ├── 0007_backup_job_add_scheduled.py │ ├── 0015_backup_comments_backup_description.py │ ├── 0006_backup_add_commit_last_time.py │ ├── 0010_backup_ip.py │ ├── 0019_alter_backup_device.py │ ├── 0017_add_job_to_backupjob.py │ ├── 0020_clean_rq_queue.py │ ├── 0022_model_ordering.py │ ├── 0003_primary_model_to_bigid.py │ ├── 0018_move_to_nbmodel.py │ ├── 0013_backup__to_netboxmodel.py │ ├── 0001_initial.py │ ├── 0002_git_models.py │ └── 0009_bctc_file_model_backup_fk_restructure.py ├── template_content.py ├── templatetags │ ├── __init__.py │ └── ncb_split.py ├── api │ ├── __init__.py │ ├── urls.py │ ├── views.py │ └── serializers.py ├── exceptions │ └── __init__.py ├── utils │ ├── db.py │ ├── __init__.py │ ├── logger.py │ ├── git.py │ ├── backups.py │ ├── rq.py │ ├── napalm.py │ └── configs.py ├── templates │ └── netbox_config_backup │ │ ├── repository.html │ │ ├── buttons │ │ ├── config.html │ │ ├── diff.html │ │ └── runbackups.html │ │ ├── config.html │ │ ├── inc │ │ └── backup_tables.html │ │ ├── backupjob.html │ │ ├── compliance.html │ │ ├── diff.html │ │ ├── backups.html │ │ └── backup.html ├── jobs │ ├── __init__.py │ ├── housekeeping.py │ └── backup.py ├── models │ ├── abstract.py │ ├── __init__.py │ ├── jobs.py │ ├── repository.py │ └── backups.py ├── helpers.py ├── choices.py ├── urls.py ├── __init__.py ├── navigation.py ├── querysets │ └── __init__.py ├── object_actions │ └── __init__.py ├── filtersets.py ├── tables.py ├── forms.py ├── git.py └── views.py ├── .black ├── MANIFEST.in ├── ruff.toml ├── .github ├── workflows │ ├── release.yml │ ├── pr_approval.yml │ ├── build-test.yml │ ├── ci.yml │ └── pypi.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ └── bug_report.yaml ├── FUNDING.yml ├── release-drafter.yml └── configuration.testing.py ├── .pre-commit-config.yaml ├── pyproject.toml ├── README.md ├── .gitignore └── LICENSE /netbox_config_backup/tasks.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/backup/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/graphql/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/template_content.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/templatetags/ncb_split.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_config_backup/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .serializers import * 2 | -------------------------------------------------------------------------------- /.black: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | skip-string-normalization = 1 3 | line-length = 120 -------------------------------------------------------------------------------- /netbox_config_backup/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | class JobExit(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/db.py: -------------------------------------------------------------------------------- 1 | from django import db 2 | 3 | 4 | def close_db(): 5 | db.connections.close_all() 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include netbox_config_backup/templates * 4 | recursive-include netbox_config_backup/static * -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/repository.html: -------------------------------------------------------------------------------- 1 | {% extends 'base/layout.html' %} 2 | 3 | {% block content %} 4 | Repository 5 | {% endblock %} -------------------------------------------------------------------------------- /netbox_config_backup/jobs/__init__.py: -------------------------------------------------------------------------------- 1 | from .backup import BackupRunner 2 | from .housekeeping import BackupHousekeeping 3 | 4 | __all__ = ('BackupRunner', 'BackupHousekeeping') 5 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .backups import get_backup_tables 2 | from .git import Differ 3 | 4 | __all__ = ( 5 | 'get_backup_tables', 6 | 'Differ', 7 | ) 8 | -------------------------------------------------------------------------------- /netbox_config_backup/api/urls.py: -------------------------------------------------------------------------------- 1 | from netbox.api.routers import NetBoxRouter 2 | from .views import * 3 | 4 | 5 | router = NetBoxRouter() 6 | router.register('backup', BackupViewSet) 7 | urlpatterns = router.urls 8 | -------------------------------------------------------------------------------- /netbox_config_backup/models/abstract.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class BigIDModel(models.Model): 5 | id = models.BigAutoField(primary_key=True) 6 | 7 | class Meta: 8 | abstract = True 9 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/buttons/config.html: -------------------------------------------------------------------------------- 1 | 2 | {{ label }} 3 | 4 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/buttons/diff.html: -------------------------------------------------------------------------------- 1 | 2 | {{ label }} 3 | 4 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/buttons/runbackups.html: -------------------------------------------------------------------------------- 1 | 2 | {{ label }} 3 | 4 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | exclude = [] 2 | line-length = 120 3 | target-version = "py310" 4 | 5 | [lint] 6 | extend-select = ["E1", "E2", "E3", "E501", "W"] 7 | ignore = ["F403", "F405"] 8 | preview = true 9 | 10 | [lint.per-file-ignores] 11 | "template_code.py" = ["E501"] 12 | 13 | [format] 14 | quote-style = "single" 15 | -------------------------------------------------------------------------------- /netbox_config_backup/api/views.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | from netbox_config_backup.api import BackupSerializer 3 | from netbox_config_backup.models import Backup 4 | 5 | 6 | class BackupViewSet(NetBoxModelViewSet): 7 | queryset = Backup.objects.all() 8 | serializer_class = BackupSerializer 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | on: 5 | push: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/pr_approval.yml: -------------------------------------------------------------------------------- 1 | name: Auto approve 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | auto-approve: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | pull-requests: write 14 | if: github.actor == 'dansheps' 15 | steps: 16 | - uses: hmarr/auto-approve-action@v4 -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.6.9 4 | hooks: 5 | - id: ruff 6 | name: "Ruff linter" 7 | args: [ netbox_config_backup/ ] 8 | - repo: https://github.com/psf/black-pre-commit-mirror 9 | rev: 25.1.0 10 | hooks: 11 | - id: black 12 | name: "Black" 13 | args: [--check] 14 | 15 | -------------------------------------------------------------------------------- /netbox_config_backup/graphql/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import strawberry 4 | import strawberry_django 5 | 6 | from .types import * 7 | 8 | 9 | @strawberry.type(name="Query") 10 | class BackupQuery: 11 | backup: BackupType = strawberry_django.field() 12 | backup_list: List[BackupType] = strawberry_django.field() 13 | 14 | 15 | schema = [ 16 | BackupQuery, 17 | ] 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: 💬 Community Slack 5 | url: https://netdev.chat/ 6 | about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" -------------------------------------------------------------------------------- /netbox_config_backup/models/__init__.py: -------------------------------------------------------------------------------- 1 | from netbox_config_backup.models.backups import Backup 2 | from netbox_config_backup.models.repository import ( 3 | BackupCommit, 4 | BackupFile, 5 | BackupObject, 6 | BackupCommitTreeChange, 7 | ) 8 | from netbox_config_backup.models.jobs import BackupJob 9 | 10 | 11 | __all__ = ( 12 | 'Backup', 13 | 'BackupCommit', 14 | 'BackupFile', 15 | 'BackupObject', 16 | 'BackupCommitTreeChange', 17 | 'BackupJob', 18 | ) 19 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | 5 | def get_logger(): 6 | # Setup logging to Stdout 7 | formatter = logging.Formatter('[%(asctime)s][%(levelname)s] - %(message)s') 8 | stdouthandler = logging.StreamHandler(sys.stdout) 9 | stdouthandler.setLevel(logging.DEBUG) 10 | stdouthandler.setFormatter(formatter) 11 | logger = logging.getLogger("netbox_config_backup") 12 | logger.addHandler(stdouthandler) 13 | 14 | return logger 15 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/config.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 | Config 9 |
10 |
11 |
{{backup_config}}
12 |
13 |
14 |
15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /netbox_config_backup/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from netbox import settings 4 | 5 | 6 | def get_repository_dir(): 7 | repository = settings.PLUGINS_CONFIG.get("netbox_config_backup", {}).get( 8 | "repository" 9 | ) 10 | if repository == os.path.abspath(repository) or repository == ( 11 | os.path.abspath(repository) + os.path.sep 12 | ): 13 | return repository 14 | 15 | return f'{os.path.dirname(os.path.dirname(settings.BASE_DIR))}{os.path.sep}{repository}' 16 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0011_alter_backup_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-04-14 15:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0010_backup_ip'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='backup', 15 | name='name', 16 | field=models.CharField(max_length=255, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0005_commit_add_time_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-23 04:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0004_custom_constraints'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='backupcommit', 15 | name='time', 16 | field=models.DateTimeField(auto_now=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0012_backup_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-05 17:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0011_alter_backup_name'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='backup', 15 | name='status', 16 | field=models.CharField(default='active', max_length=50), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/inc/backup_tables.html: -------------------------------------------------------------------------------- 1 | {% load render_table from django_tables2 %} 2 |
3 |
4 |
5 |
6 | Backups 7 |
8 |
9 | {% include 'htmx/table.html' %} 10 |
11 |
12 |
13 |
-------------------------------------------------------------------------------- /netbox_config_backup/migrations/0021_backupfile_last_change.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2b1 on 2025-06-30 15:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("netbox_config_backup", "0020_clean_rq_queue"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="backupfile", 15 | name="last_change", 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0014_backup_config_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-09-06 02:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0013_backup__to_netboxmodel'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='backup', 15 | name='config_status', 16 | field=models.BooleanField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0008_backupjob_scheduled_nullable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-23 18:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0007_backup_job_add_scheduled'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='backupjob', 15 | name='scheduled', 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0016_add_pid_to_backup_job.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-09-30 21:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0015_backup_comments_backup_description'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='backupjob', 15 | name='pid', 16 | field=models.BigIntegerField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/git.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from deepdiff import DeepDiff 3 | 4 | logger = logging.getLogger("netbox_config_backup") 5 | 6 | 7 | class Differ(DeepDiff): 8 | def is_diff(self): 9 | if self.get('values_changed'): 10 | return True 11 | return False 12 | 13 | def diff(self): 14 | diff = self.get('values_changed', {}).get('root', {}).get('diff', '') 15 | return diff 16 | 17 | def compare(self): 18 | diff = self.diff() 19 | return diff.splitlines() 20 | 21 | def cisco_compare(self): 22 | return self.compare() 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [dansheps] 4 | patreon: dansheps 5 | #open_collective: dansheps 6 | #ko_fi: # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | #liberapay: # Replace with a single Liberapay username 10 | #issuehunt: # Replace with a single IssueHunt username 11 | #otechie: # Replace with a single Otechie username 12 | custom: https://paypal.me/dansheps84?country.x=CA&locale.x=en_US 13 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0004_custom_constraints.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-23 03:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0003_primary_model_to_bigid'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name='backupcommit', 15 | constraint=models.CheckConstraint( 16 | check=models.Q(('backup__isnull', False), ('sha__isnull', False)), 17 | name='backup_and_sha_not_null', 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0007_backup_job_add_scheduled.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-23 17:54 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('netbox_config_backup', '0006_backup_add_commit_last_time'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='backupjob', 16 | name='scheduled', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /netbox_config_backup/graphql/types.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | import strawberry 4 | import strawberry_django 5 | 6 | from netbox.graphql.types import NetBoxObjectType 7 | from .filters import * 8 | 9 | from netbox_config_backup import models 10 | 11 | __all__ = ( 12 | 'BackupType', 13 | ) 14 | 15 | 16 | @strawberry_django.type( 17 | models.Backup, 18 | fields='__all__', 19 | filters=BackupFilter 20 | ) 21 | class BackupType(NetBoxObjectType): 22 | 23 | name: str 24 | device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None 25 | ip: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None 26 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0015_backup_comments_backup_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-09-12 13:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0014_backup_config_status'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='backup', 15 | name='comments', 16 | field=models.TextField(blank=True), 17 | ), 18 | migrations.AddField( 19 | model_name='backup', 20 | name='description', 21 | field=models.CharField(blank=True, max_length=200), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0006_backup_add_commit_last_time.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-23 17:43 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('netbox_config_backup', '0005_commit_add_time_field'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='backupcommit', 16 | name='backup', 17 | field=models.ForeignKey( 18 | null=True, 19 | on_delete=django.db.models.deletion.SET_NULL, 20 | related_name='commits', 21 | to='netbox_config_backup.backup', 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0010_backup_ip.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-04-07 03:40 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('ipam', '0057_created_datetimefield'), 11 | ('netbox_config_backup', '0009_bctc_file_model_backup_fk_restructure'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='backup', 17 | name='ip', 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | to='ipam.ipaddress', 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0019_alter_backup_device.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.9 on 2024-11-18 23:14 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dcim', '0191_module_bay_rebuild'), 11 | ('netbox_config_backup', '0018_move_to_nbmodel'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='backup', 17 | name='device', 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | related_name='backups', 23 | to='dcim.device', 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /netbox_config_backup/choices.py: -------------------------------------------------------------------------------- 1 | from utilities.choices import ChoiceSet 2 | 3 | 4 | # 5 | # File Types for Backup Files 6 | # 7 | 8 | 9 | class FileTypeChoices(ChoiceSet): 10 | 11 | TYPE_RUNNING = 'running' 12 | TYPE_STARTUP = 'startup' 13 | 14 | CHOICES = ( 15 | (TYPE_RUNNING, 'Running'), 16 | (TYPE_STARTUP, 'Startup'), 17 | ) 18 | 19 | 20 | class CommitTreeChangeTypeChoices(ChoiceSet): 21 | 22 | TYPE_ADD = 'add' 23 | TYPE_MODIFY = 'modify' 24 | 25 | CHOICES = ( 26 | (TYPE_ADD, 'Add'), 27 | (TYPE_MODIFY, 'Modify'), 28 | ) 29 | 30 | 31 | class StatusChoices(ChoiceSet): 32 | STATUS_ACTIVE = 'active' 33 | STATUS_DISABLED = 'disabled' 34 | 35 | CHOICES = ( 36 | (STATUS_ACTIVE, 'Active'), 37 | (STATUS_DISABLED, 'Disabled'), 38 | ) 39 | -------------------------------------------------------------------------------- /netbox_config_backup/graphql/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, TYPE_CHECKING 2 | 3 | # Base Imports 4 | import strawberry 5 | import strawberry_django 6 | 7 | # NetBox Imports 8 | from netbox.graphql.filter_mixins import BaseObjectTypeFilterMixin 9 | 10 | # Plugin Imports 11 | from netbox_config_backup import models 12 | 13 | if TYPE_CHECKING: 14 | from dcim.graphql.filters import DeviceFilter 15 | 16 | 17 | __all__ = ('BackupFilter',) 18 | 19 | 20 | @strawberry_django.filter(models.Backup, lookups=True) 21 | class BackupFilter(BaseObjectTypeFilterMixin): 22 | device: ( 23 | Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] 24 | | None # noqa: F821 25 | ) = strawberry_django.filter_field() # noqa: F821 26 | device_id: strawberry.ID | None = strawberry_django.filter_field() 27 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0017_add_job_to_backupjob.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-10-01 01:52 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('core', '0012_job_object_type_optional'), 11 | ('netbox_config_backup', '0016_add_pid_to_backup_job'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='backupjob', 17 | name='runner', 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | related_name='backup_job', 23 | to='core.job', 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'type: feature' 7 | - 'type: enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'type: bug' 11 | - title: '🧰 Maintenance' 12 | labels: 13 | - 'type: housekeeping' 14 | - 'type: documentation' 15 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 16 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 17 | version-resolver: 18 | minor: 19 | labels: 20 | - 'type: feature' 21 | patch: 22 | labels: 23 | - 'type: enhancement' 24 | - 'type: bug' 25 | - 'type: housekeeping' 26 | - 'type: documentation' 27 | default: patch 28 | template: | 29 | ## Changes 30 | 31 | $CHANGES -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build Test 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build Distribution 6 | runs-on: ubuntu-latest 7 | environment: 8 | name: build 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v4 12 | - name: Set up Python 3.12 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.12 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install --upgrade setuptools wheel 20 | python -m pip install build --user 21 | - name: Build a binary wheel and a source tarball 22 | run: python -m build 23 | - name: Store the distribution packages 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: python-package-distributions 27 | path: dist/ -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0020_clean_rq_queue.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.9 on 2024-11-18 23:14 2 | 3 | from django.db import migrations 4 | from django_rq import get_queue 5 | 6 | from core.choices import JobStatusChoices 7 | 8 | 9 | def clear_queue(apps, schema_editor): 10 | BackupJob = apps.get_model('netbox_config_backup', 'BackupJob') 11 | jobs = BackupJob.objects.filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES) 12 | for job in jobs: 13 | job.status = JobStatusChoices.STATUS_FAILED 14 | job.clean() 15 | job.save() 16 | 17 | queue = get_queue('netbox_config_backup.jobs') 18 | queue.empty() 19 | 20 | 21 | class Migration(migrations.Migration): 22 | 23 | dependencies = [ 24 | ('netbox_config_backup', '0019_alter_backup_device'), 25 | ] 26 | 27 | operations = [migrations.RunPython(code=clear_queue)] 28 | -------------------------------------------------------------------------------- /netbox_config_backup/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from utilities.urls import get_model_urls 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path('backups/', include(get_model_urls('netbox_config_backup', 'backup', detail=False))), 8 | path('backups//', include(get_model_urls('netbox_config_backup', 'backup'))), 9 | path('devices/', include(get_model_urls('netbox_config_backup', 'backup', detail=False))), 10 | path('devices//', include(get_model_urls('netbox_config_backup', 'backup'))), 11 | path('devices//config/', views.DiffView.as_view(), name='backup_config'), 12 | path('devices//diff/', views.DiffView.as_view(), name='backup_diff'), 13 | path('devices//diff//', views.DiffView.as_view(), name='backup_diff'), 14 | path('jobs/', include(get_model_urls('netbox_config_backup', 'backupjob', detail=False))), 15 | path('jobs//', include(get_model_urls('netbox_config_backup', 'backupjob'))), 16 | ] 17 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/backupjob.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load helpers %} 3 | 4 | {% block subtitle %} 5 |
6 | {% endblock %} 7 | 8 | {% block content %} 9 |
10 |
11 |
12 |
13 | Backup Job 14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
Backup{{ object.backup|linkify }}
Status{{ object.status }}
26 |
27 |
28 | {% include 'inc/panels/comments.html' %} 29 |
30 | 31 |
32 |
33 |
34 | {% endblock %} -------------------------------------------------------------------------------- /netbox_config_backup/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from dcim.models import Device, Platform 4 | from utilities.testing import create_test_device 5 | 6 | from netbox_napalm_plugin.models import NapalmPlatformConfig 7 | 8 | from netbox_config_backup.forms import * 9 | from netbox_config_backup.models import * 10 | 11 | 12 | class BackupTestCase(TestCase): 13 | 14 | @classmethod 15 | def setUpTestData(cls): 16 | platform = Platform.objects.create(name='Cisco IOS', slug='cisco-ios') 17 | device = create_test_device(name='Device 1', platform=platform) # noqa: F841 18 | NapalmPlatformConfig.objects.create( 19 | platform=platform, napalm_driver='cisco_ios' 20 | ) 21 | 22 | def test_backup(self): 23 | form = BackupForm( 24 | data={ 25 | 'name': 'New Backup', 26 | 'device': Device.objects.first().pk, 27 | 'status': 'disabled', 28 | } 29 | ) 30 | self.assertTrue(form.is_valid()) 31 | self.assertTrue(form.save()) 32 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0022_model_ordering.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-10 16:45 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0021_backupfile_last_change'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='backupcommit', 15 | options={'ordering': ('pk',)}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='backupcommittreechange', 19 | options={'ordering': ('pk',)}, 20 | ), 21 | migrations.AlterModelOptions( 22 | name='backupfile', 23 | options={'ordering': ('pk',)}, 24 | ), 25 | migrations.AlterModelOptions( 26 | name='backupobject', 27 | options={'ordering': ('pk',)}, 28 | ), 29 | migrations.AlterModelOptions( 30 | name='backup', 31 | options={'ordering': ('name',)}, 32 | ), 33 | migrations.AlterModelOptions( 34 | name='backupjob', 35 | options={'ordering': ('pk',)}, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /netbox_config_backup/management/commands/listbackups.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | 4 | class Command(BaseCommand): 5 | 6 | def handle(self, *args, **options): 7 | from netbox_config_backup.models import Backup 8 | 9 | print('Backup Name\t\tDevice Name\t\tIP') 10 | for backup in Backup.objects.filter(device__isnull=False): 11 | if backup.ip: 12 | ip = backup.ip 13 | else: 14 | ip = backup.device.primary_ip 15 | 16 | name = f'{backup.name}' 17 | if len(backup.name) > 15: 18 | name = f'{name}\t' 19 | elif len(backup.name) > 7: 20 | name = f'{name}\t\t' 21 | else: 22 | name = f'{name}\t\t\t' 23 | 24 | device_name = f'{backup.device.name}' 25 | if len(backup.device.name) > 15: 26 | device_name = f'{device_name}\t' 27 | elif len(backup.device.name) > 7: 28 | device_name = f'{device_name}\t\t' 29 | else: 30 | device_name = f'{device_name}\t\t\t' 31 | 32 | print(f'{name}{device_name}{ip}') 33 | -------------------------------------------------------------------------------- /netbox_config_backup/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import metadata 2 | 3 | from netbox.plugins import PluginConfig 4 | 5 | metadata = metadata('netbox_config_backup') 6 | 7 | 8 | class NetboxConfigBackup(PluginConfig): 9 | name = metadata.get('Name').replace('-', '_') 10 | verbose_name = metadata.get('Name').replace('-', ' ').title() 11 | description = metadata.get('Summary') 12 | version = metadata.get('Version') 13 | author = metadata.get('Author') 14 | author_email = metadata.get('Author-email') 15 | base_url = 'configbackup' 16 | min_version = '4.4.0' 17 | required_settings = [ 18 | 'repository', 19 | 'committer', 20 | 'author', 21 | ] 22 | default_settings = { 23 | # Frequency in seconds 24 | 'frequency': 3600, 25 | } 26 | queues = ['jobs'] 27 | graphql_schema = 'graphql.schema.schema' 28 | 29 | def ready(self, *args, **kwargs): 30 | super().ready() 31 | import sys 32 | 33 | if len(sys.argv) > 1 and 'rqworker' in sys.argv[1]: 34 | from netbox_config_backup.jobs import BackupRunner, BackupHousekeeping # noqa: F401 35 | 36 | 37 | config = NetboxConfigBackup 38 | -------------------------------------------------------------------------------- /netbox_config_backup/navigation.py: -------------------------------------------------------------------------------- 1 | from netbox.choices import ButtonColorChoices 2 | from netbox.plugins import PluginMenuItem, PluginMenuButton, PluginMenu 3 | 4 | jobs = PluginMenuItem( 5 | link='plugins:netbox_config_backup:backupjob_list', 6 | link_text='Jobs', 7 | permissions=['netbox_config_backup.view_backups'], 8 | buttons=[], 9 | ) 10 | 11 | assigned = PluginMenuItem( 12 | link='plugins:netbox_config_backup:backup_list', 13 | link_text='Devices', 14 | permissions=['netbox_config_backup.view_backups'], 15 | buttons=[ 16 | PluginMenuButton( 17 | link="plugins:netbox_config_backup:backup_add", 18 | title="Add", 19 | icon_class="mdi mdi-plus", 20 | color=ButtonColorChoices.GREEN, 21 | ), 22 | ], 23 | ) 24 | 25 | unassigned = PluginMenuItem( 26 | link='plugins:netbox_config_backup:backup_unassigned_list', 27 | link_text='Unassigned Backups', 28 | permissions=['netbox_config_backup.view_backups'], 29 | buttons=[], 30 | ) 31 | 32 | menu = PluginMenu( 33 | label="Configuration Backup", 34 | groups=( 35 | ('Backup Jobs', (jobs,)), 36 | ('Backups', (assigned, unassigned)), 37 | ), 38 | ) 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "netbox-config-backup" 10 | authors = [ 11 | {name = "Daniel Sheppard", email = "dans@dansheps.com"} 12 | ] 13 | maintainers = [ 14 | {name = "Daniel Sheppard", email = "dans@dansheps.com"}, 15 | ] 16 | description = "A NetBox Switch Configuration Backup Plugin" 17 | readme = "README.md" 18 | requires-python = ">=3.10" 19 | keywords = ["netbox-plugin", ] 20 | version = "2.1.9" 21 | license = {file = "LICENSE"} 22 | classifiers = [ 23 | "Programming Language :: Python :: 3", 24 | ] 25 | dependencies = [ 26 | 'netbox-napalm-plugin', 27 | 'netmiko>=4.0.0', 28 | 'napalm', 29 | 'uuid', 30 | 'dulwich', 31 | 'pydriller', 32 | 'deepdiff', 33 | ] 34 | 35 | [project.urls] 36 | Documentation = "https://github.com/dansheps/netbox-config-backup/blob/main/README.md" 37 | Source = "https://github.com/dansheps/netbox-config-backup" 38 | Tracker = "https://github.com/dansheps/netbox-config-backup/issues" 39 | 40 | [tool.setuptools.packages.find] 41 | exclude=["netbox_config_backup.tests"] 42 | 43 | [tool.black] 44 | skip-string-normalization = 1 45 | line-length = 120 46 | -------------------------------------------------------------------------------- /netbox_config_backup/querysets/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from core.choices import JobStatusChoices 4 | from utilities.querysets import RestrictedQuerySet 5 | 6 | 7 | class BackupQuerySet(RestrictedQuerySet): 8 | def default_annotate(self): 9 | from netbox_config_backup.models import BackupJob, BackupCommitTreeChange 10 | 11 | return self.annotate( 12 | last_backup=models.Subquery( 13 | BackupJob.objects.filter( 14 | backup=models.OuterRef('id'), 15 | status=JobStatusChoices.STATUS_COMPLETED, 16 | ) 17 | .order_by('-completed') 18 | .values('completed')[:1] 19 | ), 20 | next_attempt=models.Subquery( 21 | BackupJob.objects.filter( 22 | backup=models.OuterRef('id'), 23 | status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES, 24 | ) 25 | .order_by('-scheduled') 26 | .values('scheduled')[:1] 27 | ), 28 | last_change=models.Subquery( 29 | BackupCommitTreeChange.objects.filter(backup=models.OuterRef('id')) 30 | .order_by('-id') 31 | .values('commit__time')[:1] 32 | ), 33 | ) 34 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0003_primary_model_to_bigid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-23 02:41 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_config_backup', '0002_git_models'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='backup', 15 | name='created', 16 | ), 17 | migrations.RemoveField( 18 | model_name='backup', 19 | name='custom_field_data', 20 | ), 21 | migrations.RemoveField( 22 | model_name='backup', 23 | name='last_updated', 24 | ), 25 | migrations.RemoveField( 26 | model_name='backup', 27 | name='tags', 28 | ), 29 | migrations.RemoveField( 30 | model_name='backupcommit', 31 | name='created', 32 | ), 33 | migrations.RemoveField( 34 | model_name='backupcommit', 35 | name='custom_field_data', 36 | ), 37 | migrations.RemoveField( 38 | model_name='backupcommit', 39 | name='last_updated', 40 | ), 41 | migrations.RemoveField( 42 | model_name='backupcommit', 43 | name='tags', 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /netbox_config_backup/tests/test_api.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from rest_framework import status 3 | 4 | from utilities.testing import APIViewTestCases, APITestCase 5 | 6 | from netbox_config_backup.models import Backup 7 | 8 | 9 | class AppTest(APITestCase): 10 | def test_root(self): 11 | url = reverse("plugins-api:netbox_config_backup-api:api-root") 12 | response = self.client.get(f"{url}?format=api", **self.header) 13 | 14 | self.assertEqual(response.status_code, status.HTTP_200_OK) 15 | 16 | 17 | class BackupTest(APIViewTestCases.APIViewTestCase): 18 | model = Backup 19 | view_namespace = "plugins-api:netbox_config_backup" 20 | brief_fields = ['display', 'id', 'name', 'url'] 21 | create_data = [ 22 | { 23 | 'name': 'Backup 4', 24 | 'config_status': False, 25 | }, 26 | { 27 | 'name': 'Backup 5', 28 | 'config_status': False, 29 | }, 30 | { 31 | 'name': 'Backup 6', 32 | 'config_status': False, 33 | }, 34 | ] 35 | 36 | bulk_update_data = {'config_status': True} 37 | 38 | @classmethod 39 | def setUpTestData(cls): 40 | 41 | Backup.objects.create(name='Backup 1') 42 | Backup.objects.create(name='Backup 2') 43 | Backup.objects.create(name='Backup 3') 44 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/compliance.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load helpers %} 3 | 4 | {% block subtitle %} 5 |
6 | {{ current.commit.time }} 7 |
8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 |
14 |
15 | Configuration Compliance 16 |
17 |
18 |
{% for line in diff %}{% spaceless %}
19 |                     {% if line == '+++' or line == '---' %}
20 |                     {% elif line|make_list|first == '@' %}
21 |                         {{line}}
22 |                     {% elif line|make_list|first == '+' %}
23 |                          {{line|make_list|slice:'1:'|join:''}}
24 |                     {% elif line|make_list|first == '-' %}
25 |                          {{line|make_list|slice:'1:'|join:''}}
26 |                     {% else %}
27 |                         {{line}}
28 |                     {% endif %}
29 |                 {% endspaceless %}{% endfor %}
30 |
31 |
32 |
33 |
34 | {% endblock %} -------------------------------------------------------------------------------- /netbox_config_backup/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from dcim.models import Site, Manufacturer, DeviceType, DeviceRole, Device 4 | from netbox_config_backup.models import * 5 | 6 | 7 | class TestBackup(TestCase): 8 | 9 | @classmethod 10 | def setUpTestData(cls): 11 | Site.objects.create(name='Site 1', slug='site-1') 12 | manufacturer = Manufacturer.objects.create( 13 | name='Manufacturer 1', slug='manufacturer-1' 14 | ) 15 | DeviceType.objects.create( 16 | model='Generic Type', slug='generic-type', manufacturer=manufacturer 17 | ) 18 | DeviceRole.objects.create(name='Generic Role', slug='generic-role') 19 | 20 | def test_create_backup(self): 21 | configs = {'running': 'Test Backup', 'startup': 'Test Backup'} 22 | 23 | site = Site.objects.first() 24 | role = DeviceRole.objects.first() 25 | device_type = DeviceType.objects.first() 26 | 27 | device = Device.objects.create( 28 | name='Test Device', device_type=device_type, role=role, site=site 29 | ) 30 | backup = Backup.objects.create(name='Backup 1', device=device) 31 | backup.set_config(configs) 32 | retrieved = backup.get_config() 33 | 34 | self.assertEqual(configs['running'], retrieved['running']) 35 | self.assertEqual(configs['startup'], retrieved['startup']) 36 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/backups.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger("netbox_config_backup") 4 | 5 | 6 | def get_backup_tables(instance): 7 | from netbox_config_backup.models import BackupCommitTreeChange 8 | from netbox_config_backup.tables import BackupsTable 9 | 10 | def get_backup_table(data): 11 | backups = [] 12 | for row in data: 13 | commit = row.commit 14 | current = row 15 | previous = row.backup.changes.filter( 16 | file__type=row.file.type, commit__time__lt=commit.time 17 | ).last() 18 | backup = { 19 | 'pk': instance.pk, 20 | 'date': commit.time, 21 | 'current': current, 22 | 'previous': previous, 23 | } 24 | backups.append(backup) 25 | 26 | table = BackupsTable(backups) 27 | return table 28 | 29 | backups = ( 30 | BackupCommitTreeChange.objects.filter(backup=instance) 31 | .prefetch_related('backup', 'new', 'old', 'commit', 'file') 32 | .order_by('commit__time') 33 | ) 34 | 35 | tables = {} 36 | for file in ['running', 'startup']: 37 | try: 38 | tables.update({file: get_backup_table(backups.filter(file__type=file))}) 39 | except KeyError: 40 | tables.update({file: get_backup_table([])}) 41 | 42 | return tables 43 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0018_move_to_nbmodel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-10-01 23:44 2 | 3 | import taggit.managers 4 | import utilities.json 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('extras', '0121_customfield_related_object_filter'), 12 | ('netbox_config_backup', '0017_add_job_to_backupjob'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='backupjob', 18 | name='custom_field_data', 19 | field=models.JSONField( 20 | blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder 21 | ), 22 | ), 23 | migrations.AddField( 24 | model_name='backupjob', 25 | name='last_updated', 26 | field=models.DateTimeField(auto_now=True, null=True), 27 | ), 28 | migrations.AddField( 29 | model_name='backupjob', 30 | name='tags', 31 | field=taggit.managers.TaggableManager( 32 | through='extras.TaggedItem', to='extras.Tag' 33 | ), 34 | ), 35 | migrations.AlterField( 36 | model_name='backupjob', 37 | name='id', 38 | field=models.BigAutoField( 39 | auto_created=True, primary_key=True, serialize=False 40 | ), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /.github/configuration.testing.py: -------------------------------------------------------------------------------- 1 | ################################################################### 2 | # This file serves as a base configuration for testing purposes # 3 | # only. It is not intended for production use. # 4 | ################################################################### 5 | 6 | ALLOWED_HOSTS = ['*'] 7 | 8 | DATABASE = { 9 | 'NAME': 'netbox', 10 | 'USER': 'netbox', 11 | 'PASSWORD': 'netbox', 12 | 'HOST': 'localhost', 13 | 'PORT': '', 14 | 'CONN_MAX_AGE': 300, 15 | } 16 | 17 | PLUGINS = [ 18 | 'netbox_napalm_plugin', 19 | 'netbox_config_backup', 20 | ] 21 | 22 | PLUGINS_CONFIG = { 23 | 'netbox_config_backup': { 24 | 'repository': '/tmp/repository/', 25 | 'committer': 'Test Committer ', 26 | 'author': 'Test Committer ', 27 | 'frequency': 3600, 28 | }, 29 | 'netbox_napalm_plugin': { 30 | 'NAPALM_USERNAME': 'xxx', 31 | 'NAPALM_PASSWORD': 'yyy', 32 | } 33 | } 34 | 35 | REDIS = { 36 | 'tasks': { 37 | 'HOST': 'localhost', 38 | 'PORT': 6379, 39 | 'PASSWORD': '', 40 | 'DATABASE': 0, 41 | 'SSL': False, 42 | }, 43 | 'caching': { 44 | 'HOST': 'localhost', 45 | 'PORT': 6379, 46 | 'PASSWORD': '', 47 | 'DATABASE': 1, 48 | 'SSL': False, 49 | } 50 | } 51 | 52 | SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 53 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0013_backup__to_netboxmodel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-05 19:31 2 | 3 | from django.db import migrations, models 4 | import taggit.managers 5 | import utilities.json 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('extras', '0084_staging'), 12 | ('netbox_config_backup', '0012_backup_status'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='backup', 18 | name='created', 19 | field=models.DateTimeField(auto_now_add=True, null=True), 20 | ), 21 | migrations.AddField( 22 | model_name='backup', 23 | name='custom_field_data', 24 | field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), 25 | ), 26 | migrations.AddField( 27 | model_name='backup', 28 | name='last_updated', 29 | field=models.DateTimeField(auto_now=True, null=True), 30 | ), 31 | migrations.AddField( 32 | model_name='backup', 33 | name='tags', 34 | field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), 35 | ), 36 | migrations.AlterField( 37 | model_name='backup', 38 | name='id', 39 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/diff.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load helpers %} 3 | 4 | {% block subtitle %} 5 |
6 | {{ current.commit.time }} 7 | ··· 8 | {{ previous.commit.time }} 9 |
10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 |
15 |
16 |
17 | Diff 18 |
19 |
20 |
{% for line in diff %}{% spaceless %}
21 |                     {% if line == '+++' or line == '---' %}
22 |                     {% elif line|make_list|first == '@' %}
23 |                         {{line}}
24 |                     {% elif line|make_list|first == '+' %}
25 |                          {{line|make_list|slice:'1:'|join:''}}
26 |                     {% elif line|make_list|first == '-' %}
27 |                          {{line|make_list|slice:'1:'|join:''}}
28 |                     {% else %}
29 |                         {{line}}
30 |                     {% endif %}
31 |                 {% endspaceless %}{% endfor %}
32 |
33 |
34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /netbox_config_backup/object_actions/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | 3 | from netbox.object_actions import ObjectAction 4 | 5 | __all__ = ( 6 | 'DiffAction', 7 | 'ViewConfigAction', 8 | 'BulkConfigAction', 9 | 'BulkDiffAction', 10 | 'RunBackupsNowAction', 11 | ) 12 | 13 | 14 | class DiffAction(ObjectAction): 15 | """ 16 | Create a new object. 17 | """ 18 | 19 | name = 'diff' 20 | label = _('Diff') 21 | permissions_required = {'view'} 22 | template_name = 'netbox_config_backup/buttons/diff.html' 23 | 24 | 25 | class ViewConfigAction(ObjectAction): 26 | """ 27 | Create a new object. 28 | """ 29 | 30 | name = 'config' 31 | label = _('Config') 32 | permissions_required = {'view'} 33 | template_name = 'netbox_config_backup/buttons/diff.html' 34 | 35 | 36 | class BulkConfigAction(ObjectAction): 37 | name = 'bulk_config' 38 | label = _('Bulk View Config') 39 | multi = True 40 | permissions_required = {'view'} 41 | template_name = 'netbox_config_backup/buttons/config.html' 42 | 43 | 44 | class BulkDiffAction(ObjectAction): 45 | name = 'bulk_diff' 46 | label = _('Bulk Diff') 47 | multi = True 48 | permissions_required = {'view'} 49 | template_name = 'netbox_config_backup/buttons/diff.html' 50 | 51 | 52 | class RunBackupsNowAction(ObjectAction): 53 | name = 'run' 54 | label = _('Run Now') 55 | permissions_required = {'view'} 56 | template_name = 'netbox_config_backup/buttons/diff.html' 57 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/backups.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_children.html' %} 2 | {% load helpers %} 3 | 4 | {% block subtitle %} 5 |
6 | {% endblock %} 7 | 8 | {% block extra_controls %} 9 | 20 | {% endblock %} 21 | 22 | {% block bulk_edit_controls %} 23 | {{ block.super }} 24 | {% if backup %} 25 | {% with diff_view=backup|viewname:"diff" %} 26 | 27 | 32 | {% endwith %} 33 | {% endif %} 34 | {% endblock bulk_edit_controls %} 35 | -------------------------------------------------------------------------------- /netbox_config_backup/management/commands/runbackup.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from core.choices import JobStatusChoices 4 | from netbox_config_backup.jobs.backup import BackupRunner 5 | from netbox_config_backup.models import Backup 6 | 7 | 8 | class Command(BaseCommand): 9 | def add_arguments(self, parser): 10 | parser.add_argument('--time', dest='time', help="time") 11 | parser.add_argument('--device', dest='device', help="Device Name") 12 | 13 | def run_backup(self, backup=None): 14 | BackupRunner.enqueue(backup=backup, immediate=True) 15 | 16 | def handle(self, *args, **options): 17 | if options['device']: 18 | print(f'Running backup for: {options.get("device")}') 19 | backup = Backup.objects.filter(device__name=options['device']).first() 20 | if not backup: 21 | backup = Backup.objects.filter(name=options['device']).first() 22 | if backup: 23 | if options.get('time') == 'now': 24 | for job in backup.jobs.filter( 25 | status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES 26 | ): 27 | print(f'Clearing old jobs: {job}') 28 | job.status = JobStatusChoices.STATUS_ERRORED 29 | job.clean() 30 | job.save() 31 | 32 | self.run_backup(backup) 33 | else: 34 | raise Exception('Device not found') 35 | else: 36 | self.run_backup() 37 | -------------------------------------------------------------------------------- /netbox_config_backup/jobs/housekeeping.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | 4 | from django.utils import timezone 5 | from rq.job import JobStatus 6 | 7 | from core.choices import JobIntervalChoices, JobStatusChoices 8 | from core.models import Job 9 | from netbox import settings 10 | from netbox.jobs import JobRunner, system_job 11 | 12 | from netbox_config_backup.jobs import BackupRunner 13 | 14 | __all__ = 'BackupHousekeeping' 15 | 16 | logger = logging.getLogger("netbox_config_backup") 17 | 18 | job_frequency = settings.PLUGINS_CONFIG.get('netbox_config_backup', {}).get('frequency', 3600) 19 | 20 | 21 | @system_job(interval=JobIntervalChoices.INTERVAL_HOURLY * 2) 22 | class BackupHousekeeping(JobRunner): 23 | 24 | class Meta: 25 | name = 'Backup Housekeeping' 26 | 27 | def run(self, *args, **kwargs): 28 | # Will be removed in a while 29 | names = ['The Backup Job Runner', BackupRunner.name] 30 | jobs = Job.objects.filter(name__in=names, status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES) 31 | if jobs.count() > 0: 32 | for job in jobs: 33 | if job.scheduled < timezone.now() - timedelta(minutes=30): 34 | logger.info(f'Backup Job Runner {job} ({job.pk} is stale') 35 | job.status = JobStatus.FAILED 36 | job.clean() 37 | job.save() 38 | job = BackupRunner.enqueue(scheduled_at=timezone.now() + timedelta(minutes=5)) 39 | logger.info(f'\tNew Backup Job Runner enqueued as {job} ({job.pk})') 40 | else: 41 | logger.info('No stale jobs') 42 | -------------------------------------------------------------------------------- /netbox_config_backup/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from core.api.serializers_.jobs import JobSerializer 4 | from dcim.api.serializers import DeviceSerializer 5 | from ipam.api.serializers import IPAddressSerializer 6 | from netbox.api.serializers import NetBoxModelSerializer 7 | 8 | from netbox_config_backup.models import Backup, BackupJob 9 | 10 | __all__ = ( 11 | 'BackupSerializer', 12 | 'BackupJobSerializer', 13 | ) 14 | 15 | 16 | class BackupSerializer(NetBoxModelSerializer): 17 | url = serializers.HyperlinkedIdentityField( 18 | view_name='plugins-api:netbox_config_backup-api:backup-detail' 19 | ) 20 | device = DeviceSerializer(nested=True, required=False, allow_null=True), 21 | ip = IPAddressSerializer(nested=True, required=False, allow_null=True) 22 | 23 | class Meta: 24 | model = Backup 25 | fields = [ 26 | 'id', 'url', 'display', 'name', 'device', 'ip', 27 | 'uuid', 'status', 'config_status', 28 | ] 29 | brief_fields = ('display', 'id', 'name', 'url') 30 | 31 | 32 | class BackupJobSerializer(NetBoxModelSerializer): 33 | url = serializers.HyperlinkedIdentityField( 34 | view_name='plugins-api:netbox_config_backup-api:backup-detail' 35 | ) 36 | runner = JobSerializer(nested=True, required=True, allow_null=False), 37 | backup = BackupSerializer(nested=True, required=True, allow_null=False) 38 | 39 | class Meta: 40 | model = BackupJob 41 | fields = [ 42 | 'id', 'url', 'display', 'runner', 'backup', 'pid', 'created', 'scheduled', 'started', 'completed', 'status', 43 | 'data', 'status', 'job_id', 44 | ] 45 | brief_fields = ('backup', 'display', 'id', 'runner', 'url') 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | env: 11 | NETBOX_CONFIGURATION: netbox.configuration_testing 12 | strategy: 13 | matrix: 14 | python-version: ['3.10', '3.11', '3.12'] 15 | services: 16 | redis: 17 | image: redis 18 | ports: 19 | - 6379:6379 20 | postgres: 21 | image: postgres 22 | env: 23 | POSTGRES_USER: netbox 24 | POSTGRES_PASSWORD: netbox 25 | options: >- 26 | --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | ports: 31 | - 5432:5432 32 | 33 | steps: 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | 39 | - name: Check out NetBox 40 | uses: actions/checkout@v4 41 | with: 42 | repository: netbox-community/netbox 43 | ref: main 44 | path: netbox 45 | 46 | - name: Check out repo 47 | uses: actions/checkout@v4 48 | with: 49 | path: netbox-config-backup 50 | 51 | - name: Install dependencies & set up configuration 52 | run: | 53 | python -m pip install --upgrade pip 54 | pip install -r netbox/requirements.txt 55 | pip install pycodestyle coverage tblib 56 | pip install -e netbox-config-backup 57 | cp -f netbox-config-backup/.github/configuration.testing.py netbox/netbox/netbox/configuration_testing.py 58 | mkdir /tmp/repository 59 | git init /tmp/repository 60 | 61 | - name: Run tests 62 | run: coverage run --source="netbox-config-backup/netbox_config_backup" netbox/netbox/manage.py test netbox-config-backup/netbox_config_backup --parallel 63 | 64 | - name: Show coverage report 65 | run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*' -------------------------------------------------------------------------------- /netbox_config_backup/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from utilities.testing import ViewTestCases, create_test_device 2 | 3 | from netbox_config_backup.models import Backup 4 | 5 | 6 | class BackupTestCase( 7 | ViewTestCases.GetObjectViewTestCase, 8 | ViewTestCases.GetObjectChangelogViewTestCase, 9 | ViewTestCases.CreateObjectViewTestCase, 10 | ViewTestCases.EditObjectViewTestCase, 11 | ViewTestCases.DeleteObjectViewTestCase, 12 | ViewTestCases.ListObjectsViewTestCase, 13 | ViewTestCases.BulkEditObjectsViewTestCase, 14 | ViewTestCases.BulkDeleteObjectsViewTestCase, 15 | ): 16 | # ViewTestCases.BulkImportObjectsViewTestCase, 17 | model = Backup 18 | 19 | @classmethod 20 | def setUpTestData(cls): 21 | devices = ( 22 | create_test_device(name="Device 1"), 23 | create_test_device(name="Device 2"), 24 | create_test_device(name="Device 3"), 25 | create_test_device(name="Device 4"), 26 | ) 27 | 28 | backups = ( 29 | Backup(name="Backup 1", device=devices[0]), 30 | Backup(name="Backup 2", device=devices[1]), 31 | Backup(name="Backup 3", device=devices[2]), 32 | ) 33 | Backup.objects.bulk_create(backups) 34 | 35 | cls.form_data = {'name': 'Backup X', 'status': 'disabled'} 36 | 37 | cls.bulk_edit_data = { 38 | 'description': 'A description', 39 | } 40 | 41 | """ 42 | cls.csv_data = ( 43 | "name,slug,description", 44 | "Region 4,region-4,Fourth region", 45 | "Region 5,region-5,Fifth region", 46 | "Region 6,region-6,Sixth region", 47 | ) 48 | 49 | cls.csv_update_data = ( 50 | "id,name,description", 51 | f"{regions[0].pk},Region 7,Fourth region7", 52 | f"{regions[1].pk},Region 8,Fifth region8", 53 | f"{regions[2].pk},Region 0,Sixth region9", 54 | ) 55 | """ 56 | 57 | def _get_base_url(self): 58 | return 'plugins:netbox_config_backup:backup_{}' 59 | -------------------------------------------------------------------------------- /netbox_config_backup/tests/test_filtersets.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from dcim.models import Site, Manufacturer, DeviceType, DeviceRole, Device 4 | from ipam.models import IPAddress 5 | 6 | from netbox_config_backup.filtersets import BackupFilterSet 7 | from netbox_config_backup.models import Backup 8 | 9 | 10 | class BackupTestCase(TestCase): 11 | queryset = Backup.objects.all() 12 | filterset = BackupFilterSet 13 | 14 | @classmethod 15 | def setUpTestData(cls): 16 | site = Site.objects.create(name='Site 1', slug='site-1') 17 | manufacturer = Manufacturer.objects.create( 18 | name='Manufacturer 1', slug='manufacturer-1' 19 | ) 20 | device_type = DeviceType.objects.create( 21 | manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' 22 | ) 23 | role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') 24 | 25 | ip = IPAddress.objects.create(address='10.10.10.10/24') 26 | 27 | devices = ( 28 | Device(name='Device 1', device_type=device_type, role=role, site=site), 29 | Device(name='Device 2', device_type=device_type, role=role, site=site), 30 | ) 31 | Device.objects.bulk_create(devices) 32 | 33 | backups = ( 34 | Backup(name='Backup 1', device=devices[0]), 35 | Backup(name='Backup 2', device=devices[1]), 36 | Backup(name='Backup 3', device=devices[1], ip=ip), 37 | ) 38 | Backup.objects.bulk_create(backups) 39 | 40 | def test_q(self): 41 | params = {'q': 'Backup 1'} 42 | self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) 43 | 44 | def test_name(self): 45 | params = {'name': ['Backup 1', 'Backup 2']} 46 | self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 47 | 48 | def test_device(self): 49 | params = {'device': ['Device 2']} 50 | self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 51 | 52 | def test_ip(self): 53 | params = {'ip': '10.10.10.10'} 54 | self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) 55 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI Build 2 | on: 3 | release: 4 | types: released 5 | jobs: 6 | 7 | build: 8 | name: Build Distribution for PyPI 9 | runs-on: ubuntu-latest 10 | environment: release 11 | steps: 12 | - name: Checkout repo 13 | uses: actions/checkout@v4 14 | - name: Set up Python 3.12 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.12 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install --upgrade setuptools wheel 22 | python -m pip install build --user 23 | - name: Build a binary wheel and a source tarball 24 | run: python -m build 25 | - name: Store the distribution packages 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: python-package-distributions 29 | path: dist/ 30 | publish-to-testpypi: 31 | name: Publish Python 🐍 distribution 📦 to TestPyPI 32 | needs: 33 | - build 34 | runs-on: ubuntu-latest 35 | environment: 36 | name: testpypi 37 | url: https://test.pypi.org/p/netbox-config-backup 38 | permissions: 39 | id-token: write 40 | steps: 41 | - name: Download all the dists 42 | uses: actions/download-artifact@v4 43 | with: 44 | name: python-package-distributions 45 | path: dist/ 46 | - name: Publish package to TestPyPI 47 | uses: pypa/gh-action-pypi-publish@release/v1 48 | with: 49 | repository-url: https://test.pypi.org/legacy/ 50 | skip-existing: true 51 | publish-to-pypi: 52 | name: Publish Python 🐍 distribution 📦 to PyPI 53 | needs: 54 | - build 55 | runs-on: ubuntu-latest 56 | environment: 57 | name: pypi 58 | url: https://pypi.org/p/netbox-config-backup 59 | permissions: 60 | id-token: write 61 | steps: 62 | - name: Download all the dists 63 | uses: actions/download-artifact@v4 64 | with: 65 | name: python-package-distributions 66 | path: dist/ 67 | - name: Publish package 68 | uses: pypa/gh-action-pypi-publish@release/v1 69 | with: 70 | skip-existing: true 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Netbox Configuration Backup 2 | 3 | A configuration backup system using netbox and napalm to backup devices into a git repository 4 | 5 | # Features 6 | 7 | * Connects to any device that supports napalm and provides both a running configuration and startup configuration 8 | * Stores backups in a git repository 9 | * Runs as a scheduled task through Django RQ 10 | * Only displays backups with changes 11 | * Provides both configuration download and diffs for point-in-time backups 12 | 13 | # Future 14 | 15 | * Allow github repositories 16 | * Add job "discovery" based on specific criteria (napalm enabled, device role switch, has primary ip as an example) 17 | * Add RQ job to ensure all backups are queued 18 | * Allow manual queueing of job 19 | * Add API endpoint to trigger backup 20 | * Add signal(s) to trigger backup 21 | 22 | # Installation 23 | 24 | 1. Install from PyPI (`pip install netbox-config-backup`) 25 | 1. This should install `netbox_napalm_plugin` which is also required 26 | 2. Edit netbox configuration: 27 | ```pyython 28 | PLUGINS = [ 29 | 'netbox_config_backup', 30 | # Other plugins here 31 | ] 32 | 33 | PLUGINS_CONFIG = { 34 | 'netbox_config_backup': { 35 | # Parent folder must exist and be writable by your RQ worker and readable by the WSGI process 36 | 'repository': '/path/to/git/repository', 37 | 'committer': 'User ', 38 | 'author': 'User ', 39 | # Freqency of backups in seconds, can be anywhere 0+ (Recommended is 1800 (30 minutes) or 3600 (1 hr) 40 | 'frequency': 3600 41 | } 42 | } 43 | ``` 44 | 3. Migrate: `python3 netbox/manage.py migrate` 45 | 4. Create appropriate Napalm configurations for all devices you will be backing up 46 | 5. Create your first device backup 47 | 48 | ### Cleanup Old Version 49 | 50 | If you are coming from an older version, please remove the custom RQ worker as it is no longer required 51 | 52 | ## Logging 53 | 54 | To enable logging, add the following to your configuration.py under LOGGING: 55 | 56 | ```python 57 | 'netbox_config_backup': { 58 | 'handlers': ['enter_your_handlers_here'], 59 | 'level': 'desired_log_level', 60 | 'propagate': True, 61 | }, 62 | ``` 63 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/rq.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from dcim.choices import DeviceStatusChoices 4 | from netbox_config_backup.choices import StatusChoices 5 | 6 | logger = logging.getLogger("netbox_config_backup") 7 | 8 | 9 | def can_backup(backup): 10 | logger.debug(f'Checking backup suitability for {backup}') 11 | if backup.device is None: 12 | logger.info(f'No device for {backup}') 13 | return False 14 | elif backup.status == StatusChoices.STATUS_DISABLED: 15 | logger.info(f'Backup disabled for {backup}') 16 | return False 17 | elif backup.device.status in [ 18 | DeviceStatusChoices.STATUS_OFFLINE, 19 | DeviceStatusChoices.STATUS_FAILED, 20 | DeviceStatusChoices.STATUS_INVENTORY, 21 | DeviceStatusChoices.STATUS_PLANNED, 22 | ]: 23 | logger.info( 24 | f'Backup disabled for {backup} due to device status ({backup.device.status})' 25 | ) 26 | return False 27 | elif ( 28 | (backup.ip is None and backup.device.primary_ip is None) 29 | or backup.device.platform is None 30 | or hasattr(backup.device.platform, 'napalm') is False 31 | or backup.device.platform.napalm is None 32 | or backup.device.platform.napalm.napalm_driver == '' 33 | or backup.device.platform.napalm.napalm_driver is None 34 | ): 35 | if backup.ip is None and backup.device.primary_ip is None: 36 | logger.warning( 37 | f'Backup disabled for {backup} due to no primary IP ({backup.device.status})' 38 | ) 39 | elif backup.device.platform is None: 40 | logger.warning( 41 | f'Backup disabled for {backup} due to platform not set ({backup.device.status})' 42 | ) 43 | elif ( 44 | hasattr(backup.device.platform, 'napalm') is False 45 | or backup.device.platform.napalm is None 46 | ): 47 | logger.warning( 48 | f'Backup disabled for {backup} due to platform having no napalm config ({backup.device.status})' 49 | ) 50 | elif ( 51 | backup.device.platform.napalm.napalm_driver == '' 52 | or backup.device.platform.napalm.napalm_driver is None 53 | ): 54 | logger.warning( 55 | f'Backup disabled for {backup} due to napalm driver not set ({backup.device.status})' 56 | ) 57 | return False 58 | 59 | return True 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | *.egg-info/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # InteliJ 133 | .idea -------------------------------------------------------------------------------- /netbox_config_backup/models/jobs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db import models 4 | from django.db.models import ForeignKey 5 | from django.utils import timezone 6 | from django.utils.translation import gettext as _ 7 | 8 | from django_rq import get_queue 9 | 10 | from core.choices import JobStatusChoices 11 | from netbox.models import NetBoxModel 12 | from utilities.querysets import RestrictedQuerySet 13 | 14 | 15 | logger = logging.getLogger("netbox_config_backup") 16 | 17 | 18 | class BackupJob(NetBoxModel): 19 | runner = models.ForeignKey( 20 | verbose_name=_('Job Run'), 21 | to='core.Job', 22 | on_delete=models.SET_NULL, 23 | related_name='backup_job', 24 | null=True, 25 | blank=True, 26 | ) 27 | backup = ForeignKey( 28 | to='Backup', 29 | on_delete=models.CASCADE, 30 | blank=False, 31 | null=False, 32 | related_name='jobs', 33 | ) 34 | pid = models.BigIntegerField( 35 | verbose_name=_('PID'), 36 | null=True, 37 | blank=True, 38 | ) 39 | created = models.DateTimeField(auto_now_add=True) 40 | scheduled = models.DateTimeField(null=True, blank=True) 41 | started = models.DateTimeField(null=True, blank=True) 42 | completed = models.DateTimeField(null=True, blank=True) 43 | status = models.CharField( 44 | max_length=30, choices=JobStatusChoices, default=JobStatusChoices.STATUS_PENDING 45 | ) 46 | data = models.JSONField(null=True, blank=True) 47 | job_id = models.UUIDField(unique=True) 48 | 49 | objects = RestrictedQuerySet.as_manager() 50 | 51 | class Meta: 52 | ordering = ('pk',) 53 | 54 | def __str__(self): 55 | return str(self.job_id) 56 | 57 | def get_absolute_url(self): 58 | return None 59 | 60 | @property 61 | def queue(self): 62 | return get_queue('netbox_config_backup.jobs') 63 | 64 | @property 65 | def duration(self): 66 | if not self.completed: 67 | return None 68 | 69 | duration = self.completed - self.started 70 | minutes, seconds = divmod(duration.total_seconds(), 60) 71 | 72 | return f"{int(minutes)} minutes, {seconds:.2f} seconds" 73 | 74 | def set_status(self, status): 75 | """ 76 | Helper method to change the status of the job result. If the target status is terminal, the completion 77 | time is also set. 78 | """ 79 | self.status = status 80 | if status in JobStatusChoices.TERMINAL_STATE_CHOICES: 81 | self.completed = timezone.now() 82 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | description: Propose a new Plugin feature or enhancement 4 | labels: ["type: feature"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | **NOTE:** This form is only for submitting well-formed proposals to extend or modify 10 | the plugin in some way. If you're trying to solve a problem but can't figure out how, 11 | or if you still need time to work on the details of a proposed new feature, please 12 | start a [discussion](https://github.com/netbox-community/netbox/discussions) instead. 13 | - type: input 14 | attributes: 15 | label: Plugin version 16 | description: What version of the plugin are you currently running? 17 | placeholder: v1.0.6 18 | validations: 19 | required: true 20 | - type: input 21 | attributes: 22 | label: NetBox version 23 | description: What version of NetBox are you currently running? 24 | placeholder: v3.0.3 25 | validations: 26 | required: true 27 | - type: dropdown 28 | attributes: 29 | label: Feature type 30 | options: 31 | - Data model extension 32 | - New functionality 33 | - Change to existing functionality 34 | validations: 35 | required: true 36 | - type: textarea 37 | attributes: 38 | label: Proposed functionality 39 | description: > 40 | Describe in detail the new feature or behavior you are proposing. Include any specific changes 41 | to work flows, data models, and/or the user interface. The more detail you provide here, the 42 | greater chance your proposal has of being discussed. Feature requests which don't include an 43 | actionable implementation plan will be rejected. 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: Use case 49 | description: > 50 | Explain how adding this functionality would benefit NetBox users. What need does it address? 51 | validations: 52 | required: true 53 | - type: textarea 54 | attributes: 55 | label: Database changes 56 | description: > 57 | Note any changes to the database schema necessary to support the new feature. For example, 58 | does the proposal require adding a new model or field? (Not all new features require database 59 | changes.) 60 | - type: textarea 61 | attributes: 62 | label: External dependencies 63 | description: > 64 | List any new dependencies on external libraries or services that this new feature would 65 | introduce. For example, does the proposal require the installation of a new Python package? 66 | (Not all new features introduce new dependencies.) 67 | -------------------------------------------------------------------------------- /netbox_config_backup/models/repository.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | from netbox_config_backup.choices import FileTypeChoices 5 | from netbox_config_backup.models import Backup 6 | from netbox_config_backup.models.abstract import BigIDModel 7 | 8 | 9 | class BackupCommit(BigIDModel): 10 | sha = models.CharField(max_length=64) 11 | time = models.DateTimeField() 12 | 13 | class Meta: 14 | ordering = ('pk',) 15 | 16 | def __str__(self): 17 | return self.sha 18 | 19 | 20 | class BackupObject(BigIDModel): 21 | sha = models.CharField(max_length=64, unique=True) 22 | 23 | class Meta: 24 | ordering = ('pk',) 25 | 26 | def __str__(self): 27 | return f'{self.sha}' 28 | 29 | 30 | class BackupFile(BigIDModel): 31 | backup = models.ForeignKey( 32 | to=Backup, 33 | on_delete=models.CASCADE, 34 | null=False, 35 | blank=False, 36 | related_name='files', 37 | ) 38 | type = models.CharField(max_length=10, choices=FileTypeChoices, null=False, blank=False) 39 | 40 | last_change = models.DateTimeField(null=True, blank=True) 41 | 42 | class Meta: 43 | ordering = ('pk',) 44 | unique_together = ['backup', 'type'] 45 | 46 | def __str__(self): 47 | return f'{self.name}.{self.type}' 48 | 49 | @property 50 | def name(self): 51 | return f'{self.backup.uuid}' 52 | 53 | @property 54 | def path(self): 55 | return f'{self.name}.{self.type}' 56 | 57 | 58 | class BackupCommitTreeChange(BigIDModel): 59 | backup = models.ForeignKey( 60 | to=Backup, 61 | on_delete=models.CASCADE, 62 | null=False, 63 | blank=False, 64 | related_name='changes', 65 | ) 66 | file = models.ForeignKey( 67 | to=BackupFile, 68 | on_delete=models.CASCADE, 69 | null=False, 70 | blank=False, 71 | related_name='changes', 72 | ) 73 | 74 | commit = models.ForeignKey(to=BackupCommit, on_delete=models.PROTECT, related_name='changes') 75 | type = models.CharField(max_length=10) 76 | old = models.ForeignKey(to=BackupObject, on_delete=models.PROTECT, related_name='previous', null=True) 77 | new = models.ForeignKey(to=BackupObject, on_delete=models.PROTECT, related_name='changes', null=True) 78 | 79 | class Meta: 80 | ordering = ('pk',) 81 | 82 | def __str__(self): 83 | return f'{self.commit.sha}-{self.type}' 84 | 85 | def filename(self): 86 | return f'{self.backup.uuid}.{self.type}' 87 | 88 | def get_absolute_url(self): 89 | return reverse( 90 | 'plugins:netbox_config_backup:backup_config', 91 | kwargs={'pk': self.backup.pk, 'current': self.pk}, 92 | ) 93 | 94 | @property 95 | def previous(self): 96 | return self.backup.changes.filter(file__type=self.file.type, commit__time__lt=self.commit.time).last() 97 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | description: Report a reproducible bug in the current release of the plugin 4 | labels: ["type: bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | **NOTE:** This form is only for reporting _reproducible bugs_ in a current NetBox installation 10 | with the secretstore plugin. If you're having trouble with installation or just looking for 11 | assistance with using NetBox, please visit our 12 | [discussion forum](https://github.com/netbox-community/netbox/discussions) instead. 13 | - type: input 14 | attributes: 15 | label: Plugin version 16 | description: > 17 | What version of the plugin are you running? 18 | placeholder: v1.2.2 19 | validations: 20 | required: true 21 | - type: input 22 | attributes: 23 | label: NetBox version 24 | description: > 25 | What version of NetBox are you currently running? (If you don't have access to the most 26 | recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) 27 | before opening a bug report to see if your issue has already been addressed.) 28 | placeholder: v3.2.0 29 | validations: 30 | required: true 31 | - type: dropdown 32 | attributes: 33 | label: Python version 34 | description: What version of Python are you currently running? 35 | options: 36 | - 3.8 37 | - 3.9 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Steps to Reproduce 43 | description: > 44 | Describe in detail the exact steps that someone else can take to 45 | reproduce this bug using the current stable release of NetBox and the plugin. 46 | Begin with the creation of any necessary database objects and call out every 47 | operation being performed explicitly. If reporting a bug in the REST API, be 48 | sure to reconstruct the raw HTTP request(s) being made: Don't rely on a client 49 | library such as pynetbox. Additionally, **do not rely on the demo instance** 50 | for reproducing suspected bugs, as its data is prone to modification or 51 | deletion at any time. 52 | placeholder: | 53 | 1. Click on "create widget" 54 | 2. Set foo to 12 and bar to G 55 | 3. Click the "create" button 56 | validations: 57 | required: true 58 | - type: textarea 59 | attributes: 60 | label: Expected Behavior 61 | description: What did you expect to happen? 62 | placeholder: A new widget should have been created with the specified attributes 63 | validations: 64 | required: true 65 | - type: textarea 66 | attributes: 67 | label: Observed Behavior 68 | description: What happened instead? 69 | placeholder: A TypeError exception was raised 70 | validations: 71 | required: true 72 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/napalm.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from netmiko import NetmikoAuthenticationException, NetmikoTimeoutException 3 | 4 | from netbox.api.exceptions import ServiceUnavailable 5 | 6 | logger = logging.getLogger("netbox_config_backup") 7 | 8 | 9 | def napalm_init(device, ip=None, extra_args={}): 10 | from netbox import settings 11 | 12 | username = settings.PLUGINS_CONFIG.get('netbox_napalm_plugin', {}).get( 13 | 'NAPALM_USERNAME', None 14 | ) 15 | password = settings.PLUGINS_CONFIG.get('netbox_napalm_plugin', {}).get( 16 | 'NAPALM_PASSWORD', None 17 | ) 18 | timeout = settings.PLUGINS_CONFIG.get('netbox_napalm_plugin', {}).get( 19 | 'NAPALM_TIMEOUT', None 20 | ) 21 | optional_args = ( 22 | settings.PLUGINS_CONFIG.get('netbox_napalm_plugin', {}) 23 | .get('NAPALM_ARGS', []) 24 | .copy() 25 | ) 26 | 27 | if device and device.platform and device.platform.napalm.napalm_args is not None: 28 | optional_args.update(device.platform.napalm.napalm_args) 29 | if extra_args != {}: 30 | optional_args.update(extra_args) 31 | 32 | # Check for primary IP address from NetBox object 33 | if ip is not None: 34 | host = str(ip.address.ip) 35 | elif device.primary_ip and device.primary_ip is not None: 36 | host = str(device.primary_ip.address.ip) 37 | else: 38 | raise ServiceUnavailable("This device does not have a primary IP address") 39 | 40 | # Check that NAPALM is installed 41 | try: 42 | import napalm 43 | from napalm.base.exceptions import ModuleImportError 44 | except ModuleNotFoundError as e: 45 | if getattr(e, 'name') == 'napalm': 46 | raise ServiceUnavailable( 47 | "NAPALM is not installed. Please see the documentation for instructions." 48 | ) 49 | raise e 50 | 51 | # Validate the configured driver 52 | try: 53 | driver = napalm.get_network_driver(device.platform.napalm.napalm_driver) 54 | except ModuleImportError: 55 | raise ServiceUnavailable( 56 | "NAPALM driver for platform {} not found: {}.".format( 57 | device.platform, device.platform.napalm.napalm_driver 58 | ) 59 | ) 60 | 61 | # Connect to the device 62 | d = driver( 63 | hostname=host, 64 | username=username, 65 | password=password, 66 | timeout=timeout, 67 | optional_args=optional_args, 68 | ) 69 | try: 70 | d.open() 71 | except Exception as e: 72 | if isinstance(e, NetmikoAuthenticationException): 73 | logger.info(f'Authentication error for f{device}:{host}') 74 | logger.info(f'{e}') 75 | elif isinstance(e, NetmikoTimeoutException): 76 | logger.info('Connection error') 77 | raise ServiceUnavailable( 78 | "Error connecting to the device at {}: {}".format(host, e) 79 | ) 80 | 81 | return d 82 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-20 03:24 2 | 3 | import django.core.serializers.json 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import taggit.managers 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('dcim', '0133_port_colors'), 16 | ('extras', '0062_clear_secrets_changelog'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Backup', 22 | fields=[ 23 | ('created', models.DateField(auto_now_add=True, null=True)), 24 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 25 | ( 26 | 'custom_field_data', 27 | models.JSONField( 28 | blank=True, 29 | default=dict, 30 | encoder=django.core.serializers.json.DjangoJSONEncoder, 31 | ), 32 | ), 33 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 34 | ('name', models.CharField(max_length=255)), 35 | ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), 36 | ( 37 | 'device', 38 | models.ForeignKey( 39 | blank=True, 40 | null=True, 41 | on_delete=django.db.models.deletion.SET_NULL, 42 | to='dcim.device', 43 | ), 44 | ), 45 | ( 46 | 'tags', 47 | taggit.managers.TaggableManager( 48 | through='extras.TaggedItem', to='extras.Tag' 49 | ), 50 | ), 51 | ], 52 | options={ 53 | 'ordering': ['name'], 54 | }, 55 | ), 56 | migrations.CreateModel( 57 | name='BackupJob', 58 | fields=[ 59 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 60 | ('created', models.DateTimeField(auto_now_add=True)), 61 | ('started', models.DateTimeField(blank=True, null=True)), 62 | ('completed', models.DateTimeField(blank=True, null=True)), 63 | ('status', models.CharField(default='pending', max_length=30)), 64 | ('data', models.JSONField(blank=True, null=True)), 65 | ('job_id', models.UUIDField(unique=True)), 66 | ( 67 | 'backup', 68 | models.ForeignKey( 69 | on_delete=django.db.models.deletion.PROTECT, 70 | related_name='jobs', 71 | to='netbox_config_backup.backup', 72 | ), 73 | ), 74 | ], 75 | options={ 76 | 'abstract': False, 77 | }, 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /netbox_config_backup/templates/netbox_config_backup/backup.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load helpers %} 3 | 4 | {% block subtitle %} 5 |
6 | {% endblock %} 7 | 8 | {% block content %} 9 |
10 |
11 |
12 |
13 | Backup Attributes 14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% if object.device %} 28 | 29 | {% else %} 30 | 31 | {% endif %} 32 | 33 | 34 | 35 | {% if object.ip %} 36 | 37 | {% else %} 38 | 39 | {% endif %} 40 | 41 | 42 | 43 | 44 | 45 |
Name{{ object.name|placeholder }}
UUID{{ object.uuid }}
Device{{ object.device|placeholder }}{{ object.device|placeholder }}
IP Address{{ object.ip|placeholder }}{{ object.ip|placeholder }}
Description{{ object.description|placeholder }}
46 |
47 |
48 | {% include 'inc/panels/comments.html' %} 49 |
50 | 51 |
52 |
53 |
54 | Status 55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
Config Saved{{ object.config_status | placeholder }}
Scheduled{{ status.scheduled | placeholder }}{% if status.scheduled %} ({{status.next_attempt}}){% endif %}
Last Job{{ status.last_job.completed | placeholder }}
Last Success{{ status.last_success | placeholder }}
Last Change{{ status.last_change | placeholder }}
79 |
80 |
81 |
82 |
83 | {% endblock %} -------------------------------------------------------------------------------- /netbox_config_backup/management/commands/fix_missing.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.core.management.base import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args, **options): 7 | from netbox_config_backup.git import repository 8 | from netbox_config_backup.models import ( 9 | Backup, 10 | BackupCommit, 11 | BackupFile, 12 | BackupObject, 13 | BackupCommitTreeChange, 14 | ) 15 | 16 | LOCAL_TIMEZONE = ( 17 | datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo 18 | ) 19 | 20 | print('Fetching Git log') 21 | log = reversed(repository.log()) 22 | print('Fetched Git log') 23 | 24 | for entry in log: 25 | time = entry.get('time', datetime.datetime.now()).replace( 26 | tzinfo=LOCAL_TIMEZONE 27 | ) 28 | 29 | try: 30 | bc = BackupCommit.objects.get(sha=entry.get('sha', None)) 31 | except BackupCommit.DoesNotExist: 32 | bc = BackupCommit(sha=entry.get('sha', None), time=time) 33 | print( 34 | f'Saving commit: {bc.sha} at time {bc.time} with parent {entry.get("parents", [])}' 35 | ) 36 | bc.save() 37 | for change in entry.get('changes'): 38 | backup = None 39 | backupfile = None 40 | 41 | change_data = {} 42 | for key in ['old', 'new']: 43 | sha = change.get(key, {}).get('sha', None) 44 | file = change.get(key, {}).get('path', None) 45 | if file is not None and file is not None: 46 | uuid, type = file.split('.') 47 | try: 48 | backup = Backup.objects.get(uuid=uuid) 49 | except Backup.DoesNotExist: 50 | backup = Backup.objects.create(uuid=uuid, name=uuid) 51 | try: 52 | backupfile = BackupFile.objects.get( 53 | backup=backup, type=type 54 | ) 55 | except BackupFile.DoesNotExist: 56 | backupfile = BackupFile.objects.create( 57 | backup=backup, type=type 58 | ) 59 | try: 60 | object = BackupObject.objects.get(sha=sha) 61 | except BackupObject.DoesNotExist: 62 | object = BackupObject.objects.create(sha=sha) 63 | change_data[key] = object 64 | 65 | index = f'{key}-{sha}-{file}' 66 | print(f'\t\t{index}') 67 | 68 | try: 69 | bctc = BackupCommitTreeChange.objects.get( 70 | backup=backup, 71 | file=backupfile, 72 | commit=bc, 73 | type=change.get('type', None), 74 | old=change_data.get('old', None), 75 | new=change_data.get('new', None), 76 | ) 77 | except BackupCommitTreeChange.DoesNotExist: 78 | bctc = BackupCommitTreeChange.objects.create( 79 | backup=backup, 80 | file=backupfile, 81 | commit=bc, 82 | type=change.get('type', None), 83 | old=change_data.get('old', None), 84 | new=change_data.get('new', None), 85 | ) 86 | 87 | newsha = bctc.new.sha if bctc.new else None 88 | oldsha = bctc.old.sha if bctc.old else None 89 | print(f'\tSaving change {bc.sha}, {oldsha}, {newsha}') 90 | print("") 91 | -------------------------------------------------------------------------------- /netbox_config_backup/utils/configs.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import re 3 | 4 | from django.utils import timezone 5 | 6 | from netbox_config_backup.utils.logger import get_logger 7 | 8 | logger = get_logger() 9 | 10 | 11 | def check_config_save_status(d): 12 | logger.debug(f'Switch: {d.hostname}') 13 | platform = { 14 | 'ios': { 15 | 'running': { 16 | 'command': 'show running-config | inc ! Last configuration change', 17 | 'regex': r'(?P\d+):(?P\d+):(?P\d+) \S+ \S+ (?P\S+) (?P\d+) (?P\S+)(?: by \S+)?', # noqa: E501 18 | }, 19 | 'startup': { 20 | 'command': 'show startup-config | inc ! Last configuration change', 21 | 'regex': r'(?P\d+):(?P\d+):(?P\d+) \S+ \S+ (?P\S+) (?P\d+) (?P\S+)(?: by \S+)?', # noqa: E501 22 | }, 23 | }, 24 | 'nxos_ssh': { 25 | 'running': { 26 | 'command': 'show running-config | inc "!Running configuration last done at:"', 27 | 'regex': r'(?P\S+)\s+(?P\d+) (?P\d+):(?P\d+):(?P\d+) (?P\d+)', # noqa: E501 28 | }, 29 | 'startup': { 30 | 'command': 'show startup-config | inc "!Startup config saved at:"', 31 | 'regex': r'(?P\S+)\s+(?P\d+) (?P\d+):(?P\d+):(?P\d+) (?P\d+)', # noqa: E501 32 | }, 33 | }, 34 | } 35 | 36 | try: 37 | datetimes = {'running': None, 'startup': None, 'status': None} 38 | dates = {'running': None, 'startup': None, 'status': None} 39 | for file in ['running', 'startup']: 40 | command = d.cli( 41 | commands=[platform.get(d.platform, {}).get(file, {}).get('command', '')] 42 | ) 43 | result = list(command.values()).pop() 44 | regex = platform.get(d.platform, {}).get(file, {}).get('regex', '') 45 | search = re.search(regex, result) 46 | 47 | if search is not None and search.groupdict(): 48 | status = search.groupdict() 49 | year = status.get('year') 50 | month = status.get('month') 51 | day = ( 52 | f"0{int(status.get('day'))}" 53 | if int(status.get('day')) < 10 54 | else f"{int(status.get('day'))}" 55 | ) 56 | hours = status.get('hours') 57 | minutes = status.get('minutes') 58 | seconds = status.get('seconds') 59 | 60 | date = f'{year}-{month}-{day} {hours}:{minutes}:{seconds}' 61 | 62 | dates[file] = date 63 | datetimes[file] = timezone.make_aware( 64 | datetime.strptime(date, '%Y-%b-%d %H:%M:%S') 65 | ) 66 | else: 67 | logger.debug(f'\tNo {file} time found, platform: {d.platform}') 68 | 69 | if datetimes['running'] is None and datetimes['startup'] is not None: 70 | logger.debug('\tValid backup as booted from startup') 71 | datetimes.update({'status': True}) 72 | dates.update({'status': True}) 73 | return datetimes 74 | elif datetimes['startup'] is None: 75 | logger.debug('\tNo startup time') 76 | return datetimes 77 | elif datetimes['running'] <= datetimes['startup']: 78 | logger.debug('\tRunning config less then startup') 79 | datetimes.update({'status': True}) 80 | dates.update({'status': True}) 81 | return datetimes 82 | elif datetimes['running'] > datetimes['startup']: 83 | logger.debug('\tRunning config greater then startup') 84 | datetimes.update({'status': False}) 85 | dates.update({'status': False}) 86 | return datetimes 87 | 88 | except Exception as e: 89 | 90 | logger.error(f'Exception when trying to check config status: {e}') 91 | -------------------------------------------------------------------------------- /netbox_config_backup/management/commands/rebuild.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.core.management.base import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args, **options): 7 | from netbox_config_backup.git import repository 8 | from netbox_config_backup.models import ( 9 | Backup, 10 | BackupCommit, 11 | BackupFile, 12 | BackupObject, 13 | BackupCommitTreeChange, 14 | ) 15 | 16 | LOCAL_TIMEZONE = ( 17 | datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo 18 | ) 19 | 20 | BackupCommitTreeChange.objects.all().delete() 21 | BackupCommit.objects.all().delete() 22 | BackupObject.objects.all().delete() 23 | 24 | print('Fetching Git log') 25 | log = reversed(repository.log()) 26 | print('Fetched Git log') 27 | 28 | for entry in log: 29 | time = entry.get('time', datetime.datetime.now()).replace( 30 | tzinfo=LOCAL_TIMEZONE 31 | ) 32 | 33 | try: 34 | bc = BackupCommit.objects.get(sha=entry.get('sha', None)) 35 | except BackupCommit.DoesNotExist: 36 | bc = BackupCommit(sha=entry.get('sha', None), time=time) 37 | print( 38 | f'Saving commit: {bc.sha} at time {bc.time} with parent {entry.get("parents", [])}' 39 | ) 40 | bc.save() 41 | for change in entry.get('changes'): 42 | backup = None 43 | backupfile = None 44 | 45 | change_data = {} 46 | for key in ['old', 'new']: 47 | sha = change.get(key, {}).get('sha', None) 48 | file = change.get(key, {}).get('path', None) 49 | if file is not None and file is not None: 50 | uuid, type = file.split('.') 51 | try: 52 | backup = Backup.objects.get(uuid=uuid) 53 | except Backup.DoesNotExist: 54 | backup = Backup.objects.create(uuid=uuid, name=uuid) 55 | try: 56 | backupfile = BackupFile.objects.get( 57 | backup=backup, type=type 58 | ) 59 | except BackupFile.DoesNotExist: 60 | backupfile = BackupFile.objects.create( 61 | backup=backup, type=type 62 | ) 63 | try: 64 | object = BackupObject.objects.get(sha=sha) 65 | except BackupObject.DoesNotExist: 66 | object = BackupObject.objects.create(sha=sha) 67 | change_data[key] = object 68 | 69 | index = f'{key}-{sha}-{file}' 70 | print(f'\t\t{index}') 71 | 72 | try: 73 | bctc = BackupCommitTreeChange.objects.get( 74 | backup=backup, 75 | file=backupfile, 76 | commit=bc, 77 | type=change.get('type', None), 78 | old=change_data.get('old', None), 79 | new=change_data.get('new', None), 80 | ) 81 | except BackupCommitTreeChange.DoesNotExist: 82 | bctc = BackupCommitTreeChange.objects.create( 83 | backup=backup, 84 | file=backupfile, 85 | commit=bc, 86 | type=change.get('type', None), 87 | old=change_data.get('old', None), 88 | new=change_data.get('new', None), 89 | ) 90 | 91 | newsha = bctc.new.sha if bctc.new else None 92 | oldsha = bctc.old.sha if bctc.old else None 93 | print(f'\tSaving change {bc.sha}, {oldsha}, {newsha}') 94 | print("") 95 | -------------------------------------------------------------------------------- /netbox_config_backup/filtersets.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | import netaddr 3 | from django.core.exceptions import ValidationError 4 | from django.db.models import Q 5 | from django.utils.translation import gettext as _ 6 | from netaddr import AddrFormatError 7 | 8 | from netbox.filtersets import BaseFilterSet 9 | from dcim.models import Device 10 | from netbox_config_backup import models 11 | from netbox_config_backup.choices import FileTypeChoices 12 | 13 | 14 | class BackupJobFilterSet(BaseFilterSet): 15 | q = django_filters.CharFilter( 16 | method='search', 17 | label=_('Search'), 18 | ) 19 | 20 | class Meta: 21 | model = models.BackupJob 22 | fields = ['id', 'status'] 23 | 24 | def search(self, queryset, name, value): 25 | if not value.strip(): 26 | return queryset 27 | qs_filter = ( 28 | Q(backup__name__icontains=value) 29 | | Q(backup__ip__address__icontains=value) 30 | | Q(backup__device__name__icontains=value) 31 | ) 32 | 33 | return queryset.filter(qs_filter) 34 | 35 | 36 | class BackupFilterSet(BaseFilterSet): 37 | q = django_filters.CharFilter( 38 | method='search', 39 | label=_('Search'), 40 | ) 41 | device = django_filters.ModelMultipleChoiceFilter( 42 | field_name='device__name', 43 | queryset=Device.objects.all(), 44 | to_field_name='name', 45 | label=_('Device (name)'), 46 | ) 47 | device_id = django_filters.ModelMultipleChoiceFilter( 48 | field_name='device', 49 | queryset=Device.objects.all(), 50 | label=_('Device (ID)'), 51 | ) 52 | config_status = django_filters.BooleanFilter( 53 | field_name='config_status', 54 | label=_('Config Saved'), 55 | ) 56 | ip = django_filters.CharFilter( 57 | method='filter_address', 58 | label=_('Address'), 59 | ) 60 | 61 | class Meta: 62 | model = models.Backup 63 | fields = ['id', 'name', 'ip'] 64 | 65 | def search(self, queryset, name, value): 66 | if not value.strip(): 67 | return queryset 68 | qs_filter = ( 69 | Q(name__icontains=value) 70 | | Q(device__name__icontains=value) 71 | | Q(device__primary_ip4__address__contains=value.strip()) 72 | | Q(device__primary_ip6__address__contains=value.strip()) 73 | | Q(ip__address__contains=value.strip()) 74 | ) 75 | 76 | try: 77 | prefix = str(netaddr.IPNetwork(value.strip()).cidr) 78 | qs_filter |= Q(device__primary_ip4__address__net_host_contained=prefix) 79 | qs_filter |= Q(device__primary_ip6__address__net_host_contained=prefix) 80 | qs_filter |= Q(ip__address__net_host_contained=prefix) 81 | 82 | except (AddrFormatError, ValueError): 83 | pass 84 | 85 | return queryset.filter(qs_filter) 86 | 87 | def filter_address(self, queryset, name, value): 88 | try: 89 | if type(value) is list: 90 | query = Q() 91 | for val in value: 92 | query |= Q(ip__address__net_host_contained=val) 93 | return queryset.filter(query) 94 | else: 95 | return queryset.filter(ip__address__net_host_contained=value) 96 | except ValidationError: 97 | return queryset.none() 98 | 99 | 100 | class BackupsFilterSet(BaseFilterSet): 101 | q = django_filters.CharFilter( 102 | method='search', 103 | label=_('Search'), 104 | ) 105 | type = django_filters.MultipleChoiceFilter( 106 | field_name='file__type', choices=FileTypeChoices, null_value=None 107 | ) 108 | 109 | class Meta: 110 | model = models.BackupCommitTreeChange 111 | fields = ['id', 'file'] 112 | 113 | def search(self, queryset, name, value): 114 | if not value.strip(): 115 | return queryset 116 | qs_filter = Q(file__type=value) | Q(file__type__startswith=value) 117 | return queryset.filter(qs_filter) 118 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0002_git_models.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-22 23:14 2 | 3 | import django.core.serializers.json 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import taggit.managers 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('extras', '0062_clear_secrets_changelog'), 13 | ('netbox_config_backup', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='BackupCommit', 19 | fields=[ 20 | ('created', models.DateField(auto_now_add=True, null=True)), 21 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 22 | ( 23 | 'custom_field_data', 24 | models.JSONField( 25 | blank=True, 26 | default=dict, 27 | encoder=django.core.serializers.json.DjangoJSONEncoder, 28 | ), 29 | ), 30 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 31 | ('sha', models.CharField(max_length=64)), 32 | ( 33 | 'backup', 34 | models.ForeignKey( 35 | null=True, 36 | on_delete=django.db.models.deletion.SET_NULL, 37 | to='netbox_config_backup.backup', 38 | ), 39 | ), 40 | ( 41 | 'tags', 42 | taggit.managers.TaggableManager( 43 | through='extras.TaggedItem', to='extras.Tag' 44 | ), 45 | ), 46 | ], 47 | options={ 48 | 'abstract': False, 49 | }, 50 | ), 51 | migrations.AlterField( 52 | model_name='backupjob', 53 | name='backup', 54 | field=models.ForeignKey( 55 | on_delete=django.db.models.deletion.CASCADE, 56 | related_name='jobs', 57 | to='netbox_config_backup.backup', 58 | ), 59 | ), 60 | migrations.CreateModel( 61 | name='BackupObject', 62 | fields=[ 63 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 64 | ('sha', models.CharField(max_length=64)), 65 | ('file', models.CharField(max_length=255)), 66 | ], 67 | options={ 68 | 'unique_together': {('sha', 'file')}, 69 | }, 70 | ), 71 | migrations.CreateModel( 72 | name='BackupCommitTreeChange', 73 | fields=[ 74 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 75 | ('type', models.CharField(max_length=10)), 76 | ( 77 | 'commit', 78 | models.ForeignKey( 79 | on_delete=django.db.models.deletion.PROTECT, 80 | related_name='changes', 81 | to='netbox_config_backup.backupcommit', 82 | ), 83 | ), 84 | ( 85 | 'new', 86 | models.ForeignKey( 87 | null=True, 88 | on_delete=django.db.models.deletion.PROTECT, 89 | related_name='new', 90 | to='netbox_config_backup.backupobject', 91 | ), 92 | ), 93 | ( 94 | 'old', 95 | models.ForeignKey( 96 | null=True, 97 | on_delete=django.db.models.deletion.PROTECT, 98 | related_name='previous', 99 | to='netbox_config_backup.backupobject', 100 | ), 101 | ), 102 | ], 103 | options={ 104 | 'abstract': False, 105 | }, 106 | ), 107 | ] 108 | -------------------------------------------------------------------------------- /netbox_config_backup/tables.py: -------------------------------------------------------------------------------- 1 | import django_tables2 as tables 2 | from django_tables2.utils import Accessor 3 | 4 | from netbox_config_backup.models import Backup, BackupCommitTreeChange, BackupJob 5 | from netbox.tables import columns, BaseTable, NetBoxTable 6 | 7 | 8 | class ActionButtonsColumn(tables.TemplateColumn): 9 | attrs = {'td': {'class': 'text-end text-nowrap noprint min-width'}} 10 | template_code = """ 11 | 13 | 14 | 15 | 17 | 18 | 19 | {% if record.previous %} 20 | 21 | 22 | 23 | {% endif %} 24 | """ # noqa: E501 25 | 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(*args, template_code=self.template_code, **kwargs) 28 | 29 | def header(self): 30 | return '' 31 | 32 | 33 | class BackupJobTable(BaseTable): 34 | id = tables.Column(linkify=True, verbose_name='ID') 35 | pk = columns.ToggleColumn() 36 | backup = tables.Column(linkify=True, verbose_name='Backup') 37 | created = tables.DateTimeColumn() 38 | scheduled = tables.DateTimeColumn() 39 | started = tables.DateTimeColumn() 40 | completed = tables.DateTimeColumn() 41 | 42 | class Meta(BaseTable.Meta): 43 | model = BackupJob 44 | fields = ( 45 | 'pk', 46 | 'id', 47 | 'backup', 48 | 'pid', 49 | 'created', 50 | 'scheduled', 51 | 'started', 52 | 'completed', 53 | 'status', 54 | ) 55 | default_columns = ( 56 | 'pk', 57 | 'backup', 58 | 'pid', 59 | 'created', 60 | 'scheduled', 61 | 'started', 62 | 'completed', 63 | 'status', 64 | ) 65 | 66 | def render_backup_count(self, value): 67 | return f'{value.count()}' 68 | 69 | 70 | class BackupTable(BaseTable): 71 | pk = columns.ToggleColumn() 72 | name = tables.Column(linkify=True, verbose_name='Backup Name') 73 | device = tables.Column( 74 | linkify={ 75 | 'viewname': 'dcim:device', 76 | 'args': [Accessor('device_id')], 77 | } 78 | ) 79 | last_backup = tables.DateTimeColumn() 80 | next_attempt = tables.DateTimeColumn() 81 | last_change = tables.DateTimeColumn() 82 | backup_count = tables.Column(accessor='changes') 83 | config_status = tables.BooleanColumn(verbose_name='Config Saved') 84 | 85 | class Meta(BaseTable.Meta): 86 | model = Backup 87 | fields = ( 88 | 'pk', 89 | 'name', 90 | 'device', 91 | 'last_backup', 92 | 'next_attempt', 93 | 'last_change', 94 | 'backup_count', 95 | ) 96 | default_columns = ( 97 | 'pk', 98 | 'name', 99 | 'device', 100 | 'last_backup', 101 | 'next_attempt', 102 | 'backup_count', 103 | ) 104 | 105 | def render_backup_count(self, value): 106 | return f'{value.count()}' 107 | 108 | 109 | class BackupsTable(NetBoxTable): 110 | files = columns.ToggleColumn(accessor='pk', visible=True) 111 | date = tables.Column(accessor='commit__time') 112 | type = tables.Column(accessor='file__type') 113 | actions = ActionButtonsColumn() 114 | 115 | class Meta(NetBoxTable.Meta): 116 | model = BackupCommitTreeChange 117 | fields = ('files', 'id', 'date', 'type', 'backup', 'commit', 'file', 'actions') 118 | default_columns = ('files', 'id', 'date', 'type', 'actions') 119 | exclude = ('pk',) 120 | order_by = ['-date'] 121 | -------------------------------------------------------------------------------- /netbox_config_backup/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from django.utils.translation import gettext as _ 4 | 5 | from core.choices import JobStatusChoices 6 | from dcim.choices import DeviceStatusChoices 7 | from dcim.models import Device 8 | from ipam.models import IPAddress 9 | from netbox.forms import NetBoxModelForm, NetBoxModelBulkEditForm 10 | from netbox_config_backup.models import Backup, BackupJob 11 | from utilities.forms import add_blank_choice, BOOLEAN_WITH_BLANK_CHOICES 12 | from utilities.forms.fields import ( 13 | DynamicModelChoiceField, 14 | DynamicModelMultipleChoiceField, 15 | CommentField, 16 | ) 17 | 18 | __all__ = ( 19 | 'BackupForm', 20 | 'BackupJobFilterSetForm', 21 | 'BackupFilterSetForm', 22 | 'BackupBulkEditForm', 23 | ) 24 | 25 | 26 | class BackupForm(NetBoxModelForm): 27 | device = DynamicModelChoiceField( 28 | label='Device', 29 | required=False, 30 | queryset=Device.objects.all(), 31 | help_text='The device this backup operates on', 32 | query_params={ 33 | 'status': [DeviceStatusChoices.STATUS_ACTIVE], 34 | 'has_primary_ip': True, 35 | }, 36 | ) 37 | ip = DynamicModelChoiceField( 38 | label='IP Address', 39 | required=False, 40 | queryset=IPAddress.objects.all(), 41 | help_text='This field requires the device to be set', 42 | query_params={'device_id': '$device', 'assigned_to_interface': True}, 43 | ) 44 | comments = CommentField() 45 | 46 | class Meta: 47 | model = Backup 48 | fields = ( 49 | 'name', 50 | 'device', 51 | 'ip', 52 | 'status', 53 | 'description', 54 | 'comments', 55 | 'config_status', 56 | ) 57 | 58 | def clean(self): 59 | super().clean() 60 | if self.cleaned_data.get('ip') and not self.cleaned_data.get('device'): 61 | raise ValidationError({'ip': 'Device must be set'}) 62 | 63 | if self.cleaned_data.get('device'): 64 | device = self.cleaned_data.get('device') 65 | if not device.platform: 66 | raise ValidationError({'device': f'{device} has no platform set'}) 67 | elif not hasattr(device.platform, 'napalm'): 68 | raise ValidationError( 69 | { 70 | 'device': f'{device}\'s platform ({device.platform}) has no napalm driver' 71 | } 72 | ) 73 | 74 | 75 | class BackupJobFilterSetForm(forms.Form): 76 | model = BackupJob 77 | field_order = [ 78 | 'q', 79 | 'status', 80 | ] 81 | status = forms.MultipleChoiceField( 82 | required=False, choices=add_blank_choice(JobStatusChoices), label=_('Status') 83 | ) 84 | 85 | 86 | class BackupFilterSetForm(forms.Form): 87 | model = Backup 88 | field_order = ['q', 'name', 'device_id', 'ip'] 89 | q = forms.CharField( 90 | required=False, 91 | widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), 92 | label=_('Search'), 93 | ) 94 | device_id = DynamicModelMultipleChoiceField( 95 | queryset=Device.objects.all(), 96 | required=False, 97 | label=_('Device'), 98 | query_params={ 99 | 'status': [DeviceStatusChoices.STATUS_ACTIVE], 100 | 'platform__napalm__ne': None, 101 | 'has_primary_ip': True, 102 | }, 103 | ) 104 | ip = forms.CharField( 105 | required=False, 106 | widget=forms.TextInput( 107 | attrs={ 108 | 'placeholder': 'IP Address', 109 | } 110 | ), 111 | label=_('IP Address'), 112 | ) 113 | config_status = forms.NullBooleanField( 114 | required=False, 115 | label=_('Config Saved'), 116 | widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES), 117 | ) 118 | 119 | 120 | class BackupBulkEditForm(NetBoxModelBulkEditForm): 121 | 122 | description = forms.CharField( 123 | label=_('Description'), max_length=200, required=False 124 | ) 125 | comments = CommentField() 126 | 127 | model = Backup 128 | fieldsets = () 129 | nullable_fields = () 130 | -------------------------------------------------------------------------------- /netbox_config_backup/migrations/0009_bctc_file_model_backup_fk_restructure.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2022-02-28 20:47 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | def migrate_from_bo_to_bctc(apps, schema_editor): 8 | print('') 9 | print('!!!WARNING!!!') 10 | print( 11 | 'Once this migration operation is complete, it is not recommended to revert it' 12 | ) 13 | 14 | BackupCommitTreeChange = apps.get_model( 15 | 'netbox_config_backup', 'BackupCommitTreeChange' 16 | ) 17 | BackupFile = apps.get_model('netbox_config_backup', 'BackupFile') 18 | bctcs = BackupCommitTreeChange.objects.all() 19 | for bctc in bctcs: 20 | if bctc.new: 21 | uuid, ftype = bctc.new.file.split('.') # noqa: F841 22 | else: 23 | uuid, ftype = bctc.old.file.split('.') # noqa: F841 24 | 25 | try: 26 | bf = BackupFile.objects.get(backup=bctc.commit.backup, type=ftype) 27 | except BackupFile.DoesNotExist: 28 | bf = BackupFile.objects.create(backup=bctc.commit.backup, type=ftype) 29 | 30 | bctc.backup = bctc.commit.backup 31 | bctc.file = bf 32 | 33 | bctc.save() 34 | 35 | 36 | def migrate_from_bctc_to_bo(apps, schema_editor): 37 | print('') 38 | print('!!!!WARNING!!!') 39 | print( 40 | 'This migration operation may fail and leave your netbox_config_backup database in an onconsistent state' 41 | ) 42 | 43 | BackupCommitTreeChange = apps.get_model( 44 | 'netbox_config_backup', 'BackupCommitTreeChange' 45 | ) 46 | bctcs = BackupCommitTreeChange.objects.all() 47 | for bctc in bctcs: 48 | bctc.refresh_from_db() 49 | bctc.commit.refresh_from_db() 50 | 51 | bctc.commit.backup = bctc.backup 52 | bctc.commit.save() 53 | 54 | if bctc.new: 55 | bctc.new.refresh_from_db() 56 | bctc.new.type = f'{bctc.backup.uuid}.{bctc.file.type}' 57 | bctc.new.save() 58 | if bctc.old: 59 | bctc.old.refresh_from_db() 60 | bctc.old.file = f'{bctc.backup.uuid}.{bctc.file.type}' 61 | bctc.old.save() 62 | 63 | 64 | class Migration(migrations.Migration): 65 | 66 | dependencies = [ 67 | ('netbox_config_backup', '0008_backupjob_scheduled_nullable'), 68 | ] 69 | 70 | operations = [ 71 | migrations.AlterField( 72 | model_name='backupobject', 73 | name='sha', 74 | field=models.CharField(max_length=64, unique=True), 75 | ), 76 | migrations.AlterUniqueTogether( 77 | name='backupobject', 78 | unique_together=set(), 79 | ), 80 | migrations.AlterField( 81 | model_name='backupcommit', 82 | name='time', 83 | field=models.DateTimeField(), 84 | ), 85 | migrations.CreateModel( 86 | name='BackupFile', 87 | fields=[ 88 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 89 | ('type', models.CharField(max_length=10)), 90 | ], 91 | options={ 92 | 'abstract': False, 93 | }, 94 | ), 95 | migrations.AddField( 96 | model_name='backupfile', 97 | name='backup', 98 | field=models.ForeignKey( 99 | on_delete=django.db.models.deletion.CASCADE, 100 | related_name='files', 101 | to='netbox_config_backup.backup', 102 | ), 103 | ), 104 | migrations.AlterUniqueTogether( 105 | name='backupfile', 106 | unique_together={('backup', 'type')}, 107 | ), 108 | migrations.AddField( 109 | model_name='backupcommittreechange', 110 | name='backup', 111 | field=models.ForeignKey( 112 | null=True, 113 | on_delete=django.db.models.deletion.CASCADE, 114 | related_name='changes', 115 | to='netbox_config_backup.backup', 116 | ), 117 | ), 118 | migrations.AddField( 119 | model_name='backupcommittreechange', 120 | name='file', 121 | field=models.ForeignKey( 122 | null=True, 123 | on_delete=django.db.models.deletion.CASCADE, 124 | related_name='changes', 125 | to='netbox_config_backup.backupfile', 126 | ), 127 | ), 128 | migrations.AlterField( 129 | model_name='backupcommittreechange', 130 | name='new', 131 | field=models.ForeignKey( 132 | null=True, 133 | on_delete=django.db.models.deletion.PROTECT, 134 | related_name='changes', 135 | to='netbox_config_backup.backupobject', 136 | ), 137 | ), 138 | migrations.RunPython(migrate_from_bo_to_bctc, migrate_from_bctc_to_bo), 139 | migrations.AlterField( 140 | model_name='backupcommittreechange', 141 | name='backup', 142 | field=models.ForeignKey( 143 | on_delete=django.db.models.deletion.CASCADE, 144 | related_name='changes', 145 | to='netbox_config_backup.backup', 146 | ), 147 | ), 148 | migrations.AlterField( 149 | model_name='backupcommittreechange', 150 | name='file', 151 | field=models.ForeignKey( 152 | on_delete=django.db.models.deletion.CASCADE, 153 | related_name='changes', 154 | to='netbox_config_backup.backupfile', 155 | ), 156 | ), 157 | migrations.RemoveField( 158 | model_name='backupobject', 159 | name='file', 160 | ), 161 | migrations.RemoveConstraint( 162 | model_name='backupcommit', 163 | name='backup_and_sha_not_null', 164 | ), 165 | migrations.RemoveField( 166 | model_name='backupcommit', 167 | name='backup', 168 | ), 169 | ] 170 | -------------------------------------------------------------------------------- /netbox_config_backup/models/backups.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import uuid as uuid 4 | 5 | from django.db import models 6 | from django.urls import reverse 7 | 8 | from dcim.models import Device 9 | from netbox.models import PrimaryModel 10 | 11 | from netbox_config_backup.choices import StatusChoices 12 | from netbox_config_backup.helpers import get_repository_dir 13 | 14 | from ..querysets import BackupQuerySet 15 | from ..utils import Differ 16 | 17 | 18 | logger = logging.getLogger("netbox_config_backup") 19 | 20 | 21 | class Backup(PrimaryModel): 22 | name = models.CharField(max_length=255, unique=True) 23 | uuid = models.UUIDField(default=uuid.uuid4, editable=False) 24 | status = models.CharField( 25 | max_length=50, choices=StatusChoices, default=StatusChoices.STATUS_ACTIVE 26 | ) 27 | device = models.ForeignKey( 28 | to=Device, 29 | on_delete=models.SET_NULL, 30 | related_name='backups', 31 | blank=True, 32 | null=True, 33 | ) 34 | ip = models.ForeignKey( 35 | to='ipam.IPAddress', on_delete=models.SET_NULL, blank=True, null=True 36 | ) 37 | config_status = models.BooleanField(blank=True, null=True) 38 | 39 | objects = BackupQuerySet.as_manager() 40 | 41 | class Meta: 42 | ordering = ('name',) 43 | 44 | def get_absolute_url(self): 45 | return reverse('plugins:netbox_config_backup:backup', args=[self.pk]) 46 | 47 | def __str__(self): 48 | return self.name 49 | 50 | def get_config(self, index='HEAD'): 51 | from netbox_config_backup.git import repository 52 | 53 | running = repository.read(f'{self.uuid}.running') 54 | startup = repository.read(f'{self.uuid}.startup') 55 | 56 | return { 57 | 'running': running if running is not None else '', 58 | 'startup': startup if startup is not None else '', 59 | } 60 | 61 | def set_config(self, configs, files=('running', 'startup'), pk=None): 62 | from netbox_config_backup.models.repository import ( 63 | BackupCommit, 64 | BackupObject, 65 | BackupFile, 66 | BackupCommitTreeChange, 67 | ) 68 | from netbox_config_backup.git import repository 69 | 70 | LOCAL_TIMEZONE = ( 71 | datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo 72 | ) 73 | 74 | stored_configs = self.get_config() 75 | changes = False 76 | for file in files: 77 | # logger.debug(f'[{pk}] Getting existing config for {file}') 78 | stored = ( 79 | stored_configs.get(file) if stored_configs.get(file) is not None else '' 80 | ) 81 | # logger.debug(f'[{pk}] Getting new config') 82 | current = configs.get(file) if configs.get(file) is not None else '' 83 | 84 | # logger.debug(f'[{pk}] Starting diff for {file}') 85 | if Differ(stored, current).is_diff(): 86 | changes = True 87 | output = repository.write(f'{self.uuid}.{file}', current) # noqa: F841 88 | # logger.debug(f'[{pk}] Finished diff for {file}') 89 | if not changes: 90 | return None 91 | 92 | # logger.debug(f'[{pk}] Commiting files') 93 | commit = repository.commit( 94 | f'Backup of {self.device.name} for backup {self.name}' 95 | ) 96 | 97 | # logger.debug(f'[{pk}] Getting repository log') 98 | log = repository.log(index=commit, depth=1)[0] 99 | 100 | # logger.debug(f'[{pk}] Saving commit to DB') 101 | bc = BackupCommit.objects.filter(sha=commit) 102 | time = log.get('time', datetime.datetime.now()).replace(tzinfo=LOCAL_TIMEZONE) 103 | if bc.count() > 0: 104 | # logger.debug(f'[{pk}] Error committing') 105 | raise Exception('Commit already exists for this backup and sha value') 106 | else: 107 | # logger.debug(f'[{pk}] Saving commit') 108 | bc = BackupCommit(sha=commit, time=time) 109 | logger.debug(f'{self}: {commit}:{bc.time}') 110 | bc.save() 111 | 112 | for change in log.get('changes', []): 113 | # logger.debug(f'[{pk}] Adding backup tree changes') 114 | backupfile = None 115 | change_data = {} 116 | for key in ['old', 'new']: 117 | sha = change.get(key, {}).get('sha', None) 118 | file = change.get(key, {}).get('path', None) 119 | if sha is not None and file is not None: 120 | uuid, type = file.split('.') # noqa: F841 121 | try: 122 | object = BackupObject.objects.get(sha=sha) 123 | except BackupObject.DoesNotExist: 124 | object = BackupObject.objects.create(sha=sha) 125 | try: 126 | backupfile = BackupFile.objects.get(backup=self, type=type) 127 | except BackupFile.DoesNotExist: 128 | backupfile = BackupFile.objects.create(backup=self, type=type) 129 | change_data[key] = object 130 | 131 | bctc = BackupCommitTreeChange.objects.filter( 132 | backup=self, 133 | file=backupfile, 134 | commit=bc, 135 | type=change.get('type', None), 136 | old=change_data.get('old', None), 137 | new=change_data.get('new', None), 138 | ) 139 | if bctc.count() > 0: 140 | bctc = bctc.first() 141 | elif bctc.count() == 0: 142 | bctc = BackupCommitTreeChange( 143 | backup=self, 144 | file=backupfile, 145 | commit=bc, 146 | type=change.get('type', None), 147 | old=change_data.get('old', None), 148 | new=change_data.get('new', None), 149 | ) 150 | bctc.save() 151 | # logger.debug(f'[{pk}] Tree saved') 152 | # logger.debug(f'[{pk}] Get config saved') 153 | return commit 154 | 155 | @classmethod 156 | def get_repository_dir(cls): 157 | return get_repository_dir() 158 | -------------------------------------------------------------------------------- /netbox_config_backup/backup/processing.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import traceback 4 | from datetime import timedelta 5 | 6 | import uuid 7 | from django.utils import timezone 8 | 9 | from core.choices import JobStatusChoices 10 | from netbox import settings 11 | from netbox.api.exceptions import ServiceUnavailable 12 | from netbox_config_backup.models import BackupJob, Backup 13 | from netbox_config_backup.utils.db import close_db 14 | from netbox_config_backup.utils.configs import check_config_save_status 15 | from netbox_config_backup.utils.napalm import napalm_init 16 | from netbox_config_backup.utils.rq import can_backup 17 | 18 | logger = logging.getLogger("netbox_config_backup") 19 | 20 | 21 | def remove_stale_backupjobs(job: BackupJob): 22 | pass 23 | 24 | 25 | def run_backup(job_id): 26 | close_db() 27 | logger.info(f'Starting backup for job {job_id}') 28 | try: 29 | logger.debug(f'Trying to load job {job_id}') 30 | job = BackupJob.objects.get(pk=job_id) 31 | except Exception as e: 32 | logger.error(f'Unable to load job {job_id}: {e}') 33 | logger.debug(f'\t{traceback.format_exc()}') 34 | raise e 35 | 36 | try: 37 | logger.debug(f'Getting backup for {job}') 38 | backup = Backup.objects.get(pk=job.backup.pk) 39 | backup.refresh_from_db() 40 | pid = os.getpid() 41 | 42 | logger.debug(f'Setting status and saving for {job}') 43 | 44 | job.status = JobStatusChoices.STATUS_PENDING 45 | job.pid = pid 46 | job.save() 47 | 48 | logger.debug(f'Checking backup status for {job}') 49 | if not can_backup(backup): 50 | logger.info(f'Cannot backup {backup}') 51 | job.status = JobStatusChoices.STATUS_FAILED 52 | if not job.data: 53 | job.data = {} 54 | job.data.update({'error': f'Cannot backup {backup}'}) 55 | job.full_clean() 56 | job.save() 57 | logger.warning(f'Cannot backup {backup}') 58 | return 59 | 60 | commit = None 61 | try: 62 | ip = backup.ip if backup.ip is not None else backup.device.primary_ip 63 | except Exception as e: 64 | logger.debug(f'{e}: {backup}') 65 | raise e 66 | 67 | if ip: 68 | logger.debug( 69 | f'Trying to connect to device {backup.device} with ip {ip} for {job}' 70 | ) 71 | try: 72 | d = napalm_init(backup.device, ip) 73 | except (TimeoutError, ServiceUnavailable): 74 | job.status = JobStatusChoices.STATUS_FAILED 75 | job.data = { 76 | 'error': f'Timeout Connecting to {backup.device} with ip {ip}' 77 | } 78 | logger.debug(f'Timeout Connecting to {backup.device} with ip {ip}') 79 | job.save() 80 | return 81 | logger.debug(f'Connected to {backup.device} with ip {ip} for {job}') 82 | job.status = JobStatusChoices.STATUS_RUNNING 83 | job.started = timezone.now() 84 | job.save() 85 | try: 86 | logger.debug(f'Checking config save status for {backup}') 87 | config_save_status = check_config_save_status(d) 88 | status = config_save_status.get('status') 89 | running = backup.files.filter(type='running').first() 90 | startup = backup.files.filter(type='startup').first() 91 | if running.last_change != config_save_status.get('running', None): 92 | running.last_change = config_save_status.get('running', None) 93 | running.clean() 94 | running.save() 95 | if startup.last_change != config_save_status.get('startup', None): 96 | startup.last_change = config_save_status.get('startup', None) 97 | startup.clean() 98 | startup.save() 99 | 100 | if status is not None: 101 | if status and not backup.config_status: 102 | backup.config_status = status 103 | backup.save() 104 | elif not status and backup.config_status: 105 | backup.config_status = status 106 | backup.save() 107 | elif not status and backup.config_status is None: 108 | backup.config_status = status 109 | backup.save() 110 | elif status and backup.config_status is None: 111 | backup.config_status = status 112 | backup.save() 113 | except Exception as e: 114 | 115 | logger.error(f'{backup}: had error setting backup status: {e}') 116 | 117 | logger.debug(f'Getting config for {backup}') 118 | configs = d.get_config() 119 | logger.debug(f'Committing config for {backup}') 120 | commit = backup.set_config(configs) 121 | logger.debug( 122 | f'Committed config for {backup} with {commit}; closing connection for {backup}' 123 | ) 124 | d.close() 125 | 126 | logger.debug(f'Scheduling next backup for {backup}') 127 | frequency = timedelta( 128 | seconds=settings.PLUGINS_CONFIG.get('netbox_config_backup', {}).get( 129 | 'frequency', 3600 130 | ) 131 | ) 132 | new = BackupJob( 133 | runner=None, 134 | backup=job.backup, 135 | status=JobStatusChoices.STATUS_SCHEDULED, 136 | scheduled=timezone.now() + frequency, 137 | job_id=uuid.uuid4(), 138 | data={}, 139 | ) 140 | new.full_clean() 141 | new.save() 142 | 143 | logger.info(f'{backup}: Backup complete') 144 | job.status = JobStatusChoices.STATUS_COMPLETED 145 | job.completed = timezone.now() 146 | job.save() 147 | remove_stale_backupjobs(job=job) 148 | else: 149 | logger.debug(f'{backup}: No IP set') 150 | job.status = JobStatusChoices.STATUS_FAILED 151 | if not job.data: 152 | job.data = {} 153 | job.data.update({'error': f'{backup}: No IP set'}) 154 | job.full_clean() 155 | job.save() 156 | logger.debug(f'{backup}: No IP set') 157 | except Exception as e: 158 | logger.error(f'Exception in {job_id}: {e}') 159 | logger.info(f'\t{traceback.format_exc()}') 160 | if job: 161 | job.status = JobStatusChoices.STATUS_ERRORED 162 | if not job.data: 163 | job.data = {} 164 | job.data.update({'error': f'{e}'}) 165 | job.full_clean() 166 | job.save() 167 | -------------------------------------------------------------------------------- /netbox_config_backup/git.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from time import sleep 4 | 5 | from deepdiff import DeepDiff 6 | from dulwich import repo, porcelain, object_store 7 | from pydriller import Git 8 | 9 | from netbox import settings 10 | 11 | __all__ = 'repository' 12 | 13 | from netbox_config_backup.helpers import get_repository_dir 14 | 15 | 16 | def encode(value, encoding): 17 | if value is not None: 18 | return value.encode(encoding) 19 | return None 20 | 21 | 22 | def decode(value, encoding): 23 | if value is not None: 24 | return value.decode(encoding) 25 | return None 26 | 27 | 28 | class GitBackup: 29 | repository = None 30 | driller = None 31 | location = None 32 | 33 | def __init__(self): 34 | self.location = get_repository_dir() 35 | 36 | if os.path.exists(self.location): 37 | self.repository = repo.Repo(self.location) 38 | 39 | if self.repository is None: 40 | self.repository = repo.Repo.init(path=self.location, mkdir=True) 41 | 42 | if self.repository is not None: 43 | try: 44 | self.driller = Git(self.location) 45 | except OSError: 46 | pass 47 | 48 | def write(self, file, data): 49 | path = f'{self.location}{os.path.sep}{file}' 50 | with open(path, 'w') as f: 51 | f.write(data) 52 | f.close() 53 | 54 | failures = 0 55 | while failures < 10: 56 | try: 57 | porcelain.add(self.repository, path) 58 | return 59 | except FileExistsError: 60 | sleep(1) 61 | failures = failures + 1 62 | if failures >= 10: 63 | raise Exception('Unable to acquire lock on repository in a timely manner') 64 | 65 | def commit(self, message): 66 | committer = settings.PLUGINS_CONFIG.get('netbox_config_backup', {}).get('committer', None) 67 | author = settings.PLUGINS_CONFIG.get('netbox_config_backup', {}).get('author', None) 68 | 69 | if author is not None: 70 | author = author.encode('ascii') 71 | if committer is not None: 72 | committer = committer.encode('ascii') 73 | 74 | failures = 0 75 | while failures < 10: 76 | try: 77 | commit = porcelain.commit(self.repository, message, committer=committer, author=author) 78 | return commit.decode('ascii') 79 | except repo.InvalidUserIdentity: 80 | committer = 'Your NetBox is misconfigured '.encode('ascii') 81 | author = 'Your NetBox is misconfigured '.encode('ascii') 82 | except FileExistsError: 83 | sleep(1) 84 | failures = failures + 1 85 | if failures >= 10: 86 | raise Exception('Unable to acquire lock on repository in a timely manner') 87 | 88 | def read(self, file, index=None): 89 | path = file.encode('ascii') 90 | if index is None: 91 | index = 'HEAD' 92 | 93 | try: 94 | tree = self.repository[index.encode('ascii')].tree 95 | _, sha = object_store.tree_lookup_path(self.repository.__getitem__, tree, path) 96 | data = self.repository[sha].data.decode('ascii') 97 | return data 98 | except KeyError: 99 | return None 100 | 101 | def diff(self, file, a=None, b=None): 102 | _ = file.encode('ascii') 103 | commits = [a, b] 104 | data = [] 105 | for commit in commits: 106 | if commit is None: 107 | data.append(None) 108 | else: 109 | data.append(self.read(file, commit)) 110 | 111 | diff = DeepDiff(data[0], data[1]).diff() 112 | return diff 113 | 114 | def log(self, file=None, paths=[], index=None, depth=None): 115 | 116 | if file is not None: 117 | path = file.encode('ascii') 118 | paths = [path] 119 | else: 120 | path = None 121 | for idx in range(0, len(paths)): 122 | path = paths[idx] 123 | paths[idx] = path.encode('ascii') 124 | 125 | if index is not None: 126 | index = index.encode('ascii') 127 | 128 | walker = self.repository.get_walker(include=index, paths=paths, max_entries=depth) 129 | entries = [entry for entry in walker] 130 | 131 | indexes = [] 132 | for entry in entries: 133 | encoding = entry.commit.encoding.decode('ascii') if entry.commit.encoding else 'ascii' 134 | output = { 135 | 'author': decode(entry.commit.author, encoding), 136 | 'committer': decode(entry.commit.committer, encoding), 137 | 'message': decode(entry.commit.message, encoding), 138 | 'parents': [decode(parent, encoding) for parent in entry.commit.parents], 139 | 'sha': str(entry.commit.sha().hexdigest()), 140 | 'time': datetime.fromtimestamp(entry.commit.commit_time), 141 | 'tree': decode(entry.commit.tree, encoding), 142 | } 143 | changes = [] 144 | for change in entry.changes(): 145 | old = { 146 | 'sha': decode(getattr(change.old, 'sha', None), encoding), 147 | 'path': decode(getattr(change.old, 'path', None), encoding), 148 | } 149 | if path is not None and (old.get('path') == path or change.get('new') == path): 150 | output.update( 151 | { 152 | 'change': { 153 | 'type': change.type, 154 | 'old': { 155 | 'path': old.get('path'), 156 | 'sha': old.get('sha'), 157 | }, 158 | 'new': { 159 | 'path': decode(getattr(change.new, 'path', None), encoding), 160 | 'sha': decode(getattr(change.new, 'sha', None), encoding), 161 | }, 162 | } 163 | } 164 | ) 165 | changes.append( 166 | { 167 | 'type': change.type, 168 | 'old': { 169 | 'path': decode(getattr(change.old, 'path', None), encoding), 170 | 'sha': decode(getattr(change.old, 'sha', None), encoding), 171 | }, 172 | 'new': { 173 | 'path': decode(getattr(change.new, 'path', None), encoding), 174 | 'sha': decode(getattr(change.new, 'sha', None), encoding), 175 | }, 176 | } 177 | ) 178 | 179 | output.update({'changes': changes}) 180 | indexes.append(output) 181 | 182 | return indexes 183 | 184 | 185 | repository = GitBackup() 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /netbox_config_backup/jobs/backup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import signal 3 | import time 4 | import uuid 5 | import traceback 6 | import multiprocessing 7 | from datetime import timedelta 8 | 9 | from django.utils import timezone 10 | 11 | from core.choices import JobStatusChoices, JobIntervalChoices 12 | from netbox import settings 13 | from netbox.jobs import JobRunner, system_job 14 | from netbox_config_backup.backup.processing import run_backup 15 | from netbox_config_backup.choices import StatusChoices 16 | from netbox_config_backup.exceptions import JobExit 17 | from netbox_config_backup.models import Backup, BackupJob 18 | from netbox_config_backup.utils.db import close_db 19 | from netbox_config_backup.utils.rq import can_backup 20 | 21 | __all__ = ('BackupRunner',) 22 | 23 | 24 | logger = logging.getLogger("netbox_config_backup") 25 | 26 | job_frequency = settings.PLUGINS_CONFIG.get('netbox_config_backup', {}).get('frequency', 3600) 27 | 28 | 29 | @system_job(interval=JobIntervalChoices.INTERVAL_MINUTELY * 15) 30 | class BackupRunner(JobRunner): 31 | processes = {} 32 | 33 | class Meta: 34 | name = 'Backup Job Runner' 35 | 36 | @classmethod 37 | def fail_job(cls, job: BackupJob, status: str, error: str = ''): 38 | job.status = status 39 | if not job.data: 40 | job.data = {} 41 | job.data.update({'error': 'Process terminated'}) 42 | job.save() 43 | job.refresh_from_db() 44 | 45 | @classmethod 46 | def clean_stale_jobs(cls): 47 | logger.info('Starting stale job cleanup') 48 | results = {'stale': 0, 'scheduled': 0} 49 | 50 | jobs = ( 51 | BackupJob.objects.order_by('created') 52 | .filter( 53 | status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES, 54 | ) 55 | .prefetch_related('backup', 'backup__device') 56 | ) 57 | 58 | stale = jobs.filter(scheduled__lt=timezone.now() - timedelta(minutes=30)) 59 | for job in stale: 60 | results['stale'] += 1 61 | cls.fail_job(job, JobStatusChoices.STATUS_FAILED, 'Job hung') 62 | logger.warning(f'Job {job.backup} appears stuck, deleting') 63 | 64 | scheduled = jobs.filter(status=JobStatusChoices.STATUS_SCHEDULED) 65 | for job in scheduled: 66 | if job != scheduled.filter(backup=job.backup).last(): 67 | results['scheduled'] += 1 68 | cls.fail_job(job, JobStatusChoices.STATUS_ERRORED, 'Job missed') 69 | logger.warning(f'Job {job.backup} appears to have been missed, deleting') 70 | 71 | return results 72 | 73 | @classmethod 74 | def schedule_jobs(cls, runner, backup=None, device=None): 75 | scheduled_status = 0 76 | if backup: 77 | logging.debug(f'Scheduling backup for backup: {backup}') 78 | backups = Backup.objects.filter(pk=backup.pk, status=StatusChoices.STATUS_ACTIVE, device__isnull=False) 79 | elif device: 80 | logging.debug(f'Scheduling backup for device: {device}') 81 | backups = Backup.objects.filter(device=device, status=StatusChoices.STATUS_ACTIVE, device__isnull=False) 82 | else: 83 | logging.debug('Scheduling all backups for') 84 | backups = Backup.objects.filter(status=StatusChoices.STATUS_ACTIVE, device__isnull=False) 85 | 86 | frequency = timedelta(seconds=job_frequency) 87 | 88 | for backup in backups: 89 | if can_backup(backup): 90 | logger.debug(f'Checking jobs for backup for {backup.device}' f'+') 91 | jobs = BackupJob.objects.filter(backup=backup) 92 | if jobs.filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES).count() == 0: 93 | logger.debug(f'Queuing device {backup.device} for backup') 94 | if jobs.last() is not None and jobs.last().scheduled + frequency < timezone.now(): 95 | scheduled = timezone.now() 96 | elif jobs.last() is not None: 97 | scheduled = jobs.last().scheduled + frequency 98 | else: 99 | scheduled = timezone.now() 100 | job = BackupJob( 101 | runner=None, 102 | backup=backup, 103 | status=JobStatusChoices.STATUS_SCHEDULED, 104 | scheduled=scheduled, 105 | job_id=uuid.uuid4(), 106 | data={}, 107 | ) 108 | job.full_clean() 109 | job.save() 110 | scheduled_status += 1 111 | else: 112 | jobs = BackupJob.objects.filter(backup=backup, status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES) 113 | for job in jobs: 114 | cls.fail_job(job, JobStatusChoices.STATUS_FAILED, 'Cannot queue job') 115 | 116 | return scheduled_status 117 | 118 | def run_processes(self): 119 | if not self.running: 120 | self.handle_main_exit(signal.SIGTERM, None) 121 | jobs = BackupJob.objects.filter( 122 | runner=None, 123 | status=JobStatusChoices.STATUS_SCHEDULED, 124 | scheduled__lte=timezone.now(), 125 | ) 126 | for job in jobs: 127 | job.runner = self.job 128 | job.status = JobStatusChoices.STATUS_PENDING 129 | 130 | close_db() 131 | BackupJob.objects.bulk_update(jobs, ['runner', 'status']) 132 | 133 | self.job.data.update({'status': {'pending': jobs.count()}}) 134 | self.job.clean() 135 | self.job.save() 136 | 137 | for job in jobs: 138 | try: 139 | process = self.fork_process(job) 140 | process.join(1) 141 | job.pid = process.pid 142 | job.status = JobStatusChoices.STATUS_RUNNING 143 | except Exception as e: 144 | try: 145 | import sentry_sdk 146 | 147 | sentry_sdk.capture_exception(e) 148 | except ModuleNotFoundError: 149 | pass 150 | job.status = JobStatusChoices.STATUS_FAILED 151 | job.data['error'] = str(e) 152 | 153 | close_db() 154 | BackupJob.objects.bulk_update(jobs, ['pid', 'status', 'data']) 155 | 156 | def run_backup(self, job_id): 157 | self.job_id = job_id 158 | if not self.running: 159 | self.handle_main_exit(signal.SIGTERM, None) 160 | signal.signal(signal.SIGTERM, self.handle_child_exit) 161 | signal.signal(signal.SIGINT, self.handle_child_exit) 162 | run_backup(job_id) 163 | 164 | def fork_process(self, job): 165 | if not self.running: 166 | return 167 | close_db() 168 | process = self.ctx.Process( 169 | target=run_backup, 170 | args=(job.pk,), 171 | ) 172 | data = {job.backup.pk: {'process': process, 'backup': job.backup.pk, 'job': job.pk}} 173 | self.processes.update(data) 174 | process.start() 175 | logger.debug(f'Forking process {process.pid} for {job.backup} backup') 176 | return process 177 | 178 | def handle_stuck_jobs(self): 179 | jobs = BackupJob.objects.filter( 180 | status__in=['running', 'pending'], 181 | started__gte=timezone.now() + timedelta(seconds=job_frequency), 182 | ) 183 | for job in jobs: 184 | if self.processes.get(job.backup.pk): 185 | process = self.processes.get(job.backup.pk) 186 | if process.is_alive(): 187 | process.terminate() 188 | del self.processes[job.backup.pk] 189 | job.status = JobStatusChoices.STATUS_ERRORED 190 | if not job.data: 191 | job.data = {} 192 | job.data.update({'error': 'Process terminated'}) 193 | BackupJob.objects.bulk_update(jobs, ['status', 'data']) 194 | 195 | def handle_processes(self): 196 | for pk in list(self.processes.keys()): 197 | terminated = self.job.data.get('status', {}).get('terminated', 0) 198 | completed = self.job.data.get('status', {}).get('completed', 0) 199 | 200 | process = self.processes.get(pk, {}).get('process') 201 | job_pk = self.processes.get(pk, {}).get('job') 202 | backup = self.processes.get(pk, {}).get('backup') 203 | if not process.is_alive(): 204 | logger.debug(f'Terminating process {process.pid} with job pk of {pk} for {backup}') 205 | process.terminate() 206 | del self.processes[pk] 207 | job = BackupJob.objects.filter(pk=job_pk).first() 208 | if job and job.status not in [ 209 | JobStatusChoices.STATUS_COMPLETED, 210 | JobStatusChoices.STATUS_FAILED, 211 | JobStatusChoices.STATUS_ERRORED, 212 | ]: 213 | self.job.data.update({'status': {'terminated': terminated}}) 214 | job.status = JobStatusChoices.STATUS_ERRORED 215 | if not job.data: 216 | job.data = {} 217 | job.data.update({'error': 'Process terminated for unknown reason'}) 218 | else: 219 | self.job.data.update({'status': {'completed': completed}}) 220 | job.save() 221 | 222 | self.job.save() 223 | self.job.refresh_from_db() 224 | 225 | def handle_main_exit(self, signum, frame): 226 | logger.info(f'Exiting Main: {signum}') 227 | self.handle_exit('Parent', signum) 228 | 229 | def handle_child_exit(self, signum, frame): 230 | logger.info(f'Exiting Child: {signum}') 231 | self.handle_exit('Child', signum) 232 | raise JobExit('Terminating') 233 | 234 | def handle_exit(self, process, signum): 235 | code = f'UNKNOWN: {signum}' 236 | match signum: 237 | case signal.SIGKILL: 238 | code = 'SIGKILL' 239 | case signal.SIGTERM: 240 | code = 'SIGTERM' 241 | case signal.SIGINT: 242 | code = 'SIGINT' 243 | logger.info(f'Exiting {process}: {code}') 244 | self.job.data.update({'status': {'terminated': 1}}) 245 | if process != 'Child': 246 | self.running = False 247 | for pk in list(self.processes.keys()): 248 | process = self.processes.get(pk, {}).get('process') 249 | job_pk = self.processes.get(pk, {}).get('job') 250 | job = BackupJob.objects.filter(pk=job_pk).first() 251 | job.status = JobStatusChoices.STATUS_ERRORED 252 | job.data.update({'error': f'{process}: {code}'}) 253 | job.clean() 254 | job.save() 255 | process.terminate() 256 | try: 257 | process.join() 258 | except AssertionError: 259 | pass 260 | 261 | def run(self, backup=None, device=None, *args, **kwargs): 262 | 263 | self.ctx = multiprocessing.get_context() 264 | self.running = True 265 | 266 | signal.signal(signal.SIGTERM, self.handle_main_exit) 267 | signal.signal(signal.SIGINT, self.handle_main_exit) 268 | 269 | if not self.job.data: 270 | self.job.data = {} 271 | self.job.save() 272 | 273 | try: 274 | status = self.clean_stale_jobs() 275 | self.job.data.update({'status': status}) 276 | 277 | status = self.schedule_jobs(runner=self.job, backup=backup, device=device) 278 | self.job.data.update({'status': {'scheduled': status}}) 279 | 280 | self.job.save() 281 | self.run_processes() 282 | 283 | self.handle_processes() 284 | self.handle_stuck_jobs() 285 | 286 | while self.running: 287 | self.handle_processes() 288 | self.handle_stuck_jobs() 289 | if len(self.processes) == 0: 290 | self.running = False 291 | time.sleep(1) 292 | except JobExit as e: 293 | raise e 294 | except Exception as e: 295 | try: 296 | import sentry_sdk 297 | 298 | sentry_sdk.capture_exception(e) 299 | except ModuleNotFoundError: 300 | pass 301 | logger.warning(f'{traceback.format_exc()}') 302 | logger.error(f'{e}') 303 | raise e 304 | -------------------------------------------------------------------------------- /netbox_config_backup/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import uuid 4 | from django.contrib import messages 5 | from django.http import Http404 6 | from django.shortcuts import get_object_or_404, render, redirect 7 | from django.urls import reverse, NoReverseMatch 8 | from django.utils import timezone 9 | from django.utils.translation import gettext as _ 10 | from jinja2 import TemplateError 11 | 12 | from core.choices import JobStatusChoices 13 | from dcim.models import Device 14 | from netbox.object_actions import AddObject, BulkEdit, BulkDelete 15 | from netbox.views.generic import ( 16 | ObjectDeleteView, 17 | ObjectEditView, 18 | ObjectView, 19 | ObjectListView, 20 | ObjectChildrenView, 21 | BulkEditView, 22 | BulkDeleteView, 23 | ) 24 | from netbox.views.generic.base import BaseMultiObjectView 25 | from netbox_config_backup.backup.processing import run_backup 26 | from netbox_config_backup.filtersets import ( 27 | BackupFilterSet, 28 | BackupsFilterSet, 29 | BackupJobFilterSet, 30 | ) 31 | 32 | from netbox_config_backup.forms import ( 33 | BackupForm, 34 | BackupFilterSetForm, 35 | BackupBulkEditForm, 36 | BackupJobFilterSetForm, 37 | ) 38 | from netbox_config_backup.git import GitBackup 39 | from netbox_config_backup.models import Backup, BackupJob, BackupCommitTreeChange 40 | from netbox_config_backup.object_actions import * 41 | from netbox_config_backup.tables import BackupTable, BackupsTable, BackupJobTable 42 | from netbox_config_backup.utils import Differ 43 | from utilities.permissions import get_permission_for_model 44 | from utilities.views import register_model_view, ViewTab 45 | 46 | logger = logging.getLogger("netbox_config_backup") 47 | 48 | 49 | @register_model_view(BackupJob, name='list', path='', detail=False) 50 | class BackupJobListView(ObjectListView): 51 | queryset = BackupJob.objects.all() 52 | 53 | filterset = BackupJobFilterSet 54 | filterset_form = BackupJobFilterSetForm 55 | table = BackupJobTable 56 | actions = () 57 | 58 | 59 | @register_model_view(Backup, name='list', path='', detail=False) 60 | class BackupListView(ObjectListView): 61 | queryset = Backup.objects.filter(device__isnull=False).default_annotate() 62 | 63 | filterset = BackupFilterSet 64 | filterset_form = BackupFilterSetForm 65 | table = BackupTable 66 | actions = (AddObject, BulkEdit, BulkDelete) 67 | 68 | 69 | @register_model_view(Backup, name='unassigned_list', path='unassigned', detail=False) 70 | class UnassignedBackupListView(ObjectListView): 71 | queryset = Backup.objects.filter(device__isnull=True).default_annotate() 72 | 73 | filterset = BackupFilterSet 74 | filterset_form = BackupFilterSetForm 75 | table = BackupTable 76 | actions = (AddObject, BulkEdit, BulkDelete) 77 | 78 | 79 | @register_model_view(Backup) 80 | class BackupView(ObjectView): 81 | queryset = Backup.objects.all().default_annotate() 82 | template_name = 'netbox_config_backup/backup.html' 83 | actions = ObjectView.actions + (RunBackupsNowAction,) 84 | 85 | def get_extra_context(self, request, instance): 86 | 87 | jobs = BackupJob.objects.filter(backup=instance).order_by() 88 | is_running = True if jobs.filter(status=JobStatusChoices.STATUS_RUNNING).count() > 0 else False 89 | is_pending = True if jobs.filter(status=JobStatusChoices.STATUS_PENDING).count() > 0 else False 90 | 91 | job_status = None 92 | if is_pending: 93 | job_status = 'Pending' 94 | if is_running: 95 | job_status = 'Running' 96 | 97 | status = { 98 | 'status': job_status, 99 | 'next_attempt': instance.next_attempt, 100 | 'last_job': instance.jobs.filter(completed__isnull=False).last(), 101 | 'last_success': instance.last_backup, 102 | 'last_change': instance.last_change, 103 | } 104 | 105 | return { 106 | 'status': status, 107 | } 108 | 109 | 110 | @register_model_view(Backup, name='backups') 111 | class BackupBackupsView(ObjectChildrenView): 112 | queryset = Backup.objects.all().default_annotate() 113 | 114 | template_name = 'netbox_config_backup/backups.html' 115 | child_model = BackupCommitTreeChange 116 | table = BackupsTable 117 | filterset = BackupsFilterSet 118 | actions = ObjectChildrenView.actions + (ViewConfigAction, DiffAction, BulkDiffAction, BulkConfigAction) 119 | tab = ViewTab( 120 | label='View Backups', 121 | badge=lambda obj: BackupCommitTreeChange.objects.filter(backup=obj, file__isnull=False).count(), 122 | ) 123 | 124 | def get_children(self, request, parent): 125 | return self.child_model.objects.filter(backup=parent, file__isnull=False) 126 | 127 | def get_extra_context(self, request, instance): 128 | return { 129 | 'backup': instance, 130 | 'running': bool(request.GET.get('type') == 'running'), 131 | 'startup': bool(request.GET.get('type') == 'startup'), 132 | } 133 | 134 | 135 | @register_model_view(Backup, 'add', detail=False) 136 | @register_model_view(Backup, 'edit') 137 | class BackupEditView(ObjectEditView): 138 | queryset = Backup.objects.all() 139 | form = BackupForm 140 | 141 | 142 | @register_model_view(Backup, 'delete') 143 | class BackupDeleteView(ObjectDeleteView): 144 | queryset = Backup.objects.all() 145 | 146 | def get_return_url(self, request, obj=None): 147 | 148 | # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's 149 | # considered safe. 150 | return_url = request.GET.get('return_url') or request.POST.get('return_url') 151 | if return_url and return_url.startswith('/'): 152 | return return_url 153 | 154 | # Next, check if the object being modified (if any) has an absolute URL. 155 | if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): 156 | return obj.get_absolute_url() 157 | 158 | # Fall back to the default URL (if specified) for the view. 159 | if self.default_return_url is not None: 160 | return reverse(self.default_return_url) 161 | 162 | # Attempt to dynamically resolve the list view for the object 163 | if hasattr(self, 'queryset'): 164 | model_opts = self.queryset.model._meta 165 | try: 166 | return reverse(f'plugins:{model_opts.app_label}:{model_opts.model_name}_list') 167 | except NoReverseMatch: 168 | pass 169 | 170 | # If all else fails, return home. Ideally this should never happen. 171 | return reverse('home') 172 | 173 | 174 | @register_model_view(Backup, 'run') 175 | class BackupNowView(BaseMultiObjectView): 176 | queryset = Backup.objects.all() 177 | template_name = None 178 | 179 | def run_backup(self, backup): 180 | job = BackupJob( 181 | runner=None, 182 | backup=backup, 183 | status=JobStatusChoices.STATUS_SCHEDULED, 184 | scheduled=timezone.now(), 185 | job_id=uuid.uuid4(), 186 | data={}, 187 | ) 188 | job.full_clean() 189 | job.save() 190 | run_backup(job.id) 191 | 192 | def get_required_permission(self): 193 | return get_permission_for_model(self.queryset.model, 'view') 194 | 195 | def get(self, request, pk): 196 | backup = get_object_or_404(Backup, pk=self.kwargs['pk']) 197 | self.run_backup(backup) 198 | return redirect(backup.get_absolute_url()) 199 | 200 | def post(self, request, pk): 201 | backup = get_object_or_404(Backup, pk=self.kwargs['pk']) 202 | self.run_backup(backup) 203 | return redirect(backup.get_absolute_url()) 204 | 205 | 206 | @register_model_view(Backup, name='bulk_edit', path='edit', detail=False) 207 | class BackupBulkEditView(BulkEditView): 208 | queryset = Backup.objects.all() 209 | form = BackupBulkEditForm 210 | filterset = BackupFilterSet 211 | table = BackupTable 212 | 213 | 214 | @register_model_view(Backup, name='bulk_delete', path='delete', detail=False) 215 | class BackupBulkDeleteView(BulkDeleteView): 216 | queryset = Backup.objects.all() 217 | filterset = BackupFilterSet 218 | table = BackupTable 219 | 220 | 221 | @register_model_view(Backup, name='config', path='config/') 222 | class ConfigView(ObjectView): 223 | queryset = Backup.objects.all() 224 | template_name = 'netbox_config_backup/config.html' 225 | tab = ViewTab( 226 | label='Configuration', 227 | badge=lambda obj: BackupCommitTreeChange.objects.filter(backup=obj, file__isnull=False).count(), 228 | permission='netbox_config_backup.view_backup', 229 | ) 230 | 231 | def get(self, request, pk, current=None): 232 | backup = get_object_or_404(Backup.objects.all(), pk=pk) 233 | if current: 234 | current = get_object_or_404(BackupCommitTreeChange.objects.all(), pk=current) 235 | else: 236 | current = BackupCommitTreeChange.objects.filter(backup=backup, file__isnull=False).last() 237 | if not current: 238 | raise Http404("No current commit available") 239 | path = f'{current.file.path}' 240 | 241 | repo = GitBackup() 242 | config = repo.read(path, current.commit.sha) 243 | 244 | previous = None 245 | if current is not None and current.old is not None: 246 | previous = backup.changes.filter(file__type=current.file.type, commit__time__lt=current.commit.time).last() 247 | 248 | return render( 249 | request, 250 | 'netbox_config_backup/config.html', 251 | context={ 252 | 'object': backup, 253 | 'tab': self.tab, 254 | 'backup_config': config, 255 | 'current': current, 256 | 'previous': previous, 257 | 'active_tab': 'config', 258 | }, 259 | ) 260 | 261 | 262 | @register_model_view(Backup, name='compliance', path='compliance/') 263 | class ComplianceView(ObjectView): 264 | queryset = Backup.objects.all() 265 | template_name = 'netbox_config_backup/compliance.html' 266 | tab = ViewTab( 267 | label='Compliance', 268 | weight=500, 269 | ) 270 | 271 | def get_rendered_config(self, request, backup): 272 | instance = backup.device 273 | config_template = instance.get_config_template() 274 | context_data = instance.get_config_context() 275 | context_data.update({'device': instance}) 276 | try: 277 | rendered_config = config_template.render(context=context_data) 278 | except TemplateError as e: 279 | messages.error( 280 | request, 281 | _("An error occurred while rendering the template: {error}").format(error=e), 282 | ) 283 | rendered_config = '' 284 | return rendered_config 285 | 286 | def get_current_backup(self, current, backup): 287 | if current: 288 | current = get_object_or_404(BackupCommitTreeChange.objects.all(), pk=current) 289 | else: 290 | current = BackupCommitTreeChange.objects.filter(backup=backup, file__isnull=False).last() 291 | if not current: 292 | raise Http404("No current commit available") 293 | repo = GitBackup() 294 | current_sha = current.commit.sha if current.commit is not None else 'HEAD' 295 | current_config = repo.read(current.file.path, current_sha) # noqa: F841 296 | 297 | def get_diff(self, backup, rendered, current): 298 | if backup.device and backup.device.platform.napalm.napalm_driver in [ 299 | 'ios', 300 | 'nxos', 301 | ]: 302 | differ = Differ(rendered, current) 303 | diff = differ.cisco_compare() 304 | else: 305 | differ = Differ(rendered, current) 306 | diff = differ.compare() 307 | 308 | for idx, line in enumerate(diff): 309 | diff[idx] = line.rstrip() 310 | return diff 311 | 312 | def get(self, request, pk, current=None, previous=None): 313 | backup = get_object_or_404(Backup.objects.all(), pk=pk) 314 | 315 | diff = [ 316 | 'No rendered configuration', 317 | ] 318 | rendered_config = None 319 | if backup.device and backup.device.get_config_template(): 320 | rendered_config = self.get_rendered_config(request=request, backup=backup) 321 | current_config = self.get_current_backup(backup=backup, current=current) 322 | if rendered_config: 323 | diff = self.get_diff(backup=backup, rendered=rendered_config, current=current_config) 324 | 325 | return render( 326 | request, 327 | self.template_name, 328 | context={ 329 | 'object': backup, 330 | 'tab': self.tab, 331 | 'diff': diff, 332 | 'current': current, 333 | 'active_tab': 'compliance', 334 | }, 335 | ) 336 | 337 | 338 | @register_model_view(Backup, name='diff', path='diff//') 339 | class DiffView(ObjectView): 340 | queryset = Backup.objects.all() 341 | template_name = 'netbox_config_backup/diff.html' 342 | tab = ViewTab( 343 | label='Diff', 344 | badge=lambda obj: BackupCommitTreeChange.objects.filter(backup=obj, file__isnull=False).count(), 345 | permission='netbox_config_backup.view_backup', 346 | ) 347 | 348 | def post(self, request, pk, *args, **kwargs): 349 | if request.POST.get('_all') and self.filterset is not None: 350 | queryset = self.filterset(request.GET, self.parent_model.objects.only('pk'), request=request).qs 351 | pk_list = [obj.pk for obj in queryset] 352 | else: 353 | pk_list = [int(pk) for pk in request.POST.getlist('pk')] 354 | 355 | backups = pk_list[:2] 356 | 357 | if len(backups) == 2: 358 | current = int(backups[0]) 359 | previous = int(backups[1]) 360 | elif len(backups) == 1: 361 | current = int(backups[0]) 362 | previous = None 363 | else: 364 | current = None 365 | previous = None 366 | 367 | return self.get(request=request, pk=pk, current=current, previous=previous) 368 | 369 | def get(self, request, pk, current=None, previous=None): 370 | backup = get_object_or_404(Backup.objects.all(), pk=pk) 371 | if current: 372 | current = get_object_or_404(BackupCommitTreeChange.objects.all(), pk=current) 373 | else: 374 | current = BackupCommitTreeChange.objects.filter(backup=backup, file__isnull=False).last() 375 | if not current: 376 | raise Http404("No current commit available") 377 | if previous: 378 | previous = get_object_or_404(BackupCommitTreeChange.objects.all(), pk=previous) 379 | else: 380 | previous = BackupCommitTreeChange.objects.filter( 381 | backup=backup, 382 | file__type=current.file.type, 383 | commit__time__lt=current.commit.time, 384 | ).last() 385 | if not previous: 386 | raise Http404("No Previous Commit") 387 | 388 | repo = GitBackup() 389 | 390 | previous_sha = previous.commit.sha if previous.commit is not None else 'HEAD' 391 | current_sha = current.commit.sha if current.commit is not None else None 392 | 393 | if backup.device and backup.device.platform.napalm.napalm_driver in [ 394 | 'ios', 395 | 'nxos', 396 | ]: 397 | new = repo.read(current.file.path, current_sha) 398 | old = repo.read(previous.file.path, previous_sha) 399 | differ = Differ(old, new) 400 | diff = differ.cisco_compare() 401 | else: 402 | new = repo.read(current.file.path, current_sha) 403 | old = repo.read(previous.file.path, previous_sha) 404 | differ = Differ(old, new) 405 | diff = differ.compare() 406 | 407 | for idx, line in enumerate(diff): 408 | diff[idx] = line.rstrip() 409 | 410 | return render( 411 | request, 412 | 'netbox_config_backup/diff.html', 413 | { 414 | 'object': backup, 415 | 'tab': self.tab, 416 | 'diff': diff, 417 | 'current': current, 418 | 'previous': previous, 419 | 'active_tab': 'diff', 420 | }, 421 | ) 422 | 423 | 424 | @register_model_view(Device, name='backups') 425 | class DeviceBackupsView(ObjectChildrenView): 426 | queryset = Device.objects.all() 427 | 428 | template_name = 'netbox_config_backup/backups.html' 429 | child_model = BackupCommitTreeChange 430 | table = BackupsTable 431 | filterset = BackupsFilterSet 432 | # actions = (ViewConfigAction, DiffAction, BulkDiffAction) 433 | tab = ViewTab( 434 | label='Backups', 435 | weight=100, 436 | badge=lambda obj: BackupCommitTreeChange.objects.filter(backup__device=obj, file__isnull=False).count(), 437 | ) 438 | 439 | def get_children(self, request, parent): 440 | return self.child_model.objects.filter(backup__device=parent, file__isnull=False) 441 | 442 | def get_extra_context(self, request, instance): 443 | return { 444 | 'backup': instance.backups.filter(status='active').last(), 445 | 'running': bool(request.GET.get('type') == 'running'), 446 | 'startup': bool(request.GET.get('type') == 'startup'), 447 | } 448 | --------------------------------------------------------------------------------