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
87 changes: 87 additions & 0 deletions docs/switch-abstraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Switch abstraction - dynamic VLAN switching

Optional integration with the `switch-vlan` CLI from
[labgrid-switch-abstraction](https://github.com/fcefyn-testbed/labgrid-switch-abstraction)
for dynamic per-port VLAN management during multi-node tests.

When enabled, the `shared_vlan_multi` fixture (session-scoped) reconfigures
the managed switch before the test (moving all listed DUTs to a shared VLAN)
and restores the original topology on teardown. This is **opt-in**: labs
without a managed switch can ignore it entirely.

## Where `switch-vlan` runs

The fixture invokes the `switch-vlan` CLI via subprocess. Two execution modes:

| `LG_PROXY` | Command runs on | What you need locally |
|------------|----------------------------|------------------------------------------------------------------------|
| Set | The proxy/lab host via SSH | Just SSH access (no switch credentials, no `switch-vlan` install) |
| Unset | Current host | `switch-vlan` in PATH and a configured `switch.conf` / `dut-config.yaml` |

Remote developers therefore **do not need switch credentials** or any
switch-related install: the lab host owns the credentials, and the fixture
tunnels the command over SSH transparently.

## Setup (lab host only)

1. Install
[labgrid-switch-abstraction](https://github.com/fcefyn-testbed/labgrid-switch-abstraction)
on the lab host. This installs the `switch-vlan` CLI.

2. Configure `~/.config/switch.conf` (or `/etc/switch.conf` for multi-user
labs) with the switch credentials, and `dut-config.yaml` with the
DUT-to-port map. See the
[labgrid-switch-abstraction README](https://github.com/fcefyn-testbed/labgrid-switch-abstraction#readme)
for the full reference.

3. Verify on the lab host:

```bash
switch-vlan --help
```

## Setup (test runner)

1. Enable VLAN switching:

```bash
export VLAN_SWITCH_ENABLED=1
```

Without this variable (or set to `0`), the fixture is a no-op.

2. List the DUTs participating in the multi-node test:

```bash
export LG_MULTI_PLACES="labgrid-mylab-router_1,labgrid-mylab-router_2"
```

3. (Remote runs only) Set `LG_PROXY` to reach the lab host:

```bash
export LG_PROXY=labgrid-mylab
```

That is all. The fixture handles the rest.

## Optional configuration

| Env var | Purpose | Default |
|------------------|----------------------------------------------------------|--------------------------------------|
| `VLAN_SHARED` | Target VLAN ID for the multi-node test | 200 |
| `PLACE_PREFIX` | Prefix to strip from place names to derive DUT names | unset (strip up to second hyphen) |

## Place name mapping

DUT names passed to `switch-vlan` are derived from labgrid place names. By
default, everything up to and including the second hyphen is stripped:

```
labgrid-mylab-router_1 -> router_1
```

Override with `PLACE_PREFIX` if your lab uses a different convention:

```bash
export PLACE_PREFIX="labgrid-mylab-"
```
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import pytest

pytest_plugins = ["conftest_vlan"]

logger = logging.getLogger(__name__)

device = getenv("LG_ENV", "Unknown").split("/")[-1].split(".")[0]
Expand Down
185 changes: 185 additions & 0 deletions tests/conftest_vlan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""
Optional dynamic VLAN switching fixtures for multi-node tests.

When enabled, the ``shared_vlan_multi`` fixture moves all DUT ports listed
in ``LG_MULTI_PLACES`` to a shared VLAN before the test session and restores
them on teardown. Topology changes are delegated to the ``switch-vlan`` CLI
from `labgrid-switch-abstraction
<https://github.com/fcefyn-testbed/labgrid-switch-abstraction>`_.

Execution model
---------------

- **Remote** (``LG_PROXY`` set): the command is sent via SSH to the proxy
host. The lab host owns the switch credentials (``switch.conf``) and the
DUT-to-port map (``dut-config.yaml``). Developer machines need nothing
beyond SSH access to the proxy.
- **Local** (``LG_PROXY`` unset): ``switch-vlan`` runs on the current host.
Requires ``switch-vlan`` in PATH (``pip install labgrid-switch-abstraction``)
and a configured ``~/.config/switch.conf`` / ``dut-config.yaml``.

Activation
----------

Set ``VLAN_SWITCH_ENABLED=1`` to opt in. Default: skipped.

Configuration (env vars)
------------------------

- ``LG_MULTI_PLACES``: Comma-separated labgrid place names to switch.
- ``VLAN_SHARED``: Target VLAN ID for multi-node tests (default 200).
- ``PLACE_PREFIX``: Strip this prefix from place names to derive the DUT
name passed to ``switch-vlan``. If unset, everything
up to and including the second hyphen is stripped
(e.g. ``labgrid-mylab-router_1`` -> ``router_1``).

See ``docs/switch-abstraction.md`` for full setup instructions.
"""

import logging
import os
import shutil
import subprocess

import pytest

logger = logging.getLogger(__name__)

VLAN_SHARED_DEFAULT = 200
SSH_TIMEOUT = 30


def _is_enabled() -> bool:
return os.environ.get("VLAN_SWITCH_ENABLED", "").lower() in ("1", "true", "yes")


def _resolve_proxy_host() -> str | None:
"""Extract the SSH host from ``LG_PROXY`` (e.g. ``ssh://host`` -> ``host``)."""
raw = os.environ.get("LG_PROXY", "").strip()
if not raw:
return None
return raw.removeprefix("ssh://")


def _vlan_shared() -> int:
raw = os.environ.get("VLAN_SHARED", "").strip()
if not raw:
return VLAN_SHARED_DEFAULT
try:
return int(raw)
except ValueError:
logger.warning(
"Invalid VLAN_SHARED=%r, falling back to %d", raw, VLAN_SHARED_DEFAULT
)
return VLAN_SHARED_DEFAULT


def _place_to_dut_name(place: str) -> str:
"""Map a labgrid place name to the DUT name expected by ``switch-vlan``.

If ``PLACE_PREFIX`` is set it is stripped. Otherwise everything up to and
including the second hyphen is stripped (default openwrt-tests convention
``labgrid-<lab>-<dut>``).
"""
prefix = os.environ.get("PLACE_PREFIX", "")
if prefix and place.startswith(prefix):
return place[len(prefix) :]
parts = place.split("-", 2)
if len(parts) == 3:
return parts[2]
return place


def _run_switch_vlan(args: list[str]) -> bool:
"""Run ``switch-vlan`` via SSH to ``LG_PROXY``, or locally if no proxy is set.

Rationale: ``LG_PROXY`` indicates the lab is at a remote host, so the VLAN
command must run there (the proxy host owns switch credentials and the
DUT-to-port map). Only fall back to local execution when no proxy is set
(lab host or CI runner).

Returns ``True`` on success, ``False`` on failure (logged, never raises).
"""
proxy = _resolve_proxy_host()
if proxy:
cmd = ["ssh", proxy, "switch-vlan " + " ".join(args)]
elif shutil.which("switch-vlan"):
cmd = ["switch-vlan", *args]
else:
logger.warning(
"LG_PROXY not set and switch-vlan not in PATH; skipping VLAN command. "
"Install with: pip install labgrid-switch-abstraction"
)
return False

logger.debug("VLAN command: %s", cmd)
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=SSH_TIMEOUT,
)
except subprocess.TimeoutExpired:
logger.error("switch-vlan timed out after %ds: %s", SSH_TIMEOUT, cmd)
return False

if result.returncode != 0:
stderr = result.stderr.strip()
hint = ""
if "password required" in stderr.lower() and proxy:
hint = (
f"\nHint: switch-vlan ran on '{proxy}' as the SSH user. "
f"That user needs a readable switch.conf "
f"(per-user '~/.config/switch.conf' or system-wide "
f"'/etc/switch.conf' with group access). "
f"See docs/switch-abstraction.md."
)
logger.error(
"switch-vlan failed (rc=%d): %s\nstderr: %s%s",
result.returncode,
cmd,
stderr,
hint,
)
return False

logger.debug("switch-vlan stdout: %s", result.stdout.strip())
return True


@pytest.fixture(scope="session")
def shared_vlan_multi():
"""Switch all multi-node DUT ports to the shared VLAN.

Activates when ``VLAN_SWITCH_ENABLED=1`` and ``LG_MULTI_PLACES`` is set
(comma-separated). Session-scoped: switches once for the test session,
restores on teardown. Returns the list of DUT names that were switched.
"""
if not _is_enabled():
yield []
return

places_str = os.environ.get("LG_MULTI_PLACES", "")
if not places_str:
yield []
return

places = [p.strip() for p in places_str.split(",") if p.strip()]
dut_names = [_place_to_dut_name(p) for p in places]

vlan = _vlan_shared()
logger.info(
"Switching %d DUTs to shared VLAN %d: %s",
len(dut_names),
vlan,
dut_names,
)
ok = _run_switch_vlan([*dut_names, str(vlan)])
if not ok:
pytest.fail(f"VLAN switch to {vlan} failed for DUTs: {dut_names}")

yield dut_names

logger.info("Restoring %d DUTs to isolated VLANs: %s", len(dut_names), dut_names)
_run_switch_vlan([*dut_names, "--restore"])