From ea61daa0eb0feae2ffec91b7b31b451c7f11ecab Mon Sep 17 00:00:00 2001 From: scetron Date: Wed, 21 Jul 2021 10:02:30 -0700 Subject: [PATCH 01/20] safe escape for git creds from nautobot --- nautobot_golden_config/utilities/git.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nautobot_golden_config/utilities/git.py b/nautobot_golden_config/utilities/git.py index 489e062e..679c3875 100644 --- a/nautobot_golden_config/utilities/git.py +++ b/nautobot_golden_config/utilities/git.py @@ -5,6 +5,7 @@ import logging from git import Repo +from urllib.parse import quote LOGGER = logging.getLogger(__name__) @@ -25,10 +26,10 @@ def __init__(self, obj): if self.token and self.token not in self.url: # Some Git Providers require a user as well as a token. if self.token_user: - self.url = re.sub("//", f"//{self.token_user}:{self.token}@", self.url) + self.url = re.sub("//", f"//{quote(self.token_user, safe='')}:{quote(self.token, safe='')}@", self.url) else: # Github only requires the token. - self.url = re.sub("//", f"//{self.token}@", self.url) + self.url = re.sub("//", f"//{quote(self.token, safe='')}@", self.url) self.branch = obj.branch self.obj = obj From 2aaaaf156eff35017c9651ddc8a91b9257d86f5f Mon Sep 17 00:00:00 2001 From: scetron Date: Thu, 22 Jul 2021 06:34:13 -0700 Subject: [PATCH 02/20] Adjust Device scope help text --- nautobot_golden_config/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 37c22d74..313a6111 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -346,7 +346,7 @@ class GoldenConfigSetting(PrimaryModel): encoder=DjangoJSONEncoder, blank=True, null=True, - help_text="Queryset filter matching the list of devices for the scope of devices to be considered.", + help_text="API filter in JSON format matching the list of devices for the scope of devices to be considered.", ) sot_agg_query = models.TextField( null=False, From 1b05e2a5150dee6c6e277a7ac9b88820184880d5 Mon Sep 17 00:00:00 2001 From: scetron Date: Thu, 22 Jul 2021 06:34:13 -0700 Subject: [PATCH 03/20] Adjust Device scope help text --- nautobot_golden_config/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 37c22d74..313a6111 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -346,7 +346,7 @@ class GoldenConfigSetting(PrimaryModel): encoder=DjangoJSONEncoder, blank=True, null=True, - help_text="Queryset filter matching the list of devices for the scope of devices to be considered.", + help_text="API filter in JSON format matching the list of devices for the scope of devices to be considered.", ) sot_agg_query = models.TextField( null=False, From b22ae0781a8ba2eeb522a08d34f4a4173cb87ef1 Mon Sep 17 00:00:00 2001 From: scetron Date: Mon, 26 Jul 2021 08:42:29 -0700 Subject: [PATCH 04/20] update import order --- nautobot_golden_config/utilities/git.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nautobot_golden_config/utilities/git.py b/nautobot_golden_config/utilities/git.py index 679c3875..247d2b45 100644 --- a/nautobot_golden_config/utilities/git.py +++ b/nautobot_golden_config/utilities/git.py @@ -4,9 +4,11 @@ import re import logging -from git import Repo from urllib.parse import quote +from git import Repo + + LOGGER = logging.getLogger(__name__) From 2b1d5caf19a049e84e8a85ca9a47a5e972ff55be Mon Sep 17 00:00:00 2001 From: scetron Date: Mon, 26 Jul 2021 08:42:44 -0700 Subject: [PATCH 05/20] explicitly define user as None for mock/tests --- .../tests/test_utilities/test_git.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nautobot_golden_config/tests/test_utilities/test_git.py b/nautobot_golden_config/tests/test_utilities/test_git.py index f9c9ec35..953769cf 100644 --- a/nautobot_golden_config/tests/test_utilities/test_git.py +++ b/nautobot_golden_config/tests/test_utilities/test_git.py @@ -19,6 +19,7 @@ def setUp(self): @patch("nautobot_golden_config.utilities.git.Repo", autospec=True) def test_gitrepo_path_noexist(self, mock_repo): """Test Repo is not called when path isn't valid, ensure clone is called.""" + self.mock_obj.username = None GitRepo(self.mock_obj) mock_repo.assert_not_called() mock_repo.clone_from.assert_called_with("/fake/remote", to_path="/fake/path") @@ -28,6 +29,17 @@ def test_gitrepo_path_noexist(self, mock_repo): def test_gitrepo_path_exist(self, mock_repo, mock_os): """Test Repo is not called when path is valid, ensure Repo is called.""" mock_os.path.isdir.return_value = True + self.mock_obj.username = None + GitRepo(self.mock_obj) + mock_repo.assert_called_once() + mock_repo.assert_called_with(path="/fake/path") + + @patch("nautobot_golden_config.utilities.git.os") + @patch("nautobot_golden_config.utilities.git.Repo", autospec=True) + def test_path_exist_token_and_username(self, mock_repo, mock_os): + """Test Repo is not called when path is valid, ensure Repo is called.""" + mock_os.path.isdir.return_value = True + self.mock_obj.username = "Test User" GitRepo(self.mock_obj) mock_repo.assert_called_once() mock_repo.assert_called_with(path="/fake/path") From 1cd263b58e9e67f11e6a7e15bc363bb8ba946a09 Mon Sep 17 00:00:00 2001 From: scetron Date: Mon, 26 Jul 2021 09:00:52 -0700 Subject: [PATCH 06/20] add test for user with symbol in name --- .../tests/test_utilities/test_git.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nautobot_golden_config/tests/test_utilities/test_git.py b/nautobot_golden_config/tests/test_utilities/test_git.py index 953769cf..4eece841 100644 --- a/nautobot_golden_config/tests/test_utilities/test_git.py +++ b/nautobot_golden_config/tests/test_utilities/test_git.py @@ -43,3 +43,13 @@ def test_path_exist_token_and_username(self, mock_repo, mock_os): GitRepo(self.mock_obj) mock_repo.assert_called_once() mock_repo.assert_called_with(path="/fake/path") + + @patch("nautobot_golden_config.utilities.git.os") + @patch("nautobot_golden_config.utilities.git.Repo", autospec=True) + def test_username_with_symbols(self, mock_repo, mock_os): + """Test Repo is not called when path is valid, ensure Repo is called.""" + mock_os.path.isdir.return_value = True + self.mock_obj.username = "user@fakeemail.local" + GitRepo(self.mock_obj) + mock_repo.assert_called_once() + mock_repo.assert_called_with(path="/fake/path") From 1b3b7df085a7f07d90b1ca54615c98faf72c877d Mon Sep 17 00:00:00 2001 From: Josh VanDeraa Date: Mon, 2 Aug 2021 19:31:58 -0500 Subject: [PATCH 07/20] Adds step by step for launching a job --- docs/navigating-backup.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/navigating-backup.md b/docs/navigating-backup.md index 44881068..6b8b5627 100644 --- a/docs/navigating-backup.md +++ b/docs/navigating-backup.md @@ -9,7 +9,7 @@ and save the configuration. The high-level process to run backups is: * Store the backup configurations locally. * Push configurations to the remote Git repository. -# Configuration Backup Settings +## Configuration Backup Settings Backup configurations often need some amount of parsing to stay sane. The two obvious use cases are the ability to remove lines such as the "Last Configuration" changed date, as this will cause unnecessary changes the second is to strip out secrets from the configuration. In an effort to support these @@ -41,7 +41,16 @@ The credentials/secrets management is further described within the [nautbot-plug repo. For the simplist use case you can set environment variables for `NAPALM_USERNAME`, `NAPALM_PASSWORD`, and `DEVICE_SECRET`. For more complicated use cases, please refer to the plugin documentation linked above. -# Config Removals +## Starting a Backup Job + +To start a backup job manually: + +1. Navigate to the Plugin Home (Plugins->Home), with Home being in the `Golden Configuration` section +2. Select _Execute_ on the upper right buttons, then _Backup_ +3. Fill in the data that you wish to have backed up +4. Select _Run Job_ + +## Config Removals The line removals settings is a series of regex patterns to identify lines that should be removed. This is helpful as there are usually parts of the configurations that will change each time. A match simply means to remove. @@ -51,7 +60,7 @@ In order to specify line removals. Navigate to **Plugins -> Config Removals**. The remove setting is based on `Platform`. An example is shown below. ![Config Removals View](./img/00-navigating-backup.png) -# Config Replacements +## Config Replacements This is a replacement config with a regex pattern with a single capture groups to replace. This is helpful to strip out secrets. From 21b6a2461ba1f8f05bb812c7e7c6861d41371d40 Mon Sep 17 00:00:00 2001 From: Josh VanDeraa Date: Tue, 3 Aug 2021 08:34:21 -0500 Subject: [PATCH 08/20] Updates rest of the docs pages --- docs/navigating-compliance.md | 27 ++++++++++++++++++--------- docs/navigating-golden.md | 24 ++++++++++++------------ docs/navigating-intended.md | 17 ++++++++++++++--- docs/navigating-sot-agg.md | 10 +++++----- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/navigating-compliance.md b/docs/navigating-compliance.md index cc188394..2d2a749b 100644 --- a/docs/navigating-compliance.md +++ b/docs/navigating-compliance.md @@ -17,7 +17,7 @@ There is no magic to determine the state of configuration. You still must define configuration may be as a network engineer wants it, but the tool correctly considers it non-compliant, since the tool is only comparing two configurations. The tool makes no assumptions to determine what an engineer may want to do, but did not document via the configuration generation process. -# Compliance Configuration Settings +## Compliance Configuration Settings In order to generate the intended configurations two repositories are needed. @@ -26,7 +26,16 @@ In order to generate the intended configurations two repositories are needed. 3. The [intended_path_template](./navigating-golden.md#application-settings) configuration parameter. 4. The [backup_path_template](./navigating-golden.md#application-settings) configuration parameter. -# Configuration Compliance Parsing Engine +## Starting a Compliance Job + +To start a compliance job manually: + +1. Navigate to the Plugin Home (Plugins->Home), with Home being in the `Golden Configuration` section +2. Select _Execute_ on the upper right buttons, then _Compliance_ +3. Fill in the data that you wish to have a compliance report generated for +4. Select _Run Job_ + +## Configuration Compliance Parsing Engine Configuration compliance is different than a simple UNIX diff. While the UI provides both, the compliance metrics are not influenced by the UNIX diff capabilities. One of the challenges of getting a device into compliance is the ramp up it takes to model and generate configurations for an entire @@ -96,7 +105,7 @@ router bgp 65250 > Note: A platform will not run successfully against a device unless at least one compliance rule is set. -# Configuration Compliance Settings +## Configuration Compliance Settings Configuration compliance requires the Git Repo settings for `config backups` and `intended configs`--which are covered in their respective sections--regardless if they are actually managed via the plugin or not. The same is true for the `Backup Path` and `Intended Path`. @@ -122,12 +131,12 @@ what a line starts with only. Meaning, there is an implicit greediness to the ma > Note: The mapping of "network_os" as defined by netutils is provided via the plugin settings in your nautobot_config.py, and documented on the primary Readme. -# Compliance View +## Compliance View The compliance overview will provide a per device and feature overview on the compliance of your network devices. From here you can navigate to the details view. ![Compliance Overview](./img/compliance-overview.png) -# Compliance Details View +## Compliance Details View Drilling into a specific device and feature, you can get an immediate detailed understanding of your device. @@ -142,13 +151,13 @@ Please note the following about the compliance details page. * The icon next to the status will indicate whether or not the configuration is ordered. * The icons on top of the page can be used to help navigate the page easier. -# Supported Platforms +## Supported Platforms Platforms support technically come from the options provided by [nornir-nautobot](https://github.com/nautobot/nornir-nautobot) for nornir dispatcher tasks and [netutils](https://github.com/networktocode/netutils) for configuration compliance and parsing. However, for reference, the valid slug's of the platforms are provided in the [FAQ](./FAQ.md). -# Overview Report +## Overview Report There is a global overview or executive summary that provides a high level snapshot of the compliance. There are 3 points of data captured. @@ -156,14 +165,14 @@ There is a global overview or executive summary that provides a high level snaps * Features - This is the total number of features for all devices, and how many are compliant, and how many are non-compliant. * Per Feature - This is a breakdown of that feature and how many within that feature are compliant of not. -# Detail Report +## Detail Report This can be accessed via the Plugins drop-down via `Compliance` details button. From there you can filter the devices via the form on the right side, limit the columns with the `Configure` button, or bulk delete with the `Delete` button. Additionally each device is click-able to view the details of that individual device. You can configure the columns to limit how much is showing on one screen. -# Device Details +## Device Details You can get to the device details form either the Compliance details page, or there is a `content_template` on the device model page is Nautobot's core instance. diff --git a/docs/navigating-golden.md b/docs/navigating-golden.md index 8e0b9ca6..19fdcd61 100644 --- a/docs/navigating-golden.md +++ b/docs/navigating-golden.md @@ -2,7 +2,7 @@ A navigation overview of the entire plugin. -# Home +## Home The Home view is a portal to understand what the status of the devices are. @@ -24,7 +24,7 @@ Some of the information described in this view, may not be immediately obvious. The first four bring up a "modal" or "dialogue box" which has a detailed view for a dedicated page. The run job brings the user to a job to run all three components against all of the devices. -# Jobs +## Jobs There are a series of Jobs that are registered via the Plugin. They can be viewed from the standard Jobs view. @@ -34,7 +34,7 @@ Each Job attempts to provide sane error handling, and respects the `debug` flag ![Job Result](./img/job-result.png) -# Application Settings +## Application Settings The golden configuration plugin settings can be found by navigating to `Plugins -> Settings` button. Under the `Golden Configuration` section. @@ -55,7 +55,7 @@ To configure or update the settings click the pencil icon to edit. > Note: Each of these will be further detailed in their respective sections. -## Scope +### Scope The scope, is a JSON blob that describes a filter that will provide the list of devices to be allowed whenever a job is ran. A job can optionally further refine the scope, but the outbound would be based on what is defined here. The options are best described by leveraging the Devices list view, search features (the filtering shown on the side of the Devices.) Building a query there, will provide the exact keys expected. @@ -90,7 +90,7 @@ Adding a "has_primary_ip" check. When viewing the settings, the scope of devices is actually a link to the query built in the Devices view. Click that link to understand which devices are permitted by the filter. -# Git Settings +## Git Settings The plugin makes heavy use of the Nautobot git data sources feature. There are up to three repositories used in the application. This set of instructions will walk an operator through setting up the backup repository. The steps are the same, except for the "Provides" field name chosen. @@ -125,33 +125,33 @@ Once you click `Create` and the repository syncs, the main page will now show th For their respective features, the "Provides" field could be backup intended configs and jinja templates. -# Plugins Buttons +## Plugins Buttons The plugins buttons provides you with the ability to navigate to Run the script, overview report, and detailed report. -# Run Script +## Run Script This can be accessed via the Plugins drop-down via `Run Script` button of the `Home` view, the user will be provided a form of the Job (as described above), which will allow the user to limit the scope of the request. -# Device Template Content +## Device Template Content The plugin makes use of template content `right_page` in order to use display in-line the status of that device in the traditional Nautobot view. From here you can click the link to see the detail compliance view. -# Site Template Content +## Site Template Content The plugin makes use of template content `right_page` in order to use display in-line the status of that entire site in the traditional Nautobot view. -# API +## API To run the job programmactially, reference the [nautobot documentation](https://nautobot.readthedocs.io/en/stable/additional-features/jobs/#via-the-api) for the proper API call. Pay special attention to the `class_path` defintion. -# Feature Enablement +## Feature Enablement Enabling features such as backup or compliance, will render those parts of the UI visible. It is worth noting that disabling features does not provide any garbage collection and it is up to the operator to remove such data. -# Network Operating System Support +## Network Operating System Support The version of OS's supported is documented in the [FAQ](./FAQ.md) and is controlled the platform slug. The platform slug must be exactly as expected or leverage a configuration option--which is described the the FAQ--for the plugin to work. \ No newline at end of file diff --git a/docs/navigating-intended.md b/docs/navigating-intended.md index a0e21621..8d9fcbf1 100644 --- a/docs/navigating-intended.md +++ b/docs/navigating-intended.md @@ -1,4 +1,6 @@ -# Configuration Generation +# Intended Configuration + +## Configuration Generation The Golden Config plugin provides the ability to generate configurations. The process is a Nornir play that points to a single Jinja template per device that generates the configurations. Data is provided via the Source of Truth aggregation and is currently a hard requirement to be turned on if @@ -27,7 +29,16 @@ or {% endfor %} ``` -# Intended Configuration Settings +## Starting a Intended Configuration Job + +To start a intended configuration job manually: + +1. Navigate to the Plugin Home (Plugins->Home), with Home being in the `Golden Configuration` section +2. Select _Execute_ on the upper right buttons, then _Intended_ +3. Fill in the data that you wish to have configurations generated for up +4. Select _Run Job_ + +## Intended Configuration Settings In order to generate the intended configurations two repositories are needed. @@ -36,6 +47,6 @@ In order to generate the intended configurations two repositories are needed. 3. The [intended_path_template](./navigating-golden.md#application-settings) configuration parameter. 4. The [jinja_path_template](./navigating-golden.md#application-settings) configuration parameter. -# Data +## Data The data provided while rendering the configuration of a device is described in the [SoT Aggregation](./navigating-sot-agg.md) overview. diff --git a/docs/navigating-sot-agg.md b/docs/navigating-sot-agg.md index f6262a68..bc7e7d64 100644 --- a/docs/navigating-sot-agg.md +++ b/docs/navigating-sot-agg.md @@ -6,7 +6,7 @@ The Source of Truth Aggregation Overview is driven by a few key components. * The ability to modify data with a "transposer" function. * The usage of config contexts and the Nautobot's native git platform. -# GraphQL +## GraphQL There is currently support to make an arbitrary GraphQL query that has "device_id" as a variable. It is likely best to use the GraphiQL interface to model your data, and then save that query to the configuration. The application configuration ensures the following two components. @@ -19,7 +19,7 @@ It is worth noting that the graphQL query returned is modified to remove the roo It is helpful to make adjustments to the query, and then view the data from the Plugin's home page and clicking on a given device's `code-json` icon. -# Transposer Function +## Transposer Function The transposer function is an optional function to make arbitrary changes to the data after the fact. There is a Plugin configuration that allows the operator to point to a function within the python path by a string. The function will receive a single variable, that by convention should be called @@ -45,17 +45,17 @@ PLUGINS_CONFIG["nautobot_golden_config"]["sot_agg_transposer"] = "nautobot_golde ``` The path described must be within the Python path of your worker. It is up to the operator to ensure that happens. -# Config Context +## Config Context Outside of the scope of this document, but it is worth mentioning the power that configuration context's with integration to Git can provide in this solution. -# Performance +## Performance The GraphQL and transposer functionality could seriously impact the performance of the server. There are no restrictions imposed as it is up to the operator to weigh the pros and cons of the solution. -# Sample Query +## Sample Query To test your query in the GraphiQL UI, obtain a device's uuid, which can be seen in the url of the detailed device view. Once you have a valid device uuid, you can use the "Query Variables" portion of the UI, which is on the bottom left-hand side of the screen. From ee9bb9bddd3d04b9e02c5dd262a01fd07701072a Mon Sep 17 00:00:00 2001 From: itdependsnetworks Date: Mon, 2 Aug 2021 22:12:52 -0400 Subject: [PATCH 09/20] add manage commands for jobs --- .../management/commands/run_config_backup.py | 22 +++++ .../commands/run_config_compliance.py | 22 +++++ .../commands/run_generate_config.py | 22 +++++ .../tests/test_utilities/test_git.py | 9 ++ nautobot_golden_config/utilities/git.py | 9 +- .../utilities/management.py | 97 +++++++++++++++++++ 6 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 nautobot_golden_config/management/commands/run_config_backup.py create mode 100644 nautobot_golden_config/management/commands/run_config_compliance.py create mode 100644 nautobot_golden_config/management/commands/run_generate_config.py create mode 100644 nautobot_golden_config/utilities/management.py diff --git a/nautobot_golden_config/management/commands/run_config_backup.py b/nautobot_golden_config/management/commands/run_config_backup.py new file mode 100644 index 00000000..348c3949 --- /dev/null +++ b/nautobot_golden_config/management/commands/run_config_backup.py @@ -0,0 +1,22 @@ +"""Add the run_config_backup command to nautobot-server.""" + +from django.core.management.base import BaseCommand +from nautobot.extras.jobs import get_job + +from nautobot_golden_config.utilities.management import job_runner + + +class Command(BaseCommand): + """Boilerplate Command to inherit from BaseCommand.""" + + help = "Run Config Backup from Golden Config Plugin." + + def add_arguments(self, parser): + """Add arguments for run_config_backup.""" + parser.add_argument("-u", "--user", type=str, required=True, help="User to run the Job as.") + parser.add_argument("-d", "--device", type=str, help="Define a uniquely defined device name") + + def handle(self, *args, **kwargs): + """Add handler for run_config_backup.""" + job_class = get_job("plugins/nautobot_golden_config.jobs/BackupJob") + job_runner(self, job_class, kwargs.get("device"), kwargs.get("user")) diff --git a/nautobot_golden_config/management/commands/run_config_compliance.py b/nautobot_golden_config/management/commands/run_config_compliance.py new file mode 100644 index 00000000..488ecccc --- /dev/null +++ b/nautobot_golden_config/management/commands/run_config_compliance.py @@ -0,0 +1,22 @@ +"""Add the run_config_compliance command to nautobot-server.""" + +from django.core.management.base import BaseCommand +from nautobot.extras.jobs import get_job + +from nautobot_golden_config.utilities.management import job_runner + + +class Command(BaseCommand): + """Boilerplate Command to inherit from BaseCommand.""" + + help = "Run Config Compliance Job from Golden Config Plugin." + + def add_arguments(self, parser): + """Add arguments for run_config_compliance.""" + parser.add_argument("-u", "--user", type=str, required=True, help="User to run the Job as.") + parser.add_argument("-d", "--device", type=str, help="Define a uniquely defined device name") + + def handle(self, *args, **kwargs): + """Add handler for run_config_compliance.""" + job_class = get_job("plugins/nautobot_golden_config.jobs/ComplianceJob") + job_runner(self, job_class, kwargs.get("device"), kwargs.get("user")) diff --git a/nautobot_golden_config/management/commands/run_generate_config.py b/nautobot_golden_config/management/commands/run_generate_config.py new file mode 100644 index 00000000..68c70c25 --- /dev/null +++ b/nautobot_golden_config/management/commands/run_generate_config.py @@ -0,0 +1,22 @@ +"""Add the run_generate_config command to nautobot-server.""" + +from django.core.management.base import BaseCommand +from nautobot.extras.jobs import get_job + +from nautobot_golden_config.utilities.management import job_runner + + +class Command(BaseCommand): + """Boilerplate Command to inherit from BaseCommand.""" + + help = "Run Job to generate your intended configuration from Golden Config Plugin." + + def add_arguments(self, parser): + """Add arguments for run_generate_config.""" + parser.add_argument("-u", "--user", type=str, required=True, help="User to run the Job as.") + parser.add_argument("-d", "--device", type=str, help="Define a uniquely defined device name") + + def handle(self, *args, **kwargs): + """Add handler for run_generate_config.""" + job_class = get_job("plugins/nautobot_golden_config.jobs/IntendedJob") + job_runner(self, job_class, kwargs.get("device"), kwargs.get("user")) diff --git a/nautobot_golden_config/tests/test_utilities/test_git.py b/nautobot_golden_config/tests/test_utilities/test_git.py index f9c9ec35..fe0338fe 100644 --- a/nautobot_golden_config/tests/test_utilities/test_git.py +++ b/nautobot_golden_config/tests/test_utilities/test_git.py @@ -14,6 +14,7 @@ def setUp(self): mock_obj.filesystem_path = "/fake/path" mock_obj.remote_url = "/fake/remote" mock_obj._token = "fake token" # pylint: disable=protected-access + mock_obj.username = None self.mock_obj = mock_obj @patch("nautobot_golden_config.utilities.git.Repo", autospec=True) @@ -31,3 +32,11 @@ def test_gitrepo_path_exist(self, mock_repo, mock_os): GitRepo(self.mock_obj) mock_repo.assert_called_once() mock_repo.assert_called_with(path="/fake/path") + + @patch("nautobot_golden_config.utilities.git.os") + @patch("nautobot_golden_config.utilities.git.Repo", autospec=True) + def test_git_with_username(self, mock_repo, mock_os): # pylint: disable=unused-argument + """Test username with special character works.""" + self.mock_obj.username = "admin@ntc.com" + GitRepo(self.mock_obj) + mock_repo.assert_called_once() diff --git a/nautobot_golden_config/utilities/git.py b/nautobot_golden_config/utilities/git.py index 679c3875..556c7853 100644 --- a/nautobot_golden_config/utilities/git.py +++ b/nautobot_golden_config/utilities/git.py @@ -4,8 +4,9 @@ import re import logging -from git import Repo from urllib.parse import quote +from git import Repo + LOGGER = logging.getLogger(__name__) @@ -26,10 +27,12 @@ def __init__(self, obj): if self.token and self.token not in self.url: # Some Git Providers require a user as well as a token. if self.token_user: - self.url = re.sub("//", f"//{quote(self.token_user, safe='')}:{quote(self.token, safe='')}@", self.url) + self.url = re.sub( + "//", f"//{quote(str(self.token_user), safe='')}:{quote(str(self.token), safe='')}@", self.url + ) else: # Github only requires the token. - self.url = re.sub("//", f"//{quote(self.token, safe='')}@", self.url) + self.url = re.sub("//", f"//{quote(str(self.token), safe='')}@", self.url) self.branch = obj.branch self.obj = obj diff --git a/nautobot_golden_config/utilities/management.py b/nautobot_golden_config/utilities/management.py new file mode 100644 index 00000000..572b50d1 --- /dev/null +++ b/nautobot_golden_config/utilities/management.py @@ -0,0 +1,97 @@ +"""Util functions that are leveraged by the managed commands.""" +# pylint: disable=too-many-branches,bad-option-value +import time +import uuid + +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +from django.test.client import RequestFactory + +from nautobot.extras.choices import JobResultStatusChoices +from nautobot.extras.models import JobResult +from nautobot.extras.jobs import run_job +from nautobot.dcim.models import Device +from nautobot.users.models import User + + +# Largely based on nautobot core run_job command, which does not allow variables to be sent +# so copied instead of used directly. +def job_runner(handle_class, job_class, device=None, user=None): + """Function to make management command code more DRY.""" + data = {} + + if device: + data["device"] = Device.objects.get(name=device) + + request = RequestFactory().request(SERVER_NAME="WebRequestContext") + request.id = uuid.uuid4() + request.user = User.objects.get(username=user) + + job_content_type = ContentType.objects.get(app_label="extras", model="job") + + # Run the job and create a new JobResult + handle_class.stdout.write("[{:%H:%M:%S}] Running {}...".format(timezone.now(), job_class.class_path)) + + job_result = JobResult.enqueue_job( + run_job, + job_class.class_path, + job_content_type, + request.user, + data=data, + request=request, + commit=True, + ) + + # Wait on the job to finish + while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES: + time.sleep(1) + job_result = JobResult.objects.get(pk=job_result.pk) + + # Report on success/failure + for test_name, attrs in job_result.data.items(): + + if test_name in ["total", "output"]: + continue + + handle_class.stdout.write( + "\t{}: {} success, {} info, {} warning, {} failure".format( + test_name, + attrs["success"], + attrs["info"], + attrs["warning"], + attrs["failure"], + ) + ) + + for log_entry in attrs["log"]: + status = log_entry[1] + if status == "success": + status = handle_class.style.SUCCESS(status) + elif status == "info": + status = status # pylint: disable=self-assigning-variable + elif status == "warning": + status = handle_class.style.WARNING(status) + elif status == "failure": + status = handle_class.style.NOTICE(status) + + if log_entry[2]: # object associated with log entry + handle_class.stdout.write(f"\t\t{status}: {log_entry[2]}: {log_entry[-1]}") + else: + handle_class.stdout.write(f"\t\t{status}: {log_entry[-1]}") + + if job_result.data["output"]: + handle_class.stdout.write(job_result.data["output"]) + + if job_result.status == JobResultStatusChoices.STATUS_FAILED: + status = handle_class.style.ERROR("FAILED") + elif job_result.status == JobResultStatusChoices.STATUS_ERRORED: + status = handle_class.style.ERROR("ERRORED") + else: + status = handle_class.style.SUCCESS("SUCCESS") + handle_class.stdout.write("[{:%H:%M:%S}] {}: {}".format(timezone.now(), job_class.class_path, status)) + + # Wrap things up + handle_class.stdout.write( + "[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), job_class.class_path, job_result.duration) + ) + handle_class.stdout.write("[{:%H:%M:%S}] Finished".format(timezone.now())) From 605ac20f680f57deb4f0da3d069ea3cbb29e44c7 Mon Sep 17 00:00:00 2001 From: Jeff Kala Date: Mon, 16 Aug 2021 08:20:46 -0500 Subject: [PATCH 10/20] fixes #106 --- nautobot_golden_config/models.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 313a6111..c8ab1dc6 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -36,7 +36,6 @@ def null_to_empty(val): "custom_validators", "export_templates", "relationships", - "graphql", "webhooks", ) class ComplianceFeature(PrimaryModel): @@ -71,7 +70,6 @@ def get_absolute_url(self): "custom_validators", "export_templates", "relationships", - "graphql", "webhooks", ) class ComplianceRule(PrimaryModel): @@ -152,7 +150,6 @@ def clean(self): "custom_links", "custom_validators", "export_templates", - "graphql", "relationships", "webhooks", ) @@ -222,7 +219,6 @@ def save(self, *args, **kwargs): "custom_links", "custom_validators", "export_templates", - "graphql", "relationships", "webhooks", ) @@ -453,7 +449,6 @@ def get_url_to_filtered_device_list(self): "custom_links", "custom_validators", "export_templates", - "graphql", "relationships", "webhooks", ) @@ -505,7 +500,6 @@ def get_absolute_url(self): # pylint: disable=no-self-use "custom_links", "custom_validators", "export_templates", - "graphql", "relationships", "webhooks", ) From 53786b2dcf0f109921f26356c7a779b21e571f32 Mon Sep 17 00:00:00 2001 From: Jeff Kala Date: Tue, 17 Aug 2021 15:28:20 -0500 Subject: [PATCH 11/20] add quick start guide and clean up readme --- README.md | 105 ++++++++++------------------------------- docs/installation.md | 64 +++++++++++++++++++++++++ docs/quick-start.md | 110 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 80 deletions(-) create mode 100644 docs/installation.md create mode 100644 docs/quick-start.md diff --git a/README.md b/README.md index b919354a..fab5ed3b 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,38 @@ A plugin for [Nautobot](https://github.com/nautobot/nautobot) that intends to pr **This version is currently in Beta and will require a rebuild of the database for a 1.0 release.** -# Overview -You may see the [Navigating Overview](./docs/navigating-golden.md) documentation for an overview of navigating through the different areas of this plugin. You may also see the [FAQ](./docs/FAQ.md) for commonly asked questions. +# Introduction -The golden configuration plugin performs four primary actions, each of which can be toggled on with a respective `enable_*` setting, covered in detail -later in the readme. +## What is the Golden Configuration Plugin? -* Configuration Backup - Is a Nornir process to connect to devices, optionally parse out lines/secrets, backup the configuration, and save to a Git repository. - * see [Navigating Backup](./docs/navigating-backup.md) for more information -* Configuration Intended - Is a Nornir process to generate configuration based on a Git repo of Jinja files and a Git repo to store the intended configuration. - * see [Navigating Intended](./docs/navigating-intended.md) for more information -* Source of Truth Aggregation - Is a GraphQL query per device with that creates a data structure used in the generation of configuration. - * see [Navigating SoTAgg](./docs/navigating-sot-agg.md) for more information -* Configuration Compliance - Is a Nornir process to run comparison of the actual (via backups) and intended (via Jinja file creation) CLI configurations. - * see [Navigating Compliance](./docs/navigating-compliance.md) for more information +The golden configuration plugin is a Nautobot plugin that aims to solve common configuration management challenges. -The operator's of their own Nautobot instance are welcome to use any combination of these features. Though the appearance may seem like they are tightly +## Key Use Cases + +This plugin enable four (4) key use cases. + + +1. **Configuration Backups** - Is a Nornir process to connect to devices, optionally parse out lines/secrets, backup the configuration, and save to a Git repository. +2. **Intended Configuration** - Is a Nornir process to generate configuration based on a Git repo of Jinja files and a Git repo to store the intended configuration. +3. **Source of Truth Aggregation** - Is a GraphQL query per device that creates a data structure used in the generation of configuration. +4. **Configuration Compliance** - Is a Nornir process to run comparison of the actual (via backups) and intended (via Jinja file creation) CLI configurations. + +>Notice: The operator's of their own Nautobot instance are welcome to use any combination of these features. Though the appearance may seem like they are tightly coupled, this isn't actually the case. For example, one can obtain backup configurations from their current RANCID/Oxidized process and simply provide a Git Repo of the location of the backup configurations, and the compliance process would work the same way. Also, another user may only want to generate configurations, but not want to use other features, which is perfectly fine to do so. +## Documentation +- [Installation](./docs/installation.md) +- [Quick Start Guide](./docs/quick-start.md) +- [Navigating Overview](./docs/navigating-golden.md) +- [Navigating Backup](./docs/navigating-backup.md) +- [Navigating Intended](./docs/navigating-intended.md) +- [Navigating SoTAgg](./docs/navigating-sot-agg.md)] +- [Navigating Compliance](./docs/navigating-compliance.md) +- [FAQ](./docs/FAQ.md) + ## Screenshots There are many features and capabilities the plugin provides into the Nautobot ecosystem. The following screenshots are intended to provide a quick visual overview of some of these features. @@ -44,72 +55,6 @@ Drilling into a specific device and feature, you can get an immediate detailed u ![Compliance Rule](./docs/img/compliance-rule.png) -## Plugin Settings - -There is a setting to determine the inclusion of any of the four given components. - -* The `enable_backup`, `enable_compliance`, `enable_intended`, and `enable_sotagg` will toggle inclusion of the entire component. - -# Installation - -Plugins can be installed manually or use Python's `pip`. See the [nautobot documentation](https://nautobot.readthedocs.io/en/latest/plugins/#install-the-package) for more details. The pip package name for this plugin is [`nautobot-golden-config`](https://pypi.org/project/nautobot-golden-config/) - -> The plugin is compatible with Nautobot 1.0.0 and higher - -**Prerequisite:** The plugin relies on [`nautobot_plugin_nornir`](https://pypi.org/project/nautobot-plugin-nornir/) to be installed and both plugins to be enabled in your configuration settings. - -**Required:** The following block of code below shows the additional configuration required to be added to your `nautobot_config.py` file: -- append `"nautobot_golden_config"` to the `PLUGINS` list -- append the `"nautobot_golden_config"` dictionary to the `PLUGINS_CONFIG` dictionary - -```python -PLUGINS = ["nautobot_plugin_nornir", "nautobot_golden_config"] - -PLUGINS_CONFIG = { - "nautobot_plugin_nornir": { - "nornir_settings": { - "credentials": "nautobot_plugin_nornir.plugins.credentials.env_vars.CredentialsEnvVars", - "runner": { - "plugin": "threaded", - "options": { - "num_workers": 20, - }, - }, - }, - }, - "nautobot_golden_config": { - "per_feature_bar_width": 0.15, - "per_feature_width": 13, - "per_feature_height": 4, - "enable_backup": True, - "enable_compliance": True, - "enable_intended": True, - "enable_sotagg": True, - "sot_agg_transposer": None, - "platform_slug_map": None, - }, -} - -``` - -The plugin behavior can be controlled with the following list of settings. - -| Key | Example | Default | Description | -| ------- | ------ | -------- | ------------------------------------- | -| enable_backup | True | True | A boolean to represent whether or not to run backup configurations within the plugin. | -| enable_compliance | True | True | A boolean to represent whether or not to run the compliance process within the plugin. | -| enable_intended | True | True | A boolean to represent whether or not to generate intended configurations within the plugin. | -| 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 plugin. | -| platform_slug_map | {"cisco_wlc": "cisco_aireos"} | None | A dictionary in which the key is the platform slug and the value is what netutils uses in any "network_os" parameter. | -| sot_agg_transposer | mypkg.transposer | - | A string representation of a function that can post-process the graphQL data. | -| per_feature_bar_width | 0.15 | 0.15 | The width of the table bar within the overview report | -| per_feature_width | 13 | 13 | The width in inches that the overview table can be. | -| per_feature_height | 4 | 4 | The height in inches that the overview table can be. | - -> Note: Over time the intention is to make the compliance report more dynamic, but for now allow users to configure the `per_*` configs in a way that fits best for them. - -> Note: Review [`nautobot_plugin_nornir`](https://pypi.org/project/nautobot-plugin-nornir/) for Nornir and dispatcher configuration options. - # Contributing Pull requests are welcomed and automatically built and tested against multiple versions of Python and Nautobot through TravisCI. @@ -129,7 +74,7 @@ The project features a CLI helper based on [invoke](http://www.pyinvoke.org/) to Each command can be executed with `invoke `. All commands support the arguments `--nautobot-ver` and `--python-ver` if you want to manually define the version of Python and Nautobot to use. Each command also has its own help `invoke --help` -### Local dev environment +### Local Development Environment ``` build Build all docker images. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..a5c355c9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,64 @@ +# Installation + +Plugins can be installed manually or use Python's `pip`. See the [nautobot documentation](https://nautobot.readthedocs.io/en/latest/plugins/#install-the-package) for more details. The pip package name for this plugin is [`nautobot-golden-config`](https://pypi.org/project/nautobot-golden-config/) + +> The plugin is compatible with Nautobot 1.0.0 and higher + +**Prerequisite:** The plugin relies on [`nautobot_plugin_nornir`](https://pypi.org/project/nautobot-plugin-nornir/) to be installed and both plugins to be enabled in your configuration settings. + +**Required:** The following block of code below shows the additional configuration required to be added to your `nautobot_config.py` file: +- append `"nautobot_golden_config"` to the `PLUGINS` list +- append the `"nautobot_golden_config"` dictionary to the `PLUGINS_CONFIG` dictionary + +```python +PLUGINS = ["nautobot_plugin_nornir", "nautobot_golden_config"] + +PLUGINS_CONFIG = { + "nautobot_plugin_nornir": { + "nornir_settings": { + "credentials": "nautobot_plugin_nornir.plugins.credentials.env_vars.CredentialsEnvVars", + "runner": { + "plugin": "threaded", + "options": { + "num_workers": 20, + }, + }, + }, + }, + "nautobot_golden_config": { + "per_feature_bar_width": 0.15, + "per_feature_width": 13, + "per_feature_height": 4, + "enable_backup": True, + "enable_compliance": True, + "enable_intended": True, + "enable_sotagg": True, + "sot_agg_transposer": None, + "platform_slug_map": None, + }, +} + +``` + +## Plugin Settings + +The plugin behavior can be controlled with the following list of settings. + +* The `enable_backup`, `enable_compliance`, `enable_intended`, and `enable_sotagg` will toggle inclusion of the entire component. + + +| Key | Example | Default | Description | +| ------- | ------ | -------- | ------------------------------------- | +| enable_backup | True | True | A boolean to represent whether or not to run backup configurations within the plugin. | +| enable_compliance | True | True | A boolean to represent whether or not to run the compliance process within the plugin. | +| enable_intended | True | True | A boolean to represent whether or not to generate intended configurations within the plugin. | +| 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 plugin. | +| platform_slug_map | {"cisco_wlc": "cisco_aireos"} | None | A dictionary in which the key is the platform slug and the value is what netutils uses in any "network_os" parameter. | +| sot_agg_transposer | mypkg.transposer | - | A string representation of a function that can post-process the graphQL data. | +| per_feature_bar_width | 0.15 | 0.15 | The width of the table bar within the overview report | +| per_feature_width | 13 | 13 | The width in inches that the overview table can be. | +| per_feature_height | 4 | 4 | The height in inches that the overview table can be. | + +> Note: Over time the intention is to make the compliance report more dynamic, but for now allow users to configure the `per_*` configs in a way that fits best for them. + +> Note: Review [`nautobot_plugin_nornir`](https://pypi.org/project/nautobot-plugin-nornir/) for Nornir and dispatcher configuration options. diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 00000000..5dd995ec --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,110 @@ +# Quick Start Guides + +- [Backup Configuration](#backup-configuration) +- [Intended Configuration](#intended-configuration) +- [Compliance](#compliance) + +# Backup Configuration + +Follow the steps below to get up and running for the configuration backup element of the plugin. + +1. Enable the feature in the `PLUGIN_SETTINGS`. The configuration should have `"enable_backup": True` set in the `PLUGINS_CONFIG` dictionary for `nautobot_golden_config`. + +2. Add the git repository that will be used to house the backup configurations. + + 1. In the UI `Extensibility -> Git Repositories`. Click Add. + 2. Populate the Git Repository data for the backup. [Git Settings](./navigating-golden.md#git-settings) + 3. Make sure to select the **provides** called `backup configs`. + 4. Click Create. + +3. Next, make sure to update the Plugins **Settings** with the backup details. + + 1. Navigate to `Plugins -> Settings` under the Golden Configuration Section. + 2. Fill out the Backup Repository. (The dropdown will show the repository that was just created.) + 3. Fill out Backup Path Template. Typically `{{obj.site.slug}}/{{obj.name}}.cfg`, see [Setting Details](./navigating-golden.md#application-settings) + 4. Select whether or not to do a connectivity check per device. + 5. Click Save. + +4. Create Configuration Removals and Replacements. + + 1. [Config Removals](./navigating-backup#config-removals) + 2. [Config Replacements](./navigating-backup#config-replacements) + +5. Execute the Backup. + + 1. Navigate to `Plugins -> Home`. + 2. Click on the `Execute` button and select `Backup`. + 3. Select what to run the backup on. + 4. Run the Job. + +> For in-depth details see [Navigating Backup](./navigating-backup.md) + +# Intended Configuration + +Follow the steps below to get up and running for the intended configuration element of the plugin. + +> Notice: Intended Configuration requires the `enable_intended` and `enabled_sotAgg` plugin features to be used. + +1. Enable the feature in the `PLUGIN_SETTINGS`. The configuration should have `"enable_intended": True` set in the `PLUGINS_CONFIG` dictionary for `nautobot_golden_config`. + +2. Add the git repository that will be used to house the intended configurations. + + 1. In the UI `Extensibility -> Git Repositories`. Click Add. + 2. Populate the Git Repository data for the intended. [Git Settings](./navigating-golden.md#git-settings) + 3. Make sure to select the **provides** called `intended configs`. + 4. Click Create. + +3. Add the git repository that will be used to house the Jinja2 templates. + + 1. In the UI `Extensibility -> Git Repositories`. Click Add. + 2. Populate the Git Repository data for the jinja2 templates. [Git Settings](./navigating-golden.md#git-settings) + 3. Make sure to select the **provides** called `jinja templates`. + 4. Click Create. + +4. Next, make sure to update the Plugins **Settings** with the intended and jinja2 template details. + + 1. Navigate to `Plugins -> Settings` under the Golden Configuration Section. + 2. Fill out the Intended Repository. (The dropdown will show the repository that was just created.) + 3. Fill out Intended Path Template. Typically `{{obj.site.slug}}/{{obj.name}}.cfg`, see [Setting Details](./navigating-golden.md#application-settings) + 4. Fill out Jinja Repository. (The dropdown will show the repository that was just created.) + 5. Fill out Jinja Path Template. Typically `{{obj.platform.slug}}.j2`. + +4. Determine what data(variables) the Jinja2 templates need from Nautobot. + + 1. See [Source of Truth Agg Details](./navigating-sot-agg.md) + 2. Populate the SoTAgg field in the `Plugin -> Settings`. + +5. Execute the Intended. + + 1. Navigate to `Plugins -> Home`. + 2. Click on the `Execute` button and select `Intended`. + 3. Select what to run the intended generation on. + 4. Run the Job. + +> For in-depth details see [Navigating Intended](./navigating-intended.md) + +# Compliance + +Compliance requires Backups and Intended Configurations in order to be executed. + +1. Enable the feature in the `PLUGIN_SETTINGS`. The configuration should have `"enable_compliance": True` set in the `PLUGINS_CONFIG` dictionary for `nautobot_golden_config`. +2. Follow the steps in [Backup Configuration](#backup-configuration). +3. Follow the steps in [Intended Configuration](#intended-configuration). +4. Create a Compliance Feature. + + 1. Navigate to `Plugins -> Compliance Feature`. + 2. Click Add and give the feature a name. Typically this is based on the configuration snippet or section. E.g. "aaa". + +5. Create a Compliance Rule. + + 1. Navigate to `Plugins -> Compliance Rules`. + 2. Click Add and populate the fields, make sure the rule is linked to the feature created previously. See [Configuration Compliance Settings](./navigating-compliance.md#configuration-compliance-settings) for details. + +6. Execute Compliance Check. + + 1. Navigate to `Plugins -> Configuration Compliance`. + 2. Click on the `Execute` button and select `Compliance`. + 3. Select what to run the compliance on. + 4. Run the Job. + +> For in-depth details see [Navigating Compliance](./navigating-compliance.md) From b0f8aa27edd43275fe411ec14194b3ac7991184f Mon Sep 17 00:00:00 2001 From: Ken Celenza Date: Wed, 18 Aug 2021 08:05:56 -0400 Subject: [PATCH 12/20] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fab5ed3b..ea808cff 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This plugin enable four (4) key use cases. 1. **Configuration Backups** - Is a Nornir process to connect to devices, optionally parse out lines/secrets, backup the configuration, and save to a Git repository. -2. **Intended Configuration** - Is a Nornir process to generate configuration based on a Git repo of Jinja files and a Git repo to store the intended configuration. +2. **Intended Configuration** - Is a Nornir process to generate configuration based on a Git repo of Jinja files to combine with a GraphQL generated data and a Git repo to store the intended configuration. 3. **Source of Truth Aggregation** - Is a GraphQL query per device that creates a data structure used in the generation of configuration. 4. **Configuration Compliance** - Is a Nornir process to run comparison of the actual (via backups) and intended (via Jinja file creation) CLI configurations. From cb831743e24a3eb5a9469439f2c3cc7609da8524 Mon Sep 17 00:00:00 2001 From: Jeff Kala Date: Tue, 24 Aug 2021 11:15:05 -0500 Subject: [PATCH 13/20] fixes #110 --- nautobot_golden_config/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index c8ab1dc6..2e3fc821 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -35,6 +35,7 @@ def null_to_empty(val): "custom_fields", "custom_validators", "export_templates", + "graphql", "relationships", "webhooks", ) From 3ae57e9dd2919d7a2f2b6bf5f57ea3f3b10db1e1 Mon Sep 17 00:00:00 2001 From: Jeff Kala Date: Tue, 7 Sep 2021 16:14:55 -0500 Subject: [PATCH 14/20] fixes #112, also fixes pylint issue --- nautobot_golden_config/graphql/types.py | 82 ------------------------- nautobot_golden_config/models.py | 8 +++ nautobot_golden_config/utilities/git.py | 3 - 3 files changed, 8 insertions(+), 85 deletions(-) delete mode 100644 nautobot_golden_config/graphql/types.py diff --git a/nautobot_golden_config/graphql/types.py b/nautobot_golden_config/graphql/types.py deleted file mode 100644 index 90b294fe..00000000 --- a/nautobot_golden_config/graphql/types.py +++ /dev/null @@ -1,82 +0,0 @@ -"""GraphQL implementation for golden config plugin.""" -import graphene -from graphene_django import DjangoObjectType -from graphene_django.converter import convert_django_field -from taggit.managers import TaggableManager - -from nautobot.extras.graphql.types import TagType -from nautobot_golden_config import models -from nautobot_golden_config import filters - - -@convert_django_field.register(TaggableManager) -def convert_field_to_list_tags(field, registry=None): - """Convert TaggableManager to List of Tags.""" - return graphene.List(TagType) - - -class ConfigComplianceType(DjangoObjectType): - """Graphql Type Object for Config Compliance model.""" - - class Meta: - """Meta object boilerplate for ConfigComplianceType.""" - - model = models.ConfigCompliance - filterset_class = filters.ConfigComplianceFilter - - -class GoldenConfigType(DjangoObjectType): - """Graphql Type Object for Golden Configuration model.""" - - class Meta: - """Meta object boilerplate for GoldenConfigType.""" - - model = models.GoldenConfig - filterset_class = filters.GoldenConfigFilter - - -class ComplianceRuleType(DjangoObjectType): - """Graphql Type Object for Compliance Rule model.""" - - class Meta: - """Meta object boilerplate for GoldenConfigType.""" - - model = models.ComplianceRule - filterset_class = filters.ComplianceRuleFilter - - -class GoldenConfigSettingType(DjangoObjectType): - """Graphql Type Object for Golden Config Settings model.""" - - class Meta: - """Meta object boilerplate for GoldenConfigSettingType.""" - - model = models.GoldenConfigSetting - - -class ConfigRemoveType(DjangoObjectType): - """Graphql Type Object for Backup Config Line Remove model.""" - - class Meta: - """Meta object boilerplate for ConfigRemoveType.""" - - model = models.ConfigRemove - - -class ConfigReplaceType(DjangoObjectType): - """Graphql Type Object for Backup Config Line Replace model.""" - - class Meta: - """Meta object boilerplate for ConfigReplaceType.""" - - model = models.ConfigReplace - - -graphql_types = [ - ConfigComplianceType, - GoldenConfigType, - ComplianceRuleType, - GoldenConfigSettingType, - ConfigRemoveType, - ConfigReplaceType, -] diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index 2e3fc821..d7d79a4e 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -70,6 +70,7 @@ def get_absolute_url(self): "custom_fields", "custom_validators", "export_templates", + "graphql", "relationships", "webhooks", ) @@ -151,6 +152,7 @@ def clean(self): "custom_links", "custom_validators", "export_templates", + "graphql", "relationships", "webhooks", ) @@ -220,6 +222,7 @@ def save(self, *args, **kwargs): "custom_links", "custom_validators", "export_templates", + "graphql", "relationships", "webhooks", ) @@ -285,6 +288,9 @@ def __str__(self): return f"{self.device}" +@extras_features( + "graphql", +) class GoldenConfigSetting(PrimaryModel): """GoldenConfigSetting Model defintion. This provides global configs instead of via configs.py.""" @@ -450,6 +456,7 @@ def get_url_to_filtered_device_list(self): "custom_links", "custom_validators", "export_templates", + "graphql", "relationships", "webhooks", ) @@ -501,6 +508,7 @@ def get_absolute_url(self): # pylint: disable=no-self-use "custom_links", "custom_validators", "export_templates", + "graphql", "relationships", "webhooks", ) diff --git a/nautobot_golden_config/utilities/git.py b/nautobot_golden_config/utilities/git.py index 6e96eeec..556c7853 100644 --- a/nautobot_golden_config/utilities/git.py +++ b/nautobot_golden_config/utilities/git.py @@ -8,9 +8,6 @@ from git import Repo -from git import Repo - - LOGGER = logging.getLogger(__name__) From 443fa83900d505c6f227a343333eb927b4352e4a Mon Sep 17 00:00:00 2001 From: Jeff Kala <48843785+jeffkala@users.noreply.github.com> Date: Tue, 14 Sep 2021 21:41:04 -0500 Subject: [PATCH 15/20] v0 API structured data compliance (#105) --- docs/img/00-navigating-compliance-json.png | Bin 0 -> 165191 bytes docs/img/01-navigating-compliance-json.png | Bin 0 -> 67457 bytes docs/img/02-navigating-compliance-json.png | Bin 0 -> 157371 bytes docs/img/03-navigating-compliance-json.png | Bin 0 -> 108592 bytes docs/img/04-navigating-compliance-json.png | Bin 0 -> 116460 bytes docs/img/05-navigating-compliance-json.png | Bin 0 -> 155101 bytes docs/img/06-navigating-compliance-json.png | Bin 0 -> 59126 bytes docs/img/07-navigating-compliance-json.png | Bin 0 -> 69205 bytes docs/img/08-navigating-compliance-json.png | Bin 0 -> 204762 bytes docs/navigating-compliance-json.md | 81 +++++++++ docs/navigating-compliance.md | 3 + nautobot_golden_config/api/serializers.py | 7 +- nautobot_golden_config/filters.py | 165 +++++++++++++++--- nautobot_golden_config/forms.py | 11 +- .../migrations/0005_json_compliance_rule.py | 52 ++++++ nautobot_golden_config/models.py | 60 +++++-- .../nornir_plays/config_compliance.py | 4 +- nautobot_golden_config/tables.py | 154 ++++++++-------- .../configcompliancedetails.html | 4 +- .../configcompliancedetails_modal.html | 4 +- nautobot_golden_config/tests/conftest.py | 45 +++++ nautobot_golden_config/tests/test_api.py | 63 ++++++- nautobot_golden_config/tests/test_models.py | 60 ++++++- .../test_config_compliance.py | 4 +- nautobot_golden_config/views.py | 106 ++++++++--- poetry.lock | 55 +++++- pyproject.toml | 1 + 27 files changed, 699 insertions(+), 180 deletions(-) create mode 100644 docs/img/00-navigating-compliance-json.png create mode 100644 docs/img/01-navigating-compliance-json.png create mode 100644 docs/img/02-navigating-compliance-json.png create mode 100644 docs/img/03-navigating-compliance-json.png create mode 100644 docs/img/04-navigating-compliance-json.png create mode 100644 docs/img/05-navigating-compliance-json.png create mode 100644 docs/img/06-navigating-compliance-json.png create mode 100644 docs/img/07-navigating-compliance-json.png create mode 100644 docs/img/08-navigating-compliance-json.png create mode 100644 docs/navigating-compliance-json.md create mode 100644 nautobot_golden_config/migrations/0005_json_compliance_rule.py create mode 100644 nautobot_golden_config/tests/conftest.py diff --git a/docs/img/00-navigating-compliance-json.png b/docs/img/00-navigating-compliance-json.png new file mode 100644 index 0000000000000000000000000000000000000000..a74ce4fc4f095d6ba8c4f607d36e1a4972edd6a8 GIT binary patch literal 165191 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%0A5xO_r8X&GP$h} zB5ygl2c!!fgj{+xMul}j7`lxN9Or>I&NByyR$?>m!C=$}q{0q+=KU4HAxeKR-Vv^?XIs3J^>$NwH$Ks;Ww_ z-tAUnCimF$NsLbX_RY*Bxv24aQa0Yoj@xDLomiGtMx_TF%whm6jBJqZ_VR8rx2igb z;f3)w0v_!~j7Ece;!j|M{p z&pUd!J}D#SV5xH7syg!DxIxJ=se%ZufOG15tJNWTN*|~Vr^mq)z8t$zK3nhJ-C04w1H}C6$pY>ozUP@E7WFIfmeTI@)f20vw-tA_42v2}+KBF>HNN*J(|haUjG=K!djV+A^d zEE0^JUHY|8tq&}SVXb;>L zme}BV;s2)U?io)$H4KWJ#(s234iN^NBj3e>9-W>9o%9-&wE&u}*yhMWb|P3BSk-H` ze}wfzZEV9&;R%V|E7d7c=>Y?m39Y@oxr(iC^aI<$fvSy zDHVZ(jruxD&7Y|IahVTN8=1H+0_?6P`Fu$^5b<^qB>+iI&t|6P*pE=}xhz+{GH0UC z@5ooAf2cc7{+X%~9L8NyoR*_e1a9zy<wg^i8t2B7)5mGcaAArLkQ0~r4D>xM_-SpVRU7!!1Sew+*vP!Dbx!<^BE)D(RQ0;};rALr`sH zz)Rx-RU(7`_R(3{RBN~tz&jEW5ks{0+#;Xq`9UMpbf{uhH8sbJjX@V-vP(pDs{!zmK@rZY_gy zvdJwJEa*=4Nk88HEF1;wM##_lsLzD&ebzw>;V!P1`0fIF^kH<+bI=aQ>9e{6s!f_+ z>UjxA*mySl;^6z2z-py7=fk+L5y-Eo{(eLI`Yy155(?$}=b zJ=c%CyP3|~rXH7eaIc}5sg3B>3q7|SPdI8?(o7s5soy!VU-X3VpSXdrTX&AB&rQ-k%AW^%yh{i}N zQ~?dZ<&kWnL~RhZviktxb}&W6deSMouFa)<&{t`DbuKHWSKknnyUXw+%e>MARyf$$RD$QYwjhWfX z66j2X%=ed=vgGl7RocS5ZdEHsG}1pNqOZZ4v&FPGP<*w#Ho^NI`iy9ZC3Z`E+X`a*H) zU4G3KBV;az-@CAbnl^I{tkRKs9jJlMM^>*jOGAD=;ad_8L+wwLAM|eD!*h3XGjMog z2w->X!EbX}2Q$z0iC50F&D)V7N(>eo>i6(y()V%I6?|S$a4MOy^7v)0(Vg1 zQH~XI3%QT@=g@zauYj6X)hD*{=Ti=Ygs+up#N%uVV=-*twk^4OG$q~jvMj%5eM*bl zqaFxgg24#f9$^b*h?^>8+=kED;|$PX$`@T}#y6RPG?z7q;sDd}|n z7D0og1hx5(qtW7d9IipLIjLZJxWwNzo@{Hws$)N=5F}U2hJY8f2nZvk2~zUuI-G+A^BlHm2PTF%yrl1WYjWI*K0u88MWe&jRS?5 zCz$q_7u`1BUUH%#S5gm)roQCIbx{zv0a29gu<7NIMC*OWEMweL(j>M5$|KYZOpY=D zH1u3e#TNhy!mOy(!>t{-28QQDurq!`c!(F(v*RfTWQ_V%56J%Q`}k{bB028evS9Le z6Xwq-U(HFy%RX8j6*NVPnyAiVI!MfW@f4lN2WST*y4yggs86&dxur@@a`gOeJKMkh zxy>(DQOSo9Zetoyo4yh!jkTp@rQcgInGW2%miyq5jgL~{UWnS3_sYHmwc^UxdB1m0 z$0jbFGwylwC#6bU(r2n;PQ${t*P&9rF#b2^C{y45XXPeY=Wp$I17#TyViD=n7NS9; zXNOJgUdcB7VFjqqez(dzq7Bl_IqUev8{SSfzq#Wx9rQkNOk}1pZ84N7C1{ZVmRh*g zkH(u*l7jk)O8}SbESL?CH#W zA*~x%7&~3Hd#=Fm{=l7otrwhfE~YgrOO6ftrNwsdpuL7}JAFlh@f4#Q^cDB*P=iDE zk3WGo;cCEF>QMz~Jl=qbcdwqKPcwG<_tA>Z)Y+2Xny6qnhso{1oh*Cv;r>C9f9oYa8-wb)#%lDnhc?|nb4R}lQ? z{!2bGr}xVZJsf2e#7Ep8ezD+wqQUWd579>T*cny6q$fsaiudY@m3AtT$PLlbBX+w35rz5{nelujkai&2&fc;JRmRfjvFJJg+sq)%X-mhoR4 zW9AEb0O(jSQhx$ruofV8fQaYz`x|2A1D>OR|M$FLkno!me;?!F$5gBf{?_uwc&<-$ zY{JxK&7KJS_BWUg+yYD6A09sXzxQ6ipgVv9FZM@;)eYU>|H?oA~{lh)U0#f6PopA`Ky=s`wuqahYqL4kAfb1 zup1z102f#W^=^E~DbPp)U2uN&av$iaI|1aXERxJN73}_TK|{HwF_`Ka+l zfYSb}E2kjMQv#;4CfhR|3xMiJ0(3n9xS*DSN)WWV^y1XdoipFH_{hSJBtSdSK_L;K zunGvG?dKx-H%Cjm%;0zma++c#?YazjB}ZjIGFTfXPs*jDi`77v#;_OxHK8&vNLfq+ z2rd9Hxig+`L%7qHkmoG}CNqTH3qKzK=;NekBy=E7S`mQu3^n9H$o#_wrHU0_(0Y!& zY@@om2lhXTz6_~nbP(K^AoV~CKOB6AR@VjP`~_OvgbhaB^8UsvGZ@;zlh3y{D{(v! z6yA<29WwwQ!V6(Aapp|o03l`i2{h+xRd(ibods(?fdDXDeDs=EMe+0nwFlL;r%DmM zh7ZTyXf5u0lW(0KjYG_fby3xI`FQ8R9tx@B#3?r6y_F7e#e@s?nJ4oX~yE zIipMMkkCbf5XMrhVj?YYrl?^W+z-qFLa}&b|D)I^g2xy1XxhSbmdO;KE~xNWY1hJO zTVVL||HQHA<{SHQvsr1rsuzYKucel0BLw5M&Oyf&xJ8oeAo>V1^sb9(!i&9IQIYy+ zDaz{TYY7FM)KdLx_SvQ-7*J6@M8hOF5|V*LfYJB1NqQ|TB}Rk*#gf6*k*6K@dkdc& zRs4W=%13dWhQnAPW+I49KV)Mg|y8S8>-)-qPNFU|f`3N;?LQBpA z0W0Cqd8e}FJm>ZEF{k?q04pV~MD^GJG~w{jImkU2xoUfT8qB(3g;Y@&zLumcG%7Vw z;o^@=yY=@rA6S+Yj1#^(zP|c1(w|ZllIATXc3|L7U`?rw{0+nuSVE~ zYbR3M9eU)B@7}M%P@nb&PhD~B+qoeIaW}t9dfkW18yziNJ{vg+1d{>Qr3efm}Z)O$@SPgLF$2VGZyait%0ed zB1zXeB02wOSElr==hBc%lS~NMjhxzUZzD^3%``8g+xLvJz4dEhgQ!)a7B+(6oKzqj&sAS9~ivdX8^rfe*7a?vohgoRu~I z9QI@=Ze#s%)7Pn$LwWL}!mgf?(RG}zl7YjJR5Pl2v*wfDj7-0q#Z?pmgk0cksR`i( zQe$ingI%>AYU2z-;|n2L60a(i4>3eMxv7DQ=zXYy(J04X3YF2AJ`u<6@MiUmrSk9} zOCd2nJzEcXI)hK{#;bjL%Q4aK5{_2QOR|kU1r;eH_5!JibKO%GK=(TK@pF}ehk=dp z?`~FE88`t5`AktR3?*24#K;qtwbo=(V|dD!RJ$UiAFphTWD?u?m71jA1vRLEk2St= z>Rlw9k3Ft!n2u$NU2F8VMz@O`dWQUl#$2AB?aIB;b&_oVyf==b$slE55uAF0X}azL z60!NlP=|O?hYdhMl=bGuNq5LJYx>bApcOLh&`yQfKEFv*Bjc-NYyj&lxyveWmc)Tx zPxNyto1_#n1Urh_I>Osf3Jv_Ieu}WA7=St$h&k*AX)~yF+~RF)ZPp~(?zKvpp7I(X z4?cz-@(?d+y-rRri0-81PT$lypNZaYYs7%Ry*wCo74pE=h9b~e6x%iU|L~mjif@10 zBvfmdsYsmqAjV8)fi>4j+1lu%AKY$3%&f0MXL1Dq}_sy}377H5Zp;*yAOs+`=q znO?wysc0hEBGYJgi=1Szn807V(W=1~@6rXp_vAg}uTU=xH^8X?hd<7lA7bcCgvVVu z%FV1rp2U2#w|rKap$@Ue8P5X}BSwuFHCCF$913=m`PE-o{h?}YajLOItinRoDLH@w z&U7kk?nU{Z9D7W?Ao#F*;7$`mOhS~74f4mo+S#bxQL_E%LKb`DI0mXTUEZ9k=d|X{ z^3aX)4k4D)Pdgm*Mx^>I)*X;g!PllKD@ez8Tanedgnq&HBUq2<4ZvAek1C}f@JC$c z1!RtH3#aytyH=PmXZO~&Z4ja46LQXT1n?}W1DByDDz#Am@$s3qP*2iYbz^Y6iRQIM zrMp&TAn>uQg9or?U=7aWp(-NzTC1{R)g|cUZRr$crAf7Cy?qOwj)xtSeT>VfT6k>d z^!cB(_k3HIti1-n7{;wY^d&-{w9#fNTG!NTm;2ye4b(qxBkEs>7T;_Z2{pf8(TQUe zjm5fWr27W!phJqGy1l@ryO=&+aKWhz>_}K_8Ds=pCOHq&ecP9C+p{zFV#_e=b(Hys zUc+4r8o(h0zc|^2FOGehFj?^p3XxlT$t1{rLD-o^kKZ!R)ANtD%&kSXHT~8SU=tbD zz4yLL=tt}W^F_YY^^Q!l*l>nADNvl>BP^Yr=HH++^hWvKx^_$&clk23M)1-txZ%w- zf#a`W`V~4A6AfzH3zb9ke0z8zhCNa{n0Slhly;=h=V*XX%b)d=5c+6zYJ@Bk5+&|) zSS0<6#!-cIJGE*)-y7xz%HN*@foL^)T=$?TOKec=Q=+$pLrt|s^r`ZCsirfYw-p5u ziqVBo^ciw@&2lMBzXo~O2v=O4bWFR>ISX=4$fQg&t`9?hkA-csr#TN-H{ZJZL|@h( zeNgwY(rhLFle_oA8Jt{iU$=&R!LPDwK2`nwuT>>F#wJAwg#$2INmN*dRQWB{1?{G6 zfzOBo{!BX5tdu`}3GHtnfsb(|+9p1Pz6ul8C{qezTzI3r-6yhomnSd5(4eH;xa;~{^;6PRx78{32%X5h-mC6;Rg$y>N6H-N zmGbezessXqnxrL_s#??VK-e?1h%B#~b#>1fPcgM!6Z_C|eK6yMj;A<9QO!X-z#naY z__N*xE-dlsJ)$8o7!E=M}PWu7^J`^F02 z>Gkfot<|~h6&a0ElIU;KzV0*op6oI0(9~t6DqFpCB+>G;BI-x~@V0NYAzhQ*hkQuL zZ$?OUAqr_dGO*?!-h~wA4?;s?Xe&=WyU?%kz2{Iffb4a#Lj6LX)(xT1?g?itP{o@V zl_gou=-hv@0R9fIwW&Eip^+!{L9VWQkk zckS4WVm`%F0r`x zz(KCbvrDY9gnvv2qkNUDXM6N^%77jT%G*+P0qh~z^=UFWX(;xjxXL&$Q6qVvCtVxS z6qE*CQ>MD`8x6ZB|eW=+khMK`@0a@q?Oy0wQG#kiSN z_~aC59rMw3+=?w`AqUx~E-yw=C`UH`#o{xIDG5TLKQRmfX)Zr~PSH0ZHH8U<6L#%8 z`!qwfo4miZ-@L42b4*B0TfgoNzF_f!{!-ac8X%X9etBhhzK0zh49IBP2JvC~#f2F; z(bl@yLU~l>=p2toAgyC*QvSZLmd$u2<%BgllNnDiG z!~^fCav%OJJ+l@p!9W{Bx)0aS_gPH#XZ4J`Lp|1Ow$mQor?;!TfF!>+IPxUSDm9>Q zO=!(*PU~HL-(targX~y+VeH|nKvWXzr|m7c^DtG`mzW;BmY8crWh2&ZIsO6E1%PuG z*2=x|9#s1NK%|n3#?%{0y}r#t5U`I)xP7XUBm|d$Wl?4@OFin$JRRJ=zFD6i1Jy@f z^9!VbRH!XGi&N5)Ay)8myMUm<9qDkWu+$;G?xEtF4qbM6)`3Qi&yi8_aX;+pSQQ$A zVwJSl`Gpapb^S&UGYY=Aagr)Q{a-QW@mL>t41KXyx|p%6h!2ef7=^;nd1PrnD}EZH z3U+OwwT`wH?$yL96Haq%{By#L%00nbGg)7r-fZ${e=Ghp*}SM^{)`3*s}q-n(;?RI zrp?HPZlWK}fcnE^F-zh7fMo->m);xg)^Pwk-1GNDH08l_9Z{gOsx{D0WT8c?u1~O6 z!LJCnz%F+Keb|`(^iyB|9z?XhvIvI#9#KWQ9i#Bt%1S+BN7OF))2(C}9SE58a>X$0 zhNLf6X)#-8A57L#TKKbd^{oO}HXSUx`_A3+Uy8jC8a$Te_4S`*eaxTFMM7X6#Krna z<*4d`Z>6d&AqHx6e<7i2l&KfzwdN~DWs4OCKdxTMtumPISvR>q=D+lGxQNqKm26*_dfJr{+loRey= z(@RZM-8w+dKa_mJg|HkhkmE8QQJiu!_PxWKX_*gh{%1SKSgz(J`OcgZKP9tRNKx5Q%^}dicZ6V48z6-tV9(`mKz>fNfdEDZ(^3Fq+f*pFnuaZj-XLX?hOC8`e zI3(hJ8Cr}o2Iol1qXm+Ys)6m=ZFXeN8#vuI1)t^8mzf4v6EC@r=$K6-QaA0%;2QZ` zfDo<4N6%P**W}romoDID1X1Ul28**$5@DiaX4nFv9&FyfC2;CbJt4HPWG|)7oV-bQ z|CE71s=@C`K|l0`a(i}n;taTH3?ZJjZu!hmd-qbz+Buq|YYcOh!4or~LA|$l&8;@k z@1#UwQukO!ti$1(lkXI+*w5nl!kL)alz)91D`LOY!Xo(YZfaaWZky{+hCn)oBlT;g zJi0CoC7qoe%oSu6Rl7Co&QTy5SWQcwpJ|_CISQ+2P;7zc>xOJQv&m{(JC^}OE zd=^ejVXdwjP?;U7UH|tevlLt*Kccl#4mae55&4cN0N zR5=pyDxaA zsRqq?rTwKt(x9>V=)?7;H?p{ zr27mjA0h`?^{N=nbslqlbrRM)TrBJHX&_aMxrvri`qKO|t+a}X{a3cMW|y49dP~qO zMfYu$XzN*kiDo;abexps(aVPe=rf9Ae=R3S4180Q8Kw%pX9J zR1iomiBVW{j?9zGKeW-M^bW>~6VA%*D?SAZbia7dQx}^z!%J(1!_(z3sH@}VwYjf24*=v(6nJS-CwX!x%Wm@u^ zof3Ck-fp=|tO>gmFN;Uq`z1=A$&}fumhgHQ14QzSfA2Nmf-gfv({VuT4y4b zVh$U1J<6Xg0OFwB@dR&6=XJ#y!9S(0%&I?MLTg{Z?e&2w%IT_fiC8g9jvqpQDXve6 zJx8dD_pA>hpMf%mIh^WfEe&th_o8<#>nV zDgByo$6MQK-RUUDK^lx`)=W<2{L!QJKK>*ZW5VKa}pQfQ=ik1D2O@*D<6=&MyZ;dduP2;7L5HFmgo^|M##>A@-*)z~i zE@av6T)MFR`DUcOUb401PQ~yd+a{UE$gO!M{={uhIRM2$d)q>=*1FHd$+28nh}-lM z6Rb;aQJr{JrOT97ZXvHsSnQb7qITM)D1zSI1XDc%uY+59eF{5ya=H-XbTiCP2zjI7mxQSPLO zG}Q#LBLOtoeV3$-upWUAi!ver1LE)rG>32_;jGWg%22^z(hG6-M?vg%*|eTgzs}rA z2OO73?({`@C1Fa{rnD}+3CAnqBIrFGR(#C$=5$9qiw`A{ia0>nOI_m7g0B%iZ1_rO z&Hz%RB^7^`qj3%{gzo}=Vlzv)QZoOop2Ij_wE!>p5pOvWUHs^av}))|B6!Vn$xBXf z#%mr8Z~l|O;|ZXk@#aaXnW|UQl@36U&tqOz`jE`vd-K#slBGgJm zSwMCAQvzKgtd1s`0;T?P6W3n&aEBG*efK+G?GGi)Px>k^%W^tKX$ZKsU+F@iD;Rs? z{ZODd7L72Nkt(XIIa})dV2-}V5q9B?!TFGGJ?*vKZ^`Xlo!7Nef*A9UB@l zD~WW^<_jp<%raoVkpJ>og>HcoI~wwBfa2yf#9v)I?llmAi8rrkPD1Ic{F$q`oO=H@ zk}M{#t&lvEQV&VNa;JUk2vBS{8|J>2Hj$!xvsnWkzZ5=?`Qe&Tl*bf}#r^7=w50H_ElE=3IP~@Te034XDXZx|~a*hQMM3O5@pmURP<_77i$DM}T z`z8ADXNj0hhn1A*w3OWllf&X+s`ae2`3We0xhA&w!nmGYIQqs1(-JT}n_w@oCE98E zqJpa_D^;1jH7b{H?-h?q>ChX2ThS78rxT+n&O;)^ODn3zreA`zFY-M2w8t{v`T#Vb+Mhk%A4F4hGw$R4s!?Q0_85t|Ne4BuZYl}hFiFud@yTipa&3T61sw;k95r>v zRT&n$^X4ReCtwYGrTUgJa$*41&}vZ*MO)+MFiWoUcHx}Y7BvK}`L?PvcXrqj&op(* zxIKFwp!OrWI-^*D2c|gRlRNZ^{%)ctmoHwnut(e?Fz0kd&DIn)Iy|ulKHQg-SmXd7 z8d*!1x=5g;rh6m$p^rsXjL54`Wr} zyS)d=On!Sq|7pkD8?FPqr${;|hqRBI@L*|gQ**&eBSQ&-vcRPWAl?r(#31S&oQQp1 z!W}0Mn@=f)@*$Ic`>o-&agecu zP?~9Y1UC^2f2k~gS7D1gBT)v^X=5F{BU9e2qabvawiE+TmJbRvQJCg>H>aN~32h7N z+YpwWl^{K>*4de?vZx2O0NWTsUyH}*j<(YDbj9|%NtC}3~IlI^i+$f06Yr@fcN z;w@{ari5ese8^jt*nI=u?fT|i3{nnQ;hZnGi%w=deXjGwURdh=-L8vnPN}ULs5|<) zqkZOedgv?goV+q;r(3mMqBdx9QNfq|t@IcKInDq?9^N;`1_6T$G6&hL1QRyvV_WL> ztDz;MWeZ1dOC29%h*4bnfR#nQ*B)_VJ$Xa>KuHfZSB;(paX3C3t(6Slx*L(apZw%QfEXYLaZD&c zV0Z|!r+;RI4nzq6;OWfGqEvliu-8H4v-IHkKxQ@P!^OcM{E4W={sPJJY z7xyRC~Jje!R28lNA^SOB%@QBu5Lkl+8LSFC3r915sf6 zGwLLaI;xBjYe!Li;y5{o=Z%4NfLau{n`G)!(8XV&KhH_4P)C17jiV=TTM-0_#VvVs~Z6}u6x8%VpQB) zD>K1fXgIC3trWr(?>)&<5F!qkN~8v9y<1AfKC(~E=b><1o@{^IM?Fr%ZPE5ZA3g#g zoh-R#fYm_d>!?}42M?ruw;2|#Jo-{vXEdOI3RgS&-Lqa{lMbX6UJCbkKQLHMl`MpP z3z%o^iemR0pmS{+j|2R%iL^Azau~aCIxs8w^V=l3A@FG8KT+J_QvP z50P3D@hsxwgKa@PmOu{x++FH}8KPrIAq&Dr z!1CkcUz$h>K=trgJ}I=d_DTEaZ`p+#JMIB;!wGd*-I-k|+lVtGhjbQ2mmvl@5u*9O z#YGxE+XAeX1V{5LAT9*rtD88P#>=E$mCOgAj^Y1_kZwx%R%jo)s4FU5*&THlXF`=F zMLX?0;oCfi;}md-D~Mw1lr*YYUIg~yjUYxP{RJ3Kt7y%{u(ZSxfiK6~(p_WAM5`SH zah|zBv!H((CCIYY0p`RyKsS;9q69Sm5<{lrD%X6oNzYSlbA(O0!S<3b03f84Y*dDy z$^t6^k!t0Y!%a}MbeKaVI!s(gB z*)jm&%QR;4?Gsn>-IVrX0z*3zZPfS(VH2$}SHe{+I-e7*vQ#NGKjc?EZMjE}%|jLR zTz$}z|83bK4YupYn;LfZ0!w4}F?=*BTe&hV~vvK^3yB{_bW2>HfowZ*XxXqAb zar8AyV-gd_kLI>`CVLk`*dAIlBbI%vdg9>NDmyhix2#of(Pw2-&Yl4m3MUD2&591C zD4)k$Oe1dwd;L;TkquryGBq9UcJwWhW=PI6ecX(9qA4>}JRol@TDE5OuRutPOQ%z9Nw1Pop;lTRL`W$41U2e@>L4yd z#Cq2i`P|6~uUtvaxZGojoY_*PRtn?%JBz~7AS3Qou@jj4p)G+!?SUvwZ7E9&bhlVo zY_XuH*NQ#!v_%wpOmwHhT8b&^LcENjQ_`J*vqQh0Ne!E(9YAxTn5P!3HHp7$}IS3Q>$Ma|o=}3#q<2X0OdH7#0V?1!M8q zy-l#-22W33hATAIK%{c4g&pba&K<@U^kbj?7=sfuHO_x&wb95?y*{C(mWZ!(9z9V! z1lK+-@EP4`;`~!Bv|iZrY=^XK?SzjJtHKJGh#o(M0rGJai~89R(qzwS@6&u&%^E*iTOh$lH((or%!#7+ zWOeUsXg7_yq!nrrcV(UnK|Mk$Rs6vsRT;&J^wD;z#Ah|7CnFXyzKMIry)AL901GOTJhr|SB8TU$&1BJUxwP#Xk_HpB_WX{KRU=| zZD`8ug~U2C2sey-Nt33zg-jGsZ&>lN>}{OZh>xsv0tw;p28>7V~0W~57fqYQGP;rBkKJVa$; zSN;V$IzF%0vj)velJ-cqIyPo_VpC|309$f{TBtOE&2Nfk^1?ovILyla(ygXx5uUIU z^OpZ0#wZM?{%f-a#e803r%;A1&Qt2eWLZYr?qKBbLDAM)Y3;g;>M?pl?H((7N8>nG z7bl(8MyZO=$h@&k>obmOn74*$VdQltkS*Tq7c!y2myJhi?qN8|G97TxCiKS%>uriP z`$`ruK<;c6sBa)+85y*;bN zbFpE;i-=TGhg$~L0q)vm7&&HAvlwrGqVKhp8TA3n3BlfWsa1eqEFm>biqCO6d!6BQ zz6YqbpQpWYVZlvMcKLQGA7vDBp=9Q&NsU~CLi3_g^B^vq~tzb zmkNhJCZ!?wf0oH!Hq&a$+*AG%w*?Q-wJE0<5P9zMk~AxUJj>ANU7~AK+^Z}n!4HeF z1-Vre&q(Bp_#|WYr}(DZuFD9&jWA~3gcVnu)Wwz#aKs5JOAnW|Y|Pf}afc;1WX>x! z2mV5SgH+mBpQ^pkCDOB$wI%O|{^}XXRF-)Zb8#|DKhIrlp(0SMXVUk1K?FC< z=bs0(qzi_MG+M^h{&5E}&=z%oT6^qL*!EKU=2=}|?QJPYa50hInh|4iZ-|{!!@A(M zu3F7*j^;3bs7((D`)yLI28PTV!!=pPFwX+g4obVMc(2RP{VYf9*(*stCOZnXWx1?m z{fy#jL4>74EY2Hq0FZU0gPlXz8I1oC=^`ldE9qT(F_ooQ*61Wm4m%d+{POLLRJ)qA z)@@h<@r>sM!VuYe;L1xgaVIp;?g)|*XQfibX(hpvy4I|^yx*j5PrT%mUcBZYSU2Frl13MN zf%w)d;2vL+I!R7#Q1d!Pg&8o3wbS#R z-1zP_kz_Em&;P9bv#&MLRe|Hxy@?vA;qb9C$Mt1m^&63R&d$ zg8%HPZvOM7&#=Psqwb5%q2bES=(pv{-=(X+1bVGYZym`_UfRCiZE42pueg{n=0>2! zo-OrHO&6@)UK4ZMft=70T5~0LDGJXsA|Rw*d01KS49odr9M(o>PG*Qw1~%b0h#!J> zH7wFzeaijDy6^VB(9+gi7vb{ISyM#QqSNx)isH^NBy8E#R!u2!+)uY-^2ZZ4gHvN? zhgY8+Y)q=P5G$%vQ2RqGsdm5e584Vnx!TbW2!oOFWN86Mn2gJ$GGP=1rR3hBntZWV zFbmr4O2W>INCq{g;5kyQLD%yGgyet(q9tJB(8WFoscve9H?ltb6fu)AKOE(VUj`mv z*sxv>`p*~)O$w&k0bHzjZw#fwfptU3MPe7iCqZI=J?l7;sF)79tb4ZxWGaC!yAQ}Q zWz)|mDTl4y{skguN9&mvqWFjSww*dN=U#T^Jl z@jRc@ClYss73KC6)qVE;`qB@!mywdOhnZW_ZQvtXwj}wFu+Kt2_V5LDyW~e-_3D}f z@_Z2D?;RVios_yWm8p;>^Ke{g7`>4V&Dz!E`|Km|kRGepQ@m;i-SGf9m9zz?*Fkh? zzSJL8q0TH5{52q2Ex|wZ!;PyGOv*o=%*B%FdtURYZ8P?ZZ6G$V_UXI}Jhu0EAYEDv zl{*gPl01ao<=DT=Lp^tk=^3y(IP?_SuC=wN^88=X4-n4I;o7y(|N4<@YWq`wsGAW6 z6k2KudM?~mPqo0Jv%5L36NdM1V%9f9!jt*_{inaOuO*kFn0Nc$xz;~z!o>SsF6=5# z_=^pC76%?=OvQk~?h5<==*PV|fSm+ux9eT}=kJf5G2DAETw}^p3A)?E{p-40^{?d? zVxnDlLoWX!JZx^VM4mjFMVz(Yt@izE1YeT}L)A^O>^Fq*|9)Iy=LLYFI$gXRvFmd4 z*L4pv)w5Pk_T2dELU;S{&kOnIh5R)Q{^r9!GvxovB`a)XP_Egf9|*sZORTYQY&-_ z%k(A3qrEkgjPnKo>X->hwJJxxDXnL!40Y`-UuxaNIjm3xDgHX=*6Kj5VEFMtO2wrE z8oRA>|Fxio99(iNs{=uWLBKjn*c2;nyuiq40Vh-EB<|U;7agwU8kJK!>}5)0IHZt|VlHyXNK-$^-lr zhI4s}=6h*rFn**iw0uYUifV}+FWN`e-0#ZCk;MV@8*#$=Q57(}>)PI(@Gnx#w zuK{3Vh2(%uS=~F}nPLR^ncHsh3PJZHF8=55{;DYc=MQ1f(+r{ei+<`uaO^=U{r!|} z$jM9PTKK!vZZB`E|9%F4RX_jJhlJPr*N=&avVsU>%xi_DK2g?>DzM|NbHDrbN`?=y zd2mEg@i*TuxCqGyphDW`nB(}{1(IYVC@h$J_e)#+{#X9_C;vRI|Ez%j)5!ntS@qB8 z`hR6Ieqqj^wptDXlA8f=2H5PYh$Cl>MHr{!fwbm4kUIM%!jJbCI38pNSQRt201J}x z&8HzuTn{5R3!;bn7d+sFQD8b=M%ZrgfD6ra*sagu6Bjx&q z`uzaY7MJ+-l(`E-Sm4ID6`Z_`F05P0v{Z)ez%wt87H-8SHG zT}Hfq7YT&xfud^-2W6ZioySP-18J!^&t;_azIzH2-~gM}qH&P^j&C-)3ZNAEnuS+H zQ5@;a~-nRT50qf4EqZ{ zZ{att|E*kR`%_oJT05C-r&ij%>1lYccl$o0+Mp@=@OJArG4gI0-hcLWM$Rm}s7m#n zoXZ00eL_u&s5U=F?cnj=79jYWOL$o7lozq&&5PkE08^JL?Y>)_-1n z#z%u-N*!5oKA-nLbFCa&9Nzi0Lp>-_G5fueF9haxq~GH=t1wA6*2B@JwqcAUXJEXI zc>WNm$hsCSL`_#8ivJvM1+mmk$HxtF?XFH;#JEV=X04Z27tUSQ^_ zQ87+h1<|NVjQC0r1t0+=rcQNSfeqfkx$^Fz(C&*Z%2z3C5t#<&-Xr^wT(b8%9Zm((%!sZP>pjO0iJ5LdMxrvU zl~)pL^(*j=bzg={zv>8Egtj1RtLJL^`5vx+7P3B5^P|wmm>bu;dZp4`DunR$wA);d zC?s`ZG2Tyky8*+Sva~AArm|Mo^YzrwW~rTZ?PBa%h^g~fN{M@d~G^8USNBj!xJMZzPPl7`$9scslR~J5KvFZD(%3^ZmeBa+i z^{tlLp*U53wne=jqEC_HrgN+6pA|qok0wQG{8qVgCVlS{QR9!G%{89ToN&hLg3y4~ zFW`{E)L+&({o;T%{_zzMi4y>n)WhIlVqzE5YCbSnz=&~-oFGF6Qf$A${v9p{mc$#b zg(NAWj9ob6KxSY`T?YfFV_8@z^WoQ%Ch3CjzO{rb<5;-5@`N{m=t``b(XvLJ6dZiG zIug-+phaz`ys!gor>Qab7!8CuXi;l7DH8H*Z~nNa3Vh*BLTYOSpy#22M5K+b@|wGW zHyNj<2DGJX6QGP#3%=D)%u`r(vsH5GPUWfPh%&1@dB-cxW7l^U=DO8>jSWj`i7Rh{ z6UU?MiP0?OJl<1uKjp<*U4FH7Qb~@WzBW#}plW4$9vZsYwi76V3{DN%&L~I@8Qov5 z+Sb;YE|gO1+lHvEn%cgLyx$4;O3ToSAcNuBWUut!^ z8wzvoheQ|&t3FtV=G-SgHs?FPRK5&>1Sx&yc2tMUoiuYix zdDD0UP+Hrv$qh~D10jRv$5ewu)wCSQCPoT1KTtL0GndwT5E{vD31q(zY2V|&UaV%w zb?JRIVGuGtlQ$5Q*WyVd9pgY?uT!XMrLWVx-)~GBX4HT$ zQ@PLrI}hVqTMlua78KKTY7m0kqNKFIf9T%W1_^K)W`>0uox=}Gu6gCL>@v1}WedJp1 z=T_r-+MME%i?*=vN5SyOS^z=@(AbjBclAJ3UP=LS4)6tv`EMgFwvdEaNWOQHWE=U5 zwq`QRI_y0YG{CrOBl*Wx@#wihLItwvMQob;9SxHC$q03|&yVM6I}Vsy35X=I{P-wL zoN)*EjCQ||$%{xnFb`gZ2xd);i#`3L*%X5Vt7W?%@?k?dvs*dJc3Ykwm@HHMCbMyKJzmu5khNHhy=s7bfYr=1dl%Od+ZF7PWJ#55 z%*jU4e{un&fL&_0HO@&^vwz5PvDqRt#?BK$DI+tMD`~trr8%xvqOb8ofJ@V{Xdch) zygnRShG-_&a4;Y=OF33`I7Dgp7)b1#ctYjJzhp4T!R8q$XK@AuB8>ZL?FDw*5K!mM zgi$gEv9>WENT9mSqV&0JQfVY@+t_;;v}na?w!u%?^vftB&S|0@OK-bAT81Zan9sE8 zx4JB#WqAXPP!oO!Y{%&-$5t#zqM42GIRGTj#MlSs``XFMATWMBw`!4CYCVqWYu7+Y zQqcKuGfU$vN%@rvnEd{D`v8n#md^r7gkb5m!ML+E1Mq6U4;&+(R-F_uf!T(?#}opf z1V%uvpR5n~z}m$@bPLrLnD5h-p8-X-9WcKKHPb}9b$ubk>dB{z)6R=%DczGNpxtd= zz~K^B)nf{V>CV~=1m&v3ZYX>6EUFski&|T~2RbNj%1?N=T1Kpx@mTBCN%u=1i4JU= z(mL<9XSkSFT*MGS{Vfx@9NO<9jy5!(FJ1_21?gvQ&V0NGfvMV$EhQd|o6dP(xa8;0 ze#l@b8a!a4<(ptWN(;!VH61thPrh*J+zVG)b7M`Bcx0Z}&;xThm|-QK&dG6!j|7r7 zMOXl$F<`>D6NdokM4KP|VPm$vTX8%aEsBaF8*eSQ7y|p^HCj*IhV8vD8qQWbv)gOi zhj$FZr9niTQPdVZ^rcZb@5+%SaxsyKBKSH*XwAo1(L z4XNN}&f$A^^PzW8-!ss+R9}Dht|bNa0V@7HiVu1mUlWzL_v4q{=m}F*wb!$0C{W+M zrQy@MhjRb^{aX^^O)~puCn8=lql<02RFSt9qa8chI$x-Rf51A52%YiC|EqE*EgtVE zp}s1DC4d9jp^A%;W)=j{GBK$Idw|u6v1}k@@p!{Ka!-c)i`ghfU6Xd`bdrm$tt}^D zHRA;O#jNj*yoSGNtCU%K671J{qKD|{1bjm!O^K{cV$J^ul4^+&N442^*Xsup{D0`w z)&tRkY{L|0CsZ$Xxd1762HRZB$6~ojlaoTQc>-m8mmMLy9!smGL*l31<^oN}b@BT`m z`Pt0!&Vbnc-Ww9r?`G{}*gPeq;R*~gd={%oBs=T%Ykm@rdr?I-CyRASB=<-(j)tep zLPUjxcx(fc9&a3^_nWvLF4|&O3EZ&Zv7S3#((SQo4ai~=a1G!63e$)P3nNavgKyif z*^nZj>A1aNl){{5I$-K6yU}~Gd-cX6ICjeEWFuATI@Hnhl}h!>=W%d@uL$ioE5s0Y zlsgOkQT~}Q{W{$%LeGqo%i6bt8z4duE}Rb8WCBhkwtX*&P4U=v9(Dgv%Vrd?099+F zvMcD8*hbJeKpN0%ReT3RL=I?O0Bfb}>xRttc}d$Ks0%Us^tltb7NA*$KS0fqXS3ZX zxSv;B1@w_ff#ZJ_=P#4OKsgu@7Q%zw+rCZLrdi2PYLOAf(6gO_-@$Jqo-~XmG`hF6 zxgjvB?Q66%NKF);qh>weZ5G^6fw2*WlV5|+q#4RM5S`T-*2!(Ssj_4!^RxTl{V zwndrKb{Z%$A+0L%FP6S{@@NKp}W%8y3d} zsOUi{$|uy#1X@E23un@-HXSbiD(6=gmaW}UZ-eMdTh&*SG)cGvp)lY=ZzYzxDVmwP z`Sp4Fa=)!hrbWZMz22MV#~gSu+!h|=q5&3Th4u}VWpCPcug|8`gk<|we!rUdcwdEc z=sZg*I$PC?Xt7{T*;H$ZiG4SsB5RnCKPqzt_zZR){au_RL#=GD_m3N9cR0P6CS25GY{F z2ZA17s&AUxoJ63p2o&#^-Ox$+?k&lvR?0jgHpC)8$58jA*+}`zS7A;GPFr&UZBI8x zyOAteDVgKb1!_Bjfg0=Li;Z=Y+beo-y;f`*KS8L2?vrv(OFO7~i`hzn_fGKq>cVrs zF9Z4fmM6u}tdgM}>aW7LbVuox#=IrQl+gz}xrg$~i!UDRwI_*BMwL9&_GDv|d}(j4 zII2FHE&|D`T@#T1EtdQjA;M<&W1|9S!;hPATu(+Pfzqfg@09p2TgB^B%1@^P=`oiVi&q971I${TD7)&+P`gVo7h9_qz%o*m+bG z5s#GgVj)O&>Q=4uF*uVejoU}&HkH0`D`S=#LU-^Om@*Nsssv5AlNyhYF3?m{I5K8Q z{P)vZ&LIW*W(>5=r8Ubm!NjC)e;BnV=oRUa8!)N`6~F#>8{k*zR6L$LuU@V(PqnUn zmU;zJT29KtWfXwOB&cK9b(=n|?Rp{St2_d0R7(Kd3y90X^ApTQ*}OUh&bWFElx_ zR>}0BB5p`4?#nnI4xBc3bI3nqc|4g{z~XywD2IA&TboL=(TTficHqm2(|H#-*JRSI z$GzrU<;)hXdxNaj6Fn*lsXQQL(2~8z4kv@7E}MfId^|>VAkpSVo%if_ z+Sl=-?t5~p{X^b&sw2sk6SL7jAksntT4(I< zxm=9mfCL<48xEt|t5G#4ViN1@$2I1wl&P}*@X%4Xx!~ruUtu)fv8|?%h}P4so?|tw z`g9IxyK2;F-?l+CpD`upiN3xs$;Cj0$-BW_LN?oRwAs8}Yq$cULv9FCK))^2Sgqdc zenG%ikfOfwdqu~lTdz$sEr9ayax$4s*W)r_t^5oYeTZM|O-z!A-I_Pw-vO)^4eg?< zn9M4u--$zdMS6%ldC{B=#Y+Tk+DQ%;3dUR8omUpdU6)iyGGR;&g})+*P_8`eSWLlNH=anqwHM&ZvQW%yjaE3{ zn#4DkSxwwrG?+LY3JrWHlrgDk%`+PRrDb?a&HBQGBWAViqjj%*lKsjcEe|YpMFE>d zs-+|_mWZol>8rV`vsJfK=)Xe6U;coD2JW3XCY?FE%yH*I_z@^Y3Z8EMNNbAXs5dB6 zElPKIK`W4(YXL-SQ$uS2%zdFly=1~%h0qg}pALZie{OtyfFL(vH#ndhl$^>88ckl$ zxj3nlGU2Z0^rmot;S_xpg&|aT^&57Ax+i4I?kvwHd1@RN=9_o&d+|g^*3v8s zglzj052^|o+lQ-d`ws%f)0Wu7j1+C@*LdTtd$y07cXWbl)*L4z%9f&uW6OjJ2)4$a zK~{#$*4sO{bp1JActY_Y1y7JZbNbsDO>b2^VkFgiuYa#P5vEt)>r3~mX*);c?- z+B>khZTEwrr{`tO*5OoU0Yi@Jt}FscjGjsz+$g@pr^?kj~l}u z3`WH2wS7JU4H6|HjMFBWs+oXPSm0QqY>tdsaJz-zMHv3BR+&Jw8QNOlnpS5*uR397z8PuqPKf9DnKRX z!)-z0CrDO|robxu4sj{#-tQN@3^E&M=i9Zm99|{037sjBvzd8s7 zdgj3R+wFe?^kYKsw{07*vMatNQ?G4@z#7gcd!bZbezE)r>p?->)>-q(R2{FCun+@A zIyH~OS{?hf3Cr>0GFzdS-C-3NYscRrBAc^)&)F9~h*52D$G6r#f;4t2MRfQ@kR47z z*gDVKd*jCKy97llQbUPfdKY2PvEqhu&YeZdybH12aWw(UCXtvWIGYXP%H!UhVYuq9pBdOYn1X?B(IvqcjMwUy2tgP3V-?K zf5lz@-Ldqk$7>rv{0Jt2J|o-{2$gdo0hy*L%(ZSt9;g>p+bzeT#`Zv3K>Se(Y*lrs zC%KhlQ0*R1{qDCVH&A&jlbQxz7RQ<}#SDj2CEm6bZ&J`{2urZYy4cG*@oct8`G8+Y zAe?s0sdZ(wW^-D^ZY9#Jl6GF_V5Q`MbG(O4JGO?~D4G`(xW-C)BO{xy>yUjWG z>g8z9vZ_47$oi`jSYpM{{`_3`|4MW9_h{sm``-Q7LSr`kkwWE?w?-au_Mo?Y+Q3zf zEV_buIEGQjhnPSc+Np&$^)>-B#Q^ttZ`}Fkaroy7yLBg^soj_tLM8p2iq!8IglV|N zeiLYr#1!kr4!}jdjAVWXvZ-@D?=8ipVjO@1d?O;Ekt$SPz8)bnT5J|_BTE(fCcmms zkHH`xO&c6opQADpvZ3D`+np4I8#a~q;j)~iYl}}%V&+jG;ZK2G(4_! zl_602{7fgrs^DtFXISILFhU2m?_$v00@I8L%chTxu4z9p@XH=4rgN?6Ex=(QDVNL< z+Fi4lMDNv%=0`+Itm+f`9gdTEl#{Yl+QX&dV$yfqs>nwZ5dmj>nmdD*WrSB|pNcpY z3Z3Md9ZY_KRpEU!AJx}kykC{=VLxW?n;Ly zOSlH<`9bT+#CacFXCnGyx2~*NonWNkhgZq6T~ltB_I4A@wR}SeMSTSK0-uxg!*3aX zcPhp%dOTE_U=fZp%y?bJYikMo4x3Z&>D!5M@p?q87<|nah^WnaCXE)2+C_2nJED2O zcw0njE`UL@oJTxo9&H9?mN?KJM5yDJ2(=bUS4i6#9e1ELOjF7Wg)`Bm*8rkWB08>@sy zCPDgkX}nmcjauA$AYLvtmCxxgbFxd@IDKq@m{88AYhZ(J`7ry<#}@c`dUW=`-$~Ge z=n&OmnhiWcowZxDp1vwkuqt!WwV|rGUGtU)1OCfUMdbF7S&2YC)>GS1CTe)3Z=wvzIZ6_Hm63NIW z75pPFim_71wm;kBoZ(zmu5~b_icPDbc4#zF4lXdE>cbn&S9n%JdyD*Z&~JCMNjl|s zd&y|mqn?7?HoyN4jlH|(n2`Az%(7gh7-2czT!E|6F{kW{a4FnF?r-CQ_;$Nz+d$|@mER9zvBA%h;QZfuYA{E ziM`$mpoDOU8Y~Y}Key z1Do@2>ZzAkzi6scjg7Vfr|#Is=w$z$-bECv+v%upPlkX|+?BJ65=kydX0F_Yj0nt5 zoXY%kv2@t$Z0gHZuYH1%j34=-8I7PIusi3tyob5meZDze)hqp*-F+W4b~qPQqd>o1 zTT{BGR~;BipKVa)nYDKg-hYNZ+@e-yQD7+jnksIW9-(+>kcKW5&CT*5;ymW_u|=z+ z^F4j~(;28(r%GXEm@l{9q`M}!DSSmCl!QW~)MkG0{A75Mx-+Lev2=%!#6nxax1%hv5~>qJ8*WeWsSqH#B`G6wPT*~ z4vswbTvMyI+MCpjbzHQT1K}~3y#(L>#$lw(&496@1K8H`n9T1TJk&XWqS|>-%=HrJ z+}*pvHM3b{QlgZrJ0I=Yy1<~)T`esiXWmabQatAEN;mcCS!=CUJ@}O>J9Hw|WYnBg zMe?4)H#6H?zjMmra`D08qT|s49nYq&;&{)gE!r{6E?y-O`50a90uS3_i-%R*B@ER1 zr&0Lj%F*z-FX(Y;U;VAR#@|<&NCKd4g5KEvzA#^lEV$xyBNF@fDvaP`+(qx+ftWs2 z8vm^ZBC;0Yz6}6J^ffU3y5CvDzx_>o5SWf7zNJv|`&XQTD_Rk;ge<>TtOjpv_z>WS zMBnu@{PqV)1W!}FY_(G5LMpltS=RJ}lGBjiTJgUfd&8&h zPHhT9-icitzikF6UZ8++|3?=0ZvQr51@-fXw@O+@V>c6juR4nibRJPqC@7(DeZTWB zWAfjA5H>>f#tmz5VMhDC#RTv7*RA~ZRQ}pvzZH=EbyoheV1F6o|K@}K;d=k_D}VWw z|G)i;Je>7|Cptc(db!Qf_I!q7j?A&qK&Eo>j}fj!z3j*PLl_(F9L1MNwS{}Jzow@f zkC%g?Fk4V#j_Q!duj+ANRL$3w;1ADIDM~lI`l}XVD0SCYL~B$#oQtV>zkioc7~P&h zrO@#F^r%@i!<3y_RR&86Dq#-Q@+j1MMPnLg^5#+`>Q42j3q-CQCIh-mJh9>Bph3p| z!xM}{+~hT?U7Pe^{nPj}o)%SiGK!<&B;TnK?W)VhSQ$Wa$@tKt4|foL+C|e2dU*G&+1F0;j~U*P%z8^{Nqxj_ z^|##^BKy;Q|A(Z;ZxK;`z)#MWj(>cvXOo6ELOF}IAcas3}s#lwp#|oKok_tW%liTPEBlr<6mSa=EVIPkqN4 zuOSt{-mw||ZiYFSrcK>K?_JE_Umt}rnp6tW3H*6p)*?fpK~@h*`rxFg5U4bruh)tK zQP5`OW4&Bxihp{txd+-WTg^)_Ju(^eU+NNvEFw?m?pJpXMyW zdZ-V(tN-z~U+al8b)aX((+JR{sk}}tP&O>Tgf&uP5@?*7}bV_rIUCzYhEVc~;9k{g~t8tqPM$;SvBD=A6yR%gciyn)S&V zH^9f^QydLqG$bX;hA|i`1`~mBbMubcmQaWR=x>{i6arPkRQU`AmSA{8=?#F0SfME_qS4AZKAmWP^fMF6a$Aby#BKDJbJLxEPBjniKaD_br34F1U`$ZLz z0-GjKNvra(3?Ot@9Xg#3HmW9FPhx;fH1=RC+4gR)tR@f=F_N~m-E%n@4>vcDtzGHQ zKmZ^j5?I?D{AieM0%)zOSJ<@=&|D4*;`^?VId6Ukz}zp4i7Q}0rR$3b;2N?7=wopA znT%ehA2^YO^9X>1-hB$R<>QsK(tjbFCw{$!QXvAfgwb=kH+2%sg&|q&0j+-tkhAQc z|0E5y2EwWgn$`U4i`%VCOviRBK=%Hldvg0v!bUHGP<9sDVKY^-b%#g<@8Q1UEe-uP zz_6Ui`tVO3?6;eYFY=gPYB$d!K}7%oIKf}H)j>~1AthwFU%LZT6cbrJJ?V`vrLJG! zxJPiF1IWJjtr5r}kvh?(@Z{%%9AA5lj#D9GEEP#^4pGde4}$FjDdi%IhX5Z+%x&=o z#NlZ6Y4 zgMC5^>zr`jX$SKYi-5(uL&5dMJQzPUkgWnBX@4S)bZI!}@N>P(>+(718&Fan0ORYr z-@rK6J`mZ^p*u*Jz32{f-379>#px>^&Zz#h>qt$3lpiL;`Fbh}fBpsUh+h|M;^JMV z;7sTO>pK0zYY~%wtL|P70E#zZ>#xSSogdx(h$|B(D8QEs2FEm0n9&n1HOGX9Z`Jh< z0Flnmq)5OGm_lnt0J-(y7Me!;Z-5wCsP0`;*>QW>FlaBdJMu36Iq~7=G~u!aG+!$& z=)@_&f2%Mm90J00d^0sUy|(Nvz{t#?j~C5jt$+90+C&vBoKYQ#qX$A$kd&Dw{VPad zI!=rarBeili9&RO?dkwX_IwVEJyT(TEE_rF{xz^8kbA~k%>!_0Rsu{Zi{GIiF;})d zVnXD@?P0*5K5KtF(V}}!Y4r#U2TSsOq!rfYZ2TjV(@dqZ95(HVpYWsf6BwU%0V}%2 zXEt1By$Famtm=Jmw-x{jvyH8@ZnPQ*n(|P|`5{ruA%%XDr6(JJDBy6KRxX8JIsc(( zPmS9}5T%%?hkkohauw~RFr=&QIKHnRC* zu>HCd#gIbXM)RDbraJ(A&5itvrX65j8mDHTHlO+;MQP&%U1kzyh8Y-{&6JfQ`0UN2 zA7T2nIxZ)Nr)Olq_Ew4j#GlM*>C0BMd~a_rFhjG%mRsLAz~FSmF3Yu&=I0=p-COusJ(|cvR_Cp1>XTsFfyV({?4CT8PpVNj!FcwwZov}B z#8MsgaV7Z78TF1px6|3C{YPf5hd;={L=qgk+gJ8gK*{5+SLwORl`KB8eT{Dm@@l=$ z#*xE%lg`%rpHR6Q7K6mdKDKE6_yCj>CyBsOE`B_Rk^?r4X*&{aghb1T!WA0ipYlw+ z)KhKP)V2x&j1>ql^e3r$zFH+~+Q3(d&R9x;=~rK?{fCiAe0q1}aHU5y~?|VwmG)^4a zj9=Ijg*s8c+|y1|SMmh_*W>g|g4@BaL7bl;WLIY$Q<)Wz9^{_bjRyLE-Q0?kFW_6Xv>KK3te#~ZvkZf@snNkPaOOdZ;Zwlv9o>$DXLc`i zD}M!|dGs8>^~?yN^+EpN9G>L} zR#pZWC$$vjQ3^7p4sggKumrRGXm9sgz4Df^;*jOvdRk0XO@?QkXJFzJHkhqWtht4R zFB?0ZN(3AOu(5sfqwuTJPrpnD)0*hin)oMVbOQ~=O9=$F^$ElWEhY@_k zPoF6z2&Zd-v&7kW_2&p1Lko9|#dV_7&;BD?nMZKgRd&O!c#MvvJ920B@bub2p}Z9Z zsYgoOQ){Pqo-nR*Dm+#oQsQepo0C?8WGc@~P>_Zt3J&0B=t1oi+scE++d?U033}hp z(G|c0fonsO;AOqMJR~BFN@Cr{!sE5p+t?P#Rs)c+ID+2um>=84*Xk8xgO35MkuglX zqgW!vwsT<4$|vs+OLeMTzy)$~4mYm`iT;O^TXM)61}`@)@{izOW!LL zdzFU^oh{2AASN#WS9`^3c7+d_Eg*bA`!*1h7j%o+U^H5~Q8$S#A^=jWF^YE-5$ zVxr1=Fg7NGDP^*C-9OnC#D9yR(js=e3E}`-PN+T5>aFAVWWjZDHd_NsHr}*Xdvz#H z@yG4fhFH=*riP9w=3??L#TQyidxCIrKKNZXYZyZSbwvavbLpXMs>P<|wQVT79Exj6VTr8KM=TZx5ezDeUjg8jYQg6ry2`aQL=aA;- zgodbq8HrrsP=Dui>8oD~cfeB-7P<8ql$G?;#q*u6V6zF-Wkfm52D^+0qqqQl2Z>e= zTq}s~vj_x|YM-}JtCEHntfH7dQ79SN;~(5QpjtYk{t}>|>RJn;?2_97jx)sUo(o{3 zE&?OPrd4|bTPhh~>{CLovdahGXiH+WZm+=^7yG7^2^xA{-yjG=OWHX{>-_Lr!& zKlMEoWF>4v&ZN_v0wIW&B0qw8pjI67xczf&C%M>WxrNZ*~oOyJ%+)i1xc-C9T4b>Oj3kspk-g?MMas zJmE)69@k&Bo(Ft390%)ZN^owMO+ps!r_LdC`r(~qxRwn_=X;JoD)kkg0~+xFrpjc^ zKYUp<0pCp?iq*)1+xenfu+lVu#T9fn8{P?YZ%#D4Mw3V)N9+nb$aAdQd0!>CaDQI+ z%Z|67}If?lk&ssrzXJCit?~RK@BA=-yrY8T33jkUb%4Y$p zvt?1zV}7?TtOV*t69E``&F3TJfox<<_a;EfXFXCN2x?K1qj!Ph@-&Un2DhV1>t79U z5A(%sfR#oVQ3`OP1l=UD5Vlj!b07@ckiUFJg&%Zft5-zPSVh)4&^3Er+_+6}Cv;f( z4=6a64;2U;D;$hlU32K2By~KI8s7!f{x_q&m-K-BL}mu2E>cku`e-4?O{@)fn}