diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bef95d..5e18717 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: test: runs-on: ubuntu-latest - name: Runs plugin tests - NetBox v3.5 + name: Runs plugin tests steps: - name: Checkout diff --git a/README.md b/README.md index 7a1c47b..795b827 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # NetBox Cloud Pilot -[NetBox Cloud Pilot](https://github.com/Onemind-Services-LLC/netbox-cloud-pilot) is a specialized plugin tailored for -NetBox, offering enhanced functionality for users on the -[CloudMyDC public Cloud platform (VAP)](https://app.xapp.cloudmydc.com/). This plugin simplifies the management of +[NetBox Cloud Pilot](https://github.com/Onemind-Services-LLC/netbox-cloud-pilot) is a specialized plugin tailored for +NetBox, offering enhanced functionality for users on the +[CloudMyDC public Cloud platform (VAP)](https://app.xapp.cloudmydc.com/). This plugin simplifies the management of various NetBox components and infrastructure, streamlining processes and offering a more integrated experience. -NetBox Cloud Pilot, developed and maintained by [Onemind Services LLC](https://onemindservices.com/), introduces an -exclusive plugin store for NetBox. It caters to users with a diverse range of both free and paid plugins, enhancing the +NetBox Cloud Pilot, developed and maintained by [Onemind Services LLC](https://onemindservices.com/), introduces an +exclusive plugin store for NetBox. It caters to users with a diverse range of both free and paid plugins, enhancing the NetBox experience. ## Features @@ -35,5 +35,5 @@ For support, questions, or feedback, please file an issue on our [GitHub issue t ## Contributing -Contributions to NetBox Cloud Pilot are welcome! If you'd like to contribute, please fork the repository and submit a +Contributions to NetBox Cloud Pilot are welcome! If you'd like to contribute, please fork the repository and submit a pull request. For more detailed information, refer to our contributing guidelines in the repository. diff --git a/configuration/configuration.py b/configuration/configuration.py index c28e838..f5ae2cd 100644 --- a/configuration/configuration.py +++ b/configuration/configuration.py @@ -49,10 +49,7 @@ def _read_secret(secret_name, default=None): # Database connection SSLMODE "CONN_MAX_AGE": int(environ.get("DB_CONN_MAX_AGE", "300")), # Max database connection age - "DISABLE_SERVER_SIDE_CURSORS": environ.get( - "DB_DISABLE_SERVER_SIDE_CURSORS", "False" - ).lower() - == "true", + "DISABLE_SERVER_SIDE_CURSORS": environ.get("DB_DISABLE_SERVER_SIDE_CURSORS", "False").lower() == "true", # Disable the use of server-side cursors transaction pooling } @@ -66,10 +63,7 @@ def _read_secret(secret_name, default=None): "PASSWORD": _read_secret("redis_password", environ.get("REDIS_PASSWORD", "")), "DATABASE": int(environ.get("REDIS_DATABASE", 0)), "SSL": environ.get("REDIS_SSL", "False").lower() == "true", - "INSECURE_SKIP_TLS_VERIFY": environ.get( - "REDIS_INSECURE_SKIP_TLS_VERIFY", "False" - ).lower() - == "true", + "INSECURE_SKIP_TLS_VERIFY": environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False").lower() == "true", }, "caching": { "HOST": environ.get("REDIS_CACHE_HOST", environ.get("REDIS_HOST", "localhost")), @@ -79,8 +73,7 @@ def _read_secret(secret_name, default=None): environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")), ), "DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)), - "SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower() - == "true", + "SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower() == "true", "INSECURE_SKIP_TLS_VERIFY": environ.get( "REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY", environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"), diff --git a/netbox_cloud_pilot/__init__.py b/netbox_cloud_pilot/__init__.py index d6d2669..2f203dc 100644 --- a/netbox_cloud_pilot/__init__.py +++ b/netbox_cloud_pilot/__init__.py @@ -7,8 +7,8 @@ class NetBoxCloudPilot(PluginConfig): name = metadata.get("Name").replace("-", "_") - verbose_name = metadata.get("Summary") - description = metadata.get("Long-Description") + verbose_name = metadata.get("Name") + description = metadata.get("Summary") version = metadata.get("Version") author = metadata.get("Author") author_email = metadata.get("Author-email") diff --git a/netbox_cloud_pilot/constants.py b/netbox_cloud_pilot/constants.py index 2a6b2eb..3cf6e8a 100644 --- a/netbox_cloud_pilot/constants.py +++ b/netbox_cloud_pilot/constants.py @@ -5,9 +5,7 @@ from .nb_settings import * JELASTIC_API = "https://app.xapp.cloudmydc.com" -NETBOX_JPS_REPO = ( - "https://raw.githubusercontent.com/Onemind-Services-LLC/netbox-jps/master" -) +NETBOX_JPS_REPO = "https://raw.githubusercontent.com/Onemind-Services-LLC/netbox-jps/master" NODE_GROUP_CP = "cp" NODE_GROUP_SQLDB = "sqldb" @@ -174,7 +172,7 @@ help_text="Base path for URL patterns", placeholder="/", required=False, - initial="/", + initial=None, ), Param( key="EMAIL_FROM", @@ -296,9 +294,7 @@ placeholder="{'filter_name': 'path.to.filter'}", required=False, field=forms.JSONField, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, initial={}, ), Param( @@ -336,9 +332,7 @@ placeholder="{'location': '/var/netbox/media'}", required=False, field=forms.JSONField, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, initial={}, ), ], @@ -372,9 +366,7 @@ placeholder="[]", required=False, field=forms.JSONField, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, initial=[ [ { @@ -500,9 +492,7 @@ help_text="List of custom data validators", placeholder="[]", required=False, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), Param( key="FIELD_CHOICES", @@ -510,9 +500,7 @@ help_text="List of custom field choices", placeholder="[]", required=False, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), ], ), @@ -525,9 +513,7 @@ help_text="Default dashboard for users", required=False, field=forms.JSONField, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, initial=DEFAULT_DASHBOARD, ), Param( @@ -536,9 +522,7 @@ help_text="Default user preferences", required=False, field=forms.JSONField, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, initial={}, ), Param( @@ -654,9 +638,7 @@ help_text="List of NetBox administrators", placeholder="[('NetBox Admin', 'admin@example.com')]", required=False, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), Param( key="BANNER_BOTTOM", @@ -664,9 +646,7 @@ help_text="Bottom banner text", placeholder="Banner text", required=False, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), Param( key="BANNER_LOGIN", @@ -674,9 +654,7 @@ help_text="Login banner text", placeholder="Banner text", required=False, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), Param( key="BANNER_TOP", @@ -684,9 +662,7 @@ help_text="Top banner text", placeholder="Banner text", required=False, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), Param( key="BANNER_MAINTENANCE", @@ -694,9 +670,7 @@ help_text="Maintenance banner text", placeholder="Banner text", required=False, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), Param( key="CENSUS_REPORTING_ENABLED", @@ -822,9 +796,7 @@ placeholder="{'queue_name': 'queue_name'}", required=False, field=forms.JSONField, - field_kwargs={ - "widget": forms.Textarea(attrs={"class": "vLargeTextField"}) - }, + field_kwargs={"widget": forms.Textarea(attrs={"class": "vLargeTextField"})}, ), Param( key="RELEASE_CHECK_URL", diff --git a/netbox_cloud_pilot/forms.py b/netbox_cloud_pilot/forms.py index 0d14773..2ee2ca2 100644 --- a/netbox_cloud_pilot/forms.py +++ b/netbox_cloud_pilot/forms.py @@ -4,7 +4,7 @@ from django.forms import ValidationError from netbox.forms import NetBoxModelForm -from utilities.forms import BootstrapMixin +from utilities.forms import BootstrapMixin, ConfirmationForm as _ConfirmationForm from utilities.forms.fields import CommentField from .constants import NETBOX_SETTINGS, NODE_GROUP_SQLDB from .models import * @@ -98,8 +98,7 @@ def __init__(self, *args, **kwargs): f"{env_info['env']['displayName']} ({env_info['env']['envName']})", ) for env_info in env_infos - if env_info.get("env", {}).get("properties", {}).get("projectScope", "") - == "backup" + if env_info.get("env", {}).get("properties", {}).get("projectScope", "") == "backup" ] @@ -107,11 +106,7 @@ class NetBoxSettingsForm(BootstrapMixin, forms.Form): fieldsets = create_fieldset() class Meta: - fields = [ - param.key.lower() - for section in NETBOX_SETTINGS.sections - for param in section.params - ] + fields = [param.key.lower() for section in NETBOX_SETTINGS.sections for param in section.params] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -200,11 +195,7 @@ def __init__(self, *args, **kwargs): # Fetch the region list and build the choices nc = NetBoxConfiguration.objects.first() - regions = ( - nc.iaas(nc.env_name, auto_init=False) - .client.environment.Control.GetRegions() - .get("array", []) - ) + regions = nc.iaas(nc.env_name, auto_init=False).client.environment.Control.GetRegions().get("array", []) self.fields["region"].choices = [ (hard_node_group["uniqueName"], region["displayName"]) for region in regions @@ -216,17 +207,11 @@ def clean(self): if cleaned_data["deployment"] == "cluster": if cleaned_data["node_count"] == "1": - raise ValidationError( - { - "node_count": "Node count must be greater than 1 for a cluster deployment." - } - ) + raise ValidationError({"node_count": "Node count must be greater than 1 for a cluster deployment."}) if cleaned_data["deployment"] == "standalone": if cleaned_data["node_count"] != "1": - raise ValidationError( - {"node_count": "Node count must be 1 for a standalone deployment."} - ) + raise ValidationError({"node_count": "Node count must be 1 for a standalone deployment."}) return cleaned_data @@ -254,9 +239,7 @@ def __init__(self, *args, **kwargs): if self.instance.pk: # Fetch the database password from the addon settings - app = self.instance.netbox_env.get_env().get_installed_addon( - "db-backup", node_group=NODE_GROUP_SQLDB - ) + app = self.instance.netbox_env.get_env().get_installed_addon("db-backup", node_group=NODE_GROUP_SQLDB) data = app.get("settings", {}).get("main", {}).get("data", {}) self.fields["db_password"].initial = data.get("dbpass") @@ -267,9 +250,7 @@ def clean(self): self.instance._db_password = db_password if not self.instance.pk and not db_password: - raise ValidationError( - {"db_password": "This field is required when adding a new backup."} - ) + raise ValidationError({"db_password": "This field is required when adding a new backup."}) class NetBoxPluginInstallForm(BootstrapMixin, forms.Form): @@ -308,9 +289,7 @@ def __init__(self, *args, **kwargs): # Get the plugins.yaml plugins = get_plugins_list() if plugin := plugins.get(initial.get("name")): - self.fields["version"].choices = [ - (release, release) for release in filter_releases(plugin) - ] + self.fields["version"].choices = [(release, release) for release in filter_releases(plugin)] if initial.get("type") == "update": from importlib.metadata import metadata @@ -322,14 +301,13 @@ def __init__(self, *args, **kwargs): def clean(self): plugins = get_plugins_list() plugin = plugins.get(self.cleaned_data.get("name")) + selected_version = self.cleaned_data.get("version") # If the plugin is private, ensure that license is provided nc = NetBoxConfiguration.objects.first() if plugin.get("private"): if not nc.license: - raise ValidationError( - {"name": "This plugin requires a NetBox Enterprise license."} - ) + raise ValidationError({"name": "This plugin requires a NetBox Enterprise license."}) # Check if the plugin is accessible using the license response = requests.get( @@ -337,23 +315,22 @@ def clean(self): headers={"Authorization": f"Bearer {nc.license}"}, ) if not response.ok: - raise ValidationError( - { - "name": "This plugin is not accessible using the provided license." - } - ) + raise ValidationError({"name": "This plugin is not accessible using the provided license."}) # Get the required_settings from the plugin - required_settings = plugin.get("required_settings", []) + required_settings = next( + ( + release.get("netbox", {}).get("required_settings", []) + for release in plugin.get("releases", []) + if release.get("tag") == selected_version + ), + [], + ) configuration = self.cleaned_data.get("configuration") # Check if the required_settings are in the configuration if not all(key in configuration.keys() for key in required_settings): - raise ValidationError( - { - "configuration": f"Missing required settings: {', '.join(required_settings)}" - } - ) + raise ValidationError({"configuration": f"Missing required settings: {', '.join(required_settings)}"}) class NetBoxUpgradeForm(BootstrapMixin, forms.Form): @@ -371,7 +348,24 @@ def __init__(self, *args, **kwargs): instance = NetBoxConfiguration.objects.first() env = instance.get_env() - versions = env.get_patch_upgrades() - self.fields["version"].choices = [ - (str(version), str(version)) for version in versions - ] + versions = env.get_upgrades() + self.fields["version"].choices = [(str(version), str(version)) for version in versions] + + def clean(self): + super().clean() + + instance = NetBoxConfiguration.objects.first() + env = instance.get_env() + + # Run upgrade checks + upgrade_check, error = env.upgrade_checks(self.cleaned_data.get("version")) + if not upgrade_check: + raise ValidationError({"version": error}) + + +class ConfirmationForm(_ConfirmationForm): + """ + A generic confirmation form. The form is not valid unless the `confirm` field is checked. + """ + + name = forms.CharField(widget=forms.HiddenInput()) diff --git a/netbox_cloud_pilot/iaas.py b/netbox_cloud_pilot/iaas.py index 0c2eae5..8e6d5b2 100644 --- a/netbox_cloud_pilot/iaas.py +++ b/netbox_cloud_pilot/iaas.py @@ -1,6 +1,7 @@ import logging import time from functools import lru_cache +from typing import Tuple import requests import yaml @@ -12,6 +13,7 @@ from core.choices import JobStatusChoices from core.models import Job from netbox.config import get_config +from . import utils from .constants import JELASTIC_API, NODE_GROUP_CP, NODE_GROUP_SQLDB logger = logging.getLogger("netbox_cloud_pilot") @@ -116,9 +118,7 @@ def get_nodes(self, node_group: str = None, is_master: bool = True) -> dict | li """ Get the environment nodes. """ - logger.debug( - f"Getting nodes for node group {node_group}, is_master={is_master}" - ) + logger.debug(f"Getting nodes for node group {node_group}, is_master={is_master}") nodes = self._get_env_info().get("nodes", []) if node_group: nodes = [node for node in nodes if node["nodeGroup"] == node_group] @@ -137,9 +137,7 @@ def get_node_groups(self): # For each node group, get the related nodes for node_group in node_groups: - node_group["node"] = self.get_nodes( - node_group=node_group["name"], is_master=True - ) + node_group["node"] = self.get_nodes(node_group=node_group["name"], is_master=True) return node_groups @@ -162,9 +160,7 @@ def is_ssl_enabled(self): """ Check if SSL is enabled. """ - logger.debug( - f"Checking if built-in SSL is enabled for environment {self.env_name}" - ) + logger.debug(f"Checking if built-in SSL is enabled for environment {self.env_name}") return self.get_env().get("sslstate", False) def get_node_log(self, node_id, path="/var/log/run.log"): @@ -172,9 +168,9 @@ def get_node_log(self, node_id, path="/var/log/run.log"): Get the logs of a node. """ logger.debug(f"Getting log for node {node_id} for environment {self.env_name}") - return self.client.environment.Control.ReadLog( - env_name=self.env_name, node_id=node_id, path=path - ).get("body", "") + return self.client.environment.Control.ReadLog(env_name=self.env_name, node_id=node_id, path=path).get( + "body", "" + ) def get_url(self): """ @@ -201,17 +197,11 @@ def get_installed_addon(self, app_id, node_group, search=None): """ Get the installed addon for a node group. """ - logger.debug( - f"Checking if addon ({app_id}) is installed for node group {node_group}" - ) + logger.debug(f"Checking if addon ({app_id}) is installed for node group {node_group}") addons = self.get_addons(node_group=node_group, search=search) return next( - ( - addon - for addon in addons - if addon["app_id"] == app_id and addon.get("isInstalled", False) - ), + (addon for addon in addons if addon["app_id"] == app_id and addon.get("isInstalled", False)), None, ) @@ -257,16 +247,12 @@ def run_script(self, name, code, description=None, params=None): for script in scripts: if script.get("name") == name: logger.debug(f"Deleting existing script {name}") - self.client.development.Scripting.DeleteScript( - app_id=app_id, name=name - ) + self.client.development.Scripting.DeleteScript(app_id=app_id, name=name) continue except JelasticApiError as e: logger.error(e) - self.client.development.Scripting.CreateScript( - app_id=app_id, name=name, type="js", code=code - ) + self.client.development.Scripting.CreateScript(app_id=app_id, name=name, type="js", code=code) return self.client.utils.Scheduler.CreateEnvTask( env_name=self.env_name, @@ -276,9 +262,7 @@ def run_script(self, name, code, description=None, params=None): params=params, ) - def restart_nodes( - self, node_groups: list[str], lazy: bool = False, delay: int = 10000 - ): + def restart_nodes(self, node_groups: list[str], lazy: bool = False, delay: int = 10000): """ Restart nodes for a list of node groups. """ @@ -367,9 +351,7 @@ def uninstall_addon(self, app_id, node_group, search=None): Uninstall an addon. """ # Check if the addon is already installed - if addon := self.get_installed_addon( - app_id=app_id, node_group=node_group, search=search - ): + if addon := self.get_installed_addon(app_id=app_id, node_group=node_group, search=search): logger.info(f"Uninstalling addon {app_id}") return self.client.marketplace.Installation.Uninstall( @@ -401,9 +383,7 @@ def get_nb_node_groups(self): # For each node group, get the related nodes for node_group in node_groups: - if "netbox" in node_group.get("node", {}).get("customitem", {}).get( - "dockerName" - ): + if "netbox" in node_group.get("node", {}).get("customitem", {}).get("dockerName"): results.append(node_group) return results @@ -413,9 +393,7 @@ def load_plugins(self): Loads the plugins from the plugins.yaml file. """ master_node_id = self.get_master_node(NODE_GROUP_CP).get("id") - plugins_yaml = self.execute_cmd( - master_node_id, "cat /etc/netbox/config/plugins.yaml" - )[0].get("out", "") + plugins_yaml = self.execute_cmd(master_node_id, "cat /etc/netbox/config/plugins.yaml")[0].get("out", "") return yaml.safe_load(plugins_yaml) or {} def dump_plugins(self, plugins): @@ -433,22 +411,19 @@ def dump_plugins(self, plugins): is_append_mode=False, ) - def install_plugin( - self, plugin: dict, version, plugin_settings=None, github_token=None - ): + def install_plugin(self, plugin: dict, version, plugin_settings=None, github_token=None): master_node_id = self.get_master_node(NODE_GROUP_CP).get("id") activate_env = "source /opt/netbox/venv/bin/activate" # Install the plugin version if plugin.get("private"): + # Ensure `git` is installed + self.execute_cmd(master_node_id, "apt-get install -y git") + github_url = plugin.get("github_url") - github_url = github_url.replace( - "https://github.com", f"git+https://{github_token}@github.com" - ) + github_url = github_url.replace("https://github.com", f"git+https://{github_token}@github.com") - self.execute_cmd( - master_node_id, f"{activate_env} && pip install {github_url}@{version}" - ) + self.execute_cmd(master_node_id, f"{activate_env} && pip install {github_url}@{version}") else: self.execute_cmd( master_node_id, @@ -459,17 +434,14 @@ def install_plugin( plugins[plugin.get("app_label")] = plugin_settings or {} self.dump_plugins(plugins) - # TODO: Uncomment this after a fix is suggested by Virtuozzo, slack thread: https://omsmsp.slack.com/archives/C05QT7WD71U/p1704140085788849 # Run collectstatic command - # self.execute_cmd( - # node_id=master_node_id, - # command=f"{activate_env} && /opt/netbox/netbox/manage.py collectstatic --no-input", - # ) + self.execute_cmd( + node_id=master_node_id, + command=f"{activate_env} && /opt/netbox/netbox/manage.py collectstatic --no-input --clear 1>/dev/null", + ) return self.restart_nodes( - node_groups=[ - node_group["name"] for node_group in self.get_nb_node_groups() - ], + node_groups=[node_group["name"] for node_group in self.get_nb_node_groups()], lazy=True, ) @@ -482,9 +454,7 @@ def uninstall_plugin(self, plugin: dict): self.dump_plugins(plugins) return self.restart_nodes( - node_groups=[ - node_group["name"] for node_group in self.get_nb_node_groups() - ], + node_groups=[node_group["name"] for node_group in self.get_nb_node_groups()], lazy=True, ) @@ -493,10 +463,7 @@ def get_env_var(self, variable, default=None): Get the environment variable for NetBox. """ container_vars = self._get_env_var(NODE_GROUP_CP) - return ( - getattr(get_config(), variable, container_vars.get(variable, None)) - or default - ) + return getattr(get_config(), variable, container_vars.get(variable, None)) or default def _get_docker_tags(self): """ @@ -505,9 +472,7 @@ def _get_docker_tags(self): master_node = self.get_master_node(NODE_GROUP_CP) docker = master_node.get("customitem", {}) - response = requests.get( - f'https://hub.docker.com/v2/repositories/{docker["dockerName"]}/tags?page_size=1000' - ) + response = requests.get(f'https://hub.docker.com/v2/repositories/{docker["dockerName"]}/tags?page_size=1000') response.raise_for_status() response = response.json() @@ -532,68 +497,15 @@ def _get_upgrades(self): return [tag for tag in docker_tags if tag > current_version] - def get_patch_upgrades(self): - """ - Get the available patch upgrades for NetBox. - """ - all_upgrades = self._get_upgrades() - current_version = Version.parse(settings.VERSION) - - # Filter out only the patch upgrades - patch_upgrades = [] - for upgrade in all_upgrades: - if ( - upgrade.major == current_version.major - and upgrade.minor == current_version.minor - ): - patch_upgrades.append(upgrade) - - return patch_upgrades - - def get_minor_upgrades(self): - """ - Get the available minor upgrades for NetBox. - """ - all_upgrades = self._get_upgrades() - current_version = Version.parse(settings.VERSION) - - # Filter out only the minor upgrades - minor_upgrades = [] - for upgrade in all_upgrades: - if ( - upgrade.major == current_version.major - and upgrade.minor > current_version.minor - ): - minor_upgrades.append(upgrade) - - return minor_upgrades - - def get_major_upgrades(self): - """ - Get the available major upgrades for NetBox. - """ - all_upgrades = self._get_upgrades() - current_version = Version.parse(settings.VERSION) - - # Filter out only the major upgrades - major_upgrades = [] - for upgrade in all_upgrades: - if upgrade.major > current_version.major: - major_upgrades.append(upgrade) - - return major_upgrades - def is_upgrade_available(self): """ Check if an upgrade is available for NetBox. """ - return bool(self._get_upgrades()) + return bool(self.get_upgrades()) def is_db_backup_running(self, app_unique_name): # Get current running actions - current_actions = self.client.environment.Tracking.GetCurrentActions().get( - "array", [] - ) + current_actions = self.client.environment.Tracking.GetCurrentActions().get("array", []) for action in current_actions: action_parameters = action.get("parameters", {}) @@ -615,19 +527,31 @@ def db_backup(self, app_unique_name): return self.execute_action(app_unique_name=app_unique_name, action="backup") + def upgrade_checks(self, version) -> Tuple[bool, str]: + """ + Run upgrade checks for NetBox. + """ + # Fetch the plugins from the store + plugins = utils.get_plugins_list() + for plugin_name, plugin in plugins.items(): + if plugin_name in settings.PLUGINS: + if not utils.filter_releases(plugin, version): + return ( + False, + f"Plugin {plugin_name} does not have a release for version {version}", + ) + + return True, "" + def upgrade(self, version): """ Upgrade NetBox. """ version = f"v{version}" - if addon := self.get_installed_addon( - app_id="db-backup", node_group=NODE_GROUP_SQLDB - ): + if addon := self.get_installed_addon(app_id="db-backup", node_group=NODE_GROUP_SQLDB): self.db_backup(app_unique_name=addon.get("uniqueName")) - # TODO: Run plugin compatibility checks - # Fetch all node groups for node_group in self.get_nb_node_groups(): node_group_name = node_group["name"] diff --git a/netbox_cloud_pilot/models.py b/netbox_cloud_pilot/models.py index 2918e0f..631a13a 100644 --- a/netbox_cloud_pilot/models.py +++ b/netbox_cloud_pilot/models.py @@ -35,9 +35,7 @@ class NetBoxConfiguration(JobsMixin, PrimaryModel): NetBoxConfig is a model that represents the configuration of NetBox. """ - key = models.CharField( - max_length=255, unique=True, validators=[MinLengthValidator(40)] - ) + key = models.CharField(max_length=255, unique=True, validators=[MinLengthValidator(40)]) env_name = models.CharField( max_length=255, @@ -112,12 +110,7 @@ def get_env(self): return self.iaas(self.env_name) def get_docker_tag(self): - return ( - self.get_env() - .get_master_node(NODE_GROUP_CP) - .get("customitem", {}) - .get("dockerTag", "") - ) + return self.get_env().get_master_node(NODE_GROUP_CP).get("customitem", {}).get("dockerTag", "") def get_env_storage(self): return self.iaas(self.env_name_storage) @@ -151,9 +144,7 @@ def netbox_settings(self): initial = env.get_env_var(param.key, param.initial) # Alter list of strings to comma-separated string - if isinstance(initial, (tuple, list)) and all( - [isinstance(x, str) for x in initial] - ): + if isinstance(initial, (tuple, list)) and all([isinstance(x, str) for x in initial]): initial = ", ".join(initial) param.initial = initial @@ -168,11 +159,7 @@ def apply_settings(self, data: dict): """ logger.debug(f"Applying NetBox settings: {data}") - all_keys = [ - param.key - for section in NETBOX_SETTINGS.sections - for param in section.params - ] + all_keys = [param.key for section in NETBOX_SETTINGS.sections for param in section.params] env = self.get_env() # Get all NetBox node groups @@ -329,20 +316,14 @@ def clean(self): # Ensure netbox_env has a storage env connected before proceeding if not self.netbox_env.env_name_storage: raise ValidationError( - { - "netbox_env": "Add a backup storage environment to the NetBoxConfiguration instance." - } + {"netbox_env": "Add a backup storage environment to the NetBoxConfiguration instance."} ) if self.keep_backups > 30: - raise ValidationError( - {"keep_backups": "The maximum number of backups to keep is 30."} - ) + raise ValidationError({"keep_backups": "The maximum number of backups to keep is 30."}) if self.keep_backups < 1: - raise ValidationError( - {"keep_backups": "The minimum number of backups to keep is 1."} - ) + raise ValidationError({"keep_backups": "The minimum number of backups to keep is 1."}) try: croniter(self.crontab) @@ -367,11 +348,7 @@ def list_backups(self): node_id=master_node.get("id"), command="/root/getBackupsAllEnvs.sh" )[0] - if ( - backups := json.loads(result.get("out", "")) - .get("backups", {}) - .get(self.netbox_env.env_name, []) - ): + if backups := json.loads(result.get("out", "")).get("backups", {}).get(self.netbox_env.env_name, []): # Cache backups in case next time it returns empty cache.set(f"netbox_db_backups_{self.pk}", backups, timeout=60 * 60) else: @@ -420,9 +397,7 @@ def backup(self, request=None): Create a new database backup. """ # Get installed addon - addon = self.netbox_env.get_env().get_installed_addon( - app_id="db-backup", node_group=NODE_GROUP_SQLDB - ) + addon = self.netbox_env.get_env().get_installed_addon(app_id="db-backup", node_group=NODE_GROUP_SQLDB) return self.netbox_env.enqueue( self.netbox_env.get_env().db_backup, @@ -435,9 +410,7 @@ def restore(self, request, backup_name): Restore a database backup. """ # Get installed addon - addon = self.netbox_env.get_env().get_installed_addon( - app_id="db-backup", node_group=NODE_GROUP_SQLDB - ) + addon = self.netbox_env.get_env().get_installed_addon(app_id="db-backup", node_group=NODE_GROUP_SQLDB) return self.netbox_env.enqueue( self.netbox_env.get_env().execute_action, diff --git a/netbox_cloud_pilot/nb_settings.py b/netbox_cloud_pilot/nb_settings.py index 0edfcf1..388ee73 100644 --- a/netbox_cloud_pilot/nb_settings.py +++ b/netbox_cloud_pilot/nb_settings.py @@ -63,9 +63,7 @@ def __init__( if widget and hasattr(widget, "attrs"): # Update the class attribute, retaining existing classes existing_classes = widget.attrs.get("class", "") - widget.attrs["class"] = " ".join( - filter(None, [existing_classes, "form-control"]) - ) + widget.attrs["class"] = " ".join(filter(None, [existing_classes, "form-control"])) else: # If widget is not provided or doesn't have attrs, use the default field_kwargs.update(default_kwargs) diff --git a/netbox_cloud_pilot/templates/netbox_cloud_pilot/inc/plugin_store.html b/netbox_cloud_pilot/templates/netbox_cloud_pilot/inc/plugin_store.html new file mode 100644 index 0000000..6b23a13 --- /dev/null +++ b/netbox_cloud_pilot/templates/netbox_cloud_pilot/inc/plugin_store.html @@ -0,0 +1,47 @@ +
+
+
+ + {% if plugin.private %}Subscription{% else %}Community{% endif %} + + · + {{ plugin.stars }} + + {% if plugin.installed %} + v{{ plugin.current_version }} + {% endif %} +
+
+

