Repository navigation
Description
Summary
Move switch CLI commands out of the vendor switch plugins so they are not hard-coded in Python, but defined as configurable command templates in a YAML config file. This makes adding a new switch OS (or adapting an existing one for a site-specific syntax) a config change rather than a code change, and it makes the exact commands a move will send reviewable and testable.
Current state
Switch commands are hard-coded in two layers per vendor:
-
pexpect drivers —
src/quads/tools/external/arista.pyandsrc/quads/tools/external/juniper.pyembed the full commit sequences:- Arista:
enable→configure terminal→default interface X→interface X→switchport mode access|dot1q-tunnel|trunk→switchport access vlan N→speed <N*1000>→exit→write memory(waits for "Copy completed"). - Juniper:
edit→rollback→delete interfaces X→set interfaces X apply-groups QinQ_vl<N>/native-vlan-id/interface-mode trunk/vlan members→commit(waits for "commit complete").
- Arista:
-
plugins —
src/quads/plugins/builtin/switches/arista.pyandjuniper.pyhard-code:- read-back commands (
show running-config interfaces X,show configuration vlans | display set | match ...), - output parsing (arista
_parse_vlanmatchesswitchport access vlan/switchport trunk native vlan; juniper splits output lines), - error detection (arista: lines starting with
%; juniper: expectcommit complete), - login prompt regexes (
[>#]vs>/#), - speed-unit conventions (arista:
N * 1000; juniper:{speed}g) — the unit semantics are currently inconsistent with docs (README--interface-speed 1000vs API docs"speed": 10).
- read-back commands (
Adding support for another switch OS today requires a new Python driver class and plugin logic, and any site-specific vendor syntax difference requires a code change.
Proposed design
1. New config file: conf/switch_commands.yml
Loaded via the existing Config.load_from_yaml mechanism (like conf/plugins.yml). Keyed by vendor → operation → ordered command list:
vendors:
arista:
connection:
ssh: "ssh -o StrictHostKeyChecking=no {username}@{switch_ip}"
login_prompt: "[>#]"
error_prefixes: ["%"]
operations:
set_port_access:
commands:
- "enable"
- "configure terminal"
- "default interface {switch_port}"
- "interface {switch_port}"
- "switchport mode access"
- "switchport access vlan {new_vlan}"
- "speed {speed}"
- "exit"
save: "write memory"
save_wait: "Copy completed"
set_port_qinq: ...
set_port_public: ...
get_vlan:
command: "show running-config interfaces {switch_port}"
parse:
pattern: "(?:switchport access vlan|switchport trunk native vlan) (\\d+)"
juniper:
connection:
ssh: "ssh -o StrictHostKeyChecking=no {username}@{switch_ip}"
login_prompt: "[>#]"
error_prefixes: ["error:", "syntax error"]
operations:
set_port_access:
commands:
- "edit"
- "rollback"
- "delete interfaces {switch_port}"
- "set interfaces {switch_port} apply-groups QinQ_vl{new_vlan}"
...
save: "commit"
save_wait: "commit complete"
...2. Generic executor
A single SwitchCLI / command-runner class (pexpect-based) replaces the per-vendor driver classes. It connects with the template, enters the operation's command list, waits on the vendor's prompt/success marker, checks error markers, parses read-back output with the recipe's regex, and performs the save/commit. No vendor-specific Python class is required — recipes are data.
3. Plugins become thin orchestrators
Plugins keep the logic that isn't vendor-CLI-specific:
- interface ownership (
owns_interface/_owns_legacy_interfaces), - cloud VLAN computation (
get_vlan/calculate_vlan,last_nicpublic-VLAN semantics, qinq flag), - the move / modify / verify control flow and host lookup,
- SSH transport (paramiko SSHHelper for read-back, or the recipe's transport).
Each plugin call resolves a recipe + params ({switch_port}, {new_vlan}, {old_vlan}, {speed}, {switch_ip}) and delegates the command execution to the runner. Adding a new switch OS = drop a YAML recipe for it (plus a minimal plugin class if ownership logic differs).
4. Validation and fail-closed semantics
- Schema-validate the YAML at load: known operation names, all placeholders resolvable, parse regex compiles, prompts/save markers present.
- If an operation's recipe is missing or malformed, refuse and fail the move with a clear error instead of silently skipping. (This also addresses the current fail-open behavior where a disabled/misspelled
switch_vendortag leaves a port untouched while move/validate/release still report success.) - Ship bundled default recipes for juniper and arista so behavior is identical out of the box; the config file can be omitted (defaults only) or user-supplied to override.
5. Bonus (low cost, high value)
A --dry-run mode on the runner that prints the exact command sequence a move/verify would send. Makes recipe changes reviewable and is a great debugging aid for switch-admin issues.
What stays in code
VLAN calculation, interface ownership routing, scheduling/validation flow, SSH helper. Only the CLI strings, prompt/error markers, save markers, and output parse patterns move to config. Putting speed conversion in the recipe (rather than N * 1000 in code) also lets the unit convention be documented and fixed per vendor/site.
Alternatives considered (rejected)
- netmiko / NAPALM — both still hard-code vendor behavior inside their libraries and add a heavy dependency; their config-command surface is coarser than per-op control and would not allow site-specific command templates without forking. A declarative recipe layer keeps quads self-contained.
- Extending
conf/plugins.ymlwith acommands:key per vendor — workable, but mixes command data into plugin config; a dedicated + shareable recipe file keeps templates independently reviewable and testable.
Acceptance criteria
- Adding/overriding a vendor's commands requires no Python change.
- juniper + arista moved to recipes first; behavior identical to current code (existing tests pass unchanged or with fixture-level updates).
- Malformed/missing recipe for a needed operation fails the move loudly (never silent skip).
-
--dry-runprints the exact command sequence. - Recipes unit-testable: run a recipe against a fake pexpect channel.
Activity
ttlogan commentedon Oct 18, 2026
Sensible direction. The commands are baked into
src/quads/tools/external/{arista,juniper}.pyplus the switch plugins today, so the config-as-data split is real, and the speed-unit drift you flag is real too (README--interface-speed 1000vs the APIspeed: 10).This overlaps with #748 (per-interface arista plugin + vendor routing). Worth landing the recipe layer alongside that work so the yml schema and the arista plugin don't drift apart. I'll circle back.