Back to skills
SkillHub ClubShip Full StackFull StackBackendIntegration

netbox-powerdns-integration

NetBox IPAM and PowerDNS integration for automated DNS record management.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
14
Hot score
86
Updated
March 19, 2026
Overall rating
C2.5
Composite score
2.5
Best-practice grade
C67.6

Install command

npx @skill-hub/cli install basher83-lunar-claude-netbox-powerdns-integration
automationinfrastructurenetworkingapidns

Repository

basher83/lunar-claude

Skill path: plugins/homelab/netbox-powerdns-integration/skills/netbox-powerdns-integration

NetBox IPAM and PowerDNS integration for automated DNS record management.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Backend, Integration.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: basher83.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install netbox-powerdns-integration into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/basher83/lunar-claude before adding netbox-powerdns-integration to shared team environments
  • Use netbox-powerdns-integration for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: netbox-powerdns-integration
description: NetBox IPAM and PowerDNS integration for automated DNS record management.
---

# NetBox PowerDNS Integration

Expert guidance for implementing NetBox as your source of truth for infrastructure documentation and
automating DNS record management with PowerDNS.

## Quick Start

### Common Tasks

**Query NetBox API:**

```bash
# List all sites
./tools/netbox_api_client.py sites list

# Get device details
./tools/netbox_api_client.py devices get --name foxtrot

# List VMs in cluster
./tools/netbox_api_client.py vms list --cluster matrix

# Query IPs
./tools/netbox_api_client.py ips query --dns-name docker-01
```

**Create VM in NetBox:**

```bash
# Create VM with auto-assigned IP
./tools/netbox_vm_create.py --name docker-02 --cluster matrix --vcpus 4 --memory 8192

# Create VM with specific IP
./tools/netbox_vm_create.py --name k8s-01-master --cluster matrix --ip 192.168.3.50/24
```

**IPAM Queries:**

```bash
# Get available IPs
./tools/netbox_ipam_query.py available --prefix 192.168.3.0/24

# Check prefix utilization
./tools/netbox_ipam_query.py utilization --site matrix

# View IP assignments
./tools/netbox_ipam_query.py assignments --prefix 192.168.3.0/24
```

**Validate DNS Naming:**

```bash
./tools/validate_dns_naming.py --name "docker-01-nexus.spaceships.work"
```

**Deploy from NetBox Inventory:**

```bash
cd ansible && uv run ansible-playbook -i tools/netbox-dynamic-inventory.yml deploy-from-netbox.yml
```

## When to Use This Skill

Activate this skill when:

- **Querying NetBox API** - Sites, devices, VMs, IPs, prefixes, VLANs
- **Setting up NetBox IPAM** - Prefixes, IP management, VRFs
- **Implementing automated DNS** - PowerDNS sync plugin configuration
- **Creating DNS naming conventions** - `service-NN-purpose.domain` pattern
- **Managing VMs in NetBox** - Creating, updating, IP assignment
- **Using Terraform with NetBox** - Provider configuration and resources
- **Setting up Ansible dynamic inventory** - NetBox as inventory source
- **Troubleshooting NetBox-PowerDNS sync** - Tag matching, zone configuration
- **Migrating to NetBox** - From manual DNS or spreadsheet tracking
- **Infrastructure documentation** - Using NetBox as source of truth

## Core Workflows

### 1. NetBox API Usage

**Query infrastructure data:**

```python
#!/usr/bin/env -S uv run --script --quiet
# /// script
# requires-python = ">=3.11"
# dependencies = ["pynetbox>=7.0.0", "infisical-python>=2.3.3"]
# ///

import pynetbox
from infisical import InfisicalClient

# Get token from Infisical
client = InfisicalClient()
token = client.get_secret(
    secret_name="NETBOX_API_TOKEN",
    project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
    environment="prod",
    path="/matrix"
).secret_value

# Connect to NetBox
nb = pynetbox.api('https://netbox.spaceships.work', token=token)

# Query devices in Matrix cluster
site = nb.dcim.sites.get(slug='matrix')
devices = nb.dcim.devices.filter(site='matrix')

for device in devices:
    print(f"{device.name}: {device.primary_ip4.address if device.primary_ip4 else 'No IP'}")
```

See [reference/netbox-api-guide.md](reference/netbox-api-guide.md) for complete API reference.

### 2. DNS Naming Convention

This infrastructure uses the pattern: `<service>-<number>-<purpose>.<domain>`

**Examples:**

- `docker-01-nexus.spaceships.work` - Docker host #1 running Nexus
- `proxmox-foxtrot-mgmt.spaceships.work` - Proxmox node Foxtrot management interface
- `k8s-01-master.spaceships.work` - Kubernetes cluster master node #1

**Implementation:**

```python
# tools/validate_dns_naming.py validates this pattern
pattern = r'^[a-z0-9-]+-\d{2}-[a-z0-9-]+\.[a-z0-9.-]+$'
```

See [workflows/naming-conventions.md](workflows/naming-conventions.md) for complete rules.

### 3. NetBox + PowerDNS Sync Setup

#### Step 1: Install Plugin

```bash
# In NetBox virtualenv
pip install netbox-powerdns-sync
```

#### Step 2: Configure Plugin

```python
# /opt/netbox/netbox/netbox/configuration.py
PLUGINS = ['netbox_powerdns_sync']

PLUGINS_CONFIG = {
    "netbox_powerdns_sync": {
        "powerdns_managed_record_comment": "netbox-managed",
        "post_save_enabled": True,  # Real-time sync
    },
}
```

#### Step 3: Create Zones in NetBox

Configure zones with:

- Zone name (e.g., `spaceships.work`)
- PowerDNS server connection
- Tag matching rules (e.g., `production-dns`)
- DNS name generation method

See [reference/sync-plugin-reference.md](reference/sync-plugin-reference.md) for details.

### 4. Terraform Integration

**Provider Setup:**

```hcl
terraform {
  required_providers {
    netbox = {
      source  = "e-breuninger/netbox"
      version = "~> 5.0.0"
    }
  }
}

provider "netbox" {
  server_url = "https://netbox.spaceships.work"
  api_token  = var.netbox_api_token
}
```

**Create IP with Auto-DNS:**

```hcl
resource "netbox_ip_address" "docker_host" {
  ip_address  = "192.168.1.100/24"
  dns_name    = "docker-01-nexus.spaceships.work"
  description = "Docker host for Nexus registry"

  tags = [
    "terraform",
    "production-dns"  # Triggers auto DNS sync
  ]
}
```

DNS records created automatically by plugin!

See [reference/terraform-provider-guide.md](reference/terraform-provider-guide.md) and [examples/netbox-terraform-provider.tf](examples/netbox-terraform-provider.tf).

### 5. Ansible Dynamic Inventory

**Use NetBox as Inventory Source:**

```yaml
# tools/netbox-dynamic-inventory.yml
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...
group_by:
  - device_roles
  - tags
```

**Deploy Using NetBox Data:**

```bash
ansible-playbook -i tools/netbox-dynamic-inventory.yml deploy-from-netbox.yml
```

See [workflows/ansible-dynamic-inventory.md](workflows/ansible-dynamic-inventory.md).

## Architecture Reference

### DNS Automation Flow

```text
1. Create/Update resource in NetBox
   └→ IP Address with dns_name and tags

2. NetBox PowerDNS Sync Plugin activates
   └→ Matches IP to zone based on tags
   └→ Generates DNS records

3. PowerDNS API called
   └→ A record: docker-01-nexus.spaceships.work → 192.168.1.100
   └→ PTR record: 100.1.168.192.in-addr.arpa → docker-01-nexus.spaceships.work

4. DNS propagates automatically
   └→ No manual DNS configuration needed
```

### Integration with Proxmox

```text
Terraform/Ansible
  ↓
Creates VM in Proxmox
  ↓
Registers in NetBox (via API)
  ├→ Device object
  ├→ IP Address with dns_name
  └→ Tags (production-dns)
  ↓
NetBox PowerDNS Sync
  ↓
DNS Records in PowerDNS
  ↓
Ansible Dynamic Inventory
  ↓
Automated configuration management
```

## Tools Available

### NetBox API Tools (Python + uv)

**netbox_api_client.py** - Comprehensive NetBox API client

```bash
# List sites, devices, VMs, IPs
./tools/netbox_api_client.py sites list
./tools/netbox_api_client.py devices get --name foxtrot
./tools/netbox_api_client.py vms list --cluster matrix
./tools/netbox_api_client.py ips query --dns-name docker-01
./tools/netbox_api_client.py prefixes available --prefix 192.168.3.0/24
```

**netbox_vm_create.py** - Create VMs in NetBox with IP assignment

```bash
# Create VM with auto IP
./tools/netbox_vm_create.py --name docker-02 --cluster matrix --vcpus 4 --memory 8192

# Create VM with specific IP
./tools/netbox_vm_create.py --name k8s-01-master --cluster matrix --ip 192.168.3.50/24
```

**netbox_ipam_query.py** - Advanced IPAM queries

```bash
# Available IPs
./tools/netbox_ipam_query.py available --prefix 192.168.3.0/24

# Prefix utilization
./tools/netbox_ipam_query.py utilization --site matrix

# IP assignments
./tools/netbox_ipam_query.py assignments --prefix 192.168.3.0/24

# IPAM summary
./tools/netbox_ipam_query.py summary --site matrix
```

**validate_dns_naming.py** - Validate DNS naming conventions

```bash
./tools/validate_dns_naming.py --name "docker-01-nexus.spaceships.work"
```

### Terraform Modules

**netbox-data-sources.tf** - Examples using NetBox provider

- Query existing NetBox resources
- Use as data sources for other resources

### Ansible Playbooks

**deploy-from-netbox.yml** - Deploy using NetBox inventory

- Dynamic inventory from NetBox
- Tag-based host selection
- Automatic IP and hostname discovery

See [examples/](examples/) directory.

## Troubleshooting

### DNS Records Not Syncing

#### Check 1: Tag Matching

```bash
# Verify IP has correct tags
./tools/netbox_query.py --ip 192.168.1.100 | jq '.tags'
```

#### Check 2: Zone Configuration

- Ensure zone exists in NetBox
- Verify tag rules match
- Check PowerDNS server connectivity

#### Check 3: Sync Results

```bash
./tools/powerdns_sync_check.py --zone spaceships.work --verbose
```

### Naming Convention Violations

**Validate names before creating:**

```bash
./tools/validate_dns_naming.py --name "my-proposed-name.domain"
```

**Common mistakes:**

- Uppercase letters (use lowercase only)
- Missing service number (must be XX format)
- Wrong domain
- Special characters (use hyphens only)

### Terraform Provider Issues

**Version mismatch:**

```text
Warning: NetBox version 4.3.0 not supported by provider 3.9.0
```

**Solution:** Update provider version:

```hcl
version = "~> 5.0.0"  # Match NetBox 4.3.x
```

### Dynamic Inventory Not Working

**Check API connectivity:**

```bash
curl -H "Authorization: Token YOUR_TOKEN" \
  https://netbox.spaceships.work/api/dcim/devices/
```

**Verify inventory plugin:**

```bash
ansible-inventory -i tools/netbox-dynamic-inventory.yml --list
```

See [troubleshooting/](reference/) for more details.

## Best Practices

1. **Consistent naming** - Always follow `service-NN-purpose.domain` pattern
2. **Use tags** - Tag resources for auto-DNS (`production-dns`, `lab-dns`)
3. **Document in NetBox** - Single source of truth for all infrastructure
4. **Real-time sync** - Enable `post_save_enabled` for immediate DNS updates
5. **Terraform everything** - Manage NetBox resources as IaC
6. **Dynamic inventory** - Never maintain static Ansible inventory
7. **Audit regularly** - Run `dns_record_audit.py` to verify sync

## DNS Naming Patterns

### Service Types

- `docker-NN-<app>` - Docker hosts
- `k8s-NN-<role>` - Kubernetes nodes
- `proxmox-<node>-<iface>` - Proxmox infrastructure
- `storage-NN-<purpose>` - Storage systems
- `db-NN-<dbtype>` - Database servers

### Examples from This Infrastructure

```text
docker-01-nexus.spaceships.work       # Nexus container registry
k8s-01-master.spaceships.work         # K8s control plane
k8s-02-worker.spaceships.work         # K8s worker node
proxmox-foxtrot-mgmt.spaceships.work  # Proxmox mgmt interface
proxmox-foxtrot-ceph.spaceships.work  # Proxmox CEPH interface
storage-01-nas.spaceships.work        # NAS storage
db-01-postgres.spaceships.work        # PostgreSQL database
```

## Progressive Disclosure

For deeper knowledge:

### NetBox API & Integration

- [NetBox API Guide](reference/netbox-api-guide.md) - Complete REST API reference with pynetbox examples
- [NetBox Data Models](reference/netbox-data-models.md) - Data model relationships and hierarchy
- [NetBox Best Practices](reference/netbox-best-practices.md) - Security, performance, automation patterns

### DNS & PowerDNS Integration

- [Sync Plugin Reference](reference/sync-plugin-reference.md) - PowerDNS sync plugin installation and config
- [Terraform Provider Guide](reference/terraform-provider-guide.md) - Complete provider documentation
- [Naming Conventions](workflows/naming-conventions.md) - Detailed DNS naming rules
- [DNS Automation](workflows/dns-automation.md) - End-to-end automation workflows

### Ansible Integration

- [Ansible Dynamic Inventory](workflows/ansible-dynamic-inventory.md) - Using NetBox as inventory source

### Anti-Patterns & Common Mistakes

- [Common Mistakes](anti-patterns/common-mistakes.md) - DNS naming violations, cluster domain errors, master node targeting,
  and NetBox integration pitfalls for spaceships.work infrastructure

## Related Skills

- **Proxmox Infrastructure** - VMs auto-registered in NetBox with DNS
- **Ansible Best Practices** - Dynamic inventory and secrets management


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### reference/netbox-api-guide.md

```markdown
# NetBox REST API Guide

**NetBox Version:** 4.3.0
**API Documentation:** <https://netboxlabs.com/docs/netbox/en/stable/>

Complete reference for working with the NetBox REST API, including authentication, common operations, filtering, pagination, and error handling patterns for the Virgo-Core infrastructure.

---

## Table of Contents

- [Quick Start](#quick-start)
- [Authentication](#authentication)
- [API Endpoints Structure](#api-endpoints-structure)
- [Common Operations](#common-operations)
- [Filtering and Search](#filtering-and-search)
- [Pagination](#pagination)
- [Bulk Operations](#bulk-operations)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
- [Python Client (pynetbox)](#python-client-pynetbox)
- [Best Practices](#best-practices)
- [Security](#security)

---

## Quick Start

### Using curl

```bash
# Get all sites
curl -H "Authorization: Token YOUR_API_TOKEN" \
  https://netbox.spaceships.work/api/dcim/sites/

# Create a new IP address
curl -X POST \
  -H "Authorization: Token YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "192.168.3.10/24",
    "dns_name": "docker-01-nexus.spaceships.work",
    "status": "active",
    "tags": ["production-dns", "terraform"]
  }' \
  https://netbox.spaceships.work/api/ipam/ip-addresses/
```

### Using pynetbox (Python)

```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pynetbox>=7.0.0", "infisical-python>=2.3.3"]
# ///

import pynetbox
from infisical import InfisicalClient

# Get token from Infisical (following Virgo-Core security pattern)
client = InfisicalClient()
token = client.get_secret(
    secret_name="NETBOX_API_TOKEN",
    project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
    environment="prod",
    path="/matrix"
).secret_value

# Connect to NetBox
nb = pynetbox.api('https://netbox.spaceships.work', token=token)

# Query sites
sites = nb.dcim.sites.all()
for site in sites:
    print(f"{site.name}: {site.description}")
```

See [../tools/netbox_api_client.py](../tools/netbox_api_client.py) for complete working example.

---

## Authentication

### Token Authentication

NetBox uses token-based authentication for API access.

#### Creating a Token

1. Log into NetBox web UI
2. Navigate to **User** → **API Tokens**
3. Click **Add Token**
4. Configure permissions (read, write)
5. Copy token (only shown once)

#### Storing Tokens Securely

**❌ NEVER hardcode tokens:**

```python
# DON'T DO THIS
token = "a1b2c3d4e5f6..."  # NEVER hardcode!
```

**✅ Use Infisical (Virgo-Core standard):**

```python
from infisical import InfisicalClient

def get_netbox_token() -> str:
    """Get NetBox API token from Infisical."""
    client = InfisicalClient()
    secret = client.get_secret(
        secret_name="NETBOX_API_TOKEN",
        project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
        environment="prod",
        path="/matrix"
    )
    return secret.secret_value
```

See [netbox-best-practices.md](netbox-best-practices.md#security) for complete security patterns.

#### Using Tokens

**With curl:**

```bash
curl -H "Authorization: Token YOUR_TOKEN" \
  https://netbox.spaceships.work/api/dcim/sites/
```

**With pynetbox:**

```python
import pynetbox
nb = pynetbox.api('https://netbox.spaceships.work', token='YOUR_TOKEN')
```

### Session Authentication

Session authentication is available when using the NetBox web interface (browser-based). Not recommended for API automation.

---

## API Endpoints Structure

All API endpoints are prefixed with `/api/` followed by the app name:

| Prefix | Purpose | Example Endpoints |
|--------|---------|-------------------|
| `/api/dcim/` | Data Center Infrastructure Management | sites, racks, devices, cables |
| `/api/ipam/` | IP Address Management | ip-addresses, prefixes, vlans, vrfs |
| `/api/virtualization/` | Virtual Machines | virtual-machines, clusters |
| `/api/circuits/` | Circuit Management | circuits, providers |
| `/api/tenancy/` | Multi-tenancy | tenants, tenant-groups |
| `/api/extras/` | Additional Features | tags, custom-fields, webhooks |

### Common Endpoint Patterns

All endpoints follow REST conventions:

```text
GET    /api/{app}/{model}/          # List all objects
POST   /api/{app}/{model}/          # Create new object(s)
GET    /api/{app}/{model}/{id}/     # Get specific object
PUT    /api/{app}/{model}/{id}/     # Full update
PATCH  /api/{app}/{model}/{id}/     # Partial update
DELETE /api/{app}/{model}/{id}/     # Delete object
```

---

## Common Operations

### Sites

**List all sites:**

```bash
GET /api/dcim/sites/
```

```python
sites = nb.dcim.sites.all()
```

**Create a site:**

```bash
POST /api/dcim/sites/
{
  "name": "Matrix Cluster",
  "slug": "matrix",
  "status": "active",
  "description": "3-node Proxmox cluster",
  "region": null,
  "tags": ["proxmox", "production"]
}
```

```python
site = nb.dcim.sites.create(
    name="Matrix Cluster",
    slug="matrix",
    status="active",
    description="3-node Proxmox cluster",
    tags=[{"name": "proxmox"}, {"name": "production"}]
)
```

**Get specific site:**

```bash
GET /api/dcim/sites/{id}/
GET /api/dcim/sites/?slug=matrix
```

```python
site = nb.dcim.sites.get(slug='matrix')
```

**Update a site:**

```bash
PATCH /api/dcim/sites/{id}/
{
  "description": "Updated description"
}
```

```python
site.description = "Updated description"
site.save()
```

**Delete a site:**

```bash
DELETE /api/dcim/sites/{id}/
```

```python
site.delete()
```

### Devices

**List devices:**

```bash
GET /api/dcim/devices/
GET /api/dcim/devices/?site=matrix
```

```python
devices = nb.dcim.devices.filter(site='matrix')
```

**Create a device:**

```bash
POST /api/dcim/devices/
{
  "name": "foxtrot",
  "device_type": 1,
  "site": 1,
  "device_role": 3,
  "status": "active",
  "tags": ["proxmox-node"]
}
```

```python
device = nb.dcim.devices.create(
    name="foxtrot",
    device_type=1,
    site=nb.dcim.sites.get(slug='matrix').id,
    device_role=3,
    status="active",
    tags=[{"name": "proxmox-node"}]
)
```

**Get device with related objects:**

```bash
GET /api/dcim/devices/{id}/?include=interfaces,config_context
```

```python
device = nb.dcim.devices.get(name='foxtrot')
interfaces = device.interfaces  # Related interfaces
```

### IP Addresses

**List IP addresses:**

```bash
GET /api/ipam/ip-addresses/
GET /api/ipam/ip-addresses/?vrf=management
```

```python
ips = nb.ipam.ip_addresses.all()
ips_mgmt = nb.ipam.ip_addresses.filter(vrf='management')
```

**Create IP address with DNS:**

```bash
POST /api/ipam/ip-addresses/
{
  "address": "192.168.3.10/24",
  "dns_name": "docker-01-nexus.spaceships.work",
  "status": "active",
  "description": "Docker host for Nexus registry",
  "tags": ["production-dns", "terraform"]
}
```

```python
ip = nb.ipam.ip_addresses.create(
    address="192.168.3.10/24",
    dns_name="docker-01-nexus.spaceships.work",
    status="active",
    description="Docker host for Nexus registry",
    tags=[{"name": "production-dns"}, {"name": "terraform"}]
)
```

**Assign IP to interface:**

```python
ip = nb.ipam.ip_addresses.get(address='192.168.3.10/24')
interface = nb.dcim.interfaces.get(device='foxtrot', name='eth0')

