Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.egg-info
.venv-sonic-e2e/
*.pyc
*.swp
__pycache__
Expand Down
33 changes: 33 additions & 0 deletions .zuul.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,37 @@
pre-run: playbooks/pre.yml
run: playbooks/test-integration.yml

# End-to-end golden test for the SONiC config generator: provisions NetBox
# with a docker compose stack, seeds it from the in-repo fixtures under
# tests/e2e/scenario/, runs sync_sonic() and compares the exported config_db
# files against tests/e2e/golden/. In check the job only runs when files
# that can change the generated output (or the harness itself) are touched;
# periodic-daily runs it unconditionally. netbox-manager is still pulled at
# tip-of-main via required-projects because it remains the seeding tool, so
# a Depends-On is honored for its code -- but its example/ seed data is no
# longer used, so this job no longer detects drift in that data.
- job:
name: python-osism-sonic-e2e
nodeset: ubuntu-noble
pre-run: playbooks/pre-sonic-e2e.yml
run: playbooks/test-sonic-e2e.yml
required-projects:
- osism/netbox-manager
timeout: 2400
files:
- ^\.zuul\.yaml$
- ^Makefile$
- ^Pipfile\.lock$
- ^files/sonic/.*
- ^osism/settings\.py$
- ^osism/tasks/conductor/.*
- ^osism/utils/.*
- ^playbooks/(pre-|test-)sonic-e2e\.yml$
- ^requirements\.ansible\.txt$
- ^requirements\.txt$
- ^setup\.cfg$
- ^tests/e2e/.*

- project:
merge-mode: squash-merge
default-branch: main
Expand All @@ -154,6 +185,7 @@
- python-osism-test-setup
- python-osism-unit-tests
- python-osism-integration-tests
- python-osism-sonic-e2e
periodic-daily:
jobs:
- flake8
Expand All @@ -163,6 +195,7 @@
- python-osism-test-setup
- python-osism-unit-tests
- python-osism-integration-tests
- python-osism-sonic-e2e
periodic-midnight:
jobs:
- container-image-python-osism-push
Expand Down
30 changes: 30 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
NETBOX_MANAGER_DIR ?= $(abspath ../netbox-manager)

# SONiC config-generation E2E golden test (see tests/e2e/sonic_golden_test.sh).

# Full cycle: start the NetBox compose stack (an existing stack is reused
# and left in place), seed, generate, compare against tests/e2e/golden/.
sonic-e2e:
NETBOX_MANAGER_DIR=$(NETBOX_MANAGER_DIR) tests/e2e/sonic_golden_test.sh

# Regenerate the golden files after an intentional generator change,
# then review and commit the diff.
sonic-e2e-regen:
NETBOX_MANAGER_DIR=$(NETBOX_MANAGER_DIR) tests/e2e/sonic_golden_test.sh --regenerate

# Start the NetBox stack and leave it running for debugging. Export a
# NETBOX_TOKEN beforehand to get a known API token minted.
sonic-e2e-up:
tests/e2e/deploy_netbox.sh

# Stop the NetBox stack and remove its volumes.
sonic-e2e-down:
docker compose -f tests/e2e/compose.yaml down --volumes --remove-orphans

# Report config_db table coverage of the golden set (tests/e2e/coverage.py).
# A reporting tool only -- not part of the gating check, which stays the
# golden comparison run by sonic-e2e above.
sonic-e2e-coverage:
pipenv run python -m tests.e2e.coverage

.PHONY: sonic-e2e sonic-e2e-regen sonic-e2e-up sonic-e2e-down sonic-e2e-coverage
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,60 @@ REDIS_HOST=localhost REDIS_DB=15 pipenv run pytest tests/integration
> above, or `OSISM_ALLOW_DEFAULT_REDIS_DB=1` if the Redis itself is disposable.
> `REDIS_DB` moves the direct client, the Celery broker and the result backend
> together.

## Running the SONiC E2E golden test

The end-to-end test in `tests/e2e/` provisions NetBox with a docker compose
stack, seeds it from the fixtures in `tests/e2e/scenario/`, generates the SONiC
`config_db.json` files and compares them against the goldens in
`tests/e2e/golden/`. Besides the development dependencies it needs docker with
the compose plugin, `openssl`, and a `netbox-manager` checkout for the seeding
CLI — a sibling directory by default, `NETBOX_MANAGER_DIR` otherwise.

```
pipenv install --dev
make sonic-e2e
```

A cold run takes roughly ten minutes, most of it starting NetBox. To iterate
without paying that each time, bring the stack up separately and leave it
running:

