diff --git a/changes/847.fixed b/changes/847.fixed new file mode 100644 index 000000000..965e96ba6 --- /dev/null +++ b/changes/847.fixed @@ -0,0 +1,2 @@ +1. Removes the conditional UI navigation and views based on config settings. +2. Allows users to update configuration parameters in the UI - specifically enabling/disabling backups, intended, plan, deploy, and compliance \ No newline at end of file diff --git a/docs/admin/install.md b/docs/admin/install.md index 6ccb9ebe2..10549ed14 100644 --- a/docs/admin/install.md +++ b/docs/admin/install.md @@ -93,19 +93,34 @@ sudo systemctl restart nautobot nautobot-worker nautobot-scheduler ## App Configuration -The app behavior can be controlled with the following list of settings: +The app behavior can be controlled with the following list of settings. All of these keys have default values in `nautobot_golden_config`, but can be overridden via the Nautobot UI on the **Golden Config Settings** page. If you’d like to update specific toggles—such as whether backups, compliance checks, or config plans are enabled—without editing your local configuration file. -!!! note - The `enable_backup`, `enable_compliance`, `enable_intended`, `enable_sotagg`, `enable_plan`, `enable_deploy`, and `enable_postprocessing` will toggle inclusion of the entire component. +### Managing Feature Toggles in the UI + +You can easily manage these feature toggles in the UI: + + 1. Navigate to **Golden Config > Golden Config Settings**. + 2. Select **Default Settings** (or your chosen Setting if multiple exist). + 3. Click **Edit** and adjust the relevant toggles (e.g., **Enable Backup**, **Enable Compliance**, **Enable Intended**, **Enable Plan**, **Enable Deploy**). + +![Golden Config Settings List](../images/golden-config-settings-list.png) + +![Golden Config Settings - Backup and Intended](../images/golden-config-settings-01.png) + +![Golden Config Settings - Additional Configuration](../images/golden-config-settings-02.png) + +As shown, you can toggle any of the features on or off. Once saved, the job logic in Nautobot Golden Config will reflect those changes immediately, allowing you to control which aspects of Golden Config you want to enable. + +## List of Settings | Key | Example | Default | Description | | ------------------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| enable_backup | True | True | A boolean to represent whether or not to run backup configurations within the app. | -| enable_compliance | True | True | A boolean to represent whether or not to run the compliance process within the app. | -| enable_intended | True | True | A boolean to represent whether or not to generate intended configurations within the app. | +| enable_backup | True | True | A boolean to represent whether or not to run backup configurations within the app. Can be changed in UI under **Enable Backup** in Golden Config Settings. | +| enable_compliance | True | True | A boolean to represent whether or not to run the compliance process within the app. Can be changed in UI under **Compliance Enabled** in Golden Config Settings. | +| enable_intended | True | True | A boolean to represent whether or not to generate intended configurations within the app. Can be changed in UI under **Intended Enabled** in Golden Config Settings. | | enable_sotagg | True | True | A boolean to represent whether or not to provide a GraphQL query per device to allow the intended configuration to provide data variables to the app. | -| enable_plan | True | True | A boolean to represent whether or not to allow the config plan job to run. | -| enable_deploy | True | True | A boolean to represent whether or not to be able to deploy configs to network devices. | +| enable_plan | True | True | A boolean to represent whether or not to allow the config plan job to run. Can be changed in UI under **Plan Enabled** in Golden Config Settings. | +| enable_deploy | True | True | A boolean to represent whether or not to be able to deploy configs to network devices. Can be changed in UI under **Deploy Enabled** in Golden Config Settings. | | enable_postprocessing | True | False | A boolean to represent whether or not to generate intended configurations to push, with extra processing such as secrets rendering. | | default_deploy_status | "Not Approved" | "Not Approved" | A string that will be the name of the status you want as the default when create new config plans, you MUST create the status yourself before starting the app. | | postprocessing_callables | ['mypackage.myfunction'] | [] | A list of function paths, in dotted format, that are appended to the available methods for post-processing the intended configuration, for instance, the `render_secrets`. | diff --git a/docs/admin/troubleshooting/E3032.md b/docs/admin/troubleshooting/E3032.md new file mode 100644 index 000000000..8b3a9c169 --- /dev/null +++ b/docs/admin/troubleshooting/E3032.md @@ -0,0 +1,22 @@ +# E3032 Details + +## Message emitted: + +`E3032: Disabled Golden Config setting.` + +## Description: + +This error occurs when a required feature is disabled in Golden Config, preventing the task from executing properly. + +The features affected by this error include: +* Configuration backup +* Intended configuration +* Compliance execution + +## Troubleshooting: + +Review the exception message to identify the cause of the failure. + +## Recommendation: + +Enable the feature in the Golden Configuration Settings to execute the task. diff --git a/docs/admin/troubleshooting/E3033.md b/docs/admin/troubleshooting/E3033.md new file mode 100644 index 000000000..1b17c7e9a --- /dev/null +++ b/docs/admin/troubleshooting/E3033.md @@ -0,0 +1,17 @@ +# E3033 Details + +## Message emitted: + +`E3033: Missing required settings.` + +## Description: + +This error occurs when a feature is missing in Golden Config, but is required to execute the task. Currently, this applies to the intended feature, which requires an SoT Agg (GraphQL) query to render templates from device data. + +## Troubleshooting: + +Review the exception message to determine the cause of the failure. + +## Recommendation: + +Double-check the intended and template configurations to ensure all required fields are populated and enabled. diff --git a/docs/admin/troubleshooting/E3034.md b/docs/admin/troubleshooting/E3034.md new file mode 100644 index 000000000..4c4c70286 --- /dev/null +++ b/docs/admin/troubleshooting/E3034.md @@ -0,0 +1,17 @@ +# E3034 Details + +## Message emitted: + +`E3034: Config plan creation is disabled in Golden Config settings.` + +## Description: + +This error occurs when the config plan feature is disabled in Golden Config. + +## Troubleshooting: + +Review the exception message to determine the cause of the failure. + +## Recommendation: + +Enable the config plan feature in the Golden Config settings to resolve the issue. diff --git a/docs/admin/troubleshooting/E3035.md b/docs/admin/troubleshooting/E3035.md new file mode 100644 index 000000000..68d897ebe --- /dev/null +++ b/docs/admin/troubleshooting/E3035.md @@ -0,0 +1,16 @@ +# E3035 Details + +## Message emitted: + +`E3035: Device is not in scope for config plans.` + +## Description: + +This occurs when a config plan attempts to create a plan for a device that is out of scope. + +## Troubleshooting: + +Review the exception message for the cause of the failure. + +## Recommendation: + diff --git a/docs/admin/troubleshooting/E3036.md b/docs/admin/troubleshooting/E3036.md new file mode 100644 index 000000000..37e22045e --- /dev/null +++ b/docs/admin/troubleshooting/E3036.md @@ -0,0 +1,17 @@ +# E3036 Details + +## Message emitted: + +`E3036: Configuration deployment is disabled in Golden Config settings.` + +## Description: + +This error occurs when the configuration deployment feature is disabled in Golden Config, but is required to execute the current task. + +## Troubleshooting: + +Review the exception message for the cause of the failure. + +## Recommendation: + +Enable the configuration deployment feature if necessary. diff --git a/docs/admin/troubleshooting/E3037.md b/docs/admin/troubleshooting/E3037.md new file mode 100644 index 000000000..ced95e09d --- /dev/null +++ b/docs/admin/troubleshooting/E3037.md @@ -0,0 +1,15 @@ +# E3037 Details + +## Message emitted: + +`E3037: Device is no longer in scope for deployments.` + +## Description: + +This error occurs when a device is out of scope for a configuration deployment. + +## Troubleshooting: + +Review the exception message for the cause of the failure. + +## Recommendation: diff --git a/docs/images/golden-config-settings-01.png b/docs/images/golden-config-settings-01.png new file mode 100644 index 000000000..52f8fa197 Binary files /dev/null and b/docs/images/golden-config-settings-01.png differ diff --git a/docs/images/golden-config-settings-02.png b/docs/images/golden-config-settings-02.png new file mode 100644 index 000000000..9b54de376 Binary files /dev/null and b/docs/images/golden-config-settings-02.png differ diff --git a/docs/images/golden-config-settings-list.png b/docs/images/golden-config-settings-list.png new file mode 100644 index 000000000..fc41fa106 Binary files /dev/null and b/docs/images/golden-config-settings-list.png differ diff --git a/mkdocs.yml b/mkdocs.yml index 11795a904..48d36852a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -162,6 +162,12 @@ nav: - E3029: "admin/troubleshooting/E3029.md" - E3030: "admin/troubleshooting/E3030.md" - E3031: "admin/troubleshooting/E3031.md" + - E3032: "admin/troubleshooting/E3032.md" + - E3033: "admin/troubleshooting/E3033.md" + - E3034: "admin/troubleshooting/E3034.md" + - E3035: "admin/troubleshooting/E3035.md" + - E3036: "admin/troubleshooting/E3036.md" + - E3037: "admin/troubleshooting/E3037.md" - Migrating To v2: "admin/migrating_to_v2.md" - Release Notes: - "admin/release_notes/index.md" diff --git a/nautobot_golden_config/datasources.py b/nautobot_golden_config/datasources.py index 152177eca..d4e04275b 100644 --- a/nautobot_golden_config/datasources.py +++ b/nautobot_golden_config/datasources.py @@ -10,7 +10,9 @@ from nautobot_golden_config.exceptions import MissingReference from nautobot_golden_config.models import ComplianceFeature, ComplianceRule, ConfigRemove, ConfigReplace -from nautobot_golden_config.utilities.constant import ENABLE_BACKUP, ENABLE_COMPLIANCE, ENABLE_INTENDED +from nautobot_golden_config.utilities.helper import get_golden_config_settings + +settings = get_golden_config_settings() def refresh_git_jinja(repository_record, job_result, delete=False): # pylint: disable=unused-argument @@ -215,7 +217,7 @@ def update_git_gc_properties(golden_config_path, job_result, gc_config_item): # datasource_contents = [] -if ENABLE_INTENDED or ENABLE_COMPLIANCE: +if settings.intended_enabled or settings.compliance_enabled: datasource_contents.append( ( "extras.gitrepository", @@ -227,7 +229,7 @@ def update_git_gc_properties(golden_config_path, job_result, gc_config_item): # ), ) ) -if ENABLE_INTENDED: +if settings.intended_enabled: datasource_contents.append( ( "extras.gitrepository", @@ -239,7 +241,7 @@ def update_git_gc_properties(golden_config_path, job_result, gc_config_item): # ), ) ) -if ENABLE_BACKUP or ENABLE_COMPLIANCE: +if settings.backup_enabled or settings.compliance_enabled: datasource_contents.append( ( "extras.gitrepository", diff --git a/nautobot_golden_config/jobs.py b/nautobot_golden_config/jobs.py index 7cab2b532..dcb1973fe 100644 --- a/nautobot_golden_config/jobs.py +++ b/nautobot_golden_config/jobs.py @@ -33,7 +33,6 @@ from nautobot_golden_config.nornir_plays.config_compliance import config_compliance from nautobot_golden_config.nornir_plays.config_deployment import config_deployment from nautobot_golden_config.nornir_plays.config_intended import config_intended -from nautobot_golden_config.utilities import constant from nautobot_golden_config.utilities.config_plan import ( config_plan_default_status, generate_config_set_from_compliance_feature, @@ -42,8 +41,11 @@ from nautobot_golden_config.utilities.git import GitRepo from nautobot_golden_config.utilities.helper import ( get_device_to_settings_map, + get_golden_config_settings, get_job_filter, update_dynamic_groups_cache, + verify_config_plan_eligibility, + verify_deployment_eligibility, ) InventoryPluginRegister.register("nautobot-inventory", NautobotORMInventory) @@ -54,11 +56,13 @@ def get_repo_types_for_job(job_name): """Logic to determine which repo_types are needed based on job + plugin settings.""" repo_types = [] - if constant.ENABLE_BACKUP and job_name == "nautobot_golden_config.jobs.BackupJob": - repo_types.extend(["backup_repository"]) - if constant.ENABLE_INTENDED and job_name == "nautobot_golden_config.jobs.IntendedJob": + settings = get_golden_config_settings() + + if settings.backup_enabled and job_name == "nautobot_golden_config.jobs.BackupJob": + repo_types.append("backup_repository") + if settings.intended_enabled and job_name == "nautobot_golden_config.jobs.IntendedJob": repo_types.extend(["jinja_repository", "intended_repository"]) - if constant.ENABLE_COMPLIANCE and job_name == "nautobot_golden_config.jobs.ComplianceJob": + if settings.compliance_enabled and job_name == "nautobot_golden_config.jobs.ComplianceJob": repo_types.extend(["intended_repository", "backup_repository"]) if "All" in job_name: repo_types.extend(["backup_repository", "jinja_repository", "intended_repository"]) @@ -67,6 +71,7 @@ def get_repo_types_for_job(job_name): def get_refreshed_repos(job_obj, repo_types, data=None): """Small wrapper to pull latest branch, and return a GitRepo app specific object.""" + settings = get_golden_config_settings() dynamic_groups = DynamicGroup.objects.exclude(golden_config_setting__isnull=True) repository_records = set() for group in dynamic_groups: @@ -93,15 +98,16 @@ def get_refreshed_repos(job_obj, repo_types, data=None): commit = False if ( - constant.ENABLE_INTENDED + settings.intended_enabled and "nautobot_golden_config.intendedconfigs" in git_repo.nautobot_repo_obj.provided_contents ): commit = True if ( - constant.ENABLE_BACKUP + settings.backup_enabled and "nautobot_golden_config.backupconfigs" in git_repo.nautobot_repo_obj.provided_contents ): commit = True + repositories[str(git_repo.nautobot_repo_obj.id)] = {"repo_obj": git_repo, "to_commit": commit} return repositories @@ -228,7 +234,7 @@ def __init__(self, *args, **kwargs): class ComplianceJob(GoldenConfigJobMixin, FormEntry): - """Job to to run the compliance engine.""" + """Job to run the compliance engine.""" class Meta: """Meta object boilerplate for compliance.""" @@ -240,11 +246,13 @@ class Meta: @gc_repos def run(self, *args, **data): # pylint: disable=unused-argument """Run config compliance report script.""" - self.logger.warning("Starting config compliance nornir play.") - if not constant.ENABLE_COMPLIANCE: - self.logger.critical("Compliance is disabled in application settings.") - raise ValueError("Compliance is disabled in application settings.") - config_compliance(self) + try: + self.logger.warning("Starting config compliance nornir play.") + config_compliance(self) + except NornirNautobotException as error: + error_msg = str(error) + self.logger.error(error_msg) + raise NornirNautobotException(error_msg) from error class IntendedJob(GoldenConfigJobMixin, FormEntry): @@ -260,11 +268,13 @@ class Meta: @gc_repos def run(self, *args, **data): # pylint: disable=unused-argument """Run config generation script.""" - self.logger.debug("Building device settings mapping and running intended config nornir play.") - if not constant.ENABLE_INTENDED: - self.logger.critical("Intended Generation is disabled in application settings.") - raise ValueError("Intended Generation is disabled in application settings.") - config_intended(self) + try: + self.logger.debug("Building device settings mapping and running intended config nornir play.") + config_intended(self) + except NornirNautobotException as error: + error_msg = str(error) + self.logger.error(error_msg) + raise NornirNautobotException(error_msg) from error class BackupJob(GoldenConfigJobMixin, FormEntry): @@ -280,11 +290,13 @@ class Meta: @gc_repos def run(self, *args, **data): # pylint: disable=unused-argument """Run config backup process.""" - self.logger.debug("Starting config backup nornir play.") - if not constant.ENABLE_BACKUP: - self.logger.critical("Backups are disabled in application settings.") - raise ValueError("Backups are disabled in application settings.") - config_backup(self) + try: + self.logger.debug("Starting config backup nornir play.") + config_backup(self) + except NornirNautobotException as error: + error_msg = str(error) + self.logger.error(error_msg) + raise NornirNautobotException(error_msg) from error class AllGoldenConfig(GoldenConfigJobMixin): @@ -305,10 +317,11 @@ def run(self, *args, **data): # pylint: disable=unused-argument, too-many-branc current_repos = gc_repo_prep(job=self, data=data) failed_jobs = [] error_msg, jobs_list = "", "All" + settings = get_golden_config_settings() for enabled, play in [ - (constant.ENABLE_INTENDED, config_intended), - (constant.ENABLE_BACKUP, config_backup), - (constant.ENABLE_COMPLIANCE, config_compliance), + (settings.intended_enabled, config_intended), + (settings.backup_enabled, config_backup), + (settings.compliance_enabled, config_compliance), ]: try: if enabled: @@ -354,10 +367,11 @@ def run(self, *args, **data): # pylint: disable=unused-argument, too-many-branc current_repos = gc_repo_prep(job=self, data=data) failed_jobs = [] error_msg, jobs_list = "", "All" + settings = get_golden_config_settings() for enabled, play in [ - (constant.ENABLE_INTENDED, config_intended), - (constant.ENABLE_BACKUP, config_backup), - (constant.ENABLE_COMPLIANCE, config_compliance), + (settings.intended_enabled, config_intended), + (settings.backup_enabled, config_backup), + (settings.compliance_enabled, config_compliance), ]: try: if enabled: @@ -503,16 +517,22 @@ def _generate_config_plan_from_manual(self): def run(self, **data): """Run config plan generation process.""" - self.logger.debug("Updating Dynamic Group Cache.") - update_dynamic_groups_cache() self.logger.debug("Starting config plan generation job.") + settings = get_golden_config_settings() + self._validate_inputs(data) try: self._device_qs = get_job_filter(data) + + # Verify plan eligibility for each device + for device in self._device_qs: + verify_config_plan_eligibility(self.logger, device, settings) + except NornirNautobotException as error: error_msg = str(error) self.logger.error(error_msg) raise NornirNautobotException(error_msg) from error + if self._plan_type in ["intended", "missing", "remediation"]: self.logger.debug("Starting config plan generation for compliance features.") self._generate_config_plan_from_feature() @@ -549,6 +569,12 @@ def run(self, **data): # pylint: disable=arguments-differ update_dynamic_groups_cache() self.logger.debug("Starting config plan deployment job.") self.data = data + settings = get_golden_config_settings() + + # Verify deployment eligibility for each config plan + for config_plan in self.data["config_plan"]: + verify_deployment_eligibility(self.logger, config_plan, settings) + config_deployment(self) diff --git a/nautobot_golden_config/migrations/0031_goldenconfigsetting_backup_enabled_and_more.py b/nautobot_golden_config/migrations/0031_goldenconfigsetting_backup_enabled_and_more.py new file mode 100644 index 000000000..8940a98bf --- /dev/null +++ b/nautobot_golden_config/migrations/0031_goldenconfigsetting_backup_enabled_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.17 on 2024-12-19 20:51 + +from django.db import migrations, models + +from nautobot_golden_config.utilities.constant import ( + ENABLE_BACKUP, + ENABLE_COMPLIANCE, + ENABLE_DEPLOY, + ENABLE_INTENDED, + ENABLE_PLAN, +) + + +class Migration(migrations.Migration): + dependencies = [ + ("nautobot_golden_config", "0030_alter_goldenconfig_device"), + ] + + operations = [ + migrations.AddField( + model_name="goldenconfigsetting", + name="backup_enabled", + field=models.BooleanField(default=ENABLE_BACKUP), + ), + migrations.AddField( + model_name="goldenconfigsetting", + name="compliance_enabled", + field=models.BooleanField(default=ENABLE_COMPLIANCE), + ), + migrations.AddField( + model_name="goldenconfigsetting", + name="deploy_enabled", + field=models.BooleanField(default=ENABLE_DEPLOY), + ), + migrations.AddField( + model_name="goldenconfigsetting", + name="intended_enabled", + field=models.BooleanField(default=ENABLE_INTENDED), + ), + migrations.AddField( + model_name="goldenconfigsetting", + name="plan_enabled", + field=models.BooleanField(default=ENABLE_PLAN), + ), + ] diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index a8a8272fb..456b4234f 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -22,7 +22,15 @@ from xmldiff import actions, main from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice -from nautobot_golden_config.utilities.constant import ENABLE_SOTAGG, PLUGIN_CFG +from nautobot_golden_config.utilities.constant import ( + ENABLE_BACKUP, + ENABLE_COMPLIANCE, + ENABLE_DEPLOY, + ENABLE_INTENDED, + ENABLE_PLAN, + ENABLE_SOTAGG, + PLUGIN_CFG, +) LOGGER = logging.getLogger(__name__) GRAPHQL_STR_START = "query ($device_id: ID!)" @@ -543,6 +551,11 @@ class GoldenConfigSetting(PrimaryModel): # pylint: disable=too-many-ancestors verbose_name="Backup Path in Jinja Template Form", help_text="The Jinja path representation of where the backup file will be found. The variable `obj` is available as the device instance object of a given device, as is the case for all Jinja templates. e.g. `{{obj.location.name|slugify}}/{{obj.name}}.cfg`", ) + backup_enabled = models.BooleanField( + default=ENABLE_BACKUP, + verbose_name="Enable Backup", + help_text="Whether or not backups are performed by Golden Config. This can be disabled if backups are fetched from another process.", + ) intended_repository = models.ForeignKey( to="extras.GitRepository", on_delete=models.PROTECT, @@ -557,6 +570,11 @@ class GoldenConfigSetting(PrimaryModel): # pylint: disable=too-many-ancestors verbose_name="Intended Path in Jinja Template Form", help_text="The Jinja path representation of where the generated file will be placed. e.g. `{{obj.location.name|slugify}}/{{obj.name}}.cfg`", ) + intended_enabled = models.BooleanField( + default=ENABLE_INTENDED, + verbose_name="Enable Intended", + help_text="Whether or not intended config tasks are performed by Golden Config. This can be disabled if intended configs are fetched from another process.", + ) jinja_repository = models.ForeignKey( to="extras.GitRepository", on_delete=models.PROTECT, @@ -588,6 +606,21 @@ class GoldenConfigSetting(PrimaryModel): # pylint: disable=too-many-ancestors on_delete=models.PROTECT, related_name="golden_config_setting", ) + compliance_enabled = models.BooleanField( + default=ENABLE_COMPLIANCE, + verbose_name="Enable Compliance", + help_text="Whether or not compliance tasks are performed by Golden Config.", + ) + plan_enabled = models.BooleanField( + default=ENABLE_PLAN, + verbose_name="Enable Config Plan", + help_text="Whether or not config plan tasks are performed by Golden Config.", + ) + deploy_enabled = models.BooleanField( + default=ENABLE_DEPLOY, + verbose_name="Enable Deploy", + help_text="Whether or not deploy tasks are performed by Golden Config.", + ) is_dynamic_group_associable_model = False objects = GoldenConfigSettingManager() diff --git a/nautobot_golden_config/navigation.py b/nautobot_golden_config/navigation.py index 0b3aa46d2..ed4609480 100644 --- a/nautobot_golden_config/navigation.py +++ b/nautobot_golden_config/navigation.py @@ -2,150 +2,115 @@ from nautobot.apps.ui import NavMenuAddButton, NavMenuGroup, NavMenuItem, NavMenuTab -from nautobot_golden_config.utilities.constant import ENABLE_BACKUP, ENABLE_COMPLIANCE, ENABLE_PLAN - -items_operate = [ - NavMenuItem( - link="plugins:nautobot_golden_config:goldenconfig_list", - name="Config Overview", - permissions=["nautobot_golden_config.view_goldenconfig"], - ) -] - -items_setup = [] - -if ENABLE_COMPLIANCE: - items_operate.append( - NavMenuItem( - link="plugins:nautobot_golden_config:configcompliance_list", - name="Config Compliance", - permissions=["nautobot_golden_config.view_configcompliance"], - ) - ) - -if ENABLE_COMPLIANCE: - items_setup.append( - NavMenuItem( - link="plugins:nautobot_golden_config:compliancerule_list", - name="Compliance Rules", - permissions=["nautobot_golden_config.view_compliancerule"], - buttons=( - NavMenuAddButton( - link="plugins:nautobot_golden_config:compliancerule_add", - permissions=["nautobot_golden_config.add_compliancerule"], - ), - ), - ) - ) - -if ENABLE_COMPLIANCE: - items_setup.append( - NavMenuItem( - link="plugins:nautobot_golden_config:compliancefeature_list", - name="Compliance Features", - permissions=["nautobot_golden_config.view_compliancefeature"], - buttons=( - NavMenuAddButton( - link="plugins:nautobot_golden_config:compliancefeature_add", - permissions=["nautobot_golden_config.add_compliancefeature"], - ), - ), - ) - ) - - -if ENABLE_COMPLIANCE: - items_operate.append( - NavMenuItem( - link="plugins:nautobot_golden_config:configcompliance_overview", - name="Compliance Report", - permissions=["nautobot_golden_config.view_configcompliance"], - ) - ) - -if ENABLE_PLAN: - items_operate.append( - NavMenuItem( - link="plugins:nautobot_golden_config:configplan_list", - name="Config Plans", - permissions=["nautobot_golden_config.view_configplan"], - buttons=( - NavMenuAddButton( - link="plugins:nautobot_golden_config:configplan_add", - permissions=["nautobot_golden_config.add_configplan"], - ), - ), - ) - ) - -if ENABLE_BACKUP: - items_setup.append( - NavMenuItem( - link="plugins:nautobot_golden_config:configremove_list", - name="Config Removals", - permissions=["nautobot_golden_config.view_configremove"], - buttons=( - NavMenuAddButton( - link="plugins:nautobot_golden_config:configremove_add", - permissions=["nautobot_golden_config.add_configremove"], - ), - ), - ) - ) - -if ENABLE_BACKUP: - items_setup.append( - NavMenuItem( - link="plugins:nautobot_golden_config:configreplace_list", - name="Config Replacements", - permissions=["nautobot_golden_config.view_configreplace"], - buttons=( - NavMenuAddButton( - link="plugins:nautobot_golden_config:configreplace_add", - permissions=["nautobot_golden_config.add_configreplace"], - ), - ), - ) - ) - - -if ENABLE_COMPLIANCE: - items_setup.append( - NavMenuItem( - link="plugins:nautobot_golden_config:remediationsetting_list", - name="Remediation Settings", - permissions=["nautobot_golden_config.view_remediationsetting"], - buttons=( - NavMenuAddButton( - link="plugins:nautobot_golden_config:remediationsetting_add", - permissions=["nautobot_golden_config.add_remediationsetting"], - ), - ), - ) - ) - -items_setup.append( - NavMenuItem( - link="plugins:nautobot_golden_config:goldenconfigsetting_list", - name="Golden Config Settings", - permissions=["nautobot_golden_config.view_goldenconfigsetting"], - buttons=( - NavMenuAddButton( - link="plugins:nautobot_golden_config:goldenconfigsetting_add", - permissions=["nautobot_golden_config.change_goldenconfigsetting"], - ), - ), - ), -) - - menu_items = ( NavMenuTab( name="Golden Config", weight=1000, groups=( - NavMenuGroup(name="Manage", weight=100, items=tuple(items_operate)), - NavMenuGroup(name="Setup", weight=100, items=tuple(items_setup)), + NavMenuGroup( + name="Manage", + weight=100, + items=( + NavMenuItem( + link="plugins:nautobot_golden_config:goldenconfig_list", + name="Config Overview", + permissions=["nautobot_golden_config.view_goldenconfig"], + ), + NavMenuItem( + link="plugins:nautobot_golden_config:configcompliance_list", + name="Config Compliance", + permissions=["nautobot_golden_config.view_configcompliance"], + ), + NavMenuItem( + link="plugins:nautobot_golden_config:configcompliance_overview", + name="Compliance Report", + permissions=["nautobot_golden_config.view_configcompliance"], + ), + NavMenuItem( + link="plugins:nautobot_golden_config:configplan_list", + name="Config Plans", + permissions=["nautobot_golden_config.view_configplan"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:configplan_add", + permissions=["nautobot_golden_config.add_configplan"], + ), + ), + ), + ), + ), + NavMenuGroup( + name="Setup", + weight=100, + items=( + NavMenuItem( + link="plugins:nautobot_golden_config:compliancerule_list", + name="Compliance Rules", + permissions=["nautobot_golden_config.view_compliancerule"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:compliancerule_add", + permissions=["nautobot_golden_config.add_compliancerule"], + ), + ), + ), + NavMenuItem( + link="plugins:nautobot_golden_config:compliancefeature_list", + name="Compliance Features", + permissions=["nautobot_golden_config.view_compliancefeature"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:compliancefeature_add", + permissions=["nautobot_golden_config.add_compliancefeature"], + ), + ), + ), + NavMenuItem( + link="plugins:nautobot_golden_config:configremove_list", + name="Config Removals", + permissions=["nautobot_golden_config.view_configremove"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:configremove_add", + permissions=["nautobot_golden_config.add_configremove"], + ), + ), + ), + NavMenuItem( + link="plugins:nautobot_golden_config:configreplace_list", + name="Config Replacements", + permissions=["nautobot_golden_config.view_configreplace"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:configreplace_add", + permissions=["nautobot_golden_config.add_configreplace"], + ), + ), + ), + NavMenuItem( + link="plugins:nautobot_golden_config:remediationsetting_list", + name="Remediation Settings", + permissions=["nautobot_golden_config.view_remediationsetting"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:remediationsetting_add", + permissions=["nautobot_golden_config.add_remediationsetting"], + ), + ), + ), + NavMenuItem( + link="plugins:nautobot_golden_config:goldenconfigsetting_list", + name="Golden Config Settings", + permissions=["nautobot_golden_config.view_goldenconfigsetting"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:goldenconfigsetting_add", + permissions=["nautobot_golden_config.change_goldenconfigsetting"], + ), + ), + ), + ), + ), NavMenuGroup( name="Tools", weight=300, diff --git a/nautobot_golden_config/nornir_plays/config_backup.py b/nautobot_golden_config/nornir_plays/config_backup.py index 01390428f..4f2ffdd48 100644 --- a/nautobot_golden_config/nornir_plays/config_backup.py +++ b/nautobot_golden_config/nornir_plays/config_backup.py @@ -19,9 +19,9 @@ from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig from nautobot_golden_config.utilities.db_management import close_threaded_db_connections from nautobot_golden_config.utilities.helper import ( + CustomFilterSettings, dispatch_params, render_jinja_template, - verify_settings, ) from nautobot_golden_config.utilities.logger import NornirLogger @@ -100,9 +100,10 @@ def config_backup(job): """ now = make_aware(datetime.now()) logger = NornirLogger(job.job_result, job.logger.getEffectiveLevel()) + device_filter = CustomFilterSettings(job.qs) - for settings in set(job.device_to_settings_map.values()): - verify_settings(logger, settings, ["backup_path_template"]) + # Verify backup feature is enabled and has required settings + device_filter.verify_feature_enabled(logger, "backup", required_settings=["backup_path_template"]) # Build a dictionary, with keys of platform.network_driver, and the regex line in it for the netutils func. remove_regex_dict = {} @@ -126,7 +127,7 @@ def config_backup(job): "options": { "credentials_class": NORNIR_SETTINGS.get("credentials"), "params": NORNIR_SETTINGS.get("inventory_params"), - "queryset": job.qs, + "queryset": device_filter.filtered_queryset, "defaults": {"now": now}, }, }, diff --git a/nautobot_golden_config/nornir_plays/config_compliance.py b/nautobot_golden_config/nornir_plays/config_compliance.py index 5a2531efd..ce064117d 100644 --- a/nautobot_golden_config/nornir_plays/config_compliance.py +++ b/nautobot_golden_config/nornir_plays/config_compliance.py @@ -23,11 +23,11 @@ from nautobot_golden_config.nornir_plays.processor import ProcessGoldenConfig from nautobot_golden_config.utilities.db_management import close_threaded_db_connections from nautobot_golden_config.utilities.helper import ( + CustomFilterSettings, get_json_config, get_xml_config, get_xml_subtree_with_full_path, render_jinja_template, - verify_settings, ) from nautobot_golden_config.utilities.logger import NornirLogger @@ -209,11 +209,16 @@ def config_compliance(job): # pylint: disable=unused-argument """ now = make_aware(datetime.now()) logger = NornirLogger(job.job_result, job.logger.getEffectiveLevel()) - rules = get_rules() + device_filter = CustomFilterSettings(job.qs) + + # Verify compliance feature is enabled and has required settings + device_filter.verify_feature_enabled( + logger, + "compliance", + required_settings=["backup_path_template", "intended_path_template"], + ) - for settings in set(job.device_to_settings_map.values()): - verify_settings(logger, settings, ["backup_path_template", "intended_path_template"]) try: with InitNornir( runner=NORNIR_SETTINGS.get("runner"), @@ -223,7 +228,7 @@ def config_compliance(job): # pylint: disable=unused-argument "options": { "credentials_class": NORNIR_SETTINGS.get("credentials"), "params": NORNIR_SETTINGS.get("inventory_params"), - "queryset": job.qs, + "queryset": device_filter.filtered_queryset, "defaults": {"now": now}, }, }, diff --git a/nautobot_golden_config/nornir_plays/config_intended.py b/nautobot_golden_config/nornir_plays/config_intended.py index dc97f3a35..bb552dd57 100644 --- a/nautobot_golden_config/nornir_plays/config_intended.py +++ b/nautobot_golden_config/nornir_plays/config_intended.py @@ -20,10 +20,10 @@ from nautobot_golden_config.utilities.db_management import close_threaded_db_connections from nautobot_golden_config.utilities.graphql import graph_ql_query from nautobot_golden_config.utilities.helper import ( + CustomFilterSettings, dispatch_params, get_django_env, render_jinja_template, - verify_settings, ) from nautobot_golden_config.utilities.logger import NornirLogger @@ -107,9 +107,14 @@ def config_intended(job): """ now = make_aware(datetime.now()) logger = NornirLogger(job.job_result, job.logger.getEffectiveLevel()) + device_filter = CustomFilterSettings(job.qs) - for settings in set(job.device_to_settings_map.values()): - verify_settings(logger, settings, ["jinja_path_template", "intended_path_template", "sot_agg_query"]) + # Verify intended feature is enabled and has required settings + device_filter.verify_feature_enabled( + logger, + "intended", + required_settings=["jinja_path_template", "intended_path_template", "sot_agg_query"], + ) # Retrieve filters from the Django jinja template engine jinja_env = get_django_env() @@ -122,7 +127,7 @@ def config_intended(job): "options": { "credentials_class": NORNIR_SETTINGS.get("credentials"), "params": NORNIR_SETTINGS.get("inventory_params"), - "queryset": job.qs, + "queryset": device_filter.filtered_queryset, "defaults": {"now": now}, }, }, diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py index d5ca6d6b2..cc47a4946 100644 --- a/nautobot_golden_config/tables.py +++ b/nautobot_golden_config/tables.py @@ -7,7 +7,18 @@ from nautobot.extras.tables import StatusTableMixin from nautobot_golden_config import models -from nautobot_golden_config.utilities.constant import CONFIG_FEATURES, ENABLE_BACKUP, ENABLE_COMPLIANCE, ENABLE_INTENDED +from nautobot_golden_config.utilities.constant import ENABLE_POSTPROCESSING, ENABLE_SOTAGG +from nautobot_golden_config.utilities.helper import get_golden_config_settings + +settings = get_golden_config_settings() + +CONFIG_FEATURES = { + "intended": settings.intended_enabled, + "compliance": settings.compliance_enabled, + "backup": settings.backup_enabled, + "sotagg": ENABLE_SOTAGG, + "postprocessing": ENABLE_POSTPROCESSING, +} ALL_ACTIONS = """ {% if backup == True %} @@ -122,11 +133,11 @@ def actual_fields(): """Convienance function to conditionally toggle columns.""" active_fields = ["pk", "name"] - if ENABLE_BACKUP: + if settings.backup_enabled: active_fields.append("backup_last_success_date") - if ENABLE_INTENDED: + if settings.intended_enabled: active_fields.append("intended_last_success_date") - if ENABLE_COMPLIANCE: + if settings.compliance_enabled: active_fields.append("compliance_last_success_date") active_fields.append("actions") return tuple(active_fields) @@ -280,24 +291,19 @@ class GoldenConfigTable(BaseTable): text=lambda record: record.device.name, verbose_name="Device", ) - - if ENABLE_BACKUP: - backup_last_success_date = Column( - verbose_name="Backup Status", empty_values=(), order_by="backup_last_success_date" - ) - if ENABLE_INTENDED: - intended_last_success_date = Column( - verbose_name="Intended Status", - empty_values=(), - order_by="intended_last_success_date", - ) - if ENABLE_COMPLIANCE: - compliance_last_success_date = Column( - verbose_name="Compliance Status", - empty_values=(), - order_by="compliance_last_success_date", - ) - + backup_last_success_date = Column( + verbose_name="Backup Status", empty_values=(), order_by="backup_last_success_date" + ) + intended_last_success_date = Column( + verbose_name="Intended Status", + empty_values=(), + order_by="intended_last_success_date", + ) + compliance_last_success_date = Column( + verbose_name="Compliance Status", + empty_values=(), + order_by="compliance_last_success_date", + ) actions = TemplateColumn( template_code=ALL_ACTIONS, verbose_name="Actions", extra_context=CONFIG_FEATURES, orderable=False ) @@ -453,6 +459,14 @@ def _render_capability(self, record, column, record_attribute): # pylint: disab return format_html('') return format_html('') + def render_backup_enabled(self, record, column): + """Render backup_enabled boolean value.""" + return self._render_capability(record=record, column=column, record_attribute="backup_enabled") + + def render_intended_enabled(self, record, column): + """Render intended_enabled boolean value.""" + return self._render_capability(record=record, column=column, record_attribute="intended_enabled") + def render_backup_repository(self, record, column): """Render backup repository boolean value.""" return self._render_capability(record=record, column=column, record_attribute="backup_repository") @@ -475,7 +489,9 @@ class Meta(BaseTable.Meta): "weight", "description", "backup_repository", + "backup_enabled", "intended_repository", + "intended_enabled", "jinja_repository", ) diff --git a/nautobot_golden_config/template_content.py b/nautobot_golden_config/template_content.py index fb92b0fd8..44db49783 100644 --- a/nautobot_golden_config/template_content.py +++ b/nautobot_golden_config/template_content.py @@ -6,7 +6,18 @@ from nautobot.extras.plugins import PluginTemplateExtension from nautobot_golden_config.models import ConfigCompliance, GoldenConfig -from nautobot_golden_config.utilities.constant import CONFIG_FEATURES, ENABLE_COMPLIANCE +from nautobot_golden_config.utilities.constant import ENABLE_POSTPROCESSING, ENABLE_SOTAGG +from nautobot_golden_config.utilities.helper import get_golden_config_settings + +settings = get_golden_config_settings() + +CONFIG_FEATURES = { + "intended": settings.intended_enabled, + "compliance": settings.compliance_enabled, + "backup": settings.backup_enabled, + "sotagg": ENABLE_SOTAGG, + "postprocessing": ENABLE_POSTPROCESSING, +} class ConfigComplianceDeviceCheck(PluginTemplateExtension): # pylint: disable=abstract-method @@ -146,7 +157,7 @@ def right_page(self): extensions = [ConfigDeviceDetails] -if ENABLE_COMPLIANCE: +if settings.compliance_enabled: extensions.append(ConfigComplianceDeviceCheck) extensions.append(ConfigComplianceLocationCheck) extensions.append(ConfigComplianceTenantCheck) diff --git a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_list.html b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_list.html index c2e6afcc2..6e2bd446c 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_list.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/configcompliance_list.html @@ -7,9 +7,7 @@ Execute {% endblock %} \ No newline at end of file diff --git a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html index f8a38c5c7..58245fb3b 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfig_list.html @@ -37,18 +37,9 @@

