From ed3422d26708544edb87afe633931dc24e474b9a Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Fri, 29 Sep 2023 20:27:35 +0400 Subject: [PATCH 1/7] Add legacy ssh algorithms to support old OS versions --- docs/changelog.md | 4 +++ netbox_config_diff/compliance/models.py | 36 +++++++++++++++++++++++++ netbox_config_diff/models.py | 2 +- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 315755e..fcf94fb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # Changelog +## 1.2.2 (2023-09-29) + +* [#28](https://github.com/miaow2/netbox-config-diff/issues/28) Add legacy ssh algorithms to support old OS versions + ## 1.2.1 (2023-09-07) * [#26](https://github.com/miaow2/netbox-config-diff/issues/26) Add dark theme for diff diff --git a/netbox_config_diff/compliance/models.py b/netbox_config_diff/compliance/models.py index c1571d6..4f135b9 100644 --- a/netbox_config_diff/compliance/models.py +++ b/netbox_config_diff/compliance/models.py @@ -36,6 +36,42 @@ def to_scrapli(self): "platform": self.platform, "auth_strict_key": self.auth_strict_key, "transport": self.transport, + "transport_options": { + "asyncssh": { + "kex_algs": [ + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "curve448-sha512", + "ecdh-sha2-nistp521", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp256", + "ecdh-sha2-1.3.132.0.10", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha256", + "diffie-hellman-group15-sha512", + "diffie-hellman-group16-sha512", + "diffie-hellman-group17-sha512", + "diffie-hellman-group18-sha512", + "diffie-hellman-group14-sha256@ssh.com", + "diffie-hellman-group14-sha1", + "rsa2048-sha256", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha1", + "diffie-hellman-group-exchange-sha256", + ], + "encryption_algs": [ + "aes256-cbc", + "aes192-cbc", + "aes128-cbc", + "3des-cbc", + "aes256-ctr", + "aes192-ctr", + "aes128-ctr", + "aes128-gcm@openssh.com", + "chacha20-poly1305@openssh.com", + ], + }, + }, } def to_db(self): diff --git a/netbox_config_diff/models.py b/netbox_config_diff/models.py index 7c20b65..82d41c5 100644 --- a/netbox_config_diff/models.py +++ b/netbox_config_diff/models.py @@ -83,7 +83,7 @@ class PlatformSetting(NetBoxModel): ) exclude_regex = models.TextField( blank=True, - help_text=_("Regex patterns to exclude from actual config, specify each pattern on a new line."), + help_text=_("Regex patterns to exclude config lines from actual config, specify each pattern on a new line."), ) prerequisite_models = ("dcim.Platform",) From d8086896435f7b3c40a1139abfde8c67cec4a5d0 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Fri, 29 Sep 2023 20:27:55 +0400 Subject: [PATCH 2/7] Version 1.2.2 --- netbox_config_diff/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_config_diff/__init__.py b/netbox_config_diff/__init__.py index 2e3570c..048da91 100644 --- a/netbox_config_diff/__init__.py +++ b/netbox_config_diff/__init__.py @@ -2,7 +2,7 @@ __author__ = "Artem Kotik" __email__ = "miaow2@yandex.ru" -__version__ = "1.2.1" +__version__ = "1.2.2" class ConfigDiffConfig(PluginConfig): From 85e647fae16ad83d04d7327a50107caf0b4c6471 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Fri, 29 Sep 2023 20:32:26 +0400 Subject: [PATCH 3/7] Fix tests --- tests/test_compliance.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_compliance.py b/tests/test_compliance.py index a011460..8dc8a5c 100644 --- a/tests/test_compliance.py +++ b/tests/test_compliance.py @@ -90,6 +90,42 @@ def test_devicedataclass_to_scrapli(devicedataclass_data: "DeviceDataClassData") "platform": devicedataclass_data.platform, "auth_strict_key": devicedataclass_data.auth_strict_key, "transport": devicedataclass_data.transport, + "transport_options": { + "asyncssh": { + "kex_algs": [ + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "curve448-sha512", + "ecdh-sha2-nistp521", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp256", + "ecdh-sha2-1.3.132.0.10", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha256", + "diffie-hellman-group15-sha512", + "diffie-hellman-group16-sha512", + "diffie-hellman-group17-sha512", + "diffie-hellman-group18-sha512", + "diffie-hellman-group14-sha256@ssh.com", + "diffie-hellman-group14-sha1", + "rsa2048-sha256", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha1", + "diffie-hellman-group-exchange-sha256", + ], + "encryption_algs": [ + "aes256-cbc", + "aes192-cbc", + "aes128-cbc", + "3des-cbc", + "aes256-ctr", + "aes192-ctr", + "aes128-ctr", + "aes128-gcm@openssh.com", + "chacha20-poly1305@openssh.com", + ], + }, + }, } From 9cc30417f2152713b32acfa1504b7d1d7c7e5fe9 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Fri, 29 Sep 2023 21:02:46 +0400 Subject: [PATCH 4/7] Delete extra lines when using exclude regexps --- netbox_config_diff/compliance/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_config_diff/compliance/utils.py b/netbox_config_diff/compliance/utils.py index 448cede..e84bc74 100644 --- a/netbox_config_diff/compliance/utils.py +++ b/netbox_config_diff/compliance/utils.py @@ -31,4 +31,4 @@ def get_unified_diff(rendered_config: str, actual_config: str, device: str) -> s def exclude_lines(text: str, regex: str) -> str: for item in regex.splitlines(): text = re.sub(item, "", text, flags=re.MULTILINE) - return text + return text.strip() From 0a22775280220bfda8d83ea8e90b6ce237b31021 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Fri, 29 Sep 2023 21:20:51 +0400 Subject: [PATCH 5/7] Strip rendered_config in diff --- netbox_config_diff/compliance/utils.py | 2 +- tests/test_compliance_utils.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/netbox_config_diff/compliance/utils.py b/netbox_config_diff/compliance/utils.py index e84bc74..f714780 100644 --- a/netbox_config_diff/compliance/utils.py +++ b/netbox_config_diff/compliance/utils.py @@ -19,7 +19,7 @@ def get_unified_diff(rendered_config: str, actual_config: str, device: str) -> str: diff = unified_diff( - rendered_config.splitlines(), + rendered_config.strip().splitlines(), actual_config.splitlines(), fromfiledate=device, tofiledate=device, diff --git a/tests/test_compliance_utils.py b/tests/test_compliance_utils.py index c2a1d74..e4562d8 100644 --- a/tests/test_compliance_utils.py +++ b/tests/test_compliance_utils.py @@ -13,8 +13,7 @@ interface fa-0/0 switchport mode access - switchport access vlan 100 -""" + switchport access vlan 100""" @pytest.mark.parametrize( From e6ea6bfef44e4676aa825bf6f3157ca18a61d2bb Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Fri, 29 Sep 2023 21:25:13 +0400 Subject: [PATCH 6/7] Fix tests --- tests/test_compliance_utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_compliance_utils.py b/tests/test_compliance_utils.py index e4562d8..234ea17 100644 --- a/tests/test_compliance_utils.py +++ b/tests/test_compliance_utils.py @@ -13,7 +13,8 @@ interface fa-0/0 switchport mode access - switchport access vlan 100""" + switchport access vlan 100 +""" @pytest.mark.parametrize( @@ -21,15 +22,15 @@ [ ( "^interface.?\n^Building", - "hostname test-1\n\nfa-0/0\n switchport mode access\n switchport access vlan 100\n", + "hostname test-1\n\nfa-0/0\n switchport mode access\n switchport access vlan 100", ), ( "^interface.*$\n^Building", - "hostname test-1\n\n\n switchport mode access\n switchport access vlan 100\n", + "hostname test-1\n\n\n switchport mode access\n switchport access vlan 100", ), ( "^Building", - "hostname test-1\n\ninterface fa-0/0\n switchport mode access\n switchport access vlan 100\n", + "hostname test-1\n\ninterface fa-0/0\n switchport mode access\n switchport access vlan 100", ), ], ids=["part of line", "full line", "no effect"], From b174b9c3c3cacafd1bc095244282aba1f13a14c1 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Wed, 18 Oct 2023 22:22:28 +0400 Subject: [PATCH 7/7] Closes #25: Add configuration management --- .github/workflows/commit.yaml | 2 +- README.md | 16 + docs/changelog.md | 4 + docs/{usage.md => colliecting-diffs.md} | 2 +- docs/configuratiom-management.md | 114 ++++++ docs/index.md | 2 +- .../screenshots/config-temp-substitute.png | Bin 0 -> 9975 bytes docs/media/screenshots/cr-approve-button.png | Bin 0 -> 1427 bytes docs/media/screenshots/cr-approved.png | Bin 0 -> 14192 bytes .../screenshots/cr-collecting-diff-button.png | Bin 0 -> 1613 bytes docs/media/screenshots/cr-diffs-tab.png | Bin 0 -> 65258 bytes docs/media/screenshots/cr-job-log.png | Bin 0 -> 20845 bytes docs/media/screenshots/cr-schedule-button.png | Bin 0 -> 1214 bytes docs/media/screenshots/cr-scheduled.png | Bin 0 -> 14945 bytes .../media/screenshots/cr-unapprove-button.png | Bin 0 -> 1444 bytes .../screenshots/cr-unschedule-button.png | Bin 0 -> 1408 bytes docs/media/screenshots/navbar.png | Bin 4991 -> 7416 bytes .../screenshots/render-temp-substitute.png | Bin 0 -> 8971 bytes docs/media/screenshots/substitute.png | Bin 0 -> 15868 bytes mkdocs.yml | 3 +- netbox_config_diff/api/serializers.py | 118 ++++++- netbox_config_diff/api/urls.py | 2 + netbox_config_diff/api/views.py | 170 ++++++++- netbox_config_diff/choices.py | 28 ++ netbox_config_diff/compliance/base.py | 44 +-- netbox_config_diff/compliance/models.py | 8 +- netbox_config_diff/compliance/secrets.py | 34 +- netbox_config_diff/compliance/utils.py | 6 +- netbox_config_diff/configurator/__init__.py | 0 netbox_config_diff/configurator/base.py | 211 +++++++++++ netbox_config_diff/configurator/exceptions.py | 19 + netbox_config_diff/configurator/factory.py | 99 ++++++ netbox_config_diff/configurator/platforms.py | 122 +++++++ netbox_config_diff/configurator/utils.py | 53 +++ netbox_config_diff/constants.py | 7 + netbox_config_diff/filtersets.py | 66 +++- netbox_config_diff/forms.py | 127 ++++++- netbox_config_diff/graphql.py | 22 +- netbox_config_diff/jobs.py | 52 +++ .../migrations/0006_substitute.py | 62 ++++ .../migrations/0007_configurationrequest.py | 70 ++++ netbox_config_diff/models.py | 158 ++++++++- netbox_config_diff/navigation.py | 33 ++ netbox_config_diff/search.py | 21 +- netbox_config_diff/tables.py | 80 ++++- .../configurationrequest.html | 117 ++++++ .../configurationrequest/base.html | 57 +++ .../configurationrequest/diffs.html | 102 ++++++ .../netbox_config_diff/inc/diff.html | 27 ++ .../netbox_config_diff/inc/job_log.html | 27 ++ .../netbox_config_diff/platformsetting.html | 2 - .../netbox_config_diff/substitute.html | 37 ++ netbox_config_diff/urls.py | 12 +- netbox_config_diff/views/__init__.py | 29 ++ .../{views.py => views/compliance.py} | 8 +- netbox_config_diff/views/configuration.py | 332 ++++++++++++++++++ requirements/base.txt | 5 +- requirements/dev.txt | 4 +- tests/factories.py | 10 + tests/test_compliance.py | 2 +- tests/test_compliance_utils.py | 2 +- 61 files changed, 2463 insertions(+), 65 deletions(-) rename docs/{usage.md => colliecting-diffs.md} (98%) create mode 100644 docs/configuratiom-management.md create mode 100644 docs/media/screenshots/config-temp-substitute.png create mode 100644 docs/media/screenshots/cr-approve-button.png create mode 100644 docs/media/screenshots/cr-approved.png create mode 100644 docs/media/screenshots/cr-collecting-diff-button.png create mode 100644 docs/media/screenshots/cr-diffs-tab.png create mode 100644 docs/media/screenshots/cr-job-log.png create mode 100644 docs/media/screenshots/cr-schedule-button.png create mode 100644 docs/media/screenshots/cr-scheduled.png create mode 100644 docs/media/screenshots/cr-unapprove-button.png create mode 100644 docs/media/screenshots/cr-unschedule-button.png create mode 100644 docs/media/screenshots/render-temp-substitute.png create mode 100644 docs/media/screenshots/substitute.png create mode 100644 netbox_config_diff/configurator/__init__.py create mode 100644 netbox_config_diff/configurator/base.py create mode 100644 netbox_config_diff/configurator/exceptions.py create mode 100644 netbox_config_diff/configurator/factory.py create mode 100644 netbox_config_diff/configurator/platforms.py create mode 100644 netbox_config_diff/configurator/utils.py create mode 100644 netbox_config_diff/constants.py create mode 100644 netbox_config_diff/jobs.py create mode 100644 netbox_config_diff/migrations/0006_substitute.py create mode 100644 netbox_config_diff/migrations/0007_configurationrequest.py create mode 100644 netbox_config_diff/templates/netbox_config_diff/configurationrequest.html create mode 100644 netbox_config_diff/templates/netbox_config_diff/configurationrequest/base.html create mode 100644 netbox_config_diff/templates/netbox_config_diff/configurationrequest/diffs.html create mode 100644 netbox_config_diff/templates/netbox_config_diff/inc/diff.html create mode 100644 netbox_config_diff/templates/netbox_config_diff/inc/job_log.html create mode 100644 netbox_config_diff/templates/netbox_config_diff/substitute.html create mode 100644 netbox_config_diff/views/__init__.py rename netbox_config_diff/{views.py => views/compliance.py} (94%) create mode 100644 netbox_config_diff/views/configuration.py diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml index e3cad73..0f0c142 100644 --- a/.github/workflows/commit.yaml +++ b/.github/workflows/commit.yaml @@ -31,7 +31,7 @@ jobs: strategy: max-parallel: 10 matrix: - netbox_version: ["v3.5.9", "v3.6.1"] + netbox_version: ["v3.5.9", "v3.6.4"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/README.md b/README.md index 69b1c65..414600b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ ## About +### Collecting diffs + With this plugin you can find diff between the rendered configuration for a device to its actual configuration, retrieved from the device itself, or stored in DataSource. Read about [DataSources](https://demo.netbox.dev/static/docs/models/core/datasource/) for further details. @@ -23,6 +25,20 @@ Device configuration renders natively in NetBox. This [feature](https://demo.net NetBox Labs [blog](https://netboxlabs.com/blog/how-to-generate-device-configurations-with-netbox/) post about it. Plugin supports a wide list of vendors (Cisco, Juniper, Huawei, MicroTik etc.) with the help of Scrapli. Read [Scrapli](https://carlmontanari.github.io/scrapli/user_guide/project_details/#supported-platforms) and [scrapli-community](https://scrapli.github.io/scrapli_community/user_guide/project_details/#supported-platforms) documentations to find full list of vendors. + +### Pushing configuration + +Also you can push rendered configuration from NetBox to device and apply it. + +Supported platforms: + +* `arista_eos` +* `cisco_iosxe` +* `cisco_iosxr` +* `cisco_nxos` +* `juniper_junos` + +This is possible thanks to the scrapli_cfg. Read [Scrapli](https://github.com/scrapli/scrapli_cfg/) documentation for more info. ## Compatibility diff --git a/docs/changelog.md b/docs/changelog.md index fcf94fb..d0145fb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # Changelog +## 2.0.0 (2023-10-XX) + +* [#25](https://github.com/miaow2/netbox-config-diff/issues/25) Add configuration management + ## 1.2.2 (2023-09-29) * [#28](https://github.com/miaow2/netbox-config-diff/issues/28) Add legacy ssh algorithms to support old OS versions diff --git a/docs/usage.md b/docs/colliecting-diffs.md similarity index 98% rename from docs/usage.md rename to docs/colliecting-diffs.md index b4da647..35d3057 100644 --- a/docs/usage.md +++ b/docs/colliecting-diffs.md @@ -32,7 +32,7 @@ In the script, you can define a site, on which devices run compliance, or device If you define both fields, script will run only on devices from `Devices` field !!! warning - Script runs only on devices with status `Active`, assigned Primary IP, Platform and PlatformSetting + Script runs only on devices with status `Active`, assigned Primary IP, Config Template, Platform and PlatformSetting If you have configs in NetBox DataSource, you can define it, the script instead of connecting to devices will find configs in DataSource by device's names. diff --git a/docs/configuratiom-management.md b/docs/configuratiom-management.md new file mode 100644 index 0000000..c95c0aa --- /dev/null +++ b/docs/configuratiom-management.md @@ -0,0 +1,114 @@ +# Usage + +With plugin you can push rendered configuration from NetBox to devices. + +Supported platforms: + +* `arista_eos` +* `cisco_iosxe` +* `cisco_iosxr` +* `cisco_nxos` +* `juniper_junos` + +Plugin using [scrapli-cfg](https://github.com/scrapli/scrapli_cfg) for this feature. + +## Substitutes + +If you render not full configuration, it is acceptable to pull missing config sections from the actual configuration to render full configuration. + +!!! note + If you render full configuration in NetBox, you can proceed to `Configuration Request` part + +To do that you should create substitute. + +Substitutes is a "tag" that needs to be replaced with output from the real device, and a regex pattern that "pulls" this section from the actual device itself. + +![Screenshot of the substitute](media/screenshots/substitute.png) + +In screenshot below we add substitute for Arista PlatformSetting + +* **Name** is a "tag", you should put this as jinja2 variable in your config template in NetBox +* **Regexp** is a regex, that "pulls" what matched from device and replace `Name` jinja2 variable in config template + +In example substitute `ethernet_interfaces` section will be replaced with whatever the provided pattern finds from the real device. + +This pattern matches all ethernet interfaces on a Arista device. + +To correctly render substitute in config template you have two options: + +``` +{{ "{{ ethernet_interfaces }}" }} +``` + +or + +``` +{% raw %}{{ ethernet_interfaces }}{% endraw %} +``` + +Config template will look like: + +![Screenshot of the config template with substitute](media/screenshots/config-temp-substitute.png) + +And rendered config template with substitute + +![Screenshot of the rendered template with substitute](media/screenshots/render-temp-substitute.png) + +## Configuration Request + +Now you let's create `Configuration Request` with devices you want to configure. + +!!! warning + For request only accepts devices with `Active` status and assigned Platform, Primary IP, Config Template and PlatformSetting + +Find `Configuration Requests` in navbar. + +Now collect diffs for devices pressing `Collecting diffs` button. + +![Screenshot of the Collecting diffs button](media/screenshots/cr-collecting-diff-button.png) + +On tab `Diffs` you can review diffs for devices. + +![Screenshot of the Diffs tab](media/screenshots/cr-diffs-tab.png) + +To continue approve request by pressing `Approve` button. + +![Screenshot of the Approve button](media/screenshots/cr-approve-button.png) + +Also you can cancel approve after that. + +![Screenshot of the Unapprove button](media/screenshots/cr-unapprove-button.png) + +After approval you can see by whom configuration request is approved. + +![Screenshot of the Approved request](media/screenshots/cr-approved.png) + +At this moment you can schedule job that will push rendered configuration to devices in configuration request by pressing schedule button. + +![Screenshot of the Schedule button](media/screenshots/cr-schedule-button.png) + +After that you can see by whom configuration request is scheduled and time. + +![Screenshot of the Scheduled request](media/screenshots/cr-scheduled.png) + +Also you can cancel scheduled job by pressing `Unschedule` button. + +![Screenshot of the Unschedule button](media/screenshots/cr-unschedule-button.png) + +!!! warning + Approve and Schedule buttons is accessable only to user with `netbox_config_diff.approve_configurationrequest` + permission + +!!! warning + If you unapprove scheduled configuration request, scheduled job will be canceled + +After scheduled job is completed you can job logs on configuration request page. + +![Screenshot of the Unschedule button](media/screenshots/cr-job-log.png) + +!!! note + Completed configuration requests can't be edited. + +## Rollback + +If an error occurs while executing a job that pushes configurations to devices then all configured devices will be rollbacked to the previous version of the configuration. diff --git a/docs/index.md b/docs/index.md index 522f8d6..319426c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,4 +12,4 @@ ## Usage -Read this [doc](usage.md) to find out how to use plugin +Read this [doc](colliecting-diffs.md) about collecting diffs, for configuration management read [this](configuratiom-management.md) diff --git a/docs/media/screenshots/config-temp-substitute.png b/docs/media/screenshots/config-temp-substitute.png new file mode 100644 index 0000000000000000000000000000000000000000..700aba6f0726a453fe7cf14b83d2ef1834e28339 GIT binary patch literal 9975 zcmb7qXIN8PyKN{UQUpb$gA_q(C;>zWAiejd8-jrJ-brYp6cGWbp(9N?q4!XvgGevZ zl_mrT1VS%2?tRX^=RWuOzI)D(WUe){=3MJpnPZOej`xk!(NZQSp(gM6?sD!wyr;Z6u`pKCq`0IFh1FD-82&hNOXyzm47?son8 z;Ptr{y#@d{KdLG`H}He(W)7({{+RE}UcLYlv)&~HuBfpQvc(;7C=AH;v)KsMP+G_u zcgo(jy(c&@sL*fO9UfXnM)p1`HH=@OpY2JCy&EN)uIa5-%6`S(_MGLz4D~6ADZ{BL z>(a&P6N0cQZ;6IxsS%07fVLZ6Qepr=*5*v+iU!vzg|ON^Z5ta$UmdxfrC$dcIq9*^ zXovA%Q32dsLReKg#xU=Q{DcSq9RqJ9-V#=_rsVd4{0j|T@c?fQjUu6DDoT-Cc-w0e44 z8t|>~1yZ{}=t8xEWasGWkX8m9W(GGfvHoNYcPK!sZcflApf8z~=(r?A3OF02kkai4csb-bkKo7+nq>J|w;V^30ozc0qa2yswB!5Vs zD+CS|!OT z_pV$3K#@{lJN>p}UiR7`=kRS9p}Uu<93XcN8F{teEYhPq?qQG{dk`ir;3;`HFYL`NBkla)q9oJV&1psj51ciUzG=|C9 zG}=VZQDKQ+Ztc0sFS!jrSUmP8q^Zzthi5^)!|=MlXfdN>?<^B zk~xwXSP~}W&i0qQrSO%1e(R7)!~@9o7NcJa0RV5l5QO6}E_{~^4*+=0NQ(~u^gk6O z1OPs{zrx||(P{@J4sE^m#PS`iW-i7i>sKmKtEO|m#1ThRU-~1Y@Uz< z-r$4rb&Tt81a$fwnE;5ynVi0fQ8nM270}N6TELrx6({l#qiM{$^dCzs{-JHKhd^C5 ztH`O*d|y=dW+7L76o}`+AcqU2-h*}%tFyLVP&}0Lw%&dAHkF?ipO!=}@#N4mAP?fS zTErzY0o%IcLFmO`3BGo%ZJF2HrNL^y^Ng7fcX1U|Ma#o?A)RCSl@o-3N#clGnlNeYm&IdoUT}K3Xan4e8{rNWQzJdf*ESX5?_Z6&a(#)6w}91Gh!X(l z-)>~Es}zKD{vH@w6?Z8shU-nb4gbX3-I|(EPK?B&YRviT9{CCOwn%we#*u)LgfI zFjaDx>f=c1Wl<-5m(>|kZ4aXf`f-b9uE+{0dng(h*Mlr#EC21erfynZvef(4lqW7N zBlYR*d1;|2eIe=38Z(-M~&@X$4l}5Hc58O$L_*9x%fx^;L$nW`@;4`6)jZbA=$@%Ha~DyIcCN(6o9 z3kQw+GlC2IiTnTb_z5cG8oJx^@_%4B%Km3!0^BjK>%W85f1ZRue$*`>uVDFN z>nBG1iU9!7dPw`0kda^JDIB;lQ9-jgla4YTD%C_%1Kg%NC_#+nUMI1JX~>C^;A|Q( z`=I8iKil#<_yI3W60lNMJV2>JfiVnWR*S885+Qi$HtC?!b$H6>?l1i}QQks6ryHM> zK~xqrh=c?pn{L;7(N>QPIVOBf268`I;$mHY=nnEzsC#-EC#<^Y5DL!s z4ebCLBVqUR9k~Fq$wlbbRpLb7sNM~kDNiyf40}E^}c&B#L^Zq2mxu{ ztj9bzUMC?Zqp=rQHx@3qLc{pE%_g~{KMMK;6Tt`{?gVZURT#pSy&tJchD}8w&!D;+ zA0UH0VuSo;(5(Sfn0qiFE$f`_ZI=1gom7kWq7K@V>~;k$?c~rdQGKs@^gYu?r7H*w zCX|p&6aIT^&6d9ISR&!uJyYiFoV%_blWGVOe&1fZ*A{Zw@K@C)iLM&7OonZn3Irj{ zA)=-tY9dQCayCx~vbr_(5Gp0L`Uc3Cd#(B6nMU)lRK00nh@{?MsNW8!>`fv zI7|cFc>dwm|HSLR064h?@l%JbDmiWt$7S|tNXjLb92jGf8UFeiDarSV%RF=bO+!m% z`|{S?TBYQ8{bIlGy-EA|%F`cWFtK{oAl@hX%WH`Yg=1&^%|HCxLhtQ`|M7Ma(x6

