33 KiB
Aether — Management Platform for Incus + OVN
Aether is a web-based management application that connects to Incus clusters and Operations Center instances. It provides VM/container deployment via blueprints, OVN network management, NSX firewall rule translation to OVN ACLs, HAProxy load balancer management, Ansible automation, and RBAC.
Deployment
Requirements
- Image: Ubuntu Noble (24.04) golden image (~6.6 GiB compressed, 200 GiB virtual disk)
- Resources: 8 GiB RAM minimum
- Networking: needs direct LAN/VLAN access (macvlan recommended)
- Services: PostgreSQL (bundled), FFSDN Go application on port 8443
Deployment on Incus cluster
# Import image
incus image import sources/aether-golden-image-v6.tar.gz \
--alias aether-golden-image-v6 <remote>:
# Create VM (200 GiB disk required — qcow2 virtual size)
incus init <remote>:aether-golden-image-v6 <remote>:aether --vm \
--target <node> --config limits.memory=8GiB -d root,size=200GiB
# Configure macvlan networking for direct VLAN access
incus config device remove <remote>:aether eth0 2>/dev/null
incus config device add <remote>:aether eth0 nic nictype=macvlan parent=mgmt
# Start and wait ~30s for boot
incus start <remote>:aether
# Run post-deploy (configures static IP, regenerates SSH keys, sets up DB)
incus exec <remote>:aether -- /home/ffsdn/post_deploy.sh <IP/PREFIX> <GATEWAY> <DNS>
Disk size considerations
The golden image is a qcow2 with 200 GiB virtual size but only ~11 GiB actual data. The target node must have at least 200 GiB of allocatable storage (even if thin-provisioned). On a fresh IncusOS node with a 64 GiB disk, only ~29 GiB is free after the OS. Nodes with 50 GiB disks (~8.5 GiB free) cannot host Aether. Target the node with the most available storage.
Lab deployment details
| Setting | Value |
|---|---|
| VM name | aether |
| Location | oc-node-01 |
| IP | 192.168.102.160/22 (VLAN 69) |
| Gateway | 192.168.100.1 |
| DNS | 192.168.100.1 |
| Port | 8443 (HTTPS) |
| RAM | 8 GiB |
| Disk | 200 GiB (virtual), ~11 GiB actual |
Web UI
Access at https://<IP>:8443. Default credentials: admin / (set during setup).
REST API
Aether provides a documented REST API with OpenAPI 3.0 spec.
| Resource | URL |
|---|---|
| Swagger UI | https://<IP>:8443/api/docs |
| OpenAPI spec | https://<IP>:8443/api/swagger.yaml |
| Auth endpoint | POST /api/auth/token |
Authentication: JWT Bearer token. Obtain via username/password:
# Get a JWT (valid 24 hours)
AETHER_TOKEN=$(curl -sk -X POST https://192.168.102.160:8443/api/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"YOUR_PASSWORD"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Use the token
curl -sk https://192.168.102.160:8443/api/clusters \
-H "Authorization: Bearer $AETHER_TOKEN"
JWT payload includes: sub (username), id (user ID),
is_ffsnd_full_admin (bool), is_ffsnd_acl_admin (bool), exp (expiry).
Rate limits: 3 req/s on auth endpoint, 10 req/s on all others. Exceeding returns HTTP 429.
API coverage (v6.4.317, API v1.0.0):
| Tag | Endpoints | Description |
|---|---|---|
| Authentication | POST /api/auth/token |
JWT token issuance |
| Clusters | GET /api/clusters |
List clusters (admin only) |
| Local ACLs | GET/POST /api/clusters/{id}/rules |
Per-cluster firewall rules |
| Local ACLs | PUT/DELETE /api/clusters/{id}/rules/{rule_id} |
Update/delete rules |
| Global ACLs | GET/POST /api/global/rules |
Global firewall rules |
| Global ACLs | PUT/DELETE /api/global/rules/{rule_id} |
Update/delete global rules |
Features not yet in the API (UI-only as of v6.4.317):
- AWX endpoint registration and cluster AWX binding
- Instance deployment and management
- Blueprint design and deployment
- Inventory, networking, OVN management
- HAProxy load balancers
- RBAC user management
- Health checks
The API is actively developed. Check /api/swagger.yaml in newer Aether
versions for additional endpoints — features may move from UI-only to API.
ACL rule schema (source/destination support):
- IP addresses:
192.168.1.1 - CIDR:
10.0.0.0/8 - Metadata expressions:
user.key=value,user.key=^prefix,user.key=*substring - Boolean logic:
(user.env=prod AND user.tier=web) OR user.region=^eu - Comma-separated:
192.168.1.1,user.app=web - Protocols:
tcp,udp,icmp4 - Actions:
allow,drop,reject - Direction:
ingress,egress,in_out - Apply to:
instances,dfw_ovn
Post-deploy script
/home/ffsdn/post_deploy.sh performs 7 steps:
- Configure network (netplan)
- Apply network configuration
- Regenerate SSH host keys
- Wait for FFSDN to create PostgreSQL user
- Transfer database ownership
- Transfer table and sequence ownership
- Restart FFSDN service
The script deletes itself after successful execution.
Navigation
The sidebar provides access to all features:
| Menu Item | URL Path | Description |
|---|---|---|
| Home | /home |
Dashboard, welcome page |
| Manage Global FW rules | /global-acls |
Global firewall / ACL management |
| Manage Cluster FW rules | (submenu) | Per-cluster firewall rules |
| Manage RBAC | (submenu) | Role-based access control |
| Manage VMs/Containers | /infra |
View and manage instances |
| Deploy VM/Container/Blueprint | /deploy |
Launch new workloads |
| Deployed Blueprints | /deployedblueprints |
Track blueprint deployments |
| Blueprint Design | /blueprintdesign |
Create deployment templates |
| Ansible Automation | /awx-endpoints |
Ansible playbook management |
| Manage INCUS Clusters | /incus-infra |
Connect and manage Incus clusters |
| Operations Center | /operationcenter |
Connect to OC instances |
| HAProxy Load Balancers | /haproxy |
Load balancer configuration |
| Trace Network Flow | /traceflow |
Network flow analysis (NSX → OVN ACL translation?) |
| View Live GO Log | /logs/live |
Real-time application logs |
| AETHER Ledger | /logs |
Audit/activity ledger |
| View Sync Logs | /synclogs |
Synchronization logs |
| AETHER Health | /health |
System health checks |
| Change Password | /change-password |
Change admin password |
| Licensing | /licensing |
License management |
| Settings | /settings |
Application settings |
Initial Setup
Step 1: Connect Incus Cluster
Navigate to Manage INCUS Clusters (/incus-infra) → click
Add/Edit/Delete INCUS clusters from/to AETHER.
The form requires:
- Cluster Name: label for this cluster (e.g.,
oc-lab-cluster) - URL: Incus API endpoint (e.g.,
https://192.168.102.141:8443) - Trust Token: generated on the cluster
Generate a trust token on the Incus cluster:
incus config trust add <remote>:AETHER
Paste the token into the form and click Add Cluster. The cluster appears in the "Current INCUS Clusters" table with its TLS certificate fingerprint and expiry date.
After adding, select the cluster from the Select Cluster dropdown on the INCUS Infrastructure Management page. This loads the cluster dashboard with tabs:
| Tab | Description |
|---|---|
| Clustering: Members | Cluster nodes with status, memory, load, roles |
| Clustering: Cluster Groups | Logical groupings of nodes |
| Storage: Pools | Storage pool configuration |
| Storage: Volumes | Storage volume management |
| Profiles | Instance profiles with devices and config |
| Operations | Running and completed operations |
| Warnings | Cluster warnings |
| Settings | Cluster configuration |
| Images | Cached images across the cluster |
| Configuration | Cluster-level config keys |
| Instances | All instances with actions (Start/Stop/Migrate/Console/etc.) |
| Networking | Networks (bridge, OVN, physical) with View/Edit |
| ACLs | Network ACL management |
| Address Sets | Address set management for ACLs |
| OS | IncusOS node management |
The Instances tab provides per-instance action buttons: Start, Restart, Freeze, Unfreeze, Stop, Migrate, Snapshot, Console, Logs, Delete. Instances can be filtered by type (VM/Container) and status. Each row shows name, type, status, IPv4, memory usage, image, snapshots, and location (cluster member).
The Cluster Members view shows real-time memory and load bars per node, OVN roles, and an Evacuate action button.
Step 2: Connect Operations Center
Navigate to Operations Center (/operationcenter) → click
+ Add Operations Center.
The form requires:
- Name: label for this OC (e.g.,
oc-lab) - URL: OC API endpoint (e.g.,
https://192.168.102.120:8443) - Certificate (PEM): client TLS certificate (PEM format)
- Private Key (PEM): matching private key (PEM format)
Unlike Incus (which uses trust tokens), OC uses mutual TLS — Aether
connects with a client certificate that must already be in the OC trust store.
Use the same client.crt and client.key that were injected into the OC
seed during deployment (typically at ~/.config/incus/client.crt and
~/.config/incus/client.key).
Click Test Connection to verify — a successful test shows:
Connected! API: 1.0 (devel).
Click Save to add the OC. The OC appears in the "Configured Operations Centers" table with status, fingerprint, last connected timestamp, and Edit/Test/Delete actions. A green Manage badge indicates an active connection.
Connection comparison
| Incus Cluster | Operations Center | |
|---|---|---|
| Auth method | Trust token | Mutual TLS (client cert + key) |
| Token/cert source | incus config trust add |
Existing client cert from ~/.config/incus/ |
| API version | Incus REST API | OC API 1.0 |
| Port | 8443 | 8443 |
Lab State After Setup
Connected infrastructure
| Connection | Name | URL | Status |
|---|---|---|---|
| Incus Cluster | oc-lab-cluster | https://192.168.102.141:8443 | Connected |
| Operations Center | oc-lab | https://192.168.102.120:8443 | Connected |
Cluster members (as seen in Aether)
| Node | URL | Status | RAM | Disk | Roles |
|---|---|---|---|---|---|
| oc-node-01 | https://192.168.102.140:8443 | Online | 20 GiB | 64G (29.5G pool) | ovn-chassis, database |
| oc-node-02 | https://192.168.102.141:8443 | Online | 20 GiB | 100G (64.4G pool) | ovn-chassis, database |
| oc-node-03 | https://192.168.102.142:8443 | Online | 20 GiB | 100G (64.4G pool) | ovn-chassis, database-leader |
Instances visible in Aether
| Name | Type | Status | IPv4 | Network | Location | Tags |
|---|---|---|---|---|---|---|
| aether | VM | Running | 192.168.102.160 | macvlan (mgmt) | oc-node-01 | - |
| aether-test-ct | Container | Running | 10.207.217.22 | incusbr0 | oc-node-02 | owner: admin |
| aether-test-vm | VM | Running | 10.207.217.64 | incusbr0 | oc-node-03 | owner: admin |
| demo-web-tier-web-1 | Container | Running | 10.207.217.2 | incusbr0 | oc-node-02 | blueprint: web-tier, ha-group: demo-web-tier-web |
| demo-web-tier-web-2 | Container | Running | 10.207.217.3 | incusbr0 | oc-node-01 | blueprint: web-tier, ha-group: demo-web-tier-web |
| demo-web-tier-app-1 | Container | Running | 10.207.217.4 | incusbr0 | oc-node-03 | blueprint: web-tier |
| ovn-central | Container | Running | 10.207.217.23 | incusbr0 | oc-node-03 | - |
Storage usage
| Node | Pool Size | Used | Available |
|---|---|---|---|
| oc-node-01 | 29.5 GiB | 8.9 GiB | 20.6 GiB |
| oc-node-02 | 64.4 GiB | 7.4 GiB | 57.1 GiB |
| oc-node-03 | 64.4 GiB | 7.9 GiB | 56.5 GiB |
Networks visible in Aether
| Name | Type | IPv4 | Uplink | NAT | Used By |
|---|---|---|---|---|---|
| UPLINK | physical | 192.168.100.1/22 | - | - | 1 |
| incusbr0 | bridge | 10.207.217.1/24 | - | IPv4, IPv6 | 7 |
| meshbr0 | bridge | none | - | IPv6 | 1 |
| net-prod | ovn | 10.10.10.1/24 | UPLINK | IPv4, IPv6 | 0 |
Deploying Instances
Navigate to Deploy VM/Container/Blueprint (/deploy). The form uses
progressive disclosure — each section appears after the preceding selection.
Deploy flow (single instance)
- Select Cluster — dropdown populated from
/deploy/clusters - Select Type — radio: Virtual Machine, Container, or Blueprint
- Select Image — dropdown filtered by type, fetched from
/deploy/images/{clusterID}?type={type} - Name — validated in real-time against the cluster
(
/deploy/check-name/{clusterID}/{name}) - Network — dropdown shows
name (TYPE) - ipv4_network - IP Address — available IPs from the chosen network, ping-verified
before assignment (
/deploy/verify-ip/{clusterID}) - Resources — CPU (1-64, default 2), Memory (default 4 GiB), Root Disk (default 50 GiB). Memory validated against cluster available memory with warning at 90-95% and block at >95%
- Storage Pool — auto-selects pool with most free space
- Additional Disks — optional, VM only. Each disk has size + pool
- Instance Tags — optional key-value pairs (max 10). Reserved keys:
owner,deployed_by,deployed_at(set automatically) - Deploy — reserves IP → creates instance → optionally triggers AWX post-deploy playbook → releases IP reservation
Deploy form fields
| Field | Type | Default | Constraints |
|---|---|---|---|
| Cluster | select | - | Required |
| Instance type | radio | - | virtual-machine, container, blueprint |
| Image | select | - | Filtered by type |
| Name | text | - | [a-zA-Z][a-zA-Z0-9-]{0,62}, unique on cluster |
| Network | select | - | Lists all cluster networks with CIDR |
| IP Address | select | - | Available IPs in network, ping-verified |
| CPU | select | 2 | 1, 2, 4, 8, 16, 32, 64 |
| Memory | number + unit | 4 GiB | 1-1024 GiB or 64-1048576 MiB |
| Root Disk | number | 50 GiB | 1-10000 GiB |
| Storage Pool | select | (most free) | Shows driver + available GiB |
| Additional Disks | number + pool | 100 GiB | 1-10000 GiB each, VM only |
| Tags | key-value | - | Max 10, key: [a-zA-Z0-9._-]+ |
Instance creation payload
{
"cluster_id": 52,
"name": "my-instance",
"type": "virtual-machine",
"image_fingerprint": "abc123...",
"image_alias": "debian-12",
"network_name": "incusbr0",
"ip_address": "10.207.217.50",
"cpu": 2,
"memory_value": 4,
"memory_unit": "GiB",
"root_disk_gib": 50,
"storage_pool": "local",
"additional_disks": [{"size_gib": 100, "pool": "local"}],
"custom_tags": {"department": "engineering"}
}
Tested: deploy container
Deployed aether-test-ct (container, 1 CPU, 1 GiB RAM, 10 GiB disk) on
incusbr0 via the deploy form. Instance appeared immediately in the Instances
tab and via incus list. Aether auto-tags: owner, deployed_by,
deployed_at.
Tested: deploy VM
Deployed aether-test-vm (VM, 2 CPU, 2 GiB RAM, 20 GiB disk) on incusbr0.
Took ~30s to start (QEMU boot). Same auto-tagging. Both test instances are
on the bridge network (node-local only).
AWX integration
Aether can trigger Ansible (AWX) playbooks after instance creation and before decommission. The deploy page shows a "Your Recent Ansible Automation Jobs" table that auto-refreshes every 10s. AWX template IDs are configured per blueprint or globally. If AWX is not configured, the step is skipped.
Blueprint Design
Navigate to Blueprint Design (/blueprintdesign). Blueprints are
reusable deployment templates that define multiple instance components.
Blueprint structure
A blueprint contains:
- Name — unique,
[a-zA-Z][a-zA-Z0-9-]{0,99} - Description — free text, max 500 chars
- Components — 1+ component definitions (max 20 total instances)
- AWX Post-Deploy Template ID — optional, runs after all instances deploy
- AWX Decommission Template ID — optional, runs before teardown
Each component defines:
| Field | Type | Default | Constraints |
|---|---|---|---|
| Name | text | - | Max 50 chars, required |
| Count | number | 1 | 1-10 per component, max 20 total |
| Type | select | virtual-machine | virtual-machine or container |
| Image | select | - | From reference cluster, filtered by type |
| CPU | number | 2 | 1-128 |
| Memory | number + unit | 4 GiB | 1-1024 GiB or 1-1048576 MiB |
| Root Disk | number | 50 GiB | 1-10000 GiB |
| Additional Disks | number[] | - | VM only, 1-10000 GiB each |
Blueprints are cluster-agnostic — they store image aliases, not fingerprints. The "Reference Cluster" is only used to populate the image dropdown during design. The same blueprint can deploy to any cluster that has matching image aliases.
Blueprint JSON
{
"name": "web-tier",
"description": "2 web containers + 1 app container",
"components": [
{
"name": "web",
"count": 2,
"type": "container",
"image_alias": "alpine-3.21",
"cpu": 1,
"memory_value": 256,
"memory_unit": "MiB",
"root_disk_gib": 2
},
{
"name": "app",
"count": 1,
"type": "container",
"image_alias": "alpine-3.21",
"cpu": 1,
"memory_value": 512,
"memory_unit": "MiB",
"root_disk_gib": 4
}
]
}
Blueprint API
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/blueprints |
List all blueprints |
| POST | /api/blueprints |
Create blueprint |
| PUT | /api/blueprints/{id} |
Update blueprint |
| DELETE | /api/blueprints/{id} |
Delete (blocked if deployments exist) |
| GET | /api/blueprints/images/{clusterID} |
List images for design |
Tested: web-tier blueprint
Created blueprint web-tier with 2 components:
web: 2x Alpine 3.21 containers, 1 CPU, 256 MiB, 2 GiB diskapp: 1x Alpine 3.21 container, 1 CPU, 512 MiB, 4 GiB disk
Total: 3 instances, 3 vCPUs, 1 GiB memory, 8 GiB storage.
Deploying Blueprints
When "Blueprint" is selected as instance type on the deploy page, the form changes:
- Select Blueprint — eligible blueprints for the cluster (checks image availability). Shows component preview cards with resource totals
- Base Name — prefix for all instances. Names generated as
{base}-{blueprint}-{component}-{N}(e.g.,demo-web-tier-web-1). Validated against cluster in real-time - Network — same as single instance, but no individual IP selection. Verifies enough available IPs for all instances
- Storage Pool — validates total storage fits
- Deploy — three-phase process:
POST /deploy/blueprint/start— resolves images, assigns IPs- Sequential
POST /deploy/blueprint/{id}/instanceper instance POST /deploy/blueprint/{id}/complete— finalizes, triggers AWX
Blueprint deploy progress
A modal shows real-time step-by-step progress with per-instance status. Users can:
- Cancel — stops creating remaining instances, server rolls back
- Run in Background — hides modal, shows indicator in bottom-right corner (click to re-open)
Instance naming convention
{base_name}-{blueprint_name}-{component_name}-{N}
Example with base demo, blueprint web-tier:
demo-web-tier-web-1(web component, instance 1)demo-web-tier-web-2(web component, instance 2)demo-web-tier-app-1(app component, instance 1)
Blueprint instance tags
Blueprint-deployed instances receive automatic tags:
| Tag | Example |
|---|---|
blueprint |
web-tier |
blueprint_base |
demo |
blueprint_component |
web |
ha-group |
demo-web-tier-web |
deployed_by |
admin |
deployed_at |
2026-02-23T20:52:19Z |
owner |
admin |
The ha-group tag groups instances of the same component for HA tracking.
Tested: deploy web-tier blueprint
Deployed with base name demo on incusbr0 network, local storage pool.
All 3 instances created successfully on different nodes (scheduler spread them
across oc-node-01, oc-node-02, oc-node-03). Each instance received correct
tags. IPs auto-assigned from incusbr0 DHCP range.
Deployed Blueprints (Lifecycle)
Navigate to Deployed Blueprints (/deployedblueprints). Tracks all
blueprint deployments with lifecycle management.
Table columns
| Column | Description |
|---|---|
| Blueprint | Blueprint template name |
| Base Name | Instance name prefix |
| Cluster | Target cluster |
| Network | Deployment network |
| Instances | Count (clickable — shows detail modal) |
| Status | deploying, deployed, decommissioning, decommissioned, failed |
| Deployed By | Username |
| Deployed At | Timestamp |
| Actions | Change Owner (admin), Decommission |
Decommission workflow
- Click Decommission → confirmation dialog
POST /api/deployedblueprints/{id}/decommission/start— triggers AWX decommission playbook (if configured)- Sequential
DELETE /api/deployedblueprints/{id}/decommission/instance/{name}per instance — deletes from cluster one by one POST /api/deployedblueprints/{id}/decommission/complete— finalizes record
Progress modal shows per-instance deletion status. Blueprints with active deployments cannot be deleted from the Blueprint Design page.
Deployed blueprint API
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/deployedblueprints |
List all deployments |
| POST | /api/deployedblueprints/{id}/change-owner |
Transfer ownership |
| POST | /api/deployedblueprints/{id}/decommission/start |
Start teardown |
| DELETE | /api/deployedblueprints/{id}/decommission/instance/{name} |
Delete instance |
| POST | /api/deployedblueprints/{id}/decommission/complete |
Finalize |
Managing Instances
Navigate to Manage VMs/Containers (/infra). Shows all instances across
connected clusters with full lifecycle management.
Instance table columns
| Column | Width | Content |
|---|---|---|
| Manage | 8% | Opens management popup |
| Name | 15% | Instance name |
| Type | 8% | virtual-machine or container |
| Status | 8% | Running, Stopped, Frozen |
| Location | 12% | Cluster member node |
| IPs | 22% | NIC name: IP address |
| Instance Tags | 27% | Key-value metadata (user.* namespace) |
Management actions
State control:
- Running → Restart, Stop, Force Stop, Freeze
- Stopped → Start
- Frozen → Unfreeze
Resource editing:
- Edit CPU — number or range (e.g.,
0-3). Running VMs offer "Stop & Apply" - Edit Memory — value + unit. Warns about OOM (containers) or hotplug limits (VMs)
- Resize Disk — grow only, warns about manual filesystem expansion for VMs
- Add/Remove Disk — non-root disks only
Snapshots:
- Take Snapshot — named, optional expiration (default 7 days)
- Restore Snapshot — reverts instance to snapshot state
- Delete Snapshot
Console access:
- Text Console — WebSocket terminal (xterm.js)
- Exec Shell — shell exec via WebSocket
- VGA Console — SPICE graphical console (VMs only, with Ctrl+Alt+Del)
Instance Tags — add, edit, delete key-value metadata. Reserved keys excluded. Changes committed in batch.
ACLs — view applied firewall rules per NIC, with ingress/egress filtering.
Instance management API
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /infra/details/{clusterID}/{name} |
Full instance details |
| POST | /infra/state/{clusterID}/{name} |
Change state |
| DELETE | /infra/instance/{clusterID}/{name} |
Delete instance |
| POST | /infra/snapshot/create/{clusterID}/{name} |
Create snapshot |
| POST | /infra/snapshot/action/{clusterID}/{name}/{snap} |
Restore/delete |
| POST | /infra/resources/{clusterID}/{name} |
Edit CPU/memory/disk |
| POST | /infra/disk/{clusterID}/{name} |
Add disk |
| DELETE | /infra/disk/{clusterID}/{name}/{disk} |
Remove disk |
| POST | /infra/metadata/{clusterID}/{name} |
Update tags |
| WS | /infra/console/{clusterID}/{name} |
Text console |
| WS | /infra/exec/{clusterID}/{name} |
Shell exec |
| WS | /infra/vga-console/{clusterID}/{name} |
SPICE VGA |
Recovering OVN After ovn-central Container Move
Moving the ovn-central container between nodes causes the OVN northbound
(NB) database to be reset/empty. The southbound (SB) database retains chassis
entries but becomes stale. The net-prod OVN network exists in Incus but has
no backing OVN objects. incus network delete hangs because Incus tries to
clean non-existent OVN resources.
Root causes discovered
- OVN NB database loss: moving the ovn-central container creates a fresh NB database. All logical switches, routers, DNS records, NAT rules, DHCP options, and HA chassis groups are lost.
- Stale OVS bridges: when Incus creates a physical uplink (UPLINK), it
creates an OVS bridge named
incusovnN(where N is the network DB ID). Deleting and recreating the network assigns a new ID, creating a new bridge while the old one persists in OVS. The old bridge still holds the physical parent interface mapping. - OVN chassis UUID mismatch: toggling the OVN service on IncusOS nodes
can change the
system-idin the OVN SB. But thesystem-idstored in the local OVSexternal_idspersists. This creates a mismatch where Incus writesrequested-chassis=<OVS-system-id>but the SB chassis has a different name. Ports can't be bound.
Recovery procedure (tested)
Phase 1: Remove stale network from Incus DB
# Get the stale network ID
incus admin sql <remote>:global \
"SELECT id, name FROM networks WHERE name = 'net-prod'"
# Delete all related DB entries (children first)
incus admin sql <remote>:global "DELETE FROM networks_load_balancers WHERE network_id = <ID>"
incus admin sql <remote>:global "DELETE FROM networks_forwards WHERE network_id = <ID>"
incus admin sql <remote>:global "DELETE FROM networks_nodes WHERE network_id = <ID>"
incus admin sql <remote>:global "DELETE FROM networks_config WHERE network_id = <ID>"
incus admin sql <remote>:global "DELETE FROM networks WHERE id = <ID>"
Phase 2: Proxmox stop/start all nodes
Required to clear the Incus daemon's in-memory cache of stale OVN state. Guest reboot is NOT safe on OC-managed nodes (see CLAUDE.md). Proxmox stop/start IS safe.
# Stop each VM via Proxmox API, wait 10s, start
# One node at a time, wait for rejoin before next
Phase 3: Clean stale OVS bridges on each node
Use a privileged container with OVS tools to access the host's OVS database. Critical: mount the host's OVS socket at a different path to avoid the container's own OVS service masking it.
incus launch images:debian/12 <remote>:ovs-fix --target <node> \
-c security.privileged=true
incus config device add <remote>:ovs-fix ovs-run disk \
source=/run/openvswitch path=/host-ovs
# Install OVS with auto-start prevention
incus exec <remote>:ovs-fix -- bash -c "
echo -e '#!/bin/sh\nexit 101' > /usr/sbin/policy-rc.d
chmod +x /usr/sbin/policy-rc.d
apt-get update -qq && apt-get install -y -qq openvswitch-switch
rm /usr/sbin/policy-rc.d
systemctl stop openvswitch-switch"
# Delete old stale bridge (e.g., incusovn3 from the old UPLINK)
incus exec <remote>:ovs-fix -- \
ovs-vsctl --db=unix:/host-ovs/db.sock del-br incusovn3
# Fix bridge mapping to point to new bridge only
incus exec <remote>:ovs-fix -- \
ovs-vsctl --db=unix:/host-ovs/db.sock set Open_vSwitch . \
external-ids:ovn-bridge-mappings="UPLINK:incusovn<NEW_ID>"
# Move container to next node and repeat
Phase 4: Fix chassis UUID mismatch (node-01 specific)
If a node's OVS system-id doesn't match the SB chassis name, delete the
stale SB chassis and let ovn-controller re-register:
# Check OVS system-id
incus exec <remote>:ovs-fix -- \
ovs-vsctl --db=unix:/host-ovs/db.sock get Open_vSwitch . external-ids:system-id
# Delete stale chassis from SB
incus exec <remote>:ovn-central -- ovn-sbctl chassis-del "<stale-name>"
# ovn-controller re-registers with the correct system-id within seconds
Phase 5: Recreate networks
# Recreate UPLINK (must use --target per node, talk to each node directly)
incus network create oc-node-01:UPLINK --type physical parent=mgmt --target oc-node-01
incus network create oc-node-02:UPLINK --type physical parent=mgmt --target oc-node-02
incus network create oc-node-03:UPLINK --type physical parent=mgmt --target oc-node-03
# Finalize
incus network create <remote>:UPLINK --type physical \
ipv4.gateway=192.168.100.1/22 \
ipv4.ovn.ranges=192.168.103.200-192.168.103.210 \
dns.nameservers=192.168.100.1
# Create OVN network
incus network create <remote>:net-prod --type=ovn network=UPLINK \
ipv4.address=10.10.10.1/24 ipv4.nat=true \
ipv6.address=auto ipv6.nat=true
Key lessons
- Never toggle OVN service on IncusOS nodes unnecessarily — it can change
the
system-id, creating chassis UUID mismatches. - Mount host OVS at
/host-ovs(not/run/openvswitch) — the container's own OVS service masks the host's socket at the default path. incus network create --targetmust be sent directly to each node — cluster-forwarded--targetcalls can time out.- Proxmox stop/start is safe for OC-managed nodes; guest reboot is NOT.
Resizing IncusOS Nodes (Proxmox)
Growing IncusOS VMs requires three steps: Proxmox resize, boot (partition auto-expand), and manual ZFS pool expansion.
Step 1: Proxmox resize (VM must be stopped)
# Stop VM via Proxmox API
curl -fsSk -H "Authorization: PVEAPIToken=..." \
-X POST "https://<host>:8006/api2/json/nodes/<node>/qemu/<vmid>/status/stop"
# Resize disk (non-destructive, grow only)
curl -fsSk -H "Authorization: PVEAPIToken=..." \
-X PUT -d "disk=scsi0&size=100G" \
"https://<host>:8006/api2/json/nodes/<node>/qemu/<vmid>/resize"
# Set memory (in MiB)
curl -fsSk -H "Authorization: PVEAPIToken=..." \
-X PUT -d "memory=20480" \
"https://<host>:8006/api2/json/nodes/<node>/qemu/<vmid>/config"
# Start VM
curl -fsSk -H "Authorization: PVEAPIToken=..." \
-X POST "https://<host>:8006/api2/json/nodes/<node>/qemu/<vmid>/status/start"
Step 2: Partition auto-expand (automatic on boot)
IncusOS uses systemd-repart which runs during initrd on every boot. The
local-data partition (partition 11) has no SizeMaxBytes, so it
automatically grows to fill all remaining disk space. This is automatic —
no action needed.
Verify via the IncusOS API:
incus query <remote>:/os/1.0/system/resources | python3 -c "
import json,sys; d=json.load(sys.stdin)['storage']
for disk in d['disks']:
for part in disk['partitions']:
if part['partition'] == 11:
print(f'Part 11: {part[\"size\"]/1073741824:.1f} GiB')"
Step 3: ZFS pool expansion (manual — not automatic)
Critical discovery: IncusOS creates the ZFS pool without autoexpand=on.
After the partition grows, the ZFS pool does NOT automatically use the new
space. The pool's EXPANDSZ column shows the available expansion, but it
must be triggered manually.
Since IncusOS has no shell access and no API for zpool online -e, use a
privileged container to run the ZFS expansion:
# Create privileged container on the target node
incus launch images:debian/12 <remote>:zfs-expand --target <node> \
-c security.privileged=true
# Add devices for ZFS control and the partition
incus config device add <remote>:zfs-expand zfs unix-char \
source=/dev/zfs path=/dev/zfs
incus config device add <remote>:zfs-expand sda11 unix-block \
source=/dev/sda11 path=/dev/sda11
# Install ZFS tools and create device symlink
incus exec <remote>:zfs-expand -- bash -c "
apt-get update -qq && apt-get install -y -qq zfsutils-linux
mkdir -p /dev/disk/by-id
ln -sf /dev/sda11 /dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi0-part11"
# Verify pool sees expansion available
incus exec <remote>:zfs-expand -- zpool list
# Output shows EXPANDSZ column with available expansion (e.g., 50G)
# Expand the pool
incus exec <remote>:zfs-expand -- zpool online -e local \
scsi-0QEMU_QEMU_HARDDISK_drive-scsi0-part11
# Verify expansion
incus exec <remote>:zfs-expand -- zpool list
# EXPANDSZ should now show "-" and SIZE should reflect the new total
# Clean up
incus delete <remote>:zfs-expand --force
Why this works: privileged containers share the host kernel. The /dev/zfs
character device is the ZFS kernel module's control interface. The container's
zpool commands talk directly to the host kernel's ZFS, which manages the
local pool. The /dev/disk/by-id/ symlink must be created inside the
container because the container doesn't have udev populating /dev/disk/.
Warning: do NOT run zpool online -e with a device path that doesn't
exist inside the container. This causes the ZFS pool to go SUSPENDED (I/O
errors). Recovery requires a Proxmox stop/start of the VM (ZFS re-imports
cleanly on boot, no data loss).
Rolling resize procedure (cluster)
Resize one node at a time to maintain quorum (2 of 3 nodes):
- Node-03 first (lowest risk — fewest workloads)
- Node-02 — if it hosts OVN central, move it to another node first and update OVN config on all nodes
- Node-01 last — if it hosts Aether, stop it, resize, restart, then start Aether
After each node: verify cluster status (incus cluster list), storage
size (incus storage info <remote>:local --target <node>), and port 8443
reachability.