Skip to content
Merged
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
141 changes: 141 additions & 0 deletions docs/reference/introspection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Config Introspection

Networka provides config introspection to answer "where did this value come from?" - useful for debugging configuration issues when values are loaded from multiple sources.

## CLI Usage

Use the `--trace` flag with `nw info` to see the source of each configuration value:

```bash
# Show device info with source provenance
nw info sw-acc1 --trace
```

Example output:

```
Device: sw-acc1
Property Value Source
host 192.168.1.10 config/devices/switches.yml
device_type cisco_iosxe config/devices/_defaults.yml
user admin env: NW_USER_DEFAULT
port 22 default
```

## Source Types

The `Source` column shows where each value originated:

| Source Format | Description |
|---------------|-------------|
| `config/path/file.yml` | Value from a YAML config file |
| `config/path/file.yml:42` | Value from config file at specific line (reserved) |
| `env: NW_VAR_NAME` | Value from environment variable |
| `group: group-name` | Value inherited from device group |
| `ssh_config: ~/.ssh/config` | Value from SSH config file |
| `default` | Pydantic model default value |

Note: Line number tracking and some source types (dotenv, cli, interactive) are reserved for future use.

## Python API

For programmatic access, use the introspection classes directly.

### Querying Field History

```python
from network_toolkit.config import load_config

config = load_config("config/")
device = config.devices["sw-acc1"]

# Get the current source for a field
source = device.get_field_source("host")
if source:
print(f"host = {source.value}")
print(f" from: {source.format_source()}")
print(f" loader: {source.loader}")

# Get full history (all values that were set)
history = device.get_field_history("device_type")
for entry in history:
print(f" {entry.value} <- {entry.format_source()}")
```

### Core Classes

#### LoaderType

Enum identifying the source type:

```python
from network_toolkit.introspection import LoaderType

# Currently implemented
LoaderType.CONFIG_FILE # YAML/CSV config file
LoaderType.ENV_VAR # Environment variable
LoaderType.GROUP # Device group inheritance
LoaderType.SSH_CONFIG # SSH config file
LoaderType.PYDANTIC_DEFAULT # Model default

# Reserved for future use
LoaderType.DOTENV # .env file (planned)
LoaderType.CLI # CLI argument (planned)
LoaderType.INTERACTIVE # Interactive prompt (planned)
```

#### FieldHistory

Immutable record of a single field value assignment:

```python
from network_toolkit.introspection import FieldHistory, LoaderType

entry = FieldHistory(
field_name="host",
value="192.168.1.1",
loader=LoaderType.CONFIG_FILE,
identifier="config/devices/routers.yml",
line_number=15,
)

# Human-readable source string
print(entry.format_source()) # "config/devices/routers.yml:15"
```

#### ConfigHistory

Container tracking history for multiple fields:

```python
from network_toolkit.introspection import ConfigHistory, LoaderType

history = ConfigHistory()

# Record field values
history.record_field("host", "10.0.0.1", LoaderType.PYDANTIC_DEFAULT)
history.record_field("host", "192.168.1.1", LoaderType.CONFIG_FILE,
identifier="devices.yml")

# Query
current = history.get_current("host") # Most recent value
all_entries = history.get_history("host") # Full history
fields = history.get_all_fields() # All tracked field names
```

### ConfigHistoryMixin

`DeviceConfig`, `DeviceGroup`, and `GeneralConfig` all include the `ConfigHistoryMixin` which provides:

- `record_field(name, value, loader, identifier?, line_number?)` - Record a value
- `get_field_history(name)` - Get all historical values
- `get_field_source(name)` - Get the current (most recent) source

