Initial commit
This commit is contained in:
657
skills/netbox-powerdns-integration/tools/netbox_api_client.py
Executable file
657
skills/netbox-powerdns-integration/tools/netbox_api_client.py
Executable 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()
|
||||
451
skills/netbox-powerdns-integration/tools/netbox_ipam_query.py
Executable file
451
skills/netbox-powerdns-integration/tools/netbox_ipam_query.py
Executable 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()
|
||||
369
skills/netbox-powerdns-integration/tools/netbox_vm_create.py
Executable file
369
skills/netbox-powerdns-integration/tools/netbox_vm_create.py
Executable 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()
|
||||
225
skills/netbox-powerdns-integration/tools/validate_dns_naming.py
Executable file
225
skills/netbox-powerdns-integration/tools/validate_dns_naming.py
Executable 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()
|
||||
Reference in New Issue
Block a user