ip.assigned_object_type = 'dcim.interface'
ip.assigned_object_id = interface.id
ip.save()
```

**Get IP with assigned device:**

```bash
GET /api/ipam/ip-addresses/{id}/
```

```python
ip = nb.ipam.ip_addresses.get(address='192.168.3.10/24')
if ip.assigned_object:
    print(f"Assigned to: {ip.assigned_object.device.name}")
```

### Prefixes

**List prefixes:**

```bash
GET /api/ipam/prefixes/
GET /api/ipam/prefixes/?site=matrix
```

```python
prefixes = nb.ipam.prefixes.filter(site='matrix')
```

**Get available IPs in prefix:**

```bash
GET /api/ipam/prefixes/{id}/available-ips/
```

```python
prefix = nb.ipam.prefixes.get(prefix='192.168.3.0/24')
available = prefix.available_ips.list()
print(f"Available IPs: {len(available)}")
```

**Create IP from available pool:**

```bash
POST /api/ipam/prefixes/{id}/available-ips/
{
  "dns_name": "k8s-01-worker.spaceships.work",
  "tags": ["production-dns"]
}
```

```python
prefix = nb.ipam.prefixes.get(prefix='192.168.3.0/24')
ip = prefix.available_ips.create(
    dns_name="k8s-01-worker.spaceships.work",
    tags=[{"name": "production-dns"}]
)
```

### Virtual Machines

**List VMs:**

```bash
GET /api/virtualization/virtual-machines/
GET /api/virtualization/virtual-machines/?cluster=matrix
```

```python
vms = nb.virtualization.virtual_machines.filter(cluster='matrix')
```

**Create VM:**

```bash
POST /api/virtualization/virtual-machines/
{
  "name": "docker-01",
  "cluster": 1,
  "role": 2,
  "vcpus": 4,
  "memory": 8192,
  "disk": 100,
  "status": "active",
  "tags": ["docker", "production"]
}
```

```python
vm = nb.virtualization.virtual_machines.create(
    name="docker-01",
    cluster=nb.virtualization.clusters.get(name='matrix').id,
    vcpus=4,
    memory=8192,
    disk=100,
    status="active",
    tags=[{"name": "docker"}, {"name": "production"}]
)
```

**Create VM interface:**

```python
vm_interface = nb.virtualization.interfaces.create(
    virtual_machine=vm.id,
    name='eth0',
    type='virtual',
    enabled=True
)
```

**Assign IP to VM interface:**

```python
ip = nb.ipam.ip_addresses.create(
    address='192.168.3.10/24',
    dns_name='docker-01-nexus.spaceships.work',
    assigned_object_type='virtualization.vminterface',
    assigned_object_id=vm_interface.id,
    tags=[{"name": "production-dns"}]
)

# Set as primary IP
vm.primary_ip4 = ip.id
vm.save()
```

---

## Filtering and Search

### Query Parameters

Most endpoints support filtering via query parameters:

```bash
# Filter by single value
GET /api/dcim/devices/?site=matrix

# Filter by multiple values (OR logic)
GET /api/dcim/devices/?site=matrix&site=backup

# Exclude values
GET /api/dcim/devices/?site__n=decommissioned

# Partial matching (case-insensitive)
GET /api/dcim/devices/?name__ic=docker

# Starts with
GET /api/dcim/devices/?name__isw=k8s

# Ends with
GET /api/dcim/devices/?name__iew=worker

# Greater than / less than
GET /api/ipam/prefixes/?prefix_length__gte=24

# Empty / not empty
GET /api/dcim/devices/?tenant__isnull=true
```

### Python Filtering

```python
# Single filter
devices = nb.dcim.devices.filter(site='matrix')

# Multiple filters (AND logic)
devices = nb.dcim.devices.filter(site='matrix', status='active')

# Partial matching
devices = nb.dcim.devices.filter(name__ic='docker')

# Greater than
prefixes = nb.ipam.prefixes.filter(prefix_length__gte=24)

# Get single object
device = nb.dcim.devices.get(name='foxtrot')
```

### Full-Text Search

```bash
# Search across all fields
GET /api/dcim/devices/?q=foxtrot
```

```python
results = nb.dcim.devices.filter(q='foxtrot')
```

### Tag Filtering

```bash
# Devices with specific tag
GET /api/dcim/devices/?tag=proxmox-node

# Multiple tags (OR logic)
GET /api/dcim/devices/?tag=proxmox-node&tag=ceph-node
```

```python
devices = nb.dcim.devices.filter(tag='proxmox-node')
```

---

## Pagination

API responses are paginated by default (50 results per page).

### Response Format

```json
{
  "count": 1000,
  "next": "https://netbox.spaceships.work/api/dcim/devices/?limit=50&offset=50",
  "previous": null,
  "results": [...]
}
```

### Controlling Pagination

```bash
# Custom page size (max 1000)
GET /api/dcim/devices/?limit=100

# Skip to offset
GET /api/dcim/devices/?limit=50&offset=100
```

### Python Pagination

```python
# Get all (handles pagination automatically)
all_devices = nb.dcim.devices.all()

# Custom page size
devices = nb.dcim.devices.filter(limit=100)

# Manual pagination
page1 = nb.dcim.devices.filter(limit=50, offset=0)
page2 = nb.dcim.devices.filter(limit=50, offset=50)
```

**⚠️ Warning:** Using `all()` on large datasets can be slow. Use filtering to reduce result set.

---

## Bulk Operations

NetBox supports bulk creation, update, and deletion.

### Bulk Create

**curl:**

```bash
POST /api/dcim/devices/
[
  {"name": "device1", "device_type": 1, "site": 1},
  {"name": "device2", "device_type": 1, "site": 1},
  {"name": "device3", "device_type": 1, "site": 1}
]
```

**pynetbox:**

```python
devices_data = [
    {"name": "device1", "device_type": 1, "site": 1},
    {"name": "device2", "device_type": 1, "site": 1},
    {"name": "device3", "device_type": 1, "site": 1}
]

# Bulk create (more efficient than loop)
devices = nb.dcim.devices.create(devices_data)
```

### Bulk Update

**curl:**

```bash
PUT /api/dcim/devices/
[
  {"id": 1, "status": "active"},
  {"id": 2, "status": "active"},
  {"id": 3, "status": "offline"}
]
```

**pynetbox:**

```python
# Update multiple objects
for device in nb.dcim.devices.filter(site='matrix'):
    device.status = 'active'
    device.save()

# Or bulk update with PUT
updates = [
    {"id": 1, "status": "active"},
    {"id": 2, "status": "active"}
]
nb.dcim.devices.update(updates)
```

### Bulk Delete

**curl:**

```bash
DELETE /api/dcim/devices/
[
  {"id": 1},
  {"id": 2},
  {"id": 3}
]
```

**pynetbox:**

```python
# Delete multiple objects
devices_to_delete = nb.dcim.devices.filter(status='decommissioned')
for device in devices_to_delete:
    device.delete()
```

---

## Error Handling

### HTTP Status Codes

| Code | Meaning | Description |
|------|---------|-------------|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid data or malformed request |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Insufficient permissions |
| 404 | Not Found | Resource not found |
| 500 | Internal Server Error | Server error |

### Error Response Format

```json
{
  "detail": "Not found.",
  "error": "Object does not exist"
}
```

### Python Error Handling

```python
import pynetbox
from requests.exceptions import HTTPError

try:
    device = nb.dcim.devices.get(name='nonexistent')
    if not device:
        print("Device not found")
except HTTPError as e:
    if e.response.status_code == 404:
        print("Resource not found")
    elif e.response.status_code == 403:
        print("Permission denied")
    else:
        print(f"HTTP Error: {e}")
except Exception as e:
    print(f"Unexpected error: {e}")
```

### Validation Errors

```python
try:
    site = nb.dcim.sites.create(
        name="Invalid Site",
        slug="invalid slug"  # Spaces not allowed
    )
except pynetbox.RequestError as e:
    print(f"Validation error: {e.error}")
```

### Best Practices

1. **Always check for None:**

   ```python
   device = nb.dcim.devices.get(name='foo')
   if device:
       print(device.name)
   else:
       print("Device not found")
   ```

2. **Use try/except for create/update:**

   ```python
   try:
       ip = nb.ipam.ip_addresses.create(address='192.168.1.1/24')
   except pynetbox.RequestError as e:
       print(f"Failed to create IP: {e.error}")
   ```

3. **Validate data before API calls:**

   ```python
   import ipaddress

   def validate_ip(ip_str: str) -> bool:
       try:
           ipaddress.ip_interface(ip_str)
           return True
       except ValueError:
           return False
   ```

See [../tools/netbox_api_client.py](../tools/netbox_api_client.py) for complete error handling examples.

---

## Rate Limiting

NetBox may enforce rate limits on API requests.

### Response Headers

```text
X-RateLimit-Limit: 1000        # Total requests allowed
X-RateLimit-Remaining: 950     # Remaining requests
X-RateLimit-Reset: 1640000000  # Reset time (Unix timestamp)
```

### Handling Rate Limits

```python
import time
from requests.exceptions import HTTPError

def api_call_with_retry(func, max_retries=3):
    """Retry API call if rate limited."""
    for attempt in range(max_retries):
        try:
            return func()
        except HTTPError as e:
            if e.response.status_code == 429:  # Rate limited
                retry_after = int(e.response.headers.get('Retry-After', 60))
                print(f"Rate limited. Retrying in {retry_after}s...")
                time.sleep(retry_after)
            else:
                raise
    raise Exception("Max retries exceeded")

# Usage
result = api_call_with_retry(lambda: nb.dcim.devices.all())
```

### Best Practices

1. **Use pagination** to reduce request count
2. **Cache responses** when data doesn't change frequently
3. **Batch operations** using bulk endpoints
4. **Implement exponential backoff** for retries
5. **Monitor rate limit headers** in production

---

## Python Client (pynetbox)

### Installation

```bash
pip install pynetbox
```

Or with uv (Virgo-Core standard):

```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pynetbox>=7.0.0"]
# ///
```

### Basic Usage

```python
import pynetbox

# Connect
nb = pynetbox.api(
    'https://netbox.spaceships.work',
    token='your-token'
)

# Query
sites = nb.dcim.sites.all()
device = nb.dcim.devices.get(name='foxtrot')

# Create
site = nb.dcim.sites.create(
    name='Matrix',
    slug='matrix'
)

# Update
device.status = 'active'
device.save()

# Delete
device.delete()
```

### Advanced Patterns

**Lazy loading:**

```python
# Only fetches when accessed
device = nb.dcim.devices.get(name='foxtrot')
interfaces = device.interfaces  # API call happens here
```

**Custom fields:**

```python
device.custom_fields['serial_number'] = 'ABC123'
device.save()
```

**Relationships:**

```python
# Get device's primary IP
device = nb.dcim.devices.get(name='foxtrot')
if device.primary_ip4:
    print(device.primary_ip4.address)

# Get IP's assigned device
ip = nb.ipam.ip_addresses.get(address='192.168.3.5/24')
if ip.assigned_object:
    print(ip.assigned_object.device.name)
```

**Threading (for bulk operations):**

```python
from concurrent.futures import ThreadPoolExecutor

def get_device_info(device_name):
    return nb.dcim.devices.get(name=device_name)

device_names = ['foxtrot', 'golf', 'hotel']

with ThreadPoolExecutor(max_workers=5) as executor:
    devices = list(executor.map(get_device_info, device_names))
```

---

## Best Practices

### 1. Use Filtering to Reduce Data Transfer

```python
# ❌ Inefficient: Get all devices then filter
all_devices = nb.dcim.devices.all()
matrix_devices = [d for d in all_devices if d.site.slug == 'matrix']

# ✅ Efficient: Filter on server
matrix_devices = nb.dcim.devices.filter(site='matrix')
```

### 2. Use Specific Fields

```bash
# Only get specific fields
GET /api/dcim/devices/?fields=name,status,primary_ip4
```

### 3. Cache Responses

```python
from functools import lru_cache

@lru_cache(maxsize=128)
def get_site(site_slug: str):
    """Cache site lookups."""
    return nb.dcim.sites.get(slug=site_slug)
```

### 4. Validate Before API Calls

```python
import ipaddress
import re

def validate_dns_name(name: str) -> bool:
    """Validate DNS naming convention."""
    pattern = r'^[a-z0-9-]+-\d{2}-[a-z0-9-]+\.[a-z0-9.-]+$'
    return bool(re.match(pattern, name))

def validate_ip(ip_str: str) -> bool:
    """Validate IP address format."""
    try:
        ipaddress.ip_interface(ip_str)
        return True
    except ValueError:
        return False

# Use before API calls
if validate_dns_name(dns_name) and validate_ip(ip_address):
    ip = nb.ipam.ip_addresses.create(
        address=ip_address,
        dns_name=dns_name
    )
```

### 5. Use Bulk Operations

```python
# ❌ Slow: Create in loop
for ip in ip_list:
    nb.ipam.ip_addresses.create(address=ip)

# ✅ Fast: Bulk create
nb.ipam.ip_addresses.create([
    {"address": ip} for ip in ip_list
])
```

### 6. Implement Proper Error Handling

See [Error Handling](#error-handling) section above.

### 7. Use HTTPS in Production

```python
# ✅ Always use HTTPS
nb = pynetbox.api('https://netbox.spaceships.work', token=token)

# ❌ Never use HTTP in production
nb = pynetbox.api('http://netbox.spaceships.work', token=token)
```

### 8. Rotate Tokens Regularly

Store tokens in Infisical and rotate every 90 days. See [Security](#security) section.

---

## Security

### API Token Security

1. **Store in Infisical (never hardcode):**

   ```python
   from infisical import InfisicalClient

   client = InfisicalClient()
   token = client.get_secret(
       secret_name="NETBOX_API_TOKEN",
       project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
       environment="prod",
       path="/matrix"
   ).secret_value
   ```

2. **Use environment variables (alternative):**

   ```python
   import os
   token = os.getenv('NETBOX_API_TOKEN')
   if not token:
       raise ValueError("NETBOX_API_TOKEN not set")
   ```

3. **Rotate tokens regularly** (every 90 days)

4. **Use minimal permissions** (read-only for queries, write for automation)

### HTTPS Only

```python
# ✅ Verify SSL certificates
nb = pynetbox.api(
    'https://netbox.spaceships.work',
    token=token,
    ssl_verify=True  # Default, but explicit is better
)

# ⚠️ Only disable for dev/testing
nb = pynetbox.api(
    'https://netbox.local',
    token=token,
    ssl_verify=False  # Self-signed cert
)
```

### Audit API Usage

Monitor API calls in production:

```python
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def audit_api_call(action: str, resource: str, details: dict):
    """Log API calls for audit."""
    logger.info(f"API Call: {action} {resource} - {details}")

# Example usage
ip = nb.ipam.ip_addresses.create(address='192.168.1.1/24')
audit_api_call('CREATE', 'ip-address', {'address': '192.168.1.1/24'})
```

### Network Security

- Use VPN for remote access to NetBox
- Restrict NetBox API access by IP (firewall rules)
- Use Proxmox VLANs to isolate management traffic

---

## Additional Resources

- **Official API Docs:** <https://netboxlabs.com/docs/netbox/en/stable/>
- **pynetbox Docs:** <https://pynetbox.readthedocs.io/>
- **OpenAPI Schema:** `GET https://netbox.spaceships.work/api/schema/`
- **GraphQL API:** `https://netbox.spaceships.work/graphql/`

## Related Documentation

- [NetBox Data Models](netbox-data-models.md) - Data model relationships
- [NetBox Best Practices](netbox-best-practices.md) - Infrastructure patterns
- [Tools: netbox_api_client.py](../tools/netbox_api_client.py) - Complete working example
- [DNS Naming Conventions](../workflows/naming-conventions.md) - DNS naming rules

---

**Next:** [NetBox Data Models Guide](netbox-data-models.md)

```

### workflows/naming-conventions.md

```markdown
# DNS Naming Conventions

## Overview

Consistent DNS naming is critical for automation and infrastructure documentation. This document defines the naming
conventions used in the Virgo-Core infrastructure.

## Standard Pattern

**Format:** `<service>-<number>-<purpose>.<domain>`

**Components:**

- `<service>` - Service type (docker, k8s, proxmox, storage, db, etc.)
- `<number>` - Instance number (01, 02, 03, etc.) - always 2 digits
- `<purpose>` - Specific purpose or application name
- `<domain>` - DNS domain (e.g., spaceships.work)

**Regex Pattern:**

```regex
^[a-z0-9-]+-\d{2}-[a-z0-9-]+\.[a-z0-9.-]+$
```

## Service Types

### Container Platforms

**Docker hosts:**

```text
docker-01-nexus.spaceships.work          # Nexus container registry
docker-02-gitlab.spaceships.work         # GitLab CI/CD
docker-03-monitoring.spaceships.work     # Monitoring stack (Prometheus, Grafana)
```

**Kubernetes nodes:**