```
make sonic-e2e-up # start NetBox and leave it up; sonic-e2e reuses it
make sonic-e2e-down # stop it again and remove its volumes
```

After an intentional generator change, rewrite the goldens and review the diff
before committing it. Regeneration deliberately refuses to run against a stack
left over from an earlier run, because applying the fixtures over a populated
database can produce goldens that CI — which always starts fresh — would not
reproduce:

```
make sonic-e2e-down
make sonic-e2e-regen
```

How much of the generated config the golden set actually covers is reported
separately, because nothing in CI reports it:

```
make sonic-e2e-coverage
```

That compares the `config_db` tables the generator can emit against the tables
that are non-empty in at least one golden, and names any that no golden covers.
It exits non-zero while that list is non-empty, so it is worth running after
adding a scenario to confirm the new tables landed. It gates nothing on its own
— the golden comparison above is the only check that fails a run.

`tests/e2e/sonic_golden_test.sh` documents the remaining environment overrides
(`NETBOX_PORT`, `KEEP_STACK`, `SEED_PARALLEL` and the regeneration escape
hatch).

> **Warning:** Seeding applies *every* file under
> `tests/e2e/scenario/resources/`, tracked or not, so a stray file there joins
> the fixture set — which either breaks the run or silently changes the
> goldens. Check that directory with `git status --ignored` before regenerating
> or debugging a mismatch.
4 changes: 4 additions & 0 deletions osism/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ 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")

# Directory holding the per-HWSKU port_config .ini files (bundled in the
# repo under files/sonic/port_config and installed by the Dockerfile)
SONIC_PORT_CONFIG_PATH = os.getenv("SONIC_PORT_CONFIG_PATH", "/etc/sonic/port_config")

