Skip to content

netops.change — Configuration Change Management

Semantic diff, change planning, safe push, and automated rollback.


netops.change.diff

Semantic-aware configuration diff engine.

Understands network device config structure rather than treating configs as plain text. Supports three input formats:

  • cisco — IOS/IOS-XE/IOS-XR indented hierarchical style
  • junos — JunOS set-format or bracketed hierarchical style
  • flat — one directive per line (Nokia SR-OS, simple key/value)

Three output formats are available:

  • unified — classic unified diff (compatible with patch(1))
  • semantic — human-readable tree view with parent context and highlights
  • json — machine-readable dict suitable for programmatic consumption

CLI usage:

python -m netops.change.diff --before before.txt --after after.txt
python -m netops.change.diff --before b.txt --after a.txt --format semantic
python -m netops.change.diff --before b.txt --after a.txt --format json

diff

Semantic-aware configuration diff engine.

Understands network device config structure rather than treating configs as plain text. Supports three input formats:

  • cisco – IOS/IOS-XE/IOS-XR indented hierarchical style
  • junos – JunOS set-format or bracketed hierarchical style
  • flat – one directive per line (Nokia SR-OS, simple key/value)

Three output formats are available:

  • unified – classic unified diff (compatible with patch(1))
  • semantic – human-readable tree view with parent context and highlights
  • json – machine-readable dict suitable for programmatic consumption

Usage::

# From files:
python -m netops.change.diff --before before.txt --after after.txt

# With format selection:
python -m netops.change.diff --before b.txt --after a.txt --format semantic

# JSON output (suitable for CI pipelines):
python -m netops.change.diff --before b.txt --after a.txt --format json

Public API::

from netops.change.diff import diff_configs, format_unified, format_semantic, format_json

result = diff_configs(before_text, after_text, style="cisco")
print(format_semantic(result))
Classes
ConfigStyle

Bases: str, Enum

Config syntax style used for hierarchical parsing.

Methods:
detect classmethod
detect(text: str) -> ConfigStyle

Heuristically detect the config style from text.

ChangeKind

Bases: str, Enum

Type of diff change.

ConfigNode dataclass
ConfigNode(key: str, raw: str, children: list[ConfigNode] = list(), depth: int = 0, is_security: bool = False)

One node in the hierarchical config tree.

For cisco style each node corresponds to a block header (e.g. interface GigabitEthernet0/0) or a leaf line inside that block.

For junos set-format each set … directive is stored as a flat node with its full path as key.

For flat style each non-blank, non-comment line is a leaf node.

Attributes
key instance-attribute
key: str

Canonical identifier for this node (stripped, normalised).

raw instance-attribute
raw: str

Original line as it appeared in the config (may include whitespace).

children class-attribute instance-attribute
children: list[ConfigNode] = field(default_factory=list)

Child nodes (sub-stanzas in Cisco hierarchical config).

depth class-attribute instance-attribute
depth: int = 0

Nesting depth (0 = top-level).

is_security class-attribute instance-attribute
is_security: bool = False

True when the line matches a security-sensitive pattern.

Methods:
__post_init__
__post_init__() -> None

Auto-detect security sensitivity when not explicitly set.

flat_lines
flat_lines() -> list[str]

Return all lines in this subtree as a flat list (DFS order).

signature
signature() -> str

Return a string that uniquely identifies this node's content.

For leaf nodes this is the stripped line itself. For block headers it is header + sorted(child signatures) so that reordering children (where order does not matter) does not produce a diff.

DiffEntry dataclass
DiffEntry(kind: ChangeKind, path: list[str], before_lines: list[str], after_lines: list[str], is_security: bool = False)

A single semantic diff entry.

Attributes
kind instance-attribute
kind: ChangeKind

Type of change.

path instance-attribute
path: list[str]

Breadcrumb path from the root to this node (list of key strings).

before_lines instance-attribute
before_lines: list[str]

Lines from the before config (for REMOVED / CHANGED).

after_lines instance-attribute
after_lines: list[str]

Lines from the after config (for ADDED / CHANGED).

is_security class-attribute instance-attribute
is_security: bool = False

True when any involved line is security-sensitive.

section property
section: str

Human-readable section label (deepest non-trivial breadcrumb).

DiffResult dataclass
DiffResult(style: ConfigStyle, entries: list[DiffEntry] = list(), before_text: str = '', after_text: str = '')

