sfacts 2.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sfacts/__init__.py +17 -0
- sfacts/cli/__init__.py +8 -0
- sfacts/cli/discover.py +182 -0
- sfacts/cli/export.py +177 -0
- sfacts/cli/facts.py +219 -0
- sfacts/cli/main.py +39 -0
- sfacts/cli/netbox.py +705 -0
- sfacts/core/__init__.py +9 -0
- sfacts/core/collector.py +342 -0
- sfacts/core/discovery.py +300 -0
- sfacts/core/inventory.py +337 -0
- sfacts/core/netbox/__init__.py +18 -0
- sfacts/core/netbox/base_objects.py +194 -0
- sfacts/core/netbox/bulk_helpers.py +164 -0
- sfacts/core/netbox/bulk_sync.py +858 -0
- sfacts/core/netbox/client.py +102 -0
- sfacts/core/netbox/device_sync.py +361 -0
- sfacts/core/netbox/interface_sync.py +199 -0
- sfacts/core/netbox/ipam_sync.py +516 -0
- sfacts/core/netbox/mac_sync.py +183 -0
- sfacts/core/netbox/primary_mac_assignment.py +75 -0
- sfacts/core/netbox/vrf_sync.py +171 -0
- sfacts/core/scanner.py +389 -0
- sfacts/deployments.py +228 -0
- sfacts/flows/__init__.py +22 -0
- sfacts/flows/discovery_only.py +198 -0
- sfacts/flows/full_inventory.py +245 -0
- sfacts/flows/modular_flows.py +1036 -0
- sfacts/tasks/__init__.py +23 -0
- sfacts/tasks/collect.py +43 -0
- sfacts/tasks/discover.py +88 -0
- sfacts/tasks/export.py +41 -0
- sfacts/tasks/netbox.py +285 -0
- sfacts/utils/__init__.py +7 -0
- sfacts/utils/auth.py +242 -0
- sfacts/utils/logger.py +100 -0
- sfacts-2.3.0.dist-info/METADATA +414 -0
- sfacts-2.3.0.dist-info/RECORD +41 -0
- sfacts-2.3.0.dist-info/WHEEL +4 -0
- sfacts-2.3.0.dist-info/entry_points.txt +2 -0
- sfacts-2.3.0.dist-info/licenses/LICENSE +21 -0
sfacts/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFacts - Network Discovery and Fact Collection Tool
|
|
3
|
+
|
|
4
|
+
A modern network automation tool leveraging proven libraries:
|
|
5
|
+
- Netmiko for SSH connections and auto-detection
|
|
6
|
+
- NAPALM for vendor-agnostic device interaction
|
|
7
|
+
- ntc-templates for TextFSM parsing
|
|
8
|
+
- Nornir for parallel execution
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "2.3.0"
|
|
12
|
+
__author__ = "Milan Zapletal"
|
|
13
|
+
|
|
14
|
+
from .core.discovery import NetworkDiscovery
|
|
15
|
+
from .core.scanner import SubnetScanner
|
|
16
|
+
|
|
17
|
+
__all__ = ["NetworkDiscovery", "SubnetScanner"]
|
sfacts/cli/__init__.py
ADDED
sfacts/cli/discover.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discovery command for network device scanning and identification.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..core.discovery import NetworkDiscovery
|
|
12
|
+
from ..utils.auth import CredentialManager
|
|
13
|
+
from ..utils.logger import get_cli_logger
|
|
14
|
+
|
|
15
|
+
logger = get_cli_logger()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command()
|
|
19
|
+
@click.option("--subnet", help="Subnet to scan (e.g., 192.168.1.0/24)")
|
|
20
|
+
@click.option("--tunnel-host", help="SSH tunnel host (e.g., localhost for testing)")
|
|
21
|
+
@click.option("--tunnel-ports", help="SSH tunnel port range (e.g., 2101-2116)")
|
|
22
|
+
@click.option("--username", help="SSH username (or set NETOPS_USERNAME env var)")
|
|
23
|
+
@click.option("--password", help="SSH password (or set NETOPS_PASSWORD env var)")
|
|
24
|
+
@click.option("--secret", help="Enable secret (optional, or set NETOPS_SECRET env var)")
|
|
25
|
+
@click.option("--output", help="Output file (default: disco_results_TIMESTAMP.json)")
|
|
26
|
+
@click.option("--output-dir", help="Output directory for results (creates if not exists)")
|
|
27
|
+
@click.option("--timeout", default=10, help="SSH timeout in seconds")
|
|
28
|
+
@click.option("--workers", default=20, help="Number of parallel workers")
|
|
29
|
+
@click.option("--no-save", is_flag=True, help="Do not save results to file")
|
|
30
|
+
def discover(
|
|
31
|
+
subnet: Optional[str],
|
|
32
|
+
tunnel_host: Optional[str],
|
|
33
|
+
tunnel_ports: Optional[str],
|
|
34
|
+
username: Optional[str],
|
|
35
|
+
password: Optional[str],
|
|
36
|
+
secret: Optional[str],
|
|
37
|
+
output: Optional[str],
|
|
38
|
+
output_dir: Optional[str],
|
|
39
|
+
timeout: int,
|
|
40
|
+
workers: int,
|
|
41
|
+
no_save: bool,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Discover network devices and identify them using Netmiko SSHDetect.
|
|
45
|
+
|
|
46
|
+
This command supports two modes:
|
|
47
|
+
1. Subnet scanning: Scans subnet for devices with open SSH ports
|
|
48
|
+
2. SSH tunnel mode: Tests devices through SSH tunnel (localhost + port range)
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
|
|
52
|
+
# Basic subnet discovery
|
|
53
|
+
sfacts discover --subnet 192.168.1.0/24
|
|
54
|
+
|
|
55
|
+
# Save results to specific directory
|
|
56
|
+
sfacts discover --subnet 192.168.1.0/24 --output-dir /path/to/results
|
|
57
|
+
|
|
58
|
+
# SSH tunnel mode for testing
|
|
59
|
+
sfacts discover --tunnel-host localhost --tunnel-ports 2101-2116
|
|
60
|
+
|
|
61
|
+
# High-performance scanning with custom output
|
|
62
|
+
sfacts discover --subnet 10.0.0.0/16 --workers 50 --timeout 15 --output-dir ./network_scans
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
logger.info(" SFacts - Network Discovery Tool")
|
|
66
|
+
|
|
67
|
+
# Validate mode selection
|
|
68
|
+
if not subnet and not (tunnel_host and tunnel_ports):
|
|
69
|
+
logger.error("Either --subnet or both --tunnel-host and --tunnel-ports must be specified")
|
|
70
|
+
raise click.Abort()
|
|
71
|
+
|
|
72
|
+
if subnet and (tunnel_host or tunnel_ports):
|
|
73
|
+
logger.error("Cannot use both subnet and tunnel mode simultaneously")
|
|
74
|
+
raise click.Abort()
|
|
75
|
+
|
|
76
|
+
# Display mode
|
|
77
|
+
if subnet:
|
|
78
|
+
logger.info(f"Mode: Subnet scanning - {subnet}")
|
|
79
|
+
else:
|
|
80
|
+
logger.info(f"Mode: SSH tunnel - {tunnel_host} ports {tunnel_ports}")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Get credentials
|
|
84
|
+
cred_manager = CredentialManager()
|
|
85
|
+
credentials = cred_manager.get_credentials(
|
|
86
|
+
username=username, password=password, secret=secret, prompt=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Validate credentials
|
|
90
|
+
if not cred_manager.validate_credentials(credentials):
|
|
91
|
+
logger.error("Invalid credentials provided")
|
|
92
|
+
raise click.Abort()
|
|
93
|
+
|
|
94
|
+
logger.success(f"Using credentials for user: {credentials['username']}")
|
|
95
|
+
|
|
96
|
+
# Initialize discovery engine
|
|
97
|
+
discovery = NetworkDiscovery(credentials=credentials, timeout=timeout, max_workers=workers)
|
|
98
|
+
|
|
99
|
+
# Perform discovery based on mode
|
|
100
|
+
if subnet:
|
|
101
|
+
# Store subnet for results
|
|
102
|
+
discovery._last_subnet = subnet
|
|
103
|
+
|
|
104
|
+
logger.info("\nDiscovery Parameters:")
|
|
105
|
+
logger.info(f" • Timeout: {timeout}s")
|
|
106
|
+
logger.info(f" • Workers: {workers}")
|
|
107
|
+
logger.info(f" • Target: {subnet}")
|
|
108
|
+
|
|
109
|
+
devices = discovery.discover_subnet(subnet)
|
|
110
|
+
else:
|
|
111
|
+
# SSH tunnel mode
|
|
112
|
+
logger.info("\nTunnel Discovery Parameters:")
|
|
113
|
+
logger.info(f" • Timeout: {timeout}s")
|
|
114
|
+
logger.info(f" • Workers: {workers}")
|
|
115
|
+
logger.info(f" • Host: {tunnel_host}")
|
|
116
|
+
logger.info(f" • Ports: {tunnel_ports}")
|
|
117
|
+
|
|
118
|
+
assert tunnel_host is not None and tunnel_ports is not None
|
|
119
|
+
devices = discovery.discover_tunnel(tunnel_host, tunnel_ports)
|
|
120
|
+
|
|
121
|
+
# Display results
|
|
122
|
+
if devices:
|
|
123
|
+
logger.success("Discovery completed successfully!")
|
|
124
|
+
logger.success(f"Found {len(devices)} network devices")
|
|
125
|
+
|
|
126
|
+
# Show device details
|
|
127
|
+
logger.info("\nDiscovered Devices:")
|
|
128
|
+
for device in devices:
|
|
129
|
+
logger.info(f" • {device['host']} - {device['device_type']}")
|
|
130
|
+
else:
|
|
131
|
+
logger.warning(f"\n No devices found in {subnet}")
|
|
132
|
+
logger.warning("This could mean:")
|
|
133
|
+
logger.warning(" • No devices have SSH enabled")
|
|
134
|
+
logger.warning(" • Firewall is blocking SSH access")
|
|
135
|
+
logger.warning(" • Credentials are incorrect")
|
|
136
|
+
|
|
137
|
+
# Save results unless disabled
|
|
138
|
+
if not no_save:
|
|
139
|
+
# Handle output directory and filename
|
|
140
|
+
if output_dir:
|
|
141
|
+
# Create output directory if it doesn't exist
|
|
142
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
if output:
|
|
145
|
+
# If both output-dir and output are specified, combine them
|
|
146
|
+
output_path = os.path.join(output_dir, os.path.basename(output))
|
|
147
|
+
else:
|
|
148
|
+
# Generate default filename in the specified directory
|
|
149
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
150
|
+
output_path = os.path.join(output_dir, f"disco_results_{timestamp}.json")
|
|
151
|
+
else:
|
|
152
|
+
# Use output as-is or generate default in current directory
|
|
153
|
+
if not output:
|
|
154
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
155
|
+
output_path = f"disco_results_{timestamp}.json"
|
|
156
|
+
else:
|
|
157
|
+
output_path = output
|
|
158
|
+
|
|
159
|
+
discovery.save_results(output_path)
|
|
160
|
+
|
|
161
|
+
# Show file info
|
|
162
|
+
if os.path.exists(output_path):
|
|
163
|
+
file_size = os.path.getsize(output_path)
|
|
164
|
+
logger.success(f"Results saved to {output_path}")
|
|
165
|
+
logger.info(f" File size: {file_size:,} bytes")
|
|
166
|
+
|
|
167
|
+
# Show next steps
|
|
168
|
+
if devices:
|
|
169
|
+
logger.info("\nNext Steps:")
|
|
170
|
+
logger.info("• Phase 2: Fact collection (coming soon)")
|
|
171
|
+
logger.info("• Review the JSON output for detailed device information")
|
|
172
|
+
logger.info("• Use the discovered devices for network automation tasks")
|
|
173
|
+
|
|
174
|
+
logger.success("\n✅ Discovery process completed")
|
|
175
|
+
|
|
176
|
+
except KeyboardInterrupt:
|
|
177
|
+
logger.warning("Discovery interrupted by user")
|
|
178
|
+
raise click.Abort() from None
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"✗ Discovery failed: {str(e)}")
|
|
181
|
+
logger.error("Aborted!")
|
|
182
|
+
raise click.Abort() from e
|
sfacts/cli/export.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI command for exporting inventory from collected facts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from ..core.inventory import NornirInventoryExporter
|
|
15
|
+
from ..utils.logger import get_cli_logger
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
logger = get_cli_logger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command()
|
|
22
|
+
@click.option(
|
|
23
|
+
"--input-file",
|
|
24
|
+
"-i",
|
|
25
|
+
type=click.Path(exists=True, path_type=Path),
|
|
26
|
+
help="Input facts JSON file to export from",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--output-dir",
|
|
30
|
+
"-o",
|
|
31
|
+
default="inventory",
|
|
32
|
+
type=click.Path(path_type=Path),
|
|
33
|
+
help="Output directory for inventory files (default: inventory)",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--format",
|
|
37
|
+
"export_format",
|
|
38
|
+
type=click.Choice(["nornir"]),
|
|
39
|
+
default="nornir",
|
|
40
|
+
help="Export format (default: nornir)",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--include-credentials/--no-credentials",
|
|
44
|
+
default=False,
|
|
45
|
+
help="Include default credentials in inventory (default: no)",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--latest/--no-latest",
|
|
49
|
+
default=True,
|
|
50
|
+
help="Use latest facts file if no input specified (default: yes)",
|
|
51
|
+
)
|
|
52
|
+
def export_inventory(
|
|
53
|
+
input_file: Optional[Path],
|
|
54
|
+
output_dir: Path,
|
|
55
|
+
export_format: str,
|
|
56
|
+
include_credentials: bool,
|
|
57
|
+
latest: bool,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Export collected device facts to inventory format.
|
|
61
|
+
|
|
62
|
+
Converts facts collection results into network automation inventory
|
|
63
|
+
formats like Nornir for use with automation frameworks.
|
|
64
|
+
"""
|
|
65
|
+
console.print(
|
|
66
|
+
Panel.fit(
|
|
67
|
+
"[bold blue]Simple Facts - Inventory Export[/bold blue]",
|
|
68
|
+
border_style="blue",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
# Determine input file
|
|
74
|
+
if not input_file:
|
|
75
|
+
if latest:
|
|
76
|
+
input_file = _find_latest_facts_file()
|
|
77
|
+
if not input_file:
|
|
78
|
+
console.print("[red]❌ No facts files found in output/ directory[/red]")
|
|
79
|
+
raise click.Abort()
|
|
80
|
+
console.print(f"[green]📁 Using latest facts file: {input_file}[/green]")
|
|
81
|
+
else:
|
|
82
|
+
console.print("[red]❌ No input file specified[/red]")
|
|
83
|
+
raise click.Abort()
|
|
84
|
+
|
|
85
|
+
# Load facts data
|
|
86
|
+
console.print(f"[blue]📖 Loading facts from {input_file}...[/blue]")
|
|
87
|
+
with open(input_file) as f:
|
|
88
|
+
facts_data = json.load(f)
|
|
89
|
+
|
|
90
|
+
# Validate facts data
|
|
91
|
+
if "devices" not in facts_data:
|
|
92
|
+
console.print("[red]❌ Invalid facts file format - missing 'devices' key[/red]")
|
|
93
|
+
raise click.Abort()
|
|
94
|
+
|
|
95
|
+
devices_raw = facts_data["devices"]
|
|
96
|
+
# Convert dict to list and filter successful devices
|
|
97
|
+
successful_devices = []
|
|
98
|
+
for _device_key, device_data in devices_raw.items():
|
|
99
|
+
if isinstance(device_data, dict):
|
|
100
|
+
connection_info = device_data.get("connection_info", {})
|
|
101
|
+
if connection_info.get("connection_successful", False):
|
|
102
|
+
successful_devices.append(device_data)
|
|
103
|
+
|
|
104
|
+
if not successful_devices:
|
|
105
|
+
console.print("[red]❌ No successful device collections found in facts file[/red]")
|
|
106
|
+
raise click.Abort()
|
|
107
|
+
|
|
108
|
+
console.print(f"[green]✅ Found {len(successful_devices)} devices to export[/green]")
|
|
109
|
+
|
|
110
|
+
# Export based on format
|
|
111
|
+
if export_format == "nornir":
|
|
112
|
+
exporter = NornirInventoryExporter()
|
|
113
|
+
|
|
114
|
+
console.print(f"[blue]🔄 Exporting to Nornir format in {output_dir}/...[/blue]")
|
|
115
|
+
files_created = exporter.export_inventory(
|
|
116
|
+
facts_data, str(output_dir), include_credentials
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Display results
|
|
120
|
+
_display_export_results(files_created, exporter, facts_data)
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
console.print(f"[red]❌ Unsupported export format: {export_format}[/red]")
|
|
124
|
+
raise click.Abort()
|
|
125
|
+
|
|
126
|
+
console.print("\n[bold green]🎉 Inventory export completed successfully![/bold green]")
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
console.print(f"[red]❌ Export failed: {e}[/red]")
|
|
130
|
+
raise RuntimeError(f"Export failed: {e}") from e
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _find_latest_facts_file() -> Optional[Path]:
|
|
134
|
+
"""Find the most recent facts file in output directory."""
|
|
135
|
+
output_dir = Path("output")
|
|
136
|
+
if not output_dir.exists():
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
facts_files = list(output_dir.glob("facts_results_*.json"))
|
|
140
|
+
if not facts_files:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# Sort by modification time, newest first
|
|
144
|
+
return max(facts_files, key=lambda f: f.stat().st_mtime)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _display_export_results(
|
|
148
|
+
files_created: dict, exporter: NornirInventoryExporter, facts_data: dict
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Display export results in a formatted table."""
|
|
151
|
+
|
|
152
|
+
# Files created table
|
|
153
|
+
table = Table(title="📁 Files Created", show_header=True, header_style="bold blue")
|
|
154
|
+
table.add_column("File Type", style="cyan")
|
|
155
|
+
table.add_column("Path", style="green")
|
|
156
|
+
table.add_column("Size", style="yellow")
|
|
157
|
+
|
|
158
|
+
for file_type, file_path in files_created.items():
|
|
159
|
+
file_size = Path(file_path).stat().st_size
|
|
160
|
+
size_str = f"{file_size:,} bytes"
|
|
161
|
+
table.add_row(file_type.title(), file_path, size_str)
|
|
162
|
+
|
|
163
|
+
console.print(table)
|
|
164
|
+
|
|
165
|
+
# Summary report
|
|
166
|
+
summary = exporter.generate_summary_report(facts_data)
|
|
167
|
+
console.print(
|
|
168
|
+
Panel(
|
|
169
|
+
summary,
|
|
170
|
+
title="📊 Export Summary",
|
|
171
|
+
border_style="green",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
export_inventory()
|
sfacts/cli/facts.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Facts collection command for detailed device information gathering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ..core.discovery import NetworkDiscovery
|
|
13
|
+
from ..utils.auth import CredentialManager
|
|
14
|
+
from ..utils.logger import get_collector_logger
|
|
15
|
+
|
|
16
|
+
logger = get_collector_logger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command()
|
|
20
|
+
@click.option("--input", "input_file", help="Input discovery file (JSON)")
|
|
21
|
+
@click.option("--subnet", help="Subnet to discover and collect facts from")
|
|
22
|
+
@click.option("--username", help="SSH username (or set NETOPS_USERNAME env var)")
|
|
23
|
+
@click.option("--password", help="SSH password (or set NETOPS_PASSWORD env var)")
|
|
24
|
+
@click.option("--secret", help="Enable secret (optional, or set NETOPS_SECRET env var)")
|
|
25
|
+
@click.option("--output", help="Output file (default: facts_results_TIMESTAMP.json)")
|
|
26
|
+
@click.option("--output-dir", help="Output directory for results (creates if not exists)")
|
|
27
|
+
@click.option("--timeout", default=10, help="SSH timeout in seconds")
|
|
28
|
+
@click.option("--workers", default=10, help="Number of parallel workers")
|
|
29
|
+
@click.option("--no-save", is_flag=True, help="Do not save results to file")
|
|
30
|
+
def facts(
|
|
31
|
+
input_file: Optional[str],
|
|
32
|
+
subnet: Optional[str],
|
|
33
|
+
username: Optional[str],
|
|
34
|
+
password: Optional[str],
|
|
35
|
+
secret: Optional[str],
|
|
36
|
+
output: Optional[str],
|
|
37
|
+
output_dir: Optional[str],
|
|
38
|
+
timeout: int,
|
|
39
|
+
workers: int,
|
|
40
|
+
no_save: bool,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Collect detailed facts from network devices.
|
|
44
|
+
|
|
45
|
+
This command can work in two modes:
|
|
46
|
+
1. Use existing discovery results (--input)
|
|
47
|
+
2. Discover and collect facts in one step (--subnet)
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
|
|
51
|
+
# Collect facts from previous discovery
|
|
52
|
+
sfacts facts --input discovery_results.json
|
|
53
|
+
|
|
54
|
+
# Save results to specific directory
|
|
55
|
+
sfacts facts --input discovery_results.json --output-dir /path/to/results
|
|
56
|
+
|
|
57
|
+
# Discover and collect facts in one step
|
|
58
|
+
sfacts facts --subnet 192.168.1.0/24
|
|
59
|
+
|
|
60
|
+
# Combine discovery and facts with custom output directory
|
|
61
|
+
sfacts facts --subnet 192.168.1.0/24 --output-dir ./network_facts
|
|
62
|
+
|
|
63
|
+
# Use environment variables for credentials
|
|
64
|
+
export NETOPS_USERNAME=admin
|
|
65
|
+
export NETOPS_PASSWORD=admin
|
|
66
|
+
sfacts facts --subnet 10.0.0.0/24 --output-dir ./results
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
logger.info("🔍 SFacts - Enhanced Fact Collection")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
# Get credentials
|
|
73
|
+
cred_manager = CredentialManager()
|
|
74
|
+
credentials = cred_manager.get_credentials(
|
|
75
|
+
username=username, password=password, secret=secret, prompt=True
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Validate credentials
|
|
79
|
+
if not cred_manager.validate_credentials(credentials):
|
|
80
|
+
logger.error("✗ Invalid credentials provided")
|
|
81
|
+
raise click.Abort()
|
|
82
|
+
|
|
83
|
+
logger.success(f"✓ Using credentials for user: {credentials['username']}")
|
|
84
|
+
|
|
85
|
+
devices = []
|
|
86
|
+
|
|
87
|
+
# Mode 1: Load devices from existing discovery file
|
|
88
|
+
if input_file:
|
|
89
|
+
logger.info(f"📂 Loading devices from {input_file}")
|
|
90
|
+
|
|
91
|
+
if not os.path.exists(input_file):
|
|
92
|
+
logger.error(f"✗ Input file not found: {input_file}")
|
|
93
|
+
raise click.Abort()
|
|
94
|
+
|
|
95
|
+
with open(input_file) as f:
|
|
96
|
+
discovery_data = json.load(f)
|
|
97
|
+
|
|
98
|
+
# Extract devices from discovery format
|
|
99
|
+
if "devices" in discovery_data:
|
|
100
|
+
for host, device_info in discovery_data["devices"].items():
|
|
101
|
+
device_entry = {
|
|
102
|
+
"host": device_info.get("host", host.split(":")[0]),
|
|
103
|
+
"device_type": device_info.get("device_type"),
|
|
104
|
+
"platform": device_info.get("platform"),
|
|
105
|
+
"credentials": credentials,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Add port if specified in device info
|
|
109
|
+
if "port" in device_info:
|
|
110
|
+
device_entry["port"] = device_info["port"]
|
|
111
|
+
|
|
112
|
+
devices.append(device_entry)
|
|
113
|
+
|
|
114
|
+
logger.success(f"✓ Loaded {len(devices)} devices from discovery file")
|
|
115
|
+
|
|
116
|
+
# Mode 2: Discover devices first, then collect facts
|
|
117
|
+
elif subnet:
|
|
118
|
+
logger.info(f"🔍 Discovering devices in {subnet}")
|
|
119
|
+
|
|
120
|
+
# Initialize discovery engine
|
|
121
|
+
discovery = NetworkDiscovery(
|
|
122
|
+
credentials=credentials, timeout=timeout, max_workers=workers
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Discover devices
|
|
126
|
+
devices = discovery.discover_subnet(subnet)
|
|
127
|
+
|
|
128
|
+
if not devices:
|
|
129
|
+
logger.warning(f"⚠ No devices found in {subnet}")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
logger.error("✗ Either --input or --subnet must be specified")
|
|
134
|
+
raise click.Abort()
|
|
135
|
+
|
|
136
|
+
if not devices:
|
|
137
|
+
logger.warning("⚠ No devices available for fact collection")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Initialize discovery engine for fact collection
|
|
141
|
+
discovery = NetworkDiscovery(credentials=credentials, timeout=timeout, max_workers=workers)
|
|
142
|
+
|
|
143
|
+
# Collect detailed facts
|
|
144
|
+
logger.info("Fact Collection Parameters:")
|
|
145
|
+
logger.info(f" • Devices: {len(devices)}")
|
|
146
|
+
logger.info(f" • Timeout: {timeout}s")
|
|
147
|
+
logger.info(f" • Workers: {workers}")
|
|
148
|
+
|
|
149
|
+
fact_results = discovery.collect_facts(devices)
|
|
150
|
+
|
|
151
|
+
# Display summary
|
|
152
|
+
if fact_results and "collection_summary" in fact_results:
|
|
153
|
+
summary = fact_results["collection_summary"]
|
|
154
|
+
|
|
155
|
+
logger.success("📊 Fact Collection Summary")
|
|
156
|
+
logger.info(f"Total Devices: {summary.get('total_devices', 0)}")
|
|
157
|
+
logger.info(f"Successful Collections: {summary.get('successful_devices', 0)}")
|
|
158
|
+
logger.info(f"Failed Collections: {summary.get('failed_devices', 0)}")
|
|
159
|
+
|
|
160
|
+
# Show device details
|
|
161
|
+
if fact_results.get("devices"):
|
|
162
|
+
logger.info("Device Facts Collected:")
|
|
163
|
+
for host, device_facts in fact_results["devices"].items():
|
|
164
|
+
if device_facts.get("connection_info", {}).get("connection_successful", False):
|
|
165
|
+
basic_facts = device_facts.get("basic_facts", {})
|
|
166
|
+
hostname = basic_facts.get("hostname", "unknown")
|
|
167
|
+
model = basic_facts.get("model", "unknown")
|
|
168
|
+
version = basic_facts.get("software_version", "unknown")
|
|
169
|
+
logger.info(f" • {host} - {hostname} ({model}) - {version}")
|
|
170
|
+
else:
|
|
171
|
+
logger.error(f" • {host} - Connection failed")
|
|
172
|
+
|
|
173
|
+
# Save results unless disabled
|
|
174
|
+
if not no_save:
|
|
175
|
+
# Handle output directory and filename
|
|
176
|
+
if output_dir:
|
|
177
|
+
# Create output directory if it doesn't exist
|
|
178
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
if output:
|
|
181
|
+
# If both output-dir and output are specified, combine them
|
|
182
|
+
output_path = os.path.join(output_dir, os.path.basename(output))
|
|
183
|
+
else:
|
|
184
|
+
# Generate default filename in the specified directory
|
|
185
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
186
|
+
output_path = os.path.join(output_dir, f"facts_results_{timestamp}.json")
|
|
187
|
+
else:
|
|
188
|
+
# Use output as-is or generate default in current directory
|
|
189
|
+
if not output:
|
|
190
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
191
|
+
output_path = f"facts_results_{timestamp}.json"
|
|
192
|
+
else:
|
|
193
|
+
output_path = output
|
|
194
|
+
|
|
195
|
+
with open(output_path, "w") as f:
|
|
196
|
+
json.dump(fact_results, f, indent=2, default=str)
|
|
197
|
+
|
|
198
|
+
logger.success(f"✅ Results saved to {output_path}")
|
|
199
|
+
|
|
200
|
+
# Show file info
|
|
201
|
+
if os.path.exists(output_path):
|
|
202
|
+
file_size = os.path.getsize(output_path)
|
|
203
|
+
logger.info(f"📄 File size: {file_size:,} bytes")
|
|
204
|
+
|
|
205
|
+
# Show next steps
|
|
206
|
+
logger.info("Enhanced Data Available:")
|
|
207
|
+
logger.info("• Device hostnames, models, and software versions")
|
|
208
|
+
logger.info("• Command outputs (show version, show inventory, etc.)")
|
|
209
|
+
logger.info("• Connection and timing metadata")
|
|
210
|
+
logger.info("• Structured device facts for automation")
|
|
211
|
+
|
|
212
|
+
except KeyboardInterrupt:
|
|
213
|
+
logger.warning("⚠ Fact collection interrupted by user")
|
|
214
|
+
raise click.Abort() from None
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.error(f"✗ Fact collection failed: {str(e)}")
|
|
217
|
+
raise click.Abort() from e
|
|
218
|
+
|
|
219
|
+
logger.success("✅ Fact collection completed")
|
sfacts/cli/main.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main CLI entry point for sfacts network discovery tool.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from .discover import discover
|
|
9
|
+
from .export import export_inventory
|
|
10
|
+
from .facts import facts
|
|
11
|
+
from .netbox import netbox
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(version="2.1.0", prog_name="sfacts")
|
|
18
|
+
def cli() -> None:
|
|
19
|
+
"""
|
|
20
|
+
SFacts - Network Discovery and Fact Collection Tool
|
|
21
|
+
|
|
22
|
+
A modern network automation tool leveraging proven libraries:
|
|
23
|
+
- Netmiko for SSH connections and auto-detection
|
|
24
|
+
- NAPALM for vendor-agnostic device interaction
|
|
25
|
+
- ntc-templates for TextFSM parsing
|
|
26
|
+
- Nornir for parallel execution
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Add subcommands
|
|
32
|
+
cli.add_command(discover)
|
|
33
|
+
cli.add_command(facts)
|
|
34
|
+
cli.add_command(export_inventory)
|
|
35
|
+
cli.add_command(netbox)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
cli()
|