From fb25bd53d65f609ff38bbceb66337e27165fd00e Mon Sep 17 00:00:00 2001 From: Charles Flanders Date: Wed, 13 May 2026 16:24:25 -0600 Subject: [PATCH] Initial Cisco EoX API implementation --- README.md | 52 +++- netbox_lifecycle/__init__.py | 8 + netbox_lifecycle/forms/cisco_eox.py | 68 ++++++ netbox_lifecycle/jobs.py | 183 ++++++++++++++ .../migrations/0019_cisco_eox_settings.py | 53 ++++ netbox_lifecycle/models/__init__.py | 1 + netbox_lifecycle/models/cisco_eox.py | 120 +++++++++ netbox_lifecycle/navigation.py | 7 + .../netbox_lifecycle/cisco_eox_settings.html | 141 +++++++++++ .../cisco_eox_settings_edit.html | 84 +++++++ netbox_lifecycle/urls.py | 16 ++ netbox_lifecycle/utilities/cisco_eox_api.py | 231 ++++++++++++++++++ netbox_lifecycle/utilities/settings_loader.py | 89 +++++++ netbox_lifecycle/views/__init__.py | 1 + netbox_lifecycle/views/cisco_eox.py | 122 +++++++++ pyproject.toml | 2 + 16 files changed, 1177 insertions(+), 1 deletion(-) create mode 100644 netbox_lifecycle/forms/cisco_eox.py create mode 100644 netbox_lifecycle/jobs.py create mode 100644 netbox_lifecycle/migrations/0019_cisco_eox_settings.py create mode 100644 netbox_lifecycle/models/cisco_eox.py create mode 100644 netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings.html create mode 100644 netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings_edit.html create mode 100644 netbox_lifecycle/utilities/cisco_eox_api.py create mode 100644 netbox_lifecycle/utilities/settings_loader.py create mode 100644 netbox_lifecycle/views/cisco_eox.py diff --git a/README.md b/README.md index d93255c..b44ebb6 100644 --- a/README.md +++ b/README.md @@ -38,18 +38,68 @@ The plugin can be configured via `PLUGINS_CONFIG` in your NetBox configuration f ```python PLUGINS_CONFIG = { 'netbox_lifecycle': { + # UI card positions 'lifecycle_card_position': 'right_page', 'contract_card_position': 'right_page', + 'license_card_position': 'right_page', + + # Cisco EoX API sync (optional — can also be configured via the Web UI) + 'cisco_eox_enabled': False, + 'cisco_eox_client_id': '', + 'cisco_eox_client_secret': '', + 'cisco_eox_sync_interval': 10080, # minutes (default: weekly) + 'cisco_eox_manufacturer_names': 'Cisco', }, } ``` +> **Note:** Cisco EoX credentials configured here are read as plain text from your configuration file. +> For production deployments it is recommended to configure them instead through the Web UI +> (`/lifecycle/cisco-eox/settings/`), where the client secret is stored Fernet-encrypted at rest. + ### Available Settings +#### UI Card Positions + | Setting | Default | Description | |---------|---------|-------------| -| `lifecycle_card_position` | `right_page` | Position of the Hardware Lifecycle Info card on Device, Module, DeviceType, and ModuleType detail pages. Options: `left_page`, `right_page`, `full_width_page`. | +| `lifecycle_card_position` | `right_page` | Position of the Hardware Lifecycle Info card on DeviceType and ModuleType detail pages. Options: `left_page`, `right_page`, `full_width_page`. | | `contract_card_position` | `right_page` | Position of the Support Contracts card on Device and VirtualMachine detail pages. Options: `left_page`, `right_page`, `full_width_page`. | +| `license_card_position` | `right_page` | Position of the Licenses card on Device and VirtualMachine detail pages. Options: `left_page`, `right_page`, `full_width_page`. | + +#### Cisco EoX API (`PLUGINS_CONFIG` fallback) + +These settings are only used when no database configuration record exists. The Web UI (`/lifecycle/cisco-eox/settings/`) takes precedence when configured. + +| Setting | Default | Description | +|---------|---------|-------------| +| `cisco_eox_enabled` | `False` | Enable the Cisco EoX background sync job. | +| `cisco_eox_client_id` | `''` | OAuth Client ID from [Cisco API Console](https://apiconsole.cisco.com/). Requires an active SNTC or PSS agreement. | +| `cisco_eox_client_secret` | `''` | OAuth Client Secret. Stored as plain text here; use the Web UI for encrypted storage. | +| `cisco_eox_sync_interval` | `10080` | Sync frequency in minutes. Common values: `60` (hourly), `1440` (daily), `10080` (weekly), `20160` (biweekly), `43200` (monthly). | +| `cisco_eox_manufacturer_names` | `'Cisco'` | Comma-separated list of manufacturer names whose DeviceTypes and ModuleTypes are queried (e.g. `'Cisco,Cisco Systems'`). | + +### Cisco EoX Integration + +The plugin includes a background job (`CiscoEoXSyncJob`) that automatically populates `HardwareLifecycle` records with end-of-life dates from the [Cisco EoX API](https://developer.cisco.com/docs/support-apis/eox/). + +**Lookup order per DeviceType / ModuleType:** + +1. Finds a serial number from a related Device or Module and queries `EOXBySerialNumber`. +2. Falls back to the `part_number` field and queries `EOXByProductID` if no serial is available or the serial lookup returns no data. + +**Fields populated:** + +| Cisco API field | HardwareLifecycle field | +|---|---| +| `EndOfSaleDate` | `end_of_sale` | +| `EndOfSWMaintenanceReleases` | `end_of_maintenance` | +| `EndOfSecurityVulSupportDate` | `end_of_security` | +| `LastDateOfSupport` | `end_of_support` | +| `EndOfServiceContractRenewal` | `last_contract_renewal` | +| `LinkToProductBulletinURL` | `documentation` | + +**Web UI configuration** is available at `/lifecycle/cisco-eox/settings/`. After entering credentials and enabling the sync, the job is automatically scheduled at the configured interval. An on-demand **Run Now** button is also provided. Job history is displayed on the settings page and in NetBox's built-in **System → Jobs** view. ### Hardware Lifecycle Info Card diff --git a/netbox_lifecycle/__init__.py b/netbox_lifecycle/__init__.py index d0360c1..4b72ce7 100644 --- a/netbox_lifecycle/__init__.py +++ b/netbox_lifecycle/__init__.py @@ -19,6 +19,12 @@ class NetBoxLifeCycle(PluginConfig): 'lifecycle_card_position': 'right_page', 'contract_card_position': 'right_page', 'license_card_position': 'right_page', + # Cisco EoX API — fallback to PLUGINS_CONFIG when no DB settings exist + 'cisco_eox_enabled': False, + 'cisco_eox_client_id': '', + 'cisco_eox_client_secret': '', + 'cisco_eox_sync_interval': 10080, # weekly, in minutes + 'cisco_eox_manufacturer_names': 'Cisco', } queues = [] graphql_schema = 'graphql.schema.schema' @@ -27,6 +33,8 @@ def ready(self): super().ready() + from netbox_lifecycle.jobs import CiscoEoXSyncJob # noqa: F401 — registers job + from dcim.models import DeviceType, ModuleType from django.contrib.contenttypes.fields import GenericRelation diff --git a/netbox_lifecycle/forms/cisco_eox.py b/netbox_lifecycle/forms/cisco_eox.py new file mode 100644 index 0000000..65b5520 --- /dev/null +++ b/netbox_lifecycle/forms/cisco_eox.py @@ -0,0 +1,68 @@ +from django import forms + +from netbox_lifecycle.models.cisco_eox import SYNC_INTERVAL_CHOICES, CiscoEoXSettings + +__all__ = ('CiscoEoXSettingsForm',) + + +class CiscoEoXSettingsForm(forms.ModelForm): + """ + Form for editing the singleton CiscoEoXSettings record. + + The client_secret field is rendered with a password widget so the value + is never echoed back to the browser. Leaving the field blank on edit + preserves the existing encrypted value in the database. + """ + + client_secret = forms.CharField( + label='OAuth Client Secret', + required=False, + widget=forms.PasswordInput(render_value=False), + help_text=( + 'Leave blank to keep the existing secret. ' + 'Stored encrypted at rest.' + ), + ) + + sync_interval = forms.ChoiceField( + label='Sync Interval', + choices=SYNC_INTERVAL_CHOICES, + help_text='How often the background sync job should run.', + ) + + class Meta: + model = CiscoEoXSettings + fields = ( + 'enabled', + 'client_id', + 'client_secret', + 'sync_interval', + 'manufacturer_names', + ) + help_texts = { + 'client_id': ( + 'Client ID from Cisco API Console ' + '(' + 'apiconsole.cisco.com).' + ), + 'manufacturer_names': ( + 'Comma-separated list of manufacturer names to query ' + '(e.g. Cisco,Cisco Systems).' + ), + } + + def save(self, commit=True): + instance = super().save(commit=False) + + # Only update the encrypted secret if a new value was provided + new_secret = self.cleaned_data.get('client_secret', '').strip() + if new_secret: + instance.client_secret = new_secret + # else: leave instance._client_secret unchanged (already loaded from DB) + + # Coerce sync_interval to int (ChoiceField returns strings) + instance.sync_interval = int(self.cleaned_data['sync_interval']) + + if commit: + instance.save() + return instance diff --git a/netbox_lifecycle/jobs.py b/netbox_lifecycle/jobs.py new file mode 100644 index 0000000..663c963 --- /dev/null +++ b/netbox_lifecycle/jobs.py @@ -0,0 +1,183 @@ +""" +Background jobs for the netbox-lifecycle plugin. + +CiscoEoXSyncJob +--------------- +Queries the Cisco EoX API for all DeviceTypes and ModuleTypes whose +manufacturer name matches the configured list, then updates (or creates) +their HardwareLifecycle records with the returned end-of-life dates. + +Lookup order (per item): + 1. Try a Device/Module serial number associated with this DeviceType/ModuleType + 2. Fall back to the DeviceType/ModuleType part_number field + +The job reschedules itself using ``enqueue_once()`` so the configured +interval is honoured dynamically. Initial scheduling is triggered when +the user saves the settings page (or calls ``enqueue_once()`` manually). +""" + +import logging + +from netbox.jobs import JobRunner + +logger = logging.getLogger(__name__) + +__all__ = ('CiscoEoXSyncJob',) + + +class CiscoEoXSyncJob(JobRunner): + class Meta: + name = 'Cisco EoX Sync' + + def run(self, *args, **kwargs): + from django.contrib.contenttypes.models import ContentType + + from dcim.models import Device, DeviceType, Module, ModuleType + + from netbox_lifecycle.models import HardwareLifecycle + from netbox_lifecycle.utilities.cisco_eox_api import ( + CiscoEoXAPIError, + CiscoEoXApiClient, + ) + from netbox_lifecycle.utilities.settings_loader import get_cisco_eox_settings + + # ------------------------------------------------------------------ + # Load configuration + # ------------------------------------------------------------------ + cfg = get_cisco_eox_settings() + if cfg is None: + self.logger.warning( + 'Cisco EoX sync is not configured or not enabled. Skipping.' + ) + return + + self.logger.info( + 'Starting Cisco EoX sync for manufacturers: %s', + cfg.manufacturer_names, + ) + + client = CiscoEoXApiClient( + client_id=cfg.client_id, + client_secret=cfg.client_secret, + ) + + updated = 0 + skipped = 0 + errored = 0 + + # ------------------------------------------------------------------ + # Helper: resolve EoX data for one item + # ------------------------------------------------------------------ + def _sync_item(item, ct, serial_qs, part_number_attr='part_number'): + nonlocal updated, skipped, errored + + eox_data = None + label = f'{item._meta.verbose_name} "{item}"' + + # 1. Try serial number from a related device/module + serial = ( + serial_qs.filter(**{f'{item._meta.model_name}__pk': item.pk}) + .exclude(serial='') + .values_list('serial', flat=True) + .first() + ) + if serial: + try: + records = client.get_eox_by_serial([serial]) + if records: + eox_data = client.parse_eox_record(records[0]) + self.logger.debug( + '%s — EoX data found via serial %s (product: %s)', + label, + serial, + eox_data.get('_eol_product_id'), + ) + except CiscoEoXAPIError as exc: + self.logger.debug( + '%s — serial lookup failed (%s), will try part_number.', label, exc + ) + + # 2. Fall back to part_number + if eox_data is None: + part_number = getattr(item, part_number_attr, '') or '' + if part_number: + try: + records = client.get_eox_by_product_id([part_number]) + if records: + eox_data = client.parse_eox_record(records[0]) + self.logger.debug( + '%s — EoX data found via part_number %s', + label, + part_number, + ) + except CiscoEoXAPIError as exc: + self.logger.warning( + '%s — part_number lookup failed: %s', label, exc + ) + errored += 1 + return + + if eox_data is None: + self.logger.debug('%s — no EoX data found; skipping.', label) + skipped += 1 + return + + # Strip internal-only keys before saving + lifecycle_fields = { + k: v for k, v in eox_data.items() if not k.startswith('_') + } + # Remove None values so existing dates are not blanked unintentionally + # when only partial data is returned + lifecycle_fields = {k: v for k, v in lifecycle_fields.items() if v is not None} + + HardwareLifecycle.objects.update_or_create( + assigned_object_type=ct, + assigned_object_id=item.pk, + defaults=lifecycle_fields, + ) + updated += 1 + + # ------------------------------------------------------------------ + # Process DeviceTypes + # ------------------------------------------------------------------ + dt_ct = ContentType.objects.get_for_model(DeviceType) + device_types = DeviceType.objects.filter( + manufacturer__name__in=cfg.manufacturer_names_list + ).select_related('manufacturer') + + self.logger.info('Processing %d DeviceTypes …', device_types.count()) + for dt in device_types: + _sync_item(dt, dt_ct, Device.objects, part_number_attr='part_number') + + # ------------------------------------------------------------------ + # Process ModuleTypes + # ------------------------------------------------------------------ + mt_ct = ContentType.objects.get_for_model(ModuleType) + module_types = ModuleType.objects.filter( + manufacturer__name__in=cfg.manufacturer_names_list + ).select_related('manufacturer') + + self.logger.info('Processing %d ModuleTypes …', module_types.count()) + for mt in module_types: + _sync_item(mt, mt_ct, Module.objects, part_number_attr='part_number') + + # ------------------------------------------------------------------ + # Summary + # ------------------------------------------------------------------ + self.logger.info( + 'Cisco EoX sync complete — updated: %d, skipped: %d, errored: %d', + updated, + skipped, + errored, + ) + + # ------------------------------------------------------------------ + # Reschedule for next run + # ------------------------------------------------------------------ + # Reload cfg in case it changed while this job was running + cfg = get_cisco_eox_settings() + if cfg and cfg.enabled: + CiscoEoXSyncJob.enqueue_once(interval=cfg.sync_interval) + self.logger.debug( + 'Rescheduled Cisco EoX sync in %d minutes.', cfg.sync_interval + ) diff --git a/netbox_lifecycle/migrations/0019_cisco_eox_settings.py b/netbox_lifecycle/migrations/0019_cisco_eox_settings.py new file mode 100644 index 0000000..8e30e16 --- /dev/null +++ b/netbox_lifecycle/migrations/0019_cisco_eox_settings.py @@ -0,0 +1,53 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('netbox_lifecycle', '0018_netbox_v040500'), + ] + + operations = [ + migrations.CreateModel( + name='CiscoEoXSettings', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('enabled', models.BooleanField(default=False)), + ('client_id', models.CharField(blank=True, max_length=200)), + ( + 'client_secret', + models.CharField( + blank=True, db_column='client_secret', max_length=500 + ), + ), + ( + 'sync_interval', + models.PositiveIntegerField( + choices=[ + (60, 'Hourly'), + (1440, 'Daily'), + (10080, 'Weekly'), + (20160, 'Biweekly'), + (43200, 'Monthly'), + ], + default=10080, + ), + ), + ( + 'manufacturer_names', + models.CharField(default='Cisco', max_length=500), + ), + ], + options={ + 'verbose_name': 'Cisco EoX Settings', + }, + ), + ] diff --git a/netbox_lifecycle/models/__init__.py b/netbox_lifecycle/models/__init__.py index edd31fe..93ab0aa 100644 --- a/netbox_lifecycle/models/__init__.py +++ b/netbox_lifecycle/models/__init__.py @@ -1,3 +1,4 @@ +from .cisco_eox import * from .contract import * from .hardware import * from .license import * diff --git a/netbox_lifecycle/models/cisco_eox.py b/netbox_lifecycle/models/cisco_eox.py new file mode 100644 index 0000000..93a6ddb --- /dev/null +++ b/netbox_lifecycle/models/cisco_eox.py @@ -0,0 +1,120 @@ +import base64 +import hashlib + +from django.conf import settings +from django.db import models + +__all__ = ('CiscoEoXSettings',) + +SYNC_INTERVAL_CHOICES = [ + (60, 'Hourly'), + (1440, 'Daily'), + (10080, 'Weekly'), + (20160, 'Biweekly'), + (43200, 'Monthly'), +] + + +def _get_fernet(): + """Return a Fernet instance keyed from Django's SECRET_KEY.""" + from cryptography.fernet import Fernet + + key_material = settings.SECRET_KEY.encode() + digest = hashlib.sha256(key_material).digest() + fernet_key = base64.urlsafe_b64encode(digest) + return Fernet(fernet_key) + + +class CiscoEoXSettings(models.Model): + """ + Singleton model (pk=1) that stores Cisco EoX API credentials and sync + configuration. The OAuth client secret is stored Fernet-encrypted at rest + using a key derived from Django's SECRET_KEY. + + A second source of truth is PLUGINS_CONFIG (see utilities/settings_loader.py); + the database record takes precedence when present. + """ + + enabled = models.BooleanField( + default=False, + help_text='Enable automatic Cisco EoX API synchronisation.', + ) + client_id = models.CharField( + max_length=200, + blank=True, + verbose_name='OAuth Client ID', + help_text='Client ID obtained from Cisco API Console (apiconsole.cisco.com).', + ) + _client_secret = models.CharField( + max_length=500, + blank=True, + db_column='client_secret', + verbose_name='OAuth Client Secret', + ) + sync_interval = models.PositiveIntegerField( + choices=SYNC_INTERVAL_CHOICES, + default=10080, + verbose_name='Sync Interval', + help_text='How often the background sync job should run.', + ) + manufacturer_names = models.CharField( + max_length=500, + default='Cisco', + verbose_name='Manufacturer Names', + help_text=( + 'Comma-separated list of manufacturer names whose device/module types ' + 'will be queried against the Cisco EoX API.' + ), + ) + + class Meta: + verbose_name = 'Cisco EoX Settings' + + def __str__(self): + return 'Cisco EoX Settings' + + # ------------------------------------------------------------------ + # Singleton helpers + # ------------------------------------------------------------------ + + @classmethod + def get_or_create_settings(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + # ------------------------------------------------------------------ + # Encrypted client secret + # ------------------------------------------------------------------ + + @property + def client_secret(self): + if not self._client_secret: + return '' + try: + return _get_fernet().decrypt(self._client_secret.encode()).decode() + except Exception: + # If decryption fails (e.g. SECRET_KEY rotated), return empty string + return '' + + @client_secret.setter + def client_secret(self, value): + if value: + self._client_secret = _get_fernet().encrypt(value.encode()).decode() + else: + self._client_secret = '' + + # ------------------------------------------------------------------ + # Convenience helpers + # ------------------------------------------------------------------ + + @property + def manufacturer_names_list(self): + """Return manufacturer names as a list of stripped strings.""" + return [n.strip() for n in self.manufacturer_names.split(',') if n.strip()] + + @property + def sync_interval_display(self): + for value, label in SYNC_INTERVAL_CHOICES: + if value == self.sync_interval: + return label + return f'{self.sync_interval} minutes' diff --git a/netbox_lifecycle/navigation.py b/netbox_lifecycle/navigation.py index d63565d..a9efbcb 100644 --- a/netbox_lifecycle/navigation.py +++ b/netbox_lifecycle/navigation.py @@ -38,12 +38,19 @@ ) +cisco_eox_settings = PluginMenuItem( + link='plugins:netbox_lifecycle:cisco_eox_settings', + link_text='Cisco EoX Settings', + permissions=['netbox_lifecycle.view_ciscoeoxsettings'], +) + menu = PluginMenu( label='Hardware Lifecycle', groups=( ('Lifecycle', (lifecycle,)), ('Vendor Support', (vendors, skus, contracts, contract_assignments)), ('Licensing', (licenses, license_assignments)), + ('Cisco EoX', (cisco_eox_settings,)), ), icon_class='mdi mdi-server', ) diff --git a/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings.html b/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings.html new file mode 100644 index 0000000..0aaa720 --- /dev/null +++ b/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings.html @@ -0,0 +1,141 @@ +{% extends 'base/layout.html' %} +{% load helpers %} +{% load perms %} + +{% block title %}Cisco EoX Settings{% endblock %} + +{% block content %} +
+
+ + +
+
+ Cisco EoX API Settings + {% if perms.netbox_lifecycle.change_ciscoeoxsettings %} + + Edit + + {% endif %} +
+
+ + + + + + + + + + + + + + + + + + + + + +
Enabled + {% if settings_obj.enabled %} + Yes + {% else %} + No + {% endif %} +
OAuth Client ID + {% if settings_obj.client_id %} + {{ settings_obj.client_id }} + {% else %} + + {% endif %} +
OAuth Client Secret + {% if settings_obj._client_secret %} + •••••••• (stored encrypted) + {% else %} + + {% endif %} +
Sync Interval{{ settings_obj.sync_interval_display }}
Manufacturer Names{{ settings_obj.manufacturer_names|placeholder }}
+
+
+ + + {% if perms.netbox_lifecycle.change_ciscoeoxsettings %} +
+
Manual Trigger
+
+