Container for the full diff between two configs.

Attributes
style instance-attribute
style: ConfigStyle

Parsing style used.

entries class-attribute instance-attribute
entries: list[DiffEntry] = field(default_factory=list)

All detected diff entries.

before_text class-attribute instance-attribute
before_text: str = ''

Original before config text (used by unified formatter).

after_text class-attribute instance-attribute
after_text: str = ''

Original after config text (used by unified formatter).

has_changes property
has_changes: bool

True when at least one non-unchanged entry exists.

security_changes property
security_changes: list[DiffEntry]

Return only entries that touch security-sensitive config.

added property
added: list[DiffEntry]

Return only entries representing newly added lines.

removed property
removed: list[DiffEntry]

Return only entries representing removed lines.

changed property
changed: list[DiffEntry]

Return only entries representing modified lines.

Functions:
parse_config
parse_config(text: str, style: ConfigStyle = ConfigStyle.CISCO) -> list[ConfigNode]

Parse text according to style and return a list of top-level nodes.

Parameters:

Name Type Description Default
text str

Raw configuration text.

required
style ConfigStyle

One of :class:ConfigStyle. Use ConfigStyle.detect(text) to auto-detect.

CISCO
diff_configs
diff_configs(before: str, after: str, *, style: ConfigStyle | None = None) -> DiffResult

Compare two config strings and return a :class:DiffResult.

Parameters:

Name Type Description Default
before str

The before (original / running) configuration text.

required
after str

The after (new / candidate) configuration text.

required
style ConfigStyle | None

Parsing style. When None (default) the style is auto-detected from before.

None
format_unified
format_unified(result: DiffResult, fromfile: str = 'before', tofile: str = 'after') -> str

Return a classic unified diff string.

Uses Python's :mod:difflib on the original text lines so the output is compatible with patch(1).

format_semantic
format_semantic(result: DiffResult) -> str

Return a human-readable semantic diff.

Each change is prefixed with its parent breadcrumb so the operator sees full context. Security-sensitive changes are marked with [SECURITY].

format_json
format_json(result: DiffResult) -> str

Return a JSON string representing the diff.

The structure is::

{
  "style": "cisco",
  "summary": {"added": 1, "removed": 0, "changed": 2, "security": 1},
  "entries": [
    {
      "kind": "added",
      "section": "interface GigabitEthernet0/0",
      "path": ["interface GigabitEthernet0/0"],
      "is_security": false,
      "before_lines": [],
      "after_lines": [" description WAN uplink"]
    },
    ...
  ]
}
main
main() -> None

CLI entry point for the semantic config diff engine.


netops.change.plan

Change planning — risk assessment, step ordering, and dry-run simulation.

CLI usage:

python -m netops.change.plan plan --host 10.0.0.1 --desired desired.cfg --export plan.json
python -m netops.change.plan apply --plan plan.json --approve

plan

Change approval workflow: plan → dry-run → review → approve → execute.

Workflow::

1. Call :func:`generate_plan` with the *desired* config text and the
   *current* (running) config text for one or more devices.
2. Inspect the returned :class:`ChangePlan`.  The plan includes a
   human-readable preview (semantic diff), risk score, and per-device
   :class:`ChangeStep` list.
3. Export the plan to JSON/YAML for offline review with
   :func:`export_plan`.
4. When approved, call :func:`apply_plan` (requires ``approved=True``).
   Dry-run mode never modifies any device.

Usage::

# Generate and preview a plan (dry-run, no device changes):
python -m netops.change.plan plan \\
    --host router1 --desired new_config.txt

# Export plan to file for offline review:
python -m netops.change.plan plan \\
    --host router1 --desired new_config.txt --export plan.json

# Apply a previously exported + approved plan:
python -m netops.change.plan apply --plan plan.json --approve

Public API::

from netops.change.plan import (
    generate_plan, apply_plan, export_plan, load_plan,
    ChangePlan, ChangeStep, RiskLevel, DeviceRole,
)
Classes
RiskLevel

Bases: str, Enum

Overall risk classification for a change plan.

DeviceRole

Bases: str, Enum

Criticality classification of a network device.

Roles are ordered from lowest (ACCESS) to highest (CORE) criticality. The role influences the risk score of any change on that device.

Attributes
weight property
weight: int

Return the numeric risk weight for this role (higher value = greater risk).