```python
device = config.devices["router1"]

# These methods come from ConfigHistoryMixin
source = device.get_field_source("transport_type")
if source:
print(f"transport_type came from {source.format_source()}")
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ nav:
- Reference:
- CLI: reference/cli.md
- API: reference/api.md
- Config Introspection: reference/introspection.md
- Project development: development.md
- Adding platform support: adding-platforms.md
- Documentation guidelines: documentation-guidelines.md
Expand Down
14 changes: 13 additions & 1 deletion src/network_toolkit/commands/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ def info(
verbose: Annotated[
bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
] = False,
trace: Annotated[
bool,
typer.Option(
"--trace",
"-t",
help="Show verbose provenance with full file paths and line numbers",
),
] = False,
interactive_auth: Annotated[
bool,
typer.Option(
Expand All @@ -81,6 +89,7 @@ def info(

Examples:
- nw info sw-acc1 # Show device info
- nw info sw-acc1 --trace # Show device info with source provenance
- nw info sw-acc1,sw-acc2 # Show multiple devices
- nw info access_switches # Show group info
- nw info system_info # Show sequence info (all vendors)
Expand Down Expand Up @@ -140,7 +149,7 @@ def info(

if target.type == "device":
_show_device_info(
target.name, config, ctx, interactive_creds, verbose
target.name, config, ctx, interactive_creds, verbose, trace
)
known_count += 1
elif target.type == "group":
Expand Down Expand Up @@ -224,6 +233,7 @@ def _show_device_info(
ctx: CommandContext,
interactive_creds: InteractiveCredentials | None,
verbose: bool,
trace: bool = False,
) -> None:
"""Show detailed information for a device."""
if not config.devices or device not in config.devices:
Expand All @@ -235,6 +245,8 @@ def _show_device_info(
device_name=device,
interactive_creds=interactive_creds,
config_path=ctx.config_file,
show_provenance=True,
verbose_provenance=trace,
)
ctx.render_table(provider, verbose)

Expand Down
30 changes: 30 additions & 0 deletions src/network_toolkit/commands/sync_ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

# Marker to identify hosts that were synced from SSH config
SSH_CONFIG_SOURCE_MARKER = "_ssh_config_source"
# Additional provenance marker for field-level tracking
SSH_CONFIG_PROVENANCE_MARKER = "_ssh_config_provenance"

# Default paths
DEFAULT_SSH_CONFIG = Path("~/.ssh/config")
Expand Down Expand Up @@ -203,15 +205,25 @@ def sync_ssh_config(
for name, ssh_host in ssh_hosts.items():
if name not in existing:
# New host - add with SSH config values
# Track which fields came from SSH config for introspection
provenance_fields = ["host"]
new_entry: dict[str, Any] = {
"host": ssh_host.hostname,
"device_type": default_device_type,
SSH_CONFIG_SOURCE_MARKER: name,
}
if ssh_host.user:
new_entry["user"] = ssh_host.user
provenance_fields.append("user")
if ssh_host.port:
new_entry["port"] = ssh_host.port
provenance_fields.append("port")
# Store field-level provenance
new_entry[SSH_CONFIG_PROVENANCE_MARKER] = {
"source_file": str(resolved_ssh_config),
"ssh_host_alias": name,
"fields": provenance_fields,
}
existing[name] = new_entry
added.append(name)
else:
Expand Down Expand Up @@ -253,7 +265,25 @@ def sync_ssh_config(

# Preserve: device_type, tags, description, platform, etc.

# Update provenance tracking for changed fields
if changes:
provenance = current.get(SSH_CONFIG_PROVENANCE_MARKER, {})
if not provenance:
provenance = {
"source_file": str(resolved_ssh_config),
"ssh_host_alias": name,
"fields": [],
}
# Update tracked fields
tracked_fields = set(provenance.get("fields", []))
for change in changes:
field_name = change.split()[0] # Handle "user (removed)"
if "(removed)" in change:
tracked_fields.discard(field_name)
else:
tracked_fields.add(field_name)
provenance["fields"] = list(tracked_fields)
current[SSH_CONFIG_PROVENANCE_MARKER] = provenance
updated.append((name, changes))
else:
unchanged.append(name)
Expand Down
Loading
Loading