Compare commits

..

16 Commits

Author SHA1 Message Date
6482011d15 Delete docs/netbox-data-model.md 2026-02-27 12:21:21 +00:00
9e025f0c07 Merge pull request 'feat(infrahub): add Infrahub client for fabric intent (#42)' (#54) from feature/42-infrahub-client into main
Reviewed-on: #54
2026-02-27 12:08:36 +00:00
Damien
8c7e0973e2 Update test_infrahub_client.py parameter name from asn_val to asn
Update uv.lock to include new dependencies
2026-02-27 09:21:41 +01:00
Damien
4bef9303ba Update README.md tables to be more compact
Add .ruff_cache to .gitignore
2026-02-26 15:15:53 +01:00
Damien
8ee15c3228 docs(infrahub): add README with usage examples and API reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:11:58 +01:00
b31632109a docs: update README to remove Infrahub schema/data references (#42)
Remove all references to local Infrahub schemas, data files, and
.infrahub.yml config. The orchestrator now treats Infrahub as an
external Source of Truth queried via infrahub-sdk. Schema and data
are managed in the arista-evpn-vxlan-clab repository.
2026-02-26 14:05:07 +00:00
Damien
f663cc623c feat(infrahub): add Infrahub client for fabric intent (#42)
- Replace pynetbox with infrahub-sdk>=0.16.0 + pydantic>=2.0 in dependencies
- Add pytest and pytest-asyncio to dev dependencies
- Implement FabricInfrahubClient async client (src/infrahub/client.py)
  - get_device, get_device_vlans, get_device_bgp_config
  - get_device_bgp_peer_groups, get_device_bgp_sessions
  - get_device_vrfs, get_device_vtep, get_device_evpn_instances
  - get_mlag_domain, get_mlag_peer_config
  - TTL-based caching (60s) to avoid redundant SDK queries
  - Async context manager support
- Add Pydantic v2 frozen models for all intent types (src/infrahub/models.py)
- Add custom exception hierarchy (src/infrahub/exceptions.py)
- Add unit tests with fully mocked SDK (tests/test_infrahub_client.py)
  - Tests for correct model return, NotFoundError, branch selection, caching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:03:09 +01:00
21a44db736 Merge pull request 'feature/52-object-files' (#53) from feature/52-object-files into main
Reviewed-on: #53
2026-02-20 15:14:03 +00:00
Damien
6b00b30f26 feat(infrahub): remove infrahub schema 2026-02-20 16:12:28 +01:00
Damien
110c223b66 chore(schema): update schema 2026-02-20 15:49:09 +01:00
8faf5139cb fix(mlag): correct local_interface_ip for secondary leaves in MLAG pairs 2026-02-20 14:21:59 +00:00
Damien
c902d7df58 chore(schema): update schema 2026-02-20 15:13:46 +01:00
Damien
9e1fe9be7f chore(fix schema): remove unused border leaf 2026-02-20 15:02:49 +01:00
Damien
bccac190ff Update object 2026-02-16 17:49:44 +01:00
Damien
cb06068347 feat(schema,objects): add local_identifier to BGP types for unique HFID — refs #52
Infrahub does not support relation traversal in HFID computation.
Add a local_identifier attribute to PeerGroup, Session, and
AddressFamily that combines device name with the type-specific key.

Schema changes (bgp.yml):
- InfraBGPPeerGroup: HFID=local_identifier__value (e.g. spine1__evpn)
- InfraBGPSession: HFID=local_identifier__value (e.g. spine1__10.0.250.11)
- InfraBGPAddressFamily: HFID=local_identifier__value (e.g. spine1__ipv4_unicast)

Object file changes:
- 09-bgp.yml: added local_identifier to 26 PeerGroup entries
- 10-bgp-sessions.yml: added local_identifier to 72 Session + 20 AF entries,
  updated 56 peer_group + 26 active_peer_groups references
2026-02-15 21:31:03 +01:00
Damien
4b1ca7b2a6 fix(schema,objects): simplify BGP HFIDs to single field — refs #52
Infrahub does not support relation traversal in HFID computation.
bgp_config__router_id__value returned only the name, not the full path.

Schema changes:
- InfraBGPPeerGroup HFID: [name__value] (was [bgp_config__router_id__value, name__value])
- InfraBGPSession HFID: [peer_address__value] (was [bgp_config__router_id__value, peer_address__value])
- uniqueness_constraints unchanged (still scoped to bgp_config)

Object file changes (10-bgp-sessions.yml):
- peer_group: ["10.0.250.x", "name"] → peer_group: "name" (56 refs)
- active_peer_groups: - ["10.0.250.x", "name"] → - "name" (26 refs)
2026-02-15 21:23:58 +01:00
33 changed files with 2385 additions and 4953 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
__pycache__
.envrc
.ruff_cache

View File

@@ -1,18 +0,0 @@
---
schemas:
- schemas
menus:
- menus
objects:
- objects/01-foundation.yml
- objects/02-fabric.yml
- objects/03-devices.yml
- objects/04-interfaces.yml
- objects/05-ipam.yml
- objects/06-vlans-vxlan.yml
- objects/07-interface-vlans.yml
- objects/08-ipam-vlans.yml
- objects/09-bgp.yml
- objects/10-bgp-sessions.yml
- objects/11-vrfs.yml
- objects/12-mlag.yml

161
CLAUDE.md
View File

@@ -1,161 +0,0 @@
# Claude Code Instructions — fabric-orchestrator
## Project Context
Fabric-orchestrator manages Arista EVPN-VXLAN fabrics using Infrahub as the source of truth.
Gitea: https://gitea.arnodo.fr/Damien/fabric-orchestrator
Branch: `feature/52-object-files`
## Current Task: Issue #52 — Create Infrahub Object Files
Create YAML object files to load the reference topology data into Infrahub.
Object files use the `apiVersion: infrahub.app/v1` format and are loaded via `infrahubctl object load`.
### Data Sources (read-only references)
- **Arista configs**: https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab (`configs/*.cfg`)
- **Reference article**: overlaid.net EVPN Configuration Example (document `arista-bgp-evpn-configuration-example-overlaid.md` if available locally)
- **These configs are the source of truth** — extract all IPs, ASNs, peer groups, VLANs, VNIs from them
### Target Structure
```
objects/
├── 01-foundation.yml # InfraFabric, InfraAutonomousSystem
├── 02-devices.yml # InfraDevice (10 devices)
├── 03-interfaces.yml # InterfaceLoopback, InterfaceEthernet, InterfaceLag, InterfaceVlan
├── 04-ipam.yml # InfraIPAddress (all /31, /32, /24 assignments)
├── 05-vlans-vxlan.yml # InfraVLAN, InfraVNI, InfraVTEP, InfraVlanVniMapping, InfraEVPNInstance
├── 06-bgp.yml # InfraBGPRouterConfig, InfraBGPPeerGroup, InfraBGPSession, InfraBGPAddressFamily
├── 07-vrfs.yml # InfraVRF, InfraRouteTarget
└── 08-mlag.yml # InfraMlagDomain, InfraMlagPeerConfig
```
**Order matters**: files are loaded sequentially, dependencies must be satisfied (devices before interfaces, interfaces before IPs, etc.).
## Reference Topology (overlaid.net)
- 2 spines (AS 65000), 8 leafs in 4 MLAG pairs (AS 6500165004)
- Loopback0: BGP router-id per device, Loopback1: shared VTEP IP per MLAG pair
- VLANs 4090 (MLAG peer) / 4091 (MLAG iBGP) with trunk groups
- Underlay: eBGP point-to-point (spine↔leaf) + iBGP (leaf↔leaf peer)
- Overlay: EVPN eBGP multihop on loopbacks, spines with next-hop-unchanged
- L2VXLAN: VLAN 40 → VNI 110040, redistribute learned
- L3VXLAN: VRF gold → VNI 100001, redistribute connected (leaf3-4, leaf7-8)
- Border peering: BGP sessions inside VRF (leaf7/8 → AS 64999 in VRF gold)
### IP Addressing Quick Reference
| Device | Lo0 (router-id) | Lo1 (VTEP) | AS |
|---------|------------------|--------------|-------|
| spine1 | 10.0.250.1 | — | 65000 |
| spine2 | 10.0.250.2 | — | 65000 |
| leaf1 | 10.0.250.11 | 10.0.255.11 | 65001 |
| leaf2 | 10.0.250.12 | 10.0.255.11 | 65001 |
| leaf3 | 10.0.250.13 | 10.0.255.12 | 65002 |
| leaf4 | 10.0.250.14 | 10.0.255.12 | 65002 |
| leaf5 | 10.0.250.15 | 10.0.255.13 | 65003 |
| leaf6 | 10.0.250.16 | 10.0.255.13 | 65003 |
| leaf7 | 10.0.250.17 | 10.0.255.14 | 65004 |
| leaf8 | 10.0.250.18 | 10.0.255.14 | 65004 |
### P2P Addressing Pattern
- spine1→leaf{N}: `10.0.1.{(N-1)*2}/31` (spine side) / `10.0.1.{(N-1)*2+1}/31` (leaf side)
- spine2→leaf{N}: `10.0.2.{(N-1)*2}/31` (spine side) / `10.0.2.{(N-1)*2+1}/31` (leaf side)
- MLAG iBGP: leaf pairs on `10.0.3.{pair_offset}/31`
- MLAG peer: leaf pairs on `10.0.199.254/31` and `10.0.199.255/31`
## Infrahub Object File Format
```yaml
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraDevice
data:
- name: spine1
role: spine
platform: arista_eos # References InfraPlatform by HFID
```
### Key Rules
1. **Reference existing objects by their `human_friendly_id` (HFID)** — not by ID
2. For relationships with cardinality one, use the HFID value directly: `site: "dc1"`
3. For relationships with cardinality many, use a list: `vlans: ["40", "4090"]`
4. For nested objects (Component kind), use inline `data:` blocks
5. For multi-field HFIDs, use list format: `device: ["spine1", "Loopback0"]` → references `device__name + interface__name`
### Schema human_friendly_id Reference (critical for references)
| Node | HFID fields |
|-----------------------|------------------------------------------------|
| InfraDevice | `name__value` |
| InfraAutonomousSystem | `asn__value` |
| InfraInterfaceEthernet| `device__name__value` + `name__value` |
| InfraInterfaceLoopback| `device__name__value` + `name__value` |
| InfraInterfaceVlan | `device__name__value` + `name__value` |
| InfraInterfaceLag | `device__name__value` + `name__value` |
| InfraIPAddress | `address__value` |
| InfraVLAN | `vlan_id__value` |
| InfraVNI | `vni__value` |
| InfraVTEP | `device__name__value` |
| InfraBGPRouterConfig | `device__name__value` |
| InfraBGPPeerGroup | `bgp_config__router_id__value` + `name__value` |
| InfraBGPSession | `bgp_config__router_id__value` + `peer_address__value` |
| InfraVLAN | `vlan_id__value` |
| InfraFabric | `name__value` |
| LocationSite | `name__value` |
| InfraPlatform | `name__value` |
## Schema Files (in `schemas/`)
| File | Content |
|-------------------|-----------------------------------------------------------------------|
| `base.yml` | Device, Interfaces (Ethernet, Loopback, Vlan, Lag), IPAddress, Site, Platform |
| `bgp.yml` | AutonomousSystem, BGPRouterConfig, BGPPeerGroup, BGPSession, BGPAddressFamily |
| `vlan_vxlan.yml` | VLAN, VNI, VTEP, VlanVniMapping, EVPNInstance |
| `vrf.yml` | VRFConfig, RouteTarget, VRFDeviceAssignment |
| `mlag.yml` | MlagDomain, MlagPeerConfig, MlagInterface |
| `extensions.yml` | Fabric, UnderlayLink, HostConnection |
## Validation
```bash
# Validate object file format
infrahubctl object validate objects/01-foundation.yml
# Load objects sequentially
infrahubctl object load objects/01-foundation.yml
infrahubctl object load objects/02-devices.yml
# ... etc.
```
Always validate before committing. Fix any errors before pushing.
## Commit Convention
```
feat(objects): short description — refs #52
```
- One logical file per commit (or group of closely related files)
- Reference issue #52
## Workflow
1. Read the relevant schema file to understand required fields and relationships
2. Extract data from the clab configs (configs/*.cfg) or the reference topology doc
3. Create the object file respecting the Infrahub Object File format
4. Validate with `infrahubctl object validate`
5. Commit with proper message
## Don'ts
- Don't invent data not present in the reference topology configs
- Don't create schema modifications — the schema is frozen for this task
- Don't skip optional fields that have data in the configs (description, mtu, etc.)
- Don't forget the dependency order: foundation → devices → interfaces → IPs → rest
- Don't use `id` references — always use human_friendly_id (HFID) strings

165
README.md
View File

@@ -29,7 +29,6 @@ We chose [InfraHub](https://github.com/opsmill/infrahub) over NetBox as Source o
| **Schema** | Fixed DCIM/IPAM model | Fully customizable YAML schema |
| **Git Integration** | External sync needed | Native - branches = data branches |
| **Versioning** | Changelog only | True Git-like versioning with merges |
| **Test/Redeploy** | Dump/restore | `git clone` = complete environment |
| **Transforms** | Limited | Built-in Jinja2 + Python transforms |
| **GraphQL** | Yes | Yes (auto-generated from schema) |
@@ -40,62 +39,6 @@ We chose [InfraHub](https://github.com/opsmill/infrahub) over NetBox as Source o
3. **Transforms** - Generate device configs directly from InfraHub
4. **Branches** - Test fabric changes in isolated branches before merge
## 📦 Repository as InfraHub Backend
This repository serves as the **single source of truth** for both code and infrastructure data:
```
fabric-orchestrator/
├── .infrahub.yml # InfraHub repository config
├── schemas/ # InfraHub schema definitions
│ └── fabric.yml # Custom EVPN-VXLAN fabric schema
├── data/ # Infrastructure objects (YAML)
│ ├── topology/
│ │ ├── sites.yml
│ │ └── devices.yml # Spines, Leafs, VTEP pairs
│ ├── network/
│ │ ├── vlans.yml # VLANs + L2VNI mappings
│ │ ├── vrfs.yml # VRFs + L3VNI mappings
│ │ └── interfaces.yml # Interface configs
│ └── routing/
│ ├── bgp_sessions.yml # Underlay + EVPN overlay
│ └── evpn.yml # Route targets, RDs
├── transforms/ # Jinja2 templates for config generation
│ └── arista/
│ ├── base.j2
│ ├── interfaces.j2
│ ├── bgp.j2
│ └── evpn.j2
└── src/ # Python orchestration code
```
### Workflow
```bash
# 1. Edit data files (e.g., add a VLAN)
vim data/network/vlans.yml
# 2. Commit & push
git commit -am "Add VLAN 100 for production"
git push
# 3. InfraHub syncs automatically from Git
# → Data available via GraphQL
# 4. Prefect flow detects change → reconciles fabric
```
### Benefits
- **Reproductibility**: `git clone``docker compose up` → complete environment
- **Code Review**: Infrastructure changes go through PR review
- **History**: Full audit trail via Git
- **Testing**: Create a branch, test changes, merge when validated
## 🎛 Why Prefect?
| Feature | Benefit |
@@ -111,25 +54,25 @@ git push
## 🎯 Target Fabric
This project is designed for the Arista EVPN-VXLAN ContainerLab topology:
This project is designed for Arista EVPN-VXLAN fabrics:
- **2 Spines** (BGP Route Reflectors, AS 65000)
- **8 Leafs** (4 MLAG VTEP pairs, AS 65001-65004)
- **2 Spines** (BGP Route Reflectors)
- **8 Leafs** (4 MLAG VTEP pairs)
- **cEOS 4.35.0F** with gNMI enabled
- **EVPN Type-2** (L2 VXLAN) and **Type-5** (L3 VXLAN) support
Reference: [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab)
Reference lab topology: [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab)
## 📋 Project Phases
Progress is tracked via issues. See [all issues](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues) or filter by phase:
| Phase | Description | Status |
| ----------- | -------------------------------------------------------------------- | ------------- |
| **Phase 1** | YANG Path Discovery - Map EOS 4.35.0F YANG models, validate gNMI | ✅ Complete |
| **Phase 2** | InfraHub Setup & Core Reconciler - Schema, diff engine, YANG mappers | 🔄 In Progress |
| **Phase 3** | Full Fabric Coverage - BGP, MLAG, VRFs mappers | 📋 Planned |
| **Phase 4** | Prefect Integration - Flows, webhooks, drift detection | 📋 Planned |
| Phase | Description | Status |
| ----------- | ------------------------------------------------------------------------- | -------------- |
| **Phase 1** | YANG Path Discovery - Map EOS 4.35.0F YANG models, validate gNMI | ✅ Complete |
| **Phase 2** | InfraHub Client & Core Reconciler - SDK client, diff engine, YANG mappers | 🔄 In Progress |
| **Phase 3** | Full Fabric Coverage - BGP, MLAG, VRFs mappers | 📋 Planned |
| **Phase 4** | Prefect Integration - Flows, webhooks, drift detection | 📋 Planned |
## 📁 Project Structure
@@ -137,52 +80,27 @@ Progress is tracked via issues. See [all issues](https://gitea.arnodo.fr/Damien/
fabric-orchestrator/
├── README.md
├── pyproject.toml
├── .infrahub.yml # InfraHub config (points to schemas/)
├── schemas/ # InfraHub schema definitions
│ └── fabric.yml # Custom EVPN-VXLAN fabric schema
├── data/ # Infrastructure data (YAML)
│ ├── topology/
│ │ ├── sites.yml
│ │ └── devices.yml
│ ├── network/
│ │ ├── vlans.yml
│ │ ├── vrfs.yml
│ │ └── interfaces.yml
│ └── routing/
│ ├── bgp_sessions.yml
│ └── evpn.yml
├── transforms/ # Jinja2 config templates
│ └── arista/
│ └── *.j2
├── src/ # Python package
├── src/ # Python package
│ ├── __init__.py
│ ├── cli.py # CLI for YANG discovery
│ │
│ ├── flows/ # Prefect flows
│ │ ├── __init__.py
│ │ ├── reconcile.py # @flow fabric_reconcile
│ │ ├── drift.py # @flow handle_drift
│ │ └── remediation.py # @flow drift_remediation
│ ├── cli.py # CLI for YANG discovery
│ │
│ ├── gnmi/
│ │ ├── __init__.py
│ │ ├── client.py # gNMI client wrapper (pygnmi)
│ │ ├── client.py # gNMI client wrapper (pygnmi)
│ │ └── README.md
│ │
│ ├── infrahub/ # InfraHub integration
│ ├── infrahub/ # InfraHub integration
│ │ ├── __init__.py
│ │ ├── client.py # InfraHub SDK wrapper
│ │ ── queries.py # GraphQL queries
│ │ ├── client.py # InfraHub SDK wrapper
│ │ ── models.py # Pydantic intent models
│ │ └── exceptions.py # Client exceptions
│ │
│ └── yang/
│ ├── __init__.py
│ ├── mapper.py # InfraHub intent → YANG paths
│ ├── paths.py # YANG path definitions
│ └── mappers/ # Resource-specific mappers
│ ├── mapper.py # InfraHub intent → YANG paths
│ ├── paths.py # YANG path definitions
│ └── mappers/ # Resource-specific mappers
│ ├── vlan.py
│ ├── interface.py
│ ├── bgp.py
@@ -200,7 +118,6 @@ fabric-orchestrator/
| Component | Technology | Purpose |
| --------------- | -------------------------- | ------------------------------------ |
| Source of Truth | **InfraHub** | Intent definition via custom schema |
| Data Storage | **This Git repo** | Schema + data versioned together |
| Orchestrator | **Prefect** | Python-native workflow orchestration |
| Transport | gNMI | Configuration and telemetry |
| Data Models | YANG (OpenConfig + Arista) | Structured configuration |
@@ -211,7 +128,7 @@ fabric-orchestrator/
## 🔗 Related Projects
- [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) - Target fabric topology
- [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) - Lab topology, InfraHub schemas & data
- [InfraHub](https://github.com/opsmill/infrahub) - Source of Truth platform
- [InfraHub Schema Library](https://github.com/opsmill/schema-library) - Reference schemas
- [Arista YANG Models](https://github.com/aristanetworks/yang/tree/master/EOS-4.35.0F) - EOS 4.35.0F YANG definitions
@@ -219,22 +136,25 @@ fabric-orchestrator/
## 📚 References
### InfraHub
- [InfraHub Documentation](https://docs.infrahub.app)
- [InfraHub Schema Guide](https://docs.infrahub.app/guides/create-schema)
- [InfraHub Python SDK](https://github.com/opsmill/infrahub-sdk-python)
- [InfraHub .infrahub.yml Reference](https://docs.infrahub.app/reference/dotinfrahub)
### Prefect
- [Prefect Documentation](https://docs.prefect.io)
- [Prefect Flows](https://docs.prefect.io/latest/develop/write-flows/)
- [Prefect Tasks](https://docs.prefect.io/latest/develop/write-tasks/)
### YANG / gNMI
- [Arista gNMI Documentation](https://aristanetworks.github.io/openmgmt/configuration/gnmi/)
- [OpenConfig Models](https://github.com/openconfig/public)
- [pygnmi Library](https://github.com/akarneliuk/pygnmi)
### EVPN-VXLAN
- [Arista BGP EVPN Configuration Example](https://overlaid.net/2019/01/27/arista-bgp-evpn-configuration-example/)
## 🚀 Getting Started
@@ -243,31 +163,22 @@ fabric-orchestrator/
- Python 3.12+
- `uv` package manager
- Docker (for InfraHub)
- Access to ContainerLab with cEOS images
- Access to an InfraHub instance with the EVPN-VXLAN fabric schema loaded
- Access to ContainerLab with cEOS images (for lab testing)
### Quick Start
```bash
# Clone the repository (includes schema + data)
# Clone the repository
git clone https://gitea.arnodo.fr/Damien/fabric-orchestrator.git
cd fabric-orchestrator
# Install Python dependencies
uv sync
# Start InfraHub (loads schema & data from this repo)
docker compose up -d
# Configure Prefect secrets
python -c "
from prefect.blocks.system import Secret
from prefect.variables import Variable
Secret(value='your-gnmi-password').save('gnmi-password', overwrite=True)
Variable.set('infrahub_url', 'http://localhost:8000')
Variable.set('gnmi_username', 'admin')
"
# Set InfraHub connection (point to your InfraHub instance)
export INFRAHUB_ADDRESS="http://localhost:8000"
export INFRAHUB_API_TOKEN="your-token"
# Verify gNMI connectivity
uv run fabric-orch discover capabilities --target leaf1:6030
@@ -285,13 +196,15 @@ from prefect.variables import Variable
@task(retries=2, retry_delay_seconds=10)
def get_fabric_intent(device: str | None = None) -> dict:
async def get_fabric_intent(device: str | None = None) -> dict:
"""Retrieve fabric intent from InfraHub."""
from infrahub_sdk import InfrahubClient
from src.infrahub.client import FabricInfrahubClient
client = InfrahubClient(address=Variable.get("infrahub_url"))
# Query fabric intent via GraphQL
return client.query(...)
async with FabricInfrahubClient(
url=Variable.get("infrahub_url"),
api_token=Variable.get("infrahub_token"),
) as client:
return await client.get_device(device)
@task
@@ -320,4 +233,4 @@ def fabric_reconcile(device: str | None = None, dry_run: bool = True) -> dict:
---
**Status**: 🚧 Active Development - Phase 2 (InfraHub Setup & Core Reconciler)
**Status**: 🚧 Active Development - Phase 2 (InfraHub Client & Core Reconciler)

View File

@@ -1,534 +0,0 @@
# NetBox Data Model for Fabric Orchestrator
This document describes how fabric intent is represented in NetBox for the fabric-orchestrator to consume.
## Overview
The fabric-orchestrator uses NetBox as the **source of truth** for EVPN-VXLAN fabric configuration. Instead of using ConfigContexts, we leverage NetBox's native data models plus the [NetBox BGP Plugin](https://github.com/netbox-community/netbox-bgp) for comprehensive fabric representation.
### Requirements
- NetBox 4.4.x
- NetBox BGP Plugin v0.17.x
### Reference Topology
This data model represents the fabric defined in [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab):
- 2 Spine switches (spine1, spine2)
- 8 Leaf switches in 4 MLAG pairs (leaf1-2, leaf3-4, leaf5-6, leaf7-8)
- 4 Hosts dual-homed to MLAG pairs
---
## Data Model Summary
### Native NetBox Models
| Feature | NetBox Model | API Endpoint |
|---------|--------------|--------------|
| **Devices** | `dcim.Device` | `/api/dcim/devices/` |
| **Interfaces** | `dcim.Interface` | `/api/dcim/interfaces/` |
| **LAGs/Port-Channels** | `dcim.Interface` (type=LAG) | `/api/dcim/interfaces/` |
| **Cables** | `dcim.Cable` | `/api/dcim/cables/` |
| **VLANs** | `ipam.VLAN` | `/api/ipam/vlans/` |
| **VLAN Groups** | `ipam.VLANGroup` | `/api/ipam/vlan-groups/` |
| **VRFs** | `ipam.VRF` | `/api/ipam/vrfs/` |
| **Route Targets** | `ipam.RouteTarget` | `/api/ipam/route-targets/` |
| **IP Addresses** | `ipam.IPAddress` | `/api/ipam/ip-addresses/` |
| **Prefixes** | `ipam.Prefix` | `/api/ipam/prefixes/` |
| **ASNs** | `ipam.ASN` | `/api/ipam/asns/` |
| **L2VPN (EVPN)** | `vpn.L2VPN` | `/api/vpn/l2vpns/` |
| **L2VPN Terminations** | `vpn.L2VPNTermination` | `/api/vpn/l2vpn-terminations/` |
### NetBox BGP Plugin Models
| Feature | Plugin Model | API Endpoint |
|---------|--------------|--------------|
| **BGP Sessions** | `netbox_bgp.BGPSession` | `/api/plugins/bgp/sessions/` |
| **BGP Peer Groups** | `netbox_bgp.PeerGroup` | `/api/plugins/bgp/peer-groups/` |
| **BGP Communities** | `netbox_bgp.Community` | `/api/plugins/bgp/communities/` |
| **Routing Policies** | `netbox_bgp.RoutingPolicy` | `/api/plugins/bgp/routing-policies/` |
| **Prefix Lists** | `netbox_bgp.PrefixList` | `/api/plugins/bgp/prefix-lists/` |
| **AS Path Lists** | `netbox_bgp.ASPathList` | `/api/plugins/bgp/as-path-lists/` |
---
## Custom Fields Reference
NetBox doesn't natively support all fabric-specific configurations. The following custom fields must be created to support MLAG, ASN assignment, and VRF extensions.
### Complete Custom Fields List
#### Device Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `asn` | ASN | Integer | No | BGP Autonomous System Number assigned to this device |
| `mlag_domain_id` | MLAG Domain ID | Text | No | MLAG domain identifier (e.g., "leafs") |
| `mlag_peer_address` | MLAG Peer Address | Text | No | MLAG peer IP address |
| `mlag_local_address` | MLAG Local Address | Text | No | MLAG local IP address |
| `mlag_virtual_mac` | MLAG Virtual MAC | Text | No | Shared virtual-router MAC (e.g., "c001.cafe.babe") |
#### Interface Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `mlag_peer_link` | MLAG Peer Link | Boolean | No | Marks interface as MLAG peer-link |
| `mlag_id` | MLAG ID | Integer | No | MLAG port-channel ID for host-facing LAGs |
#### VRF Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `l3vni` | L3 VNI | Integer | No | Layer 3 VNI for EVPN symmetric IRB |
| `vrf_vlan` | VRF VLAN | Integer | No | VLAN ID used for L3 VNI SVI |
#### IP Address Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `virtual_ip` | Virtual IP | Boolean | No | Marks IP as anycast/virtual IP (shared across MLAG pair) |
### Custom Field Creation via API
```bash
# Example: Create ASN custom field on Device
curl -X POST "https://netbox.example.com/api/extras/custom-fields/" \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content_types": ["dcim.device"],
"name": "asn",
"label": "ASN",
"type": "integer",
"required": false,
"description": "BGP Autonomous System Number assigned to this device"
}'
# Example: Create MLAG Peer Link custom field on Interface
curl -X POST "https://netbox.example.com/api/extras/custom-fields/" \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content_types": ["dcim.interface"],
"name": "mlag_peer_link",
"label": "MLAG Peer Link",
"type": "boolean",
"required": false,
"description": "Marks interface as MLAG peer-link"
}'
```
---
## Fabric Component Mapping
### Devices and Roles
| Fabric Role | NetBox Device Role | Description |
|-------------|-------------------|-------------|
| Spine | `spine` | Spine switches (AS 65000) |
| Leaf | `leaf` | Leaf switches (AS 65001-65004) |
**Example Device Setup:**
- `spine1`, `spine2` - Device Role: spine
- `leaf1` through `leaf8` - Device Role: leaf
### Interfaces
| Interface Type | NetBox Interface Type | Usage |
|----------------|----------------------|-------|
| Physical uplinks | `1000base-t` / `10gbase-x-sfpp` | Spine-Leaf connections |
| Loopback0 | `virtual` | BGP Router-ID |
| Loopback1 | `virtual` | VTEP source (shared by MLAG pair) |
| Port-Channel | `lag` | MLAG peer-link, host bonds |
| Vxlan1 | `virtual` | VXLAN tunnel interface |
| SVI | `virtual` | VLAN interfaces (Vlan40, etc.) |
### IP Addressing Scheme
Based on the reference topology:
| Purpose | Prefix | Example |
|---------|--------|---------|
| Spine-Leaf P2P (Spine1) | `10.0.1.0/24` | `10.0.1.0/31`, `10.0.1.2/31`, ... |
| Spine-Leaf P2P (Spine2) | `10.0.2.0/24` | `10.0.2.0/31`, `10.0.2.2/31`, ... |
| MLAG iBGP Peer | `10.0.3.0/24` | `10.0.3.0/31` per MLAG pair |
| MLAG Peer VLAN 4090 | `10.0.199.0/24` | `/31` per MLAG pair |
| Loopback0 (Router-ID) | `10.0.250.0/24` | Spine: `.1-.2`, Leaf: `.11-.18` |
| Loopback1 (VTEP) | `10.0.255.0/24` | `.11-.14` (shared per MLAG pair) |
---
## Cabling
NetBox's native `dcim.Cable` model represents physical connections between devices. This enables topology visualization (via plugins like `netbox-topology-views`) and validation of the fabric infrastructure.
### Cable Model
| Field | Description |
|-------|-------------|
| `a_terminations` | Source interface(s) |
| `b_terminations` | Destination interface(s) |
| `type` | Cable type (e.g., `cat6a`, `mmf-om4`, `dac-passive`) |
| `status` | `connected`, `planned`, `decommissioning` |
| `label` | Optional identifier |
| `length` | Cable length (with unit) |
### Spine-Leaf Cabling Matrix
Based on the [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) topology:
#### Spine1 to Leafs
| Spine1 Interface | Leaf | Leaf Interface | Description |
|------------------|------|----------------|-------------|
| Ethernet1 | leaf1 | Ethernet11 | Underlay uplink |
| Ethernet2 | leaf2 | Ethernet11 | Underlay uplink |
| Ethernet3 | leaf3 | Ethernet11 | Underlay uplink |
| Ethernet4 | leaf4 | Ethernet11 | Underlay uplink |
| Ethernet5 | leaf5 | Ethernet11 | Underlay uplink |
| Ethernet6 | leaf6 | Ethernet11 | Underlay uplink |
| Ethernet7 | leaf7 | Ethernet11 | Underlay uplink |
| Ethernet8 | leaf8 | Ethernet11 | Underlay uplink |
#### Spine2 to Leafs
| Spine2 Interface | Leaf | Leaf Interface | Description |
|------------------|------|----------------|-------------|
| Ethernet1 | leaf1 | Ethernet12 | Underlay uplink |
| Ethernet2 | leaf2 | Ethernet12 | Underlay uplink |
| Ethernet3 | leaf3 | Ethernet12 | Underlay uplink |
| Ethernet4 | leaf4 | Ethernet12 | Underlay uplink |
| Ethernet5 | leaf5 | Ethernet12 | Underlay uplink |
| Ethernet6 | leaf6 | Ethernet12 | Underlay uplink |
| Ethernet7 | leaf7 | Ethernet12 | Underlay uplink |
| Ethernet8 | leaf8 | Ethernet12 | Underlay uplink |
### MLAG Peer-Link Cabling
Each MLAG pair has a dedicated peer-link connection:
| MLAG Pair | Device A | Interface A | Device B | Interface B | Description |
|-----------|----------|-------------|----------|-------------|-------------|
| VTEP1 | leaf1 | Ethernet10 | leaf2 | Ethernet10 | MLAG peer-link |
| VTEP2 | leaf3 | Ethernet10 | leaf4 | Ethernet10 | MLAG peer-link |
| VTEP3 | leaf5 | Ethernet10 | leaf6 | Ethernet10 | MLAG peer-link |
| VTEP4 | leaf7 | Ethernet10 | leaf8 | Ethernet10 | MLAG peer-link |
> Note: In production, peer-links typically use multiple interfaces in a Port-Channel for redundancy (e.g., Ethernet10 + Ethernet13 → Port-Channel10).
### Host Dual-Homing Cabling
Hosts are dual-homed to MLAG pairs using LACP bonding:
| Host | Leaf A | Leaf A Interface | Leaf B | Leaf B Interface | MLAG ID |
|------|--------|------------------|--------|------------------|---------|
| host1 | leaf1 | Ethernet1 | leaf2 | Ethernet1 | 1 |
| host2 | leaf3 | Ethernet1 | leaf4 | Ethernet1 | 1 |
| host3 | leaf5 | Ethernet1 | leaf6 | Ethernet1 | 1 |
| host4 | leaf7 | Ethernet1 | leaf8 | Ethernet1 | 1 |
### Cabling Conventions
| Interface Range | Purpose |
|-----------------|---------|
| Ethernet1-9 | Host-facing (downlinks) |
| Ethernet10 | MLAG peer-link |
| Ethernet11-12 | Spine uplinks |
### Cable Retrieval via API
```python
import pynetbox
nb = pynetbox.api('http://netbox.example.com', token='your-token')
# Get all cables for a device
cables = nb.dcim.cables.filter(device='leaf1')
for cable in cables:
a_term = cable.a_terminations[0]
b_term = cable.b_terminations[0]
print(f"{a_term.device.name}:{a_term.name} <-> {b_term.device.name}:{b_term.name}")
# Get spine-leaf cables only
spine_cables = nb.dcim.cables.filter(device='spine1')
# Get peer-link cables (filter by interface name pattern)
leaf1_interfaces = nb.dcim.interfaces.filter(device='leaf1', name='Ethernet10')
for iface in leaf1_interfaces:
if iface.cable:
cable = nb.dcim.cables.get(iface.cable.id)
print(f"Peer-link: {cable}")
# Validate expected connections
def validate_spine_leaf_cabling(nb, spine_name, expected_leafs):
"""Validate that spine has cables to all expected leafs."""
cables = nb.dcim.cables.filter(device=spine_name)
connected_devices = set()
for cable in cables:
for term in cable.b_terminations:
connected_devices.add(term.device.name)
missing = set(expected_leafs) - connected_devices
if missing:
print(f"WARNING: {spine_name} missing connections to: {missing}")
return len(missing) == 0
# Example validation
validate_spine_leaf_cabling(nb, 'spine1',
['leaf1', 'leaf2', 'leaf3', 'leaf4', 'leaf5', 'leaf6', 'leaf7', 'leaf8'])
```
### Topology Visualization
With cables properly defined, the `netbox-topology-views` plugin can automatically generate fabric diagrams. The plugin uses cable data to draw connections between devices.
Key benefits:
- **Auto-generated diagrams**: No manual drawing required
- **Real-time updates**: Topology reflects current NetBox data
- **Drill-down**: Click devices/cables for details
- **Export**: SVG/PNG export for documentation
---
## BGP Configuration
### ASN Assignments
| Device(s) | ASN | Type | Custom Field |
|-----------|-----|------|--------------|
| spine1, spine2 | 65000 | eBGP | `asn: 65000` |
| leaf1, leaf2 | 65001 | iBGP pair | `asn: 65001` |
| leaf3, leaf4 | 65002 | iBGP pair | `asn: 65002` |
| leaf5, leaf6 | 65003 | iBGP pair | `asn: 65003` |
| leaf7, leaf8 | 65004 | iBGP pair | `asn: 65004` |
### BGP Sessions (Plugin)
#### Underlay eBGP Sessions
Each leaf has eBGP sessions to both spines over point-to-point interfaces:
| Local Device | Local IP | Remote Device | Remote IP | Remote ASN |
|--------------|----------|---------------|-----------|------------|
| leaf1 | 10.0.1.1 | spine1 | 10.0.1.0 | 65000 |
| leaf1 | 10.0.2.1 | spine2 | 10.0.2.0 | 65000 |
#### Underlay iBGP Sessions
Each MLAG pair has an iBGP session for redundancy:
| Local Device | Local IP | Remote Device | Remote IP | Notes |
|--------------|----------|---------------|-----------|-------|
| leaf1 | 10.0.3.0 | leaf2 | 10.0.3.1 | next-hop-self |
#### Overlay EVPN Sessions
EVPN sessions use loopback addresses with `ebgp-multihop 3`:
| Local Device | Local IP (Lo0) | Remote Device | Remote IP (Lo0) | AFI/SAFI |
|--------------|----------------|---------------|-----------------|----------|
| leaf1 | 10.0.250.11 | spine1 | 10.0.250.1 | L2VPN EVPN |
| leaf1 | 10.0.250.11 | spine2 | 10.0.250.2 | L2VPN EVPN |
### BGP Peer Groups (Plugin)
| Peer Group | Purpose | Key Settings |
|------------|---------|--------------|
| `underlay` | eBGP to spines | `remote-as 65000`, `maximum-routes 12000` |
| `underlay_ibgp` | iBGP to MLAG peer | `next-hop-self` |
| `evpn` | EVPN overlay | `update-source Loopback0`, `ebgp-multihop 3`, `send-community extended` |
---
## VXLAN / EVPN Configuration
### L2VPN for EVPN VNI Mappings
NetBox's L2VPN model represents EVPN instances:
| L2VPN Name | Type | Identifier (VNI) | Description |
|------------|------|------------------|-------------|
| `VLAN40-L2VNI` | EVPN | 100040 | L2 VXLAN for VLAN 40 |
| `VRF-gold` | EVPN | 100001 | L3 VNI for VRF gold |
### L2VPN Terminations
Link VLANs to L2VPNs on specific devices:
| L2VPN | Device | Interface/VLAN |
|-------|--------|----------------|
| `VLAN40-L2VNI` | leaf1 | VLAN 40 |
| `VLAN40-L2VNI` | leaf2 | VLAN 40 |
| `VLAN40-L2VNI` | leaf5 | VLAN 40 |
| `VLAN40-L2VNI` | leaf6 | VLAN 40 |
### Route Targets
| VRF/VLAN | Import RT | Export RT |
|----------|-----------|-----------|
| VLAN 40 | `40:100040` | `40:100040` |
| VRF gold | `1:100001` | `1:100001` |
---
## MLAG Configuration
MLAG configuration uses Custom Fields since NetBox doesn't have native MLAG support.
### MLAG Pair Configuration
| Device | Domain ID | Local IP | Peer IP | Virtual MAC |
|--------|-----------|----------|---------|-------------|
| leaf1 | leafs | 10.0.199.254 | 10.0.199.255 | c001.cafe.babe |
| leaf2 | leafs | 10.0.199.255 | 10.0.199.254 | c001.cafe.babe |
### MLAG-Related VLANs
| VLAN | Name | Purpose | Trunk Group |
|------|------|---------|-------------|
| 4090 | mlag-peer | MLAG peer communication | mlag-peer |
| 4091 | mlag-ibgp | iBGP between MLAG peers | mlag-peer |
---
## VRF Configuration
### VRF Definition
| VRF Name | RD | Import RT | Export RT | L3 VNI (custom field) |
|----------|--------|-----------|-----------|----------------------|
| gold | `10.0.250.X:1` | `1:100001` | `1:100001` | 100001 |
> Note: RD uses device's Loopback0 IP for uniqueness
### VRF Interface Assignment
SVIs are assigned to VRFs in NetBox. VRF membership is tracked via IP addresses assigned to interfaces:
| Interface | VRF | IP Address | Virtual IP (custom field) |
|-----------|-----|------------|---------------------------|
| Vlan34 (leaf3) | gold | 10.34.34.2/24 | No |
| Vlan34 (leaf4) | gold | 10.34.34.3/24 | No |
| Vlan34 (leaf3) | gold | 10.34.34.1/24 | Yes (anycast) |
| Vlan78 (leaf7) | gold | 10.78.78.2/24 | No |
| Vlan78 (leaf8) | gold | 10.78.78.3/24 | No |
| Vlan78 (leaf7) | gold | 10.78.78.1/24 | Yes (anycast) |
---
## Data Retrieval Strategy
The fabric-orchestrator retrieves intent from NetBox using pynetbox:
```python
import pynetbox
nb = pynetbox.api('http://netbox.example.com', token='your-token')
# Get all leaf devices
leaves = nb.dcim.devices.filter(role='leaf')
# Get device ASN from custom field
device = nb.dcim.devices.get(name='leaf1')
asn = device.custom_fields.get('asn')
# Get BGP sessions for a device
bgp_sessions = nb.plugins.bgp.sessions.filter(device='leaf1')
# Get L2VPNs (EVPN)
l2vpns = nb.vpn.l2vpns.filter(type='evpn')
# Get VLAN-to-VNI mappings via L2VPN terminations
terminations = nb.vpn.l2vpn_terminations.filter(l2vpn='VLAN40-L2VNI')
# Get VRF with L3 VNI
vrf = nb.ipam.vrfs.get(name='gold')
l3vni = vrf.custom_fields.get('l3vni')
```
---
## Relationship Diagram
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ NetBox │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────────┐ ┌───────────────┐ │
│ │ Device │─────│ Interface │─────│ IP Address │ │
│ │ (leaf1) │ │ (Loopback0) │ │(10.0.250.11) │ │
│ │ cf:asn │ │ cf:mlag_peer_ │ │ cf:virtual_ip │ │
│ └────────────┘ │ link │ └───────────────┘ │
│ │ └────────────────┘ │
│ │ │ │
│ │ custom_fields │ │
│ ▼ ▼ │
│ ┌────────────┐ ┌────────────────┐ │
│ │ cf:asn │ │ Cable │ │
│ │ (65001) │ │ (connections) │ │
│ └────────────┘ └────────────────┘ │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ BGP Session │─────│ Peer Group │ │
│ │ (plugin) │ │ (plugin) │ │
│ └──────────────────┘ └───────────────────┘ │
│ │
│ ┌────────────┐ ┌───────────────────────┐ ┌────────────┐ │
│ │ VLAN │─────│ L2VPN Termination │─────│ L2VPN │ │
│ │ (40) │ │ │ │ (EVPN) │ │
│ └────────────┘ └───────────────────────┘ └────────────┘ │
│ │ │
│ │ identifier │
│ ▼ │
│ ┌────────────┐ │
│ │ 100040 │ │
│ │ (VNI) │ │
│ └────────────┘ │
│ │
│ ┌────────────┐ ┌───────────────────┐ │
│ │ VRF │─────│ Route Target │ │
│ │ (gold) │ │ (1:100001) │ │
│ │ cf:l3vni │ └───────────────────┘ │
│ └────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## API Filter Compatibility Notes
### Working Filters (NetBox 4.4)
| Endpoint | Filter | Example |
|----------|--------|---------|
| `/api/dcim/devices/` | `role`, `name`, `site` | `?role=leaf` |
| `/api/dcim/interfaces/` | `device`, `type`, `name` | `?device=leaf1&type=virtual` |
| `/api/dcim/cables/` | `device`, `site`, `rack` | `?device=leaf1` |
| `/api/ipam/ip-addresses/` | `device`, `interface`, `vrf` | `?device=leaf1&vrf=gold` |
| `/api/ipam/vlans/` | `vid`, `site`, `group` | `?vid=40` |
| `/api/vpn/l2vpns/` | `type`, `name` | `?type=evpn` |
| `/api/vpn/l2vpn-terminations/` | `l2vpn`, `vlan_id` | `?l2vpn=VLAN40-L2VNI` |
| `/api/plugins/bgp/sessions/` | `device`, `peer_group` | `?device=leaf1` |
### Non-Working Filters (Require Workarounds)
| Endpoint | Invalid Filter | Workaround |
|----------|----------------|------------|
| `/api/ipam/asns/` | `device` | Use custom field `asn` on Device |
| `/api/vpn/l2vpn-terminations/` | `device` | Filter by `vlan_id` then correlate |
| `/api/dcim/interfaces/` | `vrf` | Query IPs with VRF filter, get interface IDs |
---
## Next Steps
1. **Custom Fields Setup** - Create all custom fields listed above in NetBox
2. **Cabling Setup** - Create cables for spine-leaf, peer-links, and host connections
3. **Data Population** - Enter fabric topology data into NetBox
4. **NetBox Client** - Update `src/netbox/client.py` to use custom fields and cables
5. **Validation** - Verify data retrieval matches expected fabric config

View File

@@ -1,247 +0,0 @@
# yaml-language-server: $schema=https://schema.infrahub.app/infrahub/menu/latest.json
# Custom menu for EVPN-VXLAN Fabric Orchestrator
# Organizes schema nodes into logical topology-aligned categories
---
apiversion: infrahub.app/v1
kind: Menu
spec:
data:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Fabric Topology
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Topology
name: Mainmenu
label: Fabric Topology
icon: "mdi:lan"
children:
data:
- namespace: Topology
name: Fabric
label: Fabrics
kind: InfraFabric
icon: "mdi:vector-polygon"
- namespace: Topology
name: Site
label: Sites
kind: LocationSite
icon: "mingcute:building-4-line"
- namespace: Topology
name: Device
label: Devices
kind: InfraDevice
icon: "mdi:server-network"
- namespace: Topology
name: Platform
label: Platforms
kind: InfraPlatform
icon: "mdi:chip"
- namespace: Topology
name: UnderlayLink
label: Underlay Links
kind: InfraUnderlayLink
icon: "mdi:cable-data"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Interfaces
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Interfaces
name: Mainmenu
label: Interfaces
icon: "mdi:ethernet"
children:
data:
- namespace: Interfaces
name: Ethernet
label: Ethernet
kind: InfraInterfaceEthernet
icon: "mdi:ethernet"
- namespace: Interfaces
name: Loopback
label: Loopback
kind: InfraInterfaceLoopback
icon: "mdi:reload"
- namespace: Interfaces
name: VlanSvi
label: VLAN SVI
kind: InfraInterfaceVlan
icon: "mdi:lan"
- namespace: Interfaces
name: Lag
label: LAG / Port-Channel
kind: InfraInterfaceLag
icon: "mdi:link-variant"
- namespace: Interfaces
name: Vxlan
label: VXLAN Tunnel
kind: InfraInterfaceVxlan
icon: "mdi:tunnel"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# IP Addressing
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Addressing
name: Mainmenu
label: IP Addressing
icon: "mdi:ip-network"
children:
data:
- namespace: Addressing
name: IPAddress
label: IP Addresses
kind: InfraIPAddress
icon: "mdi:ip-network"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Layer 2 / VXLAN
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Layer2
name: Mainmenu
label: Layer 2 / VXLAN
icon: "mdi:switch"
children:
data:
- namespace: Layer2
name: Vlan
label: VLANs
kind: InfraVLAN
icon: "mdi:lan-connect"
- namespace: Layer2
name: Vni
label: VNIs
kind: InfraVNI
icon: "mdi:tunnel-outline"
- namespace: Layer2
name: Vtep
label: VTEPs
kind: InfraVTEP
icon: "mdi:server-network-outline"
- namespace: Layer2
name: VlanVniMapping
label: VLAN-VNI Mappings
kind: InfraVlanVniMapping
icon: "mdi:swap-horizontal"
- namespace: Layer2
name: EvpnInstance
label: EVPN Instances
kind: InfraEVPNInstance
icon: "mdi:cloud-sync"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Routing / BGP
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Routing
name: Mainmenu
label: Routing / BGP
icon: "mdi:routes"
children:
data:
- namespace: Routing
name: AutonomousSystem
label: Autonomous Systems
kind: InfraAutonomousSystem
icon: "mdi:cloud-outline"
- namespace: Routing
name: BGPRouterConfig
label: BGP Router Config
kind: InfraBGPRouterConfig
icon: "mdi:router-wireless"
- namespace: Routing
name: BGPPeerGroup
label: BGP Peer Groups
kind: InfraBGPPeerGroup
icon: "mdi:account-group"
- namespace: Routing
name: BGPSession
label: BGP Sessions
kind: InfraBGPSession
icon: "mdi:connection"
- namespace: Routing
name: BGPAddressFamily
label: BGP Address Families
kind: InfraBGPAddressFamily
icon: "mdi:format-list-bulleted"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# VRF
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Vrf
name: Mainmenu
label: VRF
icon: "mdi:router"
children:
data:
- namespace: Vrf
name: Vrf
label: VRFs
kind: InfraVRF
icon: "mdi:router"
- namespace: Vrf
name: RouteTarget
label: Route Targets
kind: InfraRouteTarget
icon: "mdi:target"
- namespace: Vrf
name: VrfAssignment
label: VRF Assignments
kind: InfraVRFDeviceAssignment
icon: "mdi:router-network"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# MLAG
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Mlag
name: Mainmenu
label: MLAG
icon: "mdi:link-variant"
children:
data:
- namespace: Mlag
name: MlagDomain
label: MLAG Domains
kind: InfraMlagDomain
icon: "mdi:link-variant"
- namespace: Mlag
name: MlagPeerConfig
label: MLAG Peer Config
kind: InfraMlagPeerConfig
icon: "mdi:server-network"
- namespace: Mlag
name: MlagInterface
label: MLAG Interfaces
kind: InfraMlagInterface
icon: "mdi:ethernet-cable"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Host Connectivity
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Connectivity
name: Mainmenu
label: Host Connectivity
icon: "mdi:desktop-tower"
children:
data:
- namespace: Connectivity
name: HostConnection
label: Host Connections
kind: InfraHostConnection
icon: "mdi:desktop-tower"

View File

@@ -1,41 +0,0 @@
# Foundation objects: Site, Platform, Autonomous Systems
# Must be loaded first — referenced by all subsequent files
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: LocationSite
data:
- name: dc1
description: Primary data center
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraPlatform
data:
- name: arista_eos
description: Arista EOS
napalm_driver: eos
netmiko_device_type: arista_eos
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraAutonomousSystem
data:
- asn: 65000
description: Spine AS
as_type: private
- asn: 65001
description: Leaf pair 1 (leaf1/leaf2)
as_type: private
- asn: 65002
description: Leaf pair 2 (leaf3/leaf4)
as_type: private
- asn: 65003
description: Leaf pair 3 (leaf5/leaf6)
as_type: private
- asn: 65004
description: Leaf pair 4 (leaf7/leaf8)
as_type: private

View File

@@ -1,15 +0,0 @@
# Fabric definition — depends on LocationSite and InfraAutonomousSystem from 01-foundation
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraFabric
data:
- name: evpn-lab
description: Arista EVPN-VXLAN reference fabric
underlay_protocol: ebgp
overlay_protocol: evpn
anycast_gateway_mac: "c001.cafe.babe"
spine_asn: ["65000"]
sites:
- ["dc1"]

View File

@@ -1,83 +0,0 @@
# Devices: 2 spines + 8 leafs
# Depends on: 01-foundation (Platform, Site, AutonomousSystem)
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraDevice
data:
# Spines
- name: spine1
description: Spine1 - BGP EVPN Spine
role: spine
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65000"]
- name: spine2
description: Spine2 - BGP EVPN Spine
role: spine
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65000"]
# Leaf pair 1 (VTEP1)
- name: leaf1
description: Leaf1 - VTEP1
role: leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65001"]
- name: leaf2
description: Leaf2 - VTEP1
role: leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65001"]
# Leaf pair 2 (VTEP2)
- name: leaf3
description: Leaf3 - VTEP2
role: leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65002"]
- name: leaf4
description: Leaf4 - VTEP2
role: leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65002"]
# Leaf pair 3 (VTEP3)
- name: leaf5
description: Leaf5 - VTEP3
role: leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65003"]
- name: leaf6
description: Leaf6 - VTEP3
role: leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65003"]
# Leaf pair 4 (VTEP4)
- name: leaf7
description: Leaf7 - VTEP4
role: border_leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65004"]
- name: leaf8
description: Leaf8 - VTEP4
role: border_leaf
status: active
platform: ["arista_eos"]
site: ["dc1"]
asn: ["65004"]

View File

@@ -1,492 +0,0 @@
# Interfaces: Loopback, Ethernet, LAG (Port-Channel), VLAN SVIs
# Depends on: 02-devices
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraInterfaceLoopback
data:
# Spine loopbacks (Lo0 only)
- device: ["spine1"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["spine2"]
name: Loopback0
description: Router-ID
enabled: true
# Leaf loopbacks (Lo0 + Lo1)
- device: ["leaf1"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf1"]
name: Loopback1
description: VTEP
enabled: true
- device: ["leaf2"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf2"]
name: Loopback1
description: VTEP
enabled: true
- device: ["leaf3"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf3"]
name: Loopback1
description: VTEP
enabled: true
- device: ["leaf4"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf4"]
name: Loopback1
description: VTEP
enabled: true
- device: ["leaf5"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf5"]
name: Loopback1
description: VTEP
enabled: true
- device: ["leaf6"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf6"]
name: Loopback1
description: VTEP
enabled: true
- device: ["leaf7"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf7"]
name: Loopback1
description: VTEP
enabled: true
- device: ["leaf8"]
name: Loopback0
description: Router-ID
enabled: true
- device: ["leaf8"]
name: Loopback1
description: VTEP
enabled: true
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraInterfaceLag
data:
# Port-Channel999 — MLAG peer-link (all leafs)
- device: ["leaf1"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
- device: ["leaf2"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
- device: ["leaf3"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
- device: ["leaf4"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
- device: ["leaf5"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
- device: ["leaf6"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
- device: ["leaf7"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
- device: ["leaf8"]
name: Port-Channel999
description: MLAG Peer
enabled: true
lacp_mode: active
# Port-Channel1 — Host-facing MLAG LAG (all leafs)
- device: ["leaf1"]
name: Port-Channel1
description: host1
enabled: true
lacp_mode: active
mlag_id: 1
- device: ["leaf2"]
name: Port-Channel1
description: host1
enabled: true
lacp_mode: active
mlag_id: 1
- device: ["leaf3"]
name: Port-Channel1
description: host2
enabled: true
lacp_mode: active
mlag_id: 1
- device: ["leaf4"]
name: Port-Channel1
description: host2
enabled: true
lacp_mode: active
mlag_id: 1
- device: ["leaf5"]
name: Port-Channel1
description: host3
enabled: true
lacp_mode: active
mlag_id: 1
- device: ["leaf6"]
name: Port-Channel1
description: host3
enabled: true
lacp_mode: active
mlag_id: 1
- device: ["leaf7"]
name: Port-Channel1
description: host4
enabled: true
lacp_mode: active
mlag_id: 1
- device: ["leaf8"]
name: Port-Channel1
description: host4
enabled: true
lacp_mode: active
mlag_id: 1
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraInterfaceEthernet
data:
# ============================================================
# Spine1 Ethernet interfaces (underlay to leafs)
# ============================================================
- device: ["spine1"]
name: Ethernet1
description: leaf1
enabled: true
mtu: 9214
mode: routed
- device: ["spine1"]
name: Ethernet2
description: leaf2
enabled: true
mtu: 9214
mode: routed
- device: ["spine1"]
name: Ethernet3
description: leaf3
enabled: true
mtu: 9214
mode: routed
- device: ["spine1"]
name: Ethernet4
description: leaf4
enabled: true
mtu: 9214
mode: routed
- device: ["spine1"]
name: Ethernet5
description: leaf5
enabled: true
mtu: 9214
mode: routed
- device: ["spine1"]
name: Ethernet6
description: leaf6
enabled: true
mtu: 9214
mode: routed
- device: ["spine1"]
name: Ethernet7
description: leaf7
enabled: true
mtu: 9214
mode: routed
- device: ["spine1"]
name: Ethernet8
description: leaf8
enabled: true
mtu: 9214
mode: routed
# ============================================================
# Spine2 Ethernet interfaces (underlay to leafs)
# ============================================================
- device: ["spine2"]
name: Ethernet1
description: leaf1
enabled: true
mtu: 9214
mode: routed
- device: ["spine2"]
name: Ethernet2
description: leaf2
enabled: true
mtu: 9214
mode: routed
- device: ["spine2"]
name: Ethernet3
description: leaf3
enabled: true
mtu: 9214
mode: routed
- device: ["spine2"]
name: Ethernet4
description: leaf4
enabled: true
mtu: 9214
mode: routed
- device: ["spine2"]
name: Ethernet5
description: leaf5
enabled: true
mtu: 9214
mode: routed
- device: ["spine2"]
name: Ethernet6
description: leaf6
enabled: true
mtu: 9214
mode: routed
- device: ["spine2"]
name: Ethernet7
description: leaf7
enabled: true
mtu: 9214
mode: routed
- device: ["spine2"]
name: Ethernet8
description: leaf8
enabled: true
mtu: 9214
mode: routed
# ============================================================
# Leaf Ethernet interfaces
# Each leaf has: Ethernet1 (host), Ethernet10 (mlag peer-link),
# Ethernet11 (spine1), Ethernet12 (spine2)
# ============================================================
# Leaf1
- device: ["leaf1"]
name: Ethernet1
description: host1
enabled: true
mode: trunk
lag: ["leaf1", "Port-Channel1"]
- device: ["leaf1"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf1", "Port-Channel999"]
- device: ["leaf1"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf1"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed
# Leaf2
- device: ["leaf2"]
name: Ethernet1
description: host1
enabled: true
mode: trunk
lag: ["leaf2", "Port-Channel1"]
- device: ["leaf2"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf2", "Port-Channel999"]
- device: ["leaf2"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf2"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed
# Leaf3
- device: ["leaf3"]
name: Ethernet1
description: host2
enabled: true
mode: trunk
lag: ["leaf3", "Port-Channel1"]
- device: ["leaf3"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf3", "Port-Channel999"]
- device: ["leaf3"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf3"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed
# Leaf4
- device: ["leaf4"]
name: Ethernet1
description: host2
enabled: true
mode: trunk
lag: ["leaf4", "Port-Channel1"]
- device: ["leaf4"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf4", "Port-Channel999"]
- device: ["leaf4"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf4"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed
# Leaf5
- device: ["leaf5"]
name: Ethernet1
description: host3
enabled: true
mode: trunk
lag: ["leaf5", "Port-Channel1"]
- device: ["leaf5"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf5", "Port-Channel999"]
- device: ["leaf5"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf5"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed
# Leaf6
- device: ["leaf6"]
name: Ethernet1
description: host3
enabled: true
mode: trunk
lag: ["leaf6", "Port-Channel1"]
- device: ["leaf6"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf6", "Port-Channel999"]
- device: ["leaf6"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf6"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed
# Leaf7
- device: ["leaf7"]
name: Ethernet1
description: host4
enabled: true
mode: trunk
lag: ["leaf7", "Port-Channel1"]
- device: ["leaf7"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf7", "Port-Channel999"]
- device: ["leaf7"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf7"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed
# Leaf8
- device: ["leaf8"]
name: Ethernet1
description: host4
enabled: true
mode: trunk
lag: ["leaf8", "Port-Channel1"]
- device: ["leaf8"]
name: Ethernet10
description: mlag peer link
enabled: true
mode: trunk
lag: ["leaf8", "Port-Channel999"]
- device: ["leaf8"]
name: Ethernet11
description: spine1
enabled: true
mtu: 9214
mode: routed
- device: ["leaf8"]
name: Ethernet12
description: spine2
enabled: true
mtu: 9214
mode: routed

View File

@@ -1,132 +0,0 @@
# IP Addresses: Loopbacks, P2P underlay links, MLAG SVIs, VRF SVIs
# Depends on: 03-interfaces
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraIPAddress
data:
# ============================================================
# Loopback0 addresses (Router-ID) — /32
# ============================================================
- address: "10.0.250.1/32"
description: spine1 Router-ID
- address: "10.0.250.2/32"
description: spine2 Router-ID
- address: "10.0.250.11/32"
description: leaf1 Router-ID
- address: "10.0.250.12/32"
description: leaf2 Router-ID
- address: "10.0.250.13/32"
description: leaf3 Router-ID
- address: "10.0.250.14/32"
description: leaf4 Router-ID
- address: "10.0.250.15/32"
description: leaf5 Router-ID
- address: "10.0.250.16/32"
description: leaf6 Router-ID
- address: "10.0.250.17/32"
description: leaf7 Router-ID
- address: "10.0.250.18/32"
description: leaf8 Router-ID
# ============================================================
# Loopback1 addresses (VTEP) — /32
# ============================================================
- address: "10.0.255.11/32"
description: leaf1 VTEP (shared VTEP1)
- address: "10.0.255.11/32"
description: leaf2 VTEP (shared VTEP1)
- address: "10.0.255.12/32"
description: leaf3 VTEP (shared VTEP2)
- address: "10.0.255.12/32"
description: leaf4 VTEP (shared VTEP2)
- address: "10.0.255.13/32"
description: leaf5 VTEP (shared VTEP3)
- address: "10.0.255.13/32"
description: leaf6 VTEP (shared VTEP3)
- address: "10.0.255.14/32"
description: leaf7 VTEP (shared VTEP4)
- address: "10.0.255.14/32"
description: leaf8 VTEP (shared VTEP4)
# ============================================================
# Spine1 P2P underlay — /31
# ============================================================
- address: "10.0.1.0/31"
description: spine1 Ethernet1 to leaf1
- address: "10.0.1.2/31"
description: spine1 Ethernet2 to leaf2
- address: "10.0.1.4/31"
description: spine1 Ethernet3 to leaf3
- address: "10.0.1.6/31"
description: spine1 Ethernet4 to leaf4
- address: "10.0.1.8/31"
description: spine1 Ethernet5 to leaf5
- address: "10.0.1.10/31"
description: spine1 Ethernet6 to leaf6
- address: "10.0.1.12/31"
description: spine1 Ethernet7 to leaf7
- address: "10.0.1.14/31"
description: spine1 Ethernet8 to leaf8
# ============================================================
# Spine2 P2P underlay — /31
# ============================================================
- address: "10.0.2.0/31"
description: spine2 Ethernet1 to leaf1
- address: "10.0.2.2/31"
description: spine2 Ethernet2 to leaf2
- address: "10.0.2.4/31"
description: spine2 Ethernet3 to leaf3
- address: "10.0.2.6/31"
description: spine2 Ethernet4 to leaf4
- address: "10.0.2.8/31"
description: spine2 Ethernet5 to leaf5
- address: "10.0.2.10/31"
description: spine2 Ethernet6 to leaf6
- address: "10.0.2.12/31"
description: spine2 Ethernet7 to leaf7
- address: "10.0.2.14/31"
description: spine2 Ethernet8 to leaf8
# ============================================================
# Leaf P2P underlay (leaf side) — /31
# ============================================================
# Leaf1
- address: "10.0.1.1/31"
description: leaf1 Ethernet11 to spine1
- address: "10.0.2.1/31"
description: leaf1 Ethernet12 to spine2
# Leaf2
- address: "10.0.1.3/31"
description: leaf2 Ethernet11 to spine1
- address: "10.0.2.3/31"
description: leaf2 Ethernet12 to spine2
# Leaf3
- address: "10.0.1.5/31"
description: leaf3 Ethernet11 to spine1
- address: "10.0.2.5/31"
description: leaf3 Ethernet12 to spine2
# Leaf4
- address: "10.0.1.7/31"
description: leaf4 Ethernet11 to spine1
- address: "10.0.2.7/31"
description: leaf4 Ethernet12 to spine2
# Leaf5
- address: "10.0.1.9/31"
description: leaf5 Ethernet11 to spine1
- address: "10.0.2.9/31"
description: leaf5 Ethernet12 to spine2
# Leaf6
- address: "10.0.1.11/31"
description: leaf6 Ethernet11 to spine1
- address: "10.0.2.11/31"
description: leaf6 Ethernet12 to spine2
# Leaf7
- address: "10.0.1.13/31"
description: leaf7 Ethernet11 to spine1
- address: "10.0.2.13/31"
description: leaf7 Ethernet12 to spine2
# Leaf8
- address: "10.0.1.15/31"
description: leaf8 Ethernet11 to spine1
- address: "10.0.2.15/31"
description: leaf8 Ethernet12 to spine2

View File

@@ -1,167 +0,0 @@
# VLANs, VNIs, VTEPs, VLAN-VNI mappings, EVPN instances
# Depends on: 02-devices, 03-interfaces
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraVLAN
data:
- vlan_id: 34
name: vrf-gold-subnet
description: VRF gold L3 subnet (leaf3/leaf4)
status: active
vlan_type: standard
- vlan_id: 40
name: test-l2-vxlan
description: L2 VXLAN test VLAN
status: active
vlan_type: standard
- vlan_id: 78
name: vrf-gold-subnet
description: VRF gold L3 subnet (leaf7/leaf8)
status: active
vlan_type: standard
- vlan_id: 900
name: bgp-border
description: BGP border peering VLAN
status: active
vlan_type: standard
- vlan_id: 4090
name: mlag-peer
description: MLAG peer-link control
status: active
vlan_type: mlag_peer
trunk_groups:
- mlag-peer
stp_enabled: false
- vlan_id: 4091
name: mlag-ibgp
description: MLAG iBGP peering
status: active
vlan_type: mlag_ibgp
trunk_groups:
- mlag-peer
stp_enabled: false
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraVNI
data:
- vni: 110040
description: L2VNI for VLAN 40 (test-l2-vxlan)
vni_type: l2vni
vlan: ["40"]
- vni: 100001
description: L3VNI for VRF gold
vni_type: l3vni
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraVTEP
data:
# VTEP on leaf1 (shared VTEP1 IP 10.0.255.11)
- device: ["leaf1"]
source_address: "10.0.255.11"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf1", "Loopback1"]
# VTEP on leaf2 (shared VTEP1 IP 10.0.255.11)
- device: ["leaf2"]
source_address: "10.0.255.11"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf2", "Loopback1"]
# VTEP on leaf3 (shared VTEP2 IP 10.0.255.12)
- device: ["leaf3"]
source_address: "10.0.255.12"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf3", "Loopback1"]
# VTEP on leaf4 (shared VTEP2 IP 10.0.255.12)
- device: ["leaf4"]
source_address: "10.0.255.12"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf4", "Loopback1"]
# VTEP on leaf5 (shared VTEP3 IP 10.0.255.13)
- device: ["leaf5"]
source_address: "10.0.255.13"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf5", "Loopback1"]
# VTEP on leaf6 (shared VTEP3 IP 10.0.255.13)
- device: ["leaf6"]
source_address: "10.0.255.13"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf6", "Loopback1"]
# VTEP on leaf7 (shared VTEP4 IP 10.0.255.14)
- device: ["leaf7"]
source_address: "10.0.255.14"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf7", "Loopback1"]
# VTEP on leaf8 (shared VTEP4 IP 10.0.255.14)
- device: ["leaf8"]
source_address: "10.0.255.14"
udp_port: 4789
learn_restrict: any
source_interface: ["leaf8", "Loopback1"]
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraVlanVniMapping
data:
# VLAN 40 <-> VNI 110040 on leaf1/2/5/6 (L2 VXLAN leafs)
- vtep: ["leaf1"]
vlan: ["40"]
vni: ["110040"]
description: "VLAN 40 <-> VNI 110040"
- vtep: ["leaf2"]
vlan: ["40"]
vni: ["110040"]
description: "VLAN 40 <-> VNI 110040"
- vtep: ["leaf5"]
vlan: ["40"]
vni: ["110040"]
description: "VLAN 40 <-> VNI 110040"
- vtep: ["leaf6"]
vlan: ["40"]
vni: ["110040"]
description: "VLAN 40 <-> VNI 110040"
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraEVPNInstance
data:
# EVPN instance for VLAN 40 on L2 VXLAN leafs
# leaf1: rd 65001:110040, rt both 40:110040
- device: ["leaf1"]
vlan: ["40"]
route_distinguisher: "65001:110040"
route_target_import: "40:110040"
route_target_export: "40:110040"
redistribute_learned: true
- device: ["leaf2"]
vlan: ["40"]
route_distinguisher: "65001:110040"
route_target_import: "40:110040"
route_target_export: "40:110040"
redistribute_learned: true
# leaf5/6: rd 65003:110040, rt both 40:110040
- device: ["leaf5"]
vlan: ["40"]
route_distinguisher: "65003:110040"
route_target_import: "40:110040"
route_target_export: "40:110040"
redistribute_learned: true
- device: ["leaf6"]
vlan: ["40"]
route_distinguisher: "65003:110040"
route_target_import: "40:110040"
route_target_export: "40:110040"
redistribute_learned: true

View File

@@ -1,143 +0,0 @@
# VLAN SVI interfaces
# Depends on: 03-devices, 06-vlans-vxlan (VLANs must exist)
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraInterfaceVlan
data:
# Vlan4090 — MLAG Peer-Link SVI (all leafs)
- device: ["leaf1"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
- device: ["leaf2"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
- device: ["leaf3"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
- device: ["leaf4"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
- device: ["leaf5"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
- device: ["leaf6"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
- device: ["leaf7"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
- device: ["leaf8"]
name: Vlan4090
description: MLAG Peer-Link
enabled: true
autostate: false
vlan: ["4090"]
# Vlan4091 — MLAG iBGP Peering SVI (all leafs)
- device: ["leaf1"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
- device: ["leaf2"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
- device: ["leaf3"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
- device: ["leaf4"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
- device: ["leaf5"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
- device: ["leaf6"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
- device: ["leaf7"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
- device: ["leaf8"]
name: Vlan4091
description: MLAG iBGP Peering
enabled: true
mtu: 9214
vlan: ["4091"]
# Vlan34 — VRF gold subnet (leaf3/leaf4)
- device: ["leaf3"]
name: Vlan34
description: VRF gold subnet
enabled: true
virtual_router_address: "10.34.34.1"
vlan: ["34"]
- device: ["leaf4"]
name: Vlan34
description: VRF gold subnet
enabled: true
virtual_router_address: "10.34.34.1"
vlan: ["34"]
# Vlan78 — VRF gold subnet (leaf7/leaf8)
- device: ["leaf7"]
name: Vlan78
description: VRF gold subnet
enabled: true
virtual_router_address: "10.78.78.1"
vlan: ["78"]
- device: ["leaf8"]
name: Vlan78
description: VRF gold subnet
enabled: true
virtual_router_address: "10.78.78.1"
vlan: ["78"]
# Vlan900 — BGP border peering (leaf7/leaf8)
- device: ["leaf7"]
name: Vlan900
description: BGP border peering
enabled: true
vlan: ["900"]
- device: ["leaf8"]
name: Vlan900
description: BGP border peering
enabled: true
vlan: ["900"]

View File

@@ -1,64 +0,0 @@
# IP Addresses for VLAN SVI interfaces
# Depends on: 07-interface-vlans (Vlan SVIs must exist)
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraIPAddress
data:
# ============================================================
# MLAG Peer-Link SVI (Vlan4090) — /31
# ============================================================
- address: "10.0.199.254/31"
description: leaf1 MLAG peer-link
- address: "10.0.199.255/31"
description: leaf2 MLAG peer-link
- address: "10.0.199.252/31"
description: leaf3 MLAG peer-link
- address: "10.0.199.253/31"
description: leaf4 MLAG peer-link
- address: "10.0.199.250/31"
description: leaf5 MLAG peer-link
- address: "10.0.199.251/31"
description: leaf6 MLAG peer-link
- address: "10.0.199.248/31"
description: leaf7 MLAG peer-link
- address: "10.0.199.249/31"
description: leaf8 MLAG peer-link
# ============================================================
# MLAG iBGP Peering SVI (Vlan4091) — /31
# ============================================================
- address: "10.0.3.0/31"
description: leaf1 MLAG iBGP peering
- address: "10.0.3.1/31"
description: leaf2 MLAG iBGP peering
- address: "10.0.3.2/31"
description: leaf3 MLAG iBGP peering
- address: "10.0.3.3/31"
description: leaf4 MLAG iBGP peering
- address: "10.0.3.4/31"
description: leaf5 MLAG iBGP peering
- address: "10.0.3.5/31"
description: leaf6 MLAG iBGP peering
- address: "10.0.3.6/31"
description: leaf7 MLAG iBGP peering
- address: "10.0.3.7/31"
description: leaf8 MLAG iBGP peering
# ============================================================
# VRF gold SVI addresses — /24
# ============================================================
# Vlan34 (leaf3/leaf4)
- address: "10.34.34.2/24"
description: leaf3 Vlan34 VRF gold
- address: "10.34.34.3/24"
description: leaf4 Vlan34 VRF gold
# Vlan78 (leaf7/leaf8)
- address: "10.78.78.2/24"
description: leaf7 Vlan78 VRF gold
- address: "10.78.78.3/24"
description: leaf8 Vlan78 VRF gold
# Vlan900 — BGP border (leaf7/leaf8)
- address: "10.90.90.2/29"
description: leaf7 Vlan900 BGP border
- address: "10.90.90.3/29"
description: leaf8 Vlan900 BGP border

View File

@@ -1,385 +0,0 @@
# BGP: RouterConfig, PeerGroups, Sessions, AddressFamilies
# Depends on: 01-foundation (ASNs), 02-devices, 04-ipam
#
# Spine BGP: no named peer-groups in config (neighbors defined directly),
# but evpn peer-group is defined. Underlay neighbors are direct.
# Leaf BGP: underlay, underlay_ibgp, evpn peer-groups.
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraBGPRouterConfig
data:
# ============================================================
# Spine1 BGP — AS 65000, router-id 10.0.250.1
# ============================================================
- device: ["spine1"]
router_id: "10.0.250.1"
local_asn: ["65000"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Spine2 BGP — AS 65000, router-id 10.0.250.2
# ============================================================
- device: ["spine2"]
router_id: "10.0.250.2"
local_asn: ["65000"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf1 BGP — AS 65001, router-id 10.0.250.11
# ============================================================
- device: ["leaf1"]
router_id: "10.0.250.11"
local_asn: ["65001"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf2 BGP — AS 65001, router-id 10.0.250.12
# ============================================================
- device: ["leaf2"]
router_id: "10.0.250.12"
local_asn: ["65001"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf3 BGP — AS 65002, router-id 10.0.250.13
# ============================================================
- device: ["leaf3"]
router_id: "10.0.250.13"
local_asn: ["65002"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf4 BGP — AS 65002, router-id 10.0.250.14
# ============================================================
- device: ["leaf4"]
router_id: "10.0.250.14"
local_asn: ["65002"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf5 BGP — AS 65003, router-id 10.0.250.15
# ============================================================
- device: ["leaf5"]
router_id: "10.0.250.15"
local_asn: ["65003"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf6 BGP — AS 65003, router-id 10.0.250.16
# ============================================================
- device: ["leaf6"]
router_id: "10.0.250.16"
local_asn: ["65003"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf7 BGP — AS 65004, router-id 10.0.250.17
# ============================================================
- device: ["leaf7"]
router_id: "10.0.250.17"
local_asn: ["65004"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
# ============================================================
# Leaf8 BGP — AS 65004, router-id 10.0.250.18
# ============================================================
- device: ["leaf8"]
router_id: "10.0.250.18"
local_asn: ["65004"]
default_ipv4_unicast: false
log_neighbor_changes: true
ecmp_max_paths: 4
ecmp_max_ecmp: 64
ebgp_distance: 20
ibgp_distance: 200
local_distance: 200
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraBGPPeerGroup
data:
# ============================================================
# Spine peer-groups (evpn only — underlay neighbors are direct)
# ============================================================
# Spine1 — evpn peer-group
- bgp_config: ["spine1"]
name: evpn
description: EVPN overlay to leaf loopbacks
peer_group_type: evpn
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
next_hop_unchanged: true
maximum_routes: 12000
maximum_routes_warning_only: true
# Spine2 — evpn peer-group
- bgp_config: ["spine2"]
name: evpn
description: EVPN overlay to leaf loopbacks
peer_group_type: evpn
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
next_hop_unchanged: true
maximum_routes: 12000
maximum_routes_warning_only: true
# ============================================================
# Leaf peer-groups (underlay, underlay_ibgp, evpn)
# ============================================================
# Leaf1
- bgp_config: ["leaf1"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf1"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65001"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf1"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true
# Leaf2
- bgp_config: ["leaf2"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf2"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65001"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf2"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true
# Leaf3
- bgp_config: ["leaf3"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf3"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65002"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf3"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true
# Leaf4
- bgp_config: ["leaf4"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf4"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65002"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf4"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true
# Leaf5
- bgp_config: ["leaf5"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf5"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65003"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf5"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true
# Leaf6
- bgp_config: ["leaf6"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf6"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65003"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf6"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true
# Leaf7
- bgp_config: ["leaf7"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf7"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65004"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf7"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true
# Leaf8
- bgp_config: ["leaf8"]
name: underlay
description: Underlay eBGP to spines
peer_group_type: underlay
remote_asn: ["65000"]
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf8"]
name: underlay_ibgp
description: MLAG iBGP peering
peer_group_type: underlay_ibgp
remote_asn: ["65004"]
next_hop_self: true
maximum_routes: 12000
maximum_routes_warning_only: true
- bgp_config: ["leaf8"]
name: evpn
description: EVPN overlay to spines
peer_group_type: evpn
remote_asn: ["65000"]
update_source: Loopback0
ebgp_multihop: 3
send_community: extended
maximum_routes: 12000
maximum_routes_warning_only: true

View File

@@ -1,586 +0,0 @@
# BGP Sessions and Address Families
# Depends on: 09-bgp (RouterConfig and PeerGroups must exist)
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraBGPSession
data:
# ============================================================
# Spine1 sessions
# ============================================================
# Spine1 underlay (direct neighbors, no peer-group — use remote_asn)
- bgp_config: ["spine1"]
peer_address: "10.0.1.1"
description: "underlay to leaf1"
remote_asn: ["65001"]
peer_device: ["leaf1"]
- bgp_config: ["spine1"]
peer_address: "10.0.1.3"
description: "underlay to leaf2"
remote_asn: ["65001"]
peer_device: ["leaf2"]
- bgp_config: ["spine1"]
peer_address: "10.0.1.5"
description: "underlay to leaf3"
remote_asn: ["65002"]
peer_device: ["leaf3"]
- bgp_config: ["spine1"]
peer_address: "10.0.1.7"
description: "underlay to leaf4"
remote_asn: ["65002"]
peer_device: ["leaf4"]
- bgp_config: ["spine1"]
peer_address: "10.0.1.9"
description: "underlay to leaf5"
remote_asn: ["65003"]
peer_device: ["leaf5"]
- bgp_config: ["spine1"]
peer_address: "10.0.1.11"
description: "underlay to leaf6"
remote_asn: ["65003"]
peer_device: ["leaf6"]
- bgp_config: ["spine1"]
peer_address: "10.0.1.13"
description: "underlay to leaf7"
remote_asn: ["65004"]
peer_device: ["leaf7"]
- bgp_config: ["spine1"]
peer_address: "10.0.1.15"
description: "underlay to leaf8"
remote_asn: ["65004"]
peer_device: ["leaf8"]
# Spine1 EVPN (via evpn peer-group)
- bgp_config: ["spine1"]
peer_address: "10.0.250.11"
description: "EVPN to leaf1"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65001"]
peer_device: ["leaf1"]
- bgp_config: ["spine1"]
peer_address: "10.0.250.12"
description: "EVPN to leaf2"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65001"]
peer_device: ["leaf2"]
- bgp_config: ["spine1"]
peer_address: "10.0.250.13"
description: "EVPN to leaf3"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65002"]
peer_device: ["leaf3"]
- bgp_config: ["spine1"]
peer_address: "10.0.250.14"
description: "EVPN to leaf4"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65002"]
peer_device: ["leaf4"]
- bgp_config: ["spine1"]
peer_address: "10.0.250.15"
description: "EVPN to leaf5"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65003"]
peer_device: ["leaf5"]
- bgp_config: ["spine1"]
peer_address: "10.0.250.16"
description: "EVPN to leaf6"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65003"]
peer_device: ["leaf6"]
- bgp_config: ["spine1"]
peer_address: "10.0.250.17"
description: "EVPN to leaf7"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65004"]
peer_device: ["leaf7"]
- bgp_config: ["spine1"]
peer_address: "10.0.250.18"
description: "EVPN to leaf8"
peer_group: ["10.0.250.1", "evpn"]
remote_asn: ["65004"]
peer_device: ["leaf8"]
# ============================================================
# Spine2 sessions
# ============================================================
# Spine2 underlay
- bgp_config: ["spine2"]
peer_address: "10.0.2.1"
description: "underlay to leaf1"
remote_asn: ["65001"]
peer_device: ["leaf1"]
- bgp_config: ["spine2"]
peer_address: "10.0.2.3"
description: "underlay to leaf2"
remote_asn: ["65001"]
peer_device: ["leaf2"]
- bgp_config: ["spine2"]
peer_address: "10.0.2.5"
description: "underlay to leaf3"
remote_asn: ["65002"]
peer_device: ["leaf3"]
- bgp_config: ["spine2"]
peer_address: "10.0.2.7"
description: "underlay to leaf4"
remote_asn: ["65002"]
peer_device: ["leaf4"]
- bgp_config: ["spine2"]
peer_address: "10.0.2.9"
description: "underlay to leaf5"
remote_asn: ["65003"]
peer_device: ["leaf5"]
- bgp_config: ["spine2"]
peer_address: "10.0.2.11"
description: "underlay to leaf6"
remote_asn: ["65003"]
peer_device: ["leaf6"]
- bgp_config: ["spine2"]
peer_address: "10.0.2.13"
description: "underlay to leaf7"
remote_asn: ["65004"]
peer_device: ["leaf7"]
- bgp_config: ["spine2"]
peer_address: "10.0.2.15"
description: "underlay to leaf8"
remote_asn: ["65004"]
peer_device: ["leaf8"]
# Spine2 EVPN
- bgp_config: ["spine2"]
peer_address: "10.0.250.11"
description: "EVPN to leaf1"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65001"]
peer_device: ["leaf1"]
- bgp_config: ["spine2"]
peer_address: "10.0.250.12"
description: "EVPN to leaf2"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65001"]
peer_device: ["leaf2"]
- bgp_config: ["spine2"]
peer_address: "10.0.250.13"
description: "EVPN to leaf3"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65002"]
peer_device: ["leaf3"]
- bgp_config: ["spine2"]
peer_address: "10.0.250.14"
description: "EVPN to leaf4"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65002"]
peer_device: ["leaf4"]
- bgp_config: ["spine2"]
peer_address: "10.0.250.15"
description: "EVPN to leaf5"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65003"]
peer_device: ["leaf5"]
- bgp_config: ["spine2"]
peer_address: "10.0.250.16"
description: "EVPN to leaf6"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65003"]
peer_device: ["leaf6"]
- bgp_config: ["spine2"]
peer_address: "10.0.250.17"
description: "EVPN to leaf7"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65004"]
peer_device: ["leaf7"]
- bgp_config: ["spine2"]
peer_address: "10.0.250.18"
description: "EVPN to leaf8"
peer_group: ["10.0.250.2", "evpn"]
remote_asn: ["65004"]
peer_device: ["leaf8"]
# ============================================================
# Leaf1 sessions
# ============================================================
- bgp_config: ["leaf1"]
peer_address: "10.0.1.0"
description: "underlay to spine1"
peer_group: ["10.0.250.11", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf1"]
peer_address: "10.0.2.0"
description: "underlay to spine2"
peer_group: ["10.0.250.11", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf1"]
peer_address: "10.0.3.1"
description: "iBGP to leaf2"
peer_group: ["10.0.250.11", "underlay_ibgp"]
peer_device: ["leaf2"]
- bgp_config: ["leaf1"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.11", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf1"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.11", "evpn"]
peer_device: ["spine2"]
# ============================================================
# Leaf2 sessions
# ============================================================
- bgp_config: ["leaf2"]
peer_address: "10.0.1.2"
description: "underlay to spine1"
peer_group: ["10.0.250.12", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf2"]
peer_address: "10.0.2.2"
description: "underlay to spine2"
peer_group: ["10.0.250.12", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf2"]
peer_address: "10.0.3.0"
description: "iBGP to leaf1"
peer_group: ["10.0.250.12", "underlay_ibgp"]
peer_device: ["leaf1"]
- bgp_config: ["leaf2"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.12", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf2"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.12", "evpn"]
peer_device: ["spine2"]
# ============================================================
# Leaf3 sessions
# ============================================================
- bgp_config: ["leaf3"]
peer_address: "10.0.1.4"
description: "underlay to spine1"
peer_group: ["10.0.250.13", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf3"]
peer_address: "10.0.2.4"
description: "underlay to spine2"
peer_group: ["10.0.250.13", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf3"]
peer_address: "10.0.3.3"
description: "iBGP to leaf4"
peer_group: ["10.0.250.13", "underlay_ibgp"]
peer_device: ["leaf4"]
- bgp_config: ["leaf3"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.13", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf3"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.13", "evpn"]
peer_device: ["spine2"]
# ============================================================
# Leaf4 sessions
# ============================================================
- bgp_config: ["leaf4"]
peer_address: "10.0.1.6"
description: "underlay to spine1"
peer_group: ["10.0.250.14", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf4"]
peer_address: "10.0.2.6"
description: "underlay to spine2"
peer_group: ["10.0.250.14", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf4"]
peer_address: "10.0.3.2"
description: "iBGP to leaf3"
peer_group: ["10.0.250.14", "underlay_ibgp"]
peer_device: ["leaf3"]
- bgp_config: ["leaf4"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.14", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf4"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.14", "evpn"]
peer_device: ["spine2"]
# ============================================================
# Leaf5 sessions
# ============================================================
- bgp_config: ["leaf5"]
peer_address: "10.0.1.8"
description: "underlay to spine1"
peer_group: ["10.0.250.15", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf5"]
peer_address: "10.0.2.8"
description: "underlay to spine2"
peer_group: ["10.0.250.15", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf5"]
peer_address: "10.0.3.5"
description: "iBGP to leaf6"
peer_group: ["10.0.250.15", "underlay_ibgp"]
peer_device: ["leaf6"]
- bgp_config: ["leaf5"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.15", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf5"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.15", "evpn"]
peer_device: ["spine2"]
# ============================================================
# Leaf6 sessions
# ============================================================
- bgp_config: ["leaf6"]
peer_address: "10.0.1.10"
description: "underlay to spine1"
peer_group: ["10.0.250.16", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf6"]
peer_address: "10.0.2.10"
description: "underlay to spine2"
peer_group: ["10.0.250.16", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf6"]
peer_address: "10.0.3.4"
description: "iBGP to leaf5"
peer_group: ["10.0.250.16", "underlay_ibgp"]
peer_device: ["leaf5"]
- bgp_config: ["leaf6"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.16", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf6"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.16", "evpn"]
peer_device: ["spine2"]
# ============================================================
# Leaf7 sessions
# ============================================================
- bgp_config: ["leaf7"]
peer_address: "10.0.1.12"
description: "underlay to spine1"
peer_group: ["10.0.250.17", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf7"]
peer_address: "10.0.2.12"
description: "underlay to spine2"
peer_group: ["10.0.250.17", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf7"]
peer_address: "10.0.3.7"
description: "iBGP to leaf8"
peer_group: ["10.0.250.17", "underlay_ibgp"]
peer_device: ["leaf8"]
- bgp_config: ["leaf7"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.17", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf7"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.17", "evpn"]
peer_device: ["spine2"]
# ============================================================
# Leaf8 sessions
# ============================================================
- bgp_config: ["leaf8"]
peer_address: "10.0.1.14"
description: "underlay to spine1"
peer_group: ["10.0.250.18", "underlay"]
peer_device: ["spine1"]
- bgp_config: ["leaf8"]
peer_address: "10.0.2.14"
description: "underlay to spine2"
peer_group: ["10.0.250.18", "underlay"]
peer_device: ["spine2"]
- bgp_config: ["leaf8"]
peer_address: "10.0.3.6"
description: "iBGP to leaf7"
peer_group: ["10.0.250.18", "underlay_ibgp"]
peer_device: ["leaf7"]
- bgp_config: ["leaf8"]
peer_address: "10.0.250.1"
description: "EVPN to spine1"
peer_group: ["10.0.250.18", "evpn"]
peer_device: ["spine1"]
- bgp_config: ["leaf8"]
peer_address: "10.0.250.2"
description: "EVPN to spine2"
peer_group: ["10.0.250.18", "evpn"]
peer_device: ["spine2"]
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraBGPAddressFamily
data:
# ============================================================
# Spine1 address families
# ============================================================
- bgp_config: ["spine1"]
afi: ipv4
safi: unicast
networks:
- ["10.0.250.1/32"]
- bgp_config: ["spine1"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.1", "evpn"]
# ============================================================
# Spine2 address families
# ============================================================
- bgp_config: ["spine2"]
afi: ipv4
safi: unicast
networks:
- ["10.0.250.2/32"]
- bgp_config: ["spine2"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.2", "evpn"]
# ============================================================
# Leaf1 address families
# ============================================================
- bgp_config: ["leaf1"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.11", "underlay"]
- ["10.0.250.11", "underlay_ibgp"]
networks:
- ["10.0.250.11/32"]
- ["10.0.255.11/32"]
- bgp_config: ["leaf1"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.11", "evpn"]
# ============================================================
# Leaf2 address families
# ============================================================
- bgp_config: ["leaf2"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.12", "underlay"]
- ["10.0.250.12", "underlay_ibgp"]
networks:
- ["10.0.250.12/32"]
- ["10.0.255.11/32"]
- bgp_config: ["leaf2"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.12", "evpn"]
# ============================================================
# Leaf3 address families
# ============================================================
- bgp_config: ["leaf3"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.13", "underlay"]
- ["10.0.250.13", "underlay_ibgp"]
networks:
- ["10.0.250.13/32"]
- ["10.0.255.12/32"]
- bgp_config: ["leaf3"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.13", "evpn"]
# ============================================================
# Leaf4 address families
# ============================================================
- bgp_config: ["leaf4"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.14", "underlay"]
- ["10.0.250.14", "underlay_ibgp"]
networks:
- ["10.0.250.14/32"]
- ["10.0.255.12/32"]
- bgp_config: ["leaf4"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.14", "evpn"]
# ============================================================
# Leaf5 address families
# ============================================================
- bgp_config: ["leaf5"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.15", "underlay"]
- ["10.0.250.15", "underlay_ibgp"]
networks:
- ["10.0.250.15/32"]
- ["10.0.255.13/32"]
- bgp_config: ["leaf5"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.15", "evpn"]
# ============================================================
# Leaf6 address families
# ============================================================
- bgp_config: ["leaf6"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.16", "underlay"]
- ["10.0.250.16", "underlay_ibgp"]
networks:
- ["10.0.250.16/32"]
- ["10.0.255.13/32"]
- bgp_config: ["leaf6"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.16", "evpn"]
# ============================================================
# Leaf7 address families
# ============================================================
- bgp_config: ["leaf7"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.17", "underlay"]
- ["10.0.250.17", "underlay_ibgp"]
networks:
- ["10.0.250.17/32"]
- ["10.0.255.14/32"]
- bgp_config: ["leaf7"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.17", "evpn"]
# ============================================================
# Leaf8 address families
# ============================================================
- bgp_config: ["leaf8"]
afi: ipv4
safi: unicast
active_peer_groups:
- ["10.0.250.18", "underlay"]
- ["10.0.250.18", "underlay_ibgp"]
networks:
- ["10.0.250.18/32"]
- ["10.0.255.14/32"]
- bgp_config: ["leaf8"]
afi: evpn
safi: unicast
active_peer_groups:
- ["10.0.250.18", "evpn"]

View File

@@ -1,62 +0,0 @@
# VRFs, Route Targets, VRF Device Assignments
# Depends on: 02-devices, 03-interfaces, 05-vlans-vxlan (VNI 100001)
#
# VRF gold is used on leaf3/4 (VLAN 34) and leaf7/8 (VLAN 78 + border)
# L3VNI 100001 for symmetric IRB
# Route targets: import/export evpn 1:100001
# Per-device RD: <router-id>:1
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraRouteTarget
data:
- target: "1:100001"
description: VRF gold EVPN route target
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraVRF
data:
- name: gold
description: VRF gold - L3 VXLAN with symmetric IRB
l3vni: ["100001"]
import_targets:
- ["1:100001"]
export_targets:
- ["1:100001"]
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraVRFDeviceAssignment
data:
- device: ["leaf3"]
vrf: ["gold"]
route_distinguisher: "10.0.250.13:1"
import_targets:
- ["1:100001"]
export_targets:
- ["1:100001"]
- device: ["leaf4"]
vrf: ["gold"]
route_distinguisher: "10.0.250.14:1"
import_targets:
- ["1:100001"]
export_targets:
- ["1:100001"]
- device: ["leaf7"]
vrf: ["gold"]
route_distinguisher: "10.0.250.17:1"
import_targets:
- ["1:100001"]
export_targets:
- ["1:100001"]
- device: ["leaf8"]
vrf: ["gold"]
route_distinguisher: "10.0.250.18:1"
import_targets:
- ["1:100001"]
export_targets:
- ["1:100001"]

View File

@@ -1,134 +0,0 @@
# MLAG: Domains and Peer Configs
# Depends on: 02-devices, 03-interfaces (Vlan4090, Port-Channel999), 05-vlans-vxlan (VLAN 4090/4091)
#
# All 4 MLAG pairs share domain-id "leafs" and virtual-mac c001.cafe.babe
# but each is a separate MlagDomain object linking two devices.
# MLAG peer VLAN: 4090, MLAG iBGP VLAN: 4091
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraMlagDomain
data:
# MLAG pair 1: leaf1 + leaf2
- domain_id: leafs-1-2
description: MLAG domain for leaf1/leaf2 pair
virtual_mac: "c001.cafe.babe"
heartbeat_vrf: mgmt
dual_primary_detection: true
dual_primary_delay: 10
dual_primary_action: errdisable
devices:
- ["leaf1"]
- ["leaf2"]
peer_vlan: ["4090"]
ibgp_vlan: ["4091"]
# MLAG pair 2: leaf3 + leaf4
- domain_id: leafs-3-4
description: MLAG domain for leaf3/leaf4 pair
virtual_mac: "c001.cafe.babe"
heartbeat_vrf: mgmt
dual_primary_detection: true
dual_primary_delay: 10
dual_primary_action: errdisable
devices:
- ["leaf3"]
- ["leaf4"]
peer_vlan: ["4090"]
ibgp_vlan: ["4091"]
# MLAG pair 3: leaf5 + leaf6
- domain_id: leafs-5-6
description: MLAG domain for leaf5/leaf6 pair
virtual_mac: "c001.cafe.babe"
heartbeat_vrf: mgmt
dual_primary_detection: true
dual_primary_delay: 10
dual_primary_action: errdisable
devices:
- ["leaf5"]
- ["leaf6"]
peer_vlan: ["4090"]
ibgp_vlan: ["4091"]
# MLAG pair 4: leaf7 + leaf8
- domain_id: leafs-7-8
description: MLAG domain for leaf7/leaf8 pair
virtual_mac: "c001.cafe.babe"
heartbeat_vrf: mgmt
dual_primary_detection: true
dual_primary_delay: 10
dual_primary_action: errdisable
devices:
- ["leaf7"]
- ["leaf8"]
peer_vlan: ["4090"]
ibgp_vlan: ["4091"]
---
apiVersion: infrahub.app/v1
kind: Object
spec:
kind: InfraMlagPeerConfig
data:
# Leaf1 MLAG peer config
- device: ["leaf1"]
mlag_domain: ["leafs-1-2"]
local_interface_ip: "10.0.199.254/31"
peer_address: "10.0.199.255"
heartbeat_peer_ip: "172.16.0.50"
local_interface: ["leaf1", "Vlan4090"]
peer_link: ["leaf1", "Port-Channel999"]
# Leaf2 MLAG peer config
- device: ["leaf2"]
mlag_domain: ["leafs-1-2"]
local_interface_ip: "10.0.199.255/31"
peer_address: "10.0.199.254"
heartbeat_peer_ip: "172.16.0.25"
local_interface: ["leaf2", "Vlan4090"]
peer_link: ["leaf2", "Port-Channel999"]
# Leaf3 MLAG peer config
- device: ["leaf3"]
mlag_domain: ["leafs-3-4"]
local_interface_ip: "10.0.199.252/31"
peer_address: "10.0.199.253"
heartbeat_peer_ip: "172.16.0.28"
local_interface: ["leaf3", "Vlan4090"]
peer_link: ["leaf3", "Port-Channel999"]
# Leaf4 MLAG peer config
- device: ["leaf4"]
mlag_domain: ["leafs-3-4"]
local_interface_ip: "10.0.199.253/31"
peer_address: "10.0.199.252"
heartbeat_peer_ip: "172.16.0.27"
local_interface: ["leaf4", "Vlan4090"]
peer_link: ["leaf4", "Port-Channel999"]
# Leaf5 MLAG peer config
- device: ["leaf5"]
mlag_domain: ["leafs-5-6"]
local_interface_ip: "10.0.199.250/31"
peer_address: "10.0.199.251"
heartbeat_peer_ip: "172.16.0.30"
local_interface: ["leaf5", "Vlan4090"]
peer_link: ["leaf5", "Port-Channel999"]
# Leaf6 MLAG peer config
- device: ["leaf6"]
mlag_domain: ["leafs-5-6"]
local_interface_ip: "10.0.199.251/31"
peer_address: "10.0.199.250"
heartbeat_peer_ip: "172.16.0.29"
local_interface: ["leaf6", "Vlan4090"]
peer_link: ["leaf6", "Port-Channel999"]
# Leaf7 MLAG peer config
- device: ["leaf7"]
mlag_domain: ["leafs-7-8"]
local_interface_ip: "10.0.199.248/31"
peer_address: "10.0.199.249"
heartbeat_peer_ip: "172.16.0.32"
local_interface: ["leaf7", "Vlan4090"]
peer_link: ["leaf7", "Port-Channel999"]
# Leaf8 MLAG peer config
- device: ["leaf8"]
mlag_domain: ["leafs-7-8"]
local_interface_ip: "10.0.199.249/31"
peer_address: "10.0.199.248"
heartbeat_peer_ip: "172.16.0.31"
local_interface: ["leaf8", "Vlan4090"]
peer_link: ["leaf8", "Port-Channel999"]

View File

@@ -1,13 +1,14 @@
[project]
name = "fabric-orchestrator"
version = "0.1.0"
description = "Declarative Network Fabric Orchestrator - Terraform-like infrastructure management for Arista EVPN-VXLAN using gNMI, YANG, and NetBox as Source of Truth"
description = "Declarative Network Fabric Orchestrator - Terraform-like infrastructure management for Arista EVPN-VXLAN using gNMI, YANG, and Infrahub as Source of Truth"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"click>=8.1.0",
"pygnmi>=0.8.0",
"pynetbox>=7.5.0",
"infrahub-sdk>=0.16.0",
"pydantic>=2.0",
"rich>=13.0.0",
]
@@ -35,5 +36,7 @@ ignore = ["E501"]
[dependency-groups]
dev = [
"basedpyright>=1.37.4",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.14.10",
]

View File

@@ -1,217 +0,0 @@
# Infrahub Schema for EVPN-VXLAN Fabric
This directory contains the Infrahub schema definitions for modeling an EVPN-VXLAN fabric. The schema is designed to represent the [overlaid.net reference topology](https://overlaid.net/2019/01/27/arista-bgp-evpn-configuration-example/) (2 spines, 8 leafs in 4 MLAG pairs).
## Schema Files
| File | Nodes | Description |
|------|-------|-------------|
| `base.yml` | Device, InterfaceEthernet, InterfaceLoopback, InterfaceVlan, InterfaceLag, IPAddress, Site, Platform | Core infrastructure and generic Interface |
| `bgp.yml` | AutonomousSystem, BGPRouterConfig, BGPPeerGroup, BGPSession, BGPAddressFamily | BGP routing configuration |
| `vlan_vxlan.yml` | VLAN, VNI, VTEP, VlanVniMapping, EVPNInstance | Layer 2 overlay and VXLAN tunneling |
| `vrf.yml` | VRF, RouteTarget, VRFDeviceAssignment | VRF and L3VNI configuration |
| `mlag.yml` | MlagDomain, MlagPeerConfig, MlagInterface | MLAG domain and peer configuration |
| `extensions.yml` | Fabric, UnderlayLink, HostConnection | Fabric topology and connectivity |
## Entity Relationship Diagram
```
┌──────────────┐
│ InfraFabric │
└──────┬───────┘
┌───────────┼───────────┐
▼ ▼ ▼
┌────────────┐ ┌──────────┐ ┌──────────────────┐
│LocationSite│ │InfraAS │ │InfraUnderlayLink │
└────────────┘ └────┬─────┘ │ local/remote: │
│ │ Device,Interface,│
│ │ IPAddress │
┌────────────┘ └──────────────────┘
┌──────────────┐ ┌────────────────┐
│ InfraDevice │◄────────│ InfraPlatform │
└──────┬───────┘ └────────────────┘
┌─────────────┼──────────────┬────────────────┐
▼ ▼ ▼ ▼
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│InfraInterface│ │InfraBGP- │ │InfraVTEP │ │InfraMlagDomain│
│ (generic) │ │RouterCfg │ │ │ │ (2 devices) │
└──────┬──────┘ └────┬─────┘ └─────┬─────┘ └──────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
Subtypes: ┌──────────┐ ┌──────────┐ ┌───────────────┐
- Ethernet │BGPPeer- │ │VlanVni- │ │MlagPeerConfig │
- Loopback │ Group │ │ Mapping │ │ (per device) │
- Vlan └──────────┘ └──────────┘ └───────────────┘
- Lag ┌──────────┐ ┌───────────────┐
│ │BGPSession│ │MlagInterface │
▼ └────┬─────┘ └───────────────┘
┌──────────────┐ │
│InfraIPAddress│ │ optional vrf
└──────────────┘ ▼
┌─────────┐ ┌──────────────┐
│InfraVRF │◄────│InfraRoute- │
└────┬────┘ │ Target │
│ └──────────────┘
┌────────────────┐
│VRFDevice- │
│ Assignment │
│ (per device RD)│
└────────────────┘
Layer 2 / EVPN:
┌──────────┐ ┌──────────┐ ┌──────────────┐
│InfraVLAN │◄──►│ InfraVNI │ │EVPNInstance │
│ │ │(L2/L3) │ │(per device │
│ │ └──────────┘ │ RD/RT) │
└──────────┘ └──────────────┘
```
### Relationship Legend
| Symbol | Meaning |
|--------|---------|
| `Parent` → child | Child lifecycle depends on parent (e.g., Device → Interface) |
| `Component` → child | Owned collection (e.g., VTEP → VlanVniMapping) |
| `Attribute` | Association without ownership (e.g., BGPSession → VRF) |
| `Generic` | Polymorphic (e.g., IPAddress → any Interface subtype) |
### All Relationships
| Source | Relationship | Target | Kind | Cardinality |
|--------|-------------|--------|------|-------------|
| **base.yml** | | | | |
| InfraInterface | `device` | InfraDevice | Parent | one |
| InfraInterface | `ip_addresses` | InfraIPAddress | Generic | many |
| InfraDevice | `site` | LocationSite | Attribute | one (opt) |
| InfraDevice | `platform` | InfraPlatform | Attribute | one (opt) |
| InfraDevice | `asn` | InfraAutonomousSystem | Attribute | one (opt) |
| InfraDevice | `interfaces` | InfraInterface | Component | many |
| InfraDevice | `mlag_domain` | InfraMlagDomain | Attribute | one (opt) |
| InterfaceEthernet | `lag` | InterfaceLag | Attribute | one (opt) |
| InterfaceEthernet | `connected_interface` | InterfaceEthernet | outbound | one (opt) |
| InterfaceVlan | `vlan` | InfraVLAN | Attribute | one (opt) |
| InterfaceLag | `members` | InterfaceEthernet | Component | many |
| InfraIPAddress | `interface` | InfraInterface | Attribute | one (opt) |
| **bgp.yml** | | | | |
| BGPRouterConfig | `device` | InfraDevice | Parent | one |
| BGPRouterConfig | `local_asn` | InfraAutonomousSystem | Attribute | one |
| BGPRouterConfig | `peer_groups` | BGPPeerGroup | Component | many |
| BGPRouterConfig | `sessions` | BGPSession | Component | many |
| BGPPeerGroup | `bgp_config` | BGPRouterConfig | Parent | one |
| BGPPeerGroup | `remote_asn` | InfraAutonomousSystem | Attribute | one (opt) |
| BGPSession | `bgp_config` | BGPRouterConfig | Parent | one |
| BGPSession | `peer_group` | BGPPeerGroup | Attribute | one (opt) |
| BGPSession | `remote_asn` | InfraAutonomousSystem | Attribute | one (opt) |
| BGPSession | `peer_device` | InfraDevice | Attribute | one (opt) |
| BGPSession | `vrf` | InfraVRF | Attribute | one (opt) |
| BGPAddressFamily | `bgp_config` | BGPRouterConfig | Parent | one |
| BGPAddressFamily | `active_peer_groups` | BGPPeerGroup | Attribute | many |
| BGPAddressFamily | `networks` | InfraIPAddress | Attribute | many (opt) |
| **vlan_vxlan.yml** | | | | |
| InfraVLAN | `vni` | InfraVNI | Attribute | one (opt) |
| InfraVLAN | `site` | LocationSite | Attribute | one (opt) |
| InfraVNI | `vlan` | InfraVLAN | Attribute | one (opt) |
| InfraVNI | `vrf` | InfraVRF | Attribute | one (opt) |
| InfraVTEP | `device` | InfraDevice | Parent | one |
| InfraVTEP | `source_interface` | InterfaceLoopback | Attribute | one |
| InfraVTEP | `vlan_vni_mappings` | VlanVniMapping | Component | many |
| VlanVniMapping | `vtep` | InfraVTEP | Parent | one |
| VlanVniMapping | `vlan` | InfraVLAN | Attribute | one |
| VlanVniMapping | `vni` | InfraVNI | Attribute | one |
| EVPNInstance | `device` | InfraDevice | Parent | one |
| EVPNInstance | `vlan` | InfraVLAN | Attribute | one |
| **vrf.yml** | | | | |
| InfraVRF | `l3vni` | InfraVNI | Attribute | one (opt) |
| InfraVRF | `import_targets` | InfraRouteTarget | outbound | many (opt) |
| InfraVRF | `export_targets` | InfraRouteTarget | outbound | many (opt) |
| InfraVRF | `interfaces` | InfraInterface | Attribute | many (opt) |
| VRFDeviceAssignment | `vrf` | InfraVRF | Attribute | one |
| VRFDeviceAssignment | `device` | InfraDevice | Parent | one |
| VRFDeviceAssignment | `import_targets` | InfraRouteTarget | outbound | many (opt) |
| VRFDeviceAssignment | `export_targets` | InfraRouteTarget | outbound | many (opt) |
| **mlag.yml** | | | | |
| MlagDomain | `devices` | InfraDevice | Attribute | many (2) |
| MlagDomain | `peer_vlan` | InfraVLAN | outbound | one |
| MlagDomain | `ibgp_vlan` | InfraVLAN | outbound | one (opt) |
| MlagPeerConfig | `device` | InfraDevice | Parent | one |
| MlagPeerConfig | `mlag_domain` | MlagDomain | Attribute | one |
| MlagPeerConfig | `local_interface` | InterfaceVlan | Attribute | one |
| MlagPeerConfig | `peer_link` | InterfaceLag | Attribute | one |
| MlagInterface | `mlag_domain` | MlagDomain | Attribute | one |
| MlagInterface | `lag_interfaces` | InterfaceLag | Attribute | many (1-2) |
| **extensions.yml** | | | | |
| InfraFabric | `spine_asn` | InfraAutonomousSystem | Attribute | one (opt) |
| InfraFabric | `sites` | LocationSite | Attribute | many (opt) |
| UnderlayLink | `fabric` | InfraFabric | Parent | one |
| UnderlayLink | `local_device` | InfraDevice | outbound | one |
| UnderlayLink | `local_interface` | InterfaceEthernet | outbound | one |
| UnderlayLink | `local_ip_address` | InfraIPAddress | outbound | one |
| UnderlayLink | `remote_device` | InfraDevice | outbound | one |
| UnderlayLink | `remote_interface` | InterfaceEthernet | outbound | one |
| UnderlayLink | `remote_ip_address` | InfraIPAddress | outbound | one |
| HostConnection | `vlans` | InfraVLAN | Attribute | many |
| HostConnection | `mlag_interface` | MlagInterface | Attribute | one (opt) |
| HostConnection | `lag_interface` | InterfaceLag | Attribute | one (opt) |
## Reference Topology Mapping
| Physical | Infrahub Model |
|----------|----------------|
| spine1, spine2 | InfraDevice (role: spine) |
| leaf1-8 | InfraDevice (role: leaf) |
| AS 65000 | InfraAutonomousSystem (spines) |
| AS 65001-65004 | InfraAutonomousSystem (leaf pairs) |
| AS 64999 | InfraAutonomousSystem (border router) |
| VLAN 40, 34, 78, 900 | InfraVLAN + InfraVNI |
| VLAN 4090, 4091 | InfraVLAN (vlan_type: mlag_peer/mlag_ibgp, trunk_groups, stp_enabled: false) |
| VRF gold | InfraVRF + VRFDeviceAssignment (per-device RD) |
| Route targets 1:100001 | InfraRouteTarget |
| leaf1+leaf2 pair | InfraMlagDomain |
| Port-Channel999 | InfraInterfaceLag (peer-link) |
| Port-Channel1 | InfraMlagInterface (host-facing) |
| Ethernet1-12 | InfraInterfaceEthernet |
| Loopback0 | InfraInterfaceLoopback (BGP router-id) |
| Loopback1 | InfraInterfaceLoopback (shared VTEP IP per MLAG pair) |
| Vxlan1 | InfraVTEP |
| Vlan34/78 SVIs | InfraInterfaceVlan (virtual_router_address for anycast gateway) |
| peer groups (underlay, evpn, underlay_ibgp) | InfraBGPPeerGroup |
| BGP sessions (global) | InfraBGPSession (vrf: null) |
| BGP sessions in VRF gold (leaf7/8 → AS 64999) | InfraBGPSession (vrf: gold) |
| spine1/2 p2p links | InfraUnderlayLink (IPAddress relations, not attributes) |
| distance bgp 20 200 200 | BGPRouterConfig (ebgp_distance, ibgp_distance, local_distance) |
## Usage
### Loading the Schema
```bash
infrahubctl schema load schemas/
```
### Validation
```bash
infrahubctl schema check schemas/
```
## Key Design Decisions
1. **Generic Interface**: All interface types inherit from `InfraInterface` generic for polymorphic queries
2. **MLAG as Domain**: MLAG is modeled as a domain containing exactly 2 devices, with per-device config via MlagPeerConfig
3. **BGP Hierarchy**: BGPRouterConfig → PeerGroups → Sessions allows template-based configuration
4. **BGP VRF Sessions**: BGPSession has an optional `vrf` relation (kind: Attribute) to support peering inside a VRF context (#50)
5. **VTEP as single model**: VTEP is the unique VXLAN model (InterfaceVxlan was removed to avoid duplication — #44)
6. **EVPN Instance per VLAN**: Allows device-specific RD/RT while referencing common VLAN/VNI
7. **Per-device scoped IDs**: BGPPeerGroup and BGPSession use `bgp_config__router_id__value` prefix in human_friendly_id for global uniqueness (#43)
8. **UnderlayLink IPs as relations**: local/remote IPs reference InfraIPAddress objects instead of inline attributes to avoid dual source of truth (#47)
9. **VRFDeviceAssignment**: Separates VRF definition (global) from per-device assignment (with device-specific RD/RT overrides)
10. **Anycast gateway**: InterfaceVlan has `virtual_router_address` for `ip virtual-router address` and `autostate` for MLAG SVIs (#46)
## Related Issues
- Parent issue: [#41 — Define Infrahub Schema for EVPN-VXLAN Fabric](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues/41)
- Schema fixes: #43 (unique IDs), #44 (remove duplicate VTEP), #45 (VLAN unique), #46 (anycast gateway), #47 (underlay IPs), #48 (BGP distance), #49 (trunk groups), #50 (VRF BGP sessions)
- Depends on: Schema being loaded before transforms (#30, #31, #32, #33)

View File

@@ -1,347 +0,0 @@
# Base Infrastructure Schema for EVPN-VXLAN Fabric
# This schema defines core infrastructure objects required for fabric orchestration
---
version: "1.0"
generics:
- name: Interface
namespace: Infra
description: Generic interface - parent for all interface types
label: Interface
include_in_menu: false
hierarchical: false
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
description: Interface name (e.g., Ethernet1, Loopback0)
- name: description
kind: Text
optional: true
- name: enabled
kind: Boolean
default_value: true
- name: mtu
kind: Number
optional: true
description: Maximum Transmission Unit
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: ip_addresses
peer: InfraIPAddress
identifier: interface__ip_addresses
cardinality: many
kind: Generic
nodes:
# ================================================================
# Location
# ================================================================
- name: Site
namespace: Location
description: Physical site or data center
label: Site
icon: mingcute--building-4-line
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
- name: description
kind: Text
optional: true
- name: facility
kind: Text
optional: true
description: Facility identifier or code
# ================================================================
# Platform
# ================================================================
- name: Platform
namespace: Infra
description: Device platform/OS (e.g., Arista EOS, Cisco NX-OS)
label: Platform
icon: mdi--chip
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: Platform name (e.g., arista_eos, cisco_nxos)
- name: description
kind: Text
optional: true
- name: napalm_driver
kind: Text
optional: true
description: NAPALM driver name
- name: netmiko_device_type
kind: Text
optional: true
description: Netmiko device type
# ================================================================
# Device
# ================================================================
- name: Device
namespace: Infra
description: Network device (spine, leaf, etc.)
label: Device
icon: mdi--server-network
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: Device hostname
- name: description
kind: Text
optional: true
- name: role
kind: Dropdown
choices:
- name: spine
label: Spine
color: "#3b82f6"
- name: leaf
label: Leaf
color: "#22c55e"
- name: border_leaf
label: Border Leaf
color: "#f59e0b"
description: Fabric role
- name: status
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
color: "#22c55e"
- name: planned
label: Planned
color: "#3b82f6"
- name: maintenance
label: Maintenance
color: "#f59e0b"
- name: decommissioned
label: Decommissioned
color: "#ef4444"
relationships:
- name: site
peer: LocationSite
cardinality: one
optional: true
- name: platform
peer: InfraPlatform
cardinality: one
optional: true
- name: asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
description: BGP Autonomous System
- name: interfaces
peer: InfraInterface
cardinality: many
kind: Component
- name: mlag_domain
peer: InfraMlagDomain
cardinality: one
optional: true
# ================================================================
# Interface Types (inherit from InfraInterface generic)
# ================================================================
- name: InterfaceEthernet
namespace: Infra
description: Physical Ethernet interface
label: Ethernet Interface
icon: mdi--ethernet
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: speed
kind: Dropdown
optional: true
choices:
- name: "1000"
label: 1 Gbps
- name: "10000"
label: 10 Gbps
- name: "25000"
label: 25 Gbps
- name: "40000"
label: 40 Gbps
- name: "100000"
label: 100 Gbps
- name: mode
kind: Dropdown
optional: true
choices:
- name: access
label: Access
- name: trunk
label: Trunk
- name: routed
label: Routed (L3)
description: Switchport mode
relationships:
- name: lag
peer: InfraInterfaceLag
cardinality: one
optional: true
description: Parent LAG interface
- name: connected_interface
peer: InfraInterfaceEthernet
identifier: ethernet_connected_to
direction: outbound
cardinality: one
optional: true
description: Connected peer interface
- name: InterfaceLoopback
namespace: Infra
description: Loopback interface
label: Loopback Interface
icon: mdi--reload
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
- name: InterfaceVlan
namespace: Infra
description: VLAN SVI interface
label: VLAN Interface
icon: mdi--lan
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: virtual_router_address
kind: IPHost
optional: true
description: Anycast gateway IP (ip virtual-router address)
- name: autostate
kind: Boolean
default_value: true
description: "Enable autostate (set false for MLAG peer SVIs)"
relationships:
- name: vlan
peer: InfraVLAN
cardinality: one
optional: true
- name: InterfaceLag
namespace: Infra
description: Link Aggregation (Port-Channel) interface
label: LAG Interface
icon: mdi--link-variant
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: lacp_mode
kind: Dropdown
optional: true
choices:
- name: active
label: Active
- name: passive
label: Passive
- name: static
label: Static (No LACP)
- name: mlag_id
kind: Number
optional: true
description: MLAG interface ID
relationships:
- name: members
peer: InfraInterfaceEthernet
cardinality: many
kind: Component
# ================================================================
# IP Address
# ================================================================
- name: IPAddress
namespace: Infra
description: IP Address assignment
label: IP Address
icon: mdi--ip-network
include_in_menu: false
human_friendly_id:
- address__value
order_by:
- address__value
display_label: "{{ address__value }}"
attributes:
- name: address
kind: IPHost
description: IP address with prefix (e.g., 10.0.1.1/31)
- name: description
kind: Text
optional: true
- name: status
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
color: "#22c55e"
- name: reserved
label: Reserved
color: "#3b82f6"
- name: deprecated
label: Deprecated
color: "#ef4444"
relationships:
- name: interface
peer: InfraInterface
identifier: interface__ip_addresses
cardinality: one
optional: true
kind: Attribute

View File

@@ -1,271 +0,0 @@
# BGP Schema for EVPN-VXLAN Fabric
# Defines Autonomous System, Peer Groups, and BGP Sessions
---
version: "1.0"
nodes:
# ================================================================
# Autonomous System
# ================================================================
- name: AutonomousSystem
namespace: Infra
description: BGP Autonomous System
label: Autonomous System
icon: mdi--cloud-outline
include_in_menu: false
human_friendly_id:
- asn__value
order_by:
- asn__value
display_label: "{{ asn__value }}"
attributes:
- name: asn
kind: Number
unique: true
description: AS Number (e.g., 65000)
- name: description
kind: Text
optional: true
- name: as_type
kind: Dropdown
default_value: private
choices:
- name: private
label: Private
- name: public
label: Public
# ================================================================
# BGP Router Configuration (per device)
# ================================================================
- name: BGPRouterConfig
namespace: Infra
description: BGP router configuration on a device
label: BGP Router Config
icon: mdi--router-wireless
include_in_menu: false
human_friendly_id:
- device__name__value
display_label: "{{ router_id__value }}"
attributes:
- name: router_id
kind: IPHost
unique: true
description: BGP Router ID
- name: default_ipv4_unicast
kind: Boolean
default_value: false
description: Enable default IPv4 unicast
- name: log_neighbor_changes
kind: Boolean
default_value: true
- name: ecmp_max_paths
kind: Number
default_value: 4
description: Maximum ECMP paths
- name: ecmp_max_ecmp
kind: Number
default_value: 64
description: Maximum ECMP routes
- name: ebgp_distance
kind: Number
default_value: 20
description: eBGP administrative distance
- name: ibgp_distance
kind: Number
default_value: 200
description: iBGP administrative distance
- name: local_distance
kind: Number
default_value: 200
description: Local route administrative distance
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: local_asn
peer: InfraAutonomousSystem
cardinality: one
- name: peer_groups
peer: InfraBGPPeerGroup
cardinality: many
kind: Component
- name: sessions
peer: InfraBGPSession
cardinality: many
kind: Component
# ================================================================
# BGP Peer Group
# ================================================================
- name: BGPPeerGroup
namespace: Infra
description: BGP peer group template
label: BGP Peer Group
icon: mdi--account-group
include_in_menu: false
uniqueness_constraints:
- ["bgp_config", "name__value"]
human_friendly_id:
- bgp_config__router_id__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
description: Peer group name (e.g., underlay, evpn)
- name: description
kind: Text
optional: true
- name: update_source
kind: Text
optional: true
description: Update source interface (e.g., Loopback0)
- name: ebgp_multihop
kind: Number
optional: true
description: eBGP multihop TTL
- name: send_community
kind: Dropdown
default_value: none
choices:
- name: none
label: None
- name: standard
label: Standard
- name: extended
label: Extended
- name: both
label: Both
- name: next_hop_self
kind: Boolean
default_value: false
- name: next_hop_unchanged
kind: Boolean
default_value: false
description: Keep next-hop unchanged (for route reflector)
- name: maximum_routes
kind: Number
optional: true
- name: maximum_routes_warning_only
kind: Boolean
default_value: true
- name: peer_group_type
kind: Dropdown
default_value: underlay
choices:
- name: underlay
label: Underlay IPv4
- name: underlay_ibgp
label: Underlay iBGP
- name: evpn
label: EVPN Overlay
relationships:
- name: bgp_config
peer: InfraBGPRouterConfig
cardinality: one
kind: Parent
optional: false
- name: remote_asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
# ================================================================
# BGP Session (Neighbor)
# ================================================================
- name: BGPSession
namespace: Infra
description: BGP neighbor session
label: BGP Session
icon: mdi--connection
include_in_menu: false
uniqueness_constraints:
- ["bgp_config", "peer_address__value"]
human_friendly_id:
- bgp_config__router_id__value
- peer_address__value
display_label: "{{ peer_address__value }}"
attributes:
- name: peer_address
kind: IPHost
description: Neighbor IP address
- name: description
kind: Text
optional: true
- name: enabled
kind: Boolean
default_value: true
relationships:
- name: bgp_config
peer: InfraBGPRouterConfig
cardinality: one
kind: Parent
optional: false
- name: peer_group
peer: InfraBGPPeerGroup
cardinality: one
optional: true
- name: remote_asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
description: Override peer group remote-as
- name: peer_device
peer: InfraDevice
cardinality: one
optional: true
description: Remote peer device (for documentation)
- name: vrf
peer: InfraVRF
cardinality: one
kind: Attribute
optional: true
description: VRF context for this session (null = global BGP process)
# ================================================================
# BGP Address Family Configuration
# ================================================================
- name: BGPAddressFamily
namespace: Infra
description: BGP address family configuration
label: BGP Address Family
icon: mdi--format-list-bulleted
include_in_menu: false
display_label: "{{ afi__value }}"
attributes:
- name: afi
kind: Dropdown
choices:
- name: ipv4
label: IPv4
- name: ipv6
label: IPv6
- name: evpn
label: EVPN
description: Address Family Identifier
- name: safi
kind: Dropdown
default_value: unicast
choices:
- name: unicast
label: Unicast
- name: multicast
label: Multicast
description: Sub Address Family Identifier
relationships:
- name: bgp_config
peer: InfraBGPRouterConfig
cardinality: one
kind: Parent
optional: false
- name: active_peer_groups
peer: InfraBGPPeerGroup
cardinality: many
description: Peer groups activated in this AF
- name: networks
peer: InfraIPAddress
cardinality: many
optional: true
description: Networks to advertise

View File

@@ -1,164 +0,0 @@
# Extensions Schema for EVPN-VXLAN Fabric
# Custom attributes and fabric-specific configurations
---
version: "1.0"
nodes:
# ================================================================
# Fabric (Top-level container for all fabric objects)
# ================================================================
- name: Fabric
namespace: Infra
description: EVPN-VXLAN Fabric definition
label: Fabric
icon: mdi--vector-polygon
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: Fabric name (e.g., arista-evpn-fabric)
- name: description
kind: Text
optional: true
- name: underlay_protocol
kind: Dropdown
default_value: ebgp
choices:
- name: ebgp
label: eBGP
- name: ospf
label: OSPF
- name: isis
label: IS-IS
- name: overlay_protocol
kind: Dropdown
default_value: evpn
choices:
- name: evpn
label: EVPN
- name: ingress_replication
label: Ingress Replication
- name: anycast_gateway_mac
kind: Text
optional: true
description: "Shared MAC for anycast gateway (format: xxxx.xxxx.xxxx)"
relationships:
- name: spine_asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
description: AS used by spine layer
- name: sites
peer: LocationSite
cardinality: many
optional: true
# ================================================================
# Underlay P2P Link
# ================================================================
- name: UnderlayLink
namespace: Infra
description: Point-to-point underlay link between devices
label: Underlay Link
icon: mdi--cable-data
include_in_menu: false
display_label: "{{ description__value }}"
attributes:
- name: description
kind: Text
description: Link description (e.g., spine1:eth1 <-> leaf1:eth11)
- name: mtu
kind: Number
default_value: 9214
relationships:
- name: fabric
peer: InfraFabric
cardinality: one
kind: Parent
optional: false
- name: local_device
peer: InfraDevice
identifier: underlay_link_local_device
cardinality: one
direction: outbound
- name: local_interface
peer: InfraInterfaceEthernet
identifier: underlay_link_local_interface
cardinality: one
direction: outbound
- name: local_ip_address
peer: InfraIPAddress
identifier: underlay_link_local_ip
cardinality: one
direction: outbound
- name: remote_device
peer: InfraDevice
identifier: underlay_link_remote_device
cardinality: one
direction: outbound
- name: remote_interface
peer: InfraInterfaceEthernet
identifier: underlay_link_remote_interface
cardinality: one
direction: outbound
- name: remote_ip_address
peer: InfraIPAddress
identifier: underlay_link_remote_ip
cardinality: one
direction: outbound
# ================================================================
# Host Connection
# ================================================================
- name: HostConnection
namespace: Infra
description: Host connection to fabric (single or dual-homed)
label: Host Connection
icon: mdi--desktop-tower
include_in_menu: false
human_friendly_id:
- hostname__value
display_label: "{{ hostname__value }}"
attributes:
- name: hostname
kind: Text
description: Connected host name
- name: description
kind: Text
optional: true
- name: connection_type
kind: Dropdown
default_value: dual_homed
choices:
- name: single_homed
label: Single-Homed
- name: dual_homed
label: Dual-Homed (MLAG)
- name: lacp_mode
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
- name: passive
label: Passive
relationships:
- name: vlans
peer: InfraVLAN
cardinality: many
description: VLANs allowed on this connection
- name: mlag_interface
peer: InfraMlagInterface
cardinality: one
optional: true
description: MLAG interface for dual-homed
- name: lag_interface
peer: InfraInterfaceLag
cardinality: one
optional: true
description: LAG for single-homed

View File

@@ -1,141 +0,0 @@
# MLAG Schema for EVPN-VXLAN Fabric
# Defines MLAG domain and peer configuration
---
version: "1.0"
nodes:
# ================================================================
# MLAG Domain
# ================================================================
- name: MlagDomain
namespace: Infra
description: MLAG domain configuration for leaf pair
label: MLAG Domain
icon: mdi--link-variant
include_in_menu: false
human_friendly_id:
- domain_id__value
display_label: "{{ domain_id__value }}"
attributes:
- name: domain_id
kind: Text
description: MLAG domain identifier (e.g., leafs)
- name: description
kind: Text
optional: true
- name: virtual_mac
kind: Text
description: "Shared virtual MAC (format: xxxx.xxxx.xxxx)"
- name: heartbeat_vrf
kind: Text
default_value: mgmt
description: VRF for heartbeat (typically mgmt)
- name: dual_primary_detection
kind: Boolean
default_value: true
- name: dual_primary_delay
kind: Number
default_value: 10
description: Delay in seconds before dual-primary action
- name: dual_primary_action
kind: Dropdown
default_value: errdisable
choices:
- name: errdisable
label: Error Disable Interfaces
- name: none
label: No Action
relationships:
- name: devices
peer: InfraDevice
cardinality: many
min_count: 2
max_count: 2
description: MLAG peer devices
- name: peer_vlan
peer: InfraVLAN
identifier: mlag_domain_peer_vlan
cardinality: one
direction: outbound
description: VLAN for MLAG peer-link control traffic
- name: ibgp_vlan
peer: InfraVLAN
identifier: mlag_domain_ibgp_vlan
cardinality: one
direction: outbound
optional: true
description: VLAN for iBGP peering between MLAG peers
# ================================================================
# MLAG Peer Configuration (per device)
# ================================================================
- name: MlagPeerConfig
namespace: Infra
description: MLAG configuration on a specific device
label: MLAG Peer Config
icon: mdi--server-network
include_in_menu: false
human_friendly_id:
- device__name__value
display_label: "{{ local_interface_ip__value }}"
attributes:
- name: local_interface_ip
kind: IPNetwork
description: IP on MLAG peer VLAN SVI
- name: peer_address
kind: IPHost
description: Peer's MLAG SVI IP address
- name: heartbeat_peer_ip
kind: IPHost
description: Peer's management IP for heartbeat
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: mlag_domain
peer: InfraMlagDomain
cardinality: one
- name: local_interface
peer: InfraInterfaceVlan
cardinality: one
description: Local MLAG SVI
- name: peer_link
peer: InfraInterfaceLag
cardinality: one
description: Peer-link port-channel
# ================================================================
# MLAG Interface (MLAG-enabled LAG)
# ================================================================
- name: MlagInterface
namespace: Infra
description: MLAG interface configuration
label: MLAG Interface
icon: mdi--ethernet-cable
include_in_menu: false
display_label: "{{ mlag_id__value }}"
attributes:
- name: mlag_id
kind: Number
description: MLAG interface ID
- name: description
kind: Text
optional: true
- name: lacp_fallback_timeout
kind: Number
default_value: 5
description: LACP fallback timeout in seconds
- name: lacp_fallback_individual
kind: Boolean
default_value: true
relationships:
- name: mlag_domain
peer: InfraMlagDomain
cardinality: one
- name: lag_interfaces
peer: InfraInterfaceLag
cardinality: many
min_count: 1
max_count: 2
description: LAG interfaces on each MLAG peer

View File

@@ -1,221 +0,0 @@
# VLAN and VXLAN Schema for EVPN-VXLAN Fabric
# Defines VLAN, VNI mappings, and VTEP configuration
---
version: "1.0"
nodes:
# ================================================================
# VLAN
# ================================================================
- name: VLAN
namespace: Infra
description: Virtual LAN configuration
label: VLAN
icon: mdi--lan-connect
include_in_menu: false
human_friendly_id:
- vlan_id__value
order_by:
- vlan_id__value
display_label: "{{ vlan_id__value }} ({{ name__value }})"
attributes:
- name: vlan_id
kind: Number
unique: true
description: VLAN ID (1-4094)
- name: name
kind: Text
description: VLAN name
- name: description
kind: Text
optional: true
- name: status
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
color: "#22c55e"
- name: reserved
label: Reserved
color: "#3b82f6"
- name: deprecated
label: Deprecated
color: "#ef4444"
- name: vlan_type
kind: Dropdown
default_value: standard
choices:
- name: standard
label: Standard
- name: mlag_peer
label: MLAG Peer
- name: mlag_ibgp
label: MLAG iBGP
description: VLAN purpose
- name: trunk_groups
kind: List
optional: true
description: "Trunk groups restricting VLAN propagation (e.g., mlag-peer)"
- name: stp_enabled
kind: Boolean
default_value: true
description: "Enable spanning-tree on this VLAN (set false for MLAG peer VLANs)"
relationships:
- name: vni
peer: InfraVNI
cardinality: one
optional: true
description: Associated L2VNI for VXLAN
- name: site
peer: LocationSite
cardinality: one
optional: true
# ================================================================
# VNI (VXLAN Network Identifier)
# ================================================================
- name: VNI
namespace: Infra
description: VXLAN Network Identifier
label: VNI
icon: mdi--tunnel-outline
include_in_menu: false
human_friendly_id:
- vni__value
order_by:
- vni__value
display_label: "{{ vni__value }}"
attributes:
- name: vni
kind: Number
unique: true
description: VNI value (1-16777215)
- name: description
kind: Text
optional: true
- name: vni_type
kind: Dropdown
default_value: l2vni
choices:
- name: l2vni
label: L2 VNI
color: "#3b82f6"
- name: l3vni
label: L3 VNI
color: "#22c55e"
description: VNI type for EVPN
relationships:
- name: vlan
peer: InfraVLAN
cardinality: one
optional: true
description: Associated VLAN for L2VNI
- name: vrf
peer: InfraVRF
cardinality: one
optional: true
description: Associated VRF for L3VNI
# ================================================================
# VTEP (VXLAN Tunnel Endpoint)
# ================================================================
- name: VTEP
namespace: Infra
description: VXLAN Tunnel Endpoint configuration
label: VTEP
icon: mdi--server-network-outline
include_in_menu: false
human_friendly_id:
- device__name__value
display_label: "{{ source_address__value }}"
attributes:
- name: source_address
kind: IPHost
description: VTEP source IP (typically from Loopback1)
- name: udp_port
kind: Number
default_value: 4789
- name: learn_restrict
kind: Dropdown
default_value: any
choices:
- name: any
label: Any
- name: flood
label: Flood
description: MAC learning restriction mode
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: source_interface
peer: InfraInterfaceLoopback
cardinality: one
description: Source interface for VTEP
- name: vlan_vni_mappings
peer: InfraVlanVniMapping
cardinality: many
kind: Component
# ================================================================
# VLAN to VNI Mapping (per-device)
# ================================================================
- name: VlanVniMapping
namespace: Infra
description: VLAN to VNI mapping on a VTEP
label: VLAN-VNI Mapping
icon: mdi--swap-horizontal
include_in_menu: false
display_label: "{{ description__value }}"
attributes:
- name: description
kind: Text
optional: true
description: "Mapping description (e.g., VLAN 40 <-> VNI 10040)"
relationships:
- name: vtep
peer: InfraVTEP
cardinality: one
kind: Parent
optional: false
- name: vlan
peer: InfraVLAN
cardinality: one
- name: vni
peer: InfraVNI
cardinality: one
# ================================================================
# EVPN Instance (per VLAN)
# ================================================================
- name: EVPNInstance
namespace: Infra
description: EVPN instance configuration (route targets for L2 extension)
label: EVPN Instance
icon: mdi--cloud-sync
include_in_menu: false
display_label: "{{ route_distinguisher__value }}"
attributes:
- name: route_distinguisher
kind: Text
description: "Route Distinguisher (format: ASN:VNI or IP:VNI)"
- name: route_target_import
kind: Text
description: "Import Route Target (format: VNI:VNI)"
- name: route_target_export
kind: Text
description: "Export Route Target (format: VNI:VNI)"
- name: redistribute_learned
kind: Boolean
default_value: true
relationships:
- name: vlan
peer: InfraVLAN
cardinality: one
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false

View File

@@ -1,124 +0,0 @@
# VRF Schema for EVPN-VXLAN Fabric
# Defines VRF and Route Target configuration for L3 VPN
---
version: "1.0"
nodes:
# ================================================================
# VRF (Virtual Routing and Forwarding)
# ================================================================
- name: VRF
namespace: Infra
description: Virtual Routing and Forwarding instance
label: VRF
icon: mdi--router
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: VRF name
- name: description
kind: Text
optional: true
- name: route_distinguisher
kind: Text
optional: true
description: "Route Distinguisher (format: ASN:NN or IP:NN)"
- name: vrf_id
kind: Number
optional: true
description: VRF table ID
relationships:
- name: l3vni
peer: InfraVNI
cardinality: one
optional: true
description: L3 VNI for symmetric IRB
- name: import_targets
peer: InfraRouteTarget
identifier: vrf_import_targets
cardinality: many
direction: outbound
optional: true
- name: export_targets
peer: InfraRouteTarget
identifier: vrf_export_targets
cardinality: many
direction: outbound
optional: true
- name: interfaces
peer: InfraInterface
cardinality: many
optional: true
description: Interfaces assigned to this VRF
# ================================================================
# Route Target
# ================================================================
- name: RouteTarget
namespace: Infra
description: BGP Route Target for VPN import/export
label: Route Target
icon: mdi--target
include_in_menu: false
human_friendly_id:
- target__value
order_by:
- target__value
display_label: "{{ target__value }}"
attributes:
- name: target
kind: Text
unique: true
description: "Route Target value (format: ASN:NN or IP:NN)"
- name: description
kind: Text
optional: true
# ================================================================
# VRF Device Assignment
# ================================================================
- name: VRFDeviceAssignment
namespace: Infra
description: VRF assignment to a specific device
label: VRF Assignment
icon: mdi--router-network
include_in_menu: false
human_friendly_id:
- device__name__value
- vrf__name__value
display_label: "{{ device__name__value }} - {{ vrf__name__value }}"
attributes:
- name: route_distinguisher
kind: Text
optional: true
description: "Device-specific RD (overrides VRF default)"
relationships:
- name: vrf
peer: InfraVRF
cardinality: one
optional: false
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: import_targets
peer: InfraRouteTarget
identifier: vrf_assignment_import_targets
cardinality: many
direction: outbound
optional: true
description: Device-specific import RTs
- name: export_targets
peer: InfraRouteTarget
identifier: vrf_assignment_export_targets
cardinality: many
direction: outbound
optional: true
description: Device-specific export RTs

282
src/infrahub/README.md Normal file
View File

@@ -0,0 +1,282 @@
# Infrahub Client Module
This module provides an async client for querying fabric intent data from a remote
[Infrahub](https://docs.infrahub.app/) instance. It wraps `infrahub-sdk` with structured
Pydantic model responses, TTL-based caching, and a typed exception hierarchy.
It is used by the Reconciler and Prefect flows as the single entry point to the
Source of Truth.
## Features
- Async context manager support (`async with FabricInfrahubClient(...) as client`)
- Typed, immutable Pydantic v2 response models (frozen, validated)
- TTL-based in-memory cache (60 s) to avoid redundant SDK queries per client instance
- Structured exception hierarchy for fine-grained error handling
- Branch selection — query any Infrahub branch (default: `main`)
- Full coverage of the fabric schema: devices, VLANs, BGP, VRFs, VTEP, EVPN, MLAG
## Installation
Ensure the required dependencies are installed:
```bash
uv add "infrahub-sdk>=0.16.0" "pydantic>=2.0"
```
Or via `pyproject.toml`:
```toml
dependencies = [
"infrahub-sdk>=0.16.0",
"pydantic>=2.0",
]
```
## Usage
### Basic connection
```python
import asyncio
from src.infrahub import FabricInfrahubClient
async def main():
async with FabricInfrahubClient(
url="http://infrahub:8080",
api_token="your-api-token",
branch="main", # optional, default: "main"
) as client:
device = await client.get_device("leaf1")
print(device.name, device.role, device.asn)
asyncio.run(main())
```
### `get_device()` — fetch a device intent
```python
from src.infrahub import FabricInfrahubClient, InfrahubNotFoundError
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="tok") as client:
device = await client.get_device("leaf1")
# DeviceIntent(name='leaf1', role='leaf', status='active',
# platform='EOS', site='dc1', asn=65001)
print(f"{device.name} — role={device.role}, ASN={device.asn}")
```
### `get_device_vlans()` — fetch VLANs for a device
VLANs are resolved via the device's VTEP `vlan_vni_mappings`. If the device has
no VTEP, the fallback resolves VLANs through its SVI interfaces.
```python
vlans = await client.get_device_vlans("leaf1")
for vlan in vlans:
# VlanIntent(vlan_id=10, name='PROD', status='active',
# vlan_type='standard', vni=10010, stp_enabled=True)
print(f"VLAN {vlan.vlan_id} — VNI {vlan.vni}, type={vlan.vlan_type}")
```
### `get_device_bgp_config()` — fetch BGP router configuration
```python
bgp = await client.get_device_bgp_config("leaf1")
# BgpRouterConfigIntent(router_id='10.0.0.1', local_asn=65001,
# default_ipv4_unicast=True, ecmp_max_paths=4)
print(f"Router-ID: {bgp.router_id}, ASN: {bgp.local_asn}, ECMP: {bgp.ecmp_max_paths}")
```
### `get_device_bgp_peer_groups()` — fetch BGP peer groups
```python
peer_groups = await client.get_device_bgp_peer_groups("leaf1")
for pg in peer_groups:
# BgpPeerGroupIntent(name='EVPN-PEERS', peer_group_type='evpn',
# remote_asn=65000, update_source='Loopback0',
# send_community='extended', ebgp_multihop=3,
# next_hop_unchanged=True)
print(f"{pg.name} — type={pg.peer_group_type}, remote_asn={pg.remote_asn}")
```
### `get_device_bgp_sessions()` — fetch BGP sessions
```python
sessions = await client.get_device_bgp_sessions("leaf1")
for sess in sessions:
# BgpSessionIntent(peer_address='10.0.0.2', description='to-spine1',
# enabled=True, peer_group='UNDERLAY', remote_asn=65000)
print(f"{sess.peer_address} — group={sess.peer_group}, enabled={sess.enabled}")
```
### `get_device_vrfs()` — fetch VRFs assigned to a device
VRFs are resolved via `InfraVRFDeviceAssignment` (device-specific RD + route targets).
```python
vrfs = await client.get_device_vrfs("leaf1")
for vrf in vrfs:
# VrfIntent(name='PROD', route_distinguisher='10.0.0.1:100',
# vrf_id=100, l3vni=10000,
# import_targets=['65000:100'], export_targets=['65000:100'])
print(f"VRF {vrf.name} — RD={vrf.route_distinguisher}, L3VNI={vrf.l3vni}")
print(f" import: {vrf.import_targets}")
print(f" export: {vrf.export_targets}")
```
### `get_device_vtep()` — fetch VTEP configuration
Returns `None` if the device has no VTEP.
```python
vtep = await client.get_device_vtep("leaf1")
if vtep:
# VtepIntent(source_address='10.0.0.1', udp_port=4789,
# learn_restrict=False, vlan_vni_mappings=[(10, 10010), (20, 10020)])
print(f"VTEP source: {vtep.source_address}, port: {vtep.udp_port}")
for vlan_id, vni in vtep.vlan_vni_mappings:
print(f" VLAN {vlan_id} → VNI {vni}")
```
### `get_device_evpn_instances()` — fetch EVPN instances
```python
evpn_instances = await client.get_device_evpn_instances("leaf1")
for ev in evpn_instances:
# EvpnInstanceIntent(route_distinguisher='10.0.0.1:10',
# route_target_import='65000:10',
# route_target_export='65000:10',
# redistribute_learned=True, vlan_id=10)
print(f"EVPN VLAN {ev.vlan_id} — RD={ev.route_distinguisher}")
```
### `get_mlag_domain()` — fetch MLAG domain
Returns `None` if the device is not part of an MLAG domain.
```python
domain = await client.get_mlag_domain("leaf1")
if domain:
# MlagDomainIntent(domain_id='1', virtual_mac='00:1c:73:00:00:01',
# heartbeat_vrf='MGMT', dual_primary_detection=True,
# dual_primary_delay=10, dual_primary_action='errdisable',
# peer_devices=['leaf1', 'leaf2'])
peer = [d for d in domain.peer_devices if d != "leaf1"][0]
print(f"MLAG domain {domain.domain_id} — peer: {peer}")
```
### `get_mlag_peer_config()` — fetch MLAG peer configuration
Returns `None` if no MLAG peer config exists for this device.
```python
peer_cfg = await client.get_mlag_peer_config("leaf1")
if peer_cfg:
# MlagPeerConfigIntent(local_interface_ip='10.255.255.0/31',
# peer_address='10.255.255.1',
# heartbeat_peer_ip='192.168.0.2',
# peer_link='Port-Channel1')
print(f"Local IP: {peer_cfg.local_interface_ip}")
print(f"Peer: {peer_cfg.peer_address} via {peer_cfg.peer_link}")
```
### Querying a specific branch
```python
async with FabricInfrahubClient(
url="http://infrahub:8080",
api_token="tok",
branch="proposed-change",
) as client:
device = await client.get_device("leaf1")
```
## Pydantic Models
All models are **frozen** (`model_config = ConfigDict(frozen=True)`) — instances are
immutable and hashable.
| Model | Description |
|---|---|
| `DeviceIntent` | Network device — name, role, status, platform, site, ASN |
| `VlanIntent` | VLAN — vlan_id, name, status, vlan_type, VNI (if any), STP |
| `VniIntent` | VXLAN Network Identifier — vni, vni_type, description |
| `BgpRouterConfigIntent` | BGP router config — router_id, local ASN, ECMP, default IPv4 unicast |
| `BgpPeerGroupIntent` | BGP peer group — name, type, remote ASN, update-source, community, multihop |
| `BgpSessionIntent` | BGP session — peer address, description, enabled, peer group, remote ASN |
| `VrfIntent` | VRF — name, RD, VRF ID, L3VNI, import/export route targets |
| `VtepIntent` | VTEP — source address, UDP port, learn-restrict, VLAN→VNI mappings |
| `MlagDomainIntent` | MLAG domain — domain ID, virtual MAC, heartbeat VRF, dual-primary settings |
| `MlagPeerConfigIntent` | MLAG peer config — local IP, peer address, heartbeat IP, peer-link interface |
| `EvpnInstanceIntent` | EVPN instance — RD, RT import/export, redistribute-learned, VLAN ID |
## Error Handling
```python
from src.infrahub import (
FabricInfrahubClient,
InfrahubClientError,
InfrahubConnectionError,
InfrahubNotFoundError,
InfrahubQueryError,
)
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="tok") as client:
try:
device = await client.get_device("unknown-device")
except InfrahubNotFoundError as e:
# Device does not exist in Infrahub
print(f"Not found: {e}")
except InfrahubQueryError as e:
# SDK query failed (network error, schema mismatch, etc.)
print(f"Query error: {e}")
except InfrahubConnectionError as e:
# Could not reach the Infrahub instance
print(f"Connection error: {e}")
except InfrahubClientError as e:
# Catch-all for any other client error
print(f"Infrahub error: {e}")
```
### Exception hierarchy
```
InfrahubClientError ← base for all client errors
├── InfrahubConnectionError ← raised when the instance is unreachable
├── InfrahubQueryError ← raised when an SDK query fails or returns unexpected data
└── InfrahubNotFoundError ← raised when a requested node does not exist
```
## Environment Variables
The `infrahub-sdk` library reads the following environment variables as defaults.
You can set them instead of passing values explicitly to `FabricInfrahubClient`.
| Variable | Description | Default |
|---|---|---|
| `INFRAHUB_ADDRESS` | Base URL of the Infrahub instance | — |
| `INFRAHUB_API_TOKEN` | API token for authentication | — |
| `INFRAHUB_DEFAULT_BRANCH` | Branch to query when none is specified | `main` |
> **Note:** `FabricInfrahubClient.__init__` always passes `url`, `api_token`, and `branch`
> explicitly to `infrahub_sdk.Config`, so environment variables only act as fallbacks
> at the SDK level when the client is initialised without arguments.
## Cache
Each `FabricInfrahubClient` instance maintains an in-memory TTL cache.
- **Scope:** per-instance (not shared across instances or processes)
- **TTL:** 60 seconds (constant `_CACHE_TTL` in `client.py`)
- **Key format:** `<method_name>:<device_name>` (e.g. `device:leaf1`, `bgp_config:leaf1`)
- **Behaviour:** on the first call the result is fetched from Infrahub and stored;
subsequent calls within the TTL window return the cached value without hitting the SDK
```python
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="tok") as client:
d1 = await client.get_device("leaf1") # → SDK query
d2 = await client.get_device("leaf1") # → cache hit, no SDK call
assert d1 == d2
```
To bypass the cache, create a new `FabricInfrahubClient` instance.

24
src/infrahub/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
"""
Infrahub client package for the Fabric Orchestrator.
Exports the main async client and exception hierarchy for use by the
Reconciler and Prefect flows.
"""
from __future__ import annotations
from .client import FabricInfrahubClient
from .exceptions import (
InfrahubClientError,
InfrahubConnectionError,
InfrahubNotFoundError,
InfrahubQueryError,
)
__all__ = [
"FabricInfrahubClient",
"InfrahubClientError",
"InfrahubConnectionError",
"InfrahubNotFoundError",
"InfrahubQueryError",
]

789
src/infrahub/client.py Normal file
View File

@@ -0,0 +1,789 @@
"""
Infrahub Client for Fabric Intent.
Async client wrapping `infrahub-sdk` to fetch fabric intent data from a remote
Infrahub instance. Replaces the previous NetBox/pynetbox client and is used by
the Reconciler and Prefect flows.
"""
from __future__ import annotations
import time
from typing import Any
from infrahub_sdk import Config, InfrahubClient
from .exceptions import InfrahubNotFoundError, InfrahubQueryError
from .models import (
BgpPeerGroupIntent,
BgpRouterConfigIntent,
BgpSessionIntent,
DeviceIntent,
EvpnInstanceIntent,
MlagDomainIntent,
MlagPeerConfigIntent,
VlanIntent,
VrfIntent,
VtepIntent,
)
# TTL for cache entries in seconds
_CACHE_TTL: int = 60
class FabricInfrahubClient:
"""
Async client for querying fabric intent data from a remote Infrahub instance.
Wraps `infrahub-sdk` with structured Pydantic model responses, error handling,
and a simple TTL-based cache to avoid redundant queries.
Usage as async context manager::
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="xxx") as client:
device = await client.get_device("leaf1")
vlans = await client.get_device_vlans("leaf1")
"""
def __init__(self, url: str, api_token: str, branch: str = "main") -> None:
"""
Initialize the Infrahub client.
Args:
url: Base URL of the Infrahub instance (e.g. "http://infrahub:8080")
api_token: API token for authentication
branch: Default branch to query (default: "main")
"""
self._url = url
self._branch = branch
config = Config(address=url, api_token=api_token, default_branch=branch)
self._client = InfrahubClient(config=config)
self._cache: dict[str, tuple[Any, float]] = {}
async def __aenter__(self) -> FabricInfrahubClient:
"""Enter async context manager."""
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Exit async context manager."""
pass
# =========================================================================
# Cache helpers
# =========================================================================
def _cache_get(self, key: str) -> Any | None:
"""Return cached value if still valid, else None."""
if key in self._cache:
value, ts = self._cache[key]
if time.monotonic() - ts < _CACHE_TTL:
return value
del self._cache[key]
return None
def _cache_set(self, key: str, value: Any) -> None:
"""Store a value in the cache with the current timestamp."""
self._cache[key] = (value, time.monotonic())
# =========================================================================
# Device
# =========================================================================
async def get_device(self, name: str) -> DeviceIntent:
"""
Fetch a device by name and return a structured DeviceIntent.
Args:
name: Device name as stored in Infrahub
Returns:
DeviceIntent with device attributes and resolved relationship names
Raises:
InfrahubNotFoundError: When no device with the given name exists
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device:{name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
node = await self._client.get(kind="InfraDevice", name__value=name)
except Exception as e:
msg = str(e).lower()
if "not found" in msg or "no node" in msg:
raise InfrahubNotFoundError(f"Device '{name}' not found in Infrahub") from e
raise InfrahubQueryError(f"Failed to query device '{name}': {e}") from e
if node is None:
raise InfrahubNotFoundError(f"Device '{name}' not found in Infrahub")
# Resolve cardinality-one relationships
platform: str | None = None
site: str | None = None
asn: int | None = None
try:
if node.platform.peer is None:
await node.platform.fetch()
if node.platform.peer is not None:
platform = node.platform.peer.name.value
except Exception:
pass
try:
if node.site.peer is None:
await node.site.fetch()
if node.site.peer is not None:
site = node.site.peer.name.value
except Exception:
pass
try:
if node.asn.peer is None:
await node.asn.fetch()
if node.asn.peer is not None:
asn = node.asn.peer.asn.value
except Exception:
pass
result = DeviceIntent(
name=node.name.value,
role=node.role.value,
status=node.status.value,
platform=platform,
site=site,
asn=asn,
)
self._cache_set(cache_key, result)
return result
# =========================================================================
# VLANs
# =========================================================================
async def get_device_vlans(self, device_name: str) -> list[VlanIntent]:
"""
Fetch all VLANs associated with a device via its VTEP's vlan_vni_mappings.
Falls back to VLANs linked through SVI interfaces if no VTEP is found.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of VlanIntent objects for all VLANs associated with this device
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device_vlans:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
vtep_nodes = await self._client.filters(kind="InfraVTEP", include=["vlan_vni_mappings"])
except Exception as e:
raise InfrahubQueryError(f"Failed to query VTEPs: {e}") from e
# Find VTEP belonging to this device
device_vtep = None
for vtep in vtep_nodes:
try:
if vtep.device.peer is None:
await vtep.device.fetch()
if vtep.device.peer is not None and vtep.device.peer.name.value == device_name:
device_vtep = vtep
break
except Exception:
continue
vlans: list[VlanIntent] = []
if device_vtep is not None:
for mapping_rel in device_vtep.vlan_vni_mappings.peers:
try:
mapping = mapping_rel.peer
await mapping.vlan.fetch()
vlan_node = mapping.vlan.peer
if vlan_node is None:
continue
vni_val: int | None = None
try:
await vlan_node.vni.fetch()
if vlan_node.vni.peer is not None:
vni_val = vlan_node.vni.peer.vni.value
except Exception:
pass
vlans.append(
VlanIntent(
vlan_id=vlan_node.vlan_id.value,
name=vlan_node.name.value,
status=vlan_node.status.value,
vlan_type=vlan_node.vlan_type.value,
vni=vni_val,
stp_enabled=vlan_node.stp_enabled.value,
)
)
except Exception:
continue
else:
# Fallback: discover VLANs via InfraInterfaceVlan → VLAN
try:
svi_nodes = await self._client.filters(kind="InfraInterfaceVlan")
for svi in svi_nodes:
try:
if svi.device.peer is None:
await svi.device.fetch()
if svi.device.peer is None or svi.device.peer.name.value != device_name:
continue
if svi.vlan.peer is None:
await svi.vlan.fetch()
vlan_node = svi.vlan.peer
if vlan_node is None:
continue
vni_val = None
try:
await vlan_node.vni.fetch()
if vlan_node.vni.peer is not None:
vni_val = vlan_node.vni.peer.vni.value
except Exception:
pass
vlans.append(
VlanIntent(
vlan_id=vlan_node.vlan_id.value,
name=vlan_node.name.value,
status=vlan_node.status.value,
vlan_type=vlan_node.vlan_type.value,
vni=vni_val,
stp_enabled=vlan_node.stp_enabled.value,
)
)
except Exception:
continue
except Exception as e:
raise InfrahubQueryError(
f"Failed to query SVI interfaces for device '{device_name}': {e}"
) from e
self._cache_set(cache_key, vlans)
return vlans
# =========================================================================
# BGP
# =========================================================================
async def _get_bgp_router_config_node(self, device_name: str) -> Any:
"""
Fetch the BGPRouterConfig node for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
The raw InfraBGPRouterConfig SDK node
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
try:
nodes = await self._client.filters(
kind="InfraBGPRouterConfig",
include=["peer_groups", "sessions"],
)
except Exception as e:
raise InfrahubQueryError(
f"Failed to query BGP router config for '{device_name}': {e}"
) from e
for node in nodes:
try:
if node.device.peer is None:
await node.device.fetch()
if node.device.peer is not None and node.device.peer.name.value == device_name:
return node
except Exception:
continue
raise InfrahubNotFoundError(f"No BGP router config found for device '{device_name}'")
async def get_device_bgp_config(self, device_name: str) -> BgpRouterConfigIntent:
"""
Fetch the BGP router configuration for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
BgpRouterConfigIntent with BGP router configuration
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"bgp_config:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
node = await self._get_bgp_router_config_node(device_name)
local_asn: int = 0
try:
if node.local_asn.peer is None:
await node.local_asn.fetch()
if node.local_asn.peer is not None:
local_asn = node.local_asn.peer.asn.value
except Exception:
pass
result = BgpRouterConfigIntent(
router_id=node.router_id.value,
local_asn=local_asn,
default_ipv4_unicast=node.default_ipv4_unicast.value,
ecmp_max_paths=node.ecmp_max_paths.value,
)
self._cache_set(cache_key, result)
return result
async def get_device_bgp_peer_groups(self, device_name: str) -> list[BgpPeerGroupIntent]:
"""
Fetch all BGP peer groups for a device via its BGPRouterConfig.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of BgpPeerGroupIntent objects
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"bgp_peer_groups:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
bgp_node = await self._get_bgp_router_config_node(device_name)
peer_groups: list[BgpPeerGroupIntent] = []
for pg_rel in bgp_node.peer_groups.peers:
try:
pg = pg_rel.peer
remote_asn: int | None = None
try:
if pg.remote_asn.peer is None:
await pg.remote_asn.fetch()
if pg.remote_asn.peer is not None:
remote_asn = pg.remote_asn.peer.asn.value
except Exception:
pass
peer_groups.append(
BgpPeerGroupIntent(
name=pg.name.value,
peer_group_type=pg.peer_group_type.value,
remote_asn=remote_asn,
update_source=pg.update_source.value if pg.update_source.value else None,
send_community=pg.send_community.value,
ebgp_multihop=pg.ebgp_multihop.value if pg.ebgp_multihop.value else None,
next_hop_unchanged=pg.next_hop_unchanged.value,
)
)
except Exception:
continue
self._cache_set(cache_key, peer_groups)
return peer_groups
async def get_device_bgp_sessions(self, device_name: str) -> list[BgpSessionIntent]:
"""
Fetch all BGP sessions for a device via its BGPRouterConfig.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of BgpSessionIntent objects
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"bgp_sessions:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
bgp_node = await self._get_bgp_router_config_node(device_name)
sessions: list[BgpSessionIntent] = []
for sess_rel in bgp_node.sessions.peers:
try:
sess = sess_rel.peer
peer_group: str | None = None
try:
if sess.peer_group.peer is None:
await sess.peer_group.fetch()
if sess.peer_group.peer is not None:
peer_group = sess.peer_group.peer.name.value
except Exception:
pass
remote_asn: int | None = None
try:
if sess.remote_asn.peer is None:
await sess.remote_asn.fetch()
if sess.remote_asn.peer is not None:
remote_asn = sess.remote_asn.peer.asn.value
except Exception:
pass
sessions.append(
BgpSessionIntent(
peer_address=sess.peer_address.value,
description=sess.description.value if sess.description.value else None,
enabled=sess.enabled.value,
peer_group=peer_group,
remote_asn=remote_asn,
)
)
except Exception:
continue
self._cache_set(cache_key, sessions)
return sessions
# =========================================================================
# VRF
# =========================================================================
async def get_device_vrfs(self, device_name: str) -> list[VrfIntent]:
"""
Fetch all VRFs assigned to a device via VRFDeviceAssignment.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of VrfIntent objects with resolved route targets
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device_vrfs:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
assignments = await self._client.filters(
kind="InfraVRFDeviceAssignment",
include=["import_targets", "export_targets"],
)
except Exception as e:
raise InfrahubQueryError(
f"Failed to query VRF assignments for device '{device_name}': {e}"
) from e
vrfs: list[VrfIntent] = []
for asgn in assignments:
try:
if asgn.device.peer is None:
await asgn.device.fetch()
if asgn.device.peer is None or asgn.device.peer.name.value != device_name:
continue
if asgn.vrf.peer is None:
await asgn.vrf.fetch()
vrf_node = asgn.vrf.peer
if vrf_node is None:
continue
l3vni: int | None = None
try:
if vrf_node.l3vni.peer is None:
await vrf_node.l3vni.fetch()
if vrf_node.l3vni.peer is not None:
l3vni = vrf_node.l3vni.peer.vni.value
except Exception:
pass
import_targets: list[str] = []
for rt_rel in asgn.import_targets.peers:
try:
import_targets.append(rt_rel.peer.target.value)
except Exception:
pass
export_targets: list[str] = []
for rt_rel in asgn.export_targets.peers:
try:
export_targets.append(rt_rel.peer.target.value)
except Exception:
pass
rd = asgn.route_distinguisher.value if asgn.route_distinguisher.value else None
vrfs.append(
VrfIntent(
name=vrf_node.name.value,
route_distinguisher=rd,
vrf_id=vrf_node.vrf_id.value if vrf_node.vrf_id.value else None,
l3vni=l3vni,
import_targets=import_targets,
export_targets=export_targets,
)
)
except Exception:
continue
self._cache_set(cache_key, vrfs)
return vrfs
# =========================================================================
# VTEP
# =========================================================================
async def get_device_vtep(self, device_name: str) -> VtepIntent | None:
"""
Fetch the VTEP configuration for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
VtepIntent if a VTEP exists for this device, None otherwise
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device_vtep:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
vtep_nodes = await self._client.filters(kind="InfraVTEP", include=["vlan_vni_mappings"])
except Exception as e:
raise InfrahubQueryError(f"Failed to query VTEPs: {e}") from e
for vtep in vtep_nodes:
try:
if vtep.device.peer is None:
await vtep.device.fetch()
if vtep.device.peer is None or vtep.device.peer.name.value != device_name:
continue
mappings: list[tuple[int, int]] = []
for mapping_rel in vtep.vlan_vni_mappings.peers:
try:
mapping = mapping_rel.peer
await mapping.vlan.fetch()
await mapping.vni.fetch()
vlan_node = mapping.vlan.peer
vni_node = mapping.vni.peer
if vlan_node is not None and vni_node is not None:
mappings.append((vlan_node.vlan_id.value, vni_node.vni.value))
except Exception:
continue
result = VtepIntent(
source_address=vtep.source_address.value,
udp_port=vtep.udp_port.value,
learn_restrict=vtep.learn_restrict.value,
vlan_vni_mappings=mappings,
)
self._cache_set(cache_key, result)
return result
except Exception:
continue
self._cache_set(cache_key, None)
return None
# =========================================================================
# EVPN Instances
# =========================================================================
async def get_device_evpn_instances(self, device_name: str) -> list[EvpnInstanceIntent]:
"""
Fetch all EVPN instances for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of EvpnInstanceIntent objects
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"evpn_instances:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
nodes = await self._client.filters(kind="InfraEVPNInstance")
except Exception as e:
raise InfrahubQueryError(
f"Failed to query EVPN instances for device '{device_name}': {e}"
) from e
instances: list[EvpnInstanceIntent] = []
for node in nodes:
try:
if node.device.peer is None:
await node.device.fetch()
if node.device.peer is None or node.device.peer.name.value != device_name:
continue
if node.vlan.peer is None:
await node.vlan.fetch()
vlan_node = node.vlan.peer
vlan_id: int = vlan_node.vlan_id.value if vlan_node is not None else 0
instances.append(
EvpnInstanceIntent(
route_distinguisher=node.route_distinguisher.value,
route_target_import=node.route_target_import.value,
route_target_export=node.route_target_export.value,
redistribute_learned=node.redistribute_learned.value,
vlan_id=vlan_id,
)
)
except Exception:
continue
self._cache_set(cache_key, instances)
return instances
# =========================================================================
# MLAG
# =========================================================================
async def get_mlag_domain(self, device_name: str) -> MlagDomainIntent | None:
"""
Fetch the MLAG domain that contains the given device.
Args:
device_name: Device name as stored in Infrahub
Returns:
MlagDomainIntent if an MLAG domain exists for this device, None otherwise
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"mlag_domain:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
nodes = await self._client.filters(kind="InfraMlagDomain", include=["devices"])
except Exception as e:
raise InfrahubQueryError(
f"Failed to query MLAG domains for device '{device_name}': {e}"
) from e
for node in nodes:
try:
peer_device_names: list[str] = []
found = False
for dev_rel in node.devices.peers:
try:
dev_name = dev_rel.peer.name.value
peer_device_names.append(dev_name)
if dev_name == device_name:
found = True
except Exception:
pass
if not found:
continue
result = MlagDomainIntent(
domain_id=node.domain_id.value,
virtual_mac=node.virtual_mac.value,
heartbeat_vrf=node.heartbeat_vrf.value,
dual_primary_detection=node.dual_primary_detection.value,
dual_primary_delay=node.dual_primary_delay.value,
dual_primary_action=node.dual_primary_action.value,
peer_devices=peer_device_names,
)
self._cache_set(cache_key, result)
return result
except Exception:
continue
self._cache_set(cache_key, None)
return None
async def get_mlag_peer_config(self, device_name: str) -> MlagPeerConfigIntent | None:
"""
Fetch the MLAG peer configuration for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
MlagPeerConfigIntent if MLAG peer config exists for this device, None otherwise
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"mlag_peer_config:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
nodes = await self._client.filters(kind="InfraMlagPeerConfig")
except Exception as e:
raise InfrahubQueryError(
f"Failed to query MLAG peer configs for device '{device_name}': {e}"
) from e
for node in nodes:
try:
if node.device.peer is None:
await node.device.fetch()
if node.device.peer is None or node.device.peer.name.value != device_name:
continue
peer_link: str = ""
try:
if node.peer_link.peer is None:
await node.peer_link.fetch()
if node.peer_link.peer is not None:
peer_link = node.peer_link.peer.name.value
except Exception:
pass
result = MlagPeerConfigIntent(
local_interface_ip=node.local_interface_ip.value,
peer_address=node.peer_address.value,
heartbeat_peer_ip=node.heartbeat_peer_ip.value,
peer_link=peer_link,
)
self._cache_set(cache_key, result)
return result
except Exception:
continue
self._cache_set(cache_key, None)
return None

View File

@@ -0,0 +1,31 @@
"""
Infrahub Client Exceptions.
Custom exception hierarchy for the FabricInfrahubClient.
"""
from __future__ import annotations
class InfrahubClientError(Exception):
"""Base exception for all Infrahub client errors."""
pass
class InfrahubConnectionError(InfrahubClientError):
"""Raised when connection to the Infrahub instance fails."""
pass
class InfrahubQueryError(InfrahubClientError):
"""Raised when a query to Infrahub fails or returns unexpected data."""
pass
class InfrahubNotFoundError(InfrahubClientError):
"""Raised when a requested node is not found in Infrahub."""
pass

144
src/infrahub/models.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Pydantic models for Infrahub fabric intent data.
These immutable models represent the structured intent retrieved from Infrahub
and are used throughout the orchestrator for typed access to fabric state.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class DeviceIntent(BaseModel):
"""Represents a network device from Infrahub."""
model_config = ConfigDict(frozen=True)
name: str
role: str
status: str
platform: str | None
site: str | None
asn: int | None
class VlanIntent(BaseModel):
"""Represents a VLAN from Infrahub."""
model_config = ConfigDict(frozen=True)
vlan_id: int
name: str
status: str
vlan_type: str
vni: int | None
stp_enabled: bool
class VniIntent(BaseModel):
"""Represents a VNI (VXLAN Network Identifier) from Infrahub."""
model_config = ConfigDict(frozen=True)
vni: int
vni_type: str
description: str | None
class BgpRouterConfigIntent(BaseModel):
"""Represents a BGP router configuration from Infrahub."""
model_config = ConfigDict(frozen=True)
router_id: str
local_asn: int
default_ipv4_unicast: bool
ecmp_max_paths: int
class BgpPeerGroupIntent(BaseModel):
"""Represents a BGP peer group from Infrahub."""
model_config = ConfigDict(frozen=True)
name: str
peer_group_type: str
remote_asn: int | None
update_source: str | None
send_community: str
ebgp_multihop: int | None
next_hop_unchanged: bool
class BgpSessionIntent(BaseModel):
"""Represents a BGP session from Infrahub."""
model_config = ConfigDict(frozen=True)
peer_address: str
description: str | None
enabled: bool
peer_group: str | None
remote_asn: int | None
class VrfIntent(BaseModel):
"""Represents a VRF from Infrahub."""
model_config = ConfigDict(frozen=True)
name: str
route_distinguisher: str | None
vrf_id: int | None
l3vni: int | None
import_targets: list[str]
export_targets: list[str]
class VtepIntent(BaseModel):
"""Represents a VTEP (VXLAN Tunnel Endpoint) from Infrahub."""
model_config = ConfigDict(frozen=True)
source_address: str
udp_port: int
learn_restrict: bool
vlan_vni_mappings: list[tuple[int, int]] # (vlan_id, vni)
class MlagDomainIntent(BaseModel):
"""Represents an MLAG domain from Infrahub."""
model_config = ConfigDict(frozen=True)
domain_id: str
virtual_mac: str
heartbeat_vrf: str
dual_primary_detection: bool
dual_primary_delay: int
dual_primary_action: str
peer_devices: list[str]
class MlagPeerConfigIntent(BaseModel):
"""Represents an MLAG peer configuration from Infrahub."""
model_config = ConfigDict(frozen=True)
local_interface_ip: str
peer_address: str
heartbeat_peer_ip: str
peer_link: str
class EvpnInstanceIntent(BaseModel):
"""Represents an EVPN instance from Infrahub."""
model_config = ConfigDict(frozen=True)
route_distinguisher: str
route_target_import: str
route_target_export: str
redistribute_learned: bool
vlan_id: int

View File

@@ -0,0 +1,617 @@
"""
Unit tests for FabricInfrahubClient.
All SDK interactions are mocked with AsyncMock — no real Infrahub instance required.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest # noqa: I001
from src.infrahub.client import FabricInfrahubClient
from src.infrahub.exceptions import InfrahubNotFoundError, InfrahubQueryError
from src.infrahub.models import (
BgpPeerGroupIntent,
BgpRouterConfigIntent,
BgpSessionIntent,
DeviceIntent,
EvpnInstanceIntent,
MlagDomainIntent,
MlagPeerConfigIntent,
VlanIntent,
VrfIntent,
VtepIntent,
)
# =============================================================================
# Helpers — mock node factories
# =============================================================================
def _attr(value):
"""Return a simple mock whose .value is set."""
m = MagicMock()
m.value = value
return m
def _rel_one(peer_mock):
"""Return a mock relationship (cardinality one) with an already-loaded peer."""
m = MagicMock()
m.peer = peer_mock
m.fetch = AsyncMock()
return m
def _rel_many(peer_mocks):
"""Return a mock relationship (cardinality many) whose .peers yields (peer_rel, peer)."""
peers = []
for p in peer_mocks:
rel = MagicMock()
rel.peer = p
peers.append(rel)
m = MagicMock()
m.peers = peers
return m
def make_device_node(
name="leaf1",
role="leaf",
status="active",
platform_name="EOS",
site_name="dc1",
asn=65001,
):
node = MagicMock()
node.name = _attr(name)
node.role = _attr(role)
node.status = _attr(status)
platform = MagicMock()
platform.name = _attr(platform_name)
node.platform = _rel_one(platform)
site = MagicMock()
site.name = _attr(site_name)
node.site = _rel_one(site)
asn_node = MagicMock()
asn_node.asn = _attr(asn)
node.asn = _rel_one(asn_node)
return node
def make_vlan_node(vlan_id=10, name="VLAN10", status="active", vlan_type="standard", vni=10010):
node = MagicMock()
node.vlan_id = _attr(vlan_id)
node.name = _attr(name)
node.status = _attr(status)
node.vlan_type = _attr(vlan_type)
node.stp_enabled = _attr(True)
vni_node = MagicMock()
vni_node.vni = _attr(vni)
node.vni = _rel_one(vni_node)
return node
def make_bgp_config_node(router_id="10.0.0.1", asn=65001, device_name="leaf1"):
node = MagicMock()
node.router_id = _attr(router_id)
node.default_ipv4_unicast = _attr(True)
node.ecmp_max_paths = _attr(4)
asn_node = MagicMock()
asn_node.asn = _attr(asn)
node.local_asn = _rel_one(asn_node)
device = MagicMock()
device.name = _attr(device_name)
node.device = _rel_one(device)
node.peer_groups = _rel_many([])
node.sessions = _rel_many([])
return node
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_sdk_client():
"""Patch InfrahubClient and Config so no real connection is made."""
with (
patch("src.infrahub.client.InfrahubClient") as mock_cls,
patch("src.infrahub.client.Config"),
):
sdk = AsyncMock()
mock_cls.return_value = sdk
yield sdk
@pytest.fixture
def client(mock_sdk_client):
"""Return a FabricInfrahubClient backed by a mocked SDK."""
return FabricInfrahubClient(url="http://infrahub:8080", api_token="test-token", branch="main")
# =============================================================================
# Context manager
# =============================================================================
@pytest.mark.asyncio
async def test_context_manager(mock_sdk_client):
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="test-token") as c:
assert isinstance(c, FabricInfrahubClient)
# =============================================================================
# get_device
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_returns_device_intent(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(return_value=make_device_node())
result = await client.get_device("leaf1")
assert isinstance(result, DeviceIntent)
assert result.name == "leaf1"
assert result.role == "leaf"
assert result.status == "active"
assert result.platform == "EOS"
assert result.site == "dc1"
assert result.asn == 65001
@pytest.mark.asyncio
async def test_get_device_not_found_raises(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(side_effect=Exception("not found"))
with pytest.raises(InfrahubNotFoundError):
await client.get_device("ghost-device")
@pytest.mark.asyncio
async def test_get_device_none_raises_not_found(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(return_value=None)
with pytest.raises(InfrahubNotFoundError):
await client.get_device("missing")
@pytest.mark.asyncio
async def test_get_device_query_error(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(side_effect=Exception("connection refused"))
with pytest.raises(InfrahubQueryError):
await client.get_device("leaf1")
# =============================================================================
# Caching
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_caches_result(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(return_value=make_device_node())
result1 = await client.get_device("leaf1")
result2 = await client.get_device("leaf1")
assert result1 == result2
# SDK should only be called once despite two client calls
mock_sdk_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_cache_separate_keys_per_device(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(
side_effect=[
make_device_node(name="leaf1"),
make_device_node(name="leaf2", asn=65002),
]
)
r1 = await client.get_device("leaf1")
r2 = await client.get_device("leaf2")
assert r1.name == "leaf1"
assert r2.name == "leaf2"
assert mock_sdk_client.get.call_count == 2
# =============================================================================
# Branch selection
# =============================================================================
def test_branch_passed_to_config():
with (
patch("src.infrahub.client.InfrahubClient"),
patch("src.infrahub.client.Config") as mock_cfg,
):
FabricInfrahubClient(url="http://infrahub:8080", api_token="tok", branch="proposed-change")
mock_cfg.assert_called_once_with(
address="http://infrahub:8080",
api_token="tok",
default_branch="proposed-change",
)
# =============================================================================
# get_device_bgp_config
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_bgp_config(client, mock_sdk_client):
bgp_node = make_bgp_config_node(router_id="10.0.0.1", asn=65001, device_name="leaf1")
mock_sdk_client.filters = AsyncMock(return_value=[bgp_node])
result = await client.get_device_bgp_config("leaf1")
assert isinstance(result, BgpRouterConfigIntent)
assert result.router_id == "10.0.0.1"
assert result.local_asn == 65001
assert result.default_ipv4_unicast is True
assert result.ecmp_max_paths == 4
@pytest.mark.asyncio
async def test_get_device_bgp_config_not_found(client, mock_sdk_client):
mock_sdk_client.filters = AsyncMock(return_value=[])
with pytest.raises(InfrahubNotFoundError):
await client.get_device_bgp_config("leaf1")
# =============================================================================
# get_device_bgp_peer_groups
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_bgp_peer_groups(client, mock_sdk_client):
pg_node = MagicMock()
pg_node.name = _attr("EVPN-PEERS")
pg_node.peer_group_type = _attr("evpn")
pg_node.update_source = _attr("Loopback0")
pg_node.send_community = _attr("extended")
pg_node.ebgp_multihop = _attr(3)
pg_node.next_hop_unchanged = _attr(True)
remote_asn_node = MagicMock()
remote_asn_node.asn = _attr(65000)
pg_node.remote_asn = _rel_one(remote_asn_node)
bgp_node = make_bgp_config_node(device_name="leaf1")
bgp_node.peer_groups = _rel_many([pg_node])
mock_sdk_client.filters = AsyncMock(return_value=[bgp_node])
result = await client.get_device_bgp_peer_groups("leaf1")
assert len(result) == 1
pg = result[0]
assert isinstance(pg, BgpPeerGroupIntent)
assert pg.name == "EVPN-PEERS"
assert pg.peer_group_type == "evpn"
assert pg.remote_asn == 65000
assert pg.send_community == "extended"
assert pg.next_hop_unchanged is True
# =============================================================================
# get_device_bgp_sessions
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_bgp_sessions(client, mock_sdk_client):
sess_node = MagicMock()
sess_node.peer_address = _attr("10.0.0.2")
sess_node.description = _attr("to-spine1")
sess_node.enabled = _attr(True)
pg = MagicMock()
pg.name = _attr("UNDERLAY")
sess_node.peer_group = _rel_one(pg)
remote_asn = MagicMock()
remote_asn.asn = _attr(65000)
sess_node.remote_asn = _rel_one(remote_asn)
bgp_node = make_bgp_config_node(device_name="leaf1")
bgp_node.sessions = _rel_many([sess_node])
mock_sdk_client.filters = AsyncMock(return_value=[bgp_node])
result = await client.get_device_bgp_sessions("leaf1")
assert len(result) == 1
sess = result[0]
assert isinstance(sess, BgpSessionIntent)
assert sess.peer_address == "10.0.0.2"
assert sess.description == "to-spine1"
assert sess.enabled is True
assert sess.peer_group == "UNDERLAY"
assert sess.remote_asn == 65000
# =============================================================================
# get_device_vrfs
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_vrfs(client, mock_sdk_client):
rt_import = MagicMock()
rt_import.target = _attr("65000:100")
rt_export = MagicMock()
rt_export.target = _attr("65000:100")
vni_node = MagicMock()
vni_node.vni = _attr(10000)
vrf_node = MagicMock()
vrf_node.name = _attr("PROD")
vrf_node.vrf_id = _attr(100)
vrf_node.l3vni = _rel_one(vni_node)
asgn = MagicMock()
asgn.route_distinguisher = _attr("10.0.0.1:100")
asgn.vrf = _rel_one(vrf_node)
device = MagicMock()
device.name = _attr("leaf1")
asgn.device = _rel_one(device)
asgn.import_targets = _rel_many([rt_import])
asgn.export_targets = _rel_many([rt_export])
mock_sdk_client.filters = AsyncMock(return_value=[asgn])
result = await client.get_device_vrfs("leaf1")
assert len(result) == 1
vrf = result[0]
assert isinstance(vrf, VrfIntent)
assert vrf.name == "PROD"
assert vrf.route_distinguisher == "10.0.0.1:100"
assert vrf.vrf_id == 100
assert vrf.l3vni == 10000
assert vrf.import_targets == ["65000:100"]
assert vrf.export_targets == ["65000:100"]
# =============================================================================
# get_device_vtep
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_vtep(client, mock_sdk_client):
vlan_node = MagicMock()
vlan_node.vlan_id = _attr(10)
vni_node = MagicMock()
vni_node.vni = _attr(10010)
mapping = MagicMock()
mapping.vlan = _rel_one(vlan_node)
mapping.vni = _rel_one(vni_node)
mapping.vlan.fetch = AsyncMock()
mapping.vni.fetch = AsyncMock()
device = MagicMock()
device.name = _attr("leaf1")
vtep = MagicMock()
vtep.source_address = _attr("10.0.0.1")
vtep.udp_port = _attr(4789)
vtep.learn_restrict = _attr(False)
vtep.device = _rel_one(device)
vtep.vlan_vni_mappings = _rel_many([mapping])
mock_sdk_client.filters = AsyncMock(return_value=[vtep])
result = await client.get_device_vtep("leaf1")
assert isinstance(result, VtepIntent)
assert result.source_address == "10.0.0.1"
assert result.udp_port == 4789
assert result.learn_restrict is False
assert (10, 10010) in result.vlan_vni_mappings
@pytest.mark.asyncio
async def test_get_device_vtep_none_when_missing(client, mock_sdk_client):
mock_sdk_client.filters = AsyncMock(return_value=[])
result = await client.get_device_vtep("leaf1")
assert result is None
# =============================================================================
# get_device_evpn_instances
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_evpn_instances(client, mock_sdk_client):
vlan_node = MagicMock()
vlan_node.vlan_id = _attr(10)
device = MagicMock()
device.name = _attr("leaf1")
node = MagicMock()
node.route_distinguisher = _attr("10.0.0.1:10")
node.route_target_import = _attr("65000:10")
node.route_target_export = _attr("65000:10")
node.redistribute_learned = _attr(True)
node.device = _rel_one(device)
node.vlan = _rel_one(vlan_node)
mock_sdk_client.filters = AsyncMock(return_value=[node])
result = await client.get_device_evpn_instances("leaf1")
assert len(result) == 1
ev = result[0]
assert isinstance(ev, EvpnInstanceIntent)
assert ev.route_distinguisher == "10.0.0.1:10"
assert ev.route_target_import == "65000:10"
assert ev.route_target_export == "65000:10"
assert ev.redistribute_learned is True
assert ev.vlan_id == 10
# =============================================================================
# get_mlag_domain
# =============================================================================
@pytest.mark.asyncio
async def test_get_mlag_domain(client, mock_sdk_client):
dev1 = MagicMock()
dev1.name = _attr("leaf1")
dev2 = MagicMock()
dev2.name = _attr("leaf2")
domain = MagicMock()
domain.domain_id = _attr("1")
domain.virtual_mac = _attr("00:1c:73:00:00:01")
domain.heartbeat_vrf = _attr("MGMT")
domain.dual_primary_detection = _attr(True)
domain.dual_primary_delay = _attr(10)
domain.dual_primary_action = _attr("errdisable")
domain.devices = _rel_many([dev1, dev2])
mock_sdk_client.filters = AsyncMock(return_value=[domain])
result = await client.get_mlag_domain("leaf1")
assert isinstance(result, MlagDomainIntent)
assert result.domain_id == "1"
assert result.virtual_mac == "00:1c:73:00:00:01"
assert "leaf1" in result.peer_devices
assert "leaf2" in result.peer_devices
@pytest.mark.asyncio
async def test_get_mlag_domain_returns_none_when_not_member(client, mock_sdk_client):
dev_other = MagicMock()
dev_other.name = _attr("spine1")
domain = MagicMock()
domain.domain_id = _attr("99")
domain.devices = _rel_many([dev_other])
mock_sdk_client.filters = AsyncMock(return_value=[domain])
result = await client.get_mlag_domain("leaf1")
assert result is None
# =============================================================================
# get_mlag_peer_config
# =============================================================================
@pytest.mark.asyncio
async def test_get_mlag_peer_config(client, mock_sdk_client):
lag = MagicMock()
lag.name = _attr("Port-Channel1")
device = MagicMock()
device.name = _attr("leaf1")
node = MagicMock()
node.local_interface_ip = _attr("10.255.255.0/31")
node.peer_address = _attr("10.255.255.1")
node.heartbeat_peer_ip = _attr("192.168.0.2")
node.device = _rel_one(device)
node.peer_link = _rel_one(lag)
mock_sdk_client.filters = AsyncMock(return_value=[node])
result = await client.get_mlag_peer_config("leaf1")
assert isinstance(result, MlagPeerConfigIntent)
assert result.local_interface_ip == "10.255.255.0/31"
assert result.peer_address == "10.255.255.1"
assert result.heartbeat_peer_ip == "192.168.0.2"
assert result.peer_link == "Port-Channel1"
@pytest.mark.asyncio
async def test_get_mlag_peer_config_returns_none_when_missing(client, mock_sdk_client):
mock_sdk_client.filters = AsyncMock(return_value=[])
result = await client.get_mlag_peer_config("spine1")
assert result is None
# =============================================================================
# get_device_vlans
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_vlans_via_vtep(client, mock_sdk_client):
vlan_node = make_vlan_node(vlan_id=10, name="VLAN10", vni=10010)
mapping = MagicMock()
mapping.vlan = _rel_one(vlan_node)
mapping.vlan.fetch = AsyncMock()
device = MagicMock()
device.name = _attr("leaf1")
vtep = MagicMock()
vtep.device = _rel_one(device)
vtep.vlan_vni_mappings = _rel_many([mapping])
mock_sdk_client.filters = AsyncMock(return_value=[vtep])
result = await client.get_device_vlans("leaf1")
assert len(result) == 1
assert isinstance(result[0], VlanIntent)
assert result[0].vlan_id == 10
assert result[0].vni == 10010
@pytest.mark.asyncio
async def test_get_device_vlans_caches_result(client, mock_sdk_client):
device = MagicMock()
device.name = _attr("leaf1")
vtep = MagicMock()
vtep.device = _rel_one(device)
vtep.vlan_vni_mappings = _rel_many([])
mock_sdk_client.filters = AsyncMock(return_value=[vtep])
r1 = await client.get_device_vlans("leaf1")
r2 = await client.get_device_vlans("leaf1")
assert r1 == r2
mock_sdk_client.filters.assert_called_once()

521
uv.lock generated
View File

@@ -2,6 +2,28 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "basedpyright"
version = "1.37.4"
@@ -80,63 +102,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@@ -223,37 +188,88 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload-time = "2021-07-22T13:24:26.783Z" },
]
[[package]]
name = "dulwich"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/c4/bfe2a4e5b203f87ea925339691dd6e379e1a80d805dff0502e496bcaec39/dulwich-1.1.0.tar.gz", hash = "sha256:9aa855db9fee0a7065ae9ffb38e14e353876d82f17e33e1a1fb3830eb8d0cf43", size = 1178580, upload-time = "2026-02-17T21:20:05.388Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/b9/0c72c23fa4e09f4c7cc02b6930485ddbd62b636d4a9bf8beddb405d71d63/dulwich-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f6dd0c5fc45c84790d4a48d168d07f0aa817fcb879d2632e6cee603e98a843c", size = 1334617, upload-time = "2026-02-17T21:19:24.185Z" },
{ url = "https://files.pythonhosted.org/packages/6c/99/777f11d2cb476afbc8a4f31c22746d4d51202594ced18c530f963523ff6d/dulwich-1.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f8789e14981be2d33c3c36a14ec55ae06780c0a865e9df107016c4489a4a022a", size = 1410542, upload-time = "2026-02-17T21:19:25.673Z" },
{ url = "https://files.pythonhosted.org/packages/fd/2b/e7b0fdb6acafab035baebbebfd2413f7bb153e49fa0ae0956c10d7f1fb85/dulwich-1.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9a32f92c2eb86c84a175261f8fb983b6765bb31618d79d0c0dd68fab6f6ca94a", size = 1437268, upload-time = "2026-02-17T21:19:27.713Z" },
{ url = "https://files.pythonhosted.org/packages/64/1e/32d402b3c89973fa349e8e4a1710472f2706290e87300634578cdcf39b48/dulwich-1.1.0-cp312-cp312-win32.whl", hash = "sha256:06c18293fb2c715f035052f0c74f56e5ff52925ad4d0b5a0ebf16118daa5e340", size = 1003351, upload-time = "2026-02-17T21:19:29.187Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2e/eecefa69dad228daa4e81f75785fa0c55336034afee420bcf71dac5a3386/dulwich-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:e738163865dfccf155ef5fa3a2b2c849f38dadc6f009d2be355864233899bb4b", size = 1019251, upload-time = "2026-02-17T21:19:30.537Z" },
{ url = "https://files.pythonhosted.org/packages/39/e5/d694c3a7e093c1c007ad8d43241019e442f57a612e95d28b6a615c4e5ce1/dulwich-1.1.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3ba0cb28848dd8fd80d4389d1b83968da172376cea34f9bdb39043970fa1a045", size = 1434252, upload-time = "2026-02-17T21:19:32.024Z" },
{ url = "https://files.pythonhosted.org/packages/26/e6/878d39e847c8714e1acc725451815831eede17e73a0badce7f1d74cf8251/dulwich-1.1.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:8cf55f0de4cf90155aa3ab228c8ef9e7e10f7c785339f1688fb71f6adaae302c", size = 1434245, upload-time = "2026-02-17T21:19:33.584Z" },
{ url = "https://files.pythonhosted.org/packages/b6/12/df59ee8c545fa8bb80da4965a48459bb458a60b11b50b29a2dc28eb64b4b/dulwich-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49c39844b4abe53612d18add7762faf886ade70384a101912e0849f56f885913", size = 1333720, upload-time = "2026-02-17T21:19:34.832Z" },
{ url = "https://files.pythonhosted.org/packages/53/4d/8c501844c069ba04e7a5d08a310011ae397f22d85211cb668bf9dd2562fe/dulwich-1.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:941735c87b3657019d197bb72f0e9ec03cbdbf959dc0869e672f5c6871597442", size = 1410876, upload-time = "2026-02-17T21:19:36.431Z" },
{ url = "https://files.pythonhosted.org/packages/f9/1a/b09f5849be7c3b2b38a71b4e8965087d3a9d6efeef0556681b534552726f/dulwich-1.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:37be136c7a85a64ae0cf8030f4fb2fa4860cff653ad3bcf13c49bf59fea2020c", size = 1436954, upload-time = "2026-02-17T21:19:38.477Z" },
{ url = "https://files.pythonhosted.org/packages/2a/28/77c53cd69d53d4d6779871bd1d5f420839178a453421add3e60ee814465f/dulwich-1.1.0-cp313-cp313-win32.whl", hash = "sha256:2f5a455e67f9ddd018299ce8dd05861a2696d35c6af91e9acdb4af0767bc0b8b", size = 1003251, upload-time = "2026-02-17T21:19:40.49Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0f/2c4f56cb823d2f3de947feea23966dd3ca1572cfe2ba62751f5f4d3b27af/dulwich-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b1bbb785f29f9eb51cddb9d80f82dac03939b7444961283b09adac19a823e88", size = 1018513, upload-time = "2026-02-17T21:19:41.935Z" },
{ url = "https://files.pythonhosted.org/packages/40/7b/b5f468c8448eb40a5f9b53c675c5b048e0b05a93ef13ceba61f80df25497/dulwich-1.1.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:fc38cc6f60c5e475fa61dcd2b743113f35377602c1ba1c82264898d97a7d3c48", size = 1433148, upload-time = "2026-02-17T21:19:43.897Z" },
{ url = "https://files.pythonhosted.org/packages/ea/30/b29af5c60423f5fee6e3a0ad604bb858c33dbe1ac44146443ac54d782667/dulwich-1.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:c9752d25f01e92587f8db52e50daf3e970deb49555340653ea44ba5e60f0f416", size = 1433143, upload-time = "2026-02-17T21:19:45.261Z" },
{ url = "https://files.pythonhosted.org/packages/a1/b9/36f537e6b651efbab9fc643f0805f255e818bcf8b638080f5c3dac03502f/dulwich-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:693c450a5d327a6a5276f5292d3dd0bc473066d2fd2a2d69a990d7738535deb6", size = 1334582, upload-time = "2026-02-17T21:19:46.637Z" },
{ url = "https://files.pythonhosted.org/packages/a8/33/56baecde3f996745b7a6092d8da60f32476d4318523a1fc121f53c9721ed/dulwich-1.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:dff1b67e0f76fcaae8f7345c05b1c4f00c11a6c42ace20864e80e7964af31827", size = 1429415, upload-time = "2026-02-17T21:19:48.589Z" },
{ url = "https://files.pythonhosted.org/packages/68/20/cc95e6d98d9e3b8e9d286f106033335438367e35c16f2246fe408122de89/dulwich-1.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1b1b9adaf82301fd7b360a5fa521cec1623cb9d77a0c5a09d04396637b39eb48", size = 1436114, upload-time = "2026-02-17T21:19:50.186Z" },
{ url = "https://files.pythonhosted.org/packages/38/c4/a7ba0806acfa49ee5cdb5ed079dd807317168b1193f88083d044bdf2457a/dulwich-1.1.0-cp314-cp314-win32.whl", hash = "sha256:eb5440145bb2bbab71cdfa149fd297a8b7d4db889ab90c58d7a07009a73c1d28", size = 1010851, upload-time = "2026-02-17T21:19:51.607Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4a/bf512cbe5ff8b8e0d2e8bbcf3b3dd2da77f352cfae7433b5c1decf0dbd31/dulwich-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:333b0f93b289b14f98870317fb0583fdf73d5341f21fd09c694aa88bb06ad911", size = 1027524, upload-time = "2026-02-17T21:19:53.206Z" },
{ url = "https://files.pythonhosted.org/packages/de/0c/5f967cb285339e763e7694344e14a7a419c2b267a1e08ba530b995b1e3b6/dulwich-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a0f3421802225caedd11e95ce40f6a8d3c7a5df906489b6a5f49a20f88f62928", size = 1330559, upload-time = "2026-02-17T21:19:54.633Z" },
{ url = "https://files.pythonhosted.org/packages/fb/fd/9df384b61c0cb840b756d20778f50b376971ad73cbc709876244e55b9c8b/dulwich-1.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:518307ab080746ee9c32fc13e76ad4f7df8f7665bb85922e974037dd9415541a", size = 1408946, upload-time = "2026-02-17T21:19:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/cf/5a/48ca834abdd51ba596ec520e083be452394b8c9c811c767db8c7c2f5c593/dulwich-1.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0890fff677c617efbac0cd4584bec9753388e6cd6336e7131338ea034b47e899", size = 1455138, upload-time = "2026-02-17T21:19:58.613Z" },
{ url = "https://files.pythonhosted.org/packages/5b/41/1458313472c4f3b6560b4cd4eff84ad0987ead6265a38261a391ab8b8596/dulwich-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:a05a1049b3928205672913f4c490cf7b08afaa3e7ee7e55e15476e696412672f", size = 1025585, upload-time = "2026-02-17T21:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/37/15/a240999ea53cfbb40cf54e79e672f0f46f3b2544387d8c97b43336dac641/dulwich-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ba6f3f0807868f788b7f1d53b9ac0be3e425136b16563994f5ef6ecf5b7c7863", size = 1041927, upload-time = "2026-02-17T21:20:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/16/4c/ba5b5f88b8e0dd43b2629aca66f472d1c3e03644da120a124586d7dc65ca/dulwich-1.1.0-py3-none-any.whl", hash = "sha256:bcd67e7f9bdffb4b660330c4597d251cd33e74f5df6898a2c1e6a1730a62af06", size = 673697, upload-time = "2026-02-17T21:20:03.346Z" },
]
[[package]]
name = "fabric-orchestrator"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "infrahub-sdk" },
{ name = "pydantic" },
{ name = "pygnmi" },
{ name = "pynetbox" },
{ name = "rich" },
]
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.0" },
{ name = "infrahub-sdk", specifier = ">=0.16.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pygnmi", specifier = ">=0.8.0" },
{ name = "pynetbox", specifier = ">=7.5.0" },
{ name = "rich", specifier = ">=13.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.37.4" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "ruff", specifier = ">=0.14.10" },
]
[[package]]
name = "graphql-core"
version = "3.2.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/9b/037a640a2983b09aed4a823f9cf1729e6d780b0671f854efa4727a7affbe/graphql_core-3.2.7.tar.gz", hash = "sha256:27b6904bdd3b43f2a0556dad5d579bdfdeab1f38e8e8788e555bdcb586a6f62c", size = 513484, upload-time = "2025-11-01T22:30:40.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/14/933037032608787fb92e365883ad6a741c235e0ff992865ec5d904a38f1e/graphql_core-3.2.7-py3-none-any.whl", hash = "sha256:17fc8f3ca4a42913d8e24d9ac9f08deddf0a0b2483076575757f6c412ead2ec0", size = 207262, upload-time = "2025-11-01T22:30:38.912Z" },
]
[[package]]
name = "grpcio"
version = "1.76.0"
@@ -295,6 +311,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -304,6 +357,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "infrahub-sdk"
version = "1.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dulwich" },
{ name = "graphql-core" },
{ name = "httpx" },
{ name = "netutils" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "ujson" },
{ name = "whenever" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/1d/475819223ea2ee809329e896c3dce91abd1e4f29b12de292816666ad8e7a/infrahub_sdk-1.18.1.tar.gz", hash = "sha256:70768bcd9220170d327f26362969a62445af22c6ba915244c9441d5195d8ee47", size = 758835, upload-time = "2026-01-09T04:23:10.947Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/f1/a8ef0cc4fa5edbc71961dda666ca946da0a2d372fedad572aaf8c09ccf8d/infrahub_sdk-1.18.1-py3-none-any.whl", hash = "sha256:ce0f2497661d4924709268a72cb2ba6235208610a6f338dcaa40da21390160fc", size = 198117, upload-time = "2026-01-09T04:23:09.396Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -325,6 +406,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "netutils"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/7f/e9e2415f9539c07a12c8ea5e59244b2912b1ebcf04f166f1bdab95fcfe88/netutils-1.17.1.tar.gz", hash = "sha256:354faa6ed9baa144e82dd1dadd7d2146070deb645fe4d79587f44cd1a6cb37be", size = 520861, upload-time = "2026-02-04T17:23:52.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c1/02da7e8c147ff95c67fa981651e7b532b8e3a2ace83ee70927c89c9e428e/netutils-1.17.1-py3-none-any.whl", hash = "sha256:7aea3c1e3881e92e3d0aa8776ecec74008c944cc25ff33a42c2557a7b9a7adb2", size = 535761, upload-time = "2026-02-04T17:23:51.083Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.13.0"
@@ -350,6 +440,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "protobuf"
version = "6.33.2"
@@ -374,6 +473,106 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -399,31 +598,41 @@ wheels = [
]
[[package]]
name = "pynetbox"
version = "7.5.0"
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "requests" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/0b/695021a23c373991d07c1e4cb510287a521318cfc4b29f68ebbecb19fcd2/pynetbox-7.5.0.tar.gz", hash = "sha256:780064c800fb8c079c9828df472203146442ed3dd0b522a28a501204eb00c066", size = 73850, upload-time = "2025-05-20T16:03:03.831Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b7/a24bc58f0e27f0cd847bf14ffbe9722604f5abe3ec9c12dd8f89cb965be8/pynetbox-7.5.0-py3-none-any.whl", hash = "sha256:ab755a0020c0abb09b4d24c8f8ba89df26f04fa56c35de73302e29a39352f031", size = 35808, upload-time = "2025-05-20T16:03:02.445Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
@@ -474,6 +683,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "ujson"
version = "5.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/ef/a9cb1fce38f699123ff012161599fb9f2ff3f8d482b4b18c43a2dc35073f/ujson-5.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7895f0d2d53bd6aea11743bd56e3cb82d729980636cd0ed9b89418bf66591702", size = 55434, upload-time = "2025-08-20T11:55:34.987Z" },
{ url = "https://files.pythonhosted.org/packages/b1/05/dba51a00eb30bd947791b173766cbed3492269c150a7771d2750000c965f/ujson-5.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12b5e7e22a1fe01058000d1b317d3b65cc3daf61bd2ea7a2b76721fe160fa74d", size = 53190, upload-time = "2025-08-20T11:55:36.384Z" },
{ url = "https://files.pythonhosted.org/packages/03/3c/fd11a224f73fbffa299fb9644e425f38b38b30231f7923a088dd513aabb4/ujson-5.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0180a480a7d099082501cad1fe85252e4d4bf926b40960fb3d9e87a3a6fbbc80", size = 57600, upload-time = "2025-08-20T11:55:37.692Z" },
{ url = "https://files.pythonhosted.org/packages/55/b9/405103cae24899df688a3431c776e00528bd4799e7d68820e7ebcf824f92/ujson-5.11.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:fa79fdb47701942c2132a9dd2297a1a85941d966d8c87bfd9e29b0cf423f26cc", size = 59791, upload-time = "2025-08-20T11:55:38.877Z" },
{ url = "https://files.pythonhosted.org/packages/17/7b/2dcbc2bbfdbf68f2368fb21ab0f6735e872290bb604c75f6e06b81edcb3f/ujson-5.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8254e858437c00f17cb72e7a644fc42dad0ebb21ea981b71df6e84b1072aaa7c", size = 57356, upload-time = "2025-08-20T11:55:40.036Z" },
{ url = "https://files.pythonhosted.org/packages/d1/71/fea2ca18986a366c750767b694430d5ded6b20b6985fddca72f74af38a4c/ujson-5.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aa8a2ab482f09f6c10fba37112af5f957689a79ea598399c85009f2f29898b5", size = 1036313, upload-time = "2025-08-20T11:55:41.408Z" },
{ url = "https://files.pythonhosted.org/packages/a3/bb/d4220bd7532eac6288d8115db51710fa2d7d271250797b0bfba9f1e755af/ujson-5.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a638425d3c6eed0318df663df44480f4a40dc87cc7c6da44d221418312f6413b", size = 1195782, upload-time = "2025-08-20T11:55:43.357Z" },
{ url = "https://files.pythonhosted.org/packages/80/47/226e540aa38878ce1194454385701d82df538ccb5ff8db2cf1641dde849a/ujson-5.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e3cff632c1d78023b15f7e3a81c3745cd3f94c044d1e8fa8efbd6b161997bbc", size = 1088817, upload-time = "2025-08-20T11:55:45.262Z" },
{ url = "https://files.pythonhosted.org/packages/7e/81/546042f0b23c9040d61d46ea5ca76f0cc5e0d399180ddfb2ae976ebff5b5/ujson-5.11.0-cp312-cp312-win32.whl", hash = "sha256:be6b0eaf92cae8cdee4d4c9e074bde43ef1c590ed5ba037ea26c9632fb479c88", size = 39757, upload-time = "2025-08-20T11:55:46.522Z" },
{ url = "https://files.pythonhosted.org/packages/44/1b/27c05dc8c9728f44875d74b5bfa948ce91f6c33349232619279f35c6e817/ujson-5.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7b136cc6abc7619124fd897ef75f8e63105298b5ca9bdf43ebd0e1fa0ee105f", size = 43859, upload-time = "2025-08-20T11:55:47.987Z" },
{ url = "https://files.pythonhosted.org/packages/22/2d/37b6557c97c3409c202c838aa9c960ca3896843b4295c4b7bb2bbd260664/ujson-5.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cd2df62f24c506a0ba322d5e4fe4466d47a9467b57e881ee15a31f7ecf68ff6", size = 38361, upload-time = "2025-08-20T11:55:49.122Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" },
{ url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" },
{ url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" },
{ url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" },
{ url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" },
{ url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" },
{ url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" },
{ url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" },
{ url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" },
{ url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" },
{ url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" },
{ url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" },
{ url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" },
{ url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" },
{ url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" },
{ url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" },
{ url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" },
{ url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" },
{ url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" },
{ url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" },
{ url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" },
{ url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" },
{ url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
@@ -482,3 +776,86 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "whenever"
version = "0.9.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "tzlocal", marker = "sys_platform != 'darwin' and sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/9a/6b12d5708a703010877bec0efc5172ae8a851516abc13cd4543ce4d02a20/whenever-0.9.5.tar.gz", hash = "sha256:9d8f2fbc70acdab98a99b81a2ac594ebd4cc68d5b3506b990729a5f0b04d0083", size = 259436, upload-time = "2026-01-11T19:47:51.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/7d/f05ea580a1c99575c82a1b15d6c64826cb0e53101ad06020df109a51aa29/whenever-0.9.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:59c65af262738a9f649e008d51bbba506223ca7a7f2a5c935b9b0975f58227ed", size = 467312, upload-time = "2026-01-11T19:47:20.275Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6d/15102f4703ce111b1f1066afb2d62c40575ad2be91c7896442a77bf4d937/whenever-0.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a957d5e2bb652cb802eee2e1683c62023a9ba696e82483c4629b829f6c16fb5d", size = 438830, upload-time = "2026-01-11T19:47:10.453Z" },
{ url = "https://files.pythonhosted.org/packages/1c/cb/a603e92c85b3d70b64380e737d080329fd731d3ab2ef30475d099bb1f85a/whenever-0.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5e6da8f343a7522f1b2830b0e365e5417086da7a67d3086fbfc83d332b55c56", size = 453060, upload-time = "2026-01-11T19:45:28.885Z" },
{ url = "https://files.pythonhosted.org/packages/b0/36/a9f07ec85ddc415f3500d00acdfeb5b63a6dcaba91f2802e1061f4a32240/whenever-0.9.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:767f34bd6dd209afaebebb481de3af03d9a61271f5455c7e4dffedfac63ed6e9", size = 498406, upload-time = "2026-01-11T19:45:48.771Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f8/bb0891fce7605b44d0dc4d0a09c27dd8408e75e90c1f1adff1a3ef6ab0c0/whenever-0.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42d707e3f2c07a0c688c27c16f3e9bbe56551b72ec1fb9bc8df32b8d45e54520", size = 487042, upload-time = "2026-01-11T19:46:08.306Z" },
{ url = "https://files.pythonhosted.org/packages/66/35/3ea67d9e4fbebb619dc7801d678b3b40408586619a8bf0a18c7d20e9dc91/whenever-0.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e0a004b2b143146dbfafcf023b7bf007333e73c2d0955f44762a3131cbfcb47", size = 521880, upload-time = "2026-01-11T19:46:18.987Z" },
{ url = "https://files.pythonhosted.org/packages/62/5f/24f6c09a4f1e66b9257b88711164cad6262dc8c8d51d1a758d144e26f249/whenever-0.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9b760e8968f1d2681687dfd0ac06d7cb310603710faad0b85d3d9b86ac792f3", size = 483374, upload-time = "2026-01-11T19:46:48.425Z" },
{ url = "https://files.pythonhosted.org/packages/93/af/335af2917c6fdb58fd01dcf4922dd5d251ce70387d9bea60e723d45a01ae/whenever-0.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e7fb3da8014c3170b53a629b278105bcb3679c0835db7d88c27798058686181a", size = 521134, upload-time = "2026-01-11T19:46:28.778Z" },
{ url = "https://files.pythonhosted.org/packages/dc/05/fa3c60413befbd2c5c52fa9c65b840bcb4d73e8ed32e2df69696640481cc/whenever-0.9.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ce9a6d8d05db2aefb13b0f49d4a581b13035f966c967cd3e7f98a2bb6016ab66", size = 636617, upload-time = "2026-01-11T19:45:38.49Z" },
{ url = "https://files.pythonhosted.org/packages/3c/56/63668cd8ae23c0219e75b254ba926425d44ddb3482f6c95aac30b00cee2f/whenever-0.9.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9c52bd3ff2246e9c03d8e72c5d28044f35070b7c23d44b93592cd5b61c45f36", size = 769639, upload-time = "2026-01-11T19:45:57.548Z" },
{ url = "https://files.pythonhosted.org/packages/25/ff/3781f3867897974b39eea74a83f337479d98c76cf74608b8b6fa9c7b59a7/whenever-0.9.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77086386feba0bee7bc030b86e118a521c18b3741ad3c69e0c8e7b601a763a2a", size = 733169, upload-time = "2026-01-11T19:46:38.25Z" },
{ url = "https://files.pythonhosted.org/packages/e6/85/09d9e4e17ef78b380e57c6b89274fa37c8b250321d5e09f9efa8a58c941e/whenever-0.9.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:45ae1ce8bbb3668f6281251a0d22ac81bdf563909306b0d69eaa630ceb1051b9", size = 696787, upload-time = "2026-01-11T19:46:58.887Z" },
{ url = "https://files.pythonhosted.org/packages/0b/96/78408214f359016c0153f103decc6e3566426f809ea0c250d638b4315de6/whenever-0.9.5-cp312-cp312-win32.whl", hash = "sha256:6f2d87720d45a891f606420fcc073796740266f1bca718607569d44b7e0e6639", size = 416143, upload-time = "2026-01-11T19:47:30.894Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/ef3898377abf417a9e30692c1299cb0623f1f42ec6f8d8ba07809ca58eab/whenever-0.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:a5bfb110948bc668728dbb87f8985e6004dedf37ed910a946ddbe31985ada006", size = 440123, upload-time = "2026-01-11T19:47:41.822Z" },
{ url = "https://files.pythonhosted.org/packages/48/c7/f5626d4cc477116e6ec3191515981a60cdd021adc97046cb422f6bed327c/whenever-0.9.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d7de2cac536fe28f012928b74e0a3ed15dd8f4ac9940cfce35104cca345937f0", size = 467317, upload-time = "2026-01-11T19:47:21.582Z" },
{ url = "https://files.pythonhosted.org/packages/ea/81/d59f0e226ef542fc4bc86567d7b9e2bf9016c353b1f83661ee3913a140a7/whenever-0.9.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e00bc8f93fa469c630aad9dfdc538587c28891d6a4dce2f0b08628d5a108a219", size = 438838, upload-time = "2026-01-11T19:47:11.553Z" },
{ url = "https://files.pythonhosted.org/packages/b5/dc/090732e6e75f15a6084700d3247db6aa1f885971b637531529c62c4ba1c6/whenever-0.9.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ac83555db44e1fcfc032114f45c09af0ed9d641380672c8deb7f1131a0fd783", size = 453069, upload-time = "2026-01-11T19:45:30.238Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f9/b4744370a9491689fc46327a17e3246a9e107b19efa0e24f51d9dcbe6aeb/whenever-0.9.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c486b1db0b833a007e2b8264f265079e57593925131b83ab69514fb9563b8d9", size = 498413, upload-time = "2026-01-11T19:45:49.811Z" },
{ url = "https://files.pythonhosted.org/packages/62/78/45ef94ef51cb97c514650c2af33cdfc3de3cf967fcf12955c0e3a430a6cf/whenever-0.9.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a57e8f207b5ad9b0c6bcd3efee38d65a70434c1bdcb95cc5d2e2b7a5237c208", size = 487043, upload-time = "2026-01-11T19:46:09.528Z" },
{ url = "https://files.pythonhosted.org/packages/6b/23/f45082a60471ee79edc5bf5c09c1d4f55324a2c740fb138a3a691f19d2b6/whenever-0.9.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e1d66a0ece1dabd6952eb7b03a555843bada42bed4683ea71b113f194714144", size = 521879, upload-time = "2026-01-11T19:46:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/56/5c/8d6dc529595b5387f5727cd6c2c5b8615851d95fec5c599a61ef239cc1b3/whenever-0.9.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4056aaff273a579f0294e5397a0d198f52906bbaf7171da0a12ecd8cdf5026c", size = 483388, upload-time = "2026-01-11T19:46:49.746Z" },
{ url = "https://files.pythonhosted.org/packages/29/52/366c4658286a5986acb76473e9e21457af2bd1b53dc050cee00160106eaa/whenever-0.9.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b0e7c669be2897108895f3283e23fe761782c9c1abb0ce8834e57e09ce5cdfd", size = 521135, upload-time = "2026-01-11T19:46:30.122Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cb/82bbe5ce0879dd38d201214323afad418f1d721111311807c75aaf2e2d16/whenever-0.9.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e0c7a146da12c37d68387c639f957e3624cf7a55a1d534788d5aea748a261e7", size = 636617, upload-time = "2026-01-11T19:45:39.656Z" },
{ url = "https://files.pythonhosted.org/packages/ec/32/8dcb6facf229b8e2b5c73d0c50adf800593769e34ab5abcd77828451c4ed/whenever-0.9.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70c5f3013d6a1b7c40adbc05bc886e017f2b981739aaceafce47069da5b7e617", size = 769643, upload-time = "2026-01-11T19:45:59.047Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/6ab4308550851209982b78692d14a9729bf20b6e814ddedf2658e46a6cd8/whenever-0.9.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a8ce45c7271b85292e9d25e802543942b0249e73976c8614d676f0b561b25420", size = 733170, upload-time = "2026-01-11T19:46:39.457Z" },
{ url = "https://files.pythonhosted.org/packages/47/38/7ad290229e8df99107d04e21ecef9d09f215428ab3aa06c0e46d51cba860/whenever-0.9.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f1db334becda2d7895bc31373b1e53ff73e3a0ea208e2845b4f946ad9a5e667", size = 696797, upload-time = "2026-01-11T19:47:00.001Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8e/94c4fba5ef73bb817c557e19bb75665cb5761e4badfb60950bd4d3cc5a01/whenever-0.9.5-cp313-cp313-win32.whl", hash = "sha256:4079eabfa21d1418bcc2c3d605e780cfec0ad8161317e12fa0f1ef87cb08fe7d", size = 416152, upload-time = "2026-01-11T19:47:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/81/b4/17d4bc76ca73c21eb5b7883d10d8bacb7ce7a30a8f36501db2373c63ffb3/whenever-0.9.5-cp313-cp313-win_amd64.whl", hash = "sha256:16497a2b889aeeb0ee80a0d3b9ce14cdb63d7eb7d904e003aae3cd4ac67da1e8", size = 440135, upload-time = "2026-01-11T19:47:42.963Z" },
{ url = "https://files.pythonhosted.org/packages/30/d3/13db725dfef188bec32e51128e888a869ee97d26d965cfe39edf1321ca8f/whenever-0.9.5-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f222b1d59cdeadc1669657198e572727db8640e0b534e4e1ad0491417ec98379", size = 466415, upload-time = "2026-01-11T19:47:22.7Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e9/4a9deeb616fbdf8e0c75fc78f13c37270d1d7e7ec776ffdf306262b8c593/whenever-0.9.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6f4ae436a4db888cd5b48a88fb83ae3f70c468a7d0018cc9572e26e99b9f591f", size = 437247, upload-time = "2026-01-11T19:47:13.1Z" },
{ url = "https://files.pythonhosted.org/packages/15/02/21866bddf5f819f1a3a59463f4637755590cb4045d25a50fb6ab60db97a6/whenever-0.9.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68b7c201fc49123159b1518147e48791e95a19cba0b3ebb646a446eb7e95148f", size = 451556, upload-time = "2026-01-11T19:45:31.645Z" },
{ url = "https://files.pythonhosted.org/packages/51/14/2c84db8f23577ddfd6ecd90c8d192d21dfa75aea57cda44eb53048b27e69/whenever-0.9.5-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5d2a8dd9c565eacb199ff704d96fc95e74f6c324639d970cac3b4d80eefbaebb", size = 498034, upload-time = "2026-01-11T19:45:50.926Z" },
{ url = "https://files.pythonhosted.org/packages/cc/37/cb69a10573382bbfd14954b5386bf5c47290551246d497a9aa733a5b5a8a/whenever-0.9.5-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d965ee9dafe1eb37ec273f7520e26b70d8b1bb075c06b0bb9e434363ca66fd", size = 487381, upload-time = "2026-01-11T19:46:10.752Z" },
{ url = "https://files.pythonhosted.org/packages/47/c4/fcc0f886322f72c5d83ee669ed1aa253c4e58625e5bf64fede03a69aa7a0/whenever-0.9.5-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035594337920cc9b9eb4531a97037971c109cc60e9a7a53e49e65722a1db7535", size = 520975, upload-time = "2026-01-11T19:46:21.789Z" },
{ url = "https://files.pythonhosted.org/packages/29/cc/ad7e9c225a97be16b97a20c46802991908a12f9920d83071d669ed7ed79c/whenever-0.9.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c5b04d6fcb0e4ee9f6e62e4400fd99c5f188449314f5863a865be33a653984", size = 481911, upload-time = "2026-01-11T19:46:50.87Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/4fa12c102d6aa3190ce1f34dc9f709776e9c3b1537328c46b6444b9a1638/whenever-0.9.5-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04755e5a2b802451332d2a18bf1c45d74f99cd16e61a0724fe763907f32cf7f8", size = 520068, upload-time = "2026-01-11T19:46:31.175Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e7/08d7c7e59130e8379240fda96cd68ae60435f6d6b6e356a5900c6856e48f/whenever-0.9.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f7a37894b9cfc1e68b23f25d19ac38da68454f6bf782eab4cfac58578092daa", size = 635235, upload-time = "2026-01-11T19:45:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/c1/6d/49c5566c5560606659dfed8dd49cadfa4d6a14b3f0d7a3d5a46a0c4e5624/whenever-0.9.5-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55f10416bed357e0d1d1e4bf5e625974fe0a01348117161b4a0d2a69361c0efc", size = 769640, upload-time = "2026-01-11T19:46:00.112Z" },
{ url = "https://files.pythonhosted.org/packages/b8/09/e05cdc29897b97f3e3c0ffd485bf165f779c6d89bc4c0c0a76592e7c2e8c/whenever-0.9.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:75bfb8c778db727230fa5e7e2e256615be287d00cb1132b93789525e4d1c5dc6", size = 731095, upload-time = "2026-01-11T19:46:40.693Z" },
{ url = "https://files.pythonhosted.org/packages/1c/d1/dcb635c2951ed5a112d340f73bf3cc81d69d1af7c0b1a33d0ca9d3299a26/whenever-0.9.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdc5ca9cddc9264d587469d994e046ae9c55cfdb6b9048927fe2922ae8763a1", size = 694782, upload-time = "2026-01-11T19:47:01.532Z" },
{ url = "https://files.pythonhosted.org/packages/56/06/1544751be5e96aeefb59b5fedc1f8f4b0f238197ce21496d5960da20f18c/whenever-0.9.5-cp313-cp313t-win32.whl", hash = "sha256:274b7acfdc1fdfb29ab55299ce0a90cabedd079ff9940e24d05e3de4b04e1638", size = 415984, upload-time = "2026-01-11T19:47:33.34Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0e/c87cf223816731718e5fef792b1f8c6d124c9fcbec677d4b7c9fcfe8f44f/whenever-0.9.5-cp313-cp313t-win_amd64.whl", hash = "sha256:7c9429fa1249aa39385716644453d9a4de0974b537974988b175d3ba7175a43f", size = 438641, upload-time = "2026-01-11T19:47:44.295Z" },
{ url = "https://files.pythonhosted.org/packages/d7/e4/5e78fc3b8a2bc84e0f055a89a9f4eabc06bc435463ea112265e1e73592f5/whenever-0.9.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e381120682f6675cccbcba13b4c88b033f31167f820c161936fa99556e455f03", size = 468861, upload-time = "2026-01-11T19:47:24.176Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a3/bd597dff8c82b2a1568813015ff25f3df2b6817872c8061176f04e3aad0f/whenever-0.9.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:caa7be36a609d765b9ea1c49159c446521b6ac33ff60b67214aa019713e05dda", size = 441322, upload-time = "2026-01-11T19:47:14.184Z" },
{ url = "https://files.pythonhosted.org/packages/82/3c/fcb3a85cfdc12783c22d44640be538f78bcc3946cc3fb454139cdaebd119/whenever-0.9.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:037fd64e27df6116c3ffe8e96cdb6562051b0eda3f0bc7c35068fe642a913da9", size = 455230, upload-time = "2026-01-11T19:45:32.978Z" },
{ url = "https://files.pythonhosted.org/packages/e9/17/83c83dddb55f7576f3748b077774d2bd8521bc2220bd2fa6f711ad8f046e/whenever-0.9.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c864363a2ff97c0b661915112140b9229132586d035ee4989cbe914aa3e3df4", size = 499473, upload-time = "2026-01-11T19:45:52.257Z" },
{ url = "https://files.pythonhosted.org/packages/07/11/406d26cf641ef9e3cdd0f11237a2a49b06f225c5f57257850a2e101c8760/whenever-0.9.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3d2c3f7bc9ceaef7d58fb40f0a4602a1947691eac1bff2623d2f923d4374543", size = 488680, upload-time = "2026-01-11T19:46:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/b5/15/7d85e7f5710fc4dfaec830da0b069791ca6b7108939ab06a0440cfd11f7a/whenever-0.9.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce45c364734e63a459b3b816f1ce159bb4d4f570faa2b0fa504d6d329dd654a3", size = 522715, upload-time = "2026-01-11T19:46:22.835Z" },
{ url = "https://files.pythonhosted.org/packages/3f/53/ec6eb7d71624437571ed22c9fbfa7c8532b6868c94138e0775f61c69101c/whenever-0.9.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8fc8e6e500f60299be739e19ffb830b2d9f50da017e14cd832b31f31ee93166", size = 484518, upload-time = "2026-01-11T19:46:51.971Z" },
{ url = "https://files.pythonhosted.org/packages/17/48/220d4a7335d91face9e5c16ec5228ad454ff58cfb3713c291c90d54b3e84/whenever-0.9.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b422bb8651f094f4420749f4c0d72287121480a495c6d247035bd52a72c4586c", size = 522611, upload-time = "2026-01-11T19:46:32.224Z" },
{ url = "https://files.pythonhosted.org/packages/be/fd/d1b270585f459f469af7c08e03ba2bcfe058449162354bf809e671ccfb1e/whenever-0.9.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:87635742acdd0adbbed62c6ed2ec4a49e25b1e3c7374f13f39d2c5f9229ae24f", size = 638421, upload-time = "2026-01-11T19:45:42.843Z" },
{ url = "https://files.pythonhosted.org/packages/2f/0c/7fc33cdb8da7d2b14cd7129f37b48c1aa91644eabc07780333979bdf15f6/whenever-0.9.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1ae39fff83ad73ec563db65707e5bde1e74b350bf4309cfb265d527a6cd222c6", size = 770967, upload-time = "2026-01-11T19:46:01.614Z" },
{ url = "https://files.pythonhosted.org/packages/fc/39/a379dcf478136619050cee1ed789a9bdb0a6537fe8c43b23ea832e38c8ce/whenever-0.9.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae5b01eb93c3ad703d7870d9e6566c389b889e97d7efec30c73abf64aa95b47f", size = 735292, upload-time = "2026-01-11T19:46:41.75Z" },
{ url = "https://files.pythonhosted.org/packages/09/a7/139ac3b81239f1d6275e8eb792c52a48adc6ba303b7e0c9d8ca405a86c76/whenever-0.9.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5e278daa1e650958f9ba7084b1c3c245444acf3e30db9cca5141658347a7c67d", size = 697998, upload-time = "2026-01-11T19:47:02.681Z" },
{ url = "https://files.pythonhosted.org/packages/2e/37/141041e40f76dd530183c715faae1d8b21e47867fe159b8b88c4960eae2d/whenever-0.9.5-cp314-cp314-win32.whl", hash = "sha256:1b62c62a00bd93e51f71d231aef3178f1c22566b2818c190e755c96fd939b91a", size = 418248, upload-time = "2026-01-11T19:47:34.855Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7b/c86657bec1a679042c239a247bc3e6a92f3acc53ec858d13c1e770777f41/whenever-0.9.5-cp314-cp314-win_amd64.whl", hash = "sha256:483c2736ce367dbda01133700b064dfa8b6ed24833fc984e1d767e81aa02a189", size = 442418, upload-time = "2026-01-11T19:47:45.727Z" },
{ url = "https://files.pythonhosted.org/packages/55/3b/2d37c3438214a1bf6caddddf825d5ff86e6cfcdf91a41fa1175334a2bab0/whenever-0.9.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7644b6c35f9c1fa7b7f6cb03bf5764207374bf3e2049cb49e7578648b0d27dd9", size = 468132, upload-time = "2026-01-11T19:47:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0d/284336cf592107ee65c61030f1ca9717214365661d642aadf9fbce38bf8d/whenever-0.9.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be31d5799b49e593da413cddc73728a51d0e80461262c7a4ed66b95819a69016", size = 438449, upload-time = "2026-01-11T19:47:15.283Z" },
{ url = "https://files.pythonhosted.org/packages/5a/50/a13b1869b06a8838fab79e31707bf882f836764dcf1260ce170c1084011b/whenever-0.9.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6691e1157026773354c77771cd72db727bbd0ee36121cd139cf16f03cddc1699", size = 452750, upload-time = "2026-01-11T19:45:34.047Z" },
{ url = "https://files.pythonhosted.org/packages/79/9d/3b9006df1289c78a2e3b8bdd6498430ffee57cb851c938a9da9a7f2ca11d/whenever-0.9.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7e31f4bb592cdd433dde40acef83f968b89178b42169724e0b200a318536c4f", size = 497651, upload-time = "2026-01-11T19:45:53.477Z" },
{ url = "https://files.pythonhosted.org/packages/c3/94/6e66e55649ed8c2e742ca3284e3dd158b0cb3d1272fc7bacc5b0938eba12/whenever-0.9.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f8100609213040dda06e1d078bc43768d5d16dbaabfa75b13c5adb5189056be", size = 488006, upload-time = "2026-01-11T19:46:13.624Z" },
{ url = "https://files.pythonhosted.org/packages/c2/62/5c0bea83a518f3a76377400bdc29215d2f964be4d1bc8d3886f91824a4cc/whenever-0.9.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38c415a27661f830320c96f3cf6af99ea96b712c9c3a14975ff8c34263d125c6", size = 521517, upload-time = "2026-01-11T19:46:23.874Z" },
{ url = "https://files.pythonhosted.org/packages/80/0a/e93c2ccfb993d5ac1434913d9a2cea2e79d93affbbec06942c748d04681f/whenever-0.9.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee6fce59f948dd30c01107d55d40d2c035c6dfd3005c49d535e10175832601c", size = 483401, upload-time = "2026-01-11T19:46:53.531Z" },
{ url = "https://files.pythonhosted.org/packages/4d/05/d142ca3b56a1012082d0d2b71f9e9e1b9b42ba266d92294f841cb7acc07e/whenever-0.9.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef181122ffcef41551d5eef483bf12ea48d1115554911a674e404e640ef0fd26", size = 520096, upload-time = "2026-01-11T19:46:33.746Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b9/53a3ebb2c44e7215fa8c6720361a5c78891538f9c934935ffbf508801aae/whenever-0.9.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4970bc5c37d4fff22e299314bebebb2d1a88133a67a51f87013dad0ac6535fb", size = 637251, upload-time = "2026-01-11T19:45:43.849Z" },
{ url = "https://files.pythonhosted.org/packages/ea/17/4f15ae9977972a7b8cd2819c4f03b63884416e4064d6d7366164cd6093f4/whenever-0.9.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:db8043800bd6eba7e681a316cba3734c8d10dad044b4a0dcf5fb0129665902ce", size = 769681, upload-time = "2026-01-11T19:46:03.041Z" },
{ url = "https://files.pythonhosted.org/packages/a0/a5/280dd222b31f478cef551f0d6554ab224e436b7b4ba1456d3cad864de756/whenever-0.9.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:067235cd1be14f709219eae4e6ae639ec19418e7860d1be1e59c5e4bd9cb3542", size = 732487, upload-time = "2026-01-11T19:46:43.395Z" },
{ url = "https://files.pythonhosted.org/packages/8d/33/d2bed59cfe7cf358e78bfa26bad4414a0a3d99c6aa5e5b76aea448661bae/whenever-0.9.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:81556be508452f5fe7d7e7837718ff91ddb16089a705febe178fa8baabc99236", size = 696906, upload-time = "2026-01-11T19:47:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/36/4a/b27b182fdf24c9760a91481c6b97d9f536a15f6bc7c33d750eccaa245670/whenever-0.9.5-cp314-cp314t-win32.whl", hash = "sha256:681c9f4d322182df28037a33f0d4eadf7577fcd590130f7a5d23763a86ab5687", size = 417599, upload-time = "2026-01-11T19:47:36.201Z" },
{ url = "https://files.pythonhosted.org/packages/6d/93/712f65c4fc3c188b0c163cd650cb04a46f993a22d1ad3f66a3e4a42a39f8/whenever-0.9.5-cp314-cp314t-win_amd64.whl", hash = "sha256:37242c895078bb1bfc26bea460f6d92dc5bdfe4a59e95a4f9e01f99b90f0157e", size = 440654, upload-time = "2026-01-11T19:47:46.924Z" },
{ url = "https://files.pythonhosted.org/packages/91/fb/1b112af72e5a2b5ac66b5a6a478cc220117224c1481a2b1a35e87222a791/whenever-0.9.5-py3-none-any.whl", hash = "sha256:cc600303e703d57cc42c5db1c81bbc3becb8ef6fde5c46aee5d68c292239fc4b", size = 64873, upload-time = "2026-01-11T19:47:49.585Z" },
]