# SONiC ZTP firmware configuration
#
# The ZTP firmware install uses a dynamic-url built from
Expand Down
132 changes: 79 additions & 53 deletions osism/tasks/conductor/sonic/config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,11 @@ def generate_sonic_config(device, hwsku, device_as_mapping=None, config_version=
if breakout_info["breakout_cfgs"]:
config["BREAKOUT_CFG"].update(breakout_info["breakout_cfgs"])
if breakout_info["breakout_ports"]:
config["BREAKOUT_PORTS"].update(breakout_info["breakout_ports"])
# Project each entry to the SONiC schema ({port: {"master": <port>}});
# the full stash (declared/lanes/speed) stays in breakout_info for the
# PORT-building helpers and must not leak into the owned table.
for child, entry in breakout_info["breakout_ports"].items():
config["BREAKOUT_PORTS"][child] = {"master": entry["master"]}

# Add port channel configuration
_add_portchannel_configuration(config, portchannel_info)
Expand Down Expand Up @@ -609,33 +613,45 @@ def _add_port_configurations(
port_speed = sonic_speed

if port_name in breakout_info["breakout_ports"]:
bp = breakout_info["breakout_ports"][port_name]
# Get the master port to determine original speed and lanes
master_port = breakout_info["breakout_ports"][port_name]["master"]
master_port = bp["master"]

# Override with individual breakout port speed from NetBox if available
if port_name in netbox_interfaces and netbox_interfaces[port_name]["speed"]:
port_speed = str(int(netbox_interfaces[port_name]["speed"]))
logger.debug(
f"Using NetBox speed {port_speed} Mbps for breakout port {port_name}"
if bp.get("declared"):
# Declared-mode stash is authoritative: it takes precedence over
# the NetBox-speed override and the inferred lane calculation.
port_speed = str(bp["speed"])
port_lanes = bp["lanes"]
else:
# Override with individual breakout port speed from NetBox if available
if (
port_name in netbox_interfaces
and netbox_interfaces[port_name]["speed"]
):
port_speed = str(int(netbox_interfaces[port_name]["speed"]))
logger.debug(
f"Using NetBox speed {port_speed} Mbps for breakout port {port_name}"
)
elif master_port in breakout_info["breakout_cfgs"]:
# Fallback to extracting speed from breakout mode
brkout_mode = breakout_info["breakout_cfgs"][master_port][
"brkout_mode"
]
if "10G" in brkout_mode:
port_speed = "10000"
elif "25G" in brkout_mode:
port_speed = "25000"
elif "50G" in brkout_mode:
port_speed = "50000"
elif "100G" in brkout_mode:
port_speed = "100000"
elif "200G" in brkout_mode:
port_speed = "200000"

# Calculate individual lane for this breakout port
port_lanes = _calculate_breakout_port_lane(
port_name, master_port, port_config
)
elif master_port in breakout_info["breakout_cfgs"]:
# Fallback to extracting speed from breakout mode
brkout_mode = breakout_info["breakout_cfgs"][master_port]["brkout_mode"]
if "10G" in brkout_mode:
port_speed = "10000"
elif "25G" in brkout_mode:
port_speed = "25000"
elif "50G" in brkout_mode:
port_speed = "50000"
elif "100G" in brkout_mode:
port_speed = "100000"
elif "200G" in brkout_mode:
port_speed = "200000"

# Calculate individual lane for this breakout port
port_lanes = _calculate_breakout_port_lane(
port_name, master_port, port_config
)

# Generate correct alias based on port name and speed
interface_speed = int(port_speed) if port_speed else None
Expand Down Expand Up @@ -817,32 +833,47 @@ def _add_missing_breakout_ports(
for port_name in breakout_info["breakout_ports"]:
if port_name not in config["PORT"]:
# Get the master port to determine configuration
master_port = breakout_info["breakout_ports"][port_name]["master"]
bp = breakout_info["breakout_ports"][port_name]
master_port = bp["master"]

# Override with individual breakout port speed from NetBox if available
# Note: netbox_interfaces speeds are already normalized to Mbps
if port_name in netbox_interfaces and netbox_interfaces[port_name]["speed"]:
port_speed = str(int(netbox_interfaces[port_name]["speed"]))
logger.debug(
f"Using NetBox speed {port_speed} Mbps for missing breakout port {port_name}"
)
elif master_port in breakout_info["breakout_cfgs"]:
# Fallback to extracting speed from breakout mode
brkout_mode = breakout_info["breakout_cfgs"][master_port]["brkout_mode"]
if "10G" in brkout_mode:
port_speed = "10000"
elif "25G" in brkout_mode:
port_speed = "25000"
elif "50G" in brkout_mode:
port_speed = "50000"
elif "100G" in brkout_mode:
port_speed = "100000"
elif "200G" in brkout_mode:
port_speed = "200000"
if bp.get("declared"):
port_speed = str(bp["speed"])
port_lanes = bp["lanes"]
else:
# Override with individual breakout port speed from NetBox if available
# Note: netbox_interfaces speeds are already normalized to Mbps
if (
port_name in netbox_interfaces
and netbox_interfaces[port_name]["speed"]
):
port_speed = str(int(netbox_interfaces[port_name]["speed"]))
logger.debug(
f"Using NetBox speed {port_speed} Mbps for missing breakout port {port_name}"
)
elif master_port in breakout_info["breakout_cfgs"]:
# Fallback to extracting speed from breakout mode
brkout_mode = breakout_info["breakout_cfgs"][master_port][
"brkout_mode"
]
if "10G" in brkout_mode:
port_speed = "10000"
elif "25G" in brkout_mode:
port_speed = "25000"
elif "50G" in brkout_mode:
port_speed = "50000"
elif "100G" in brkout_mode:
port_speed = "100000"
elif "200G" in brkout_mode:
port_speed = "200000"
else:
port_speed = "25000" # Default fallback
else:
port_speed = "25000" # Default fallback
else:
port_speed = "25000" # Default fallback

# Calculate individual lane for this breakout port
port_lanes = _calculate_breakout_port_lane(
port_name, master_port, port_config
)

# Set admin_status based on connection or port channel membership
admin_status = (
Expand All @@ -865,11 +896,6 @@ def _add_missing_breakout_ports(
if master_port in port_config:
port_index = port_config[master_port]["index"]

# Calculate individual lane for this breakout port
port_lanes = _calculate_breakout_port_lane(
port_name, master_port, port_config
)

port_data = {
"admin_status": admin_status,
"alias": correct_alias,
Expand Down
4 changes: 3 additions & 1 deletion osism/tasks/conductor/sonic/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

"""Constants and mappings for SONiC configuration."""

from osism import settings

# Tag to add AF L2VPN EVPN to BGP neighbor
BGP_AF_L2VPN_EVPN_TAG = "bgp-af-l2vpn-evpn"

Expand Down Expand Up @@ -87,7 +89,7 @@
}

# Path to SONiC port configuration files
PORT_CONFIG_PATH = "/etc/sonic/port_config"
PORT_CONFIG_PATH = settings.SONIC_PORT_CONFIG_PATH

# List of supported vendors
SUPPORTED_VENDORS = [
Expand Down
Loading