+ Immediately queue a Cisco EoX sync job. The job will run on the + next available RQ worker. +

+
+ {% csrf_token %} + + {% if not settings_obj.enabled or not settings_obj.client_id %} + Enable the sync and provide credentials first. + {% endif %} +
+
+
+ {% endif %} + +
+ + +
+
+
Recent Sync Jobs
+
+ {% if recent_jobs %} + + + + + + + + + + {% for job in recent_jobs %} + + + + + + {% endfor %} + +
StartedStatusCompleted
+ {% if job.url %} + {{ job.created }} + {% else %} + {{ job.created }} + {% endif %} + + {% if job.status == 'completed' %} + Completed + {% elif job.status == 'running' %} + Running + {% elif job.status == 'pending' %} + Pending + {% elif job.status == 'failed' %} + Failed + {% elif job.status == 'errored' %} + Errored + {% else %} + {{ job.status }} + {% endif %} + {{ job.completed|placeholder }}
+ {% else %} +

No sync jobs have run yet.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings_edit.html b/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings_edit.html new file mode 100644 index 0000000..928f36d --- /dev/null +++ b/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings_edit.html @@ -0,0 +1,84 @@ +{% extends 'base/layout.html' %} +{% load helpers %} + +{% block title %}Edit Cisco EoX Settings{% endblock %} + +{% block content %} +
+
+
+
Cisco EoX API Settings
+
+ +
+ {% csrf_token %} + +
+
+ {{ form.enabled }} + +
+ {% if form.enabled.help_text %} +
{{ form.enabled.help_text }}
+ {% endif %} + {% for error in form.enabled.errors %} +
{{ error }}
+ {% endfor %} +
+ + {% for field in form %} + {% if field.name != 'enabled' %} +
+ + {{ field }} + {% if field.help_text %} +
{{ field.help_text|safe }}
+ {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} + {% endfor %} + +
+ + + Cancel + +
+
+ +
+
+ +
+
About Cisco EoX API Credentials
+
+