{{ plugin_name }}

+

{{ plugin.description }}

+ {% if plugin.github_url and not plugin.private %} + + View on GitHub + + {% endif %} + {% if plugin.pypi_url %} + + View on PyPI + + {% endif %} +
+ +
+
\ No newline at end of file diff --git a/netbox_cloud_pilot/templates/netbox_cloud_pilot/plugins_store.html b/netbox_cloud_pilot/templates/netbox_cloud_pilot/plugins_store.html index 3531033..3a9a37c 100644 --- a/netbox_cloud_pilot/templates/netbox_cloud_pilot/plugins_store.html +++ b/netbox_cloud_pilot/templates/netbox_cloud_pilot/plugins_store.html @@ -12,51 +12,24 @@
{% block content %}
- {% for plugin_name, plugin in plugins.items %} -
-
-
- - {% if plugin.private %}Paid{% else %}Free{% endif %} - - · - {{ plugin.stars }} - - {% if plugin.installed %} - v{{ plugin.current_version }} - {% endif %} -
-
-

{{ plugin_name }}

-

{{ plugin.description }}

- {% if plugin.github_url %} - - View on GitHub - - {% endif %} - {% if plugin.pypi_url %} - - View on PyPI - - {% endif %} -
- -
-
+

Installed Plugins