_Si5lu@9hHQ4ntK^C6AQY^P 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 literal 0 HcmV?d00001 diff --git a/docs/media/screenshots/cr-approve-button.png b/docs/media/screenshots/cr-approve-button.png new file mode 100644 index 0000000000000000000000000000000000000000..99fd64d78d769d68a317e15a027aa340463ba5dd GIT binary patch literal 1427 zcmV;E1#J3>P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1t>{GK~#8N?V4Rk zBUcp1|2AskN3}L4)Y5FX`Vb0Il%hWPpx`1%R}uQuQu<(ubl-%sEC@oWP*_S~U%G8W zV`(Ywz7!UBiy#$zal!Q=Qc{W!>u#I2*(PQ~QsXAoVDFi^`Ea~rbTZiucz}Q;+Z6Z{ZvhMue^qgTLIs@2NQN zNzC9?TQgSPc!&26BdigWj%X%G%!PbPz7-)9f@*?HN9IF5CEtn=3PFWgQXcetD-=Th zJ1QS~zLn1r6@nC^LXbjK2*T%x*Y17ATPNcn6u`gVEFy7d3;W7QE36j>@WKZJsB5cH zMkIM2^nS=&c}kuU`^!JzukkPNI41(Jjr5%#@z3}t*k0b|W5q}yIeiHaeOtVLwkS!6 zgTNvp)2jsn9LDmd9edN?A|5!wGf7|-k&ZF6csG&eLuZK+gxE&Xx1j4Z%g6-Uu1}%$ zBOvCxrz=*L^x6Vi`bJ=zTtIy3>^;XA7b5fkyLWk8gCfGmJ;2f&9(~SI&D~#-4soN9 zEu_3-uyq2~zA@~sY$C%g3T);*oL)zUzWZ4EqK*38T-46aAMf)bxy0?c^he`-e`Q_?S`n7}4t zlWth4-RVZlvL7k!jV;e1xaP+BwJ(*et%LOo>q#*?yM{=R+db{9Pa=GV^z7B#F|OGl zLR`Qj0TEtDoCQnsaFZ&62WaERo%gvAxmGCr=`k*ZMfu>rTPt{i9yojxXuK#AfnXO; zI}2e&dY;k}IfrOx>B*1eh9*4?O~9o%P{lerf%?LFQp}ESI7ah!MSOOQogB-t3}46x zfhHg_8-DEaV4*ZxKijVc4$;{U2NU{8@SFnULUgpFfxe^bhGG z7cznU`*eL*3R2S*M3`vmamFw%M6DZ*Q#``B81e5!BCzP3>V>VAcQmMxb~%?G)a@!9 zLkma+_)a3i(c^_GVz<2P0`5lz>7*OgPPJv}>INu1QLwOan=;;nY8F&2zQXHOw`Nx3 zjAC2}M(z09XvzLK6C-wAkS9CG@ZvbTX(1G8{(`iWr3tpdZp79O*MOazFf5&2n*$2d zLovG+CH2f9T2PQPOE(e8Ih^5ivsv%Q&TT(1=+4a^svj{=zOb7?=}*>A9wGVlO+3D# z`KQC2Su;Kc=Ttvx>iCeBT+tgj>M&Lu!=rvQ+@3^3nC&;;3I{8|5tgtqGBV8?h*sWYGU972#(c5^ki+yD-Ply97Jkk z3wz-`R5@RQwablKr%A6jxTAG%w3xlZD9bC3ELBby>V^l<^xg$LKip}a2xkP-gpibm zX{kwaa>|;TB&8t~f>x+W%7dP7g+kCWK}mVg^Q}+_TB}LR1SRG|J|*9Z5DGyHW@)Xa z#9YXydW(^Di#08nrL~%JW|R*3kbFr#)kAj?au&HP-#Df$%a=S|bW@Y$=^}-w5TpE?@PyJ%K~n()s)(UJd2t?ie#uS6 z*b@Z0(t7rLt`q$A6$m78UR6m!-`4_Mya`v#y8? z-EbAS2=nJ_7wGdZY0&dt)ASf?8f!YRkO4n>6nELIYyHI*}n(G0x+0;%+Lb$zogK-�GYMQ|0fD|RGl^(1O8cLumDf*# z!C)tKS{c%X1Z)7R&kaWbdi`h5C3;44uTKI;-?@mhi-|msJ088@IPAoX%c7{f=4rs^f(NfvoPVt*&~A{)C<4V5=CPnPGzmoJm=Omg6dvwDPZ#=9WKk z5@#<64u{L_-SXiAfnI0jD#xs*`)JwkB?(7l4H}KRrQXx1+ai-ln=vvgcVa)D1A&O* z{(JW3f!j}9T`w-Z%ecOOt!jX#Kr6$m7dQim0hnb$Wd)K^onK}Q!W4oPk3IV(U z?d|FD@N9jpz*+a51J&2(b@qiU$7C`Rd#*PtanQnm&q%UREMCko2HwcNWe+T8;F=f+ z^vM1qa7X+lMo9|-U1JUb?u(1G3KSsFo3JckHIEesfCK;6AMbxh&D?n)pPO>ta$uxz z*n8KmCA*#z**AY|P`h$z6wXR8WEKcpTl)iKCM2Sj_HfkYG$_!>C{bEQ#(Pht!Nb!N z|I2fslr*_N&$AZ_3o>rO!pzugu?D&OgQ^k^T2J!S%1|Cg)TW)iF%C#s)beZ5jQu(H z6t_`ju>#xpGC#-qC07qy3;%{Oaxauv*KqK5GY@<<9=#vJo=T*3sn%09PI24SB>qa= z98OE^obUs^rdD-wDt;Q%ZD?zo|1^p@@52WgpRKppoNZfDC~pIv9Ye-r{IOS=I!u-K zS3Rc;(Do@ehO!k=;Hq2m(O}2U;neEwfMWff7b~kjQ@!4L4j(UDD(&fj7pdQQvcVb+ zdl~c#H)SodXu5z9z(?amYB2%{nZ0Ee!=7wE zhOT61Hb@ZoTm|&@>1KvekoAGx28cacyQLuNYP`c^e`Zlu<&mH+mWHFN)M-=Tp`>3e zXcUB`&yp*V*ViJ{m|stCs!#ako?hu0sJQJ-a)_tf%5V$Qlpo#Q*7Wxpv$t~%w;(p~ zR9lZ5`4VB}Agj8|g7%FyI_gG^>oryycP;bm$ml=fr`^)azWFqk^p+1RM=~xqzf9>< zF6nbmAu__sQZW&Xdp%AY7eQ7xFY((q?rG1U)omB*MGR1xIea3Ri2}kDX7V{SrpGlE z`0$lf%YihmVuRY2PBdmlF88>9r;vJoJ$v4Nzq41I5RF1iP5W!dXZUj=PCs!7dMzb7 z9SJtAnDg~CLCO1~B5zs1ejd`$Vdl z`5H~$=vm>JhdZ9KkQk{BT=jts_^1M1(|~>v4b{YYKQG2n$Pf8|Q{y}a0Dt47;FEECi6u0}Z*%Eb%F^8n099;LGHu?W7lYGv` z=0C-Tu%Ppp{`7`Z5UkTr%Bvq`osqKW4q5cC!QsA3zLXQ~-sLScD>Vm(I$=*j1rke@k>Hf+H-WGC5v{bJa!Q$L^ zH+tOxUt`qfmcRA%31jVAuH8+MKHA3hXkJ;vTFVP!lckH=T^_CnD3Vg|29b{+Rz@IC zYhnUAIbnHHG3$n%%fif_g}d@6i9W@)FS4!#71jz?^$D>lV1oYOVE>xsW+qtbd2l?S zT|E!KQVag^rZ{?ef}Yv{vCVqGvwRr!Kp^NDi{%4!u_9Z#?Gm+#WwTjKGFpcCG(JvJ z;srt4d9caSPebsC&0Ox4MW?;_oYpIczWoDlsQv8YhAc9~K0>?hOzWVF{o+zWdHlXgUZNEWYXM2fEqBF}N9uW46L{kg` z!}FuJZCRFBTYOO_viqr5789oP1u-6o-J|icMI>&vf|26I{6{)T@^qlBg(q^}0|);w z7~gUfu|b}!t)BORq!WGwa^p^pJW8P(@5)YcnNsvT(kp|GL&mT$BemzkYyF<(g`VPN znZKMxpJX=fy@RYn`&~v^)&tIi7$5GAbj$E)K{&`H84S1yT5|k7m}2Q(9051p;w4C$ zcfe$Ngek*ZaOud+G#A?>m(wrD%>~R2GeIaMZ1XvRPa6t9j_E(O)%PLyKvg=nIkbId z>|(Y%BZJ0CVJ)W>mJoT9)qe7hUMVDh+m;|Yv$Zrs&<0QXWgNhWMKWZ)8Q=N_407$X zwzHvSd4r6q=fHUOA-zsITfF2SPEBd|5L<$(2@luW#j(U6^W4!ZG)pI@C+J1TW$X^d z5>k6y-tEhSC4CEk=6lPZkS`dyE(eWZP(}@zM^|NfS9iU|<*+0!ACml9&>>^++7>ny z({gw$zdP14g~)(Fb{?h^7AF?=1CNcw4~Tsm*z1jb&>fV!31M%ds-fi}n#q+M+ZNQ) zxVo~D;Im#728+%c#k5>B>(dKZ7gp!k3~FzBpj=LRP@z7pdQ}#EKtRA zXhaM7{akn-TOfU1QIel|lhrdd64mj^@?oj;Fb!<}p#id9&e*Zrt2qx_@n~1WD4K2x zo|}!U#k<#&i*4#BEy+9g>fvq235TmNR&O%fhs2=m4_L3${uJ*Ph8yM^{bF(t6{&e$ zE9r1Q?euqRWo6p2Uj&~N*lT>_S{}NWV)84h+3(eW%SHc{Onj^aa;+Zbm<9W=A}H*W zk)OJqm^oOMLE>^fRL2n7*Vf;xOdlJ^*YPWb?=6{bl!$t)!xjDa>Fo|n{Wt{CBNv); zqpnVuU8;e1Z=u{B+Z*Eyqf2s7tA?T;%Q*jSZ`=&0`zWceuTCc(Mt4X1+M z%`A}e3r2mfL)jAx`n#z8w`cm+x^xrF1FcZ5P0aRgOr9j~8hQ%Q(|os(jwJUKE9I%F zvPExtMh+?s|lKr>dHSP?#-{dqo*ox`DOa|{5d8XvMg(UHR*a<9HvAuZ%Hj6u3IlAm z6c|Bak!~SRmz3kyg63maA#JE4@A>!Y6f>;hulWSjVYE%wWV)1qR5}56|Hs-S=gerF z^tsvH+QNmW4L_ya_-V1c%VHgM}pDQ@0-K5lL}r!jgl>? zFQJlB?e_ex*d|G1Ym7cLn2VEVxJxZPQuC#=ab0DZ_7jb|@zCS*#Ww15O_HO^u zxr`f!i%zcHT-`@ur|;QhIgzy|9L^)^JYNd0IuD^9xD3trGB_xB1*=`g^M*dqGMWM( zL7N29#|sjf``%S$?BX9f8)r5f^+u*^cSj@|?8Z#tk24-BL)RfV?-?{sh-6}SWMJLM zaXTPSq5Qx(c;#V<;c@axZ`oKYq)&Pwuw(rzimbtoIAG{sn`iCY%G0I{abJ>zp6o{M zEf33&qMG%VPOq;8YhGP7J7A-FO|7Y-(26ja@yO&YNclP!Xl?GExb(2`iC|OEw4A|Q zpX_Oh1##O^)^PLf$6|SWAe|T|chbq-0fp}mUlRAMO`KfEkhLAjPgV}OgAj7!qif;Y zOIOEM3%_&5v+=VS?BeBuwr=61H&6EIT&Cj9$X#VT)z~J#%tCAbDelsB;o#NNHPmw9 zIdvXn)DF#Qem746W+NdV`$6_=C-Wa`KI}tf-Z5*&qNgh&82>B9iX@iO6?)hT_M^7d}h$QkRc!MLA$0*7Z_1Tw!T>T`2+1f=feo-ua2V+57yDb zgtCyog7I*iyy%uOX>1&b0 z&?WG^OI}+NG1?l24g-)x4b+Z)q-j%1hN~EV2^HoA!Gj?9Kf8ydU9bVj+w~eBv1G zD3{voWV0nN@*bHXHfH?w>o-J;j#8)6t5jmwdiM4`cKy?L$qhWy%05-D%|_xrAGDed zmZXzrL=ml*uHmuy`J2eJ3t*pP3bt`#mRj@2lfG%PiIS)Dk$`<4tf_Ua&HrV?MhoW1 z8L^gF#NF#hZZ!B@#y?!yg1|NzY8xc*l#R0D((;2g*hfz~$Xo2geDZb5wJGz0#$}y- zo0)z719y(f(B%L?-=B8qO0m@^xKt(5=+E21KkQ0 z>a?TgV)auCR1o;nQj)h!biFoD(#&v@MNsA#m>9i;{n&BJvcz2ji7M&^?**mK!#N#W zRz*#SP2}8TxnZ=+xD@KbnEeVHT58Q_xhu$yJg4Sfud#t}- z)884*o7_nUI}(orP3G4n%YBd}Y(IHGwCFT@<+YTFUdt)0dOLecj7049?vX3p$zPW` zHP|W4EYY4WfRkq%8Fq>NGbkx9HAJRmQGsDXAxfUsca(YwudVoGd%0gpzl)N zShD}xmZv^ybB8^kkJ!3)kqHE%d7U*_&bX)VT#KcFEQqq{)yjQ!Bq--Rb;9!FcETG# zpqoJ#nJIid72{96h`7e+^j(c`)hXdoyz;hYzh6rWH5KT)kn+GX+MCxHz~&h@g!Dl` zIwer~nZh`qb_j2;_$SKcj;GlcM?Wk8{trb?afS zczb&nR#oXK$5iu4NT3C%s5UdnKH;bPD}+5_Z~AH=8p(P~L@=SONmwtle*QM88-}X% zJZE+2;Nmj6EIiTE*EjN2CpYfXr@1R~Ayi024TU*zV>;H~1zeCWT4^qEPtT{kMai0H z;PpkTLn-rtS#qEx?jloRA6y7c33_@PIOd+7z^zw%wq+5B2Oy!LYhrF%$#P7~ENH6l zDsdfIgJt}K5j}i+;YX7m;{rdwy^s#d0h_UaICWA&IxI@>e)#I}Lk=!BdCTH{(;UA` zae8@UN)xt?$^L8dR|FgnxvbcoEN|`+!A?Z+?ZqIsx?%(t3PdXR_6>KX<-W?`(@sBB z^MFC$RkQ~rr%SjEzP_qVf8e2Jz>wC_RmrV4zrJjfS*N14P6?t>T)D(7b;LVBHHxno zK9}$PvgcRb4L%=rUcPl{S3c#OzF#Opk6_iyA!Pd}rK3Eb$al3FErR7wxDbLSipUlj za#!G9y_bEdy9w#|#kvNjXNsDLlbNe+oUC34&ow2k{3(iAy-!G#S_{I^ z@&+-r&Ws@~cNH2k*iN8FyCjE}v|;~b^v@ydhVZdEJ}%S`Hn}X8vdZ0apr$YP>?J+a z;MnP93H2(C-k-0bB599wjEgEDj_V^YH6v@6v(ni=r1zIkRXEdYB5UR)1)V1r2FkV6 zsAasnm^RJjx<6Vx%qtIEPJ$#G#;gfyVV9$d>-R&d*7?Y}dYh7K*k=x{!F-BfQf$?_ zV(0$5v}ap3IO}GzxsRpgQ81lz8Wb`2a<0LDk>75AO|paLmo9YzfU%k4t7_= z9K(}yR+%TzYaK<@goSbdA0b_$FG zrcHMJ`O|PV~CH&GL^oAK-owL#pbHCd2DFKSW-%^0)St zpl|%_IP%=Isqc={#IBAQ4&R=oF@Zl}EMuGcm68A}P1hP7o4ilI#G}UJ(y)5LdGKaxs$lyn z9aMhOF|ml(l}DpwjWM8< z_TIT$-7|zb`JH1M${tSJ$r=^2gXBk2ohJd~)bbM3i-Z|BrF? za1GV82fEx{H#YQc*WA#(se|dO8%y+ua&;sijl{M_RNW?Nu8QFbs_k!Jj9mzJusx@p z+b&VATg0HGeR0d;+t~X2{V$Jt#o7gWQ~6z@xQ=17(;_f!H zKAFfN)8N;zR8sF~}2Cu~s6X5w6V3)vh>iagyW z?L>UKj~Ib%*P(}gf$fm3c;7(XtI4FJPI7`&b27}I!jc7aGvjcrRICBNphk9ank0|rFptssNcgNc#b@`6D~3Rji4Xb&e6!nX?=t&g*g&$ z$DQIYOsB4^tW!EzRQchTn3J3oxW%lqT_CcW5;yL!K)3D=pF=+{}SHR`m|&l|oLBWj5wa z`uWq!`NQ#m54>zNdHOZw60_6JyoqIs3(j}fw*9sVy=6fqtBmJ6;UBNmb_3!1T9rT; z@EWW_)`92rKqs4Yi~K#x(*e6UEOr;GZ2cQ|MvH%FsRzJsKeI?SopQy=Ojkbfn0PM> zY378BRZULKWQ@8dzltrq`|7d2)OK2EEY6lm{>o#Lr+H*!ZTCbbGE z9FdH7Qx}?ig-bR3{IH?zPs{v#ChQ4UEWYuEyTOxHgO{}~6KR_o0SfI-?hz_F;2EgP zW`ZFD&Z)SiDFFE??%JymwRG9=gns(TWM55yn}8M93S(vW-#;nx_W2bir|MH4w+i7h z1*MpfwpDpW*4WBMQ=Y%Ap&~x}z|U{+iOs0zl=zbP?!H=?JmPWjZ5Dk-n#&|dbhL~6 z6Nw8oJ}9Z2@qAp zWad|<%Rx=s3eeJ>_3&}<`W4{(J$_f2X|)An4Grt~|Jb0NOt_~TrsVYY5(~Dml$Y?c zL)-=>OCR_O^5BC-bi(k$*fP?O>0sZWO{go8uZ8{v;)~*vp#p_PjUY7f`sP|v#I1e z{1HaQ4vepY_zTkr>1>+XSj&wsWne4FAer& zHkF>CmA5svJZJB`B8|70C^3c{Kb%U2Z$=k@Kp*Zq9RDcG?tZvfsdZlQ@B)x;-Rue5 zy9xnHa4zb1^db6Fod*P(C1rX20Rkae{&6&tePV?U4h>(wd3H+f4;*%#R5&mB@pi$` zKR~Y)<`Wgpfxi3wgIwi*#wfqY+KY{7=v%z7`a0p1NFyEq&v9$6VXcY1HwovG*5NxC zS8Wtr5*4nk!DyxDwZc=THjgPmk~2d$#rmQGa%>%9O_&K>VWmYzq2}{uj>YNnZxm^e z$!WT!V6(Gorv=^A9l9Z=LH!7kV%RaNE7gtMy z7uQ(HXepe1>E!yCUMO3!qC*ptzizoF%IVWivs7=&dSGG`gf_x^J%Ig$^-Hz>d3CcO zRX*Rq9$t`}Tpo{o<6utur-xCsv82f2BE&dfaZq*k7~yKuSmGmHXdXlzs1QBVD)LN8LBADa^jsb|D3F-GdIxk`B5jP*RTcR?m|nT(9iWcHJ9&jLTozKy5Uvs*+O)7NhOAd6T7|MIwbn{65k8 zbli!TWUMS4HDlyqb&ce!|LdqZ*}L|jaR7O|_YEpIO%OjsbRnK_eX2{rZgC%9$E4lw zq{pmK#s!QlXTznlCTV zcpXr_rq=Zj2|MQZ3SC96x(_(h-$(%$)I`V%)y*Vmc0Er`bPkcwT*Zb#nNw3U@8*4> z$yXXSx+XoHHt91}i z0~o$@Jpb2c&XCO+b^EU?EupUlX{t15dNB;|f1W7O1x}R_&zXu%2hmmRG{cU6lExYp zoc_R#Z7qD~zY{jY)3!*NXK2 zF}Au5|BY$f=vM@#opFq3ajH8NRLoZG>fvz;ro>>IJ!Z8&{c(E3a-ZpE==Ct!DfxD< zS+8=iYV~ASb@3zaJjCeVW8$ zK34b_@~RrJ$bzvk!zlLH(!M^8C}wdrCMKqhS(caD=`@10;)|Rmbaz3Z!i&r=zG=7i zIhKrVu(!Oq3}~~*E0jPlWX_}ezlGHhLpy#mn6LB!>w7{&hBB6Z;sJPl?f(}LRsKzX zuHHvv@|5v`Xbc>F_dq>`>c!2c=dISOYEK5 zW*(md-CP=C5jz;+W8gRZ#9erY*$7i+<>}W@qok?Yek@Twz!Jj6&>}W>>XcXz$9qs> zXi;rw>Zn_*X*|Ppy?@WO>ex}-S1*HoSGN{lQ6^boOB#wcG?aYN<=RmCya8WxcFQKf zVnrpIC)741G79S3-ND8!zlz?E6t~UTdNzyV-zH+wt<|SW)vH>Y{^rlp9u@WG9N1PjVQjTGH>gAb*&Cn})D{Cs3lUm&@Hl_8d*jXWl#p{cS z&vB<)^W4TbgU|nZm~-ycGH_IUSJa~TcDkAAw}G9BEB6bSnb@FSn$(Wiys+r z#I|D5BP9)^#u-2Tu5#3DZjTqXr`VRIa@0+`-833z!M?H#+4LDq*Aj|26W+j;G6Gol zRGB}1gsNXn%psA_ws}ro%FV$l@YKN-i(ZhD$T@8+%lB0}j~-Wba&~PV8D9^JwesCk zli76{5S~f_cb*;*anmLFt$0VrxXT`-@1oCt(>>Y8F;hgk##@%pbbBqUuJbcMTR13s z0A8FHjLZPJDX8fUY?(4Rdm`lP`ezTgXJSJ83+^d54*sG{IyEZ}Ic+8}EKQ}>9d+R0 zyp-xP*qZ$FIh|4s+LFHKejF!ccujiZ+o|u%5vCdfWSt8pl#0K2g!%%6XES%MXWQ^CI5!YO=0{_Mu}+ z=)!x?6A)Q@&TygZ?&wK|v=M@DXc6)OfU^QO9WF8Fhc0Rn9AYz^uU(w>%|Z5)&b8t`DPFK*PZO1aKBW%MmYuuLwa0Qzr25r3AXb1U}`_;p8 zRUGrbhTP$M6AM^NH@D2^_8H{~J=9yOA4F|3Y>iV3FtUGK1c4NF{(}*eyv;(owBX_R z5D=*D+cmMUi-b-DGV2B<==*!kYGQ3aAoq4y{|$W|tz6){@kUi> zt@P3+lfg}=hlFx!KjdAL{;FTht)358SK7ZgJ|Cf3CAMnfP+O zq3c~gBw^xn@0h)rfjTMm8)7?AJvDZU{=P-Z#oOE}8WV;k7|2w@dH$mJe!WQSqfU|F zTmPL-4zFCHPi@jSaV6j^I9PJH8TpvY(b12x9LJj#h7AqBc4<+7+-|8@+6@52TS8)_ zf9U=$t=z!pfvp7*heeP0#c?Jrp?iG&HS@Y&yE$#NHOolH-=1FbxL)~l0QY#*gkrr2 zYK5+e4i5IewdHS+8$bjLdW5`ZlQ*Sc16=l^NF)0fvuTmM5-VQe?_Dht1N)Kt#>;Q+ zZcaBrjbv9WbDN4HpXiH-BY+7p0m>C>W;vWKAQjL>9Ui9s_C|WS`X2)hBnE57Z0|K| z=sBlo?7N4!a+7BXCGCHQbFE|ATwMDC17ce-Plwh4m z!nb&&<*&7c3GC{}z@SLm*-NZ|ZS!wt+e-?Cn#-5-^2e-T;i94sBrM;%s>ShLS6sYf zw*u#V*ESlr^D2_jk;?kJwRvo!v&}Yw%?gG5u~f(AxiFpc`5n5vJx()6w9ko|_?|bw zEK`&wcljoy~Ablw!5?DXJf`h5LwdCCPyx#VV=ik+Zl+JJN1 zH4g>~Jy28z=QkEzeYZ?$SzX}jdI<$Lm!H>Et)+aIO7$4flEG$Cp8*1J( z?u;|pfRf~t1%l8&o!svX=x=zZnQhMKYf9O8HW%GK-SH4l*SW_v-aW$cSvxNY zj5ejFSd7NE7ed9RpAkr0M%PU+vnDz#f?#a$>Izg%o z+hd_RX08R#c#irSxh7DQ!1S6*M?`EvJ=^N)s;-?~K{UIAk*jN&`G?)!hbw@b-F{S{ z1K{(=|A6JmXhX%^xS1V$1e3I~K%l+1S;99E@2R5ODW64WI0T?Y z)ryVJ{aXdja0Cz=9dowgg^vM4sQQy$QROUPzTAWi(TREN7-Z2Fv3#ZUs5A7Wvx(1G zq#eFJWZBJCSK?#b#t%E}&4p{ts;-(dOPLqiW?APYge{Fdm^uR71TzQTbd90UXWT0_ zb<0tF9C3;JmC~xgb%Huys{Ttxriuxqh^fZ}b5yaTL+Ru+*HVY2fQy!^(Kla*+wYS8 z0)!+GFSwX8RDb1N;+F}aVCw}s?y7AEz7lFO-l!2OcLP|u5>YyIDbBG80T{uy$zW1& z!kaLjL{+}T;`^OQw-8lhhZ?rToK(ibu4qP#ajC;L1DhCKz$!hUAvcsR*?!)zxHpOy z;ittdml=Tw)0gBxgnp#r;3d$o&zQmzX;kNrlmsF zt<8#=dx4P!bImAS1=k&JX#S*0YTluUX2s%VQ4M2OG;#@@o>yLck6q`7IJSNh{kqft zZ<7j)X6yb}C#v`C?wyfxuRK27oLLE4!&_r6Twz_-3ZHQ$sS)0UTe@`;9-f3_=uM}l zxAk2x@@a%{%nZ>;#({Nm(%c=YNnjT}EfIdCx2N{8o0eNXiS@2!+C-gOLaZTKL?#gNgCkeJ1y?3u1tw4lx!4&TG5`xuV(JQ4gR1Fq+#c<}NuhI+}+> zYO%=GGdG~zsEq*E81Tmkf|?4}<%Tz&j#OJ^nlR(92_|5-5^6?~ zMi%`R;Flkn9l0G{T^iB>OHNVY|EH3ttP3bK)Z60P-R~K0BPlJhlY?d@n3}$q37unOMg5qc?}~ejHxjIP3kv^xMu-J6 zLdoK!kHV&tj)cKVDNBG*K$AY|2)NYrGSBml*J#LKYyh}5Q~jF{_<#?G?hn5gvdaA6 z9B+`|Sdz{x1AjjJi(lHlAn%>%9Av$BX3aeJO!p*y(BUFKlJj@)ESZ0Uk4-T%dKE-(;DcB6NQ(>MHL+ zPP!y@X5((^`=CC6sS4cu4{lJOjF2$&h1Qk{!ewUeS0(+!8%ZvsDwodLT^DOysb>nh zmmi*SEO9-L;mmEdTlG@l)YMKNaeUH~TkspS9FEYFEBb&tuKObBHf1%wNPg{L z|BiU>fIys&m_x%5gIXi^U3Aswo7=t_>^pvQqOsxr>vt0c?mrD6Q?7PWuQt=C?DlCHZf+i$A8NUmxZS|B?j=*{BQ|bOLY?k zJZN7s@z|`QQ(&VvV>Dua=eo@m@UgM51MZiKRdX(CA6nR%ZaU=*Y`hTi`0_t^x=_*J zA2t5tl|38r{VDXN2C3$X?~5Hv_r4jkmnTfKz?#&i_vcOMC$hw4dsr$a)bkc=^yi(2 z#Tqh$u5*j^CDcq>*8-3`s_VJ#9N&K6;H00jlraTVA5C@hi^oruyhUTnZC>n(NCCSb zUjNC3PggjG7g$uNz}c@<`+wk1FSl>Bkh%WjuN;G3b@8zeOJ@`labz?;q+XMc`d|;( zn;UV0^%GPSoCW&Sx$dL*RSY`k#c|m8X4Y7~YPBEM1v|P`8ya=OHt#Q z;5B`gJ-&{OdG}ys{7Twc7Ze<(2wnu3utDBSO@cZ{=rvQ8<@pLbxNSReBhEyuHO=yy z=*o90GkG4%^J;j(Awam`tLK6L`NqmxuCU*y-6&Y>W!)ZHzyqDx#0jaY``^#31e_)7 z8tsn1p4b8YW@qY|zZt|{;EhD#16RwP!X7D)&~K_pHI(>%>P85`uONa8O4RG6T$$U zjnlj-kBp{8t^|FH{4JeBJAUUn|2u{I4>>3JYq7vSqDRwNpMam60*D1&iELI`{}FTG zm@J-HIxLi6E+Ikbsie&W5GpSjfSD{_$;ZBY5QrnR7}+k}HTPSLuiqO|_a1!0I02-m z-&OXo0h=pr5p=GLxm8u_iO{Xk`omw5n4NB5XlXjz4%jXgmGPqW_a-L@u}tPFaLk*C zNP+t29CZuVC9L*nm`GH#t(2EVT_1;WNZ0pUo$eX}tKp36zNQuTER2@HR!E`s4YlY- z>9F#E5NA+vnEaiPr&jF1g-4t)P|H83G!;I{E>Ic}3dzpF0pG@e=yS^O<}Z}Atyk%P zyjD2d;uOYlH)JlS<>L!rTh&vP)$iAyqMivq>k=Z;Fe|*F0d_}u)w})aHF=f|3P}!; a&og>*QM^k1t^^znQhlPORQA{^Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1>#9WK~#8N?VDd{ z6h|D#zpa|Sr1%gJ(`wIN4Q&Yt1QHq&k(_@}D%98qY17zBd?+EpNrY0wKUA;2DLIIZ zJ{Yj3*VaPsBBrFM2{r~IX$`q>q#+^65xt8ck<#=fe`Gtq*}2Q!dAECsn|QkOf#v2m zv$H$1-<{w5=I-Vdg#n2rcvXB#EPxc-LP)VKgcRFCNU<%16x%{bu`R@+*4bA*KjG%> zb_@*-V{U$4x4uL>Fe)U~PaBK``PoKhuH{TGYPm_|^pC9@XyLN2HXP@pDIg)hahaa!u z_glB|{pCv{=V=V*Y;xT+qNUL34e3KVkzUC#6r1K~a@~^jhjb#nl3^$|1!FWvOVS_G ziS$Z_q1Y64%V6xqfOOJZCCPQRg^*%f2r0IOFiq^KmcuA2b;KS=eVC%c@NKvb?&5i+ z2T!8)0k0xiVOC&d2l2}(7n%oWxivf3ffHXfVBiTiljC>Cy*zI8i6VW%Xg=zI+b>MI zPUNLqVfrpOaHJ*7(|j<*v_owt^(}JFLZ4IZ>*82`vHu&wKtQA;1Oxb6=jqc!Dyjw= zJKND6IU0brh=40j58S1P(L5o{C6KkJ9^L24ktOV@mT%#*pXYt6+mp?5*gdKTaokJ& zbs|qa%~Y0O?98{Y$tKbfvUBlXq}OrK2&fu%qBXw5`A#_VMfxQmG#WtZruD*NOaw84 z9N5Pl)9-y>lrgw82b zoHs(kR+PybHA-QvSDEBiNb%K{DRsTvP?X*}O_^FZS{8Rde|?!!SbtwN7?U@=il3*9 z+ZEde2HjWN&X{(n_tN%3dE`}`C3WI{@#P`K->W%S2R*&a8`&9|cztc$k(nV18LKjQ zUPi(Zy$Jk&psCo26MUXi$=Y)Wm0h=R*Jw@^bt|LOK>;d{0qxyv)UXkJ*;9_jngUTq z*DX(R?o8m3*tFAFA<|2vb97v!vXPg+(BGwEz8eqw(Af4^7_pFxj7G@`S&vP{0Yv+l zfT^)Tz;26k^TzcEUboAQ-5U3}`)ojcsN+F&7Tb{g*I+%yG1`?2?G*g1nmTCCN=(+q z9AG$@p!3ELo7b&SBjA{WM)P_bO1pvxiE|f6qL(d<;pD$bqy!00?Q7k33%gAa+*Gk= z{Z~COLf_`6MC0*AHY$}0wf+BsZ6!_QVjgEVYdTKS2S(*UxTYlq!$ef z;w~&)i@&5MV4m1df@rSyMMuWS#fIp*G;7;wG+p+vc^26FwTGoo!;v)HE`XE0&5ib0 zcUyGDCgxfyKaaP0Br9BT+YYp9*09N8b-hlERBNsqT|Z}I?>P^S4-41HuM6SU)wx|S z8s~0T+vbj~&oF1Rmj{|_(RU6h^-mweza`Pl$dwo|JNz4(+6FNrY~nTBhp60{gGiVb zVoQvHcbsr+5E&BF(bLN`u_ft&o?hlhVp|9)wuO*lTL>w(WdQyMR$~GBY#llq00000 LNkvXXu0mjfuJQ?& literal 0 HcmV?d00001 diff --git a/docs/media/screenshots/cr-diffs-tab.png b/docs/media/screenshots/cr-diffs-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..4134a86e9710070566fc5da26e8f09f9906f7675 GIT binary patch literal 65258 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

