Skip to content
Open
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
18 changes: 18 additions & 0 deletions osism/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ def read_secret(secret_name):
SONIC_EXPORT_SUFFIX = os.getenv("SONIC_EXPORT_SUFFIX", "_config_db.json")
SONIC_EXPORT_IDENTIFIER = os.getenv("SONIC_EXPORT_IDENTIFIER", "serial-number")

# SONiC ZTP firmware configuration
#
# The ZTP firmware install uses a dynamic-url built from
# <prefix><identifier><suffix> (see osism/ansible-collection-services#2131),
# so every switch fetches its firmware image via a per-device name during ZTP.
# sync_sonic creates that per-device name as a symlink to the version-specific
# image <prefix><version><suffix>, driven by the device's
# sonic_parameters.version custom field. The defaults mirror the ansible
# httpd role and place the links in the same httpd-served directory as the
# config exports (SONIC_EXPORT_DIR), which stays the single source of truth
# for the base export path unless a separate firmware directory is set.
SONIC_FIRMWARE_DIR = os.getenv("SONIC_FIRMWARE_DIR", SONIC_EXPORT_DIR)
SONIC_FIRMWARE_PREFIX = os.getenv(
"SONIC_FIRMWARE_PREFIX", "sonic-broadcom-enterprise-base_"
)
SONIC_FIRMWARE_SUFFIX = os.getenv("SONIC_FIRMWARE_SUFFIX", ".bin")
SONIC_FIRMWARE_IDENTIFIER = os.getenv("SONIC_FIRMWARE_IDENTIFIER", "serial-number")


NETBOX_SECONDARIES = (
os.getenv("NETBOX_SECONDARIES", read_secret("NETBOX_SECONDARIES")) or "[]"
Expand Down
96 changes: 96 additions & 0 deletions osism/tasks/conductor/sonic/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,99 @@ def export_config_to_file(device, config):
except Exception as e:
logger.error(f"Failed to export config for device {device.name}: {e}")
raise


def export_firmware_link(device, version):
"""Create the per-device SONiC ZTP firmware symlink for a device.

PR osism/ansible-collection-services#2131 switched the ZTP firmware
install to a dynamic-url built from ``<prefix><identifier><suffix>``
(identifier ``serial-number`` by default), so during ZTP each switch
fetches ``<prefix><serial><suffix>``. This function creates that
per-device name as a *relative* symlink to the version-specific image
``<prefix><version><suffix>`` in the same firmware directory, using the
version from the device's ``sonic_parameters.version`` custom field.

The symlink is reconciled on every call: a missing, stale, or wrongly
pointed link is repaired even when nothing else changed. The target image
is provided out of band (e.g. downloaded by metalbox), so the link may be
dangling until that image is present -- creating a symlink does not require
the target to exist.

Args:
device: NetBox device object
version: SONiC firmware version string (e.g. "4.4.2"), or a falsy
value when the device has no ``sonic_parameters.version`` set.

Returns:
bool: True if the symlink was created or repointed, False if it
already pointed at the correct target or firmware management was
skipped (no version configured).

Raises:
Exception: If reconciling the symlink fails, so a failed link is
distinguishable from "no changes".
"""
if not version:
logger.debug(
f"No SONiC firmware version configured for device {device.name}, "
"skipping firmware symlink"
)
return False

try:
firmware_dir = settings.SONIC_FIRMWARE_DIR
prefix = settings.SONIC_FIRMWARE_PREFIX
suffix = settings.SONIC_FIRMWARE_SUFFIX
identifier_type = settings.SONIC_FIRMWARE_IDENTIFIER

os.makedirs(firmware_dir, exist_ok=True)

# Determine the per-device identifier the switch requests during ZTP
if identifier_type == "serial-number":
identifier = (
device.serial if hasattr(device, "serial") and device.serial else None
)
if not identifier:
logger.warning(
f"Serial number not found for device {device.name}, "
"falling back to hostname for firmware symlink"
)
identifier = get_device_hostname(device)
else:
identifier = get_device_hostname(device)

link_name = f"{prefix}{identifier}{suffix}"
link_path = os.path.join(firmware_dir, link_name)
# Relative target within firmware_dir, matching the served httpd layout
target_name = f"{prefix}{version}{suffix}"

if link_name == target_name:
# The device identifier equals the version: a symlink here would
# point to itself and shadow the actual image
logger.debug(
f"Skipping firmware symlink for device {device.name}: "
"link name equals version image name"
)
return False

if os.path.islink(link_path) and os.readlink(link_path) == target_name:
logger.debug(
f"Firmware symlink {link_path} already points to {target_name}"
)
return False

if os.path.exists(link_path) or os.path.islink(link_path):
logger.debug(f"Removing existing firmware file/symlink: {link_path}")
os.remove(link_path)

os.symlink(target_name, link_path)
logger.info(
f"Created firmware symlink {link_path} -> {target_name} "
f"for device {device.name}"
)
return True

except Exception as e:
logger.error(f"Failed to create firmware symlink for device {device.name}: {e}")
raise
61 changes: 38 additions & 23 deletions osism/tasks/conductor/sonic/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,27 @@
_load_metalbox_devices_cache,
)
from .constants import DEFAULT_SONIC_ROLES, SUPPORTED_HWSKUS
from .exporter import save_config_to_netbox, export_config_to_file
from .exporter import (
save_config_to_netbox,
export_config_to_file,
export_firmware_link,
)
from .cache import clear_interface_cache, get_interface_cache_stats