ChangeStep dataclass
ChangeStep(host: str, device_type: str, device_role: DeviceRole, commands: list[str], current_config: str = '', desired_config: str = '', diff_preview: str = '', unified_diff: str = '', has_security_changes: bool = False, applied: bool = False, error: str | None = None)

A single per-device step inside a :class:ChangePlan.

Attributes
host instance-attribute
host: str

Target device hostname or IP.

device_type instance-attribute
device_type: str

Netmiko device type string (e.g. cisco_ios).

device_role instance-attribute
device_role: DeviceRole

Criticality role of this device.

commands instance-attribute
commands: list[str]

Ordered list of configuration commands to apply.

current_config class-attribute instance-attribute
current_config: str = ''

Running config captured from the device (or provided as input).

desired_config class-attribute instance-attribute
desired_config: str = ''

Target config (full desired state — used when commands is empty).

diff_preview class-attribute instance-attribute
diff_preview: str = ''

Human-readable semantic diff preview (populated by :func:generate_plan).

unified_diff class-attribute instance-attribute
unified_diff: str = ''

Classic unified diff string (populated by :func:generate_plan).

has_security_changes class-attribute instance-attribute
has_security_changes: bool = False

True when the diff contains security-sensitive configuration lines.

applied class-attribute instance-attribute
applied: bool = False

True after this step has been successfully applied.

error class-attribute instance-attribute
error: str | None = None

Error message if this step failed during :func:apply_plan.

ChangePlan dataclass
ChangePlan(plan_id: str, created_at: str, operator: str, description: str, steps: list[ChangeStep] = list(), risk_level: RiskLevel = RiskLevel.LOW, risk_score: float = 0.0, dry_run: bool = True, approved: bool = False, applied_at: str | None = None, changelog_path: str | None = None)

Full change plan: metadata + one :class:ChangeStep per device.

Attributes
plan_id instance-attribute
plan_id: str

UUID for cross-system correlation. A fresh UUID is generated for each :func:generate_plan call. The structure (steps, diff, risk score) is fully reproducible given the same input — only the plan_id will differ between two calls with identical arguments.

created_at instance-attribute
created_at: str

ISO-8601 UTC timestamp when the plan was generated.

operator instance-attribute
operator: str

Human name / system that generated the plan.

description instance-attribute
description: str

Free-text description or ticket reference.

steps class-attribute instance-attribute
steps: list[ChangeStep] = field(default_factory=list)

Ordered list of per-device change steps.

risk_level class-attribute instance-attribute
risk_level: RiskLevel = RiskLevel.LOW

Overall risk level derived from scope and device criticality.

risk_score class-attribute instance-attribute
risk_score: float = 0.0

Numeric risk score (used to derive :attr:risk_level).

Score components:

  • device_weight — based on :class:DeviceRole (1–4)
  • change_scope — number of diff entries (adds + removes + changes)
  • security_bonus — +3 for each step that touches security config
  • multi_device_bonus — +2 when more than one device is in the plan
dry_run class-attribute instance-attribute
dry_run: bool = True

When True the plan was generated without connecting to any device.

approved class-attribute instance-attribute
approved: bool = False

Set to True before calling :func:apply_plan to authorise execution.

applied_at class-attribute instance-attribute
applied_at: str | None = None

ISO-8601 UTC timestamp when :func:apply_plan was called.

changelog_path class-attribute instance-attribute
changelog_path: str | None = None

Optional path to a JSON-lines changelog to append results to.

Functions:
generate_plan
generate_plan(steps_input: list[dict], *, operator: str = '', description: str = '', config_style: ConfigStyle | None = None) -> ChangePlan

Generate a :class:ChangePlan from desired-vs-current state.

Parameters:

Name Type Description Default
steps_input list[dict]

A list of dicts, one per device, with keys:

host (required) Target device hostname or IP. device_type (optional, default cisco_ios) Netmiko device-type string. device_role (optional, default unknown) One of the :class:DeviceRole values (string). commands (optional) List of configuration commands. Used as-is when current_config / desired_config are also provided; commands are derived automatically when only config texts are given. current_config (optional) The before config text. When omitted the diff preview will be empty. desired_config (optional) The after / target config text. Required when commands is not provided.

required
operator str

Human-readable name of the person or system generating the plan.

''
description str

Free-text plan description or ticket reference.

''
config_style ConfigStyle | None

