nbpull 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nbpull-0.1.0.dist-info/METADATA +250 -0
- nbpull-0.1.0.dist-info/RECORD +15 -0
- nbpull-0.1.0.dist-info/WHEEL +4 -0
- nbpull-0.1.0.dist-info/entry_points.txt +3 -0
- netbox_data_puller/__init__.py +3 -0
- netbox_data_puller/cli.py +467 -0
- netbox_data_puller/client.py +116 -0
- netbox_data_puller/config.py +24 -0
- netbox_data_puller/formatters.py +321 -0
- netbox_data_puller/models/__init__.py +1 -0
- netbox_data_puller/models/ip_address.py +22 -0
- netbox_data_puller/models/prefix.py +45 -0
- netbox_data_puller/models/vlan.py +21 -0
- netbox_data_puller/models/vrf.py +18 -0
- netbox_data_puller/py.typed +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nbpull
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 🔍 Read-only CLI tool to pull IPAM data from NetBox
|
|
5
|
+
Keywords: netbox,ipam,network,cli,read-only
|
|
6
|
+
Author: Aditya Patel
|
|
7
|
+
Author-email: Aditya Patel <adityapatel0905@gmail.com>
|
|
8
|
+
License-Expression: GPL-3.0-only
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: httpx>=0.28
|
|
21
|
+
Requires-Dist: pydantic>=2.10
|
|
22
|
+
Requires-Dist: pydantic-settings>=2.7
|
|
23
|
+
Requires-Dist: typer>=0.15
|
|
24
|
+
Requires-Dist: rich>=13.9
|
|
25
|
+
Requires-Python: >=3.13
|
|
26
|
+
Project-URL: Homepage, https://github.com/Champion2005/nbpull
|
|
27
|
+
Project-URL: Documentation, https://github.com/Champion2005/nbpull/tree/main/docs
|
|
28
|
+
Project-URL: Repository, https://github.com/Champion2005/nbpull
|
|
29
|
+
Project-URL: Issues, https://github.com/Champion2005/nbpull/issues
|
|
30
|
+
Project-URL: Changelog, https://github.com/Champion2005/nbpull/blob/main/CHANGELOG.md
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# 🔍 nbpull
|
|
34
|
+
|
|
35
|
+
[](https://github.com/Champion2005/nbpull/actions/workflows/ci.yml)
|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
[](https://www.gnu.org/licenses/gpl-3.0)
|
|
38
|
+
[](https://mypy-lang.org/)
|
|
39
|
+
|
|
40
|
+
**Read-only CLI tool to pull IPAM data from
|
|
41
|
+
[NetBox](https://netbox.dev).**
|
|
42
|
+
|
|
43
|
+
> **🔒 Safety guarantee:** nbpull **only reads** data from NetBox. No
|
|
44
|
+
> POST / PUT / PATCH / DELETE requests are ever made. The HTTP client is
|
|
45
|
+
> hardcoded to GET-only — this invariant is enforced by code structure
|
|
46
|
+
> and verified by tests.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## ✨ Features
|
|
51
|
+
|
|
52
|
+
- 📡 **Prefixes** — list and filter IPAM prefixes
|
|
53
|
+
- 🖥️ **IP Addresses** — query IP address allocations
|
|
54
|
+
- 🏷️ **VLANs** — browse VLAN assignments
|
|
55
|
+
- 🔀 **VRFs** — inspect VRF instances
|
|
56
|
+
- 📦 **Batch queries** — check many prefixes at once from a TOML file
|
|
57
|
+
- 🎨 Rich table output (default) or JSON (`--format json`)
|
|
58
|
+
- 🔎 Filter by status, VRF, tenant, site, tag, or free-text search
|
|
59
|
+
- ⚡ Async HTTP with automatic pagination
|
|
60
|
+
- 🔒 Strict typing (mypy strict mode + Pydantic v2)
|
|
61
|
+
|
|
62
|
+
## 📦 Installation
|
|
63
|
+
|
|
64
|
+
### With [uv](https://docs.astral.sh/uv/) (recommended)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv tool install nbpull
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### With [pipx](https://pipx.pypa.io/)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pipx install nbpull
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### With pip
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install nbpull
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### From source
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
git clone https://github.com/Champion2005/nbpull.git
|
|
86
|
+
cd nbpull
|
|
87
|
+
make install # uses uv sync
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 🚀 Quick Start
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# 1. Configure your NetBox connection
|
|
94
|
+
export NETBOX_URL=https://netbox.example.com
|
|
95
|
+
export NETBOX_TOKEN=your_read_only_token
|
|
96
|
+
|
|
97
|
+
# Or use a .env file:
|
|
98
|
+
cp .env.example .env
|
|
99
|
+
# Edit .env with your values
|
|
100
|
+
|
|
101
|
+
# 2. Pull data
|
|
102
|
+
nbpull prefixes
|
|
103
|
+
nbpull prefixes --status active --vrf Production
|
|
104
|
+
nbpull ip-addresses --prefix 10.0.0.0/24
|
|
105
|
+
nbpull vlans --site DC1
|
|
106
|
+
nbpull vrfs --tenant Ops
|
|
107
|
+
nbpull batch-prefixes --file my_prefixes.toml --status-only
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 📋 Commands
|
|
111
|
+
|
|
112
|
+
| Command | Description |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `nbpull prefixes` | List IPAM prefixes |
|
|
115
|
+
| `nbpull ip-addresses` | List IP addresses |
|
|
116
|
+
| `nbpull vlans` | List VLANs |
|
|
117
|
+
| `nbpull vrfs` | List VRFs |
|
|
118
|
+
| `nbpull batch-prefixes` | Query multiple prefixes from a TOML file |
|
|
119
|
+
|
|
120
|
+
### Common Flags
|
|
121
|
+
|
|
122
|
+
| Flag | Description |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `--status` | Filter by status (active, reserved, deprecated, container) |
|
|
125
|
+
| `--vrf` | Filter by VRF name |
|
|
126
|
+
| `--tenant` | Filter by tenant name |
|
|
127
|
+
| `--site` | Filter by site name |
|
|
128
|
+
| `--tag` | Filter by tag slug |
|
|
129
|
+
| `--search` / `-s` | Free-text search |
|
|
130
|
+
| `--limit` / `-l` | Max results (default: 50) |
|
|
131
|
+
| `--format` / `-f` | Output format: `table` (default) or `json` |
|
|
132
|
+
| `--verbose` / `-v` | Enable debug logging |
|
|
133
|
+
|
|
134
|
+
See the full [command reference](docs/commands.md) for all options.
|
|
135
|
+
|
|
136
|
+
## ⚙️ Configuration
|
|
137
|
+
|
|
138
|
+
Set these in `.env` or as environment variables:
|
|
139
|
+
|
|
140
|
+
| Variable | Required | Default | Description |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| `NETBOX_URL` | ✅ | — | NetBox instance URL |
|
|
143
|
+
| `NETBOX_TOKEN` | ✅ | — | API token (read-only recommended) |
|
|
144
|
+
| `NETBOX_PAGE_SIZE` | ❌ | `100` | Results per API page |
|
|
145
|
+
| `NETBOX_TIMEOUT` | ❌ | `30` | Request timeout (seconds) |
|
|
146
|
+
| `NETBOX_VERIFY_SSL` | ❌ | `true` | Verify SSL certificates |
|
|
147
|
+
|
|
148
|
+
See [docs/configuration.md](docs/configuration.md) for details on
|
|
149
|
+
token setup and SSL options.
|
|
150
|
+
|
|
151
|
+
## 📦 Batch Queries
|
|
152
|
+
|
|
153
|
+
Create a TOML file to query multiple prefixes in one run:
|
|
154
|
+
|
|
155
|
+
```toml
|
|
156
|
+
prefixes = [
|
|
157
|
+
"10.0.0.0/8",
|
|
158
|
+
"172.16.0.0/12",
|
|
159
|
+
"192.168.0.0/16",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
[filters]
|
|
163
|
+
# status = "active"
|
|
164
|
+
# vrf = "Production"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
nbpull batch-prefixes --file prefixes.toml --status-only
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 📐 Architecture
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
src/netbox_data_puller/
|
|
175
|
+
├── cli.py # Typer commands + filtering
|
|
176
|
+
├── client.py # Async GET-only NetBox API client
|
|
177
|
+
├── config.py # Pydantic Settings (.env)
|
|
178
|
+
├── formatters.py # Rich table renderers
|
|
179
|
+
└── models/ # Pydantic models per resource
|
|
180
|
+
├── prefix.py
|
|
181
|
+
├── ip_address.py
|
|
182
|
+
├── vlan.py
|
|
183
|
+
└── vrf.py
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
See [docs/architecture.md](docs/architecture.md) for a full breakdown.
|
|
187
|
+
|
|
188
|
+
## 🛠️ Development
|
|
189
|
+
|
|
190
|
+
### Prerequisites
|
|
191
|
+
|
|
192
|
+
- **Python 3.13+**
|
|
193
|
+
- **[uv](https://docs.astral.sh/uv/)** — fast Python package manager
|
|
194
|
+
|
|
195
|
+
### Setup
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
git clone https://github.com/Champion2005/nbpull.git
|
|
199
|
+
cd nbpull
|
|
200
|
+
make install # Install dependencies
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Commands
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
make all # format → lint → typecheck → test
|
|
207
|
+
make test # unit tests (no network)
|
|
208
|
+
make lint # ruff linter
|
|
209
|
+
make format # auto-format with ruff
|
|
210
|
+
make typecheck # mypy strict mode
|
|
211
|
+
make test-integration # hits real NetBox API
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Running Tests
|
|
215
|
+
|
|
216
|
+
Unit tests use mocked HTTP responses and require no network access:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
make test
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Integration tests require `NETBOX_URL` and `NETBOX_TOKEN`:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
make test-integration
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## 🤝 Contributing
|
|
229
|
+
|
|
230
|
+
Contributions are welcome! Please read
|
|
231
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR.
|
|
232
|
+
|
|
233
|
+
## 📝 Changelog
|
|
234
|
+
|
|
235
|
+
See [CHANGELOG.md](CHANGELOG.md) for a history of changes.
|
|
236
|
+
|
|
237
|
+
## 📄 License
|
|
238
|
+
|
|
239
|
+
This project is licensed under the
|
|
240
|
+
**GNU General Public License v3.0** — see the [LICENSE](LICENSE) file
|
|
241
|
+
for details.
|
|
242
|
+
|
|
243
|
+
## 🙏 Acknowledgements
|
|
244
|
+
|
|
245
|
+
- [NetBox](https://netbox.dev) — the leading open-source IPAM/DCIM
|
|
246
|
+
platform
|
|
247
|
+
- [Typer](https://typer.tiangolo.com/) — CLI framework
|
|
248
|
+
- [Rich](https://rich.readthedocs.io/) — beautiful terminal formatting
|
|
249
|
+
- [httpx](https://www.python-httpx.org/) — async HTTP client
|
|
250
|
+
- [Pydantic](https://docs.pydantic.dev/) — data validation
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
netbox_data_puller/__init__.py,sha256=7iXthOLjrLHdTZMyYOLcnptqfZ_eJ09QI-B0IdDElc8,102
|
|
2
|
+
netbox_data_puller/cli.py,sha256=25ZgUYkj7nL-1qMqHT8n-1198VUhPcfHzeNgL0KWvO8,12659
|
|
3
|
+
netbox_data_puller/client.py,sha256=jsLGQ8D00e8H_IotmvsTaI72Mh_nWw96hYwF248GGWY,3525
|
|
4
|
+
netbox_data_puller/config.py,sha256=x3OstA8HLeUInL_xkknOneAehdhT5V6Sf_Qh0Z7O92w,582
|
|
5
|
+
netbox_data_puller/formatters.py,sha256=8RNt6Onp4hs3lQX8StNhR5c2mYppXbnTvZUQAq20-2o,9586
|
|
6
|
+
netbox_data_puller/models/__init__.py,sha256=Or2eAaquny9YSpq8PcqbikNyMdI92XqLe69ac1_D9Oc,54
|
|
7
|
+
netbox_data_puller/models/ip_address.py,sha256=_PnieCUhjtSEbjjA1D6akNVl0XLZlGc_Cm07MWqbOMM,593
|
|
8
|
+
netbox_data_puller/models/prefix.py,sha256=d2QEUBTONMCwAtKM5lwMG0J4mPCiM8jsFMI_N02pBFI,1060
|
|
9
|
+
netbox_data_puller/models/vlan.py,sha256=ttKGxAlO3T-g51f3FGBVl80_VV4IZkm9e5ycM1fU7W4,513
|
|
10
|
+
netbox_data_puller/models/vrf.py,sha256=eVE9xPYfDkTpQtN5OcY3Rc104tI30-cuOCQiShrkEx0,405
|
|
11
|
+
netbox_data_puller/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
nbpull-0.1.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
13
|
+
nbpull-0.1.0.dist-info/entry_points.txt,sha256=l4XI8XvxYl7G1dlhoqbeB9NdPVElzpia0TRIh5VdC50,55
|
|
14
|
+
nbpull-0.1.0.dist-info/METADATA,sha256=6n4C5O52m1t2S1DKR9w9qfbjJ1d4F8NDqz33-FXZ2b8,7075
|
|
15
|
+
nbpull-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""🖥️ CLI commands for querying NetBox IPAM data.
|
|
2
|
+
|
|
3
|
+
All commands are READ-ONLY — no data is ever written to NetBox.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.progress import (
|
|
17
|
+
BarColumn,
|
|
18
|
+
MofNCompleteColumn,
|
|
19
|
+
Progress,
|
|
20
|
+
SpinnerColumn,
|
|
21
|
+
TextColumn,
|
|
22
|
+
TimeElapsedColumn,
|
|
23
|
+
)
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
from netbox_data_puller.client import NetBoxClient
|
|
27
|
+
from netbox_data_puller.config import Settings
|
|
28
|
+
from netbox_data_puller.formatters import (
|
|
29
|
+
print_batch_summary,
|
|
30
|
+
print_ip_addresses,
|
|
31
|
+
print_json,
|
|
32
|
+
print_prefixes,
|
|
33
|
+
print_prefixes_status,
|
|
34
|
+
print_vlans,
|
|
35
|
+
print_vrfs,
|
|
36
|
+
)
|
|
37
|
+
from netbox_data_puller.models.ip_address import IPAddress
|
|
38
|
+
from netbox_data_puller.models.prefix import Prefix
|
|
39
|
+
from netbox_data_puller.models.vlan import VLAN
|
|
40
|
+
from netbox_data_puller.models.vrf import VRF
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
console = Console(stderr=True)
|
|
44
|
+
|
|
45
|
+
app = typer.Typer(
|
|
46
|
+
name="nbpull",
|
|
47
|
+
help="🔍 Read-only CLI to pull IPAM data from NetBox.",
|
|
48
|
+
no_args_is_help=True,
|
|
49
|
+
rich_markup_mode="rich",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Shared types
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OutputFormat(StrEnum):
|
|
59
|
+
"""Output format selector."""
|
|
60
|
+
|
|
61
|
+
table = "table"
|
|
62
|
+
json = "json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Common option type aliases
|
|
66
|
+
FormatOpt = Annotated[
|
|
67
|
+
OutputFormat,
|
|
68
|
+
typer.Option("--format", "-f", help="Output format."),
|
|
69
|
+
]
|
|
70
|
+
StatusOpt = Annotated[
|
|
71
|
+
str | None,
|
|
72
|
+
typer.Option(help="Filter by status (e.g. active, reserved)."),
|
|
73
|
+
]
|
|
74
|
+
VRFOpt = Annotated[
|
|
75
|
+
str | None,
|
|
76
|
+
typer.Option("--vrf", help="Filter by VRF name."),
|
|
77
|
+
]
|
|
78
|
+
TenantOpt = Annotated[
|
|
79
|
+
str | None,
|
|
80
|
+
typer.Option("--tenant", help="Filter by tenant name."),
|
|
81
|
+
]
|
|
82
|
+
SiteOpt = Annotated[
|
|
83
|
+
str | None,
|
|
84
|
+
typer.Option("--site", help="Filter by site name."),
|
|
85
|
+
]
|
|
86
|
+
TagOpt = Annotated[
|
|
87
|
+
str | None,
|
|
88
|
+
typer.Option("--tag", help="Filter by tag slug."),
|
|
89
|
+
]
|
|
90
|
+
SearchOpt = Annotated[
|
|
91
|
+
str | None,
|
|
92
|
+
typer.Option("--search", "-s", help="Free-text search."),
|
|
93
|
+
]
|
|
94
|
+
LimitOpt = Annotated[
|
|
95
|
+
int,
|
|
96
|
+
typer.Option("--limit", "-l", help="Maximum results to return."),
|
|
97
|
+
]
|
|
98
|
+
VerboseOpt = Annotated[
|
|
99
|
+
bool,
|
|
100
|
+
typer.Option("--verbose", "-v", help="Enable debug logging."),
|
|
101
|
+
]
|
|
102
|
+
StatusOnlyOpt = Annotated[
|
|
103
|
+
bool,
|
|
104
|
+
typer.Option(
|
|
105
|
+
"--status-only",
|
|
106
|
+
help="Show only prefix and status columns.",
|
|
107
|
+
),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Helpers
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_params(**kwargs: Any) -> dict[str, Any]:
|
|
117
|
+
"""Build API query params, dropping None values."""
|
|
118
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _get_settings() -> Settings:
|
|
122
|
+
"""Load settings, with a friendly error on misconfiguration."""
|
|
123
|
+
try:
|
|
124
|
+
return Settings()
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
console.print(
|
|
127
|
+
f"[bold red]❌ Configuration error:[/bold red] {exc}\n\n"
|
|
128
|
+
"Make sure NETBOX_URL and NETBOX_TOKEN are set in your "
|
|
129
|
+
".env file or environment variables.\n"
|
|
130
|
+
"See .env.example for reference.",
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(code=1) from exc
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _configure_logging(verbose: bool) -> None:
|
|
136
|
+
"""Set up logging level based on verbosity flag."""
|
|
137
|
+
level = logging.DEBUG if verbose else logging.WARNING
|
|
138
|
+
logging.basicConfig(
|
|
139
|
+
level=level,
|
|
140
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
141
|
+
stream=sys.stderr,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _fetch(
|
|
146
|
+
endpoint: str,
|
|
147
|
+
params: dict[str, Any],
|
|
148
|
+
) -> list[dict[str, Any]]:
|
|
149
|
+
"""Execute a read-only fetch against NetBox."""
|
|
150
|
+
settings = _get_settings()
|
|
151
|
+
async with NetBoxClient(settings) as client:
|
|
152
|
+
return await client.get(endpoint, params)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fetch_with_spinner(
|
|
156
|
+
endpoint: str,
|
|
157
|
+
params: dict[str, Any],
|
|
158
|
+
label: str = "Querying NetBox",
|
|
159
|
+
) -> list[dict[str, Any]]:
|
|
160
|
+
"""Fetch with a live spinner shown on stderr."""
|
|
161
|
+
with Progress(
|
|
162
|
+
SpinnerColumn("dots"),
|
|
163
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
164
|
+
TimeElapsedColumn(),
|
|
165
|
+
console=console,
|
|
166
|
+
transient=True,
|
|
167
|
+
) as progress:
|
|
168
|
+
progress.add_task(label, total=None)
|
|
169
|
+
return asyncio.run(_fetch(endpoint, params))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Commands
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def prefixes(
|
|
179
|
+
status: StatusOpt = None,
|
|
180
|
+
vrf: VRFOpt = None,
|
|
181
|
+
tenant: TenantOpt = None,
|
|
182
|
+
site: SiteOpt = None,
|
|
183
|
+
tag: TagOpt = None,
|
|
184
|
+
search: SearchOpt = None,
|
|
185
|
+
limit: LimitOpt = 50,
|
|
186
|
+
fmt: FormatOpt = OutputFormat.table,
|
|
187
|
+
status_only: StatusOnlyOpt = False,
|
|
188
|
+
verbose: VerboseOpt = False,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""📡 List IPAM prefixes from NetBox."""
|
|
191
|
+
_configure_logging(verbose)
|
|
192
|
+
params = _build_params(
|
|
193
|
+
status=status,
|
|
194
|
+
vrf=vrf,
|
|
195
|
+
tenant=tenant,
|
|
196
|
+
site=site,
|
|
197
|
+
tag=tag,
|
|
198
|
+
q=search,
|
|
199
|
+
limit=limit,
|
|
200
|
+
)
|
|
201
|
+
raw = _fetch_with_spinner("ipam/prefixes/", params, "Fetching prefixes")
|
|
202
|
+
records = [Prefix.model_validate(r) for r in raw]
|
|
203
|
+
|
|
204
|
+
if fmt == OutputFormat.json:
|
|
205
|
+
print_json(records)
|
|
206
|
+
elif status_only:
|
|
207
|
+
print_prefixes_status(records)
|
|
208
|
+
else:
|
|
209
|
+
print_prefixes(records)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command(name="ip-addresses")
|
|
213
|
+
def ip_addresses(
|
|
214
|
+
status: StatusOpt = None,
|
|
215
|
+
vrf: VRFOpt = None,
|
|
216
|
+
tenant: TenantOpt = None,
|
|
217
|
+
tag: TagOpt = None,
|
|
218
|
+
search: SearchOpt = None,
|
|
219
|
+
prefix: Annotated[
|
|
220
|
+
str | None,
|
|
221
|
+
typer.Option(
|
|
222
|
+
"--prefix",
|
|
223
|
+
help="Filter by parent prefix (e.g. 10.0.0.0/24).",
|
|
224
|
+
),
|
|
225
|
+
] = None,
|
|
226
|
+
limit: LimitOpt = 50,
|
|
227
|
+
fmt: FormatOpt = OutputFormat.table,
|
|
228
|
+
verbose: VerboseOpt = False,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""🖥️ List IPAM IP addresses from NetBox."""
|
|
231
|
+
_configure_logging(verbose)
|
|
232
|
+
params = _build_params(
|
|
233
|
+
status=status,
|
|
234
|
+
vrf=vrf,
|
|
235
|
+
tenant=tenant,
|
|
236
|
+
tag=tag,
|
|
237
|
+
q=search,
|
|
238
|
+
parent=prefix,
|
|
239
|
+
limit=limit,
|
|
240
|
+
)
|
|
241
|
+
raw = _fetch_with_spinner(
|
|
242
|
+
"ipam/ip-addresses/",
|
|
243
|
+
params,
|
|
244
|
+
"Fetching IP addresses",
|
|
245
|
+
)
|
|
246
|
+
records = [IPAddress.model_validate(r) for r in raw]
|
|
247
|
+
|
|
248
|
+
if fmt == OutputFormat.json:
|
|
249
|
+
print_json(records)
|
|
250
|
+
else:
|
|
251
|
+
print_ip_addresses(records)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.command()
|
|
255
|
+
def vlans(
|
|
256
|
+
status: StatusOpt = None,
|
|
257
|
+
tenant: TenantOpt = None,
|
|
258
|
+
site: SiteOpt = None,
|
|
259
|
+
tag: TagOpt = None,
|
|
260
|
+
search: SearchOpt = None,
|
|
261
|
+
limit: LimitOpt = 50,
|
|
262
|
+
fmt: FormatOpt = OutputFormat.table,
|
|
263
|
+
verbose: VerboseOpt = False,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""🏷️ List IPAM VLANs from NetBox."""
|
|
266
|
+
_configure_logging(verbose)
|
|
267
|
+
params = _build_params(
|
|
268
|
+
status=status,
|
|
269
|
+
tenant=tenant,
|
|
270
|
+
site=site,
|
|
271
|
+
tag=tag,
|
|
272
|
+
q=search,
|
|
273
|
+
limit=limit,
|
|
274
|
+
)
|
|
275
|
+
raw = _fetch_with_spinner("ipam/vlans/", params, "Fetching VLANs")
|
|
276
|
+
records = [VLAN.model_validate(r) for r in raw]
|
|
277
|
+
|
|
278
|
+
if fmt == OutputFormat.json:
|
|
279
|
+
print_json(records)
|
|
280
|
+
else:
|
|
281
|
+
print_vlans(records)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.command()
|
|
285
|
+
def vrfs(
|
|
286
|
+
tenant: TenantOpt = None,
|
|
287
|
+
tag: TagOpt = None,
|
|
288
|
+
search: SearchOpt = None,
|
|
289
|
+
limit: LimitOpt = 50,
|
|
290
|
+
fmt: FormatOpt = OutputFormat.table,
|
|
291
|
+
verbose: VerboseOpt = False,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""🔀 List IPAM VRFs from NetBox."""
|
|
294
|
+
_configure_logging(verbose)
|
|
295
|
+
params = _build_params(
|
|
296
|
+
tenant=tenant,
|
|
297
|
+
tag=tag,
|
|
298
|
+
q=search,
|
|
299
|
+
limit=limit,
|
|
300
|
+
)
|
|
301
|
+
raw = _fetch_with_spinner("ipam/vrfs/", params, "Fetching VRFs")
|
|
302
|
+
records = [VRF.model_validate(r) for r in raw]
|
|
303
|
+
|
|
304
|
+
if fmt == OutputFormat.json:
|
|
305
|
+
print_json(records)
|
|
306
|
+
else:
|
|
307
|
+
print_vrfs(records)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Batch commands
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
_DEFAULT_BATCH_FILE = "batch_prefixes.toml"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _load_batch_toml(path: Path) -> dict[str, Any]:
|
|
318
|
+
"""Load and validate a batch-prefixes TOML file."""
|
|
319
|
+
if not path.exists():
|
|
320
|
+
console.print(
|
|
321
|
+
f"[bold red]❌ File not found:[/bold red] {path}\n\n"
|
|
322
|
+
f"Create a [bold]{_DEFAULT_BATCH_FILE}[/bold] or pass "
|
|
323
|
+
"--file /path/to/file.toml",
|
|
324
|
+
)
|
|
325
|
+
raise typer.Exit(code=1)
|
|
326
|
+
|
|
327
|
+
import tomllib
|
|
328
|
+
|
|
329
|
+
with path.open("rb") as fh:
|
|
330
|
+
data = tomllib.load(fh)
|
|
331
|
+
|
|
332
|
+
if "prefixes" not in data or not data["prefixes"]:
|
|
333
|
+
console.print(
|
|
334
|
+
"[bold red]❌ TOML file must contain a non-empty "
|
|
335
|
+
"'prefixes' list.[/bold red]",
|
|
336
|
+
)
|
|
337
|
+
raise typer.Exit(code=1)
|
|
338
|
+
|
|
339
|
+
return data
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@app.command(name="batch-prefixes")
|
|
343
|
+
def batch_prefixes(
|
|
344
|
+
file: Annotated[
|
|
345
|
+
Path,
|
|
346
|
+
typer.Option(
|
|
347
|
+
"--file",
|
|
348
|
+
help=(
|
|
349
|
+
f"Path to TOML file with prefix list (default: {_DEFAULT_BATCH_FILE})."
|
|
350
|
+
),
|
|
351
|
+
),
|
|
352
|
+
] = Path(_DEFAULT_BATCH_FILE),
|
|
353
|
+
fmt: FormatOpt = OutputFormat.table,
|
|
354
|
+
status_only: StatusOnlyOpt = False,
|
|
355
|
+
verbose: VerboseOpt = False,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""📦 Query NetBox for multiple prefixes defined in a TOML file.
|
|
358
|
+
|
|
359
|
+
Reads a list of CIDR prefixes and optional global filters from
|
|
360
|
+
a TOML file, then queries NetBox for each one.
|
|
361
|
+
|
|
362
|
+
Example TOML:
|
|
363
|
+
|
|
364
|
+
prefixes = ["10.32.16.0/20", "172.16.0.0/12"]
|
|
365
|
+
|
|
366
|
+
[filters]
|
|
367
|
+
|
|
368
|
+
status = "active"
|
|
369
|
+
"""
|
|
370
|
+
_configure_logging(verbose)
|
|
371
|
+
data = _load_batch_toml(file)
|
|
372
|
+
|
|
373
|
+
prefix_list: list[str] = data["prefixes"]
|
|
374
|
+
global_filters: dict[str, Any] = data.get("filters", {})
|
|
375
|
+
|
|
376
|
+
# Header panel
|
|
377
|
+
header_lines = [
|
|
378
|
+
f"[bold]Source:[/bold] [cyan]{file}[/cyan]",
|
|
379
|
+
f"[bold]Prefixes:[/bold] {len(prefix_list)}",
|
|
380
|
+
]
|
|
381
|
+
if global_filters:
|
|
382
|
+
header_lines.append(
|
|
383
|
+
f"[bold]Filters:[/bold] [dim]{global_filters}[/dim]",
|
|
384
|
+
)
|
|
385
|
+
console.print()
|
|
386
|
+
console.print(
|
|
387
|
+
Panel(
|
|
388
|
+
"\n".join(header_lines),
|
|
389
|
+
title="📦 Batch Prefix Query",
|
|
390
|
+
border_style="cyan",
|
|
391
|
+
expand=False,
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
console.print()
|
|
395
|
+
|
|
396
|
+
# Fetch all prefixes with a progress bar
|
|
397
|
+
batch_results: list[tuple[str, list[Prefix]]] = []
|
|
398
|
+
not_found: list[str] = []
|
|
399
|
+
|
|
400
|
+
with Progress(
|
|
401
|
+
SpinnerColumn("dots"),
|
|
402
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
403
|
+
BarColumn(bar_width=30),
|
|
404
|
+
MofNCompleteColumn(),
|
|
405
|
+
TimeElapsedColumn(),
|
|
406
|
+
console=console,
|
|
407
|
+
transient=True,
|
|
408
|
+
) as progress:
|
|
409
|
+
task = progress.add_task(
|
|
410
|
+
"Querying NetBox",
|
|
411
|
+
total=len(prefix_list),
|
|
412
|
+
)
|
|
413
|
+
for cidr in prefix_list:
|
|
414
|
+
progress.update(task, description=f"Querying [green]{cidr}[/green]")
|
|
415
|
+
params = _build_params(q=cidr, **global_filters)
|
|
416
|
+
raw = asyncio.run(_fetch("ipam/prefixes/", params))
|
|
417
|
+
records = [Prefix.model_validate(r) for r in raw]
|
|
418
|
+
if records:
|
|
419
|
+
batch_results.append((cidr, records))
|
|
420
|
+
else:
|
|
421
|
+
not_found.append(cidr)
|
|
422
|
+
progress.advance(task)
|
|
423
|
+
|
|
424
|
+
# Render results
|
|
425
|
+
if fmt == OutputFormat.json:
|
|
426
|
+
for cidr, records in batch_results:
|
|
427
|
+
console.print(
|
|
428
|
+
f"\n[bold cyan]── {cidr} ──[/bold cyan]",
|
|
429
|
+
highlight=False,
|
|
430
|
+
)
|
|
431
|
+
print_json(records)
|
|
432
|
+
for cidr in not_found:
|
|
433
|
+
console.print(
|
|
434
|
+
f"\n[bold cyan]── {cidr} ──[/bold cyan]",
|
|
435
|
+
highlight=False,
|
|
436
|
+
)
|
|
437
|
+
console.print("[yellow] ⚠️ No results found.[/yellow]")
|
|
438
|
+
elif status_only:
|
|
439
|
+
print_batch_summary(batch_results, not_found)
|
|
440
|
+
else:
|
|
441
|
+
for cidr, records in batch_results:
|
|
442
|
+
console.print(
|
|
443
|
+
f"\n[bold cyan]── {cidr} ──[/bold cyan]",
|
|
444
|
+
highlight=False,
|
|
445
|
+
)
|
|
446
|
+
print_prefixes(records)
|
|
447
|
+
for cidr in not_found:
|
|
448
|
+
console.print(
|
|
449
|
+
f"\n[bold cyan]── {cidr} ──[/bold cyan]",
|
|
450
|
+
highlight=False,
|
|
451
|
+
)
|
|
452
|
+
console.print("[yellow] ⚠️ No results found.[/yellow]")
|
|
453
|
+
|
|
454
|
+
# Final summary line
|
|
455
|
+
done = Text()
|
|
456
|
+
done.append("\n✅ ", style="bold green")
|
|
457
|
+
done.append(f"{len(batch_results)}", style="bold")
|
|
458
|
+
done.append(" found")
|
|
459
|
+
if not_found:
|
|
460
|
+
done.append(" · ", style="dim")
|
|
461
|
+
done.append(f"⚠️ {len(not_found)}", style="bold yellow")
|
|
462
|
+
done.append(" not found")
|
|
463
|
+
console.print(done)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
if __name__ == "__main__":
|
|
467
|
+
app()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""🔒 Read-only async HTTP client for the NetBox REST API.
|
|
2
|
+
|
|
3
|
+
SAFETY: This client ONLY performs GET requests. No data is ever
|
|
4
|
+
written, modified, or deleted in NetBox. The HTTP method is
|
|
5
|
+
hardcoded — there are no POST/PUT/PATCH/DELETE methods.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from netbox_data_puller.config import Settings
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NetBoxClient:
|
|
19
|
+
"""Async, read-only NetBox API client.
|
|
20
|
+
|
|
21
|
+
All requests use HTTP GET. No write operations are exposed
|
|
22
|
+
or possible through this client.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, settings: Settings) -> None:
|
|
26
|
+
base_url = str(settings.url).rstrip("/")
|
|
27
|
+
self._base_url = f"{base_url}/api"
|
|
28
|
+
self._page_size = settings.page_size
|
|
29
|
+
self._client = httpx.AsyncClient(
|
|
30
|
+
base_url=self._base_url,
|
|
31
|
+
headers={
|
|
32
|
+
"Authorization": f"Token {settings.token}",
|
|
33
|
+
"Accept": "application/json",
|
|
34
|
+
},
|
|
35
|
+
timeout=httpx.Timeout(settings.timeout),
|
|
36
|
+
verify=settings.verify_ssl,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Public interface — READ ONLY
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async def get(
|
|
44
|
+
self,
|
|
45
|
+
endpoint: str,
|
|
46
|
+
params: dict[str, Any] | None = None,
|
|
47
|
+
) -> list[dict[str, Any]]:
|
|
48
|
+
"""Fetch all pages of results from a NetBox API endpoint.
|
|
49
|
+
|
|
50
|
+
Uses HTTP GET exclusively. Automatically follows pagination.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
endpoint: API path relative to /api/ (e.g. "ipam/prefixes/").
|
|
54
|
+
params: Optional query parameters for filtering.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Flat list of result dicts across all pages.
|
|
58
|
+
"""
|
|
59
|
+
results: list[dict[str, Any]] = []
|
|
60
|
+
query = {**(params or {}), "limit": self._page_size, "offset": 0}
|
|
61
|
+
|
|
62
|
+
while True:
|
|
63
|
+
logger.debug("GET %s params=%s", endpoint, query)
|
|
64
|
+
response = await self._client.get(endpoint, params=query)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
data = response.json()
|
|
67
|
+
|
|
68
|
+
results.extend(data.get("results", []))
|
|
69
|
+
|
|
70
|
+
if data.get("next") is None:
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
query["offset"] = query["offset"] + self._page_size
|
|
74
|
+
|
|
75
|
+
logger.info(
|
|
76
|
+
"Fetched %d records from %s",
|
|
77
|
+
len(results),
|
|
78
|
+
endpoint,
|
|
79
|
+
)
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
async def get_single(
|
|
83
|
+
self,
|
|
84
|
+
endpoint: str,
|
|
85
|
+
params: dict[str, Any] | None = None,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""Fetch a single object from a NetBox API endpoint.
|
|
88
|
+
|
|
89
|
+
Uses HTTP GET exclusively.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
endpoint: API path (e.g. "ipam/prefixes/42/").
|
|
93
|
+
params: Optional query parameters.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Single result dict.
|
|
97
|
+
"""
|
|
98
|
+
logger.debug("GET %s params=%s", endpoint, params)
|
|
99
|
+
response = await self._client.get(endpoint, params=params)
|
|
100
|
+
response.raise_for_status()
|
|
101
|
+
result: dict[str, Any] = response.json()
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Lifecycle
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
"""Close the underlying HTTP connection pool."""
|
|
110
|
+
await self._client.aclose()
|
|
111
|
+
|
|
112
|
+
async def __aenter__(self) -> "NetBoxClient":
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
116
|
+
await self.close()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""⚙️ Configuration via environment variables / .env file."""
|
|
2
|
+
|
|
3
|
+
from pydantic import HttpUrl
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
"""NetBox connection settings.
|
|
9
|
+
|
|
10
|
+
Reads from environment variables or a .env file in the project root.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
model_config = SettingsConfigDict(
|
|
14
|
+
env_file=".env",
|
|
15
|
+
env_file_encoding="utf-8",
|
|
16
|
+
env_prefix="NETBOX_",
|
|
17
|
+
case_sensitive=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
url: HttpUrl
|
|
21
|
+
token: str
|
|
22
|
+
page_size: int = 100
|
|
23
|
+
timeout: int = 30
|
|
24
|
+
verify_ssl: bool = True
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""🎨 Rich table formatters for NetBox IPAM resources."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
# ------------------------------------------------------------------
|
|
13
|
+
# Helpers
|
|
14
|
+
# ------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _display_or_dash(obj: Any) -> str:
|
|
18
|
+
"""Extract display name from a nested ref, or return '—'."""
|
|
19
|
+
if obj is None:
|
|
20
|
+
return "—"
|
|
21
|
+
if hasattr(obj, "display"):
|
|
22
|
+
return str(obj.display)
|
|
23
|
+
return str(obj)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _tags_str(tags: list[Any]) -> str:
|
|
27
|
+
"""Format tags as a comma-separated string."""
|
|
28
|
+
return ", ".join(t.display for t in tags) if tags else "—"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _styled_status(obj: Any) -> Text:
|
|
32
|
+
"""Return a Rich Text object with colour-coded status."""
|
|
33
|
+
if obj is None:
|
|
34
|
+
return Text("—", style="dim")
|
|
35
|
+
label = str(obj.display) if hasattr(obj, "display") else str(obj)
|
|
36
|
+
value = obj.value if hasattr(obj, "value") else ""
|
|
37
|
+
style_map = {
|
|
38
|
+
"active": "bold green",
|
|
39
|
+
"reserved": "bold yellow",
|
|
40
|
+
"deprecated": "bold red",
|
|
41
|
+
"container": "bold blue",
|
|
42
|
+
"dhcp": "bold magenta",
|
|
43
|
+
"slaac": "bold magenta",
|
|
44
|
+
}
|
|
45
|
+
return Text(label, style=style_map.get(value, ""))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# JSON output
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_json(records: list[Any]) -> None:
|
|
54
|
+
"""Print records as formatted JSON to stdout."""
|
|
55
|
+
data = [
|
|
56
|
+
r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in records
|
|
57
|
+
]
|
|
58
|
+
console.print_json(json.dumps(data, indent=2, default=str))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Prefixes
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_prefixes(records: list[Any]) -> None:
|
|
67
|
+
"""Render prefixes as a Rich table."""
|
|
68
|
+
table = Table(
|
|
69
|
+
title="📡 IPAM Prefixes",
|
|
70
|
+
show_lines=True,
|
|
71
|
+
header_style="bold cyan",
|
|
72
|
+
title_style="bold",
|
|
73
|
+
)
|
|
74
|
+
table.add_column("ID", style="dim", justify="right")
|
|
75
|
+
table.add_column("Prefix", style="bold green")
|
|
76
|
+
table.add_column("Status")
|
|
77
|
+
table.add_column("VRF")
|
|
78
|
+
table.add_column("Tenant")
|
|
79
|
+
table.add_column("Site")
|
|
80
|
+
table.add_column("VLAN")
|
|
81
|
+
table.add_column("Role")
|
|
82
|
+
table.add_column("Pool", justify="center")
|
|
83
|
+
table.add_column("Description", max_width=30)
|
|
84
|
+
table.add_column("Tags")
|
|
85
|
+
|
|
86
|
+
for r in records:
|
|
87
|
+
table.add_row(
|
|
88
|
+
str(r.id),
|
|
89
|
+
r.prefix,
|
|
90
|
+
_styled_status(r.status),
|
|
91
|
+
_display_or_dash(r.vrf),
|
|
92
|
+
_display_or_dash(r.tenant),
|
|
93
|
+
_display_or_dash(r.site),
|
|
94
|
+
_display_or_dash(r.vlan),
|
|
95
|
+
_display_or_dash(r.role),
|
|
96
|
+
"✅" if r.is_pool else "—",
|
|
97
|
+
r.description or "—",
|
|
98
|
+
_tags_str(r.tags),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
console.print(table)
|
|
102
|
+
console.print(f"[dim] {len(records)} prefixes[/dim]\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def print_prefixes_status(records: list[Any]) -> None:
|
|
106
|
+
"""Render a compact prefix + status table."""
|
|
107
|
+
table = Table(
|
|
108
|
+
title="📡 Prefix Status",
|
|
109
|
+
show_lines=True,
|
|
110
|
+
header_style="bold cyan",
|
|
111
|
+
title_style="bold",
|
|
112
|
+
)
|
|
113
|
+
table.add_column("Prefix", style="bold green")
|
|
114
|
+
table.add_column("Status")
|
|
115
|
+
|
|
116
|
+
for r in records:
|
|
117
|
+
table.add_row(r.prefix, _styled_status(r.status))
|
|
118
|
+
|
|
119
|
+
console.print(table)
|
|
120
|
+
console.print(f"[dim] {len(records)} prefixes[/dim]\n")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def print_batch_summary(
|
|
124
|
+
results: list[tuple[str, list[Any]]],
|
|
125
|
+
not_found: list[str],
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Render a single consolidated table for batch --status-only.
|
|
128
|
+
|
|
129
|
+
Groups results by queried CIDR and shows the direct match status
|
|
130
|
+
prominently, with parent containers shown underneath in dim text.
|
|
131
|
+
"""
|
|
132
|
+
table = Table(
|
|
133
|
+
title="📦 Batch Prefix Status",
|
|
134
|
+
show_lines=True,
|
|
135
|
+
header_style="bold cyan",
|
|
136
|
+
title_style="bold",
|
|
137
|
+
padding=(0, 1),
|
|
138
|
+
)
|
|
139
|
+
table.add_column("Queried Prefix", style="bold white")
|
|
140
|
+
table.add_column("Matched Prefix", style="green")
|
|
141
|
+
table.add_column("Status")
|
|
142
|
+
table.add_column("Site")
|
|
143
|
+
table.add_column("Tenant")
|
|
144
|
+
table.add_column("Description", max_width=32)
|
|
145
|
+
|
|
146
|
+
for cidr, records in results:
|
|
147
|
+
# Separate direct match from parent containers
|
|
148
|
+
direct = [r for r in records if r.prefix == cidr]
|
|
149
|
+
parents = [r for r in records if r.prefix != cidr]
|
|
150
|
+
|
|
151
|
+
if direct:
|
|
152
|
+
for r in direct:
|
|
153
|
+
table.add_row(
|
|
154
|
+
cidr,
|
|
155
|
+
r.prefix,
|
|
156
|
+
_styled_status(r.status),
|
|
157
|
+
_display_or_dash(r.site),
|
|
158
|
+
_display_or_dash(r.tenant),
|
|
159
|
+
r.description or "—",
|
|
160
|
+
)
|
|
161
|
+
elif parents:
|
|
162
|
+
# No exact match — show closest parent
|
|
163
|
+
closest = max(parents, key=lambda p: _prefix_len(p.prefix))
|
|
164
|
+
table.add_row(
|
|
165
|
+
cidr,
|
|
166
|
+
Text(f"≈ {closest.prefix}", style="yellow"),
|
|
167
|
+
_styled_status(closest.status),
|
|
168
|
+
_display_or_dash(closest.site),
|
|
169
|
+
_display_or_dash(closest.tenant),
|
|
170
|
+
closest.description or "—",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
for cidr in not_found:
|
|
174
|
+
table.add_row(
|
|
175
|
+
cidr,
|
|
176
|
+
Text("—", style="dim"),
|
|
177
|
+
Text("Not Found", style="bold red"),
|
|
178
|
+
"—",
|
|
179
|
+
"—",
|
|
180
|
+
"—",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
console.print()
|
|
184
|
+
console.print(table)
|
|
185
|
+
|
|
186
|
+
# Compact legend
|
|
187
|
+
legend = (
|
|
188
|
+
"[dim]Status: "
|
|
189
|
+
"[bold green]● Active[/bold green] "
|
|
190
|
+
"[bold blue]● Container[/bold blue] "
|
|
191
|
+
"[bold yellow]● Reserved[/bold yellow] "
|
|
192
|
+
"[bold red]● Deprecated[/bold red]"
|
|
193
|
+
"[/dim]"
|
|
194
|
+
)
|
|
195
|
+
console.print(legend)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _prefix_len(prefix: str) -> int:
|
|
199
|
+
"""Extract the CIDR mask length for sorting."""
|
|
200
|
+
try:
|
|
201
|
+
return int(prefix.split("/")[1])
|
|
202
|
+
except (IndexError, ValueError):
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# IP Addresses
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def print_ip_addresses(records: list[Any]) -> None:
|
|
212
|
+
"""Render IP addresses as a Rich table."""
|
|
213
|
+
table = Table(
|
|
214
|
+
title="🖥️ IPAM IP Addresses",
|
|
215
|
+
show_lines=True,
|
|
216
|
+
header_style="bold cyan",
|
|
217
|
+
title_style="bold",
|
|
218
|
+
)
|
|
219
|
+
table.add_column("ID", style="dim", justify="right")
|
|
220
|
+
table.add_column("Address", style="bold green")
|
|
221
|
+
table.add_column("Status")
|
|
222
|
+
table.add_column("VRF")
|
|
223
|
+
table.add_column("Tenant")
|
|
224
|
+
table.add_column("DNS Name")
|
|
225
|
+
table.add_column("Role")
|
|
226
|
+
table.add_column("Description", max_width=30)
|
|
227
|
+
table.add_column("Tags")
|
|
228
|
+
|
|
229
|
+
for r in records:
|
|
230
|
+
table.add_row(
|
|
231
|
+
str(r.id),
|
|
232
|
+
r.address,
|
|
233
|
+
_styled_status(r.status),
|
|
234
|
+
_display_or_dash(r.vrf),
|
|
235
|
+
_display_or_dash(r.tenant),
|
|
236
|
+
r.dns_name or "—",
|
|
237
|
+
_display_or_dash(r.role),
|
|
238
|
+
r.description or "—",
|
|
239
|
+
_tags_str(r.tags),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
console.print(table)
|
|
243
|
+
console.print(f"[dim] {len(records)} IP addresses[/dim]\n")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# VLANs
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def print_vlans(records: list[Any]) -> None:
|
|
252
|
+
"""Render VLANs as a Rich table."""
|
|
253
|
+
table = Table(
|
|
254
|
+
title="🏷️ IPAM VLANs",
|
|
255
|
+
show_lines=True,
|
|
256
|
+
header_style="bold cyan",
|
|
257
|
+
title_style="bold",
|
|
258
|
+
)
|
|
259
|
+
table.add_column("ID", style="dim", justify="right")
|
|
260
|
+
table.add_column("VID", justify="right", style="bold")
|
|
261
|
+
table.add_column("Name", style="bold green")
|
|
262
|
+
table.add_column("Status")
|
|
263
|
+
table.add_column("Tenant")
|
|
264
|
+
table.add_column("Site")
|
|
265
|
+
table.add_column("Group")
|
|
266
|
+
table.add_column("Role")
|
|
267
|
+
table.add_column("Description", max_width=30)
|
|
268
|
+
table.add_column("Tags")
|
|
269
|
+
|
|
270
|
+
for r in records:
|
|
271
|
+
table.add_row(
|
|
272
|
+
str(r.id),
|
|
273
|
+
str(r.vid),
|
|
274
|
+
r.name,
|
|
275
|
+
_styled_status(r.status),
|
|
276
|
+
_display_or_dash(r.tenant),
|
|
277
|
+
_display_or_dash(r.site),
|
|
278
|
+
_display_or_dash(r.group),
|
|
279
|
+
_display_or_dash(r.role),
|
|
280
|
+
r.description or "—",
|
|
281
|
+
_tags_str(r.tags),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
console.print(table)
|
|
285
|
+
console.print(f"[dim] {len(records)} VLANs[/dim]\n")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
# VRFs
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def print_vrfs(records: list[Any]) -> None:
|
|
294
|
+
"""Render VRFs as a Rich table."""
|
|
295
|
+
table = Table(
|
|
296
|
+
title="🔀 IPAM VRFs",
|
|
297
|
+
show_lines=True,
|
|
298
|
+
header_style="bold cyan",
|
|
299
|
+
title_style="bold",
|
|
300
|
+
)
|
|
301
|
+
table.add_column("ID", style="dim", justify="right")
|
|
302
|
+
table.add_column("Name", style="bold green")
|
|
303
|
+
table.add_column("RD")
|
|
304
|
+
table.add_column("Tenant")
|
|
305
|
+
table.add_column("Enforce Unique", justify="center")
|
|
306
|
+
table.add_column("Description", max_width=30)
|
|
307
|
+
table.add_column("Tags")
|
|
308
|
+
|
|
309
|
+
for r in records:
|
|
310
|
+
table.add_row(
|
|
311
|
+
str(r.id),
|
|
312
|
+
r.name,
|
|
313
|
+
r.rd or "—",
|
|
314
|
+
_display_or_dash(r.tenant),
|
|
315
|
+
"✅" if r.enforce_unique else "❌",
|
|
316
|
+
r.description or "—",
|
|
317
|
+
_tags_str(r.tags),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
console.print(table)
|
|
321
|
+
console.print(f"[dim] {len(records)} VRFs[/dim]\n")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""📦 Pydantic models for NetBox IPAM resources."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""📦 Pydantic model for NetBox IPAM IP Address."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from netbox_data_puller.models.prefix import ChoiceRef, NestedRef
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IPAddress(BaseModel, extra="allow"):
|
|
9
|
+
"""NetBox IPAM IP Address resource."""
|
|
10
|
+
|
|
11
|
+
id: int
|
|
12
|
+
display: str
|
|
13
|
+
address: str
|
|
14
|
+
status: ChoiceRef | None = None
|
|
15
|
+
vrf: NestedRef | None = None
|
|
16
|
+
tenant: NestedRef | None = None
|
|
17
|
+
role: ChoiceRef | None = None
|
|
18
|
+
assigned_object_type: str | None = None
|
|
19
|
+
assigned_object_id: int | None = None
|
|
20
|
+
dns_name: str = ""
|
|
21
|
+
description: str = ""
|
|
22
|
+
tags: list[NestedRef] = []
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""📦 Pydantic model for NetBox IPAM Prefix."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NestedRef(BaseModel):
|
|
7
|
+
"""Nested reference to a related object (id + display name)."""
|
|
8
|
+
|
|
9
|
+
id: int
|
|
10
|
+
display: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChoiceRef(BaseModel):
|
|
14
|
+
"""NetBox v4 choice/enum field (value + label).
|
|
15
|
+
|
|
16
|
+
Used for status, role, and other enumerated fields that
|
|
17
|
+
return {"value": "active", "label": "Active"} instead of
|
|
18
|
+
{"id": 1, "display": "Active"}.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
value: str
|
|
22
|
+
label: str
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def display(self) -> str:
|
|
26
|
+
"""Consistent interface with NestedRef."""
|
|
27
|
+
return self.label
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Prefix(BaseModel, extra="allow"):
|
|
31
|
+
"""NetBox IPAM Prefix resource."""
|
|
32
|
+
|
|
33
|
+
id: int
|
|
34
|
+
display: str
|
|
35
|
+
prefix: str
|
|
36
|
+
status: ChoiceRef | None = None
|
|
37
|
+
vrf: NestedRef | None = None
|
|
38
|
+
tenant: NestedRef | None = None
|
|
39
|
+
site: NestedRef | None = None
|
|
40
|
+
vlan: NestedRef | None = None
|
|
41
|
+
role: NestedRef | None = None
|
|
42
|
+
is_pool: bool = False
|
|
43
|
+
mark_utilized: bool = False
|
|
44
|
+
description: str = ""
|
|
45
|
+
tags: list[NestedRef] = []
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""📦 Pydantic model for NetBox IPAM VLAN."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from netbox_data_puller.models.prefix import ChoiceRef, NestedRef
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VLAN(BaseModel, extra="allow"):
|
|
9
|
+
"""NetBox IPAM VLAN resource."""
|
|
10
|
+
|
|
11
|
+
id: int
|
|
12
|
+
display: str
|
|
13
|
+
vid: int
|
|
14
|
+
name: str
|
|
15
|
+
status: ChoiceRef | None = None
|
|
16
|
+
tenant: NestedRef | None = None
|
|
17
|
+
site: NestedRef | None = None
|
|
18
|
+
group: NestedRef | None = None
|
|
19
|
+
role: NestedRef | None = None
|
|
20
|
+
description: str = ""
|
|
21
|
+
tags: list[NestedRef] = []
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""📦 Pydantic model for NetBox IPAM VRF."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from netbox_data_puller.models.prefix import NestedRef
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VRF(BaseModel, extra="allow"):
|
|
9
|
+
"""NetBox IPAM VRF resource."""
|
|
10
|
+
|
|
11
|
+
id: int
|
|
12
|
+
display: str
|
|
13
|
+
name: str
|
|
14
|
+
rd: str | None = None
|
|
15
|
+
tenant: NestedRef | None = None
|
|
16
|
+
enforce_unique: bool = True
|
|
17
|
+
description: str = ""
|
|
18
|
+
tags: list[NestedRef] = []
|
|
File without changes
|