```text
k8s-01-master.spaceships.work            # Control plane node 1
k8s-02-master.spaceships.work            # Control plane node 2
k8s-03-master.spaceships.work            # Control plane node 3
k8s-04-worker.spaceships.work            # Worker node 1
k8s-05-worker.spaceships.work            # Worker node 2
```

### Infrastructure

**Proxmox nodes:**

```text
proxmox-foxtrot-mgmt.spaceships.work     # Foxtrot management interface
proxmox-foxtrot-ceph.spaceships.work     # Foxtrot CEPH public interface
proxmox-golf-mgmt.spaceships.work        # Golf management interface
proxmox-hotel-mgmt.spaceships.work       # Hotel management interface
```

**Storage systems:**

```text
storage-01-nas.spaceships.work           # NAS storage (TrueNAS/FreeNAS)
storage-02-backup.spaceships.work        # Backup storage
storage-03-archive.spaceships.work       # Long-term archive storage
```

### Databases

```text
db-01-postgres.spaceships.work           # PostgreSQL primary
db-02-postgres.spaceships.work           # PostgreSQL replica
db-03-mysql.spaceships.work              # MySQL/MariaDB
db-04-redis.spaceships.work              # Redis cache
```

### Network Services

```text
network-01-pfsense.spaceships.work       # pfSense router
network-02-unifi.spaceships.work         # UniFi controller
network-03-dns.spaceships.work           # DNS server (PowerDNS)
network-04-dhcp.spaceships.work          # DHCP server
```

### Application Services

```text
app-01-netbox.spaceships.work            # NetBox IPAM
app-02-vault.spaceships.work             # HashiCorp Vault
app-03-consul.spaceships.work            # HashiCorp Consul
app-04-nomad.spaceships.work             # HashiCorp Nomad
```

## Special Cases

### Management Interfaces

For infrastructure with multiple interfaces, include interface purpose:

```text
proxmox-<node>-mgmt.spaceships.work      # Management network
proxmox-<node>-ceph.spaceships.work      # CEPH public network
proxmox-<node>-backup.spaceships.work    # Backup network
```

### Virtual IPs (FHRP/VIPs)

```text
vip-01-k8s-api.spaceships.work           # Kubernetes API VIP
vip-02-haproxy.spaceships.work           # HAProxy VIP
vip-03-postgres.spaceships.work          # PostgreSQL VIP
```

### Service Endpoints

```text
service-01-api.spaceships.work           # API endpoint
service-02-web.spaceships.work           # Web frontend
service-03-cdn.spaceships.work           # CDN endpoint
```

## Rules and Best Practices

### Mandatory Rules

1. **Always lowercase** - No uppercase letters
2. **Hyphens only** - No underscores or other special characters
3. **Two-digit numbers** - Use 01, 02, not 1, 2
4. **Descriptive purpose** - Purpose should clearly indicate function
5. **Valid DNS characters** - Only `a-z`, `0-9`, `-`, `.`

### Recommended Practices

1. **Consistent service names** - Stick to established service types
2. **Logical numbering** - Start at 01, increment sequentially
3. **Purpose specificity** - Be specific but concise (nexus, not nexus-container-registry)
4. **Avoid ambiguity** - Don't use `test-01-prod` or similar confusing names
5. **Document exceptions** - If you must break a rule, document why

## Validation

### Python Validation Script

```python
#!/usr/bin/env python3
# /// script
# dependencies = []
# ///

import re
import sys

PATTERN = r'^[a-z0-9-]+-\d{2}-[a-z0-9-]+\.[a-z0-9.-]+$'

def validate_dns_name(name: str) -> tuple[bool, str]:
    """Validate DNS name against convention."""
    if not re.match(PATTERN, name):
        return False, "Name doesn't match pattern: <service>-<NN>-<purpose>.<domain>"

    parts = name.split('.')
    if len(parts) < 2:
        return False, "Must include domain"

    hostname = parts[0]
    components = hostname.split('-')

    if len(components) < 3:
        return False, "Hostname must have at least 3 components: <service>-<NN>-<purpose>"

    # Check number component (should be 2 digits)
    number_component = components[1]
    if not number_component.isdigit() or len(number_component) != 2:
        return False, f"Number component '{number_component}' must be exactly 2 digits (01-99)"

    return True, "Valid"

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: validate_dns_naming.py <dns-name>")
        sys.exit(1)

    name = sys.argv[1]
    valid, message = validate_dns_name(name)

    if valid:
        print(f"✓ {name}: {message}")
        sys.exit(0)
    else:
        print(f"✗ {name}: {message}", file=sys.stderr)
        sys.exit(1)
```

**Usage:**

```bash
./tools/validate_dns_naming.py docker-01-nexus.spaceships.work
# ✓ docker-01-nexus.spaceships.work: Valid

./tools/validate_dns_naming.py Docker-1-Nexus.spaceships.work
# ✗ Docker-1-Nexus.spaceships.work: Name doesn't match pattern
```

## NetBox Integration

### Setting DNS Names in NetBox

**Via Web UI:**

IP Addresses → Add → DNS Name field: `docker-01-nexus.spaceships.work`

**Via Terraform:**

```hcl
resource "netbox_ip_address" "docker_nexus" {
  ip_address  = "192.168.1.100/24"
  dns_name    = "docker-01-nexus.spaceships.work"
  description = "Docker host for Nexus container registry"

  tags = ["terraform", "production-dns"]
}
```

**Via Ansible:**

```yaml
- name: Create IP address in NetBox
  netbox.netbox.netbox_ip_address:
    netbox_url: "{{ netbox_url }}"
    netbox_token: "{{ netbox_token }}"
    data:
      address: "192.168.1.100/24"
      dns_name: "docker-01-nexus.spaceships.work"
      tags:
        - name: production-dns
```

### Tagging for Auto-DNS

Tag IP addresses to trigger automatic DNS record creation:

**Production DNS:**

```text
Tag: production-dns
Zone: spaceships.work
```

**Development DNS:**

```text
Tag: dev-dns
Zone: dev.spaceships.work
```

**Lab/Testing DNS:**

```text
Tag: lab-dns
Zone: lab.spaceships.work
```

## PowerDNS Record Generation

### Automatic Record Creation

When an IP address with correct tags is created in NetBox:

```text
IP: 192.168.1.100/24
DNS Name: docker-01-nexus.spaceships.work
Tag: production-dns

→ A record created: docker-01-nexus.spaceships.work → 192.168.1.100
→ PTR record created: 100.1.168.192.in-addr.arpa → docker-01-nexus.spaceships.work
```

### Sync Verification

```bash
# Verify DNS record exists
dig @192.168.3.1 docker-01-nexus.spaceships.work +short
# Should return: 192.168.1.100

# Verify PTR record
dig @192.168.3.1 -x 192.168.1.100 +short
# Should return: docker-01-nexus.spaceships.work
```

## Common Mistakes

### Wrong Number Format

```text
❌ docker-1-nexus.spaceships.work         # Single digit
✓  docker-01-nexus.spaceships.work        # Two digits

❌ docker-001-nexus.spaceships.work       # Three digits
✓  docker-01-nexus.spaceships.work        # Two digits
```

### Wrong Separators

```text
❌ docker_01_nexus.spaceships.work        # Underscores
✓  docker-01-nexus.spaceships.work        # Hyphens

❌ docker.01.nexus.spaceships.work        # Dots in hostname
✓  docker-01-nexus.spaceships.work        # Hyphens only
```

### Wrong Case

```text
❌ Docker-01-Nexus.spaceships.work        # Mixed case
✓  docker-01-nexus.spaceships.work        # Lowercase only

❌ DOCKER-01-NEXUS.SPACESHIPS.WORK        # Uppercase
✓  docker-01-nexus.spaceships.work        # Lowercase only
```

### Missing Components

```text
❌ docker-nexus.spaceships.work           # Missing number
✓  docker-01-nexus.spaceships.work        # Complete

❌ 01-nexus.spaceships.work               # Missing service
✓  docker-01-nexus.spaceships.work        # Complete

❌ docker-01.spaceships.work              # Missing purpose
✓  docker-01-nexus.spaceships.work        # Complete
```

## Migration Strategy

### From Legacy Names

If you have existing DNS names that don't follow the convention:

1. **Document current names** - Create inventory of legacy names
2. **Create new names** - Following convention
3. **Create CNAME records** - Point legacy names to new names
4. **Update configs gradually** - Migrate services to use new names
5. **Monitor usage** - Track legacy CNAME usage
6. **Deprecate legacy names** - Remove after migration complete

**Example migration:**

```text
Legacy:  nexus.spaceships.work
New:     docker-01-nexus.spaceships.work
CNAME:   nexus.spaceships.work → docker-01-nexus.spaceships.work

After 6 months: Remove CNAME, update all references to use new name
```

## Environment-Specific Domains

### Production

```text
Domain: spaceships.work
Example: docker-01-nexus.spaceships.work
```

### Development

```text
Domain: dev.spaceships.work
Example: docker-01-nexus.dev.spaceships.work
```

### Lab/Testing

```text
Domain: lab.spaceships.work
Example: docker-01-nexus.lab.spaceships.work
```

## Documentation

### In NetBox

Use the **Description** field to document:

- Primary purpose
- Hosted applications
- Related services
- Contact owner

**Example:**

```text
IP: 192.168.1.100/24
DNS Name: docker-01-nexus.spaceships.work
Description: Docker host for Nexus container registry.
             Serves Docker and Maven artifacts.
             Owner: Platform Team
             Related: docker-02-gitlab.spaceships.work
```

### In Infrastructure Code

**Terraform example:**

```hcl
resource "netbox_ip_address" "docker_nexus" {
  ip_address  = "192.168.1.100/24"
  dns_name    = "docker-01-nexus.spaceships.work"
  description = "Docker host for Nexus container registry"

  tags = ["terraform", "production-dns", "docker-host", "nexus"]
}
```

## Further Reading