Force a specific :class:ConfigStyle for diffing. When None (default) the style is auto-detected.

None

Returns:

Type Description
ChangePlan

A fully populated plan ready for export or review. The plan is never applied here — call :func:apply_plan for that.

apply_plan
apply_plan(plan: ChangePlan, *, connection_params: list[ConnectionParams] | None = None, approved: bool = False, changelog_path: Path | None = None) -> ChangePlan

Apply an approved :class:ChangePlan to the target devices.

Dry-run guarantee: if approved is False (the default) this function immediately returns the plan unchanged — no device is ever modified.

Parameters:

Name Type Description Default
plan ChangePlan

The plan to apply. Must have been generated by :func:generate_plan.

required
connection_params list[ConnectionParams] | None

A list of :class:~netops.core.connection.ConnectionParams, one per step, in the same order as plan.steps. Required when approved is True.

None
approved bool

Must be explicitly set to True to allow device modifications.

False
changelog_path Path | None

Optional path to a JSON-lines file. The applied plan dict is appended as a single line after successful completion.

None

Returns:

Type Description
ChangePlan

The same plan object with each step's applied / error fields updated.

export_plan
export_plan(plan: ChangePlan, path: Path, *, fmt: str = 'json') -> None

Write plan to path in JSON or YAML format.

Parameters:

Name Type Description Default
plan ChangePlan

The plan to serialise.

required
path Path

Destination file path. Parent directories are created if needed.

required
fmt str

"json" (default) or "yaml".

'json'

Raises:

Type Description
ValueError

When fmt is not "json" or "yaml".

load_plan
load_plan(path: Path) -> ChangePlan

Load a :class:ChangePlan from a JSON or YAML file.

The format is auto-detected from the file extension: .yaml / .yml → YAML; everything else → JSON.

Parameters:

Name Type Description Default
path Path

Path to the exported plan file.

required

Returns:

Type Description
ChangePlan

Deserialised plan.

Raises:

Type Description
FileNotFoundError

When path does not exist.

main
main() -> None

CLI entry point for the change-plan generator and applier.


netops.change.push

Safe configuration push — pre/post health validation with automatic rollback.

CLI usage:

python -m netops.change.push --host 10.0.0.1 --vendor cisco_ios --commands changes.txt
python -m netops.change.push --host 10.0.0.1 --vendor cisco_ios --commands changes.txt --commit
python -m netops.change.rollback --host 10.0.0.1 --vendor cisco_ios --commands changes.txt \
    --commit --rollback-on-failure --validate-health

push

Safe configuration push with pre/post diff and auto-rollback confirm timer.

Workflow:

  1. Connect to the device and snapshot the running config (pre-change).
  2. Optionally push the given commands (requires --commit flag; dry-run by default).
  3. Snapshot the config again (post-change) and compute a unified diff.
  4. If --confirm-timer N is set, start a countdown. The operator must type confirm within N minutes or the pre-change config is restored (rollback).
  5. Append a structured entry to a JSON-lines change log.

Usage::

# Dry-run (default — no changes pushed):
python -m netops.change.push --host router1 --commands changes.txt

# Commit with 5-minute confirm timer:
python -m netops.change.push --host router1 --commands changes.txt \\
    --commit --confirm-timer 5
Classes
ChangeRecord dataclass
ChangeRecord(host: str, operator: str, started_at: str, commands: list[str], pre_config: str, post_config: str | None = None, diff: str | None = None, committed: bool = False, confirmed: bool = False, rolled_back: bool = False, error: str | None = None)

Captures every meaningful attribute of a single config-push event.

Functions:
run_push
run_push(params: ConnectionParams, commands: list[str], *, commit: bool = False, confirm_timer_minutes: int = 0, operator: str = '', changelog_path: Path | None = None) -> ChangeRecord

Execute the full safe-push workflow.

Parameters:

Name Type Description Default
params ConnectionParams

Connection parameters for the target device.

required
commands list[str]

Ordered list of configuration commands to apply.

required
commit bool

When False (default) snapshot + diff are generated but nothing is pushed to the device.

False
confirm_timer_minutes int

If > 0 the operator must confirm within this many minutes after a successful push or the pre-change config is restored.

0
operator str

Human-readable identifier of the person or system executing the change.

''
changelog_path Path | None

Optional path to a JSON-lines changelog file. Each call appends one record.

None
append_changelog
append_changelog(record: ChangeRecord, path: Path) -> None