F>`cA?RA`K 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 G&;K8rJR|G? literal 0 HcmV?d00001 diff --git a/docs/media/screenshots/cr-job-log.png b/docs/media/screenshots/cr-job-log.png new file mode 100644 index 0000000000000000000000000000000000000000..6df550899b2d5b47e7e0566bdc659864a48042f4 GIT binary patch literal 20845 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 C_p9~* literal 0 HcmV?d00001 diff --git a/docs/media/screenshots/cr-schedule-button.png b/docs/media/screenshots/cr-schedule-button.png new file mode 100644 index 0000000000000000000000000000000000000000..7baf7cc18c89892aee2df44a9eb17787ed66a0bb GIT binary patch literal 1214 zcmV;v1VQ_WP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1XD>wK~#8N?VC?% z6G0Tlzm14`FcDDebzM1!C!o*PomPC=# z@0J8%MM)r5lmuc$NgzuG`l+W1x1TG_e*^xOb*7~$)^G}RZw5~7cW~~IqjPJC73JMm zVCY5}Uw#5Vf9p(3TiCb`*t!vzIh(?+EjlwmtSEg~%6Lm*ES+OF1$lY7Hi8&qinuJ9 zA(qY}HWph)F^VEF#Ja4`BQ_RWM=^>bLEW-4m~3qn#jdhe4wJ2oqF7cJD@p=sizvg# zQz(qqZidc}rttW9{3gj_#<)*OpId%VIdjaxU=Py<;9fD@R5$Z`e<|dPT>9Ntc%}0# zVHT&tbE7p;4mwyEa*)xN^_ZP0r2t#1zjM*;i$EFt}-k54v3!|A(|ilothsd$9@ltm_#$2xR#-H|<@z zJX1Ily#CKA6MB>F=?;w1+~t#`0oV-v`D7-R^_5gC6&LO%Uzl z9Q$?H!MHANln#jEVB&;>TRNT5xpkqt>e3H$Qbt<9uz4E2NW)eJa>TXGK2*5weqz2X z@6rsyayddHbLDrZQkS3K0DXJeHihZqqT)fW8l=Y0YG=vj_h-*seZywXSaV?&H@=F0{Skbp~>*aKuJnz+0S1#4D>Fb=xeW`?uS zzt19PsH`NjO)geCB1-X-pA_bG-ttj+^g$bsl<1P0dARHHL*M7Bd#WT^!hTfvLBT@q zilbT<9F4_wi%BDaJ8SbZBc%m@rCg44<*Xw#lxYsE}0_xxnx&uC_ zFHjHTO(oW_e!kwKz=tlRxQ%U_>gvVC(ZGFk`@;LJBJZDJKm53mqf3p8Ib-i5eQc4| zHjmt~7f!RYYxQ*;e;5RBGAZc94Fucs76blcALyAF-*}R%~n{_JFsK{VPQKX!)-YmenKHeOXoyD@p>fq9hP2 cO5y4iqe~O0xC!^QbH%7C_R9* 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 VBnDSBTz#4W>MGjGm5Mg+{{sw;(bE6` diff --git a/docs/media/screenshots/render-temp-substitute.png b/docs/media/screenshots/render-temp-substitute.png new file mode 100644 index 0000000000000000000000000000000000000000..d3ed33fa10da2669e123940035c7a0dec0ecdd1e GIT binary patch literal 8971 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 Y#RFB?o{?FEzr28)l(J-{xJmH;0W)GD-2eap literal 0 HcmV?d00001 diff --git a/docs/media/screenshots/substitute.png b/docs/media/screenshots/substitute.png new file mode 100644 index 0000000000000000000000000000000000000000..0a9a228f3a65b8435e3bf48b4de32fbb5d362652 GIT binary patch literal 15868 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 BxEcTe literal 0 HcmV?d00001 diff --git a/mkdocs.yml b/mkdocs.yml index 9f4a0d9..a6a6e94 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,7 +6,8 @@ repo_name: miaow2/netbox-config-diff nav: - Home: index.md - User Guide: - - Quick Start Guide: usage.md + - Collecting diffs: colliecting-diffs.md + - Configuration management: configuratiom-management.md - Integration with secrets: secrets.md - Screenshots: screenshots.md - Contributing: contributing.md diff --git a/netbox_config_diff/api/serializers.py b/netbox_config_diff/api/serializers.py index 26195cd..bf38323 100644 --- a/netbox_config_diff/api/serializers.py +++ b/netbox_config_diff/api/serializers.py @@ -1,10 +1,15 @@ from dcim.api.serializers import NestedDeviceSerializer, NestedPlatformSerializer -from netbox.api.fields import ChoiceField +from dcim.models import Device +from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from rest_framework import serializers +from rest_framework.serializers import ValidationError +from users.api.nested_serializers import NestedUserSerializer +from utilities.utils import local_now -from netbox_config_diff.choices import ConfigComplianceStatusChoices -from netbox_config_diff.models import ConfigCompliance, PlatformSetting +from netbox_config_diff.choices import ConfigComplianceStatusChoices, ConfigurationRequestStatusChoices +from netbox_config_diff.constants import ACCEPTABLE_DRIVERS +from netbox_config_diff.models import ConfigCompliance, ConfigurationRequest, PlatformSetting, Substitute class ConfigComplianceSerializer(NetBoxModelSerializer): @@ -45,6 +50,113 @@ class Meta: "driver", "command", "exclude_regex", + "description", + "tags", + "custom_fields", + "created", + "last_updated", + ) + + +class NestedPlatformSettingSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_config_diff-api:platformsetting-detail") + + class Meta: + model = PlatformSetting + fields = ("id", "url", "display", "driver") + + +class ConfigurationRequestSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:netbox_config_diff-api:configurationrequest-detail" + ) + devices = SerializedPKRelatedField( + queryset=Device.objects.all(), + serializer=NestedDeviceSerializer, + many=True, + ) + status = ChoiceField(choices=ConfigurationRequestStatusChoices, read_only=True) + created_by = NestedUserSerializer(read_only=True) + approved_by = NestedUserSerializer(read_only=True) + scheduled_by = NestedUserSerializer(read_only=True) + + class Meta: + model = ConfigurationRequest + fields = ( + "id", + "url", + "display", + "devices", + "created_by", + "approved_by", + "scheduled_by", + "scheduled", + "started", + "completed", + "status", + "description", + "comments", + "tags", + "custom_fields", + "created", + "last_updated", + ) + read_only_fields = ["started", "scheduled", "completed"] + + def validate(self, data): + if data.get("devices"): + if devices := data["devices"].filter(platform__platform_setting__isnull=True): + platforms = {d.platform.name for d in devices} + raise ValidationError({"devices": f"Assign PlatformSetting for platform(s): {', '.join(platforms)}"}) + + if drivers := { + device.platform.platform_setting.driver + for device in data["devices"] + if device.platform.platform_setting.driver not in ACCEPTABLE_DRIVERS + }: + raise ValidationError({"devices": f"Driver(s) not supported: {', '.join(drivers)}"}) + + return super().validate(data) + + +class ConfigurationRequestRWSerializer(ConfigurationRequestSerializer): + created_by = NestedUserSerializer() + + +class NestedConfigurationRequestSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:netbox_config_diff-api:configurationrequest-detail" + ) + status = ChoiceField(choices=ConfigurationRequestStatusChoices) + + class Meta: + model = ConfigurationRequest + fields = ("id", "url", "display", "status") + + +class ConfigurationRequestScheduleSerializer(serializers.Serializer): + schedule_at = serializers.DateTimeField() + + def validate_schedule_at(self, value): + if value < local_now(): + raise serializers.ValidationError("Scheduled time must be in the future.") + return value + + +class SubstituteSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_config_diff-api:substitute-detail") + platform_setting = NestedPlatformSettingSerializer() + + class Meta: + model = Substitute + fields = ( + "id", + "url", + "display", + "platform_setting", + "name", + "description", + "regexp", "tags", "custom_fields", "created", diff --git a/netbox_config_diff/api/urls.py b/netbox_config_diff/api/urls.py index d79a2b3..9a1863d 100644 --- a/netbox_config_diff/api/urls.py +++ b/netbox_config_diff/api/urls.py @@ -7,5 +7,7 @@ router = NetBoxRouter() router.register("config-compliances", views.ConfigComplianceViewSet) router.register("platform-settings", views.PlatformSettingViewSet) +router.register("configuration-requests", views.ConfigurationRequestViewSet) +router.register("substitutes", views.SubstituteViewSet) urlpatterns = router.urls diff --git a/netbox_config_diff/api/views.py b/netbox_config_diff/api/views.py index cbb2df9..38f9674 100644 --- a/netbox_config_diff/api/views.py +++ b/netbox_config_diff/api/views.py @@ -1,9 +1,35 @@ +from core.api.serializers import JobSerializer +from core.choices import JobStatusChoices +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from django_rq.queues import get_connection, get_queue from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet +from netbox.constants import RQ_QUEUE_DEFAULT +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rq import Worker +from rq.exceptions import InvalidJobOperation +from utilities.exceptions import RQWorkerNotRunningException -from netbox_config_diff.filtersets import ConfigComplianceFilterSet, PlatformSettingFilterSet -from netbox_config_diff.models import ConfigCompliance, PlatformSetting +from netbox_config_diff.choices import ConfigurationRequestStatusChoices +from netbox_config_diff.filtersets import ( + ConfigComplianceFilterSet, + ConfigurationRequestFilterSet, + PlatformSettingFilterSet, + SubstituteFilterSet, +) +from netbox_config_diff.models import ConfigCompliance, ConfigurationRequest, PlatformSetting, Substitute -from .serializers import ConfigComplianceSerializer, PlatformSettingSerializer +from .serializers import ( + ConfigComplianceSerializer, + ConfigurationRequestRWSerializer, + ConfigurationRequestScheduleSerializer, + ConfigurationRequestSerializer, + PlatformSettingSerializer, + SubstituteSerializer, +) class ConfigComplianceViewSet(NetBoxReadOnlyModelViewSet): @@ -16,3 +42,141 @@ class PlatformSettingViewSet(NetBoxModelViewSet): queryset = PlatformSetting.objects.prefetch_related("platform", "tags") serializer_class = PlatformSettingSerializer filterset_class = PlatformSettingFilterSet + + +class ConfigurationRequestViewSet(NetBoxModelViewSet): + queryset = ConfigurationRequest.objects.prefetch_related( + "devices", "created_by", "approved_by", "scheduled_by", "tags" + ) + serializer_class = ConfigurationRequestSerializer + filterset_class = ConfigurationRequestFilterSet + + def create(self, request, *args, **kwargs): + serializer = ConfigurationRequestRWSerializer( + data=request.data | {"created_by": request.user.pk}, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + @action(detail=True, methods=["post"]) + def approve(self, request, pk): + if not request.user.has_perm("netbox_config_diff.approve_configurationrequest"): + raise PermissionDenied( + "Approving configuration requests requires the " + "netbox_config_diff.approve_configurationrequest permission." + ) + + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + return Response( + {"description": f"{obj} is finished, you can't change it."}, status=status.HTTP_400_BAD_REQUEST + ) + + if obj.approved_by: + obj.approved_by = None + obj.status = ConfigurationRequestStatusChoices.CREATED + if obj.scheduled: + obj.scheduled = None + obj.scheduled_by = None + else: + obj.approved_by = get_user_model().objects.filter(pk=request.user.pk).first() + obj.status = ConfigurationRequestStatusChoices.APPROVED + obj.save() + + serializer = ConfigurationRequestSerializer(obj, context={"request": request}) + + return Response(serializer.data) + + @action(detail=True, methods=["post"]) + def schedule(self, request, pk): + if not request.user.has_perm("netbox_config_diff.approve_configurationrequest"): + raise PermissionDenied( + "Scheduling configuration requests requires the" + "netbox_config_diff.approve_configurationrequest permission." + ) + + if not Worker.count(get_connection(RQ_QUEUE_DEFAULT)): + raise RQWorkerNotRunningException() + + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + return Response( + {"description": f"{obj} is finished, you can't change it."}, status=status.HTTP_400_BAD_REQUEST + ) + if obj.scheduled: + return Response({"description": f"{obj} already scheduled"}, status=status.HTTP_400_BAD_REQUEST) + if obj.approved_by is None: + return Response({"description": f"Approve {obj} before schedule."}, status=status.HTTP_400_BAD_REQUEST) + + input_serializer = ConfigurationRequestScheduleSerializer(data=request.data) + if input_serializer.is_valid(): + obj.scheduled = input_serializer.validated_data.get("schedule_at") + obj.status = ConfigurationRequestStatusChoices.SCHEDULED + obj.scheduled_by = get_user_model().objects.filter(pk=request.user.pk).first() + obj.save() + serializer = ConfigurationRequestSerializer(obj, context={"request": request}) + obj.enqueue_job(request, "push_configs", schedule_at=input_serializer.validated_data.get("schedule_at")) + return Response(serializer.data) + + return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"]) + def unschedule(self, request, pk): + if not request.user.has_perm("netbox_config_diff.approve_configurationrequest"): + raise PermissionDenied( + "Scheduling configuration requests requires the" + "netbox_config_diff.approve_configurationrequest permission." + ) + + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + return Response( + {"description": f"{obj} is finished, you can't change it."}, status=status.HTTP_400_BAD_REQUEST + ) + if obj.approved_by is None: + return Response({"description": f"Approve {obj} before unschedule."}, status=status.HTTP_400_BAD_REQUEST) + + if obj.scheduled_by: + obj.scheduled = None + obj.scheduled_by = None + obj.status = ConfigurationRequestStatusChoices.APPROVED + obj.save() + queue = get_queue(RQ_QUEUE_DEFAULT) + for result in obj.jobs.filter(name__contains="push_configs", status=JobStatusChoices.STATUS_SCHEDULED): + result.delete() + if job := queue.fetch_job(str(result.job_id)): + try: + job.cancel() + except InvalidJobOperation: + pass + + serializer = ConfigurationRequestSerializer(obj, context={"request": request}) + return Response(serializer.data) + + @action(detail=True, methods=["post"], url_path="collect-diffs") + def collect_diffs(self, request, pk): + if not request.user.has_perm("netbox_config_diff.change_configurationrequest"): + raise PermissionDenied( + "Collecting diffs requires the netbox_config_diff.change_configurationrequest permission." + ) + + if not Worker.count(get_connection(RQ_QUEUE_DEFAULT)): + raise RQWorkerNotRunningException() + + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + return Response( + {"description": f"{obj} is finished, you can't change it."}, status=status.HTTP_400_BAD_REQUEST + ) + + job = obj.enqueue_job(request, "collect_diffs") + serializer = JobSerializer(job, context={"request": request}) + return Response(serializer.data) + + +class SubstituteViewSet(NetBoxModelViewSet): + queryset = Substitute.objects.prefetch_related("platform_setting", "tags") + serializer_class = SubstituteSerializer + filterset_class = SubstituteFilterSet diff --git a/netbox_config_diff/choices.py b/netbox_config_diff/choices.py index e1e443b..3fb5714 100644 --- a/netbox_config_diff/choices.py +++ b/netbox_config_diff/choices.py @@ -6,10 +6,38 @@ class ConfigComplianceStatusChoices(ChoiceSet): PENDING = "pending" FAILED = "failed" ERRORED = "errored" + DIFF = "diff" CHOICES = ( (COMPLIANT, "Compliant", "green"), (PENDING, "Pending", "cyan"), (FAILED, "Failed", "red"), (ERRORED, "Errored", "red"), + (DIFF, "Diff", "teal"), + ) + + +class ConfigurationRequestStatusChoices(ChoiceSet): + CREATED = "created" + APPROVED = "approved" + SCHEDULED = "scheduled" + RUNNING = "running" + FAILED = "failed" + ERRORED = "errored" + COMPLETED = "completed" + + CHOICES = ( + (CREATED, "Created", "cyan"), + (APPROVED, "Approved", "indigo"), + (SCHEDULED, "Scheduled", "teal"), + (RUNNING, "Running", "blue"), + (FAILED, "Failed", "red"), + (ERRORED, "Errored", "red"), + (COMPLETED, "Completed", "green"), + ) + + FINISHED_STATE_CHOICES = ( + FAILED, + ERRORED, + COMPLETED, ) diff --git a/netbox_config_diff/compliance/base.py b/netbox_config_diff/compliance/base.py index bf8c64d..f902019 100644 --- a/netbox_config_diff/compliance/base.py +++ b/netbox_config_diff/compliance/base.py @@ -1,4 +1,5 @@ import asyncio +import re import traceback from typing import Iterable, Iterator @@ -19,17 +20,13 @@ from .secrets import SecretsMixin from .utils import PLATFORM_MAPPING, exclude_lines, get_unified_diff -try: - from extras.plugins import get_installed_plugins, get_plugin_config -except ImportError: - from extras.plugins.utils import get_installed_plugins, get_plugin_config - class ConfigDiffBase(SecretsMixin): site = ObjectVar( model=Site, required=False, - description="Run compliance for devices (with status Active, primary IP and platform) in this site", + description="Run compliance for devices (with status Active, " + "primary IP, platform and config template) in this site", ) devices = MultiObjectVar( model=Device, @@ -38,6 +35,7 @@ class ConfigDiffBase(SecretsMixin): "status": DeviceStatusChoices.STATUS_ACTIVE, "has_primary_ip": True, "platform_id__n": "null", + "config_template_id__n": "null", }, description="If you define devices in this field, the Site field will be ignored", ) @@ -70,6 +68,7 @@ def validate_data(self, data: dict) -> Iterable[Device]: .filter( status=DeviceStatusChoices.STATUS_ACTIVE, platform__platform_setting__isnull=False, + config_template__isnull=False, ) .exclude( Q(primary_ip4__isnull=True) & Q(primary_ip6__isnull=True), @@ -80,6 +79,7 @@ def validate_data(self, data: dict) -> Iterable[Device]: site=data["site"], status=DeviceStatusChoices.STATUS_ACTIVE, platform__platform_setting__isnull=False, + config_template__isnull=False, ).exclude( Q(primary_ip4__isnull=True) & Q(primary_ip6__isnull=True), ) @@ -116,13 +116,10 @@ def log_results(self, device: DeviceDataClass) -> None: self.log_success(f"{device.name} no diff") def get_devices_with_rendered_configs(self, devices: Iterable[Device]) -> Iterator[DeviceDataClass]: - if "netbox_secrets" in get_installed_plugins(): - self.get_master_key() - self.user_role = get_plugin_config("netbox_config_diff", "USER_SECRET_ROLE") - self.password_role = get_plugin_config("netbox_config_diff", "PASSWORD_SECRET_ROLE") + self.check_netbox_secrets() + self.substitutes = {} for device in devices: username, password = self.get_credentials(device) - self.log_info(f"{username} {password}") rendered_config = None error = None context_data = device.get_config_context() @@ -130,16 +127,22 @@ def get_devices_with_rendered_configs(self, devices: Iterable[Device]) -> Iterat if config_template := device.get_config_template(): try: rendered_config = config_template.render(context=context_data) + rendered_config = re.sub(r"{{.+}}\s+", "", rendered_config) except TemplateError: error = traceback.format_exc() else: error = "Define config template for device" + platform = device.platform.platform_setting.driver + if not self.substitutes.get(platform): + if substitutes := device.platform.platform_setting.substitutes.all(): + self.substitutes[platform] = [s.regexp for s in substitutes] + yield DeviceDataClass( pk=device.pk, name=device.name, mgmt_ip=str(device.primary_ip.address.ip), - platform=device.platform.platform_setting.driver, + platform=platform, command=device.platform.platform_setting.command, exclude_regex=device.platform.platform_setting.exclude_regex, username=username, @@ -169,7 +172,9 @@ def get_diff(self, devices: list[DeviceDataClass]) -> None: for device in devices: if device.error is not None: continue - cleaned_config = exclude_lines(device.actual_config, device.exclude_regex) + cleaned_config = exclude_lines(device.actual_config, device.exclude_regex.splitlines()) + if self.substitutes.get(device.platform): + cleaned_config = exclude_lines(cleaned_config, self.substitutes[device.platform]) device.diff = get_unified_diff(device.rendered_config, cleaned_config, device.name) if device.platform in PLATFORM_MAPPING: device.missing = diff_network_config( @@ -178,16 +183,3 @@ def get_diff(self, devices: list[DeviceDataClass]) -> None: device.extra = diff_network_config( cleaned_config, device.rendered_config, PLATFORM_MAPPING[device.platform] ) - - def get_credentials(self, device: Device) -> tuple[str, str]: - username = get_plugin_config("netbox_config_diff", "USERNAME") - password = get_plugin_config("netbox_config_diff", "PASSWORD") - if "netbox_secrets" in get_installed_plugins(): - if secret := device.secrets.filter(role__name=self.user_role).first(): - if value := self.get_secret(secret): - username = value - if secret := device.secrets.filter(role__name=self.password_role).first(): - if value := self.get_secret(secret): - password = value - - return username, password diff --git a/netbox_config_diff/compliance/models.py b/netbox_config_diff/compliance/models.py index 4f135b9..f16077e 100644 --- a/netbox_config_diff/compliance/models.py +++ b/netbox_config_diff/compliance/models.py @@ -12,9 +12,9 @@ class DeviceDataClass: name: str mgmt_ip: str platform: str - command: str username: str password: str + command: str | None = None exclude_regex: str | None = None rendered_config: str | None = None actual_config: str | None = None @@ -22,12 +22,16 @@ class DeviceDataClass: missing: str | None = None extra: str | None = None error: str | None = None + config_error: str | None = None auth_strict_key: bool = False transport: str = "asyncssh" def __str__(self): return self.name + def __hash__(self): + return hash(self.name) + def to_scrapli(self): return { "host": self.mgmt_ip, @@ -78,7 +82,7 @@ def to_db(self): if self.error: status = ConfigComplianceStatusChoices.ERRORED elif self.diff: - status = ConfigComplianceStatusChoices.FAILED + status = ConfigComplianceStatusChoices.DIFF else: status = ConfigComplianceStatusChoices.COMPLIANT diff --git a/netbox_config_diff/compliance/secrets.py b/netbox_config_diff/compliance/secrets.py index 85af298..998311c 100644 --- a/netbox_config_diff/compliance/secrets.py +++ b/netbox_config_diff/compliance/secrets.py @@ -1,11 +1,18 @@ import base64 from typing import TYPE_CHECKING +from dcim.models import Device +from extras.plugins import get_installed_plugins, get_plugin_config + if TYPE_CHECKING: from netbox_secrets.models import Secret class SecretsMixin: + username: str + password: str + netbox_secrets_installed: bool = False + def get_session_key(self) -> None: if "netbox_secrets_sessionid" in self.request.COOKIES: self.session_key = base64.b64decode(self.request.COOKIES['netbox_secrets_sessionid']) @@ -26,7 +33,10 @@ def get_master_key(self) -> None: sk = SessionKey.objects.get(userkey__user=self.request.user) self.master_key = sk.get_master_key(self.session_key) except Exception as e: - self.log_failure(f"Can't fetch master_key: {str(e)}") + if getattr(self, "logger"): + self.logger.log_failure(f"Can't fetch master_key: {str(e)}") + else: + self.log_failure(f"Can't fetch master_key: {str(e)}") def get_secret(self, secret: "Secret") -> str | None: try: @@ -34,3 +44,25 @@ def get_secret(self, secret: "Secret") -> str | None: except Exception: return None return secret.plaintext + + def get_credentials(self, device: Device) -> tuple[str, str]: + if self.netbox_secrets_installed: + if secret := device.secrets.filter(role__name=self.user_role).first(): + if value := self.get_secret(secret): + username = value + if secret := device.secrets.filter(role__name=self.password_role).first(): + if value := self.get_secret(secret): + password = value + return username, password + + return self.username, self.password + + def check_netbox_secrets(self) -> None: + if "netbox_secrets" in get_installed_plugins(): + self.get_master_key() + self.user_role = get_plugin_config("netbox_config_diff", "USER_SECRET_ROLE") + self.password_role = get_plugin_config("netbox_config_diff", "PASSWORD_SECRET_ROLE") + self.netbox_secrets_installed = True + else: + self.username = get_plugin_config("netbox_config_diff", "USERNAME") + self.password = get_plugin_config("netbox_config_diff", "PASSWORD") diff --git a/netbox_config_diff/compliance/utils.py b/netbox_config_diff/compliance/utils.py index f714780..d3729d5 100644 --- a/netbox_config_diff/compliance/utils.py +++ b/netbox_config_diff/compliance/utils.py @@ -28,7 +28,7 @@ def get_unified_diff(rendered_config: str, actual_config: str, device: str) -> s return "\n".join(diff).strip() -def exclude_lines(text: str, regex: str) -> str: - for item in regex.splitlines(): - text = re.sub(item, "", text, flags=re.MULTILINE) +def exclude_lines(text: str, regexs: list) -> str: + for item in regexs: + text = re.sub(item, "", text, flags=re.I | re.M) return text.strip() diff --git a/netbox_config_diff/configurator/__init__.py b/netbox_config_diff/configurator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_config_diff/configurator/base.py b/netbox_config_diff/configurator/base.py new file mode 100644 index 0000000..1e546a7 --- /dev/null +++ b/netbox_config_diff/configurator/base.py @@ -0,0 +1,211 @@ +import asyncio +import re +import traceback +from contextlib import asynccontextmanager +from typing import AsyncIterator, Iterable + +from asgiref.sync import sync_to_async +from dcim.models import Device +from jinja2.exceptions import TemplateError +from netutils.config.compliance import diff_network_config +from scrapli import AsyncScrapli +from scrapli_cfg.platform.base.async_platform import AsyncScrapliCfgPlatform +from scrapli_cfg.response import ScrapliCfgResponse +from utilities.utils import NetBoxFakeRequest + +from netbox_config_diff.compliance.models import DeviceDataClass +from netbox_config_diff.compliance.secrets import SecretsMixin +from netbox_config_diff.compliance.utils import PLATFORM_MAPPING, get_unified_diff +from netbox_config_diff.configurator.exceptions import DeviceConfigurationError, DeviceValidationError +from netbox_config_diff.configurator.utils import CustomLogger +from netbox_config_diff.constants import ACCEPTABLE_DRIVERS +from netbox_config_diff.models import ConfigCompliance + +from .factory import AsyncScrapliCfg + + +class Configurator(SecretsMixin): + def __init__(self, devices: Iterable[Device], request: NetBoxFakeRequest) -> None: + self.devices = devices + self.request = request + self.unprocessed_devices = set() + self.processed_devices = set() + self.failed_devices = set() + self.substitutes: dict[str, list] = {} + self.logger = CustomLogger() + self.connections: dict[str, AsyncScrapliCfgPlatform] = {} + + def validate_devices(self) -> None: + self.check_netbox_secrets() + for device in self.devices: + username, password = self.get_credentials(device) + if device.platform.platform_setting is None: + self.logger.log_warning(f"Skipping {device}, add PlatformSetting for {device.platform} platform") + elif device.platform.platform_setting.driver not in ACCEPTABLE_DRIVERS: + self.logger.log_warning( + f"Skipping {device}, driver {device.platform.platform_setting.driver} is not supported" + ) + else: + rendered_config = None + error = None + context_data = device.get_config_context() + context_data.update({"device": device}) + if config_template := device.get_config_template(): + try: + rendered_config = config_template.render(context=context_data) + except TemplateError: + error = traceback.format_exc() + self.logger.log_failure(error) + else: + error = "Define config template for device" + self.logger.log_failure(error) + + d = DeviceDataClass( + pk=device.pk, + name=device.name, + mgmt_ip=str(device.primary_ip.address.ip), + platform=device.platform.platform_setting.driver, + username=username, + password=password, + rendered_config=rendered_config, + error=error, + ) + if error: + self.failed_devices.add(d) + else: + self.connections[d.name] = AsyncScrapliCfg( + conn=AsyncScrapli(**d.to_scrapli()), dedicated_connection=True + ) + self.unprocessed_devices.add(d) + if not self.substitutes.get(d.platform): + if substitutes := device.platform.platform_setting.substitutes.all(): + self.substitutes[d.platform] = [ + (s.name, re.compile(s.regexp, flags=re.I | re.M)) for s in substitutes + ] + + if self.failed_devices: + raise DeviceValidationError( + "Error in validating devices", devices=", ".join(f"{d.name}: {d.error}" for d in self.failed_devices) + ) + + self.logger.log_info(f"Working with {', '.join(d.name for d in self.unprocessed_devices)}") + + @asynccontextmanager + async def connection(self, only_processed_devices: bool = False) -> AsyncIterator[None]: + if only_processed_devices: + connections = [self.connections[device.name] for device in self.processed_devices] + else: + connections = self.connections.values() + try: + await asyncio.gather(*(conn.__aenter__() for conn in connections)) + yield + finally: + await asyncio.gather(*(conn.__aexit__(None, None, None) for conn in connections)) + + def collect_diffs(self) -> None: + loop = asyncio.get_event_loop() + loop.run_until_complete(self._collect_diffs()) + + @sync_to_async + def update_diffs(self) -> None: + for device in self.unprocessed_devices: + try: + obj = ConfigCompliance.objects.get(device_id=device.pk) + obj.snapshot() + obj.update(**device.to_db()) + obj.save() + except ConfigCompliance.DoesNotExist: + ConfigCompliance.objects.create(**device.to_db()) + + async def _collect_diffs(self) -> None: + async with self.connection(): + await asyncio.gather(*(self._collect_one_diff(d) for d in self.unprocessed_devices)) + await self.update_diffs() + + async def _collect_one_diff(self, device: DeviceDataClass) -> None: + self.logger.log_info(f"Collecting diff on {device.name}") + try: + conn = self.connections[device.name] + if substitutes := self.substitutes.get(device.platform): + actual_config, rendered_config = await conn.render_substituted_config( + config_template=device.rendered_config, substitutes=substitutes + ) + device.rendered_config = rendered_config + else: + actual_config = await conn.get_config() + device.actual_config = conn.clean_config(actual_config.result) + + device.diff = get_unified_diff(device.rendered_config, device.actual_config, device.name) + self.logger.add_diff(device.name, diff=device.diff) + device.missing = diff_network_config( + device.rendered_config, device.actual_config, PLATFORM_MAPPING[device.platform] + ) + device.extra = diff_network_config( + device.actual_config, device.rendered_config, PLATFORM_MAPPING[device.platform] + ) + self.logger.log_info(f"Got diff from {device.name}") + except Exception: + error = traceback.format_exc() + device.error = error + self.logger.log_failure(error) + self.logger.add_diff(device.name, error=error) + + def push_configs(self): + loop = asyncio.get_event_loop() + loop.run_until_complete(self._push_configs()) + + async def _push_configs(self) -> None: + async with self.connection(): + await asyncio.gather(*(self._collect_one_diff(d) for d in self.unprocessed_devices)) + await self.update_diffs() + await asyncio.gather(*(self._push_one_config(d) for d in self.unprocessed_devices)) + if self.failed_devices: + self.logger.log_warning(f"Failed device(s): {', '.join(d.name for d in self.failed_devices)}") + async with self.connection(only_processed_devices=True): + await self.rollback() + raise DeviceConfigurationError( + "Error in configuring devices", + devices=", ".join(f"{d.name}: {d.config_error}" for d in self.failed_devices), + ) + + async def _push_one_config(self, device: DeviceDataClass) -> None: + self.logger.log_info(f"Push config to {device.name}") + try: + conn = self.connections[device.name] + response = await conn.load_config(config=device.rendered_config, replace=True) + if response.failed: + await self.abort_config("load", conn, response, device.name) + return + response = await conn.commit_config() + if response.failed: + await self.abort_config("commit", conn, response, device.name) + return + self.unprocessed_devices.remove(device) + self.processed_devices.add(device) + self.logger.log_info(f"Successfully pushed config to {device.name}") + except Exception: + error = traceback.format_exc() + device.config_error = error + self.logger.log_failure(error) + self.unprocessed_devices.remove(device) + self.failed_devices.add(device) + + async def abort_config( + self, operation: str, conn: AsyncScrapliCfgPlatform, response: ScrapliCfgResponse, device: DeviceDataClass + ) -> None: + self.logger.log_failure(f"Failed to {operation} config on {device.name}: {response.result}") + device.config_error = response.result + await conn.abort_config() + self.unprocessed_devices.remove(device) + self.failed_devices.add(device) + self.logger.log_info(f"Aborted config on {device.name}") + + async def rollback(self) -> None: + self.logger.log_info(f"Rollback config: {', '.join(d.name for d in self.processed_devices)}") + await asyncio.gather(*(self._rollback_one(d) for d in self.processed_devices)) + + async def _rollback_one(self, device: DeviceDataClass) -> None: + conn = self.connections[device.name] + await conn.load_config(config=device.actual_config, replace=True) + await conn.commit_config() + self.logger.log_info(f"Successfully rollbacked {device.name}") diff --git a/netbox_config_diff/configurator/exceptions.py b/netbox_config_diff/configurator/exceptions.py new file mode 100644 index 0000000..acdce96 --- /dev/null +++ b/netbox_config_diff/configurator/exceptions.py @@ -0,0 +1,19 @@ +from typing import Any + + +class DeviceError(Exception): + def __str__(self) -> str: + return f"{self.message}, {self.kwargs['devices']}" + + def __init__(self, message: str, **kwargs: Any) -> None: + self.message = message + self.kwargs = kwargs + super().__init__(message) + + +class DeviceValidationError(DeviceError): + pass + + +class DeviceConfigurationError(DeviceError): + pass diff --git a/netbox_config_diff/configurator/factory.py b/netbox_config_diff/configurator/factory.py new file mode 100644 index 0000000..4ce153e --- /dev/null +++ b/netbox_config_diff/configurator/factory.py @@ -0,0 +1,99 @@ +from typing import TYPE_CHECKING, Any, Callable + +from scrapli.driver.core import ( + AsyncEOSDriver, + AsyncIOSXEDriver, + AsyncIOSXRDriver, + AsyncJunosDriver, + AsyncNXOSDriver, +) +from scrapli.driver.network import AsyncNetworkDriver, NetworkDriver +from scrapli_cfg.exceptions import ScrapliCfgException +from scrapli_cfg.logging import logger + +from .platforms import ( + CustomAsyncScrapliCfgEOS, + CustomAsyncScrapliCfgIOSXE, + CustomAsyncScrapliCfgIOSXR, + CustomAsyncScrapliCfgJunos, + CustomAsyncScrapliCfgNXOS, +) + +ASYNC_CORE_PLATFORM_MAP = { + AsyncEOSDriver: CustomAsyncScrapliCfgEOS, + AsyncIOSXEDriver: CustomAsyncScrapliCfgIOSXE, + AsyncIOSXRDriver: CustomAsyncScrapliCfgIOSXR, + AsyncNXOSDriver: CustomAsyncScrapliCfgNXOS, + AsyncJunosDriver: CustomAsyncScrapliCfgJunos, +} + +if TYPE_CHECKING: + from scrapli_cfg.platform.base.async_platform import AsyncScrapliCfgPlatform + + +def AsyncScrapliCfg( + conn: AsyncNetworkDriver, + *, + config_sources: list[str] | None = None, + on_prepare: Callable[..., Any] | None = None, + dedicated_connection: bool = False, + ignore_version: bool = False, + **kwargs: Any, +) -> "AsyncScrapliCfgPlatform": + """ + Scrapli Config Async Factory + + Return a async scrapli config object for the provided platform. Prefer to use factory classes + just so that the naming convention (w/ upper case things) is "right", but given that the class + version inherited from the base ScrapliCfgPlatform and did not implement the abstract methods + this felt like a better move. + + Args: + conn: scrapli connection to use + config_sources: list of config sources + on_prepare: optional callable to run at connection `prepare` + dedicated_connection: if `False` (default value) scrapli cfg will not open or close the + underlying scrapli connection and will raise an exception if the scrapli connection + is not open. If `True` will automatically open and close the scrapli connection when + using with a context manager, `prepare` will open the scrapli connection (if not + already open), and `close` will close the scrapli connection. + ignore_version: ignore checking device version support; currently this just means that + scrapli-cfg will not fetch the device version during the prepare phase, however this + will (hopefully) be used in the future to limit what methods can be used against a + target device. For example, for EOS devices we need > 4.14 to load configs; so if a + device is encountered at 4.13 the version check would raise an exception rather than + just failing in a potentially awkward fashion. + kwargs: keyword args to pass to the scrapli_cfg object (for things like iosxe 'filesystem' + argument) + + Returns: + AsyncScrapliCfg: async scrapli cfg object + + Raises: + ScrapliCfgException: if provided connection object is sync + ScrapliCfgException: if provided connection object is async but is not a supported ("core") + platform type + + """ + logger.debug("AsyncScrapliCfg factory initialized") + + if isinstance(conn, NetworkDriver): + raise ScrapliCfgException( + "provided scrapli connection is sync but using 'AsyncScrapliCfg' -- you must use an " + "async connection with 'AsyncScrapliCfg'!" + ) + + platform_class = ASYNC_CORE_PLATFORM_MAP.get(type(conn)) + if not platform_class: + raise ScrapliCfgException(f"scrapli connection object type '{type(conn)}' not a supported scrapli-cfg type") + + final_platform: "AsyncScrapliCfgPlatform" = platform_class( + conn=conn, + config_sources=config_sources, + on_prepare=on_prepare, + dedicated_connection=dedicated_connection, + ignore_version=ignore_version, + **kwargs, + ) + + return final_platform diff --git a/netbox_config_diff/configurator/platforms.py b/netbox_config_diff/configurator/platforms.py new file mode 100644 index 0000000..d4ffe97 --- /dev/null +++ b/netbox_config_diff/configurator/platforms.py @@ -0,0 +1,122 @@ +import re +from typing import Pattern + +from scrapli_cfg.exceptions import TemplateError +from scrapli_cfg.platform.core.arista_eos import AsyncScrapliCfgEOS +from scrapli_cfg.platform.core.cisco_iosxe import AsyncScrapliCfgIOSXE +from scrapli_cfg.platform.core.cisco_iosxr import AsyncScrapliCfgIOSXR +from scrapli_cfg.platform.core.cisco_nxos import AsyncScrapliCfgNXOS +from scrapli_cfg.platform.core.juniper_junos import AsyncScrapliCfgJunos +from scrapli_cfg.response import ScrapliCfgResponse + + +class CustomScrapliCfg: + def _render_substituted_config( + self, config_template: str, substitutes: list[tuple[str, Pattern[str]]], source_config: str + ) -> str: + """ + Render a substituted configuration file + + Renders a configuration based on a user template, substitutes, and a target config from the + device. + + Args: + config_template: config file to use as the base for substitutions -- should contain + jinja2-like variables that will be replaced with data fetched from the source config + by the substitutes patterns + substitutes: tuple of name, pattern -- where name matches the jinja2-like variable in + the config_template file, and pattern is a compiled regular expression pattern to be + used to fetch that section from the source config + source_config: current source config to use in substitution process + + Returns: + None + + Raises: + TemplateError: if no substitute sections are provided + TemplateError: if a substitute pattern is not found in the config template + + """ + self.logger.debug("rendering substituted config") + + if not substitutes: + msg = "no substitutes provided..." + self.logger.critical(msg) + raise TemplateError(msg) + + replace_sections = [(name, re.search(pattern=pattern, string=source_config)) for name, pattern in substitutes] + + rendered_config = "" + for name, replace_section in replace_sections: + if not replace_section: + msg = f"substitution pattern {name} was unable to find a match in the target config" " source" + self.logger.critical(msg) + raise TemplateError(msg) + + replace_group = replace_section.group() + rendered_config = config_template.replace(f"{{{{ {name} }}}}", replace_group) + + # remove any totally empty lines (from bad regex, or just device spitting out lines w/ + # nothing on it + rendered_config = "\n".join(line for line in rendered_config.splitlines() if line) + + self.logger.debug("rendering substituted config complete") + + return rendered_config + + async def render_substituted_config( + self, + config_template: str, + substitutes: list[tuple[str, Pattern[str]]], + source: str = "running", + ) -> tuple[ScrapliCfgResponse, str]: + """ + Render a substituted configuration file + + Renders a configuration based on a user template, substitutes, and a target config from the + device. + + Args: + config_template: config file to use as the base for substitutions -- should contain + jinja2-like variables that will be replaced with data fetched from the source config + by the substitutes patterns + substitutes: tuple of name, pattern -- where name matches the jinja2-like variable in + the config_template file, and pattern is a compiled regular expression pattern to be + used to fetch that section from the source config + source: config source to use for the substitution efforts, typically running|startup + + Returns: + str: actual and substituted/rendered config + + Raises: + N/A + + """ + self.logger.info("fetching configuration and replacing with provided substitutes") + + source_config = await self.get_config(source=source) + return source_config, self._render_substituted_config( + config_template=config_template, + substitutes=substitutes, + source_config=source_config.result, + ) + + +class CustomAsyncScrapliCfgEOS(CustomScrapliCfg, AsyncScrapliCfgEOS): + pass + + +class CustomAsyncScrapliCfgIOSXE(CustomScrapliCfg, AsyncScrapliCfgIOSXE): + pass + + +class CustomAsyncScrapliCfgIOSXR(CustomScrapliCfg, AsyncScrapliCfgIOSXR): + pass + + +class CustomAsyncScrapliCfgNXOS(CustomScrapliCfg, AsyncScrapliCfgNXOS): + pass + + +class CustomAsyncScrapliCfgJunos(CustomScrapliCfg, AsyncScrapliCfgJunos): + pass diff --git a/netbox_config_diff/configurator/utils.py b/netbox_config_diff/configurator/utils.py new file mode 100644 index 0000000..c556f32 --- /dev/null +++ b/netbox_config_diff/configurator/utils.py @@ -0,0 +1,53 @@ +import logging + +from django.utils import timezone +from extras.choices import LogLevelChoices + + +class CustomLogger: + def __init__(self) -> None: + self.log_data = [] + self.diffs = [] + self.logger = logging.getLogger("netbox_config_diff.configurator") + + def _log(self, message: str, log_level: str | None = None) -> None: + if log_level not in LogLevelChoices.values(): + raise Exception(f"Unknown logging level: {log_level}") + if log_level is None: + log_level = LogLevelChoices.LOG_DEFAULT + self.log_data.append((timezone.now().strftime('%Y-%m-%d %H:%M:%S'), log_level, message)) + + def log(self, message: str) -> None: + self._log(message, log_level=LogLevelChoices.LOG_DEFAULT) + self.logger.info(message) + + def log_success(self, message: str) -> None: + self._log(message, log_level=LogLevelChoices.LOG_SUCCESS) + self.logger.info(message) + + def log_info(self, message: str) -> None: + self._log(message, log_level=LogLevelChoices.LOG_INFO) + self.logger.info(message) + + def log_warning(self, message: str) -> None: + self._log(message, log_level=LogLevelChoices.LOG_WARNING) + self.logger.info(message) + + def log_failure(self, message: str) -> None: + self._log(message, log_level=LogLevelChoices.LOG_FAILURE) + self.logger.info(message) + + def clear_log(self) -> None: + self.log_data = [] + + def logs(self) -> dict: + return {"logs": self.log_data} + + def get_diffs(self) -> dict: + return {"diffs": self.diffs} + + def get_data(self) -> dict: + return self.get_diffs() | self.logs() + + def add_diff(self, name: str, diff: str | None = None, error: str | None = None) -> None: + self.diffs.append({"name": name, "diff": diff, "error": error}) diff --git a/netbox_config_diff/constants.py b/netbox_config_diff/constants.py new file mode 100644 index 0000000..14f514b --- /dev/null +++ b/netbox_config_diff/constants.py @@ -0,0 +1,7 @@ +ACCEPTABLE_DRIVERS = [ + "arista_eos", + "cisco_iosxe", + "cisco_iosxr", + "cisco_nxos", + "juniper_junos", +] diff --git a/netbox_config_diff/filtersets.py b/netbox_config_diff/filtersets.py index 744e3a7..1bd32f0 100644 --- a/netbox_config_diff/filtersets.py +++ b/netbox_config_diff/filtersets.py @@ -1,10 +1,12 @@ import django_filters from dcim.models import Device, Platform +from django.contrib.auth import get_user_model from django.db.models import Q from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet +from utilities.filters import MultiValueDateTimeFilter -from .choices import ConfigComplianceStatusChoices -from .models import ConfigCompliance, PlatformSetting +from netbox_config_diff.choices import ConfigComplianceStatusChoices +from netbox_config_diff.models import ConfigCompliance, ConfigurationRequest, PlatformSetting, Substitute class ConfigComplianceFilterSet(ChangeLoggedModelFilterSet): @@ -53,3 +55,63 @@ def search(self, queryset, name, value): | Q(description__icontains=value) ) return queryset.filter(qs_filter) + + +class ConfigurationRequestFilterSet(NetBoxModelFilterSet): + created_by_id = django_filters.ModelMultipleChoiceFilter( + queryset=get_user_model().objects.all(), + ) + created_by = django_filters.ModelMultipleChoiceFilter( + field_name="created_by__username", + queryset=get_user_model().objects.all(), + to_field_name="username", + ) + approved_by_id = django_filters.ModelMultipleChoiceFilter( + queryset=get_user_model().objects.all(), + ) + approved_by = django_filters.ModelMultipleChoiceFilter( + field_name="approved_by__username", + queryset=get_user_model().objects.all(), + to_field_name="username", + ) + scheduled_by_id = django_filters.ModelMultipleChoiceFilter( + queryset=get_user_model().objects.all(), + ) + scheduled_by = django_filters.ModelMultipleChoiceFilter( + field_name="scheduled_by__username", + queryset=get_user_model().objects.all(), + to_field_name="username", + ) + device_id = django_filters.ModelMultipleChoiceFilter( + field_name="devices", + queryset=Device.objects.all(), + ) + scheduled = MultiValueDateTimeFilter() + started = MultiValueDateTimeFilter() + completed = MultiValueDateTimeFilter() + + class Meta: + model = ConfigurationRequest + fields = ["id", "status", "description", "comments"] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(description__icontains=value) | Q(comments__icontains=value) + return queryset.filter(qs_filter) + + +class SubstituteFilterSet(NetBoxModelFilterSet): + platform_setting_id = django_filters.ModelMultipleChoiceFilter( + queryset=PlatformSetting.objects.all(), + ) + + class Meta: + model = Substitute + fields = ["id", "name", "description", "regexp"] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(name__icontains=value) | Q(description__icontains=value) | Q(regexp__icontains=value) + return queryset.filter(qs_filter) diff --git a/netbox_config_diff/forms.py b/netbox_config_diff/forms.py index 6d43f70..2682bf1 100644 --- a/netbox_config_diff/forms.py +++ b/netbox_config_diff/forms.py @@ -1,14 +1,20 @@ +from dcim.choices import DeviceStatusChoices from dcim.models import Device, Platform from django import forms +from django.contrib.auth import get_user_model from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ) +from utilities.forms.mixins import BootstrapMixin +from utilities.forms.widgets import DateTimePicker +from utilities.utils import local_now -from .choices import ConfigComplianceStatusChoices -from .models import ConfigCompliance, PlatformSetting +from netbox_config_diff.choices import ConfigComplianceStatusChoices, ConfigurationRequestStatusChoices +from netbox_config_diff.constants import ACCEPTABLE_DRIVERS +from netbox_config_diff.models import ConfigCompliance, ConfigurationRequest, PlatformSetting, Substitute class ConfigComplianceFilterForm(NetBoxModelFilterSetForm): @@ -48,6 +54,7 @@ class PlatformSettingFilterForm(NetBoxModelFilterSetForm): platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), required=False, + label="Platform", ) tag = TagFilterField(model) @@ -73,3 +80,119 @@ class PlatformSettingBulkEditForm(NetBoxModelBulkEditForm): model = PlatformSetting fieldsets = ((None, ("driver", "command", "description", "exclude_regex")),) nullable_fields = ("description", "exclude_regex") + + +class ConfigurationRequestForm(NetBoxModelForm): + devices = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + query_params={ + "status": DeviceStatusChoices.STATUS_ACTIVE, + "has_primary_ip": True, + "platform_id__n": "null", + "config_template_id__n": "null", + }, + ) + created_by = forms.ModelChoiceField( + queryset=get_user_model().objects.all(), + required=False, + widget=forms.HiddenInput(), + ) + + class Meta: + model = ConfigurationRequest + fields = ("devices", "description", "comments", "created_by", "tags") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + self.fields["devices"].disabled = True + + def clean(self): + super().clean() + + if devices := self.cleaned_data["devices"].filter(platform__platform_setting__isnull=True): + platforms = {d.platform.name for d in devices} + raise forms.ValidationError({"devices": f"Assign PlatformSetting for platform(s): {', '.join(platforms)}"}) + + if drivers := { + device.platform.platform_setting.driver + for device in self.cleaned_data["devices"] + if device.platform.platform_setting.driver not in ACCEPTABLE_DRIVERS + }: + raise forms.ValidationError({"devices": f"Driver(s) not supported: {', '.join(drivers)}"}) + + +class ConfigurationRequestFilterForm(NetBoxModelFilterSetForm): + model = ConfigurationRequest + fieldsets = ((None, ("q", "created_by_id", "approved_by_id", "scheduled_by_id", "device_id", "status", "tag")),) + created_by_id = DynamicModelMultipleChoiceField( + queryset=get_user_model().objects.all(), + required=False, + label="Created by", + ) + approved_by_id = DynamicModelMultipleChoiceField( + queryset=get_user_model().objects.all(), + required=False, + label="Approved by", + ) + scheduled_by_id = DynamicModelMultipleChoiceField( + queryset=get_user_model().objects.all(), + required=False, + label="Scheduled by", + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label="Device", + ) + status = forms.MultipleChoiceField( + choices=ConfigurationRequestStatusChoices, + required=False, + ) + tag = TagFilterField(model) + + +class ConfigurationRequestScheduleForm(BootstrapMixin, forms.ModelForm): + scheduled = forms.DateTimeField( + widget=DateTimePicker(), + label="Schedule at", + help_text="Schedule execution of configuration request to a set time", + ) + scheduled_by = forms.ModelChoiceField( + queryset=get_user_model().objects.all(), + required=False, + widget=forms.HiddenInput(), + ) + + class Meta: + model = ConfigurationRequest + fields = ("scheduled", "scheduled_by", "status") + widgets = { + "status": forms.HiddenInput(), + } + + def clean(self): + scheduled_time = self.cleaned_data.get("scheduled") + if scheduled_time and scheduled_time < local_now(): + raise forms.ValidationError("Scheduled time must be in the future.") + + +class SubstituteForm(NetBoxModelForm): + platform_setting = DynamicModelChoiceField( + queryset=PlatformSetting.objects.all(), + ) + + class Meta: + model = Substitute + fields = ("platform_setting", "name", "description", "regexp", "tags") + + +class SubstituteFilterForm(NetBoxModelFilterSetForm): + model = Substitute + fieldsets = ((None, ("q", "platform_setting_id", "tag")),) + platform_setting_id = DynamicModelMultipleChoiceField( + queryset=PlatformSetting.objects.all(), + required=False, + ) + tag = TagFilterField(model) diff --git a/netbox_config_diff/graphql.py b/netbox_config_diff/graphql.py index 600df91..33fde3d 100644 --- a/netbox_config_diff/graphql.py +++ b/netbox_config_diff/graphql.py @@ -2,7 +2,7 @@ from netbox.graphql.fields import ObjectField, ObjectListField from netbox.graphql.types import NetBoxObjectType -from . import filtersets, models +from netbox_config_diff import filtersets, models class ConfigComplianceType(NetBoxObjectType): @@ -19,6 +19,20 @@ class Meta: filterset_class = filtersets.PlatformSettingFilterSet +class ConfigurationRequestType(NetBoxObjectType): + class Meta: + model = models.ConfigurationRequest + fields = "__all__" + filterset_class = filtersets.ConfigurationRequestFilterSet + + +class SubstituteType(NetBoxObjectType): + class Meta: + model = models.Substitute + fields = "__all__" + filterset_class = filtersets.SubstituteFilterSet + + class Query(ObjectType): config_compliance = ObjectField(ConfigComplianceType) config_compliance_list = ObjectListField(ConfigComplianceType) @@ -26,5 +40,11 @@ class Query(ObjectType): platform_setting = ObjectField(PlatformSettingType) platform_setting_list = ObjectListField(PlatformSettingType) + configuration_request = ObjectField(ConfigurationRequestType) + configuration_request_list = ObjectListField(ConfigurationRequestType) + + substitute = ObjectField(SubstituteType) + substitute_list = ObjectListField(SubstituteType) + schema = Query diff --git a/netbox_config_diff/jobs.py b/netbox_config_diff/jobs.py new file mode 100644 index 0000000..873414d --- /dev/null +++ b/netbox_config_diff/jobs.py @@ -0,0 +1,52 @@ +import logging +import traceback + +from core.choices import JobStatusChoices +from core.models import Job +from utilities.utils import NetBoxFakeRequest + +from netbox_config_diff.choices import ConfigurationRequestStatusChoices +from netbox_config_diff.configurator.base import Configurator +from netbox_config_diff.models import ConfigurationRequest + +logger = logging.getLogger(__name__) + + +def collect_diffs(job: Job, request: NetBoxFakeRequest, *args, **kwargs) -> None: + job.start() + cr = ConfigurationRequest.objects.get(pk=job.object_id) + logger.info(f"Collecting diffs for {cr}") + configurator = Configurator(cr.devices.all(), request) + try: + configurator.validate_devices() + configurator.collect_diffs() + job.data = configurator.logger.get_data() + job.terminate() + except Exception as e: + stacktrace = traceback.format_exc() + configurator.logger.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```") + logger.error(f"Exception raised during script execution: {e}") + job.data = configurator.logger.get_data() + job.terminate(status=JobStatusChoices.STATUS_ERRORED) + + logger.info(f"Collecting diffs job completed in {job.duration}") + + +def push_configs(job: Job, request: NetBoxFakeRequest, *args, **kwargs) -> None: + cr = ConfigurationRequest.objects.get(pk=job.object_id) + cr.start(job) + logger.info(f"Applying configs for {cr}") + configurator = Configurator(cr.devices.all(), request) + try: + configurator.validate_devices() + configurator.push_configs() + job.data = configurator.logger.get_data() + cr.terminate(job=job) + except Exception as e: + stacktrace = traceback.format_exc() + configurator.logger.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```") + logger.error(f"Exception raised during script execution: {e}") + job.data = configurator.logger.get_data() + cr.terminate(job=job, status=ConfigurationRequestStatusChoices.ERRORED) + + logger.info(f"Applying configs job completed in {job.duration}") diff --git a/netbox_config_diff/migrations/0006_substitute.py b/netbox_config_diff/migrations/0006_substitute.py new file mode 100644 index 0000000..4825db6 --- /dev/null +++ b/netbox_config_diff/migrations/0006_substitute.py @@ -0,0 +1,62 @@ +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import re +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + dependencies = [ + ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('netbox_config_diff', '0005_configcompliance_extra_missing'), + ] + + operations = [ + migrations.CreateModel( + name='Substitute', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ( + 'custom_field_data', + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ( + 'name', + models.CharField( + max_length=250, + unique=True, + validators=[ + django.core.validators.RegexValidator( + flags=re.RegexFlag['IGNORECASE'], + message='Only alphanumeric characters and underscores are allowed.', + regex='^[a-z0-9_]+$', + ), + django.core.validators.RegexValidator( + flags=re.RegexFlag['IGNORECASE'], + inverse_match=True, + message='Double underscores are not permitted in names.', + regex='__', + ), + ], + ), + ), + ('description', models.CharField(blank=True, max_length=200)), + ('regexp', models.CharField(max_length=1000)), + ( + 'platform_setting', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='substitutes', + to='netbox_config_diff.platformsetting', + ), + ), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('name',), + }, + ), + ] diff --git a/netbox_config_diff/migrations/0007_configurationrequest.py b/netbox_config_diff/migrations/0007_configurationrequest.py new file mode 100644 index 0000000..1241ca0 --- /dev/null +++ b/netbox_config_diff/migrations/0007_configurationrequest.py @@ -0,0 +1,70 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('dcim', '0181_rename_device_role_device_role'), + ('netbox_config_diff', '0006_substitute'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigurationRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ( + 'custom_field_data', + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('status', models.CharField(default='created', max_length=30)), + ('scheduled', models.DateTimeField(blank=True, null=True)), + ('started', models.DateTimeField(blank=True, null=True)), + ('completed', models.DateTimeField(blank=True, null=True)), + ( + 'approved_by', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'created_by', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to=settings.AUTH_USER_MODEL, + ), + ), + ('devices', models.ManyToManyField(related_name='configuration_requests', to='dcim.device')), + ( + 'scheduled_by', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to=settings.AUTH_USER_MODEL, + ), + ), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('-created',), + }, + ), + ] diff --git a/netbox_config_diff/models.py b/netbox_config_diff/models.py index 82d41c5..d65dff1 100644 --- a/netbox_config_diff/models.py +++ b/netbox_config_diff/models.py @@ -1,11 +1,22 @@ +import re + +import django_rq +from core.models import Job +from django.conf import settings +from django.core.validators import RegexValidator from django.db import models from django.urls import reverse +from django.utils import timezone +from django.utils.module_loading import import_string from django.utils.translation import gettext as _ -from netbox.models import NetBoxModel -from netbox.models.features import ChangeLoggingMixin +from netbox.constants import RQ_QUEUE_DEFAULT +from netbox.models import NetBoxModel, PrimaryModel +from netbox.models.features import ChangeLoggingMixin, JobsMixin +from rq.exceptions import InvalidJobOperation from utilities.querysets import RestrictedQuerySet +from utilities.utils import copy_safe_request -from .choices import ConfigComplianceStatusChoices +from netbox_config_diff.choices import ConfigComplianceStatusChoices, ConfigurationRequestStatusChoices class ConfigCompliance(ChangeLoggingMixin, models.Model): @@ -96,3 +107,144 @@ def __str__(self): def get_absolute_url(self): return reverse("plugins:netbox_config_diff:platformsetting", args=[self.pk]) + + +class ConfigurationRequest(JobsMixin, PrimaryModel): + created_by = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + approved_by = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + scheduled_by = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + status = models.CharField( + max_length=30, + choices=ConfigurationRequestStatusChoices, + default=ConfigurationRequestStatusChoices.CREATED, + ) + devices = models.ManyToManyField( + to="dcim.Device", + related_name="configuration_requests", + ) + scheduled = models.DateTimeField( + null=True, + blank=True, + ) + started = models.DateTimeField( + null=True, + blank=True, + ) + completed = models.DateTimeField( + null=True, + blank=True, + ) + + class Meta: + ordering = ("-created",) + + def __str__(self): + return f"CR #{self.pk}" + + def get_absolute_url(self): + return reverse("plugins:netbox_config_diff:configurationrequest", args=[self.pk]) + + def get_status_color(self): + return ConfigurationRequestStatusChoices.colors.get(self.status) + + @property + def finished(self): + return self.status in ConfigurationRequestStatusChoices.FINISHED_STATE_CHOICES + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + + queue = django_rq.get_queue(RQ_QUEUE_DEFAULT) + for result in self.jobs.all(): + if job := queue.fetch_job(str(result.job_id)): + try: + job.cancel() + except InvalidJobOperation: + pass + + def enqueue_job(self, request, job_name, schedule_at=None): + return Job.enqueue( + import_string(f"netbox_config_diff.jobs.{job_name}"), + name=f"{self} {job_name}", + instance=self, + user=request.user, + request=copy_safe_request(request), + schedule_at=schedule_at, + ) + + def start(self, job: Job): + """ + Record the job's start time and update its status to "running." + """ + if self.started is not None: + return + job.start() + self.started = timezone.now() + self.status = ConfigurationRequestStatusChoices.RUNNING + self.save() + + def terminate(self, job: Job, status: str = ConfigurationRequestStatusChoices.COMPLETED): + job.terminate(status=status) + self.status = status + self.completed = timezone.now() + self.save() + + +class Substitute(NetBoxModel): + platform_setting = models.ForeignKey( + to="netbox_config_diff.PlatformSetting", + on_delete=models.CASCADE, + related_name="substitutes", + ) + name = models.CharField( + max_length=250, + unique=True, + validators=( + RegexValidator( + regex=r'^[a-z0-9_]+$', + message=_("Only alphanumeric characters and underscores are allowed."), + flags=re.IGNORECASE, + ), + RegexValidator( + regex=r'__', + message=_("Double underscores are not permitted in names."), + flags=re.IGNORECASE, + inverse_match=True, + ), + ), + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True, + ) + regexp = models.CharField( + max_length=1000, + ) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("plugins:netbox_config_diff:substitute", args=[self.pk]) diff --git a/netbox_config_diff/navigation.py b/netbox_config_diff/navigation.py index 5f5a6ae..0e08eb1 100644 --- a/netbox_config_diff/navigation.py +++ b/netbox_config_diff/navigation.py @@ -1,3 +1,6 @@ +from django import forms +from extras.dashboard.utils import register_widget +from extras.dashboard.widgets import DashboardWidget, WidgetConfigForm from extras.plugins import PluginMenuButton, PluginMenuItem from utilities.choices import ButtonColorChoices @@ -25,4 +28,34 @@ def get_add_button(model: str) -> PluginMenuButton: buttons=[], permissions=["netbox_config_diff.view_configcompliance"], ), + PluginMenuItem( + link="plugins:netbox_config_diff:configurationrequest_list", + link_text="Configuration Requests", + buttons=[get_add_button("configurationrequest")], + permissions=["netbox_config_diff.view_configurationrequest"], + ), + PluginMenuItem( + link="plugins:netbox_config_diff:configurationrequest_job_list", + link_text="Jobs", + buttons=[], + permissions=["core.view_job"], + ), + PluginMenuItem( + link="plugins:netbox_config_diff:substitute_list", + link_text="Substitutes", + buttons=[get_add_button("substitute")], + permissions=["netbox_config_diff.view_substitute"], + ), ) + + +@register_widget +class ReminderWidget(DashboardWidget): + default_title = 'Reminder' + description = 'Add a virtual sticky note' + + class ConfigForm(WidgetConfigForm): + content = forms.CharField(widget=forms.Textarea()) + + def render(self, request): + return self.config.get('content') diff --git a/netbox_config_diff/search.py b/netbox_config_diff/search.py index 9e4428b..e5e8d67 100644 --- a/netbox_config_diff/search.py +++ b/netbox_config_diff/search.py @@ -1,6 +1,6 @@ from netbox.search import SearchIndex, register_search -from . import models +from netbox_config_diff import models @register_search @@ -11,3 +11,22 @@ class PlatformSettingIndex(SearchIndex): ("command", 500), ("exclude_regex", 1000), ) + + +@register_search +class ConfigurationRequestIndex(SearchIndex): + model = models.ConfigurationRequest + fields = ( + ("description", 100), + ("comments", 500), + ) + + +@register_search +class SubstituteIndex(SearchIndex): + model = models.Substitute + fields = ( + ("name", 100), + ("description", 500), + ("regexp", 1000), + ) diff --git a/netbox_config_diff/tables.py b/netbox_config_diff/tables.py index 1afee66..ea3fa01 100644 --- a/netbox_config_diff/tables.py +++ b/netbox_config_diff/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from netbox.tables import NetBoxTable, columns -from .models import ConfigCompliance, PlatformSetting +from netbox_config_diff.models import ConfigCompliance, ConfigurationRequest, PlatformSetting, Substitute class ConfigComplianceTable(NetBoxTable): @@ -34,3 +34,81 @@ class Meta(NetBoxTable.Meta): model = PlatformSetting fields = ("driver", "platform", "command", "exclude_regex", "description", "tags", "created", "last_updated") default_columns = ("driver", "platform", "command", "exclude_regex", "description") + + +class ConfigurationRequestTable(NetBoxTable): + devices = columns.ManyToManyColumn( + linkify_item=True, + ) + status = columns.ChoiceFieldColumn() + scheduled = columns.DateTimeColumn() + started = columns.DateTimeColumn() + completed = columns.DateTimeColumn() + tags = columns.TagColumn( + url_name="netbox_config_diff:configurationrequest_list", + ) + actions = columns.ActionsColumn( + actions=("delete", "changelog"), + ) + + class Meta(NetBoxTable.Meta): + model = ConfigurationRequest + fields = ( + "id", + "devices", + "status", + "description", + "created_by", + "approved_by", + "scheduled_by", + "scheduled", + "started", + "completed", + "tags", + "created", + "last_updated", + ) + default_columns = ( + "id", + "devices", + "status", + "description", + "created_by", + "approved_by", + "scheduled_by", + "scheduled", + "started", + "completed", + ) + + +class SubstituteTable(NetBoxTable): + name = tables.Column( + linkify=True, + ) + platform_setting = tables.Column( + linkify=True, + ) + tags = columns.TagColumn( + url_name="netbox_config_diff:substitute_list", + ) + + class Meta(NetBoxTable.Meta): + model = Substitute + fields = ( + "id", + "name", + "platform_setting", + "description", + "regexp", + "tags", + "created", + "last_updated", + ) + default_columns = ( + "name", + "platform_setting", + "description", + "regexp", + "tags", + ) diff --git a/netbox_config_diff/templates/netbox_config_diff/configurationrequest.html b/netbox_config_diff/templates/netbox_config_diff/configurationrequest.html new file mode 100644 index 0000000..8798f84 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/configurationrequest.html @@ -0,0 +1,117 @@ +{% extends "netbox_config_diff/configurationrequest/base.html" %} +{% load helpers %} + +{% block content %} +

