Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions netbox_lifecycle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Expand Down
68 changes: 68 additions & 0 deletions netbox_lifecycle/forms/cisco_eox.py
Original file line number Diff line number Diff line change
@@ -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 '
'(<a href="https://apiconsole.cisco.com/" target="_blank">'
'apiconsole.cisco.com</a>).'
),
'manufacturer_names': (
'Comma-separated list of manufacturer names to query '
'(e.g. <code>Cisco,Cisco Systems</code>).'
),
}

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
183 changes: 183 additions & 0 deletions netbox_lifecycle/jobs.py
Original file line number Diff line number Diff line change
@@ -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
)
53 changes: 53 additions & 0 deletions netbox_lifecycle/migrations/0019_cisco_eox_settings.py
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
Loading