{% block title %}Configuration Overview{% endblock %}

Execute {% endblock %} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_retrieve.html b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_retrieve.html index 707747391..d1bbac0f3 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_retrieve.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_retrieve.html @@ -80,6 +80,14 @@ {{ object.backup_test_connectivity|render_boolean|placeholder }} + + + Backup Enabled + + + {{ object.backup_enabled|render_boolean|placeholder }} + +
@@ -107,6 +115,14 @@ {{ object.intended_path_template|placeholder }} + + + Intended Enabled + + + {{ object.intended_enabled|render_boolean|placeholder }} + +
@@ -146,4 +162,35 @@
+
+
+ Additional Configuration +
+ + + + + + + + + + + + + +
+ Compliance Enabled + + {{ object.compliance_enabled|render_boolean|placeholder }} +
+ Plan Enabled + + {{ object.plan_enabled|render_boolean|placeholder }} +
+ Deploy Enabled + + {{ object.deploy_enabled|render_boolean|placeholder }} +
+
{% endblock %} diff --git a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html index 417ad0909..2b214f40b 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/goldenconfigsetting_update.html @@ -18,6 +18,7 @@ {% render_field form.backup_repository %} {% render_field form.backup_path_template %} {% render_field form.backup_test_connectivity %} + {% render_field form.backup_enabled %}
@@ -25,6 +26,7 @@
{% render_field form.intended_repository %} {% render_field form.intended_path_template %} + {% render_field form.intended_enabled %}
@@ -35,5 +37,13 @@ {% render_field form.sot_agg_query %}
+
+
Additional Configuration
+
+ {% render_field form.compliance_enabled %} + {% render_field form.plan_enabled %} + {% render_field form.deploy_enabled %} +
+
{% include 'inc/extras_features_edit_form_fields.html' %} {% endblock %} diff --git a/nautobot_golden_config/tests/test_jobs.py b/nautobot_golden_config/tests/test_jobs.py index 2debfd8bb..f43410f7c 100755 --- a/nautobot_golden_config/tests/test_jobs.py +++ b/nautobot_golden_config/tests/test_jobs.py @@ -7,17 +7,61 @@ from nautobot.extras.models import JobLogEntry from nautobot_golden_config import jobs +from nautobot_golden_config.models import GoldenConfigSetting from nautobot_golden_config.tests.conftest import ( create_device, create_orphan_device, dgs_gc_settings_and_job_repo_objects, ) -from nautobot_golden_config.utilities import constant + + +class BaseGoldenConfigTestCase(TransactionTestCase): + """Base test case with helper methods for GoldenConfigSetting.""" + + def setUp(self): + super().setUp() + self.settings = GoldenConfigSetting.objects.first() + self.settings.backup_enabled = True + self.settings.intended_enabled = True + self.settings.compliance_enabled = True + self.settings.plan_enabled = True + self.settings.deploy_enabled = True + self.settings.save() + + def tearDown(self): + self.settings.backup_enabled = True + self.settings.intended_enabled = True + self.settings.compliance_enabled = True + self.settings.plan_enabled = True + self.settings.deploy_enabled = True + self.settings.save() + super().tearDown() + + def update_golden_config_settings( # pylint: disable=too-many-arguments + self, + backup_enabled=None, + intended_enabled=None, + compliance_enabled=None, + plan_enabled=None, + deploy_enabled=None, + ): + """Update fields of the GoldenConfigSetting instance.""" + if backup_enabled is not None: + self.settings.backup_enabled = backup_enabled + if intended_enabled is not None: + self.settings.intended_enabled = intended_enabled + if compliance_enabled is not None: + self.settings.compliance_enabled = compliance_enabled + if plan_enabled is not None: + self.settings.plan_enabled = plan_enabled + if deploy_enabled is not None: + self.settings.deploy_enabled = deploy_enabled + self.settings.save() @patch("nautobot_golden_config.nornir_plays.config_backup.run_backup", MagicMock(return_value="foo")) @patch.object(jobs, "ensure_git_repository") -class GCReposBackupTestCase(TransactionTestCase): +class GCReposBackupTestCase(BaseGoldenConfigTestCase): """Test the repos to sync and commit are working for backup job.""" databases = ("default", "job_logs") @@ -29,10 +73,10 @@ def setUp(self) -> None: dgs_gc_settings_and_job_repo_objects() super().setUp() - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", True) def test_backup_job_repos_one_setting(self, mock_ensure_git_repository): """Test backup job repo-types are backup only.""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(backup_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="BackupJob", device=Device.objects.filter(name=self.device.name) ) @@ -50,10 +94,10 @@ def test_backup_job_repos_one_setting(self, mock_ensure_git_repository): log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", True) def test_backup_job_repos_two_setting(self, mock_ensure_git_repository): """Test backup job repo-types are backup only.""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(backup_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="BackupJob", @@ -73,11 +117,11 @@ def test_backup_job_repos_two_setting(self, mock_ensure_git_repository): log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) def test_backup_job_repos_one_setting_backup_disabled(self, mock_ensure_git_repository): """Test backup job repo-types are backup only.""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_BACKUP) + self.update_golden_config_settings(backup_enabled=False) + job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="BackupJob", device=Device.objects.filter(name=self.device.name) ) @@ -89,13 +133,14 @@ def test_backup_job_repos_one_setting_backup_disabled(self, mock_ensure_git_repo self.assertEqual(log_entries.last().message, "In scope device count for this job: 1") log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="run") - self.assertEqual(log_entries.last().message, "Backups are disabled in application settings.") + self.assertEqual( + log_entries.last().message, "`E3032:` The backup feature is disabled in Golden Config settings." + ) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) def test_backup_job_repos_two_setting_backup_disabled(self, mock_ensure_git_repository): """Test backup job repo-types are backup only.""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_BACKUP) + self.update_golden_config_settings(backup_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="BackupJob", device=Device.objects.all() ) @@ -107,12 +152,14 @@ def test_backup_job_repos_two_setting_backup_disabled(self, mock_ensure_git_repo self.assertEqual(log_entries.last().message, "In scope device count for this job: 2") log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="run") - self.assertEqual(log_entries.last().message, "Backups are disabled in application settings.") + self.assertEqual( + log_entries.last().message, "`E3032:` The backup feature is disabled in Golden Config settings." + ) @patch("nautobot_golden_config.nornir_plays.config_intended.run_template", MagicMock(return_value="foo")) @patch.object(jobs, "ensure_git_repository") -class GCReposIntendedTestCase(TransactionTestCase): +class GCReposIntendedTestCase(BaseGoldenConfigTestCase): """Test the repos to sync and commit are working for intended job.""" databases = ("default", "job_logs") @@ -127,6 +174,7 @@ def setUp(self) -> None: def test_intended_job_repos_one_setting(self, mock_ensure_git_repository): """Test intended job one GC setting enabled_intended enabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(intended_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="IntendedJob", @@ -149,6 +197,7 @@ def test_intended_job_repos_one_setting(self, mock_ensure_git_repository): def test_intended_job_repos_two_setting(self, mock_ensure_git_repository): """Test intended job two GC setting enabled_intended enabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(intended_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="IntendedJob", device=Device.objects.all() ) @@ -165,11 +214,10 @@ def test_intended_job_repos_two_setting(self, mock_ensure_git_repository): log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_intended_job_repos_one_setting_intended_disabled(self, mock_ensure_git_repository): """Test intended job one GC setting enabled_intended disabled""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_INTENDED) + self.update_golden_config_settings(intended_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="IntendedJob", @@ -183,13 +231,14 @@ def test_intended_job_repos_one_setting_intended_disabled(self, mock_ensure_git_ self.assertEqual(log_entries.last().message, "In scope device count for this job: 1") log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="run") - self.assertEqual(log_entries.last().message, "Intended Generation is disabled in application settings.") + self.assertEqual( + log_entries.last().message, "`E3032:` The intended feature is disabled in Golden Config settings." + ) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_intended_job_repos_two_setting_intended_disabled(self, mock_ensure_git_repository): """Test intended job two GC setting enabled_intended disabled""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_INTENDED) + self.update_golden_config_settings(intended_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="IntendedJob", device=Device.objects.all() ) @@ -201,12 +250,14 @@ def test_intended_job_repos_two_setting_intended_disabled(self, mock_ensure_git_ self.assertEqual(log_entries.first().message, "Repository types to sync: ") log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="run") - self.assertEqual(log_entries.last().message, "Intended Generation is disabled in application settings.") + self.assertEqual( + log_entries.last().message, "`E3032:` The intended feature is disabled in Golden Config settings." + ) @patch("nautobot_golden_config.nornir_plays.config_compliance.run_compliance", MagicMock(return_value="foo")) @patch.object(jobs, "ensure_git_repository") -class GCReposComplianceTestCase(TransactionTestCase): +class GCReposComplianceTestCase(BaseGoldenConfigTestCase): """Test the repos to sync and commit are working for compliance job.""" databases = ("default", "job_logs") @@ -221,6 +272,7 @@ def setUp(self) -> None: def test_compliance_job_repos_one_setting(self, mock_ensure_git_repository): """Test compliance job one GC setting enabled_compliance enabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(compliance_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="ComplianceJob", @@ -245,6 +297,7 @@ def test_compliance_job_repos_one_setting(self, mock_ensure_git_repository): def test_compliance_job_repos_two_setting(self, mock_ensure_git_repository): """Test compliance job two GC setting enabled_compliance enabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(compliance_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="ComplianceJob", device=Device.objects.all() ) @@ -263,11 +316,10 @@ def test_compliance_job_repos_two_setting(self, mock_ensure_git_repository): log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_COMPLIANCE", False) def test_compliance_job_repos_one_setting_compliance_disabled(self, mock_ensure_git_repository): """Test compliance job one GC setting enabled_compliance disabled""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_COMPLIANCE) + self.update_golden_config_settings(compliance_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="ComplianceJob", @@ -281,13 +333,14 @@ def test_compliance_job_repos_one_setting_compliance_disabled(self, mock_ensure_ self.assertEqual(log_entries.last().message, "In scope device count for this job: 1") log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="run") - self.assertEqual(log_entries.last().message, "Compliance is disabled in application settings.") + self.assertEqual( + log_entries.last().message, "`E3032:` The compliance feature is disabled in Golden Config settings." + ) - @patch("nautobot_golden_config.utilities.constant.ENABLE_COMPLIANCE", False) def test_compliance_job_repos_two_setting_compliance_disabled(self, mock_ensure_git_repository): """Test compliance job two GC setting enabled_compliance disabled""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_COMPLIANCE) + self.update_golden_config_settings(compliance_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="ComplianceJob", device=Device.objects.all() ) @@ -299,13 +352,14 @@ def test_compliance_job_repos_two_setting_compliance_disabled(self, mock_ensure_ self.assertEqual(log_entries.first().message, "Repository types to sync: ") log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="run") - self.assertEqual(log_entries.last().message, "Compliance is disabled in application settings.") + self.assertEqual( + log_entries.last().message, "`E3032:` The compliance feature is disabled in Golden Config settings." + ) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) def test_compliance_job_repos_backup_disabled(self, mock_ensure_git_repository): """Test compliance job one GC setting enabled_backup disabled""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_BACKUP) + self.update_golden_config_settings(backup_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="ComplianceJob", device=Device.objects.all() ) @@ -324,11 +378,10 @@ def test_compliance_job_repos_backup_disabled(self, mock_ensure_git_repository): log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_compliance_job_repos_intended_disabled(self, mock_ensure_git_repository): """Test compliance job one GC setting enabled_intended disabled""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_INTENDED) + self.update_golden_config_settings(intended_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="ComplianceJob", device=Device.objects.all() ) @@ -346,13 +399,10 @@ def test_compliance_job_repos_intended_disabled(self, mock_ensure_git_repository log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_compliance_job_repos_both_disabled(self, mock_ensure_git_repository): """Test compliance job one GC setting both disabled""" mock_ensure_git_repository.return_value = True - self.assertFalse(constant.ENABLE_BACKUP) - self.assertFalse(constant.ENABLE_INTENDED) + self.update_golden_config_settings(backup_enabled=False, intended_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="ComplianceJob", device=Device.objects.all() ) @@ -375,7 +425,7 @@ def test_compliance_job_repos_both_disabled(self, mock_ensure_git_repository): @patch("nautobot_golden_config.nornir_plays.config_intended.run_template", MagicMock(return_value="foo")) @patch("nautobot_golden_config.nornir_plays.config_compliance.run_compliance", MagicMock(return_value="foo")) @patch.object(jobs, "ensure_git_repository") -class GCReposRunAllSingleTestCase(TransactionTestCase): +class GCReposRunAllSingleTestCase(BaseGoldenConfigTestCase): """Test the repos to sync and commit are working for run all single job.""" databases = ("default", "job_logs") @@ -389,10 +439,15 @@ def setUp(self) -> None: def test_run_all_job_single_repos(self, mock_ensure_git_repository): """Test run all job single on one GC setting enabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings( + backup_enabled=True, + intended_enabled=True, + compliance_enabled=True, + deploy_enabled=True, + ) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllGoldenConfig", device=self.device.id ) - self.assertTrue(constant.ENABLE_BACKUP) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, @@ -412,14 +467,13 @@ def test_run_all_job_single_repos(self, mock_ensure_git_repository): log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) def test_run_all_job_single_repos_backup_disabled(self, mock_ensure_git_repository): """Test run all job single on one GC backup_disabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(backup_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllGoldenConfig", device=self.device.id ) - self.assertFalse(constant.ENABLE_BACKUP) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, @@ -439,14 +493,13 @@ def test_run_all_job_single_repos_backup_disabled(self, mock_ensure_git_reposito log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_run_all_job_single_repos_intended_disabled(self, mock_ensure_git_repository): """Test run all job single on one GC intended_disabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(intended_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllGoldenConfig", device=self.device.id ) - self.assertFalse(constant.ENABLE_INTENDED) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, @@ -466,16 +519,13 @@ def test_run_all_job_single_repos_intended_disabled(self, mock_ensure_git_reposi log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_run_all_job_single_repos_both_disabled(self, mock_ensure_git_repository): """Test run all job single on one GC both_disabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(backup_enabled=False, intended_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllGoldenConfig", device=self.device.id ) - self.assertFalse(constant.ENABLE_BACKUP) - self.assertFalse(constant.ENABLE_INTENDED) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, @@ -500,7 +550,7 @@ def test_run_all_job_single_repos_both_disabled(self, mock_ensure_git_repository @patch("nautobot_golden_config.nornir_plays.config_intended.run_template", MagicMock(return_value="foo")) @patch("nautobot_golden_config.nornir_plays.config_compliance.run_compliance", MagicMock(return_value="foo")) @patch.object(jobs, "ensure_git_repository") -class GCReposRunAllMultipleTestCase(TransactionTestCase): +class GCReposRunAllMultipleTestCase(BaseGoldenConfigTestCase): """Test the repos to sync and commit are working for run all multiple job.""" databases = ("default", "job_logs") @@ -515,10 +565,10 @@ def setUp(self) -> None: def test_run_all_job_multiple_repos(self, mock_ensure_git_repository): """Test run all job multiple on one GC setting enabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(backup_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllDevicesGoldenConfig", device=Device.objects.all() ) - self.assertTrue(constant.ENABLE_BACKUP) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, @@ -538,14 +588,13 @@ def test_run_all_job_multiple_repos(self, mock_ensure_git_repository): log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) def test_run_all_job_multiple_repos_backup_disabled(self, mock_ensure_git_repository): """Test run all job multiple on one GC backup_disabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(backup_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllDevicesGoldenConfig", device=Device.objects.all() ) - self.assertFalse(constant.ENABLE_BACKUP) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, @@ -565,14 +614,13 @@ def test_run_all_job_multiple_repos_backup_disabled(self, mock_ensure_git_reposi log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_run_all_job_multiple_repos_intended_disabled(self, mock_ensure_git_repository): """Test run all job multiple on one GC intended_disabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(intended_enabled=True) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllDevicesGoldenConfig", device=Device.objects.all() ) - self.assertFalse(constant.ENABLE_INTENDED) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, @@ -592,16 +640,13 @@ def test_run_all_job_multiple_repos_intended_disabled(self, mock_ensure_git_repo log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Commit and Push") self.assertEqual(log_entries.count(), 1) - @patch("nautobot_golden_config.utilities.constant.ENABLE_BACKUP", False) - @patch("nautobot_golden_config.utilities.constant.ENABLE_INTENDED", False) def test_run_all_job_multiple_repos_both_disabled(self, mock_ensure_git_repository): """Test run all job multiple on one GC both_disabled""" mock_ensure_git_repository.return_value = True + self.update_golden_config_settings(backup_enabled=False, intended_enabled=False) job_result = create_job_result_and_run_job( module="nautobot_golden_config.jobs", name="AllDevicesGoldenConfig", device=Device.objects.all() ) - self.assertFalse(constant.ENABLE_BACKUP) - self.assertFalse(constant.ENABLE_INTENDED) log_entries = JobLogEntry.objects.filter(job_result=job_result, grouping="GC Repo Syncs") self.assertEqual( log_entries.first().message, diff --git a/nautobot_golden_config/tests/test_utilities/test_helpers.py b/nautobot_golden_config/tests/test_utilities/test_helpers.py index b09032406..1581bc6bf 100644 --- a/nautobot_golden_config/tests/test_utilities/test_helpers.py +++ b/nautobot_golden_config/tests/test_utilities/test_helpers.py @@ -14,10 +14,12 @@ from nautobot_golden_config.models import GoldenConfigSetting from nautobot_golden_config.tests.conftest import create_device, create_helper_repo, create_orphan_device from nautobot_golden_config.utilities.helper import ( + CustomFilterSettings, get_device_to_settings_map, get_job_filter, null_to_empty, render_jinja_template, + verify_feature_enabled, ) @@ -115,6 +117,8 @@ def setUp(self): self.data = MagicMock() self.logger = logging.getLogger(__name__) self.device_to_settings_map = get_device_to_settings_map(queryset=Device.objects.all()) + self.queryset = Device.objects.all() + self.custom_filter_settings = CustomFilterSettings(self.queryset) def test_null_to_empty_null(self): """Ensure None returns with empty string.""" @@ -296,3 +300,46 @@ def test_device_to_settings_map(self): self.assertEqual(self.device_to_settings_map[test_device.id], self.test_settings_c) self.assertEqual(self.device_to_settings_map[orphan_device.id], self.test_settings_b) self.assertEqual(get_device_to_settings_map(queryset=Device.objects.none()), {}) + + def test_verify_feature_enabled__enabled_e3033_error(self): + """Verify that we do not raise an exception when the backup feature is enabled.""" + feature_name = "backup" + required_settings = ["backup_path_template"] + self.assertEqual(self.test_settings_a.backup_enabled, True) + + with self.assertRaises(NornirNautobotException) as error: + verify_feature_enabled(self.logger, feature_name, self.test_settings_a, required_settings) + self.assertContains(str(error), "E3033") + + def test_verify_feature_enabled__disabled_e3032_error(self): + """Verify that we raise an exception when the backup feature is disabled.""" + feature_name = "backup" + required_settings = ["backup_path_template"] + self.test_settings_a.backup_enabled = False + self.test_settings_a.save() + + self.assertEqual(self.test_settings_a.backup_enabled, False) + + with self.assertRaises(NornirNautobotException) as error: + verify_feature_enabled(self.logger, feature_name, self.test_settings_a, required_settings) + self.assertContains(str(error), "E3032") + + def test_verify_feature_enabled__enabled(self): + """Verify that we do not raise an exception when the backup feature is enabled.""" + feature_name = "backup" + required_settings = ["backup_path_template"] + self.test_settings_a.backup_enabled = True + self.test_settings_a.backup_path_template = "backup.conf" + self.test_settings_a.save() + + self.assertEqual(self.test_settings_a.backup_enabled, True) + self.assertEqual(self.test_settings_a.backup_path_template, "backup.conf") + self.assertIsNone(verify_feature_enabled(self.logger, feature_name, self.test_settings_a, required_settings)) + + def test_custom_filter_settings__filtered_queryset(self): + """Verify that the filtered_queryset attribute is set to the queryset.""" + self.assertEqual(len(self.custom_filter_settings.filtered_queryset), len(self.queryset)) + + def test_custom_filter_settings__device_to_settings_maps(self): + """Verify that the device_to_settings_maps attribute is set to a non-empty list.""" + self.assertGreater(len(self.custom_filter_settings.device_to_settings_maps), 0) diff --git a/nautobot_golden_config/utilities/constant.py b/nautobot_golden_config/utilities/constant.py index f1f5db283..9641e8843 100644 --- a/nautobot_golden_config/utilities/constant.py +++ b/nautobot_golden_config/utilities/constant.py @@ -14,14 +14,6 @@ ENABLE_POSTPROCESSING = PLUGIN_CFG["enable_postprocessing"] DEFAULT_DEPLOY_STATUS = PLUGIN_CFG["default_deploy_status"] -CONFIG_FEATURES = { - "intended": ENABLE_INTENDED, - "compliance": ENABLE_COMPLIANCE, - "backup": ENABLE_BACKUP, - "sotagg": ENABLE_SOTAGG, - "postprocessing": ENABLE_POSTPROCESSING, -} - JINJA_ENV = PLUGIN_CFG["jinja_env"] if not JINJA_ENV.get("undefined"): raise ValueError("The `jinja_env` setting did not include the required key for `undefined`.") diff --git a/nautobot_golden_config/utilities/helper.py b/nautobot_golden_config/utilities/helper.py index a0264aa59..1f96bcc50 100644 --- a/nautobot_golden_config/utilities/helper.py +++ b/nautobot_golden_config/utilities/helper.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib import messages from django.db.models import Q +from django.db.utils import ProgrammingError from django.template import engines from django.urls import reverse from django.utils.html import format_html @@ -23,7 +24,7 @@ from nautobot_golden_config import config as app_config from nautobot_golden_config import models from nautobot_golden_config.utilities import utils -from nautobot_golden_config.utilities.constant import JINJA_ENV +from nautobot_golden_config.utilities.constant import ENABLE_SOTAGG, JINJA_ENV FRAMEWORK_METHODS = { "default": utils.default_framework, @@ -106,15 +107,6 @@ def null_to_empty(val): return val -def verify_settings(logger, global_settings, attrs): - """Helper function to verify required attributes are set before a Nornir play start.""" - for item in attrs: - if not getattr(global_settings, item): - error_msg = f"`E3018:` Missing the required global setting: `{item}`." - logger.error(error_msg) - raise NornirNautobotException(error_msg) - - def get_django_env(): """Load Django Jinja filters from the Django jinja template engine, and add them to the jinja_env. @@ -288,3 +280,186 @@ def update_dynamic_groups_cache(): if not settings.PLUGINS_CONFIG[app_config.name].get("_manual_dynamic_group_mgmt"): for setting in models.GoldenConfigSetting.objects.all(): setting.dynamic_group.update_cached_members() + + +class GoldenConfigDefaults: + """Lightweight stand-in for GoldenConfigSetting rows if none exist or DB is unmigrated.""" + + def __init__(self, defaults_dict): + """Store each default key as an attribute on self, so that code can use `gc_settings.backup_enabled` as normal.""" + settings_mapper = { + "enable_backup": "backup_enabled", + "enable_intended": "intended_enabled", + "enable_compliance": "compliance_enabled", + "enable_plan": "plan_enabled", + "enable_deploy": "deploy_enabled", + } + for key, value in defaults_dict.items(): + if settings_mapper.get(key): + setattr(self, settings_mapper.get(key), value) + + def __str__(self): + """GoldenConfigDefaults string repreentation.""" + return "" + + +def get_golden_config_settings(): + """Return the first GoldenConfigSetting in the database if it exists; otherwise return a fallback object that uses GoldenConfig.default_settings.""" + try: + db_instance = models.GoldenConfigSetting.objects.first() + if db_instance: + return db_instance + except ProgrammingError: + # Table doesn't exist yet, or other DB issues + pass + + # Fall back to default settings if no DB row is available + return GoldenConfigDefaults(app_config.default_settings) + + +def verify_feature_enabled(logger, feature_name, gc_settings, required_settings=None): + """Verify if a feature is enabled and has required settings. + + Args: + logger: Logger instance + feature_name: Name of the feature to check (backup, intended, compliance, etc) + gc_settings: GoldenConfigSetting instance + required_settings: List of required setting attributes for this feature + + Raises: + NornirNautobotException: If feature is disabled or missing required settings + """ + feature_enabled = getattr(gc_settings, f"{feature_name}_enabled", False) + if not feature_enabled: + error_msg = f"`E3032:` The {feature_name} feature is disabled in Golden Config settings." + logger.error(error_msg) + raise NornirNautobotException(error_msg) + + if required_settings: + missing_settings = [] + for setting in required_settings: + if not getattr(gc_settings, setting, None): + missing_settings.append(setting) + + if missing_settings: + if feature_name == "intended" and "sot_agg_query" in missing_settings and not ENABLE_SOTAGG: + # Skip SOT aggregation query check if the feature is disabled + missing_settings.remove("sot_agg_query") + + if missing_settings: # Check again in case we removed the only missing setting + error_msg = f"`E3033:` Missing required settings for {feature_name}: {', '.join(missing_settings)}" + logger.error(error_msg) + raise NornirNautobotException(error_msg) + + +def verify_config_plan_eligibility(logger, device, gc_settings): + """Verify if a device is eligible for config plan operations. + + Args: + logger: Logger instance + device: Device instance + gc_settings: GoldenConfigSetting instance + + Raises: + NornirNautobotException: If device is not eligible for config plans + """ + if not gc_settings.plan_enabled: + error_msg = "`E3034:` Config plan creation is disabled in Golden Config settings." + logger.error(error_msg) + raise NornirNautobotException(error_msg) + + # Check if device is in scope + device_settings = get_device_to_settings_map(device) + if not device_settings: + error_msg = f"`E3035:` Device {device.name} is not in scope for config plans." + logger.error(error_msg) + raise NornirNautobotException(error_msg) + + +def verify_deployment_eligibility(logger, config_plan, gc_settings): + """Verify if a config plan is eligible for deployment. + + Args: + logger: Logger instance + config_plan: ConfigPlan instance + gc_settings: GoldenConfigSetting instance + + Raises: + NornirNautobotException: If deployment is not allowed + """ + if not gc_settings.deploy_enabled: + error_msg = "`E3036:` Configuration deployment is disabled in Golden Config settings." + logger.error(error_msg) + raise NornirNautobotException(error_msg) + + # Check if device is still in scope + device_settings = get_device_to_settings_map(config_plan.device) + if not device_settings: + error_msg = f"`E3037:` Device {config_plan.device.name} is no longer in scope for deployments." + logger.error(error_msg) + raise NornirNautobotException(error_msg) + + +class CustomFilterSettings: + """ + Helper class to filter and group devices based on their Golden Config settings. + + Provides compatibility with existing code while adding enhanced device filtering. + """ + + def __init__(self, queryset): + """ + Initialize with a device queryset. + + Args: + queryset: Django queryset of Device objects + """ + self.queryset = queryset + self._filtered_queryset = deepcopy(queryset) + self._device_to_settings_maps = set() + self._excluded_devices = [] + + @property + def device_to_settings_maps(self): + """Get mapping of devices to their settings, lazy loaded.""" + if len(self._device_to_settings_maps) == 0: + self._device_to_settings_maps = set(get_device_to_settings_map(self.queryset).values()) + return self._device_to_settings_maps + + def exclude_devices(self, devices): + """ + Exclude devices from the queryset. + + Args: + devices: List of Device objects to exclude + """ + return self._excluded_devices.extend(devices) + + @property + def filtered_queryset(self): + """Get the filtered queryset.""" + return self._filtered_queryset.exclude(pk__in=self._excluded_devices) + + def verify_feature_enabled(self, logger, feature_name, required_settings=None): + """ + Drop-in replacement for the original verify_feature_enabled function. + + This maintains compatibility with existing tests by using a representative + setting to generate the exact same error messages. + + Args: + logger: Logger instance + feature_name: Feature name to check (backup, intended, compliance) + required_settings: List of setting attributes that should be populated + + Raises: + NornirNautobotException: If feature is disabled or required settings are missing + """ + for setting in self.device_to_settings_maps: + try: + verify_feature_enabled(logger, feature_name, setting, required_settings) + except NornirNautobotException as error: + if any(code in str(error) for code in ["E3032", "E3033"]): + if hasattr(setting, "dynamic_group") and len(setting.dynamic_group.members) > 0: + self.exclude_devices([device.pk for device in setting.dynamic_group.members]) + raise error diff --git a/nautobot_golden_config/views.py b/nautobot_golden_config/views.py index e2e2fd3f5..729deec06 100644 --- a/nautobot_golden_config/views.py +++ b/nautobot_golden_config/views.py @@ -28,7 +28,7 @@ from nautobot_golden_config.utilities import constant from nautobot_golden_config.utilities.config_postprocessing import get_config_postprocessing from nautobot_golden_config.utilities.graphql import graph_ql_query -from nautobot_golden_config.utilities.helper import add_message, get_device_to_settings_map +from nautobot_golden_config.utilities.helper import add_message, get_device_to_settings_map, get_golden_config_settings from nautobot_golden_config.utilities.mat_plot import get_global_aggr, plot_barchart_visual, plot_visual # TODO: Future #4512 @@ -101,14 +101,15 @@ def filter_queryset(self, queryset): def get_extra_context(self, request, instance=None, **kwargs): """Get extra context data.""" + settings = get_golden_config_settings() context = super().get_extra_context(request, instance) - context["compliance"] = constant.ENABLE_COMPLIANCE - context["backup"] = constant.ENABLE_BACKUP - context["intended"] = constant.ENABLE_INTENDED + context["compliance"] = settings.compliance_enabled + context["backup"] = settings.backup_enabled + context["intended"] = settings.intended_enabled jobs = [] - jobs.append(["BackupJob", constant.ENABLE_BACKUP]) - jobs.append(["IntendedJob", constant.ENABLE_INTENDED]) - jobs.append(["ComplianceJob", constant.ENABLE_COMPLIANCE]) + jobs.append(["BackupJob", settings.backup_enabled]) + jobs.append(["IntendedJob", settings.intended_enabled]) + jobs.append(["ComplianceJob", settings.compliance_enabled]) add_message(jobs, request) return context @@ -259,6 +260,7 @@ def __init__(self, *args, **kwargs): def get_extra_context(self, request, instance=None, **kwargs): """A ConfigCompliance helper function to warn if the Job is not enabled to run.""" + settings = get_golden_config_settings() context = super().get_extra_context(request, instance) if self.action == "overview": context = {**context, **self.report_context} @@ -266,10 +268,10 @@ def get_extra_context(self, request, instance=None, **kwargs): if self.action == "bulk_destroy": context["table"] = self.store_table - context["compliance"] = constant.ENABLE_COMPLIANCE - context["backup"] = constant.ENABLE_BACKUP - context["intended"] = constant.ENABLE_INTENDED - add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + context["compliance"] = settings.compliance_enabled + context["backup"] = settings.backup_enabled + context["intended"] = settings.intended_enabled + add_message([["ComplianceJob", settings.compliance_enabled]], request) return context def alter_queryset(self, request): @@ -379,6 +381,7 @@ class ConfigComplianceOverview(generic.ObjectListView): def setup(self, request, *args, **kwargs): """Using request object to perform filtering based on query params.""" super().setup(request, *args, **kwargs) + settings = get_golden_config_settings() filter_params = self.get_filter_params(request) # Add .restrict() to the queryset to restrict the view based on user permissions. main_qs = models.ConfigCompliance.objects.restrict(request.user, "view") @@ -390,7 +393,7 @@ def setup(self, request, *args, **kwargs): "device_visual": plot_visual(device_aggr), "feature_aggr": feature_aggr, "feature_visual": plot_visual(feature_aggr), - "compliance": constant.ENABLE_COMPLIANCE, + "compliance": settings.compliance_enabled, } def extra_context(self): @@ -414,7 +417,8 @@ class ComplianceFeatureUIViewSet(views.NautobotUIViewSet): def get_extra_context(self, request, instance=None): """A ComplianceFeature helper function to warn if the Job is not enabled to run.""" - add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + settings = get_golden_config_settings() + add_message([["ComplianceJob", settings.compliance_enabled]], request) return {} @@ -433,7 +437,8 @@ class ComplianceRuleUIViewSet(views.NautobotUIViewSet): def get_extra_context(self, request, instance=None): """A ComplianceRule helper function to warn if the Job is not enabled to run.""" - add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + settings = get_golden_config_settings() + add_message([["ComplianceJob", settings.compliance_enabled]], request) return {} @@ -451,19 +456,20 @@ class GoldenConfigSettingUIViewSet(views.NautobotUIViewSet): def get_extra_context(self, request, instance=None): """A GoldenConfig helper function to warn if the Job is not enabled to run.""" + settings = get_golden_config_settings() jobs = [] - jobs.append(["BackupJob", constant.ENABLE_BACKUP]) - jobs.append(["IntendedJob", constant.ENABLE_INTENDED]) - jobs.append(["DeployConfigPlans", constant.ENABLE_DEPLOY]) - jobs.append(["ComplianceJob", constant.ENABLE_COMPLIANCE]) + jobs.append(["BackupJob", settings.backup_enabled]) + jobs.append(["IntendedJob", settings.intended_enabled]) + jobs.append(["DeployConfigPlans", settings.plan_enabled]) + jobs.append(["ComplianceJob", settings.compliance_enabled]) jobs.append( [ "AllGoldenConfig", [ - constant.ENABLE_BACKUP, - constant.ENABLE_COMPLIANCE, - constant.ENABLE_DEPLOY, - constant.ENABLE_INTENDED, + settings.backup_enabled, + settings.compliance_enabled, + settings.deploy_enabled, + settings.intended_enabled, constant.ENABLE_SOTAGG, ], ] @@ -472,10 +478,10 @@ def get_extra_context(self, request, instance=None): [ "AllDevicesGoldenConfig", [ - constant.ENABLE_BACKUP, - constant.ENABLE_COMPLIANCE, - constant.ENABLE_DEPLOY, - constant.ENABLE_INTENDED, + settings.backup_enabled, + settings.compliance_enabled, + settings.deploy_enabled, + settings.intended_enabled, constant.ENABLE_SOTAGG, ], ] @@ -499,7 +505,8 @@ class ConfigRemoveUIViewSet(views.NautobotUIViewSet): def get_extra_context(self, request, instance=None): """A ConfigRemove helper function to warn if the Job is not enabled to run.""" - add_message([["BackupJob", constant.ENABLE_BACKUP]], request) + settings = get_golden_config_settings() + add_message([["BackupJob", settings.backup_enabled]], request) return {} @@ -518,7 +525,8 @@ class ConfigReplaceUIViewSet(views.NautobotUIViewSet): def get_extra_context(self, request, instance=None): """A ConfigReplace helper function to warn if the Job is not enabled to run.""" - add_message([["BackupJob", constant.ENABLE_BACKUP]], request) + settings = get_golden_config_settings() + add_message([["BackupJob", settings.backup_enabled]], request) return {} @@ -538,7 +546,8 @@ class RemediationSettingUIViewSet(views.NautobotUIViewSet): def get_extra_context(self, request, instance=None): """A RemediationSetting helper function to warn if the Job is not enabled to run.""" - add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + settings = get_golden_config_settings() + add_message([["ComplianceJob", settings.compliance_enabled]], request) return {} @@ -564,10 +573,11 @@ def alter_queryset(self, request): def get_extra_context(self, request, instance=None): """A ConfigPlan helper function to warn if the Job is not enabled to run.""" + settings = get_golden_config_settings() jobs = [] - jobs.append(["GenerateConfigPlans", constant.ENABLE_PLAN]) - jobs.append(["DeployConfigPlans", constant.ENABLE_DEPLOY]) - jobs.append(["DeployConfigPlanJobButtonReceiver", constant.ENABLE_DEPLOY]) + jobs.append(["GenerateConfigPlans", settings.plan_enabled]) + jobs.append(["DeployConfigPlans", settings.deploy_enabled]) + jobs.append(["DeployConfigPlanJobButtonReceiver", settings.deploy_enabled]) add_message(jobs, request) return {}