+ {% if job %} +
+
+ {% include 'netbox_config_diff/inc/job_log.html' %} +
+
+ {% endif %} +{% endblock content %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configurationrequest/base.html b/netbox_config_diff/templates/netbox_config_diff/configurationrequest/base.html new file mode 100644 index 0000000..11e2303 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/configurationrequest/base.html @@ -0,0 +1,57 @@ +{% extends "generic/object.html" %} +{% load buttons %} +{% load perms %} + +{% block controls %} +
+{% endblock controls %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configurationrequest/diffs.html b/netbox_config_diff/templates/netbox_config_diff/configurationrequest/diffs.html new file mode 100644 index 0000000..0c89ab1 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/configurationrequest/diffs.html @@ -0,0 +1,102 @@ +{% extends "netbox_config_diff/configurationrequest/base.html" %} +{% load helpers %} +{% load static %} + +{% block content %} + {% if job %} +
+
+
+
Job
+
+ + + + + + + + + + + + + +
Name + {{ job.name }} +
Status{% badge job.get_status_display job.get_status_color %}
Created By{{ job.user|placeholder }}
+
+
+
+
+
+
Time
+
+ + + + + + + + + + + + + +
Created{{ job.created|annotated_date }}
Started{{ job.started|annotated_date|placeholder }}
Completed{{ job.completed|annotated_date|placeholder }}
+
+
+
+
+ {% if job.completed %} +
+
+ {% if job.status == "failed" or job.status == "errored" %} + {% include 'netbox_config_diff/inc/job_log.html' %} + {% else %} + {% for diff in job.data.diffs %} + {% if diff.error %} +
+
{{ diff.name }} - Error
+
+
{{ diff.error }}
+
+
+ {% elif diff.diff %} + {% include 'netbox_config_diff/inc/diff.html' with device_name=diff.name data=diff.diff %} + {% else %} +
+
{{ diff.name }} - No diff
+
+ {% endif %} + {% endfor %} + {% endif %} +
+
+ {% endif %} + {% else %} + + {% endif %} +{% endblock content %} + +{% block javascript %} + {% if job.status == "completed" %} + + + {% endif %} +{% endblock javascript %} diff --git a/netbox_config_diff/templates/netbox_config_diff/inc/diff.html b/netbox_config_diff/templates/netbox_config_diff/inc/diff.html new file mode 100644 index 0000000..bc1864a --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/inc/diff.html @@ -0,0 +1,27 @@ +
+
{{ device_name }} - Diff
+
+
+ diff --git a/netbox_config_diff/templates/netbox_config_diff/inc/job_log.html b/netbox_config_diff/templates/netbox_config_diff/inc/job_log.html new file mode 100644 index 0000000..4e0c508 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/inc/job_log.html @@ -0,0 +1,27 @@ +{% load log_levels %} + +
+
Job Log
+
+ + + + + + + {% for log in job.data.logs %} + + + + + + {% empty %} + + + + {% endfor %} +
TimeLevelMessage
{{ log.0 }}{% log_level log.1 %}{{ log.2|markdown }}
+ No log output +
+
+
diff --git a/netbox_config_diff/templates/netbox_config_diff/platformsetting.html b/netbox_config_diff/templates/netbox_config_diff/platformsetting.html index 306ee6f..4bdf7f5 100644 --- a/netbox_config_diff/templates/netbox_config_diff/platformsetting.html +++ b/netbox_config_diff/templates/netbox_config_diff/platformsetting.html @@ -1,4 +1,3 @@ - {% extends "generic/object.html" %} {% load helpers %} {% load render_table from django_tables2 %} @@ -46,4 +45,3 @@
{{ object|meta:"verbose_name"|bettertitle }}
{% endblock content %} - diff --git a/netbox_config_diff/templates/netbox_config_diff/substitute.html b/netbox_config_diff/templates/netbox_config_diff/substitute.html new file mode 100644 index 0000000..51b940c --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/substitute.html @@ -0,0 +1,37 @@ +{% extends "generic/object.html" %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
{{ object|meta:"verbose_name"|bettertitle }}
+
+ + + + + + + + + + + + + + + + + +
Platform Setting{{ object.platform_setting|linkify }}
Name{{ object.name }}
Description{{ object.description|placeholder }}
Regexp
{{ object.regexp }}
+
+
+ {% include "inc/panels/custom_fields.html" %} +
+
+ {% include "inc/panels/tags.html" %} +
+
+{% endblock content %} diff --git a/netbox_config_diff/urls.py b/netbox_config_diff/urls.py index 14dafba..b9d22c8 100644 --- a/netbox_config_diff/urls.py +++ b/netbox_config_diff/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from utilities.urls import get_model_urls -from . import views +from netbox_config_diff import views urlpatterns = ( # Config Compliances @@ -20,4 +20,14 @@ "platform-settings/delete/", views.PlatformSettingBulkDeleteView.as_view(), name="platformsetting_bulk_delete" ), path("platform-settings//", include(get_model_urls("netbox_config_diff", "platformsetting"))), + # Configuration Requests + path("configuration-requests/", views.ConfigurationRequestListView.as_view(), name="configurationrequest_list"), + path("configuration-requests/add/", views.ConfigurationRequestEditView.as_view(), name="configurationrequest_add"), + path("configuration-requests//", include(get_model_urls("netbox_config_diff", "configurationrequest"))), + # Jobs + path("jobs/", views.JobListView.as_view(), name="configurationrequest_job_list"), + # Configuration Requests + path("substitutes/", views.SubstituteListView.as_view(), name="substitute_list"), + path("substitutes/add/", views.SubstituteEditView.as_view(), name="substitute_add"), + path("substitutes//", include(get_model_urls("netbox_config_diff", "substitute"))), ) diff --git a/netbox_config_diff/views/__init__.py b/netbox_config_diff/views/__init__.py new file mode 100644 index 0000000..fc696be --- /dev/null +++ b/netbox_config_diff/views/__init__.py @@ -0,0 +1,29 @@ +from .compliance import ( + ConfigComplianceBulkDeleteView, + ConfigComplianceListView, + PlatformSettingBulkDeleteView, + PlatformSettingBulkEditView, + PlatformSettingEditView, + PlatformSettingListView, +) +from .configuration import ( + ConfigurationRequestEditView, + ConfigurationRequestListView, + JobListView, + SubstituteEditView, + SubstituteListView, +) + +__all__ = ( + "ConfigComplianceBulkDeleteView", + "ConfigComplianceListView", + "ConfigurationRequestEditView", + "ConfigurationRequestListView", + "JobListView", + "PlatformSettingBulkDeleteView", + "PlatformSettingBulkEditView", + "PlatformSettingEditView", + "PlatformSettingListView", + "SubstituteEditView", + "SubstituteListView", +) diff --git a/netbox_config_diff/views.py b/netbox_config_diff/views/compliance.py similarity index 94% rename from netbox_config_diff/views.py rename to netbox_config_diff/views/compliance.py index 4d486b3..f6cfd40 100644 --- a/netbox_config_diff/views.py +++ b/netbox_config_diff/views/compliance.py @@ -4,15 +4,15 @@ from netbox.views import generic from utilities.views import ViewTab, register_model_view -from .filtersets import ConfigComplianceFilterSet, PlatformSettingFilterSet -from .forms import ( +from netbox_config_diff.filtersets import ConfigComplianceFilterSet, PlatformSettingFilterSet +from netbox_config_diff.forms import ( ConfigComplianceFilterForm, PlatformSettingBulkEditForm, PlatformSettingFilterForm, PlatformSettingForm, ) -from .models import ConfigCompliance, PlatformSetting -from .tables import ConfigComplianceTable, PlatformSettingTable +from netbox_config_diff.models import ConfigCompliance, PlatformSetting +from netbox_config_diff.tables import ConfigComplianceTable, PlatformSettingTable class BaseConfigComplianceConfigView(generic.ObjectView): diff --git a/netbox_config_diff/views/configuration.py b/netbox_config_diff/views/configuration.py new file mode 100644 index 0000000..70d86d9 --- /dev/null +++ b/netbox_config_diff/views/configuration.py @@ -0,0 +1,332 @@ +import django_rq +from core.choices import JobStatusChoices +from core.filtersets import JobFilterSet +from core.forms import JobFilterForm +from core.models import Job +from core.tables import JobTable +from django.contrib import messages +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404, redirect, render +from netbox.constants import RQ_QUEUE_DEFAULT +from netbox.views import generic +from netbox.views.generic.base import BaseObjectView +from rq.exceptions import InvalidJobOperation +from utilities.forms import restrict_form_fields +from utilities.rqworker import get_workers_for_queue +from utilities.utils import normalize_querydict +from utilities.views import ViewTab, register_model_view + +from netbox_config_diff.choices import ConfigurationRequestStatusChoices +from netbox_config_diff.filtersets import ConfigurationRequestFilterSet, SubstituteFilterSet +from netbox_config_diff.forms import ( + ConfigurationRequestFilterForm, + ConfigurationRequestForm, + ConfigurationRequestScheduleForm, + SubstituteFilterForm, + SubstituteForm, +) +from netbox_config_diff.models import ConfigurationRequest, Substitute +from netbox_config_diff.tables import ConfigurationRequestTable, SubstituteTable + + +@register_model_view(ConfigurationRequest) +class ConfigurationRequestView(generic.ObjectView): + queryset = ConfigurationRequest.objects.all() + + def get_extra_context(self, request, instance): + job = Job.objects.filter( + object_id=instance.pk, name__contains="push_configs", status__in=JobStatusChoices.TERMINAL_STATE_CHOICES + ).first() + + return { + "job": job, + } + + +class ConfigurationRequestListView(generic.ObjectListView): + queryset = ConfigurationRequest.objects.prefetch_related( + "devices", "created_by", "approved_by", "scheduled_by", "tags" + ) + filterset = ConfigurationRequestFilterSet + filterset_form = ConfigurationRequestFilterForm + table = ConfigurationRequestTable + + +@register_model_view(ConfigurationRequest, "edit") +class ConfigurationRequestEditView(generic.ObjectEditView): + queryset = ConfigurationRequest.objects.all() + form = ConfigurationRequestForm + + def get(self, request, *args, **kwargs): + obj = self.get_object(**kwargs) + if obj.finished: + messages.error(request, f"{obj} is finished, you can't change it.") + return redirect(obj.get_absolute_url()) + obj = self.alter_object(obj, request, args, kwargs) + model = self.queryset.model + + initial_data = normalize_querydict(request.GET) + initial_data["created_by"] = request.user.pk + form = self.form(instance=obj, initial=initial_data) + restrict_form_fields(form, request.user) + + return render( + request, + self.template_name, + { + "model": model, + "object": obj, + "form": form, + "return_url": self.get_return_url(request, obj), + **self.get_extra_context(request, obj), + }, + ) + + +@register_model_view(ConfigurationRequest, "delete") +class ConfigurationRequestDeleteView(generic.ObjectDeleteView): + queryset = ConfigurationRequest.objects.all() + + +@register_model_view(ConfigurationRequest, "diffs") +class ConfigurationRequestDiffsView(generic.ObjectView): + queryset = ConfigurationRequest.objects.all() + template_name = "netbox_config_diff/configurationrequest/diffs.html" + tab = ViewTab( + label="Diffs", + permission="netbox_config_diff.view_configurationrequest", + weight=500, + ) + + def get_extra_context(self, request, instance): + job = Job.objects.filter(object_id=instance.pk, name__contains="collect_diffs").first() + + return { + "job": job, + } + + +@register_model_view(ConfigurationRequest, "scheduled_job", "scheduled-job") +class ConfigurationRequestScheduledJobView(generic.ObjectChildrenView): + queryset = ConfigurationRequest.objects.all() + child_model = Job + table = JobTable + template_name = "generic/object_children.html" + tab = ViewTab( + label="Scheduled job", + badge=lambda obj: obj.jobs.filter( + object_id=obj.pk, name__contains="push_configs", status=JobStatusChoices.STATUS_SCHEDULED + ).count(), + permission="netbox_config_diff.view_configurationrequest", + weight=510, + hide_if_empty=True, + ) + + def get_children(self, request, parent): + return Job.objects.restrict(request.user, "view").filter( + object_id=parent.pk, name__contains="push_configs", status=JobStatusChoices.STATUS_SCHEDULED + ) + + def get_permitted_actions(self, user, model=None): + return [] + + def get_table(self, data, request, bulk_actions=True): + table = self.table(data, user=request.user, exclude=("actions",)) + table.configure(request) + + return table + + +@register_model_view(ConfigurationRequest, "approve") +class ConfigurationRequestApproveView(BaseObjectView): + queryset = ConfigurationRequest.objects.all() + + def get_required_permission(self): + return "netbox_config_diff.approve_configurationrequest" + + def get(self, request, pk): + obj = get_object_or_404(self.queryset, pk=pk) + return redirect(obj.get_absolute_url()) + + def post(self, request, pk): + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + messages.error(request, f"{obj} is finished, you can't change it.") + return redirect(obj.get_absolute_url()) + + if obj.approved_by: + obj.approved_by = None + obj.status = ConfigurationRequestStatusChoices.CREATED + if obj.scheduled: + obj.scheduled = None + obj.scheduled_by = None + messages.success(request, f"Unapproved {obj}") + else: + obj.approved_by = User.objects.filter(pk=request.user.pk).first() + obj.status = ConfigurationRequestStatusChoices.APPROVED + messages.success(request, f"Approved {obj}") + obj.save() + + return redirect(obj.get_absolute_url()) + + +@register_model_view(ConfigurationRequest, "schedule") +class ConfigurationRequestScheduleView(generic.ObjectEditView): + queryset = ConfigurationRequest.objects.all() + form = ConfigurationRequestScheduleForm + + def get_required_permission(self): + return "netbox_config_diff.approve_configurationrequest" + + def get(self, request, *args, **kwargs): + obj = self.get_object(**kwargs) + if obj.finished: + messages.error(request, f"{obj} is finished, you can't change it.") + return redirect(obj.get_absolute_url()) + if obj.scheduled: + messages.error(request, f"{obj} already scheduled.") + return redirect(obj.get_absolute_url()) + obj = self.alter_object(obj, request, args, kwargs) + model = self.queryset.model + + initial_data = normalize_querydict(request.GET) + initial_data["scheduled_by"] = request.user.pk + initial_data["status"] = ConfigurationRequestStatusChoices.SCHEDULED + form = self.form(instance=obj, initial=initial_data) + restrict_form_fields(form, request.user) + + return render( + request, + self.template_name, + { + "model": model, + "object": obj, + "form": form, + "return_url": self.get_return_url(request, obj), + **self.get_extra_context(request, obj), + }, + ) + + def post(self, request, pk): + if not request.user.has_perm("netbox_config_diff.approve_configurationrequest"): + return HttpResponseForbidden() + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + messages.error(request, f"{obj} is finished, you can't change it.") + elif not get_workers_for_queue("default"): + messages.error(request, "Unable to run script: RQ worker process not running.") + elif obj.scheduled_by: + messages.error(request, f"{obj} already scheduled.") + elif obj.approved_by is None: + messages.error(request, f"Approve {obj} before schedule.") + else: + form = self.form(data=request.POST, files=request.FILES, instance=obj) + if not form.is_valid(): + return render( + request, + self.template_name, + context={ + "object": obj, + "form": form, + "return_url": self.get_return_url(request, obj), + **self.get_extra_context(request, obj), + }, + ) + form.save() + obj.enqueue_job(request, "push_configs", schedule_at=form.cleaned_data["scheduled"]) + messages.success(request, f"Scheduled job for {obj}") + return redirect(obj.get_absolute_url()) + + +@register_model_view(ConfigurationRequest, "unschedule") +class ConfigurationRequestUnscheduleView(BaseObjectView): + queryset = ConfigurationRequest.objects.all() + + def get_required_permission(self): + return "netbox_config_diff.approve_configurationrequest" + + def get(self, request, pk): + obj = get_object_or_404(self.queryset, pk=pk) + return redirect(obj.get_absolute_url()) + + def post(self, request, pk): + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + messages.error(request, f"{obj} is finished, you can't change it.") + return redirect(obj.get_absolute_url()) + + if obj.scheduled_by: + obj.scheduled = None + obj.scheduled_by = None + obj.status = ConfigurationRequestStatusChoices.APPROVED + obj.save() + queue = django_rq.get_queue(RQ_QUEUE_DEFAULT) + for result in obj.jobs.filter(name__contains="push_configs", status=JobStatusChoices.STATUS_SCHEDULED): + result.delete() + if job := queue.fetch_job(str(result.job_id)): + try: + job.cancel() + except InvalidJobOperation: + pass + messages.success(request, f"Unscheduled {obj}") + + return redirect(obj.get_absolute_url()) + + +@register_model_view(ConfigurationRequest, name="collectdiffs", path="collect-diffs") +class ConfigurationRequestCollectDiffsView(BaseObjectView): + queryset = ConfigurationRequest.objects.all() + + def get_required_permission(self): + return "netbox_config_diff.change_configurationrequest" + + def get(self, request, pk): + obj = get_object_or_404(self.queryset, pk=pk) + return redirect(obj.get_absolute_url()) + + def post(self, request, pk): + obj = get_object_or_404(self.queryset, pk=pk) + if obj.finished: + messages.error(request, f"{obj} is finished, you can't change it.") + elif not get_workers_for_queue("default"): + messages.error(request, "Unable to run: RQ worker process not running.") + else: + obj.enqueue_job(request, "collect_diffs") + messages.success(request, f"Start collecting configuration diffs for {obj}") + + return redirect(obj.get_absolute_url()) + + +class JobListView(generic.ObjectListView): + queryset = Job.objects.filter( + object_type=ContentType.objects.get(app_label="netbox_config_diff", model="configurationrequest") + ) + filterset = JobFilterSet + filterset_form = JobFilterForm + table = JobTable + actions = ("export", "delete", "bulk_delete") + + +@register_model_view(Substitute) +class SubstituteView(generic.ObjectView): + queryset = Substitute.objects.all() + + +class SubstituteListView(generic.ObjectListView): + queryset = Substitute.objects.prefetch_related("platform_setting", "tags") + filterset = SubstituteFilterSet + filterset_form = SubstituteFilterForm + table = SubstituteTable + + +@register_model_view(Substitute, "edit") +class SubstituteEditView(generic.ObjectEditView): + queryset = Substitute.objects.all() + form = SubstituteForm + + +@register_model_view(Substitute, "delete") +class SubstituteDeleteView(generic.ObjectDeleteView): + queryset = Substitute.objects.all() diff --git a/requirements/base.txt b/requirements/base.txt index 5488b7a..80304ec 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,3 +1,4 @@ netutils==1.5.0 -scrapli[asyncssh]==2023.01.30 -scrapli-community==2023.01.30 +scrapli[asyncssh]==2023.07.30 +scrapli-cfg==2023.07.30 +scrapli-community==2023.07.30 diff --git a/requirements/dev.txt b/requirements/dev.txt index 361a62e..844be90 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,2 +1,2 @@ -black==23.7.0 -ruff==0.0.280 \ No newline at end of file +black==23.10.0 +ruff==0.1.0 diff --git a/tests/factories.py b/tests/factories.py index 59b6d38..408efbb 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -2,6 +2,7 @@ import factory.fuzzy from core.models import DataSource from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Site +from extras.models import ConfigTemplate from factory.django import DjangoModelFactory from ipam.models import IPAddress @@ -56,6 +57,14 @@ class Meta: model = IPAddress +class ConfigTemplateFactory(DjangoModelFactory): + name = factory.Sequence(lambda n: f"configcontext-{n}") + template_code = factory.fuzzy.FuzzyText() + + class Meta: + model = ConfigTemplate + + class DeviceFactory(DjangoModelFactory): name = factory.Sequence(lambda n: f"device-{n}") site = factory.SubFactory(SiteFactory) @@ -63,6 +72,7 @@ class DeviceFactory(DjangoModelFactory): device_role = factory.SubFactory(DeviceRoleFactory) platform = factory.SubFactory(PlatformFactory) primary_ip4 = factory.SubFactory(IPAddressFactory) + config_template = factory.SubFactory(ConfigTemplateFactory) class Meta: model = Device diff --git a/tests/test_compliance.py b/tests/test_compliance.py index 8dc8a5c..f274ec8 100644 --- a/tests/test_compliance.py +++ b/tests/test_compliance.py @@ -133,7 +133,7 @@ def test_devicedataclass_to_scrapli(devicedataclass_data: "DeviceDataClassData") "diff, error, status", [ ("", "asyncio.exceptions.CancelledError", "errored"), - ("there is a diff", "", "failed"), + ("there is a diff", "", "diff"), ("", "", "compliant"), ], ids=["errored", "failed", "compliant"], diff --git a/tests/test_compliance_utils.py b/tests/test_compliance_utils.py index 234ea17..4178a26 100644 --- a/tests/test_compliance_utils.py +++ b/tests/test_compliance_utils.py @@ -36,7 +36,7 @@ ids=["part of line", "full line", "no effect"], ) def test_exclude_lines(regex: str, expected: str) -> None: - assert exclude_lines(ACTUAL_CONFIG, regex) == expected + assert exclude_lines(ACTUAL_CONFIG, regex.splitlines()) == expected @pytest.mark.parametrize(