Skip to content

RFE: Move switch CLI commands out of vendor plugins into configurable command templates #750

Open

Description

@kambiz-aghaiepour

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:

  1. pexpect drivers — src/quads/tools/external/arista.py and src/quads/tools/external/juniper.py embed 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").
  2. plugins — src/quads/plugins/builtin/switches/arista.py and juniper.py hard-code:

    • read-back commands (show running-config interfaces X, show configuration vlans | display set | match ...),
    • output parsing (arista _parse_vlan matches switchport access vlan / switchport trunk native vlan; juniper splits output lines),
    • error detection (arista: lines starting with %; juniper: expect commit 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 1000 vs API docs "speed": 10).

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_nic public-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_vendor tag 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.yml with a commands: 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-run prints the exact command sequence.
  • Recipes unit-testable: run a recipe against a fake pexpect channel.

Activity

  1. ttlogan commented on Oct 18, 2026

    @ttlogan
    Contributor

    Sensible direction. The commands are baked into src/quads/tools/external/{arista,juniper}.py plus 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 1000 vs the API speed: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions