Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:00:21 +08:00
commit 26377bd9be
20 changed files with 8845 additions and 0 deletions

View File

@@ -0,0 +1,657 @@
#!/usr/bin/env -S uv run --script --quiet
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pynetbox>=7.0.0",
# "infisicalsdk>=1.0.3",
# "rich>=13.0.0",
# "typer>=0.9.0",
# ]
# ///
"""
NetBox API Client
Complete working example of NetBox REST API usage with pynetbox.
Demonstrates authentication, queries, filtering, error handling, and best practices.
Usage:
# List all sites
./netbox_api_client.py sites list
# Get specific device
./netbox_api_client.py devices get --name foxtrot
# List VMs in cluster
./netbox_api_client.py vms list --cluster matrix
# Query IPs by DNS name
./netbox_api_client.py ips query --dns-name docker-01
# Get available IPs in prefix
./netbox_api_client.py prefixes available --prefix 192.168.3.0/24
# Create VM with IP
./netbox_api_client.py vms create --name test-vm --cluster matrix --ip 192.168.3.100
Example:
./netbox_api_client.py devices get --name foxtrot --output json
"""
import os
import sys
from typing import Optional
from dataclasses import dataclass
import typer
from rich import print as rprint
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
import pynetbox
from infisical_sdk import InfisicalSDKClient
app = typer.Typer(help="NetBox API Client for Matrix cluster infrastructure")
console = Console()
# ============================================================================
# Configuration and Authentication
# ============================================================================
@dataclass
class NetBoxConfig:
"""NetBox connection configuration."""
url: str
token: str
ssl_verify: bool = True
def get_netbox_client() -> pynetbox.api:
"""
Get authenticated NetBox API client.
Uses Infisical SDK with Universal Auth to securely retrieve API token.
Requires INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET environment variables.
Returns:
pynetbox.api: Authenticated NetBox client
Raises:
ValueError: If token cannot be retrieved or is empty
typer.Exit: On connection or authentication errors (CLI exits)
"""
try:
# Initialize Infisical SDK client
client = InfisicalSDKClient(host="https://app.infisical.com")
# Authenticate using Universal Auth (machine identity)
client_id = os.getenv("INFISICAL_CLIENT_ID")
client_secret = os.getenv("INFISICAL_CLIENT_SECRET")
if not client_id or not client_secret:
console.print(
"[red]INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET environment variables required[/red]")
raise typer.Exit(1)
client.auth.universal_auth.login(
client_id=client_id,
client_secret=client_secret
)
# Get NetBox API token from Infisical
secret = client.secrets.get_secret_by_name(
secret_name="NETBOX_API_TOKEN",
project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
environment_slug="prod",
secret_path="/matrix"
)
token = secret.secretValue
if not token:
console.print("[red]NETBOX_API_TOKEN is empty in Infisical[/red]")
raise ValueError("NETBOX_API_TOKEN is empty")
config = NetBoxConfig(
url="https://netbox.spaceships.work",
token=token,
ssl_verify=True
)
return pynetbox.api(config.url, token=config.token)
except ValueError:
# ValueError already logged above, re-raise to propagate
raise
except Exception as e:
console.print(f"[red]Failed to connect to NetBox: {e}[/red]")
raise typer.Exit(1)
# ============================================================================
# Sites Commands
# ============================================================================
sites_app = typer.Typer(help="Manage NetBox sites")
@sites_app.command("list")
def sites_list(
output: str = typer.Option("table", help="Output format: table or json")
):
"""List all sites in NetBox."""
nb = get_netbox_client()
try:
sites = nb.dcim.sites.all()
if output == "json":
import json
sites_data = [
{
"id": s.id,
"name": s.name,
"slug": s.slug,
"status": s.status.value if hasattr(s.status, 'value') else str(s.status),
"description": s.description or ""
}
for s in sites
]
print(json.dumps(sites_data, indent=2))
else:
table = Table(title="NetBox Sites")
table.add_column("ID", style="cyan")
table.add_column("Name", style="green")
table.add_column("Slug", style="yellow")
table.add_column("Status")
table.add_column("Description")
for site in sites:
table.add_row(
str(site.id),
site.name,
site.slug,
site.status.value if hasattr(
site.status, 'value') else str(site.status),
site.description or ""
)
console.print(table)
console.print(f"\n[green]Total sites: {len(sites)}[/green]")
except Exception as e:
console.print(f"[red]Error listing sites: {e}[/red]")
sys.exit(1)
@sites_app.command("get")
def sites_get(
slug: str = typer.Option(..., help="Site slug"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Get specific site by slug."""
nb = get_netbox_client()
try:
site = nb.dcim.sites.get(slug=slug)
if not site:
console.print(f"[red]Site '{slug}' not found[/red]")
sys.exit(1)
if output == "json":
import json
site_data = {
"id": site.id,
"name": site.name,
"slug": site.slug,
"status": site.status.value if hasattr(site.status, 'value') else str(site.status),
"description": site.description or "",
"tags": [tag.name for tag in site.tags] if site.tags else []
}
print(json.dumps(site_data, indent=2))
else:
console.print(Panel(
f"[green]Name:[/green] {site.name}\n"
f"[green]Slug:[/green] {site.slug}\n"
f"[green]Status:[/green] {site.status}\n"
f"[green]Description:[/green] {site.description or 'N/A'}\n"
f"[green]Tags:[/green] {', '.join([tag.name for tag in site.tags]) if site.tags else 'None'}",
title=f"Site: {site.name}",
border_style="green"
))
except Exception as e:
console.print(f"[red]Error getting site: {e}[/red]")
sys.exit(1)
# ============================================================================
# Devices Commands
# ============================================================================
devices_app = typer.Typer(help="Manage NetBox devices")
@devices_app.command("list")
def devices_list(
site: Optional[str] = typer.Option(None, help="Filter by site slug"),
role: Optional[str] = typer.Option(None, help="Filter by device role"),
tag: Optional[str] = typer.Option(None, help="Filter by tag"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""List devices with optional filtering."""
nb = get_netbox_client()
try:
# Build filter
filters = {}
if site:
filters['site'] = site
if role:
filters['role'] = role
if tag:
filters['tag'] = tag
devices = nb.dcim.devices.filter(
**filters) if filters else nb.dcim.devices.all()
if output == "json":
import json
devices_data = [
{
"id": d.id,
"name": d.name,
"site": d.site.name if d.site else None,
"role": d.device_role.name if d.device_role else None,
"status": d.status.value if hasattr(d.status, 'value') else str(d.status),
"primary_ip": str(d.primary_ip4.address) if d.primary_ip4 else None
}
for d in devices
]
print(json.dumps(devices_data, indent=2))
else:
table = Table(title="NetBox Devices")
table.add_column("ID", style="cyan")
table.add_column("Name", style="green")
table.add_column("Site", style="yellow")
table.add_column("Role")
table.add_column("Status")
table.add_column("Primary IP")
for device in devices:
table.add_row(
str(device.id),
device.name,
device.site.name if device.site else "N/A",
device.device_role.name if device.device_role else "N/A",
device.status.value if hasattr(
device.status, 'value') else str(device.status),
str(device.primary_ip4.address) if device.primary_ip4 else "N/A"
)
console.print(table)
console.print(
f"\n[green]Total devices: {len(list(devices))}[/green]")
except Exception as e:
console.print(f"[red]Error listing devices: {e}[/red]")
sys.exit(1)
@devices_app.command("get")
def devices_get(
name: str = typer.Option(..., help="Device name"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Get device details including interfaces."""
nb = get_netbox_client()
try:
device = nb.dcim.devices.get(name=name)
if not device:
console.print(f"[red]Device '{name}' not found[/red]")
sys.exit(1)
# Get interfaces
interfaces = nb.dcim.interfaces.filter(device=name)
if output == "json":
import json
device_data = {
"id": device.id,
"name": device.name,
"site": device.site.name if device.site else None,
"role": device.device_role.name if device.device_role else None,
"status": device.status.value if hasattr(device.status, 'value') else str(device.status),
"primary_ip4": str(device.primary_ip4.address) if device.primary_ip4 else None,
"interfaces": [
{
"name": iface.name,
"type": iface.type.value if hasattr(iface.type, 'value') else str(iface.type),
"enabled": iface.enabled,
"mtu": iface.mtu
}
for iface in interfaces
]
}
print(json.dumps(device_data, indent=2))
else:
# Device info
console.print(Panel(
f"[green]Name:[/green] {device.name}\n"
f"[green]Site:[/green] {device.site.name if device.site else 'N/A'}\n"
f"[green]Role:[/green] {device.device_role.name if device.device_role else 'N/A'}\n"
f"[green]Status:[/green] {device.status}\n"
f"[green]Primary IP:[/green] {device.primary_ip4.address if device.primary_ip4 else 'N/A'}",
title=f"Device: {device.name}",
border_style="green"
))
# Interfaces table
if interfaces:
iface_table = Table(title="Interfaces")
iface_table.add_column("Name", style="cyan")
iface_table.add_column("Type")
iface_table.add_column("Enabled")
iface_table.add_column("MTU")
for iface in interfaces:
iface_table.add_row(
iface.name,
iface.type.value if hasattr(
iface.type, 'value') else str(iface.type),
"" if iface.enabled else "",
str(iface.mtu) if iface.mtu else "default"
)
console.print("\n", iface_table)
except Exception as e:
console.print(f"[red]Error getting device: {e}[/red]")
sys.exit(1)
# ============================================================================
# Virtual Machines Commands
# ============================================================================
vms_app = typer.Typer(help="Manage NetBox virtual machines")
@vms_app.command("list")
def vms_list(
cluster: Optional[str] = typer.Option(None, help="Filter by cluster"),
tag: Optional[str] = typer.Option(None, help="Filter by tag"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""List virtual machines."""
nb = get_netbox_client()
try:
filters = {}
if cluster:
filters['cluster'] = cluster
if tag:
filters['tag'] = tag
vms = nb.virtualization.virtual_machines.filter(
**filters) if filters else nb.virtualization.virtual_machines.all()
if output == "json":
import json
vms_data = [
{
"id": vm.id,
"name": vm.name,
"cluster": vm.cluster.name if vm.cluster else None,
"vcpus": vm.vcpus,
"memory": vm.memory,
"status": vm.status.value if hasattr(vm.status, 'value') else str(vm.status),
"primary_ip": str(vm.primary_ip4.address) if vm.primary_ip4 else None
}
for vm in vms
]
print(json.dumps(vms_data, indent=2))
else:
table = Table(title="NetBox Virtual Machines")
table.add_column("ID", style="cyan")
table.add_column("Name", style="green")
table.add_column("Cluster", style="yellow")
table.add_column("vCPUs")
table.add_column("Memory (MB)")
table.add_column("Status")
table.add_column("Primary IP")
for vm in vms:
table.add_row(
str(vm.id),
vm.name,
vm.cluster.name if vm.cluster else "N/A",
str(vm.vcpus) if vm.vcpus else "N/A",
str(vm.memory) if vm.memory else "N/A",
vm.status.value if hasattr(
vm.status, 'value') else str(vm.status),
str(vm.primary_ip4.address) if vm.primary_ip4 else "N/A"
)
console.print(table)
console.print(f"\n[green]Total VMs: {len(list(vms))}[/green]")
except Exception as e:
console.print(f"[red]Error listing VMs: {e}[/red]")
sys.exit(1)
@vms_app.command("get")
def vms_get(
name: str = typer.Option(..., help="VM name"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Get VM details including interfaces and IPs."""
nb = get_netbox_client()
try:
vm = nb.virtualization.virtual_machines.get(name=name)
if not vm:
console.print(f"[red]VM '{name}' not found[/red]")
sys.exit(1)
# Get interfaces
interfaces = nb.virtualization.interfaces.filter(
virtual_machine_id=vm.id)
if output == "json":
import json
vm_data = {
"id": vm.id,
"name": vm.name,
"cluster": vm.cluster.name if vm.cluster else None,
"vcpus": vm.vcpus,
"memory": vm.memory,
"disk": vm.disk,
"status": vm.status.value if hasattr(vm.status, 'value') else str(vm.status),
"primary_ip4": str(vm.primary_ip4.address) if vm.primary_ip4 else None,
"interfaces": [
{
"name": iface.name,
"enabled": iface.enabled,
"mtu": iface.mtu
}
for iface in interfaces
]
}
print(json.dumps(vm_data, indent=2))
else:
console.print(Panel(
f"[green]Name:[/green] {vm.name}\n"
f"[green]Cluster:[/green] {vm.cluster.name if vm.cluster else 'N/A'}\n"
f"[green]vCPUs:[/green] {vm.vcpus or 'N/A'}\n"
f"[green]Memory:[/green] {vm.memory or 'N/A'} MB\n"
f"[green]Disk:[/green] {vm.disk or 'N/A'} GB\n"
f"[green]Status:[/green] {vm.status}\n"
f"[green]Primary IP:[/green] {vm.primary_ip4.address if vm.primary_ip4 else 'N/A'}",
title=f"VM: {vm.name}",
border_style="green"
))
if interfaces:
iface_table = Table(title="Interfaces")
iface_table.add_column("Name", style="cyan")
iface_table.add_column("Enabled")
iface_table.add_column("MTU")
for iface in interfaces:
iface_table.add_row(
iface.name,
"" if iface.enabled else "",
str(iface.mtu) if iface.mtu else "default"
)
console.print("\n", iface_table)
except Exception as e:
console.print(f"[red]Error getting VM: {e}[/red]")
sys.exit(1)
# ============================================================================
# IP Addresses Commands
# ============================================================================
ips_app = typer.Typer(help="Manage NetBox IP addresses")
@ips_app.command("query")
def ips_query(
dns_name: Optional[str] = typer.Option(
None, help="Filter by DNS name (partial match)"),
address: Optional[str] = typer.Option(None, help="Filter by IP address"),
tag: Optional[str] = typer.Option(None, help="Filter by tag"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Query IP addresses."""
nb = get_netbox_client()
try:
filters = {}
if dns_name:
filters['dns_name__ic'] = dns_name # Case-insensitive contains
if address:
filters['address'] = address
if tag:
filters['tag'] = tag
if not filters:
console.print(
"[yellow]Please provide at least one filter[/yellow]")
sys.exit(0)
ips = nb.ipam.ip_addresses.filter(**filters)
if output == "json":
import json
ips_data = [
{
"id": ip.id,
"address": str(ip.address),
"dns_name": ip.dns_name or "",
"status": ip.status.value if hasattr(ip.status, 'value') else str(ip.status),
"assigned_to": ip.assigned_object.name if ip.assigned_object else None
}
for ip in ips
]
print(json.dumps(ips_data, indent=2))
else:
table = Table(title="IP Addresses")
table.add_column("ID", style="cyan")
table.add_column("Address", style="green")
table.add_column("DNS Name", style="yellow")
table.add_column("Status")
table.add_column("Assigned To")
for ip in ips:
table.add_row(
str(ip.id),
str(ip.address),
ip.dns_name or "",
ip.status.value if hasattr(
ip.status, 'value') else str(ip.status),
ip.assigned_object.name if ip.assigned_object else "N/A"
)
console.print(table)
console.print(f"\n[green]Total IPs: {len(list(ips))}[/green]")
except Exception as e:
console.print(f"[red]Error querying IPs: {e}[/red]")
sys.exit(1)
# ============================================================================
# Prefixes Commands
# ============================================================================
prefixes_app = typer.Typer(help="Manage NetBox prefixes")
@prefixes_app.command("available")
def prefixes_available(
prefix: str = typer.Option(..., help="Prefix (e.g., 192.168.3.0/24)"),
limit: int = typer.Option(10, help="Max results to show"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Get available IPs in a prefix."""
nb = get_netbox_client()
try:
prefix_obj = nb.ipam.prefixes.get(prefix=prefix)
if not prefix_obj:
console.print(f"[red]Prefix '{prefix}' not found[/red]")
sys.exit(1)
available = prefix_obj.available_ips.list()[:limit]
if output == "json":
import json
print(json.dumps([str(ip.address) for ip in available], indent=2))
else:
console.print(f"\n[green]Prefix:[/green] {prefix}")
console.print(
f"[green]Available IPs (showing first {limit}):[/green]\n")
for ip in available:
console.print(f"{ip.address}")
console.print(
f"\n[yellow]Total available: {len(available)}+[/yellow]")
except Exception as e:
console.print(f"[red]Error getting available IPs: {e}[/red]")
sys.exit(1)
# ============================================================================
# Main App
# ============================================================================
app.add_typer(sites_app, name="sites")
app.add_typer(devices_app, name="devices")
app.add_typer(vms_app, name="vms")
app.add_typer(ips_app, name="ips")
app.add_typer(prefixes_app, name="prefixes")
@app.command("version")
def version():
"""Show version information."""
console.print("[green]NetBox API Client v1.0.0[/green]")
console.print("Part of Virgo-Core infrastructure automation")
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,451 @@
#!/usr/bin/env -S uv run --script --quiet
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pynetbox>=7.0.0",
# "infisicalsdk>=1.0.3",
# "rich>=13.0.0",
# "typer>=0.9.0",
# ]
# ///
"""
NetBox IPAM Query Tool
Advanced IPAM queries for the Matrix cluster infrastructure.
Query available IPs, prefix utilization, IP assignments, and VLAN information.
Usage:
# Get available IPs in prefix
./netbox_ipam_query.py available --prefix 192.168.3.0/24
# Check prefix utilization
./netbox_ipam_query.py utilization --site matrix
# Find IP assignments
./netbox_ipam_query.py assignments --prefix 192.168.3.0/24
# List VLANs
./netbox_ipam_query.py vlans --site matrix
Example:
./netbox_ipam_query.py available --prefix 192.168.3.0/24 --limit 5
./netbox_ipam_query.py utilization --site matrix --output json
"""
import os
import sys
from typing import Optional
from dataclasses import dataclass
import typer
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
import pynetbox
from infisical_sdk import InfisicalSDKClient
app = typer.Typer()
console = Console()
# ============================================================================
# Configuration and Authentication
# ============================================================================
@dataclass
class NetBoxConfig:
"""NetBox connection configuration."""
url: str
token: str
ssl_verify: bool = True
def get_netbox_client() -> pynetbox.api:
"""
Get authenticated NetBox API client.
Uses Infisical SDK with Universal Auth to securely retrieve API token.
Requires INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET environment variables.
Returns:
pynetbox.api: Authenticated NetBox client
Raises:
ValueError: If token cannot be retrieved or is empty
typer.Exit: On connection or authentication errors (CLI exits)
"""
try:
# Initialize Infisical SDK client
client = InfisicalSDKClient(host="https://app.infisical.com")
# Authenticate using Universal Auth (machine identity)
client_id = os.getenv("INFISICAL_CLIENT_ID")
client_secret = os.getenv("INFISICAL_CLIENT_SECRET")
if not client_id or not client_secret:
console.print("[red]INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET environment variables required[/red]")
raise typer.Exit(1)
client.auth.universal_auth.login(
client_id=client_id,
client_secret=client_secret
)
# Get NetBox API token from Infisical
secret = client.secrets.get_secret_by_name(
secret_name="NETBOX_API_TOKEN",
project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
environment_slug="prod",
secret_path="/matrix"
)
token = secret.secretValue
if not token:
console.print("[red]NETBOX_API_TOKEN is empty in Infisical[/red]")
raise ValueError("NETBOX_API_TOKEN is empty")
config = NetBoxConfig(
url="https://netbox.spaceships.work",
token=token,
ssl_verify=True
)
return pynetbox.api(config.url, token=config.token)
except ValueError:
# ValueError already logged above, re-raise to propagate
raise
except Exception as e:
console.print(f"[red]Failed to connect to NetBox: {e}[/red]")
raise typer.Exit(1)
@app.command("available")
def available_ips(
prefix: str = typer.Option(..., help="Prefix (e.g., 192.168.3.0/24)"),
limit: int = typer.Option(10, help="Number of IPs to show"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Get available IPs in a prefix."""
nb = get_netbox_client()
try:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console
) as progress:
task = progress.add_task(
f"Querying prefix {prefix}...", total=None)
prefix_obj = nb.ipam.prefixes.get(prefix=prefix)
if not prefix_obj:
console.print(f"[red]Prefix '{prefix}' not found[/red]")
sys.exit(1)
progress.update(task, description="Getting available IPs...")
available = prefix_obj.available_ips.list()[:limit]
if output == "json":
import json
data = {
"prefix": str(prefix),
"total_shown": len(available),
"available_ips": [str(ip.address) for ip in available]
}
print(json.dumps(data, indent=2))
else:
console.print(f"\n[green]Prefix:[/green] {prefix}")
console.print(
f"[green]Site:[/green] {prefix_obj.site.name if prefix_obj.site else 'N/A'}")
console.print(
f"[green]Description:[/green] {prefix_obj.description or 'N/A'}\n")
table = Table(title="Available IP Addresses")
table.add_column("IP Address", style="cyan")
table.add_column("Ready for Assignment")
for ip in available:
table.add_row(str(ip.address), "")
console.print(table)
console.print(
f"\n[yellow]Showing first {limit} available IPs[/yellow]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)
@app.command("utilization")
def prefix_utilization(
site: Optional[str] = typer.Option(None, help="Filter by site slug"),
role: Optional[str] = typer.Option(None, help="Filter by prefix role"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Show prefix utilization statistics."""
nb = get_netbox_client()
try:
filters = {}
if site:
filters['site'] = site
if role:
filters['role'] = role
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console
) as progress:
task = progress.add_task("Querying prefixes...", total=None)
prefixes = nb.ipam.prefixes.filter(
**filters) if filters else nb.ipam.prefixes.all()
if output == "json":
import json
data = [
{
"prefix": str(p.prefix),
"site": p.site.name if p.site else None,
"role": p.role.name if p.role else None,
"utilization": float(p.utilization) if hasattr(p, 'utilization') else 0.0,
"description": p.description or ""
}
for p in prefixes
]
print(json.dumps(data, indent=2))
else:
table = Table(title="Prefix Utilization")
table.add_column("Prefix", style="cyan")
table.add_column("Site", style="yellow")
table.add_column("Role")
table.add_column("Utilization", justify="right")
table.add_column("Description")
for p in prefixes:
utilization = p.utilization if hasattr(p, 'utilization') else 0
util_pct = f"{utilization}%"
# Color code based on utilization
if utilization >= 90:
util_color = "red"
elif utilization >= 75:
util_color = "yellow"
else:
util_color = "green"
table.add_row(
str(p.prefix),
p.site.name if p.site else "N/A",
p.role.name if p.role else "N/A",
f"[{util_color}]{util_pct}[/{util_color}]",
p.description or ""
)
console.print(table)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)
@app.command("assignments")
def ip_assignments(
prefix: str = typer.Option(..., help="Prefix (e.g., 192.168.3.0/24)"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""Show IP assignments in a prefix."""
nb = get_netbox_client()
try:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console
) as progress:
task = progress.add_task(
f"Querying prefix {prefix}...", total=None)
prefix_obj = nb.ipam.prefixes.get(prefix=prefix)
if not prefix_obj:
console.print(f"[red]Prefix '{prefix}' not found[/red]")
sys.exit(1)
progress.update(task, description="Getting IP assignments...")
# Get IPs by parent prefix (materialize once to avoid re-fetching)
ips = list(nb.ipam.ip_addresses.filter(parent=prefix))
if output == "json":
import json
data = [
{
"address": str(ip.address),
"dns_name": ip.dns_name or "",
"status": ip.status.value if hasattr(ip.status, 'value') else str(ip.status),
"assigned_to": {
"type": ip.assigned_object_type if ip.assigned_object else None,
"name": ip.assigned_object.name if ip.assigned_object else None
},
"description": ip.description or ""
}
for ip in ips
]
print(json.dumps(data, indent=2))
else:
table = Table(title=f"IP Assignments in {prefix}")
table.add_column("IP Address", style="cyan")
table.add_column("DNS Name", style="green")
table.add_column("Status")
table.add_column("Assigned To")
table.add_column("Description")
for ip in ips:
assigned_to = "N/A"
if ip.assigned_object:
obj_type = ip.assigned_object_type.split(
'.')[-1] if ip.assigned_object_type else "unknown"
assigned_to = f"{ip.assigned_object.name} ({obj_type})"
table.add_row(
str(ip.address),
ip.dns_name or "",
ip.status.value if hasattr(
ip.status, 'value') else str(ip.status),
assigned_to,
ip.description or ""
)
console.print(table)
console.print(f"\n[green]Total IPs: {len(ips)}[/green]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)
@app.command("vlans")
def list_vlans(
site: Optional[str] = typer.Option(None, help="Filter by site slug"),
output: str = typer.Option("table", help="Output format: table or json")
):
"""List VLANs."""
nb = get_netbox_client()
try:
filters = {}
if site:
filters['site'] = site
# Materialize once to avoid re-fetching on len() call
vlans = list(nb.ipam.vlans.filter(**filters)
if filters else nb.ipam.vlans.all())
if output == "json":
import json
data = [
{
"id": vlan.id,
"vid": vlan.vid,
"name": vlan.name,
"site": vlan.site.name if vlan.site else None,
"status": vlan.status.value if hasattr(vlan.status, 'value') else str(vlan.status),
"description": vlan.description or ""
}
for vlan in vlans
]
print(json.dumps(data, indent=2))
else:
table = Table(title="VLANs")
table.add_column("ID", style="cyan")
table.add_column("VID", justify="right", style="yellow")
table.add_column("Name", style="green")
table.add_column("Site")
table.add_column("Status")
table.add_column("Description")
for vlan in vlans:
table.add_row(
str(vlan.id),
str(vlan.vid),
vlan.name,
vlan.site.name if vlan.site else "N/A",
vlan.status.value if hasattr(
vlan.status, 'value') else str(vlan.status),
vlan.description or ""
)
console.print(table)
console.print(f"\n[green]Total VLANs: {len(vlans)}[/green]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)
@app.command("summary")
def ipam_summary(
site: str = typer.Option(..., help="Site slug")
):
"""Show IPAM summary for a site."""
nb = get_netbox_client()
try:
site_obj = nb.dcim.sites.get(slug=site)
if not site_obj:
console.print(f"[red]Site '{site}' not found[/red]")
sys.exit(1)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console
) as progress:
task = progress.add_task("Gathering IPAM data...", total=None)
# Get prefixes
prefixes = list(nb.ipam.prefixes.filter(site=site))
# Get IPs
ips = list(nb.ipam.ip_addresses.filter(site=site)
if hasattr(nb.ipam.ip_addresses, 'filter') else [])
# Get VLANs
vlans = list(nb.ipam.vlans.filter(site=site))
# Display summary
console.print(Panel(
f"[green]Site:[/green] {site_obj.name}\n"
f"[green]Prefixes:[/green] {len(prefixes)}\n"
f"[green]IP Addresses:[/green] {len(ips)}\n"
f"[green]VLANs:[/green] {len(vlans)}",
title="IPAM Summary",
border_style="cyan"
))
# Prefix details
if prefixes:
console.print("\n[yellow]Prefixes:[/yellow]")
for p in prefixes:
utilization = p.utilization if hasattr(p, 'utilization') else 0
console.print(
f"{p.prefix} - {p.description or 'No description'} ({utilization}% used)")
# VLAN details
if vlans:
console.print("\n[yellow]VLANs:[/yellow]")
for v in vlans:
console.print(
f" • VLAN {v.vid} ({v.name}) - {v.description or 'No description'}")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,369 @@
#!/usr/bin/env -S uv run --script --quiet
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pynetbox>=7.0.0",
# "infisicalsdk>=1.0.3",
# "rich>=13.0.0",
# "typer>=0.9.0",
# ]
# ///
"""
NetBox VM Creator
Create a complete VM in NetBox with automatic IP assignment and DNS configuration.
Follows Matrix cluster naming conventions and integrates with PowerDNS sync.
Usage:
# Create VM with auto-assigned IP
./netbox_vm_create.py --name docker-02 --cluster matrix --vcpus 4 --memory 8192
# Create VM with specific IP
./netbox_vm_create.py --name k8s-01-master --cluster matrix --ip 192.168.3.50
# Create VM with custom DNS name
./netbox_vm_create.py --name app-01 --cluster matrix --dns-name app-01-web.spaceships.work
Example:
./netbox_vm_create.py \\
--name docker-02 \\
--cluster matrix \\
--vcpus 4 \\
--memory 8192 \\
--disk 100 \\
--description "Docker host for GitLab"
"""
import ipaddress
import os
import sys
import re
from typing import Optional
from dataclasses import dataclass
import typer
from rich.console import Console
from rich.panel import Panel
import pynetbox
from infisical_sdk import InfisicalSDKClient
app = typer.Typer()
console = Console()
# ============================================================================
# Configuration and Authentication
# ============================================================================
@dataclass
class NetBoxConfig:
"""NetBox connection configuration."""
url: str
token: str
ssl_verify: bool = True
def get_netbox_client() -> pynetbox.api:
"""
Get authenticated NetBox API client.
Uses Infisical SDK with Universal Auth to securely retrieve API token.
Requires INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET environment variables.
Returns:
pynetbox.api: Authenticated NetBox client
Raises:
ValueError: If token cannot be retrieved or is empty
typer.Exit: On connection or authentication errors (CLI exits)
"""
try:
# Initialize Infisical SDK client
client = InfisicalSDKClient(host="https://app.infisical.com")
# Authenticate using Universal Auth (machine identity)
client_id = os.getenv("INFISICAL_CLIENT_ID")
client_secret = os.getenv("INFISICAL_CLIENT_SECRET")
if not client_id or not client_secret:
console.print(
"[red]INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET environment variables required[/red]")
raise typer.Exit(1)
client.auth.universal_auth.login(
client_id=client_id,
client_secret=client_secret
)
# Get NetBox API token from Infisical
secret = client.secrets.get_secret_by_name(
secret_name="NETBOX_API_TOKEN",
project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
environment_slug="prod",
secret_path="/matrix"
)
token = secret.secretValue
if not token:
console.print("[red]NETBOX_API_TOKEN is empty in Infisical[/red]")
raise ValueError("NETBOX_API_TOKEN is empty")
config = NetBoxConfig(
url="https://netbox.spaceships.work",
token=token,
ssl_verify=True
)
return pynetbox.api(config.url, token=config.token)
except ValueError:
# ValueError already logged above, re-raise to propagate
raise
except Exception as e:
console.print(f"[red]Failed to connect to NetBox: {e}[/red]")
raise typer.Exit(1)
def validate_dns_name(name: str) -> bool:
"""
Validate DNS naming convention.
Pattern: <service>-<number>[-<purpose>].<domain>
Examples: docker-01.spaceships.work, docker-01-nexus.spaceships.work
"""
pattern = r'^[a-z0-9-]+-\d{2}(-[a-z0-9-]+)?\.[a-z0-9.-]+$'
return bool(re.match(pattern, name.lower()))
def validate_vm_name(name: str) -> bool:
"""
Validate VM name (without domain).
Pattern: <service>-<number> or <service>-<number>-<purpose>
- service: one or more lowercase letters, digits, or hyphens
- number: exactly two digits (00-99)
- purpose (optional): one or more lowercase letters/numbers/hyphens
Example: docker-01, k8s-01-master, or docker-proxy-01
"""
pattern = r'^[a-z0-9-]+\-\d{2}(-[a-z0-9-]+)?$'
return bool(re.match(pattern, name.lower()))
@app.command()
def main(
name: str = typer.Option(...,
help="VM name (e.g., docker-02, k8s-01-master)"),
cluster: str = typer.Option("matrix", help="Cluster name"),
vcpus: int = typer.Option(2, help="Number of vCPUs"),
memory: int = typer.Option(2048, help="Memory in MB"),
disk: int = typer.Option(20, help="Disk size in GB"),
ip: Optional[str] = typer.Option(
None, help="Specific IP address (e.g., 192.168.3.50/24)"),
prefix: str = typer.Option(
"192.168.3.0/24", help="Prefix for auto IP assignment"),
dns_name: Optional[str] = typer.Option(
None, help="Custom DNS name (FQDN)"),
description: Optional[str] = typer.Option(None, help="VM description"),
tags: str = typer.Option("terraform,production-dns",
help="Comma-separated tags"),
dry_run: bool = typer.Option(
False, help="Show what would be created without creating")
):
"""
Create a VM in NetBox with automatic IP assignment and DNS.
This tool follows Matrix cluster conventions and integrates with PowerDNS sync.
"""
# Validate VM name
if not validate_vm_name(name):
console.print(f"[red]Invalid VM name: {name}[/red]")
console.print(
"[yellow]VM name must contain only lowercase letters, numbers, and hyphens[/yellow]")
raise typer.Exit(1)
# Generate DNS name if not provided
if not dns_name:
dns_name = f"{name}.spaceships.work"
# Validate DNS name
if not validate_dns_name(dns_name):
console.print(f"[red]Invalid DNS name: {dns_name}[/red]")
console.print(
"[yellow]DNS name must follow pattern: service-NN[-purpose].domain[/yellow]")
console.print(
"[yellow]Examples: docker-01.spaceships.work or docker-01-nexus.spaceships.work[/yellow]")
raise typer.Exit(1)
# Parse tags
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
# Show configuration
console.print(Panel(
f"[green]VM Name:[/green] {name}\n"
f"[green]Cluster:[/green] {cluster}\n"
f"[green]vCPUs:[/green] {vcpus}\n"
f"[green]Memory:[/green] {memory} MB\n"
f"[green]Disk:[/green] {disk} GB\n"
f"[green]IP:[/green] {ip or f'Auto (from {prefix})'}\n"
f"[green]DNS Name:[/green] {dns_name}\n"
f"[green]Description:[/green] {description or 'N/A'}\n"
f"[green]Tags:[/green] {', '.join(tag_list)}",
title="VM Configuration",
border_style="cyan"
))
if dry_run:
console.print("\n[yellow]Dry run - no changes made[/yellow]")
raise typer.Exit(0)
# Confirm
if not typer.confirm("\nCreate this VM in NetBox?"):
console.print("[yellow]Aborted[/yellow]")
raise typer.Exit(0)
nb = get_netbox_client()
try:
# 1. Get cluster
console.print(f"\n[cyan]1. Looking up cluster '{cluster}'...[/cyan]")
cluster_obj = nb.virtualization.clusters.get(name=cluster)
if not cluster_obj:
console.print(f"[red]Cluster '{cluster}' not found[/red]")
raise typer.Exit(1)
console.print(f"[green]✓ Found cluster: {cluster_obj.name}[/green]")
# 2. Check if VM already exists
console.print(
f"\n[cyan]2. Checking if VM '{name}' already exists...[/cyan]")
existing_vm = nb.virtualization.virtual_machines.get(name=name)
if existing_vm:
console.print(
f"[red]VM '{name}' already exists (ID: {existing_vm.id})[/red]")
raise typer.Exit(1)
console.print("[green]✓ VM name available[/green]")
# 3. Create VM
console.print(f"\n[cyan]3. Creating VM '{name}'...[/cyan]")
vm = nb.virtualization.virtual_machines.create(
name=name,
cluster=cluster_obj.id,
status='active',
vcpus=vcpus,
memory=memory,
disk=disk,
description=description or "VM created via netbox_vm_create.py",
tags=[{"name": tag} for tag in tag_list]
)
console.print(f"[green]✓ Created VM (ID: {vm.id})[/green]")
# 4. Create VM interface
console.print("\n[cyan]4. Creating network interface 'eth0'...[/cyan]")
vm_iface = nb.virtualization.interfaces.create(
virtual_machine=vm.id,
name='eth0',
type='virtual',
enabled=True,
mtu=1500
)
console.print(
f"[green]✓ Created interface (ID: {vm_iface.id})[/green]")
# 5. Assign IP
# Validate IP format if provided
if ip:
try:
ipaddress.ip_interface(ip)
except ValueError:
console.print(f"[red]Invalid IP address format: {ip}[/red]")
console.print(
"[yellow]Expected format: 192.168.3.50/24[/yellow]")
# Rollback
vm_iface.delete()
vm.delete()
raise typer.Exit(1)
try:
if ip:
# Use specific IP
console.print(f"\n[cyan]5. Creating IP address {ip}...[/cyan]")
vm_ip = nb.ipam.ip_addresses.create(
address=ip,
dns_name=dns_name,
status='active',
assigned_object_type='virtualization.vminterface',
assigned_object_id=vm_iface.id,
tags=[{"name": tag} for tag in tag_list]
)
else:
# Auto-assign from prefix
console.print(
f"\n[cyan]5. Getting next available IP from {prefix}...[/cyan]")
prefix_obj = nb.ipam.prefixes.get(prefix=prefix)
if not prefix_obj:
console.print(f"[red]Prefix '{prefix}' not found[/red]")
# Rollback
vm_iface.delete()
vm.delete()
raise typer.Exit(1)
vm_ip = prefix_obj.available_ips.create(
dns_name=dns_name,
status='active',
assigned_object_type='virtualization.vminterface',
assigned_object_id=vm_iface.id,
tags=[{"name": tag} for tag in tag_list]
)
console.print(f"[green]✓ Assigned IP: {vm_ip.address}[/green]")
except Exception as e:
console.print(f"[red]Failed to assign IP: {e}[/red]")
console.print(
"[yellow]Rolling back: deleting interface and VM...[/yellow]")
try:
vm_iface.delete()
vm.delete()
except Exception as rollback_error:
console.print(f"[red]Rollback failed: {rollback_error}[/red]")
raise typer.Exit(1)
# 6. Set as primary IP
console.print(
f"\n[cyan]6. Setting {vm_ip.address} as primary IP...[/cyan]")
vm.primary_ip4 = vm_ip.id
vm.save()
console.print("[green]✓ Set primary IP[/green]")
# Success summary
console.print("\n" + "="*60)
console.print(Panel(
f"[green]VM Name:[/green] {vm.name}\n"
f"[green]ID:[/green] {vm.id}\n"
f"[green]Cluster:[/green] {cluster_obj.name}\n"
f"[green]IP Address:[/green] {vm_ip.address}\n"
f"[green]DNS Name:[/green] {vm_ip.dns_name}\n"
f"[green]vCPUs:[/green] {vm.vcpus}\n"
f"[green]Memory:[/green] {vm.memory} MB\n"
f"[green]Disk:[/green] {vm.disk} GB\n"
f"[green]Tags:[/green] {', '.join(tag_list)}",
title="✓ VM Created Successfully",
border_style="green"
))
console.print(
"\n[yellow]Note:[/yellow] DNS record will be automatically created by NetBox PowerDNS sync plugin")
console.print(
f"[yellow]DNS:[/yellow] {dns_name}{vm_ip.address.split('/')[0]}")
except pynetbox.RequestError as e:
console.print(f"\n[red]NetBox API Error: {e.error}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env -S uv run --script --quiet
# /// script
# dependencies = []
# ///
"""
Validate DNS names against Virgo-Core naming convention.
Naming Convention: <service>-<NN>-<purpose>.<domain>
Example: docker-01-nexus.spaceships.work
Usage:
./validate_dns_naming.py docker-01-nexus.spaceships.work
./validate_dns_naming.py --file hostnames.txt
./validate_dns_naming.py --check-format docker-1-nexus.spaceships.work
"""
import argparse
import re
import sys
from typing import Tuple
class DNSNameValidator:
"""Validates DNS names against naming convention."""
# Pattern: <service>-<NN>-<purpose>.<domain>
PATTERN = r'^[a-z0-9-]+-\d{2}-[a-z0-9-]+\.[a-z0-9.-]+$'
# Common service types
KNOWN_SERVICES = {
'docker', 'k8s', 'proxmox', 'storage', 'db', 'network',
'app', 'vip', 'service', 'test', 'dev', 'staging', 'prod'
}
def __init__(self):
self.pattern = re.compile(self.PATTERN)
def validate(self, name: str) -> Tuple[bool, str, dict]:
"""
Validate DNS name.
Returns:
(is_valid, message, details_dict)
"""
# Basic pattern match
if not self.pattern.match(name):
return False, "Name doesn't match pattern: <service>-<NN>-<purpose>.<domain>", {}
# Split into components
parts = name.split('.')
if len(parts) < 2:
return False, "Must include domain", {}
hostname = parts[0]
domain = '.'.join(parts[1:])
# Split hostname
components = hostname.split('-')
if len(components) < 3:
return False, "Hostname must have at least 3 components: <service>-<NN>-<purpose>", {}
service = components[0]
number = components[1]
purpose = '-'.join(components[2:]) # Purpose can have hyphens
# Validate number component (must be 2 digits)
if not number.isdigit() or len(number) != 2:
return False, f"Number component '{number}' must be exactly 2 digits (01-99)", {}
# Additional checks
warnings = []
# Check for known service type
if service not in self.KNOWN_SERVICES:
warnings.append(f"Service '{service}' not in known types (informational)")
# Check for uppercase
if name != name.lower():
return False, "Name must be lowercase only", {}
# Check for invalid characters
if not re.match(r'^[a-z0-9.-]+$', name):
return False, "Name contains invalid characters (only a-z, 0-9, -, . allowed)", {}
# Build details
details = {
'service': service,
'number': number,
'purpose': purpose,
'domain': domain,
'warnings': warnings
}
message = "Valid"
if warnings:
message = f"Valid (with warnings: {', '.join(warnings)})"
return True, message, details
def validate_batch(self, names: list) -> dict:
"""
Validate multiple names.
Returns:
{
'valid': [(name, details), ...],
'invalid': [(name, reason), ...]
}
"""
results = {'valid': [], 'invalid': []}
for name in names:
name = name.strip()
if not name or name.startswith('#'):
continue
is_valid, message, details = self.validate(name)
if is_valid:
results['valid'].append((name, details))
else:
results['invalid'].append((name, message))
return results
def print_validation_result(name: str, is_valid: bool, message: str, details: dict, verbose: bool = False):
"""Print formatted validation result."""
status = "" if is_valid else ""
print(f"{status} {name}: {message}")
if verbose and is_valid and details:
print(f" Service: {details.get('service')}")
print(f" Number: {details.get('number')}")
print(f" Purpose: {details.get('purpose')}")
print(f" Domain: {details.get('domain')}")
warnings = details.get('warnings', [])
if warnings:
print(f" Warnings:")
for warning in warnings:
print(f" - {warning}")
def main():
parser = argparse.ArgumentParser(
description="Validate DNS names against Virgo-Core naming convention",
epilog="Pattern: <service>-<NN>-<purpose>.<domain>\nExample: docker-01-nexus.spaceships.work"
)
parser.add_argument(
"name",
nargs="?",
help="DNS name to validate"
)
parser.add_argument(
"--file",
help="File containing DNS names (one per line)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed component breakdown"
)
parser.add_argument(
"--check-format",
action="store_true",
help="Only check format, don't suggest corrections"
)
args = parser.parse_args()
validator = DNSNameValidator()
# Batch mode from file
if args.file:
try:
with open(args.file, 'r') as f:
names = f.readlines()
except IOError as e:
print(f"❌ Failed to read file: {e}", file=sys.stderr)
sys.exit(1)
results = validator.validate_batch(names)
print(f"\n📊 Validation Results:")
print(f" Valid: {len(results['valid'])}")
print(f" Invalid: {len(results['invalid'])}")
if results['invalid']:
print(f"\n✗ Invalid Names:")
for name, reason in results['invalid']:
print(f" {name}")
print(f" Reason: {reason}")
if results['valid']:
print(f"\n✓ Valid Names:")
for name, details in results['valid']:
print(f" {name}")
if args.verbose:
print(f" Service: {details['service']}, Number: {details['number']}, Purpose: {details['purpose']}")
sys.exit(0 if len(results['invalid']) == 0 else 1)
# Single name mode
if not args.name:
print("❌ Provide a DNS name or use --file", file=sys.stderr)
parser.print_help()
sys.exit(1)
is_valid, message, details = validator.validate(args.name)
print_validation_result(args.name, is_valid, message, details, args.verbose)
# Provide suggestions for common mistakes
if not is_valid and not args.check_format:
print(f"\n💡 Common Issues:")
print(f" - Use lowercase only: {args.name.lower()}")
print(f" - Use hyphens, not underscores: {args.name.replace('_', '-')}")
print(f" - Number must be 2 digits: docker-1-app → docker-01-app")
print(f" - Pattern: <service>-<NN>-<purpose>.<domain>")
print(f" - Example: docker-01-nexus.spaceships.work")
sys.exit(0 if is_valid else 1)
if __name__ == "__main__":
main()