+
+ {% for plugin_name, plugin in plugins.installed.items %} + {% include 'netbox_cloud_pilot/inc/plugin_store.html' with plugin_name=plugin_name plugin=plugin %} + {% endfor %} +
+
+

Subscription Store

+
+ {% for plugin_name, plugin in plugins.not_installed.subscription.items %} + {% include 'netbox_cloud_pilot/inc/plugin_store.html' with plugin_name=plugin_name plugin=plugin %} + {% endfor %} +
+
+

Community Store

+
+ {% for plugin_name, plugin in plugins.not_installed.community.items %} + {% include 'netbox_cloud_pilot/inc/plugin_store.html' with plugin_name=plugin_name plugin=plugin %} {% endfor %}
{% endblock %} diff --git a/netbox_cloud_pilot/utils.py b/netbox_cloud_pilot/utils.py index ec5aab9..7131b25 100644 --- a/netbox_cloud_pilot/utils.py +++ b/netbox_cloud_pilot/utils.py @@ -33,18 +33,19 @@ def is_compatible(netbox_version, min_version, max_version): return True -def filter_releases(plugin): +def filter_releases(plugin, version: str = None): """ Filter the releases based on the NetBox version. """ compatible_releases = [] + version = version or settings.VERSION for release in plugin.get("releases", []): netbox = release.get("netbox") min_version = netbox.get("min") max_version = netbox.get("max") - if is_compatible(settings.VERSION, min_version, max_version): + if is_compatible(version, min_version, max_version): compatible_releases.append(release["tag"]) try: @@ -54,6 +55,4 @@ def filter_releases(plugin): def job_msg(job): - return mark_safe( - f"Job {job} has been created successfully." - ) + return mark_safe(f"Job {job} has been created successfully.") diff --git a/netbox_cloud_pilot/views.py b/netbox_cloud_pilot/views.py index 2ad4900..2c3e0ba 100644 --- a/netbox_cloud_pilot/views.py +++ b/netbox_cloud_pilot/views.py @@ -132,14 +132,10 @@ def post(self, request, pk): node_groups=[node_group], ) messages.success(request, utils.job_msg(job)) - return redirect( - "plugins:netbox_cloud_pilot:netboxconfiguration", pk=instance.pk - ) + return redirect("plugins:netbox_cloud_pilot:netboxconfiguration", pk=instance.pk) -@register_model_view( - models.NetBoxConfiguration, "backup_storage", path="backup-storage" -) +@register_model_view(models.NetBoxConfiguration, "backup_storage", path="backup-storage") class NetBoxStorageView(PermissionRequiredMixin, GetReturnURLMixin, View): def get_permission_required(self): return ["netbox_cloud_pilot.view_netboxconfiguration"] @@ -272,9 +268,7 @@ class NetBoxPluginListView(View): def get(self, request): if nc := models.NetBoxConfiguration.objects.first(): installed_plugins = settings.PLUGINS - installed_plugins = [ - metadata(plugin).get("Name") for plugin in installed_plugins - ] + installed_plugins = [metadata(plugin).get("Name") for plugin in installed_plugins] plugins = utils.get_plugins_list() for plugin_name, _ in plugins.items(): @@ -286,6 +280,30 @@ def get(self, request): } ) + # Divide the plugins into two lists: installed and not installed + plugins = { + "installed": { + plugin_name: plugin for plugin_name, plugin in plugins.items() if plugin.get("installed") + }, + "not_installed": { + plugin_name: plugin for plugin_name, plugin in plugins.items() if not plugin.get("installed") + }, + } + + # Divide not installed plugins into two lists: subscription and community + plugins["not_installed"] = { + "subscription": { + plugin_name: plugin + for plugin_name, plugin in plugins["not_installed"].items() + if plugin.get("private") + }, + "community": { + plugin_name: plugin + for plugin_name, plugin in plugins["not_installed"].items() + if not plugin.get("private") + }, + } + return render( request, "netbox_cloud_pilot/plugins_store.html", @@ -334,14 +352,12 @@ def post(self, request, pk, *args, **kwargs): "generic/object_edit.html", { "object": obj, - "form": self.form(instance=obj), + "form": form, }, ) -@register_model_view( - models.NetBoxConfiguration, "plugin_install", path="plugin-install" -) +@register_model_view(models.NetBoxConfiguration, "plugin_install", path="plugin-install") class NetBoxPluginInstallView(generic.ObjectEditView): queryset = models.NetBoxConfiguration.objects.all() form = forms.NetBoxPluginInstallForm @@ -402,9 +418,7 @@ def post(self, request, *args, **kwargs): ) -@register_model_view( - models.NetBoxConfiguration, "plugin_uninstall", path="plugin-uninstall" -) +@register_model_view(models.NetBoxConfiguration, "plugin_uninstall", path="plugin-uninstall") class NetBoxPluginUninstallView(generic.ObjectDeleteView): queryset = models.NetBoxConfiguration.objects.all() template_name = "netbox_cloud_pilot/plugin_uninstall.html" @@ -417,14 +431,13 @@ def get(self, request, *args, **kwargs): messages.error(request, "Plugin not found.") return redirect("plugins:netbox_cloud_pilot:netboxplugin_list") - form = ConfirmationForm(initial=request.GET) + form = forms.ConfirmationForm(initial=request.GET) return render( request, self.template_name, { "object": obj, - "plugin": plugin, "form": form, "return_url": self.get_return_url(request, obj), **self.get_extra_context(request, obj), @@ -433,11 +446,10 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): obj = self.get_object(**kwargs) - form = ConfirmationForm(request.POST) - - plugin = utils.get_plugins_list().get(request.POST.get("name")) + form = forms.ConfirmationForm(request.POST) if form.is_valid(): + plugin = utils.get_plugins_list().get(form.cleaned_data["name"]) job = obj.enqueue(obj.get_env().uninstall_plugin, request, plugin=plugin) messages.success(request, utils.job_msg(job)) @@ -448,7 +460,6 @@ def post(self, request, *args, **kwargs): self.template_name, { "object": obj, - "plugin": plugin, "form": form, "return_url": self.get_return_url(request, obj), **self.get_extra_context(request, obj), diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..653edae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +# See PEP 518 for the spec of this file +# https://www.python.org/dev/peps/pep-0518/ + +[tool.black] +line-length = 120 +target_version = ['py38', 'py39', 'py310'] +skip-string-normalization = true + +[tool.isort] +profile = "black" + +[tool.pylint] +max-line-length = 120 + +[tool.pyright] +include = ["netbox_cloud_pilot"] +exclude = [ + "**/node_modules", + "**/__pycache__", +] +reportMissingImports = true +reportMissingTypeStubs = false diff --git a/setup.py b/setup.py index 935c089..b3c4dce 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,16 @@ +import os + from setuptools import find_packages, setup -description = "Enhances NetBox on CloudMyDC's VAP with advanced management and control features." +readme = os.path.join(os.path.dirname(__file__), 'README.md') -with open("README.md", "r") as fh: +with open(readme) as fh: long_description = fh.read() setup( name="netbox-cloud-pilot", - version="0.0.1", - description=description, + version="0.0.2", + description="Enhances NetBox on CloudMyDC's VAP with advanced management and control features.", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/Onemind-Services-LLC/netbox-cloud-pilot/",