def _get_sonic_parameter(device, key):
"""Return ``sonic_parameters[key]`` from a device's custom fields, or None.

Centralises the nested ``custom_fields -> sonic_parameters -> <key>``
lookup so the per-field reads in ``sync_sonic`` (hwsku, config_version,
version) stay short and consistent. Missing custom fields, a missing or
empty ``sonic_parameters``, or a missing key all yield None.
"""
custom_fields = getattr(device, "custom_fields", None) or {}
sonic_parameters = custom_fields.get("sonic_parameters") or {}
return sonic_parameters.get(key)


def sync_sonic(device_name=None, task_id=None, show_diff=True):
"""Sync SONiC configurations for eligible devices.

Expand Down Expand Up @@ -156,31 +173,25 @@ def sync_sonic(device_name=None, task_id=None, show_diff=True):

# Generate SONIC configuration for each device
for device in devices:
# Get HWSKU from sonic_parameters custom field, default to None
hwsku = None
if (
hasattr(device, "custom_fields")
and "sonic_parameters" in device.custom_fields
and device.custom_fields["sonic_parameters"]
and "hwsku" in device.custom_fields["sonic_parameters"]
):
hwsku = device.custom_fields["sonic_parameters"]["hwsku"]

# Get config_version from sonic_parameters custom field, default to None
config_version = None
if (
hasattr(device, "custom_fields")
and "sonic_parameters" in device.custom_fields
and device.custom_fields["sonic_parameters"]
and "config_version" in device.custom_fields["sonic_parameters"]
):
config_version = device.custom_fields["sonic_parameters"][
"config_version"
]
# Read the per-device SONiC settings from the sonic_parameters
# custom field (all default to None when unset).
hwsku = _get_sonic_parameter(device, "hwsku")

# config_version overrides the generated CONFIG DB VERSION
config_version = _get_sonic_parameter(device, "config_version")
if config_version:
logger.debug(
f"Device {device.name} has custom config_version: {config_version}"
)

# version drives the per-device ZTP firmware symlink (see
# export_firmware_link), independent of config_version above.
version = _get_sonic_parameter(device, "version")
if version:
logger.debug(
f"Device {device.name} has SONiC firmware version: {version}"
)

# Skip devices without HWSKU
if not hwsku:
logger.debug(f"Skipping device {device.name}: no HWSKU configured")
Expand Down Expand Up @@ -237,7 +248,11 @@ def sync_sonic(device_name=None, task_id=None, show_diff=True):
# Export the generated configuration to local file (only if changed)
file_changed = export_config_to_file(device, sonic_config)

if netbox_changed or file_changed:
# Reconcile the per-device ZTP firmware symlink (only when a
# sonic_parameters.version is configured)
firmware_changed = export_firmware_link(device, version)

if netbox_changed or file_changed or firmware_changed:
logger.info(f"Configuration updated for device {device.name}")
else:
logger.info(f"No configuration changes for device {device.name}")
Expand Down
Loading