- [RFC 1035 - Domain Names](https://www.rfc-editor.org/rfc/rfc1035)
- [DNS Best Practices](https://www.ietf.org/rfc/rfc1912.txt)

```

### reference/sync-plugin-reference.md

```markdown
# NetBox PowerDNS Sync Plugin Reference

*Source: <https://github.com/ArnesSI/netbox-powerdns-sync*>

## Overview

A NetBox plugin that automatically generates DNS records in PowerDNS based on NetBox IP Address and Device objects.

## Features

- Automatically generates A, AAAA & PTR records
- Manages multiple DNS Zones across multiple PowerDNS servers
- Flexible rules to match NetBox IP Addresses into DNS zones
- Multiple options to generate DNS names from IP address or Device
- Scheduled sync of DNS zones
- Can add DNS records for new zones immediately
- Per-zone synchronization schedule

## DNS Name Generation

### Zone Matching Priority

When determining the zone for an IP Address, match rules are evaluated in this order:

1. Check if `IPAddress.dns_name` matches any zone
2. Check if IPAddress is assigned to Device/VirtualMachine and if its name matches any zone
3. Check if IPAddress is assigned to FHRPGroup and if its name matches any zone
4. Try to match based on assigned tags (in order):
   - `IPAddress.tags`
   - `Interface.tags`
   - `VMInterface.tags`
   - `Device.tags`
   - `VirtualMachine.tags`
   - `Device.device_role`
   - `VM.role`
5. Use default zone if configured

### Name Generation Methods

Each zone can use multiple naming methods (tried in order):

1. **IP naming method** - Generate name from IP address
2. **Device naming method** - Generate name from Device/VirtualMachine
3. **FHRP group method** - Generate name from FHRP Group

## Installation

### Via pip

```bash
# Activate NetBox virtual environment
source /opt/netbox/venv/bin/activate

# Install plugin
pip install netbox-powerdns-sync
```

### From GitHub

```bash
pip install git+https://github.com/ArnesSI/netbox-powerdns-sync.git@master
```

### Configuration

Add to `/opt/netbox/netbox/netbox/configuration.py`:

```python
PLUGINS = [
    'netbox_powerdns_sync'
]

PLUGINS_CONFIG = {
    "netbox_powerdns_sync": {
        "ttl_custom_field": "",
        "powerdns_managed_record_comment": "netbox-powerdns-sync",
        "post_save_enabled": False,
    },
}
```

### Apply Migrations

```bash
cd /opt/netbox/netbox/
python3 manage.py migrate
python3 manage.py reindex --lazy
```

## Configuration Settings

| Setting | Default | Description |
|---------|---------|-------------|
| `ttl_custom_field` | `None` | Name of NetBox Custom field applied to IP Address objects for TTL |
| `powerdns_managed_record_comment` | `"netbox-powerdns-sync"` | Plugin only touches records with this comment. Set to `None` to manage all records |
| `post_save_enabled` | `False` | Immediately create DNS records when creating/updating IP Address, Device, or FHRP Group |

### Custom TTL Field

To set TTL per DNS record:

1. Create NetBox Custom Field:
   - Type: Integer
   - Apply to: IP Address objects
   - Name: e.g., "dns_ttl"

2. Set in plugin config:

   ```python
   "ttl_custom_field": "dns_ttl"
   ```

3. Set TTL value on IP Address objects in NetBox

## Compatibility

| NetBox Version | Plugin Version |
|---------------|----------------|
| 3.5.0-7 | 0.0.1 - 0.0.6 |
| 3.5.8 | 0.0.7 |
| 3.6.x | 0.8.0 |

## Usage Workflow

### 1. Configure DNS Zones in NetBox

Create zones in the plugin interface with:

- Zone name (e.g., `spaceships.work`)
- PowerDNS server connection
- Tag matching rules
- DNS name generation method

### 2. Tag Resources

Apply tags to IP Addresses, Devices, or Interfaces to match zones:

```python
# Example: Tag IP for specific zone
ipaddress.tags.add("production-dns")
```

### 3. Schedule Sync

Configure sync schedule for each zone:

- Immediate (on save)
- Scheduled (cron-style)
- Manual only

### 4. Monitor Sync Results

View sync results in NetBox:

- Records created
- Records updated
- Records deleted
- Sync errors

## Best Practices

### DNS Naming Conventions

For homelab naming like `docker-01-nexus.spaceships.work`:

1. Use Device name as base: `docker-01-nexus`
2. Zone maps to domain: `spaceships.work`
3. Set device naming method in zone config

### Tag Organization

```python
# Production resources
tags: ["production", "dns-auto"]

# Development resources
tags: ["development", "dns-dev"]
```

### TTL Strategy

- Default TTL in zone: 300 (5 minutes)
- Override with custom field for specific records
- Longer TTL for stable infrastructure (3600)
- Shorter TTL for dynamic services (60-300)

### PowerDNS Server Management

- Configure multiple PowerDNS servers for HA
- Use different servers for different zones
- Monitor PowerDNS API connectivity

## Integration Patterns

### With Terraform

Use NetBox as data source, sync DNS automatically:

```hcl
# Terraform creates resource in NetBox
resource "netbox_ip_address" "server" {
  ip_address = "192.168.1.100/24"
  dns_name   = "docker-01-nexus"
  tags       = ["production-dns"]
}

# Plugin automatically creates DNS in PowerDNS
# A record: docker-01-nexus.spaceships.work -> 192.168.1.100
# PTR record: 100.1.168.192.in-addr.arpa -> docker-01-nexus.spaceships.work
```

### With Ansible

Use NetBox dynamic inventory with automatic DNS:

```yaml
---
# Ansible creates VM in Proxmox
- name: Create VM
  proxmox_kvm:
    name: docker-01-nexus
    # ... vm config ...

# Add to NetBox via API
- name: Register in NetBox
  netbox.netbox.netbox_ip_address:
    data:
      address: "192.168.1.100/24"
      dns_name: "docker-01-nexus"
      tags:
        - production-dns

# DNS records created automatically by plugin
```

## Troubleshooting

### Records Not Syncing

1. Check zone matching rules
2. Verify tags applied correctly
3. Check PowerDNS API connectivity
4. Review sync results for errors

### Duplicate Records

If `powerdns_managed_record_comment` is `None`, plugin manages ALL records. Set a comment to limit scope:

```python
"powerdns_managed_record_comment": "netbox-managed"
```

### Performance Issues

- Disable `post_save_enabled` for large environments
- Use scheduled sync instead
- Batch changes before sync

### Name Generation Not Working

1. Check zone name generation method configuration
2. Verify Device/IP naming follows expected pattern
3. Test with manual sync first

## API Endpoints

Plugin adds REST API endpoints:

- `/api/plugins/netbox-powerdns-sync/zones/` - List/manage zones
- `/api/plugins/netbox-powerdns-sync/servers/` - PowerDNS servers
- `/api/plugins/netbox-powerdns-sync/sync-results/` - Sync history

## Example Configuration

### Zone for Production

```python
zone_config = {
    "name": "spaceships.work",
    "server": powerdns_server_prod,
    "default_ttl": 300,
    "naming_methods": ["device", "ip"],
    "tag_match": ["production-dns"],
    "auto_sync": True,
    "sync_schedule": "*/15 * * * *"  # Every 15 minutes
}
```

### Zone for Lab

```python
zone_config = {
    "name": "lab.spaceships.work",
    "server": powerdns_server_dev,
    "default_ttl": 60,
    "naming_methods": ["ip", "device"],
    "tag_match": ["lab-dns"],
    "auto_sync": False  # Manual sync only
}
```

```

### reference/terraform-provider-guide.md

```markdown
# Terraform NetBox Provider Guide

*Source: <https://registry.terraform.io/providers/e-breuninger/netbox/latest/docs*>

## Overview

The Terraform NetBox provider enables full lifecycle management of NetBox resources using Infrastructure as Code.

## Version Compatibility

| NetBox Version | Provider Version |
|---------------|------------------|
| v4.3.0 - 4.4.0 | v5.0.0 and up |
| v4.2.2 - 4.2.9 | v4.0.0 - 4.3.1 |
| v4.1.0 - 4.1.11 | v3.10.0 - 3.11.1 |
| v4.0.0 - 4.0.11 | v3.9.0 - 3.9.2 |
| v3.7.0 - 3.7.8 | v3.8.0 - 3.8.9 |
| v3.6.0 - 3.6.9 | v3.7.0 - 3.7.7 |
| v3.5.1 - 3.5.9 | v3.6.x |

**Important**: NetBox makes breaking API changes even in non-major releases. Match provider version to NetBox version.

## Provider Configuration

### Basic Setup

```hcl
terraform {
  required_providers {
    netbox = {
      source  = "e-breuninger/netbox"
      version = "~> 5.0.0"  # Match your NetBox version
    }
  }
}

provider "netbox" {
  server_url = "https://netbox.spaceships.work"
  api_token  = var.netbox_api_token
}
```

### Environment Variables

Configure via environment instead of hard-coding:

```bash
export NETBOX_SERVER_URL="https://netbox.spaceships.work"
export NETBOX_API_TOKEN="your-api-token-here"
```

```hcl
# Provider auto-reads from environment
provider "netbox" {}
```

## Configuration Schema

### Required

- `api_token` (String) - NetBox API authentication token
  - Environment: `NETBOX_API_TOKEN`
- `server_url` (String) - NetBox server URL (with scheme and port)
  - Environment: `NETBOX_SERVER_URL`

### Optional

- `allow_insecure_https` (Boolean) - Allow invalid certificates
  - Environment: `NETBOX_ALLOW_INSECURE_HTTPS`
  - Default: `false`

- `ca_cert_file` (String) - Path to PEM-encoded CA certificate
  - Environment: `NETBOX_CA_CERT_FILE`

- `default_tags` (Set of String) - Tags added to every resource
  - Useful for tracking Terraform-managed resources

- `headers` (Map of String) - Custom headers for all requests
  - Environment: `NETBOX_HEADERS`

- `request_timeout` (Number) - HTTP request timeout (seconds)
  - Environment: `NETBOX_REQUEST_TIMEOUT`

- `skip_version_check` (Boolean) - Skip NetBox version validation
  - Environment: `NETBOX_SKIP_VERSION_CHECK`
  - Default: `false`
  - Useful for: Testing, unsupported versions

- `strip_trailing_slashes_from_url` (Boolean) - Auto-fix URL format
  - Environment: `NETBOX_STRIP_TRAILING_SLASHES_FROM_URL`
  - Default: `true`

## Usage Examples

### Create IP Address

```hcl
resource "netbox_ip_address" "server_ip" {
  ip_address  = "192.168.1.100/24"
  dns_name    = "docker-01-nexus.spaceships.work"
  status      = "active"
  description = "Docker host - Nexus container registry"

  tags = [
    "terraform",
    "production",
    "dns-auto"
  ]
}
```

### Create Device

```hcl
resource "netbox_device" "proxmox_node" {
  name        = "foxtrot"
  device_type = netbox_device_type.minisforum_ms_a2.id
  role        = netbox_device_role.hypervisor.id
  site        = netbox_site.homelab.id

  primary_ip4 = netbox_ip_address.foxtrot_mgmt.id

  tags = [
    "terraform",
    "proxmox",
    "cluster-matrix"
  ]

  comments = "Proxmox node in Matrix cluster - AMD Ryzen 9 9955HX"
}
```

### Create Prefix

```hcl
resource "netbox_prefix" "vlan_30_mgmt" {
  prefix      = "192.168.3.0/24"
  vlan        = netbox_vlan.management.id
  status      = "active"
  description = "Management network for Proxmox cluster"

  tags = [
    "terraform",
    "mgmt-network"
  ]
}
```

### Create VLAN

```hcl
resource "netbox_vlan" "management" {
  vid         = 30
  name        = "MGMT"
  site        = netbox_site.homelab.id
  status      = "active"
  description = "Management VLAN for infrastructure"

  tags = ["terraform"]
}
```

## Integration Patterns

### With Proxmox Provider

```hcl
# Create VM in Proxmox
resource "proxmox_vm_qemu" "docker_host" {
  name        = "docker-01-nexus"
  target_node = "foxtrot"
  # ... vm config ...
}

# Register in NetBox
resource "netbox_ip_address" "docker_host_ip" {
  ip_address  = "192.168.1.100/24"
  dns_name    = "${proxmox_vm_qemu.docker_host.name}.spaceships.work"
  description = "Docker host for Nexus registry"

  tags = [
    "terraform",
    "production-dns",
    "docker-host"
  ]
}

# DNS record auto-created by netbox-powerdns-sync plugin
```

### Data Sources

Query existing NetBox data:

```hcl
# Get all production IPs
data "netbox_ip_addresses" "production" {
  filter {
    name  = "tag"
    value = "production"
  }
}

# Get device details
data "netbox_device" "proxmox_node" {
  name = "foxtrot"
}

# Use in other resources
resource "proxmox_vm_qemu" "new_vm" {
  target_node = data.netbox_device.proxmox_node.name
  # ... config ...
}
```

### Dynamic Inventory for Ansible

```hcl
# Export NetBox data for Ansible
output "ansible_inventory" {
  value = {
    for device in data.netbox_devices.all.devices :
    device.name => {
      ansible_host = device.primary_ip4_address
      device_role  = device.role
      site         = device.site
      tags         = device.tags
    }
  }
}
```

Save to file:

```bash
terraform output -json ansible_inventory > inventory.json
```

## Best Practices

### 1. Use Default Tags

Track all Terraform-managed resources:

```hcl
provider "netbox" {
  server_url   = var.netbox_url
  api_token    = var.netbox_token
  default_tags = ["terraform", "iac"]
}
```

### 2. Organize with Modules

```hcl
module "vm_network" {
  source = "./modules/netbox-vm"

  vm_name    = "docker-01"
  ip_address = "192.168.1.100/24"
  vlan_id    = 30
  dns_zone   = "spaceships.work"
}
```

### 3. Use Variables for Secrets

Never hard-code tokens:

```hcl
variable "netbox_api_token" {
  description = "NetBox API token"
  type        = string
  sensitive   = true
}
```

### 4. State Management

Use remote state for team collaboration:

```hcl
terraform {
  backend "s3" {
    bucket = "terraform-state"
    key    = "netbox/terraform.tfstate"
    region = "us-east-1"
  }
}
```

### 5. Version Pinning

Pin provider version to prevent breaking changes:

```hcl
terraform {
  required_providers {
    netbox = {
      source  = "e-breuninger/netbox"
      version = "= 5.0.0"  # Exact version
    }
  }
}
```

## Common Workflows

### 1. VM Provisioning Workflow

```hcl
# 1. Reserve IP in NetBox
resource "netbox_ip_address" "vm_ip" {
  ip_address  = "192.168.1.100/24"
  dns_name    = "app-server.spaceships.work"
  status      = "reserved"
  description = "Reserved for new application server"
}

# 2. Create VM in Proxmox
resource "proxmox_vm_qemu" "app_server" {
  # ... config using netbox_ip_address.vm_ip.ip_address ...
}

# 3. Mark IP as active
resource "netbox_ip_address" "vm_ip_active" {
  ip_address  = netbox_ip_address.vm_ip.ip_address
  status      = "active"  # Update status
  description = "Application server - deployed ${timestamp()}"
}
```

### 2. DNS Automation Workflow

```hcl
# Create IP with DNS name and auto-DNS tag
resource "netbox_ip_address" "service" {
  ip_address  = "192.168.1.200/24"
  dns_name    = "service-01-api.spaceships.work"

  tags = [
    "terraform",
    "production-dns"  # Triggers netbox-powerdns-sync
  ]
}

# DNS records created automatically by plugin
# No manual DNS configuration needed
```

### 3. Network Documentation Workflow

```hcl
# Document entire network in NetBox
module "network_documentation" {
  source = "./modules/network"

  site_name = "homelab"

  vlans = {
    "mgmt"    = { vid = 30, prefix = "192.168.3.0/24" }
    "storage" = { vid = 40, prefix = "192.168.5.0/24" }
    "ceph"    = { vid = 50, prefix = "192.168.7.0/24" }
  }

  devices = var.proxmox_nodes
}
```

## Troubleshooting

### Version Mismatch Warning

```text
Warning: NetBox version X.Y.Z is not officially supported by provider version A.B.C
```

**Solution**: Use matching provider version or set `skip_version_check = true`

### API Authentication Errors

```text
Error: authentication failed
```

**Solution**:

1. Verify `api_token` is valid
2. Check token has required permissions
3. Ensure `server_url` includes scheme (`https://`)

### SSL Certificate Errors

```text
Error: x509: certificate signed by unknown authority
```

**Solution**:

```hcl
provider "netbox" {
  server_url          = var.netbox_url
  api_token           = var.netbox_token
  ca_cert_file        = "/path/to/ca.pem"
  # OR for dev/testing only:
  # allow_insecure_https = true
}
```

### Trailing Slash Issues

```text
Error: invalid URL format
```

**Solution**: Remove trailing slashes from `server_url` or let provider auto-fix:

```hcl
provider "netbox" {
  server_url = "https://netbox.example.com"  # No trailing slash
  strip_trailing_slashes_from_url = true     # Auto-fix if present
}
```

## Further Resources

- [Provider GitHub Repository](https://github.com/e-breuninger/terraform-provider-netbox)
- [NetBox Official Documentation](https://docs.netbox.dev/)
- [NetBox API Reference](https://demo.netbox.dev/api/docs/)

```

### workflows/ansible-dynamic-inventory.md

```markdown
# Ansible Dynamic Inventory from NetBox

## Overview

Use NetBox as a dynamic inventory source for Ansible, eliminating the need for static inventory
files and ensuring your automation always has up-to-date infrastructure data.

## Architecture

```text
┌──────────┐
│  NetBox  │ (Source of Truth)
│   IPAM   │
└────┬─────┘
     │
     │ API Query
     │
     ▼
┌────────────────┐
│ nb_inventory   │ (Ansible Plugin)
│    Plugin      │
└────┬───────────┘
     │
     │ Generates Dynamic Inventory
     │
     ▼
┌────────────────┐
│   Ansible      │ (Uses inventory for playbooks)
│  Playbooks     │
└────────────────┘
```

## Prerequisites

### Install NetBox Ansible Collection

```bash
cd ansible
uv run ansible-galaxy collection install netbox.netbox
```

**Or add to requirements.yml:**

```yaml
---
collections:
  - name: netbox.netbox
    version: ">=3.0.0"
```

```bash
uv run ansible-galaxy collection install -r requirements.yml
```

### NetBox API Token

Create read-only API token in NetBox:

**NetBox UI:** Admin → API Tokens → Add

- User: ansible (create service user)
- Key: Generated automatically
- Write enabled: No (read-only)

**Save token securely:**

```bash
# Option 1: Environment variable
export NETBOX_API_TOKEN="your-token-here"

# Option 2: Ansible Vault
ansible-vault create group_vars/all/vault.yml
# Add: netbox_token: "your-token-here"
```

## Basic Configuration

### Create Inventory File

**File:** `ansible/inventory/netbox.yml`

```yaml
---
plugin: netbox.netbox.nb_inventory

# NetBox API connection
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  ...

# Validate SSL (set to false for self-signed certs)
validate_certs: true

# Group hosts by these NetBox attributes
group_by:
  - device_roles
  - tags
  - sites
  - platforms

# Set ansible_host variable from primary_ip4
compose:
  ansible_host: primary_ip4

# Only include active devices/VMs
query_filters:
  - status: active
```

### Test Inventory

```bash
# List all hosts
ansible-inventory -i ansible/inventory/netbox.yml --list

# View in YAML format
ansible-inventory -i ansible/inventory/netbox.yml --list --yaml

# View specific host
ansible-inventory -i ansible/inventory/netbox.yml --host docker-01-nexus

# Graph inventory
ansible-inventory -i ansible/inventory/netbox.yml --graph
```

## Advanced Configuration

### Filter by Tags

**Only include hosts with specific tag:**

```yaml
---
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...

# Only hosts tagged with "ansible-managed"
query_filters:
  - tag: ansible-managed
  - status: active

group_by:
  - tags
```

### Filter by Device Role

**Only include specific device roles:**

```yaml
query_filters:
  - role: docker-host
  - role: k8s-node
  - status: active
```

### Custom Groups

**Create custom groups based on NetBox data:**

```yaml
---
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...

group_by:
  - device_roles
  - tags
  - sites

# Custom group mappings
keyed_groups:
  - key: tags
    prefix: tag
  - key: device_role.name
    prefix: role
  - key: platform.name
    prefix: platform

compose:
  ansible_host: primary_ip4
  ansible_user: ansible
  ansible_become: true
```

### Include Custom Fields

**Use NetBox custom fields in inventory:**

```yaml
---
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...

compose:
  ansible_host: primary_ip4

  # Use custom fields from NetBox
  backup_schedule: custom_fields.backup_schedule
  monitoring_enabled: custom_fields.monitoring_enabled
  application_owner: custom_fields.owner

group_by:
  - tags
  - custom_fields.environment
```

## Usage Examples

### Example 1: Configure All Docker Hosts

**Inventory:** `ansible/inventory/netbox.yml`

```yaml
---
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...

query_filters:
  - tag: docker-host
  - status: active

group_by:
  - tags

compose:
  ansible_host: primary_ip4
  ansible_user: ansible
  ansible_become: true
```

**Playbook:** `ansible/playbooks/configure-docker-hosts.yml`

```yaml
---
- name: Configure Docker hosts from NetBox inventory
  hosts: tag_docker_host
  become: true

  tasks:
    - name: Ensure Docker is running
      ansible.builtin.systemd:
        name: docker
        state: started
        enabled: true

    - name: Update Docker daemon config
      ansible.builtin.copy:
        dest: /etc/docker/daemon.json
        content: |
          {
            "log-driver": "json-file",
            "log-opts": {
              "max-size": "10m",
              "max-file": "3"
            }
          }
      notify: Restart Docker

  handlers:
    - name: Restart Docker
      ansible.builtin.systemd:
        name: docker
        state: restarted
```

**Run playbook:**

```bash
cd ansible
uv run ansible-playbook -i inventory/netbox.yml playbooks/configure-docker-hosts.yml
```

### Example 2: Site-Specific Deployments

**Inventory with site grouping:**

```yaml
---
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...

group_by:
  - sites
  - tags

compose:
  ansible_host: primary_ip4

query_filters:
  - status: active
```

**Playbook targeting specific site:**

```yaml
---
- name: Update hosts at primary site
  hosts: site_homelab  # Automatically grouped by site name
  become: true

  tasks:
    - name: Update all packages
      ansible.builtin.apt:
        upgrade: dist
        update_cache: true
      when: ansible_os_family == "Debian"
```

### Example 3: Platform-Specific Configuration

**Inventory:**

```yaml
---
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...

group_by:
  - platforms

compose:
  ansible_host: primary_ip4

keyed_groups:
  - key: platform.name
    prefix: platform
```

**Playbook with platform-specific tasks:**

```yaml
---
- name: Platform-specific configuration
  hosts: all
  become: true

  tasks:
    - name: Configure Ubuntu hosts
      ansible.builtin.apt:
        name: netbox-agent
        state: present
      when: "'ubuntu' in group_names"

    - name: Configure Rocky hosts
      ansible.builtin.dnf:
        name: netbox-agent
        state: present
      when: "'rocky' in group_names"
```

## Integration with Secrets Management

### Use with Infisical

**Combine dynamic inventory with Infisical secrets:**

```yaml
---
- name: Deploy app with NetBox inventory and Infisical secrets
  hosts: tag_app_server
  become: true

  vars:
    infisical_project_id: "7b832220-24c0-45bc-a5f1-ce9794a31259"
    infisical_env: "prod"
    infisical_path: "/app-config"

  tasks:
    - name: Retrieve database password
      ansible.builtin.include_tasks: "{{ playbook_dir }}/../tasks/infisical-secret-lookup.yml"
      vars:
        secret_name: 'DB_PASSWORD'
        secret_var_name: 'db_password'

    - name: Deploy application config
      ansible.builtin.template:
        src: app-config.j2
        dest: /etc/app/config.yml
        owner: app
        group: app
        mode: '0600'
      vars:
        db_host: "{{ hostvars[groups['tag_database'][0]]['ansible_host'] }}"
        db_password: "{{ db_password }}"
```

## Caching for Performance

### Enable Inventory Caching

**File:** `ansible/ansible.cfg`

```ini
[defaults]
inventory_plugins = /usr/share/ansible/plugins/inventory

[inventory]
enable_plugins = netbox.netbox.nb_inventory

# Enable caching
cache = true
cache_plugin = jsonfile
cache_timeout = 3600  # 1 hour
cache_connection = /tmp/ansible-netbox-cache
```

**Benefits:**

- Faster playbook runs
- Reduced API calls to NetBox
- Works offline (for cache duration)

**Clear cache:**

```bash
rm -rf /tmp/ansible-netbox-cache
```

## Troubleshooting

### Authentication Errors

**Error:** `Failed to query NetBox API`

**Check:**

```bash
# Test API token
curl -H "Authorization: Token $NETBOX_API_TOKEN" \
  https://netbox.spaceships.work/api/dcim/devices/ | jq

# Verify token permissions
# Token must have read access to: DCIM, IPAM, Virtualization
```

### SSL Certificate Errors

**Error:** `SSL: CERTIFICATE_VERIFY_FAILED`

**Solutions:**

```yaml
# Option 1: Add CA certificate
validate_certs: true
ssl_ca_cert: /path/to/ca-bundle.crt

# Option 2: Disable for self-signed (dev only!)
validate_certs: false
```

### No Hosts Found

**Error:** Inventory is empty

**Check:**

```bash
# List all devices in NetBox
curl -H "Authorization: Token $NETBOX_API_TOKEN" \
  https://netbox.spaceships.work/api/dcim/devices/ | jq '.count'

# Check query filters
# Ensure devices match your filters (status, tags, etc.)
```

**Debug inventory plugin:**

```bash
ansible-inventory -i ansible/inventory/netbox.yml --list -vvv
```

### Primary IP Not Set

**Error:** `ansible_host` is undefined

**Cause:** Devices/VMs in NetBox don't have primary_ip4 set

**Solution:**

```yaml
# Fallback to custom field or use DNS name
compose:
  ansible_host: primary_ip4 | default(custom_fields.management_ip) | default(name + '.spaceships.work')
```

## Best Practices

### 1. Use Service Account

Create dedicated NetBox user for Ansible:

```text
Username: ansible-automation
Permissions: Read-only (DCIM, IPAM, Virtualization)
Token: Never expires (or set appropriate expiration)
```

### 2. Tag for Inventory

Tag devices/VMs intended for Ansible management:

```text
Tag: ansible-managed
```

**Filter in inventory:**

```yaml
query_filters:
  - tag: ansible-managed
```

### 3. Set Primary IPs

Always set primary_ip4 in NetBox for devices/VMs:

```text
Device → Edit → Primary IPv4
```

### 4. Use Custom Fields

Add custom fields to NetBox for Ansible-specific data:

```text
ansible_user (Text)
ansible_port (Integer)
ansible_python_interpreter (Text)
backup_enabled (Boolean)
```

### 5. Test Before Running

Always test inventory before running playbooks:

```bash
# Verify hosts
ansible-inventory -i inventory/netbox.yml --graph

# Test connectivity
ansible all -i inventory/netbox.yml -m ping
```

### 6. Document in NetBox

Use NetBox description fields to document:

- Ansible playbooks that manage this host
- Special configuration requirements
- Dependencies on other hosts

## Further Reading

- [NetBox Ansible Collection Documentation](https://docs.ansible.com/ansible/latest/collections/netbox/netbox/)
- [Dynamic Inventory Plugin Guide](https://docs.ansible.com/ansible/latest/plugins/inventory.html)
- [NetBox API Documentation](https://demo.netbox.dev/api/docs/)

```

### reference/netbox-data-models.md

```markdown
# NetBox Data Models and Relationships

**NetBox Version:** 4.3.0

Comprehensive guide to NetBox's data models, their relationships, and how they map to the Matrix cluster infrastructure in Virgo-Core.

---

## Table of Contents

- [Overview](#overview)
- [Core Data Models](#core-data-models)
- [Model Relationships](#model-relationships)
- [DCIM Models (Data Center)](#dcim-models-data-center)
- [IPAM Models (IP Management)](#ipam-models-ip-management)
- [Virtualization Models](#virtualization-models)
- [Matrix Cluster Example](#matrix-cluster-example)
- [Best Practices](#best-practices)

---

## Overview

NetBox organizes infrastructure data into logical models across several applications:

| Application | Purpose | Key Models |
|-------------|---------|------------|
| **DCIM** | Data Center Infrastructure | Site, Rack, Device, Interface, Cable |
| **IPAM** | IP Address Management | IP Address, Prefix, VLAN, VRF |
| **Virtualization** | Virtual Machines | Virtual Machine, Cluster, VM Interface |
| **Circuits** | WAN/Circuit Management | Circuit, Provider, Circuit Termination |
| **Tenancy** | Multi-tenant Support | Tenant, Tenant Group, Contact |
| **Extras** | Extensions | Tag, Custom Field, Webhook |

---

## Core Data Models

### Site

Represents a physical location containing infrastructure.

**Fields:**

- `name` - Display name (e.g., "Matrix Cluster")
- `slug` - URL-friendly identifier (e.g., "matrix")
- `status` - active, planned, retired, etc.
- `region` - Geographic region (optional)
- `description` - Purpose and details
- `tags` - Flexible categorization

**Example:**

```python
site = nb.dcim.sites.create(
    name="Matrix Cluster",
    slug="matrix",
    status="active",
    description="3-node Proxmox VE cluster (foxtrot, golf, hotel)",
    tags=[{"name": "proxmox"}, {"name": "homelab"}]
)
```

**Relationships:**

- Has many: Racks, Devices, Prefixes
- Belongs to: Region (optional)

---

### Rack

Physical rack within a site.

**Fields:**

- `name` - Rack identifier
- `site` - Parent site
- `u_height` - Units (typically 42U)
- `desc_units` - Count units top-down
- `width` - 19" or 23"
- `tags`

**Example:**

```python
rack = nb.dcim.racks.create(
    name="Rack-01",
    site=site.id,
    u_height=42,
    width=19
)
```

**Relationships:**

- Belongs to: Site
- Has many: Devices (mounted in rack)

---

### Device

Physical piece of equipment.

**Fields:**

- `name` - Device hostname (e.g., "foxtrot")
- `device_type` - Reference to device type
- `device_role` - Purpose (server, switch, etc.)
- `site` - Physical location
- `rack` - Optional rack location
- `position` - Rack unit position
- `status` - active, offline, planned, etc.
- `primary_ip4` - Primary IPv4 address
- `primary_ip6` - Primary IPv6 address
- `tags`

**Example:**

```python
device = nb.dcim.devices.create(
    name="foxtrot",
    device_type=device_type.id,
    device_role=role.id,
    site=site.id,
    status="active",
    tags=[{"name": "proxmox-node"}]
)
```

**Relationships:**

- Belongs to: Site, Rack, Device Type, Device Role
- Has many: Interfaces, Console Ports, Power Ports
- Has one: Primary IP4, Primary IP6

---

### Interface

Network interface on a device.

**Fields:**

- `device` - Parent device
- `name` - Interface name (e.g., "eth0", "enp1s0")
- `type` - Physical type (1000base-t, 10gbase-x, etc.)
- `enabled` - Administrative status
- `mtu` - Maximum transmission unit
- `mac_address` - MAC address
- `mode` - Access or Trunk (for VLANs)
- `untagged_vlan` - Native VLAN
- `tagged_vlans` - Tagged VLANs
- `tags`

**Example:**

```python
interface = nb.dcim.interfaces.create(
    device=device.id,
    name="enp1s0",
    type="10gbase-x-sfpp",
    enabled=True,
    mtu=9000,  # Jumbo frames for CEPH
    tags=[{"name": "ceph-public"}]
)
```

**Relationships:**

- Belongs to: Device
- Has many: IP Addresses (assigned to interface)
- Connected to: Cable (physical connection)

---

### Cable

Physical cable connection between interfaces.

**Fields:**

- `a_terminations` - End A (interface, console port, etc.)
- `b_terminations` - End B
- `type` - Cable type (cat6, fiber, dac, etc.)
- `status` - connected, planned, etc.
- `length` - Cable length
- `length_unit` - m, ft, etc.
- `color` - Cable color
- `tags`

**Example:**

```python
cable = nb.dcim.cables.create(
    a_terminations=[{"object_type": "dcim.interface", "object_id": iface1.id}],
    b_terminations=[{"object_type": "dcim.interface", "object_id": iface2.id}],
    type="dac-active",
    status="connected",
    length=3,
    length_unit="m"
)
```

**Relationships:**

- Connects: Two termination objects (interfaces, ports, etc.)

---

### IP Address

IPv4 or IPv6 address.

**Fields:**

- `address` - IP with CIDR (e.g., "192.168.3.5/24")
- `dns_name` - FQDN (e.g., "foxtrot.spaceships.work")
- `status` - active, reserved, deprecated, etc.
- `role` - loopback, secondary, anycast, etc.
- `assigned_object_type` - Interface type (dcim.interface or virtualization.vminterface)
- `assigned_object_id` - Interface ID
- `vrf` - Virtual routing and forwarding instance
- `tenant` - Tenant assignment
- `tags`

**Example:**

```python
ip = nb.ipam.ip_addresses.create(
    address="192.168.3.5/24",
    dns_name="foxtrot.spaceships.work",
    status="active",
    assigned_object_type="dcim.interface",
    assigned_object_id=interface.id,
    tags=[{"name": "production-dns"}]
)
```

**Relationships:**

- Belongs to: Prefix, VRF (optional)
- Assigned to: Interface (device or VM)
- Referenced by: Device (as primary IP)

---

### Prefix

IP network or subnet.

**Fields:**

- `prefix` - Network in CIDR (e.g., "192.168.3.0/24")
- `status` - active, reserved, deprecated, etc.
- `role` - Purpose (e.g., "management", "ceph-public")
- `site` - Physical location
- `vrf` - VRF assignment
- `vlan` - Associated VLAN
- `is_pool` - Allow automatic IP assignment
- `description`
- `tags`

**Example:**

```python
prefix = nb.ipam.prefixes.create(
    prefix="192.168.3.0/24",
    status="active",
    role=nb.ipam.roles.get(slug='management').id,
    site=site.id,
    is_pool=True,
    description="Management network for Matrix cluster",
    tags=[{"name": "proxmox-mgmt"}]
)
```

**Relationships:**

- Belongs to: Site, VRF, VLAN (optional)
- Contains: IP Addresses
- Hierarchical: Can contain child prefixes

---

### VLAN

Virtual LAN.

**Fields:**

- `vid` - VLAN ID (1-4094)
- `name` - VLAN name
- `site` - Site assignment
- `group` - VLAN group (optional)
- `status` - active, reserved, deprecated
- `role` - Purpose
- `description`
- `tags`

**Example:**

```python
vlan = nb.ipam.vlans.create(
    vid=9,
    name="Corosync",
    site=site.id,
    status="active",
    description="Proxmox corosync cluster communication",
    tags=[{"name": "proxmox-cluster"}]
)
```

**Relationships:**

- Belongs to: Site, VLAN Group
- Assigned to: Prefixes, Interfaces

---

### VRF

Virtual Routing and Forwarding instance.

**Fields:**

- `name` - VRF name
- `rd` - Route distinguisher (optional)
- `description`
- `enforce_unique` - Enforce unique IP addressing
- `tags`

**Example:**

```python
vrf = nb.ipam.vrfs.create(
    name="management",
    enforce_unique=True,
    description="Management VRF"
)
```

**Relationships:**

- Has many: Prefixes, IP Addresses

---

### Virtual Machine

VM in a virtualization cluster.

**Fields:**

- `name` - VM hostname
- `cluster` - Virtualization cluster
- `role` - VM role (optional)
- `status` - active, offline, planned, etc.
- `vcpus` - Virtual CPU count
- `memory` - Memory in MB
- `disk` - Disk in GB
- `primary_ip4` - Primary IPv4
- `primary_ip6` - Primary IPv6
- `description`
- `tags`

**Example:**

```python
vm = nb.virtualization.virtual_machines.create(
    name="docker-01",
    cluster=cluster.id,
    status="active",
    vcpus=4,
    memory=8192,  # 8 GB
    disk=100,  # 100 GB
    tags=[{"name": "docker"}, {"name": "production"}]
)
```

**Relationships:**

- Belongs to: Cluster, Role (optional)
- Has many: VM Interfaces
- Has one: Primary IP4, Primary IP6

---

### Cluster

Virtualization cluster (e.g., Proxmox, VMware).

**Fields:**

- `name` - Cluster name
- `type` - Cluster type
- `site` - Physical location
- `description`
- `tags`

**Example:**

```python
cluster_type = nb.virtualization.cluster_types.get(slug='proxmox')
cluster = nb.virtualization.clusters.create(
    name="Matrix",
    type=cluster_type.id,
    site=site.id,
    description="3-node Proxmox VE 9.x cluster",
    tags=[{"name": "production"}]
)
```

**Relationships:**

- Belongs to: Site, Cluster Type
- Has many: Virtual Machines

---

### VM Interface

Network interface on a virtual machine.

**Fields:**

- `virtual_machine` - Parent VM
- `name` - Interface name (e.g., "eth0")
- `type` - Interface type (virtual, bridge)
- `enabled` - Administrative status
- `mtu` - MTU
- `mac_address` - MAC address
- `untagged_vlan` - Native VLAN
- `tagged_vlans` - Tagged VLANs
- `tags`

**Example:**

```python
vm_interface = nb.virtualization.interfaces.create(
    virtual_machine=vm.id,
    name="eth0",
    type="virtual",
    enabled=True,
    mtu=1500
)
```

**Relationships:**

- Belongs to: Virtual Machine
- Has many: IP Addresses

---

## Model Relationships

### Hierarchical Relationships

```text
Region (optional)
  └── Site
      ├── Rack
      │   └── Device
      │       └── Interface
      │           └── IP Address
      ├── Cluster
      │   └── Virtual Machine
      │       └── VM Interface
      │           └── IP Address
      └── Prefix
          └── IP Address
```

### Key Relationships

**Site containment:**

```text
Site
  ├── has many Racks
  ├── has many Devices
  ├── has many Clusters
  ├── has many Prefixes
  └── has many VLANs
```

**Device structure:**

```text
Device
  ├── belongs to Site
  ├── belongs to Rack (optional)
  ├── belongs to Device Type
  ├── belongs to Device Role
  ├── has many Interfaces
  ├── has one Primary IP4 (optional)
  └── has one Primary IP6 (optional)
```

**Interface connectivity:**

```text
Interface
  ├── belongs to Device
  ├── has many IP Addresses
  ├── connected via Cable
  ├── assigned to VLAN(s)
  └── assigned object for IP
```

**IP Address assignment:**

```text
IP Address
  ├── belongs to Prefix
  ├── assigned to Interface (device or VM)
  ├── belongs to VRF (optional)
  └── referenced as Primary IP by Device/VM
```

**VM structure:**

```text
Virtual Machine
  ├── belongs to Cluster
  ├── has many VM Interfaces
  ├── has one Primary IP4 (optional)
  └── has one Primary IP6 (optional)
```

**IPAM hierarchy:**

```text
VRF (optional)
  └── Prefix
      ├── child Prefix (nested)
      └── IP Address
```

---

## DCIM Models (Data Center)

### Complete Device Example

Creating a complete device with interfaces and IPs:

```python
# 1. Create device type (if not exists)
manufacturer = nb.dcim.manufacturers.get(slug='minisforum')
if not manufacturer:
    manufacturer = nb.dcim.manufacturers.create(name='MINISFORUM', slug='minisforum')

device_type = nb.dcim.device_types.get(slug='ms-a2')
if not device_type:
    device_type = nb.dcim.device_types.create(
        manufacturer=manufacturer.id,
        model='MS-A2',
        slug='ms-a2'
    )

# 2. Create device role
role = nb.dcim.device_roles.get(slug='proxmox-node')
if not role:
    role = nb.dcim.device_roles.create(
        name='Proxmox Node',
        slug='proxmox-node',
        color='2196f3'
    )

# 3. Create device
device = nb.dcim.devices.create(
    name='foxtrot',
    device_type=device_type.id,
    device_role=role.id,
    site=site.id,
    status='active',
    tags=[{'name': 'proxmox-node'}, {'name': 'ceph-node'}]
)

# 4. Create management interface
mgmt_iface = nb.dcim.interfaces.create(
    device=device.id,
    name='enp2s0',
    type='2.5gbase-t',
    enabled=True,
    mtu=1500,
    description='Management interface'
)

# 5. Assign management IP
mgmt_ip = nb.ipam.ip_addresses.create(
    address='192.168.3.5/24',
    dns_name='foxtrot.spaceships.work',
    status='active',
    assigned_object_type='dcim.interface',
    assigned_object_id=mgmt_iface.id,
    tags=[{'name': 'production-dns'}]
)

# 6. Set as primary IP
device.primary_ip4 = mgmt_ip.id
device.save()

# 7. Create CEPH public interface
ceph_pub_iface = nb.dcim.interfaces.create(
    device=device.id,
    name='enp1s0f0',
    type='10gbase-x-sfpp',
    enabled=True,
    mtu=9000,
    description='CEPH public network'
)

# 8. Assign CEPH public IP
ceph_pub_ip = nb.ipam.ip_addresses.create(
    address='192.168.5.5/24',
    dns_name='foxtrot-ceph-pub.spaceships.work',
    status='active',
    assigned_object_type='dcim.interface',
    assigned_object_id=ceph_pub_iface.id
)

# 9. Create CEPH private interface
ceph_priv_iface = nb.dcim.interfaces.create(
    device=device.id,
    name='enp1s0f1',
    type='10gbase-x-sfpp',
    enabled=True,
    mtu=9000,
    description='CEPH private network'
)

# 10. Assign CEPH private IP
ceph_priv_ip = nb.ipam.ip_addresses.create(
    address='192.168.7.5/24',
    status='active',
    assigned_object_type='dcim.interface',
    assigned_object_id=ceph_priv_iface.id
)
```

---

## IPAM Models (IP Management)

### Complete IPAM Example

Setting up IPAM for Matrix cluster:

```python
# 1. Create VRF (optional but recommended)
vrf_mgmt = nb.ipam.vrfs.create(
    name='management',
    enforce_unique=True,
    description='Management VRF'
)

# 2. Create prefix role
role_mgmt = nb.ipam.roles.get(slug='management')
if not role_mgmt:
    role_mgmt = nb.ipam.roles.create(
        name='Management',
        slug='management'
    )

# 3. Create management prefix
prefix_mgmt = nb.ipam.prefixes.create(
    prefix='192.168.3.0/24',
    status='active',
    role=role_mgmt.id,
    site=site.id,
    vrf=vrf_mgmt.id,
    is_pool=True,
    description='Management network for Matrix cluster'
)

# 4. Create CEPH public prefix
role_storage = nb.ipam.roles.create(name='Storage', slug='storage')
prefix_ceph_pub = nb.ipam.prefixes.create(
    prefix='192.168.5.0/24',
    status='active',
    role=role_storage.id,
    site=site.id,
    is_pool=True,
    description='CEPH public network (MTU 9000)'
)

# 5. Create CEPH private prefix
prefix_ceph_priv = nb.ipam.prefixes.create(
    prefix='192.168.7.0/24',
    status='active',
    role=role_storage.id,
    site=site.id,
    is_pool=True,
    description='CEPH private network (MTU 9000)'
)

# 6. Create Corosync VLAN
vlan_corosync = nb.ipam.vlans.create(
    vid=9,
    name='Corosync',
    site=site.id,
    status='active',
    description='Proxmox cluster communication'
)

# 7. Create Corosync prefix
prefix_corosync = nb.ipam.prefixes.create(
    prefix='192.168.8.0/24',
    status='active',
    site=site.id,
    vlan=vlan_corosync.id,
    description='Corosync cluster network (VLAN 9)'
)

# 8. Get available IPs from prefix
available_ips = prefix_mgmt.available_ips.list()
print(f"Available IPs in management network: {len(available_ips)}")

# 9. Reserve gateway
gateway = nb.ipam.ip_addresses.create(
    address='192.168.3.1/24',
    status='active',
    role='anycast',
    description='Management network gateway'
)
```

---

## Virtualization Models

### Complete VM Example

Creating a VM with network configuration:

```python
# 1. Create cluster type (if not exists)
cluster_type = nb.virtualization.cluster_types.get(slug='proxmox')
if not cluster_type:
    cluster_type = nb.virtualization.cluster_types.create(
        name='Proxmox VE',
        slug='proxmox'
    )

# 2. Create cluster
cluster = nb.virtualization.clusters.create(
    name='Matrix',
    type=cluster_type.id,
    site=site.id,
    description='3-node Proxmox VE 9.x cluster'
)

# 3. Create VM role
vm_role = nb.dcim.device_roles.get(slug='docker-host')
if not vm_role:
    vm_role = nb.dcim.device_roles.create(
        name='Docker Host',
        slug='docker-host',
        vm_role=True,  # Mark as VM role
        color='4caf50'
    )

# 4. Create VM
vm = nb.virtualization.virtual_machines.create(
    name='docker-01',
    cluster=cluster.id,
    role=vm_role.id,
    status='active',
    vcpus=4,
    memory=8192,
    disk=100,
    description='Docker host for Nexus registry',
    tags=[{'name': 'docker'}, {'name': 'production'}]
)

# 5. Create VM interface
vm_iface = nb.virtualization.interfaces.create(
    virtual_machine=vm.id,
    name='eth0',
    type='virtual',
    enabled=True,
    mtu=1500
)

# 6. Get next available IP from prefix
prefix = nb.ipam.prefixes.get(prefix='192.168.3.0/24')
vm_ip = prefix.available_ips.create(
    dns_name='docker-01-nexus.spaceships.work',
    status='active',
    assigned_object_type='virtualization.vminterface',
    assigned_object_id=vm_iface.id,
    tags=[{'name': 'production-dns'}, {'name': 'terraform'}]
)

# 7. Set as primary IP
vm.primary_ip4 = vm_ip.id
vm.save()

# 8. Query VM with interfaces
vm = nb.virtualization.virtual_machines.get(name='docker-01')
print(f"VM: {vm.name}")
print(f"Cluster: {vm.cluster.name}")
print(f"Primary IP: {vm.primary_ip4.address}")
for iface in vm.interfaces:
    print(f"  Interface: {iface.name}")
    for ip in nb.ipam.ip_addresses.filter(vminterface_id=iface.id):
        print(f"    IP: {ip.address} ({ip.dns_name})")
```

---

## Matrix Cluster Example

Complete NetBox representation of the Matrix cluster:

```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pynetbox>=7.0.0", "infisical-python>=2.3.3"]
# ///

import pynetbox
from infisical import InfisicalClient

# Get token
client = InfisicalClient()
token = client.get_secret(
    secret_name="NETBOX_API_TOKEN",
    project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
    environment="prod",
    path="/matrix"
).secret_value

nb = pynetbox.api('https://netbox.spaceships.work', token=token)

# Create site
site = nb.dcim.sites.create(
    name="Matrix Cluster",
    slug="matrix",
    status="active",
    description="3-node Proxmox VE 9.x cluster with CEPH storage"
)

# Create cluster
cluster = nb.virtualization.clusters.create(
    name="Matrix",
    type=nb.virtualization.cluster_types.get(slug='proxmox').id,
    site=site.id
)

# Create prefixes
prefixes = {
    'mgmt': nb.ipam.prefixes.create(
        prefix='192.168.3.0/24',
        site=site.id,
        description='Management network',
        is_pool=True
    ),
    'ceph_pub': nb.ipam.prefixes.create(
        prefix='192.168.5.0/24',
        site=site.id,
        description='CEPH public (MTU 9000)',
        is_pool=True
    ),
    'ceph_priv': nb.ipam.prefixes.create(
        prefix='192.168.7.0/24',
        site=site.id,
        description='CEPH private (MTU 9000)',
        is_pool=True
    ),
    'corosync': nb.ipam.prefixes.create(
        prefix='192.168.8.0/24',
        site=site.id,
        description='Corosync (VLAN 9)',
        is_pool=True
    )
}

# Matrix nodes
nodes = [
    {'name': 'foxtrot', 'mgmt_ip': '192.168.3.5', 'ceph_pub': '192.168.5.5',
     'ceph_priv': '192.168.7.5', 'corosync': '192.168.8.5'},
    {'name': 'golf', 'mgmt_ip': '192.168.3.6', 'ceph_pub': '192.168.5.6',
     'ceph_priv': '192.168.7.6', 'corosync': '192.168.8.6'},
    {'name': 'hotel', 'mgmt_ip': '192.168.3.7', 'ceph_pub': '192.168.5.7',
     'ceph_priv': '192.168.7.7', 'corosync': '192.168.8.7'}
]

for node_data in nodes:
    # Create device
    device = nb.dcim.devices.create(
        name=node_data['name'],
        device_type=nb.dcim.device_types.get(slug='ms-a2').id,
        device_role=nb.dcim.device_roles.get(slug='proxmox-node').id,
        site=site.id,
        status='active'
    )

    # Create interfaces and IPs
    # Management
    mgmt_iface = nb.dcim.interfaces.create(
        device=device.id, name='enp2s0', type='2.5gbase-t', mtu=1500
    )
    mgmt_ip = nb.ipam.ip_addresses.create(
        address=f"{node_data['mgmt_ip']}/24",
        dns_name=f"{node_data['name']}.spaceships.work",
        assigned_object_type='dcim.interface',
        assigned_object_id=mgmt_iface.id,
        tags=[{'name': 'production-dns'}]
    )
    device.primary_ip4 = mgmt_ip.id
    device.save()

    # CEPH public
    ceph_pub_iface = nb.dcim.interfaces.create(
        device=device.id, name='enp1s0f0', type='10gbase-x-sfpp', mtu=9000
    )
    nb.ipam.ip_addresses.create(
        address=f"{node_data['ceph_pub']}/24",
        assigned_object_type='dcim.interface',
        assigned_object_id=ceph_pub_iface.id
    )

    # CEPH private
    ceph_priv_iface = nb.dcim.interfaces.create(
        device=device.id, name='enp1s0f1', type='10gbase-x-sfpp', mtu=9000
    )
    nb.ipam.ip_addresses.create(
        address=f"{node_data['ceph_priv']}/24",
        assigned_object_type='dcim.interface',
        assigned_object_id=ceph_priv_iface.id
    )

    # Corosync
    corosync_iface = nb.dcim.interfaces.create(
        device=device.id, name='enp2s0.9', type='virtual', mtu=1500
    )
    nb.ipam.ip_addresses.create(
        address=f"{node_data['corosync']}/24",
        assigned_object_type='dcim.interface',
        assigned_object_id=corosync_iface.id
    )

print("Matrix cluster created in NetBox!")
```

---

## Best Practices

### 1. Plan Hierarchy First

```text
1. Create Site
2. Create Prefixes (IPAM)
3. Create VLANs
4. Create Device Types/Roles
5. Create Devices
6. Create Interfaces
7. Assign IPs
8. Set Primary IPs
```

### 2. Use Consistent Naming

- Sites: Descriptive names (e.g., "Matrix Cluster")
- Devices: Hostname only (e.g., "foxtrot")
- Interfaces: Match OS names (e.g., "enp1s0f0")
- DNS names: Follow convention (e.g., "foxtrot.spaceships.work")

### 3. Tag Everything

```python
tags = [
    {'name': 'proxmox-node'},
    {'name': 'ceph-node'},
    {'name': 'production'},
    {'name': 'production-dns'},
    {'name': 'terraform'}
]
```

### 4. Use Prefixes as IP Pools

```python
prefix = nb.ipam.prefixes.create(
    prefix='192.168.3.0/24',
    is_pool=True  # Enable automatic IP assignment
)

# Get next available IP
ip = prefix.available_ips.create(dns_name='host.domain')
```

### 5. Always Set Primary IPs

```python
# After creating IPs, set primary
device.primary_ip4 = mgmt_ip.id
device.save()
```

### 6. Validate Relationships

```python
# Check if IP is assigned
ip = nb.ipam.ip_addresses.get(address='192.168.3.5/24')
if ip.assigned_object:
    print(f"Assigned to: {ip.assigned_object.name}")
else:
    print("IP not assigned to any interface")
```

### 7. Use Descriptions

```python
device = nb.dcim.devices.create(
    name='foxtrot',
    description='AMD Ryzen 9 9955HX, 64GB RAM, 3× NVMe (1TB + 2× 4TB)'
)
```

---

## Related Documentation

- [NetBox API Guide](netbox-api-guide.md) - API reference
- [NetBox Best Practices](netbox-best-practices.md) - Infrastructure patterns
- [Tools: netbox_api_client.py](../tools/netbox_api_client.py) - Working examples
- [DNS Naming Conventions](../workflows/naming-conventions.md) - Naming rules

---

**Next:** [NetBox Best Practices Guide](netbox-best-practices.md)

```

### reference/netbox-best-practices.md

```markdown
# NetBox Best Practices for Virgo-Core

**NetBox Version:** 4.3.0
**Audience:** Infrastructure automation engineers

Comprehensive best practices for using NetBox as the source of truth for the Matrix cluster infrastructure, including data organization, security, performance, and integration patterns.

---

## Table of Contents

- [Data Organization](#data-organization)
- [Naming Conventions](#naming-conventions)
- [IP Address Management](#ip-address-management)
- [Device Management](#device-management)
- [Virtualization](#virtualization)
- [Tagging Strategy](#tagging-strategy)
- [Security](#security)
- [Performance](#performance)
- [API Integration](#api-integration)
- [Automation Patterns](#automation-patterns)
- [Troubleshooting](#troubleshooting)

---

## Data Organization

### Hierarchical Structure

Follow this order when setting up infrastructure in NetBox:

```text
1. Sites          → Create physical locations first
2. Prefixes       → Define IP networks (IPAM)
3. VLANs          → Network segmentation
4. Device Types   → Hardware models
5. Device Roles   → Purpose categories
6. Clusters       → Virtualization clusters
7. Devices        → Physical hardware
8. Interfaces     → Network interfaces
9. IP Addresses   → Assign IPs to interfaces
10. VMs           → Virtual machines
```

**Why this order?**

- Parent objects must exist before children
- Avoids circular dependencies
- Enables atomic operations

### Site Organization

**✅ Good:**

```python
# One site per physical location
site = nb.dcim.sites.create(
    name="Matrix Cluster",
    slug="matrix",
    description="3-node Proxmox VE cluster at home lab",
    tags=[{"name": "production"}, {"name": "homelab"}]
)
```

**❌ Bad:**

```python
# Don't create separate sites for logical groupings
site_proxmox = nb.dcim.sites.create(name="Proxmox Nodes", ...)
site_vms = nb.dcim.sites.create(name="Virtual Machines", ...)
```

Use **device roles** and **tags** for logical grouping, not separate sites.

### Consistent Data Entry

**Required fields:** Always populate these

```python
device = nb.dcim.devices.create(
    name="foxtrot",                    # ✅ Required
    device_type=device_type.id,        # ✅ Required
    device_role=role.id,               # ✅ Required
    site=site.id,                      # ✅ Required
    status="active",                   # ✅ Required
    description="AMD Ryzen 9 9955HX",  # ✅ Recommended
    tags=[{"name": "proxmox-node"}]    # ✅ Recommended
)
```

**Optional but recommended:**

- `description` - Hardware specs, purpose
- `tags` - For filtering and automation
- `comments` - Additional notes
- `custom_fields` - Serial numbers, purchase dates

---

## Naming Conventions

### Device Names

**✅ Use hostname only (no domain):**

```python
device = nb.dcim.devices.create(name="foxtrot", ...)   # ✅ Good
device = nb.dcim.devices.create(name="foxtrot.spaceships.work", ...)  # ❌ Bad
```

**Rationale:** Domain goes in DNS name field, not device name.

### Interface Names

**✅ Match actual OS interface names:**

```python
# Linux
interface = nb.dcim.interfaces.create(name="enp1s0f0", ...)  # ✅ Good

# Not generic names
interface = nb.dcim.interfaces.create(name="eth0", ...)      # ❌ Bad (unless actually eth0)
```

**Why?** Enables automation that references interfaces by name.

### DNS Naming Convention

Follow the Matrix cluster pattern: **`<service>-<number>-<purpose>.<domain>`**

```python
# ✅ Good examples
dns_name="docker-01-nexus.spaceships.work"
dns_name="k8s-01-master.spaceships.work"
dns_name="proxmox-foxtrot-mgmt.spaceships.work"

# ❌ Bad examples
dns_name="server1.spaceships.work"         # Not descriptive
dns_name="nexus.spaceships.work"           # Missing number
dns_name="DOCKER-01.spaceships.work"       # Uppercase not allowed
```

See [../workflows/naming-conventions.md](../workflows/naming-conventions.md) for complete rules.

### Slugs

**✅ Lowercase with hyphens:**

```python
site = nb.dcim.sites.create(slug="matrix", ...)        # ✅ Good
site = nb.dcim.sites.create(slug="Matrix_Cluster", ...)  # ❌ Bad
```

**Pattern:** `^[a-z0-9-]+$`

---

## IP Address Management

### Plan IP Hierarchy

**Matrix cluster example:**

```text
192.168.0.0/16 (Home network supernet)
├── 192.168.3.0/24 (Management)
│   ├── 192.168.3.1     (Gateway)
│   ├── 192.168.3.5-7   (Proxmox nodes)
│   ├── 192.168.3.10+   (VMs)
│   └── 192.168.3.200+  (Reserved for future)
├── 192.168.5.0/24 (CEPH Public, MTU 9000)
├── 192.168.7.0/24 (CEPH Private, MTU 9000)
└── 192.168.8.0/24 (Corosync, VLAN 9)
```

### Use Prefix Roles

Create roles for clarity:

```python
# Create roles
role_mgmt = nb.ipam.roles.create(name='Management', slug='management')
role_storage = nb.ipam.roles.create(name='Storage', slug='storage')
role_cluster = nb.ipam.roles.create(name='Cluster', slug='cluster')

# Apply to prefixes
prefix = nb.ipam.prefixes.create(
    prefix='192.168.3.0/24',
    role=role_mgmt.id,
    description='Management network for Matrix cluster'
)
```

### Reserve Important IPs

**✅ Explicitly reserve gateway, broadcast, network addresses:**

```python
# Gateway
gateway = nb.ipam.ip_addresses.create(
    address='192.168.3.1/24',
    status='active',
    role='anycast',
    description='Management network gateway'
)

# DNS servers
dns1 = nb.ipam.ip_addresses.create(
    address='192.168.3.2/24',
    status='reserved',
    description='Primary DNS server'
)
```

### Use Prefixes as IP Pools

**✅ Enable automatic IP assignment:**

```python
prefix = nb.ipam.prefixes.create(
    prefix='192.168.3.0/24',
    is_pool=True,  # ✅ Allow automatic IP assignment
    ...
)

# Get next available IP
ip = prefix.available_ips.create(dns_name='docker-02.spaceships.work')
```

**❌ Don't manually track available IPs** - let NetBox do it.

### IP Status Values

Use appropriate status:

| Status | Use Case |
|--------|----------|
| `active` | Currently in use |
| `reserved` | Reserved for specific purpose |
| `deprecated` | Planned for decommission |
| `dhcp` | Managed by DHCP server |

```python
# Production VM
ip = nb.ipam.ip_addresses.create(address='192.168.3.10/24', status='active')

# Future expansion
ip = nb.ipam.ip_addresses.create(address='192.168.3.50/24', status='reserved')
```

### VRF for Isolation

**Use VRFs for true isolation:**

```python
# Management VRF (enforce unique IPs)
vrf_mgmt = nb.ipam.vrfs.create(
    name='management',
    enforce_unique=True,
    description='Management network VRF'
)

# Lab VRF (allow overlapping IPs)
vrf_lab = nb.ipam.vrfs.create(
    name='lab',
    enforce_unique=False,
    description='Lab/testing VRF'
)
```

**When to use VRFs:**

- Multiple environments (prod, dev, lab)
- Overlapping IP ranges
- Security isolation

---

## Device Management

### Create Device Types First

**✅ Always create device type before devices:**

```python
# 1. Create manufacturer
manufacturer = nb.dcim.manufacturers.get(slug='minisforum')
if not manufacturer:
    manufacturer = nb.dcim.manufacturers.create(
        name='MINISFORUM',
        slug='minisforum'
    )

# 2. Create device type
device_type = nb.dcim.device_types.create(
    manufacturer=manufacturer.id,
    model='MS-A2',
    slug='ms-a2',
    u_height=0,  # Not rack mounted
    is_full_depth=False
)

# 3. Create device
device = nb.dcim.devices.create(
    name='foxtrot',
    device_type=device_type.id,
    ...
)
```

### Use Device Roles Consistently

**Create specific roles:**

```python
roles = [
    ('Proxmox Node', 'proxmox-node', '2196f3'),    # Blue
    ('Docker Host', 'docker-host', '4caf50'),      # Green
    ('K8s Master', 'k8s-master', 'ff9800'),        # Orange
    ('K8s Worker', 'k8s-worker', 'ffc107'),        # Amber
    ('Storage', 'storage', '9c27b0'),              # Purple
]

for name, slug, color in roles:
    nb.dcim.device_roles.create(
        name=name,
        slug=slug,
        color=color,
        vm_role=True  # If role applies to VMs too
    )
```

**✅ Consistent naming helps automation:**

```python
# Get all Proxmox nodes
proxmox_nodes = nb.dcim.devices.filter(role='proxmox-node')

# Get all Kubernetes workers
k8s_workers = nb.virtualization.virtual_machines.filter(role='k8s-worker')
```

### Always Set Primary IP

**✅ Set primary IP after creating device and IPs:**

```python
# Create device
device = nb.dcim.devices.create(name='foxtrot', ...)

# Create interface
iface = nb.dcim.interfaces.create(device=device.id, name='enp2s0', ...)

# Create IP
ip = nb.ipam.ip_addresses.create(
    address='192.168.3.5/24',
    assigned_object_type='dcim.interface',
    assigned_object_id=iface.id
)

# ✅ Set as primary (critical for automation!)
device.primary_ip4 = ip.id
device.save()
```

**Why?** Primary IP is used by:

- Ansible dynamic inventory
- Monitoring tools
- DNS automation

### Document Interfaces

**✅ Include descriptions:**

```python
# Management
mgmt = nb.dcim.interfaces.create(
    device=device.id,
    name='enp2s0',
    type='2.5gbase-t',
    mtu=1500,
    description='Management interface (vmbr0)',
    tags=[{'name': 'management'}]
)

# CEPH public
ceph_pub = nb.dcim.interfaces.create(
    device=device.id,
    name='enp1s0f0',
    type='10gbase-x-sfpp',
    mtu=9000,
    description='CEPH public network (vmbr1)',
    tags=[{'name': 'ceph-public'}, {'name': 'jumbo-frames'}]
)
```

---

## Virtualization

### Create Cluster First

**✅ Create cluster before VMs:**

```python
# 1. Get/create cluster type
cluster_type = nb.virtualization.cluster_types.get(slug='proxmox')
if not cluster_type:
    cluster_type = nb.virtualization.cluster_types.create(
        name='Proxmox VE',
        slug='proxmox'
    )

# 2. Create cluster
cluster = nb.virtualization.clusters.create(
    name='Matrix',
    type=cluster_type.id,
    site=site.id,
    description='3-node Proxmox VE 9.x cluster'
)

# 3. Create VMs in cluster
vm = nb.virtualization.virtual_machines.create(
    name='docker-01',
    cluster=cluster.id,
    ...
)
```

### Standardize VM Sizing

**✅ Use consistent resource allocations:**

| Role | vCPUs | Memory (MB) | Disk (GB) |
|------|-------|-------------|-----------|
| Small (dev) | 2 | 2048 | 20 |
| Medium (app) | 4 | 8192 | 100 |
| Large (database) | 8 | 16384 | 200 |
| XL (compute) | 16 | 32768 | 500 |

```python
VM_SIZES = {
    'small': {'vcpus': 2, 'memory': 2048, 'disk': 20},
    'medium': {'vcpus': 4, 'memory': 8192, 'disk': 100},
    'large': {'vcpus': 8, 'memory': 16384, 'disk': 200},
}

# Create VM with standard size
vm = nb.virtualization.virtual_machines.create(
    name='docker-01',
    cluster=cluster.id,
    **VM_SIZES['medium']
)
```

### VM Network Configuration

**✅ Complete network setup:**

```python
# 1. Create VM
vm = nb.virtualization.virtual_machines.create(...)

# 2. Create interface
vm_iface = nb.virtualization.interfaces.create(
    virtual_machine=vm.id,
    name='eth0',
    type='virtual',
    enabled=True,
    mtu=1500
)

# 3. Assign IP from pool
prefix = nb.ipam.prefixes.get(prefix='192.168.3.0/24')
vm_ip = prefix.available_ips.create(
    dns_name='docker-01-nexus.spaceships.work',
    assigned_object_type='virtualization.vminterface',
    assigned_object_id=vm_iface.id,
    tags=[{'name': 'production-dns'}]  # ✅ Triggers PowerDNS sync
)

# 4. Set as primary IP
vm.primary_ip4 = vm_ip.id
vm.save()
```

---

## Tagging Strategy

### Tag Categories

Organize tags by purpose:

**Infrastructure Type:**

- `proxmox-node`, `ceph-node`, `docker-host`, `k8s-master`, `k8s-worker`

**Environment:**

- `production`, `staging`, `development`, `lab`

**DNS Automation:**

- `production-dns`, `lab-dns` (triggers PowerDNS sync)

**Management:**

- `terraform`, `ansible`, `manual`

**Networking:**

- `management`, `ceph-public`, `ceph-private`, `jumbo-frames`

### Tag Naming Convention

**✅ Lowercase with hyphens:**

```python
tags = [
    {'name': 'proxmox-node'},      # ✅ Good
    {'name': 'production-dns'},    # ✅ Good
    {'name': 'Proxmox Node'},      # ❌ Bad (spaces, capitals)
    {'name': 'production_dns'},    # ❌ Bad (underscores)
]
```

### Apply Tags Consistently

**✅ Tag at multiple levels:**

```python
# Tag device
device = nb.dcim.devices.create(
    name='foxtrot',
    tags=[{'name': 'proxmox-node'}, {'name': 'ceph-node'}, {'name': 'production'}]
)

# Tag interface
iface = nb.dcim.interfaces.create(
    device=device.id,
    name='enp1s0f0',
    tags=[{'name': 'ceph-public'}, {'name': 'jumbo-frames'}]
)

# Tag IP
ip = nb.ipam.ip_addresses.create(
    address='192.168.3.5/24',
    tags=[{'name': 'production-dns'}, {'name': 'terraform'}]
)
```

**Why?** Enables granular filtering:

```bash
# Get all CEPH nodes
ansible-playbook -i netbox-inventory.yml setup-ceph.yml --limit tag_ceph_node

# Get all production DNS-enabled IPs
ips = nb.ipam.ip_addresses.filter(tag='production-dns')
```

---

## Security

### API Token Management

**✅ Store tokens in Infisical (Virgo-Core standard):**

```python
from infisical import InfisicalClient

def get_netbox_token() -> str:
    """Get NetBox API token from Infisical."""
    client = InfisicalClient()
    secret = client.get_secret(
        secret_name="NETBOX_API_TOKEN",
        project_id="7b832220-24c0-45bc-a5f1-ce9794a31259",
        environment="prod",
        path="/matrix"
    )
    return secret.secret_value

# Use token
nb = pynetbox.api('https://netbox.spaceships.work', token=get_netbox_token())
```

**❌ Never hardcode tokens:**

```python
# ❌ NEVER DO THIS
token = "a1b2c3d4e5f6..."
nb = pynetbox.api(url, token=token)
```

### Use Minimal Permissions

Create tokens with appropriate scopes:

| Use Case | Permissions |
|----------|-------------|
| Read-only queries | Read only |
| Terraform automation | Read + Write (DCIM, IPAM, Virtualization) |
| Full automation | Read + Write (all) |
| Emergency admin | Full access |

**✅ Create separate tokens for different purposes:**

```text
NETBOX_API_TOKEN_READONLY   → Read-only queries
NETBOX_API_TOKEN_TERRAFORM  → Terraform automation
NETBOX_API_TOKEN_ANSIBLE    → Ansible dynamic inventory
```

### HTTPS Only

**✅ Always use HTTPS in production:**

```python
# ✅ Production
nb = pynetbox.api('https://netbox.spaceships.work', token=token)

# ❌ Never HTTP in production
nb = pynetbox.api('http://netbox.spaceships.work', token=token)
```

**For self-signed certs (dev/lab only):**

```python
# ⚠️ Dev/testing only
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

nb = pynetbox.api(
    'https://netbox.local',
    token=token,
    ssl_verify=False  # Only for self-signed certs in lab
)
```

### Rotate Tokens Regularly

**Best practice:** Rotate every 90 days

```bash
# 1. Create new token in NetBox UI
# 2. Update Infisical secret
infisical secrets set NETBOX_API_TOKEN="new-token-here"

# 3. Test new token
./tools/netbox_api_client.py sites list

# 4. Delete old token in NetBox UI
```

### Audit API Usage

**✅ Log API calls in production:**

```python
import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    filename='/var/log/netbox-api.log'
)

logger = logging.getLogger(__name__)

def audit_api_call(action: str, resource: str, details: dict):
    """Log API calls for security audit."""
    logger.info(f"API Call: {action} {resource} - User: {os.getenv('USER')} - {details}")

# Usage
ip = nb.ipam.ip_addresses.create(address='192.168.1.1/24')
audit_api_call('CREATE', 'ip-address', {'address': '192.168.1.1/24'})
```

---

## Performance

### Use Filtering Server-Side

**✅ Filter on server:**

```python
# ✅ Efficient: Server filters results
devices = nb.dcim.devices.filter(site='matrix', status='active')
```

**❌ Don't filter client-side:**

```python
# ❌ Inefficient: Downloads all devices then filters
all_devices = nb.dcim.devices.all()
matrix_devices = [d for d in all_devices if d.site.slug == 'matrix']
```

### Request Only Needed Fields

**✅ Use field selection:**

```python
# Get only specific fields
devices = nb.dcim.devices.filter(site='matrix', fields=['name', 'primary_ip4'])
```

### Use Pagination for Large Datasets

**✅ Process in batches:**

```python
# Paginate automatically
for device in nb.dcim.devices.filter(site='matrix'):
    process_device(device)  # pynetbox handles pagination

# Manual pagination for control
page_size = 100
offset = 0
while True:
    devices = nb.dcim.devices.filter(limit=page_size, offset=offset)
    if not devices:
        break
    for device in devices:
        process_device(device)
    offset += page_size
```

### Cache Lookups

**✅ Cache static data:**

```python
from functools import lru_cache

@lru_cache(maxsize=128)
def get_site(site_slug: str):
    """Cached site lookup."""
    return nb.dcim.sites.get(slug=site_slug)

@lru_cache(maxsize=256)
def get_device_type(slug: str):
    """Cached device type lookup."""
    return nb.dcim.device_types.get(slug=slug)
```

### Use Bulk Operations

**✅ Bulk create is faster:**

```python
# ✅ Fast: Bulk create
ips = [
    {'address': f'192.168.3.{i}/24', 'status': 'active'}
    for i in range(10, 20)
]
nb.ipam.ip_addresses.create(ips)

# ❌ Slow: Loop with individual creates
for i in range(10, 20):
    nb.ipam.ip_addresses.create(address=f'192.168.3.{i}/24', status='active')
```

---

## API Integration

### Error Handling

**✅ Always handle errors:**

```python
import pynetbox
from requests.exceptions import HTTPError

try:
    device = nb.dcim.devices.get(name='foxtrot')
    if not device:
        console.print("[yellow]Device not found[/yellow]")
        return None

except HTTPError as e:
    if e.response.status_code == 404:
        console.print("[red]Resource not found[/red]")
    elif e.response.status_code == 403:
        console.print("[red]Permission denied[/red]")
    else:
        console.print(f"[red]HTTP Error: {e}[/red]")
    sys.exit(1)

except pynetbox.RequestError as e:
    console.print(f"[red]NetBox API Error: {e.error}[/red]")
    sys.exit(1)

except Exception as e:
    console.print(f"[red]Unexpected error: {e}[/red]")
    sys.exit(1)
```

### Validate Before Creating

**✅ Validate input before API calls:**

```python
import ipaddress
import re

def validate_ip(ip_str: str) -> bool:
    """Validate IP address format."""
    try:
        ipaddress.ip_interface(ip_str)
        return True
    except ValueError:
        return False

def validate_dns_name(name: str) -> bool:
    """Validate DNS naming convention."""
    pattern = r'^[a-z0-9-]+-\d{2}-[a-z0-9-]+\.[a-z0-9.-]+$'
    return bool(re.match(pattern, name))

# Use before API calls
if not validate_ip(ip_address):
    raise ValueError(f"Invalid IP address: {ip_address}")

if not validate_dns_name(dns_name):
    raise ValueError(f"Invalid DNS name: {dns_name}")

ip = nb.ipam.ip_addresses.create(address=ip_address, dns_name=dns_name)
```

### Check Before Create

**✅ Check existence before creating:**

```python
# Check if device exists
device = nb.dcim.devices.get(name='foxtrot')

if device:
    console.print("[yellow]Device already exists, updating...[/yellow]")
    device.status = 'active'
    device.save()
else:
    console.print("[green]Creating new device...[/green]")
    device = nb.dcim.devices.create(name='foxtrot', ...)
```

---

## Automation Patterns

### Idempotent Operations

**✅ Design operations to be safely re-run:**

```python
def ensure_vm_exists(name: str, cluster: str, **kwargs) -> pynetbox.core.response.Record:
    """Ensure VM exists (idempotent)."""
    # Check if exists
    vm = nb.virtualization.virtual_machines.get(name=name)

    if vm:
        # Update if needed
        updated = False
        for key, value in kwargs.items():
            if getattr(vm, key) != value:
                setattr(vm, key, value)
                updated = True

        if updated:
            vm.save()
            console.print(f"[yellow]Updated VM: {name}[/yellow]")
        else:
            console.print(f"[green]VM unchanged: {name}[/green]")

        return vm
    else:
        # Create new
        vm = nb.virtualization.virtual_machines.create(
            name=name,
            cluster=nb.virtualization.clusters.get(name=cluster).id,
            **kwargs
        )
        console.print(f"[green]Created VM: {name}[/green]")
        return vm
```

### Terraform Integration

See [terraform-provider-guide.md](terraform-provider-guide.md) for complete examples.

**Key pattern:**

```hcl
# Use NetBox as data source
data "netbox_prefix" "management" {
  prefix = "192.168.3.0/24"
}

# Create IP in NetBox via Terraform
resource "netbox_ip_address" "vm_ip" {
  ip_address  = cidrhost(data.netbox_prefix.management.prefix, 10)
  dns_name    = "docker-01-nexus.spaceships.work"
  status      = "active"
  tags        = ["terraform", "production-dns"]
}
```

### Ansible Dynamic Inventory

See [../workflows/ansible-dynamic-inventory.md](../workflows/ansible-dynamic-inventory.md).

**Key pattern:**

```yaml
# netbox-dynamic-inventory.yml
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;...
group_by:
  - device_roles
  - tags
  - site
```

---

## Troubleshooting

### Common Issues

**Problem:** "Permission denied" errors

**Solution:** Check API token permissions

```bash
# Test token
curl -H "Authorization: Token YOUR_TOKEN" \
  https://netbox.spaceships.work/api/
```

**Problem:** IP not syncing to PowerDNS

**Solution:** Check tags

```python
# IP must have tag matching zone rules
ip = nb.ipam.ip_addresses.get(address='192.168.3.10/24')
print(f"Tags: {[tag.name for tag in ip.tags]}")
# Must include 'production-dns' or matching tag
```

**Problem:** Slow API queries

**Solution:** Use filtering and pagination

```python
# ❌ Slow
all_devices = nb.dcim.devices.all()

# ✅ Fast
devices = nb.dcim.devices.filter(site='matrix', limit=50)
```

### Debug Mode

**Enable verbose logging:**

```python
import logging

# Enable debug logging
logging.basicConfig(level=logging.DEBUG)

# Now pynetbox will log all API calls
nb = pynetbox.api('https://netbox.spaceships.work', token=token)
devices = nb.dcim.devices.all()
```

---

## Related Documentation

- [NetBox API Guide](netbox-api-guide.md) - Complete API reference
- [NetBox Data Models](netbox-data-models.md) - Data model relationships
- [DNS Naming Conventions](../workflows/naming-conventions.md) - Naming rules
- [Terraform Provider Guide](terraform-provider-guide.md) - Terraform integration
- [Tools: netbox_api_client.py](../tools/netbox_api_client.py) - Working examples

---

**Next:** Review [API Integration Patterns](netbox-api-guide.md#api-integration)

```

### workflows/dns-automation.md

```markdown
# DNS Automation Workflows

## Overview

This guide covers end-to-end workflows for automating DNS record management using NetBox as the
source of truth and PowerDNS as the authoritative DNS server.

## Architecture

```text
┌─────────────┐
│  Terraform  │───┐
│   Ansible   │   │
│   Manual    │   │
└─────────────┘   │
                  ▼
            ┌──────────┐
            │  NetBox  │ (Source of Truth)
            │   IPAM   │
            └────┬─────┘
                 │
                 │ netbox-powerdns-sync plugin
                 │
                 ▼
            ┌──────────┐
            │ PowerDNS │ (Authoritative DNS)
            │   API    │
            └────┬─────┘
                 │
                 │ Zone files / API
                 │
                 ▼
            ┌──────────┐
            │   DNS    │ (Resolvers query here)
            │ Clients  │
            └──────────┘
```

## Workflow 1: Create VM with Automatic DNS

### Using Terraform

**End-to-end automation:**

```hcl
# 1. Create VM in Proxmox
resource "proxmox_vm_qemu" "docker_host" {
  name        = "docker-01-nexus"
  target_node = "foxtrot"
  vmid        = 101

  clone      = "ubuntu-template"
  full_clone = true

  cores   = 4
  memory  = 8192

  network {
    bridge = "vmbr0"
    model  = "virtio"
    tag    = 30
  }

  disk {
    storage = "local-lvm"
    type    = "scsi"
    size    = "50G"
  }

  # Cloud-init IP configuration
  ipconfig0 = "ip=192.168.1.100/24,gw=192.168.1.1"

  sshkeys = file("~/.ssh/id_rsa.pub")
}

# 2. Register in NetBox (triggers DNS sync)
resource "netbox_ip_address" "docker_host" {
  ip_address  = "192.168.1.100/24"
  dns_name    = "docker-01-nexus.spaceships.work"
  status      = "active"
  description = "Docker host for Nexus container registry"

  tags = [
    "terraform",
    "production-dns",  # Triggers auto DNS sync
    "docker-host"
  ]

  # Ensure VM is created first
  depends_on = [proxmox_vm_qemu.docker_host]
}

# 3. DNS records automatically created by netbox-powerdns-sync plugin:
#    A:   docker-01-nexus.spaceships.work → 192.168.1.100
#    PTR: 100.1.168.192.in-addr.arpa → docker-01-nexus.spaceships.work

# 4. Output for verification
output "vm_fqdn" {
  value = netbox_ip_address.docker_host.dns_name
}

output "vm_ip" {
  value = split("/", netbox_ip_address.docker_host.ip_address)[0]
}
```

**Apply workflow:**

```bash
cd terraform/netbox-vm/
tofu init
tofu plan
tofu apply

# Verify DNS
dig @192.168.3.1 docker-01-nexus.spaceships.work +short
# Returns: 192.168.1.100

# Verify PTR
dig @192.168.3.1 -x 192.168.1.100 +short
# Returns: docker-01-nexus.spaceships.work
```

### Using Ansible

**Playbook for VM with DNS:**

```yaml
---
- name: Provision VM with automatic DNS
  hosts: localhost
  gather_facts: false

  vars:
    vm_name: docker-01-nexus
    vm_ip: 192.168.1.100
    vm_fqdn: "{{ vm_name }}.spaceships.work"
    proxmox_node: foxtrot

  tasks:
    # 1. Create VM in Proxmox
    - name: Clone template to create VM
      community.proxmox.proxmox_kvm:
        api_host: "{{ proxmox_api_host }}"
        api_user: "{{ proxmox_api_user }}"
        api_token_id: "{{ proxmox_token_id }}"
        api_token_secret: "{{ proxmox_token_secret }}"
        node: "{{ proxmox_node }}"
        vmid: 101
        name: "{{ vm_name }}"
        clone: ubuntu-template
        full: true
        storage: local-lvm
        net:
          net0: 'virtio,bridge=vmbr0,tag=30'
        ipconfig:
          ipconfig0: 'ip={{ vm_ip }}/24,gw=192.168.1.1'
        cores: 4
        memory: 8192
        agent: 1
        state: present
      register: vm_result

    - name: Start VM
      community.proxmox.proxmox_kvm:
        api_host: "{{ proxmox_api_host }}"
        api_user: "{{ proxmox_api_user }}"
        api_token_id: "{{ proxmox_token_id }}"
        api_token_secret: "{{ proxmox_token_secret }}"
        node: "{{ proxmox_node }}"
        vmid: 101
        state: started

    # 2. Register in NetBox
    - name: Create IP address in NetBox
      netbox.netbox.netbox_ip_address:
        netbox_url: "{{ netbox_url }}"
        netbox_token: "{{ netbox_token }}"
        data:
          address: "{{ vm_ip }}/24"
          dns_name: "{{ vm_fqdn }}"
          status: active
          description: "Docker host for Nexus container registry"
          tags:
            - name: production-dns
            - name: ansible
            - name: docker-host

    # 3. Wait for DNS propagation
    - name: Wait for DNS record
      ansible.builtin.command: dig @192.168.3.1 {{ vm_fqdn }} +short
      register: dns_check
      until: dns_check.stdout == vm_ip
      retries: 10
      delay: 5
      changed_when: false

    # 4. Verify DNS resolution
    - name: Verify DNS forward resolution
      ansible.builtin.command: dig @192.168.3.1 {{ vm_fqdn }} +short
      register: forward_dns
      changed_when: false

    - name: Verify DNS reverse resolution
      ansible.builtin.command: dig @192.168.3.1 -x {{ vm_ip }} +short
      register: reverse_dns
      changed_when: false

    - name: Report DNS status
      ansible.builtin.debug:
        msg:
          - "VM created: {{ vm_name }}"
          - "IP: {{ vm_ip }}"
          - "FQDN: {{ vm_fqdn }}"
          - "Forward DNS: {{ forward_dns.stdout }}"
          - "Reverse DNS: {{ reverse_dns.stdout }}"
```

**Run playbook:**

```bash
cd ansible
uv run ansible-playbook playbooks/provision-vm-with-dns.yml
```

## Workflow 2: Bulk IP Address Management

### Reserve IP Range in NetBox

```python
#!/usr/bin/env python3
# /// script
# dependencies = ["pynetbox"]
# ///

import pynetbox
import os

netbox = pynetbox.api(
    os.getenv("NETBOX_URL"),
    token=os.getenv("NETBOX_TOKEN")
)

# Define IP range for Docker hosts
docker_ips = [
    {"ip": "192.168.1.100/24", "dns": "docker-01-nexus.spaceships.work", "desc": "Nexus registry"},
    {"ip": "192.168.1.101/24", "dns": "docker-02-gitlab.spaceships.work", "desc": "GitLab CI/CD"},
    {"ip": "192.168.1.102/24", "dns": "docker-03-monitoring.spaceships.work", "desc": "Monitoring stack"},
]

for entry in docker_ips:
    ip = netbox.ipam.ip_addresses.create(
        address=entry["ip"],
        dns_name=entry["dns"],
        description=entry["desc"],
        status="reserved",
        tags=[{"name": "production-dns"}, {"name": "docker-host"}]
    )
    print(f"Created: {ip.dns_name} → {entry['ip']}")
```

**Run script:**

```bash
export NETBOX_URL="https://netbox.spaceships.work"
export NETBOX_TOKEN="your-api-token"

uv run reserve-docker-ips.py
```

### Update Status to Active

When VMs are deployed, update IPs from "reserved" to "active":

```python
#!/usr/bin/env python3
# /// script
# dependencies = ["pynetbox"]
# ///

import pynetbox
import os
import sys

netbox = pynetbox.api(
    os.getenv("NETBOX_URL"),
    token=os.getenv("NETBOX_TOKEN")
)

fqdn = sys.argv[1] if len(sys.argv) > 1 else "docker-01-nexus.spaceships.work"

# Find IP by DNS name
ips = netbox.ipam.ip_addresses.filter(dns_name=fqdn)
if not ips:
    print(f"No IP found for {fqdn}")
    sys.exit(1)

ip = ips[0]
ip.status = "active"
ip.save()

print(f"Updated {ip.dns_name}: {ip.address} → active")
```

## Workflow 3: DNS Record Auditing

### Verify NetBox and PowerDNS are in Sync

```python
#!/usr/bin/env python3
# /// script
# dependencies = ["pynetbox", "requests"]
# ///

import pynetbox
import requests
import os
import sys

netbox = pynetbox.api(
    os.getenv("NETBOX_URL"),
    token=os.getenv("NETBOX_TOKEN")
)

powerdns_url = os.getenv("POWERDNS_URL", "http://192.168.3.1:8081/api/v1")
powerdns_key = os.getenv("POWERDNS_API_KEY")
zone = sys.argv[1] if len(sys.argv) > 1 else "spaceships.work"

# Get NetBox IPs tagged for DNS
netbox_ips = netbox.ipam.ip_addresses.filter(tag="production-dns")

# Get PowerDNS records
headers = {"X-API-Key": powerdns_key}
pdns_resp = requests.get(f"{powerdns_url}/servers/localhost/zones/{zone}", headers=headers)
pdns_zone = pdns_resp.json()

# Extract A records from PowerDNS
pdns_records = {}
for rrset in pdns_zone.get("rrsets", []):
    if rrset["type"] == "A":
        name = rrset["name"].rstrip(".")
        for record in rrset["records"]:
            pdns_records[name] = record["content"]

# Compare
print("NetBox → PowerDNS Sync Status\n")
print(f"{'DNS Name':<45} {'NetBox IP':<15} {'PowerDNS IP':<15} {'Status'}")
print("-" * 90)

for nb_ip in netbox_ips:
    if not nb_ip.dns_name:
        continue

    dns_name = nb_ip.dns_name.rstrip(".")
    nb_addr = str(nb_ip.address).split("/")[0]
    pdns_addr = pdns_records.get(dns_name, "MISSING")

    if pdns_addr == nb_addr:
        status = "✓ SYNCED"
    elif pdns_addr == "MISSING":
        status = "✗ NOT IN POWERDNS"
    else:
        status = f"✗ MISMATCH"

    print(f"{dns_name:<45} {nb_addr:<15} {pdns_addr:<15} {status}")
```

**Run audit:**

```bash
export NETBOX_URL="https://netbox.spaceships.work"
export NETBOX_TOKEN="your-netbox-token"
export POWERDNS_URL="http://192.168.3.1:8081/api/v1"
export POWERDNS_API_KEY="your-powerdns-key"

uv run dns-audit.py spaceships.work
```

## Workflow 4: Dynamic Inventory for Ansible

### Use NetBox as Inventory Source

**Create dynamic inventory file:**

```yaml
# ansible/inventory/netbox.yml
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.spaceships.work
token: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  ...

# Group hosts by tags
group_by:
  - tags

# Group hosts by device role
compose:
  ansible_host: primary_ip4

# Filter to only include VMs with production-dns tag
query_filters:
  - tag: production-dns
```

**Test inventory:**

```bash
ansible-inventory -i ansible/inventory/netbox.yml --list --yaml
```

**Use in playbook:**

```yaml
---
- name: Configure all Docker hosts
  hosts: tag_docker_host  # Automatically grouped by tag
  become: true

  tasks:
    - name: Ensure Docker is running
      ansible.builtin.systemd:
        name: docker
        state: started
        enabled: true

    - name: Report host info
      ansible.builtin.debug:
        msg: "Configuring {{ inventory_hostname }} ({{ ansible_host }})"
```

**Run with dynamic inventory:**

```bash
cd ansible
uv run ansible-playbook -i inventory/netbox.yml playbooks/configure-docker-hosts.yml
```

## Workflow 5: Cleanup and Decommission

### Remove VM and DNS Records

**Terraform destroy workflow:**

```bash
cd terraform/netbox-vm/
tofu destroy

# This will:
# 1. Remove NetBox IP address record
# 2. netbox-powerdns-sync plugin removes DNS records
# 3. Proxmox VM is deleted
```

**Ansible decommission playbook:**

```yaml
---
- name: Decommission VM and remove DNS
  hosts: localhost
  gather_facts: false

  vars:
    vm_fqdn: docker-01-nexus.spaceships.work
    vm_ip: 192.168.1.100

  tasks:
    - name: Remove IP from NetBox
      netbox.netbox.netbox_ip_address:
        netbox_url: "{{ netbox_url }}"
        netbox_token: "{{ netbox_token }}"
        data:
          address: "{{ vm_ip }}/24"
        state: absent

    # DNS records automatically removed by plugin

    - name: Verify DNS record removed
      ansible.builtin.command: dig @192.168.3.1 {{ vm_fqdn }} +short
      register: dns_check
      failed_when: dns_check.stdout != ""
      changed_when: false

    - name: Delete VM from Proxmox
      community.proxmox.proxmox_kvm:
        api_host: "{{ proxmox_api_host }}"
        api_user: "{{ proxmox_api_user }}"
        api_token_id: "{{ proxmox_token_id }}"
        api_token_secret: "{{ proxmox_token_secret }}"
        node: foxtrot
        vmid: 101
        state: absent
```

## Best Practices

### 1. Always Use Tags

Tag IP addresses for automatic DNS sync:

```hcl
tags = ["terraform", "production-dns", "service-type"]
```

### 2. Reserve Before Deploy

Reserve IPs in NetBox before deploying VMs:

```text
Status: reserved → active (after deployment)
```

### 3. Validate Names

Use naming convention validation before creating:

```bash
./tools/validate_dns_naming.py docker-01-nexus.spaceships.work
```

### 4. Monitor Sync Status

Regular audits to ensure NetBox and PowerDNS are in sync:

```bash
./tools/dns-audit.py spaceships.work
```

### 5. Use Descriptions

Document in NetBox description field:

```text
Description: Docker host for Nexus container registry
             Owner: Platform Team
             Related: docker-02-gitlab.spaceships.work
```

### 6. Test DNS Resolution

Always verify DNS after creation:

```bash
dig @192.168.3.1 <fqdn> +short
dig @192.168.3.1 -x <ip> +short
```

## Troubleshooting

### DNS Records Not Created

#### Check 1: Tag matching

```bash
# Verify IP has production-dns tag
curl -H "Authorization: Token $NETBOX_TOKEN" \
  "$NETBOX_URL/api/ipam/ip-addresses/?address=192.168.1.100" | jq '.results[0].tags'
```

#### Check 2: Plugin configuration

```python
# In NetBox: Plugins → NetBox PowerDNS Sync → Zones
# Verify zone exists and tag rules match
```

#### Check 3: Manual sync

```bash
# In NetBox UI: Plugins → NetBox PowerDNS Sync → Zones → <zone> → Sync Now
```

### DNS Resolution Failures

**Check PowerDNS API:**

```bash
curl -H "X-API-Key: $POWERDNS_API_KEY" \
  http://192.168.3.1:8081/api/v1/servers/localhost/zones/spaceships.work
```

**Check DNS server:**

```bash
dig @192.168.3.1 spaceships.work SOA
```

## Further Reading

- [NetBox PowerDNS Sync Plugin](../reference/sync-plugin-reference.md)
- [Terraform NetBox Provider](../reference/terraform-provider-guide.md)
- [DNS Naming Conventions](naming-conventions.md)

```

### anti-patterns/common-mistakes.md

```markdown
# Common Mistakes and Anti-Patterns

DNS naming convention violations and NetBox/PowerDNS integration pitfalls based on the `spaceships.work` infrastructure.

## DNS Naming Convention Violations

### Infrastructure Overview

**Root Domain**: `spaceships.work`

**Cluster Domains**:

- `matrix.spaceships.work` - Nexus cluster (3 nodes)
- `quantum.spaceships.work` - Quantum cluster (3 nodes)
- `nexus.spaceships.work` - (Legacy naming reference)

**Proxmox Node Domains** (with master node designations):

**Matrix Cluster** (nexus.spaceships.work):

- `foxtrot.nexus.spaceships.work` - **Master Node** (API Target)
- `golf.nexus.spaceships.work`
- `hotel.nexus.spaceships.work`

**Quantum Cluster** (quantum.spaceships.work):

- `charlie.quantum.spaceships.work`
- `delta.quantum.spaceships.work` - **Master Node** (API Target)
- `echo.quantum.spaceships.work`

**Matrix Cluster** (matrix.spaceships.work):

- `alpha.matrix.spaceships.work`
- `bravo.matrix.spaceships.work` - **Master Node** (API Target)
- `charlie.matrix.spaceships.work`

---

## ❌ Wrong Root Domain

**Problem**: Using incorrect root domain in DNS records.

```python
# BAD - Wrong domain
hostname = "docker-01-nexus.internal.lan"
hostname = "k8s-master.homelab.local"
```

**Solution**: Always use `spaceships.work` as root domain.

```python
# GOOD
hostname = "docker-01-nexus.spaceships.work"
hostname = "k8s-01-master.matrix.spaceships.work"
```

---

## ❌ Wrong Cluster Subdomain

**Problem**: Using non-existent cluster domains.

```python
# BAD - Invalid cluster domains
hostname = "docker-01.homelab.spaceships.work"  # No 'homelab' cluster
hostname = "vm-01.lab.spaceships.work"          # No 'lab' cluster
hostname = "test.prod.spaceships.work"          # No 'prod' cluster
```

**Solution**: Use only valid cluster domains.

```python
# GOOD - Valid cluster domains
hostname = "docker-01-nexus.matrix.spaceships.work"
hostname = "k8s-01-worker.quantum.spaceships.work"
hostname = "storage-01-ceph.nexus.spaceships.work"
```

**Valid Clusters**:

- `matrix.spaceships.work`
- `quantum.spaceships.work`
- `nexus.spaceships.work`

---

## ❌ Wrong Node Domain

**Problem**: Using incorrect Proxmox node FQDNs.

```python
# BAD - Incorrect node names
proxmox_host = "node1.spaceships.work"          # Not the naming pattern
proxmox_host = "foxtrot.spaceships.work"        # Missing cluster subdomain
proxmox_host = "foxtrot.matrix.spaceships.org"  # Wrong TLD
```

**Solution**: Use correct node FQDN pattern.

```python
# GOOD - Correct node FQDNs
proxmox_host = "foxtrot.nexus.spaceships.work"    # Nexus cluster master
proxmox_host = "delta.quantum.spaceships.work"    # Quantum cluster master
proxmox_host = "bravo.matrix.spaceships.work"     # Matrix cluster master
```

---

## ❌ Targeting Non-Master Nodes

**Problem**: Sending API calls to non-master nodes in cluster.

```python
# BAD - Not a master node
from proxmoxer import ProxmoxAPI

proxmox = ProxmoxAPI(
    'golf.nexus.spaceships.work',  # ❌ Not the master!
    user='root@pam',
    password='...'
)
```

**Solution**: Always target the designated master node for API operations.

```python
# GOOD - Target master nodes
from proxmoxer import ProxmoxAPI

# Nexus cluster
proxmox_nexus = ProxmoxAPI(
    'foxtrot.nexus.spaceships.work',  # ✅ Master node
    user='root@pam',
    password='...'
)

# Quantum cluster
proxmox_quantum = ProxmoxAPI(
    'delta.quantum.spaceships.work',  # ✅ Master node
    user='root@pam',
    password='...'
)

# Matrix cluster
proxmox_matrix = ProxmoxAPI(
    'bravo.matrix.spaceships.work',  # ✅ Master node
    user='root@pam',
    password='...'
)
```

**Master Nodes Quick Reference**:

- **Nexus**: `foxtrot.nexus.spaceships.work`
- **Quantum**: `delta.quantum.spaceships.work`
- **Matrix**: `bravo.matrix.spaceships.work`

---

## ❌ Violating Service-Number-Purpose Pattern

**Problem**: Not following the `service-NN-purpose` naming convention.

```python
# BAD - Violates naming pattern
hostname = "nexus.spaceships.work"              # Missing service number
hostname = "docker-nexus.spaceships.work"       # Missing number
hostname = "docker1-nexus.spaceships.work"      # Wrong number format (1 vs 01)
hostname = "nexus-docker-01.spaceships.work"    # Wrong order
```

**Solution**: Follow `service-NN-purpose.domain` pattern.

```python
# GOOD - Correct naming pattern
hostname = "docker-01-nexus.spaceships.work"      # ✅ service-01-purpose
hostname = "k8s-02-worker.matrix.spaceships.work" # ✅ service-02-purpose
hostname = "storage-03-ceph.nexus.spaceships.work"# ✅ service-03-purpose
```

**Pattern**: `<service>-<NN>-<purpose>.<cluster>.<root-domain>`

Components:

- **service**: Infrastructure type (`docker`, `k8s`, `proxmox`, `storage`, `db`)
- **NN**: Two-digit number (`01`, `02`, `03`, ... `99`)
- **purpose**: Specific role (`nexus`, `master`, `worker`, `ceph`, `postgres`)
- **cluster**: Cluster subdomain (`matrix`, `quantum`, `nexus`)
- **root-domain**: `spaceships.work`

---

## ❌ Improper Case in Hostnames

**Problem**: Using uppercase letters in DNS names.

```python
# BAD - Uppercase not allowed
hostname = "Docker-01-Nexus.spaceships.work"
hostname = "K8S-01-MASTER.matrix.spaceships.work"
```

**Solution**: Always use lowercase.

```python
# GOOD
hostname = "docker-01-nexus.spaceships.work"
hostname = "k8s-01-master.matrix.spaceships.work"
```

---

## NetBox Integration Issues

### ❌ Not Setting DNS Name in NetBox

**Problem**: Creating IP address in NetBox without `dns_name` field.

```python
# BAD - Missing DNS name
ip = nb.ipam.ip_addresses.create(
    address="192.168.3.100/24",
    description="Docker host",
    # Missing dns_name!
)
```

**Solution**: Always set `dns_name` when creating IPs.

```python
# GOOD
ip = nb.ipam.ip_addresses.create(
    address="192.168.3.100/24",
    dns_name="docker-01-nexus.matrix.spaceships.work",  # ✅
    description="Docker host for Nexus registry",
    tags=["production-dns"]  # Triggers PowerDNS sync
)
```

---

### ❌ Missing PowerDNS Sync Tags

**Problem**: DNS record not created automatically because missing trigger tag.

```python
# BAD - No sync tag
ip = nb.ipam.ip_addresses.create(
    address="192.168.3.100/24",
    dns_name="docker-01-nexus.matrix.spaceships.work",
    tags=["docker", "production"]  # Missing 'production-dns' tag!
)
# PowerDNS record NOT created automatically
```

**Solution**: Include appropriate sync tag.

```python
# GOOD
ip = nb.ipam.ip_addresses.create(
    address="192.168.3.100/24",
    dns_name="docker-01-nexus.matrix.spaceships.work",
    tags=[
        "docker",
        "production",
        "production-dns"  # ✅ Triggers PowerDNS sync
    ]
)
```

**Sync Tags**:

- `production-dns` - Auto-create in PowerDNS production zone
- `lab-dns` - Auto-create in PowerDNS lab zone

---

### ❌ Inconsistent DNS Naming Between Tools

**Problem**: Different naming in OpenTofu vs NetBox vs Proxmox.

**Note**: Use `tofu` CLI (not `terraform`).

```hcl
# OpenTofu
resource "proxmox_virtual_environment_vm" "docker_host" {
  name = "docker-nexus-01"  # ❌ Wrong order
}

# NetBox
dns_name = "nexus-docker-01.spaceships.work"  # ❌ Different order

# Proxmox
hostname = "docker01-nexus"  # ❌ Missing hyphen
```

**Solution**: Consistent naming everywhere.

```hcl
# OpenTofu
resource "proxmox_virtual_environment_vm" "docker_host" {
  name = "docker-01-nexus"  # ✅ Consistent
}
```

```python
# NetBox
dns_name = "docker-01-nexus.matrix.spaceships.work"  # ✅ Consistent
```

```yaml
# Proxmox (via OpenTofu)
initialization {
  user_data_file_id = "local:snippets/user-data.yaml"
  # Inside user-data.yaml:
  # hostname: docker-01-nexus  # ✅ Consistent
  # fqdn: docker-01-nexus.matrix.spaceships.work
}
```

---

## Validation Tools

### Check DNS Naming Convention

```bash
# Validate hostname format
./tools/validate_dns_naming.py --name "docker-01-nexus.matrix.spaceships.work"
# ✅ Valid

./tools/validate_dns_naming.py --name "docker-nexus.spaceships.work"
# ❌ Invalid: Missing number in service-NN-purpose pattern
```

### Check NetBox DNS Records

```bash
# Query NetBox for DNS records
./tools/netbox_api_client.py ips query --dns-name docker-01

# Verify PowerDNS sync
./tools/powerdns_sync_check.py --zone spaceships.work --verbose
```

---

## Quick Reference

### Correct Naming Patterns

**VM Hostnames**:

```text
docker-01-nexus.matrix.spaceships.work
k8s-01-master.quantum.spaceships.work
storage-01-ceph.nexus.spaceships.work
db-01-postgres.matrix.spaceships.work
```

**Proxmox Nodes**:

```text
foxtrot.nexus.spaceships.work (master)
delta.quantum.spaceships.work (master)
bravo.matrix.spaceships.work (master)
```

**Service Types**:

- `docker-NN-<app>` - Docker hosts
- `k8s-NN-<role>` - Kubernetes nodes (master, worker)
- `proxmox-<node>-<iface>` - Proxmox infrastructure
- `storage-NN-<type>` - Storage systems (ceph, nas)
- `db-NN-<dbtype>` - Database servers (postgres, mysql)

### Valid Domains

**Clusters**:

- `matrix.spaceships.work`
- `quantum.spaceships.work`
- `nexus.spaceships.work`

**Master Nodes** (API targets):

- Nexus: `foxtrot.nexus.spaceships.work`
- Quantum: `delta.quantum.spaceships.work`
- Matrix: `bravo.matrix.spaceships.work`

---

## Troubleshooting

### DNS Record Not Created in PowerDNS

**Check**:

1. ✅ DNS name follows pattern? `service-NN-purpose.cluster.spaceships.work`
2. ✅ Has `production-dns` or `lab-dns` tag in NetBox?
3. ✅ NetBox PowerDNS sync plugin enabled?
4. ✅ Zone exists in NetBox and matches domain?

### Can't Connect to Proxmox API

**Check**:

1. ✅ Using master node FQDN?
   - Nexus: `foxtrot.nexus.spaceships.work`
   - Quantum: `delta.quantum.spaceships.work`
   - Matrix: `bravo.matrix.spaceships.work`
2. ✅ DNS resolves correctly? `dig <master-node-fqdn>`
3. ✅ Cluster subdomain included? (not just `<node>.spaceships.work`)

### Validation Script Fails

**Common Issues**:

```bash
# Missing number
❌ docker-nexus.spaceships.work
✅ docker-01-nexus.matrix.spaceships.work

# Wrong number format
❌ docker-1-nexus.spaceships.work
✅ docker-01-nexus.matrix.spaceships.work

# Missing cluster subdomain
❌ docker-01-nexus.spaceships.work
✅ docker-01-nexus.matrix.spaceships.work

# Wrong domain
❌ docker-01-nexus.local
✅ docker-01-nexus.matrix.spaceships.work
```

Run validation:

```bash
./tools/validate_dns_naming.py --name "your-hostname-here"
```

```

### tools/netbox_api_client.py

```python
#!/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()

```

### tools/netbox_vm_create.py

```python
#!/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()

```

### tools/netbox_ipam_query.py

```python
#!/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()

```

### tools/validate_dns_naming.py

```python
#!/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()

```

netbox-powerdns-integration | SkillHub