+ Access to the Cisco EoX API requires an active Cisco Smart Net Total Care (SNTC) + or Partner Support Services (PSS) agreement. +

+
    +
  1. Register an application at + apiconsole.cisco.com. +
  2. +
  3. Enable the EoX API for your application.
  4. +
  5. Copy the generated Key (Client ID) and Client Secret here.
  6. +
+

+ The client secret is stored Fernet-encrypted in the database using a key + derived from Django's SECRET_KEY. +

+
+
+
+
+{% endblock %} diff --git a/netbox_lifecycle/urls.py b/netbox_lifecycle/urls.py index c0252a3..873f3e4 100644 --- a/netbox_lifecycle/urls.py +++ b/netbox_lifecycle/urls.py @@ -285,4 +285,20 @@ views.VirtualMachineLicensesHTMXView.as_view(), name='virtualmachine_licenses_htmx', ), + # Cisco EoX settings + path( + 'cisco-eox/settings/', + views.CiscoEoXSettingsView.as_view(), + name='cisco_eox_settings', + ), + path( + 'cisco-eox/settings/edit/', + views.CiscoEoXSettingsEditView.as_view(), + name='cisco_eox_settings_edit', + ), + path( + 'cisco-eox/run-now/', + views.CiscoEoXRunNowView.as_view(), + name='cisco_eox_run_now', + ), ] diff --git a/netbox_lifecycle/utilities/cisco_eox_api.py b/netbox_lifecycle/utilities/cisco_eox_api.py new file mode 100644 index 0000000..bcf54da --- /dev/null +++ b/netbox_lifecycle/utilities/cisco_eox_api.py @@ -0,0 +1,231 @@ +""" +Cisco EoX (End-of-Life) API client. + +Authentication: OAuth 2.0 client_credentials grant. +Credentials are retrieved via the plugin's settings loader; see +netbox_lifecycle.utilities.settings_loader. + +API documentation: + https://developer.cisco.com/docs/support-apis/eox/ +""" + +import logging +from datetime import date, datetime + +import requests +from django.core.cache import cache + +logger = logging.getLogger(__name__) + +EOX_API_BASE = 'https://apix.cisco.com/supporttools/eox/rest/5' +TOKEN_URL = 'https://id.cisco.com/oauth2/default/v1/token' +TOKEN_CACHE_KEY = 'netbox_lifecycle__cisco_eox_token' +# Sentinel date Cisco returns when no specific date exists +_CISCO_NULL_DATE = 'Y-Y-Y-Y' + + +class CiscoEoXAPIError(Exception): + """Raised for authentication failures or API-level errors.""" + + +class CiscoEoXApiClient: + """ + Thin wrapper around the Cisco EoX REST API v5. + + Usage:: + + client = CiscoEoXApiClient(client_id='…', client_secret='…') + records = client.get_eox_by_product_id(['WS-C3750X-48P-S']) + for r in records: + parsed = client.parse_eox_record(r) + """ + + def __init__(self, client_id: str, client_secret: str): + if not client_id or not client_secret: + raise CiscoEoXAPIError('client_id and client_secret must be non-empty.') + self._client_id = client_id + self._client_secret = client_secret + + # ------------------------------------------------------------------ + # Authentication + # ------------------------------------------------------------------ + + def _get_token(self) -> str: + """ + Return a valid Bearer token, fetching a new one if the cached token + has expired. Tokens are cached for 58 minutes (Cisco tokens expire + after ~60 minutes). + """ + token = cache.get(TOKEN_CACHE_KEY) + if token: + return token + + response = requests.post( + TOKEN_URL, + data={ + 'grant_type': 'client_credentials', + 'client_id': self._client_id, + 'client_secret': self._client_secret, + }, + timeout=30, + ) + + if response.status_code != 200: + raise CiscoEoXAPIError( + f'Cisco OAuth token request failed ({response.status_code}): ' + f'{response.text[:300]}' + ) + + payload = response.json() + token = payload.get('access_token') + if not token: + raise CiscoEoXAPIError( + f'Cisco OAuth response missing access_token: {payload}' + ) + + # Cache for 58 minutes to avoid using an about-to-expire token + cache.set(TOKEN_CACHE_KEY, token, timeout=58 * 60) + return token + + def _get_headers(self) -> dict: + return { + 'Authorization': f'Bearer {self._get_token()}', + 'Accept': 'application/json', + } + + # ------------------------------------------------------------------ + # API calls + # ------------------------------------------------------------------ + + def get_eox_by_serial(self, serial_numbers: list[str]) -> list[dict]: + """ + Look up EoX records for up to 20 serial numbers. + + Returns a list of raw EoX record dicts from the API. Records where + the API returned an error (e.g. serial not found) are silently + filtered out. + """ + if not serial_numbers: + return [] + + results = [] + for chunk in _chunks(serial_numbers, 20): + serials_str = ','.join(chunk) + url = f'{EOX_API_BASE}/EOXBySerialNumber/1/{serials_str}' + records = self._fetch_eox(url) + results.extend(records) + + return results + + def get_eox_by_product_id(self, product_ids: list[str]) -> list[dict]: + """ + Look up EoX records for up to 20 Product IDs (part numbers). + + Returns a list of raw EoX record dicts. + """ + if not product_ids: + return [] + + results = [] + for chunk in _chunks(product_ids, 20): + pids_str = ','.join(chunk) + url = f'{EOX_API_BASE}/EOXByProductID/1/{pids_str}' + records = self._fetch_eox(url) + results.extend(records) + + return results + + def _fetch_eox(self, url: str) -> list[dict]: + """Execute a GET against a pre-built EoX URL and return EoXRecord list.""" + try: + response = requests.get( + url, + params={'responseencoding': 'json'}, + headers=self._get_headers(), + timeout=30, + ) + except requests.RequestException as exc: + raise CiscoEoXAPIError(f'HTTP request failed: {exc}') from exc + + if response.status_code == 401: + # Invalidate cached token and retry once + cache.delete(TOKEN_CACHE_KEY) + try: + response = requests.get( + url, + params={'responseencoding': 'json'}, + headers=self._get_headers(), + timeout=30, + ) + except requests.RequestException as exc: + raise CiscoEoXAPIError(f'HTTP request failed on retry: {exc}') from exc + + if response.status_code != 200: + raise CiscoEoXAPIError( + f'Cisco EoX API returned {response.status_code}: {response.text[:300]}' + ) + + data = response.json() + raw_records = data.get('EOXRecord', []) + + # Filter out error records (no EoX data for this product/serial) + good_records = [ + r for r in raw_records if not r.get('EOXError') and r.get('EOLProductID') + ] + return good_records + + # ------------------------------------------------------------------ + # Parsing + # ------------------------------------------------------------------ + + @staticmethod + def parse_eox_record(record: dict) -> dict: + """ + Convert a raw Cisco EoX API record into a dict whose keys match + HardwareLifecycle field names. + + Date values are returned as ``datetime.date`` objects (or ``None`` + when the API returns a sentinel / missing value). + """ + + def _date(field_name: str) -> date | None: + obj = record.get(field_name, {}) + if isinstance(obj, dict): + value = obj.get('value', '') + else: + value = str(obj) if obj else '' + if not value or value == _CISCO_NULL_DATE or value == '': + return None + try: + return datetime.strptime(value, '%Y-%m-%d').date() + except ValueError: + logger.debug('Cannot parse date %r for field %s', value, field_name) + return None + + documentation = record.get('LinkToProductBulletinURL') or None + # Truncate to the model's max_length of 500 + if documentation and len(documentation) > 500: + documentation = documentation[:500] + + return { + 'end_of_sale': _date('EndOfSaleDate'), + 'end_of_maintenance': _date('EndOfSWMaintenanceReleases'), + 'end_of_security': _date('EndOfSecurityVulSupportDate'), + 'end_of_support': _date('LastDateOfSupport'), + 'last_contract_renewal': _date('EndOfServiceContractRenewal'), + 'documentation': documentation, + # Informational extras (not stored directly, useful for logging) + '_eol_product_id': record.get('EOLProductID', ''), + '_product_description': record.get('ProductIDDescription', ''), + } + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + + +def _chunks(lst: list, size: int): + """Yield successive ``size``-sized chunks from ``lst``.""" + for i in range(0, len(lst), size): + yield lst[i : i + size] diff --git a/netbox_lifecycle/utilities/settings_loader.py b/netbox_lifecycle/utilities/settings_loader.py new file mode 100644 index 0000000..9f6111e --- /dev/null +++ b/netbox_lifecycle/utilities/settings_loader.py @@ -0,0 +1,89 @@ +""" +Resolves Cisco EoX plugin settings from the database (preferred) or from +PLUGINS_CONFIG (fallback). + +Usage:: + + from netbox_lifecycle.utilities.settings_loader import get_cisco_eox_settings + cfg = get_cisco_eox_settings() + if cfg is None: + # not configured / not enabled + ... +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from netbox.plugins import get_plugin_config + +logger = logging.getLogger(__name__) + + +@dataclass +class CiscoEoXConfig: + """Resolved Cisco EoX configuration (from DB or PLUGINS_CONFIG).""" + + enabled: bool + client_id: str + client_secret: str + sync_interval: int + manufacturer_names: str + + @property + def manufacturer_names_list(self) -> list[str]: + return [n.strip() for n in self.manufacturer_names.split(',') if n.strip()] + + +def get_cisco_eox_settings() -> CiscoEoXConfig | None: + """ + Return a :class:`CiscoEoXConfig` populated from the most authoritative + source available: + + 1. ``CiscoEoXSettings`` database record (pk=1) — if it exists, is enabled, + and contains a ``client_id``. + 2. ``PLUGINS_CONFIG['netbox_lifecycle']`` keys prefixed with + ``cisco_eox_*`` — if present. + + Returns ``None`` if no valid, enabled configuration is found. + """ + # --- 1. Try database --- + try: + from netbox_lifecycle.models.cisco_eox import CiscoEoXSettings + + db_cfg = CiscoEoXSettings.objects.filter(pk=1).first() + if db_cfg is not None and db_cfg.enabled and db_cfg.client_id: + return CiscoEoXConfig( + enabled=db_cfg.enabled, + client_id=db_cfg.client_id, + client_secret=db_cfg.client_secret, + sync_interval=db_cfg.sync_interval, + manufacturer_names=db_cfg.manufacturer_names, + ) + except Exception as exc: + # DB may not be available during migrations or tests + logger.debug('Could not load CiscoEoXSettings from database: %s', exc) + + # --- 2. Fall back to PLUGINS_CONFIG --- + try: + enabled = get_plugin_config('netbox_lifecycle', 'cisco_eox_enabled') + client_id = get_plugin_config('netbox_lifecycle', 'cisco_eox_client_id') + client_secret = get_plugin_config('netbox_lifecycle', 'cisco_eox_client_secret') + + if enabled and client_id and client_secret: + return CiscoEoXConfig( + enabled=True, + client_id=client_id, + client_secret=client_secret, + sync_interval=get_plugin_config( + 'netbox_lifecycle', 'cisco_eox_sync_interval' + ), + manufacturer_names=get_plugin_config( + 'netbox_lifecycle', 'cisco_eox_manufacturer_names' + ), + ) + except Exception as exc: + logger.debug('Could not load Cisco EoX settings from PLUGINS_CONFIG: %s', exc) + + return None diff --git a/netbox_lifecycle/views/__init__.py b/netbox_lifecycle/views/__init__.py index 21fcea4..e16c4c9 100644 --- a/netbox_lifecycle/views/__init__.py +++ b/netbox_lifecycle/views/__init__.py @@ -1,3 +1,4 @@ +from .cisco_eox import * from .contract import * from .hardware import * from .htmx import * diff --git a/netbox_lifecycle/views/cisco_eox.py b/netbox_lifecycle/views/cisco_eox.py new file mode 100644 index 0000000..f00bd33 --- /dev/null +++ b/netbox_lifecycle/views/cisco_eox.py @@ -0,0 +1,122 @@ +""" +Views for the Cisco EoX Settings page. + +Three views are provided: + +* CiscoEoXSettingsView — display current settings + recent job history +* CiscoEoXSettingsEditView — edit/save the CiscoEoXSettings record +* CiscoEoXRunNowView — POST-only; immediately enqueues the sync job +""" + +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.shortcuts import redirect, render +from django.views import View + +from netbox_lifecycle.forms.cisco_eox import CiscoEoXSettingsForm +from netbox_lifecycle.jobs import CiscoEoXSyncJob +from netbox_lifecycle.models.cisco_eox import CiscoEoXSettings + +__all__ = ( + 'CiscoEoXSettingsView', + 'CiscoEoXSettingsEditView', + 'CiscoEoXRunNowView', +) + + +class CiscoEoXSettingsView(PermissionRequiredMixin, View): + """Display the current Cisco EoX settings and recent job history.""" + + permission_required = 'netbox_lifecycle.view_ciscoeoxsettings' + template_name = 'netbox_lifecycle/cisco_eox_settings.html' + + def get(self, request): + settings_obj = CiscoEoXSettings.get_or_create_settings() + + # Recent jobs — core.models.Job tracks JobRunner executions + try: + from core.models import Job + + recent_jobs = Job.objects.filter(name=CiscoEoXSyncJob.Meta.name).order_by( + '-created' + )[:10] + except Exception: + recent_jobs = [] + + return render( + request, + self.template_name, + { + 'settings_obj': settings_obj, + 'recent_jobs': recent_jobs, + }, + ) + + +class CiscoEoXSettingsEditView(PermissionRequiredMixin, View): + """Create or update the singleton CiscoEoXSettings record.""" + + permission_required = 'netbox_lifecycle.change_ciscoeoxsettings' + template_name = 'netbox_lifecycle/cisco_eox_settings_edit.html' + + def get(self, request): + settings_obj = CiscoEoXSettings.get_or_create_settings() + form = CiscoEoXSettingsForm(instance=settings_obj) + return render( + request, + self.template_name, + { + 'form': form, + 'settings_obj': settings_obj, + }, + ) + + def post(self, request): + settings_obj = CiscoEoXSettings.get_or_create_settings() + form = CiscoEoXSettingsForm(request.POST, instance=settings_obj) + + if form.is_valid(): + saved = form.save() + + # Re-schedule the sync job with the (potentially new) interval + if saved.enabled and saved.client_id: + CiscoEoXSyncJob.enqueue_once(interval=saved.sync_interval) + messages.success( + request, + f'Cisco EoX sync scheduled (interval: {saved.sync_interval_display}).', + ) + elif not saved.enabled: + messages.info(request, 'Cisco EoX sync disabled. No job has been scheduled.') + + return redirect('plugins:netbox_lifecycle:cisco_eox_settings') + + return render( + request, + self.template_name, + { + 'form': form, + 'settings_obj': settings_obj, + }, + ) + + +class CiscoEoXRunNowView(PermissionRequiredMixin, View): + """Immediately enqueue the Cisco EoX sync job (staff action).""" + + permission_required = 'netbox_lifecycle.change_ciscoeoxsettings' + + def post(self, request): + from netbox_lifecycle.utilities.settings_loader import get_cisco_eox_settings + + cfg = get_cisco_eox_settings() + if cfg is None: + messages.error( + request, + 'Cisco EoX is not configured or not enabled. ' + 'Please save your settings first.', + ) + else: + CiscoEoXSyncJob.enqueue() + messages.success(request, 'Cisco EoX sync job has been queued.') + + return redirect('plugins:netbox_lifecycle:cisco_eox_settings') diff --git a/pyproject.toml b/pyproject.toml index b85f543..8563572 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ classifiers = [ ] dependencies = [ 'django-polymorphic', + 'cryptography>=41.0', + 'requests>=2.31', ] [project.urls]