Append record as a JSON object to a newline-delimited log file.

load_changelog
load_changelog(path: Path) -> list[dict]

Return all change records from path as a list of dicts.

Returns:

Type Description
list

All change records from path as a list of dicts.

main
main() -> None

CLI entry point for safe config-push with pre/post diff and optional auto-rollback.


netops.change.rollback

Automated rollback — pre-snapshot + health monitoring with rollback on degradation.

CLI usage:

python -m netops.change.rollback --host 10.0.0.1 --vendor cisco_ios --commands changes.txt \
    --commit --rollback-on-failure --validate-health

rollback

Automated rollback with pre/post health validation.

Workflow:

  1. Connect to the device and capture:
  2. Running configuration (pre-change snapshot)
  3. Health-check baseline (CPU, memory, interface errors, logs)
  4. Optionally save the snapshot via backup integration.
  5. Apply the configuration change (requires --commit; dry-run by default).
  6. Re-run health checks and capture a post-change snapshot + unified diff.
  7. Compare pre/post health:
  8. Any alert that was not firing before the change → validation FAILED.
  9. Device unreachable after the change → validation FAILED.
  10. On failure (when --rollback-on-failure is set):
  11. Restore the pre-change configuration automatically.
  12. Write a structured entry to a JSON-lines audit log (who/what/when/why).

Usage::

# Dry-run (default — no changes pushed):
python -m netops.change.rollback --host router1 --commands changes.txt

# Commit with health validation and auto-rollback on failure:
python -m netops.change.rollback --host router1 --commands changes.txt \\
    --commit --rollback-on-failure --validate-health

# Include snapshot backup and custom thresholds:
python -m netops.change.rollback --host router1 --commands changes.txt \\
    --commit --rollback-on-failure --validate-health \\
    --snapshot-dir /var/backups/network --cpu-threshold 70
Classes
RollbackRecord dataclass
RollbackRecord(change_id: str, host: str, operator: str, reason: str, started_at: str, commands: list[str], pre_config: str = '', post_config: str | None = None, diff: str | None = None, pre_health: dict | None = None, post_health: dict | None = None, committed: bool = False, validation_passed: bool | None = None, rolled_back: bool = False, rollback_reason: str | None = None, snapshot_path: str | None = None, completed_at: str | None = None, error: str | None = None)

Full audit record for a single change-with-rollback event.

Functions:
run_rollback_push
run_rollback_push(params: ConnectionParams, commands: list[str], *, commit: bool = False, validate_health: bool = True, rollback_on_failure: bool = True, cpu_threshold: float = DEFAULT_CPU_THRESHOLD, mem_threshold: float = DEFAULT_MEM_THRESHOLD, operator: str = '', reason: str = '', audit_log_path: Path | None = None, snapshot_dir: Path | None = None) -> RollbackRecord

Execute a configuration change with pre/post health validation.

Parameters:

Name Type Description Default
params ConnectionParams

Connection parameters for the target device.

required
commands list[str]

Ordered list of configuration commands to apply.

required
commit bool

When False (default) only a pre-change snapshot (and health check when validate_health is True) are collected — nothing is pushed.

False
validate_health bool

When True run health checks before and after the change and compare them. Any alert that was not present before the change causes validation to fail.

True
rollback_on_failure bool

When True automatically restore the pre-change config if validation fails.

True
cpu_threshold float

CPU alert threshold percentage forwarded to :func:run_health_check.

DEFAULT_CPU_THRESHOLD
mem_threshold float

Memory alert threshold percentage forwarded to :func:run_health_check.

DEFAULT_MEM_THRESHOLD
operator str

Human-readable identifier of the person or system executing the change.

''
reason str

Change rationale or ticket reference written to the audit log.

''
audit_log_path Path | None

Optional path to a JSON-lines audit log. One record is appended per call, even when the change or rollback fails.

None
snapshot_dir Path | None

Optional directory where pre-change snapshots are saved via the backup integration.

None
append_audit_log
append_audit_log(record: RollbackRecord, path: Path) -> None

Append record as a JSON object to a newline-delimited audit log file.

load_audit_log
load_audit_log(path: Path) -> list[dict]

Return all audit records from path as a list of dicts.

Returns:

Type Description
list

All audit records from path as a list of dicts.

main
main() -> None

CLI entry point for config push with health-validated auto-rollback.