1808 lines
52 KiB
Markdown
1808 lines
52 KiB
Markdown
# Production Home Lab Guide
|
|
|
|
Build a production-quality Incus home lab from scratch: Operations Center
|
|
dashboard, OC-managed 3-node cluster with OVN overlay networking, Aether
|
|
management platform, HAProxy load balancing, AWX lifecycle automation,
|
|
Prometheus/Grafana/Loki observability stack, live migration, network
|
|
security, and cluster lifecycle management.
|
|
|
|
All commands and output in this guide are from actual deployments on
|
|
Proxmox VE 9.1.5 with IncusOS 202602230420, Incus client 6.21,
|
|
Operations Center v0.3.0, and Aether v6.4.317.
|
|
|
|
|
|
## Section 0: Architecture Overview
|
|
|
|
### Network Topology
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
vlan(("VLAN 69<br/>192.168.100.0/22"))
|
|
|
|
subgraph mgmt["Management"]
|
|
oc["oc-server<br/>VMID 920 · .120<br/>Operations Center"]
|
|
end
|
|
|
|
subgraph cluster["Incus Cluster"]
|
|
n1["oc-node-01<br/>VMID 400 · .140<br/>Aether · OVN central"]
|
|
n2["oc-node-02<br/>VMID 401 · .141<br/>AWX · Monitoring"]
|
|
n3["oc-node-03<br/>VMID 402 · .142<br/>HAProxy backends"]
|
|
end
|
|
|
|
subgraph services["Macvlan Services"]
|
|
aether["Aether · .160"]
|
|
awx["AWX · .161"]
|
|
end
|
|
|
|
subgraph ovn["OVN · net-prod 10.10.10.0/24"]
|
|
haproxy["HAProxy HA<br/>.50 · .51"]
|
|
backends["nginx backends<br/>.60 · .61 · .62"]
|
|
monitoring["Observability<br/>.70 + node-exp .71-.73"]
|
|
end
|
|
|
|
subgraph uplink["UPLINK · 192.168.103.x"]
|
|
vip[".200 HAProxy VIP"]
|
|
fwd[".201 Grafana / Prometheus"]
|
|
end
|
|
|
|
vlan --- mgmt & cluster
|
|
cluster --> services & ovn
|
|
ovn -.-> uplink
|
|
|
|
classDef nodeClass fill:#009E73,color:#fff,stroke:#007a5e
|
|
classDef mgmtClass fill:#CC79A7,color:#fff,stroke:#a36088
|
|
classDef serviceClass fill:#E69F00,color:#fff,stroke:#b87d00
|
|
classDef ovnClass fill:#56B4E9,color:#fff,stroke:#3a8fbf
|
|
classDef networkClass fill:#0072B2,color:#fff,stroke:#005a8e
|
|
|
|
class n1,n2,n3 nodeClass
|
|
class oc mgmtClass
|
|
class aether,awx serviceClass
|
|
class haproxy,backends,monitoring ovnClass
|
|
class vip,fwd,vlan networkClass
|
|
|
|
style mgmt fill:#f5e6f0,stroke:#CC79A7
|
|
style cluster fill:#e6f5f0,stroke:#009E73
|
|
style services fill:#fef3e0,stroke:#E69F00
|
|
style ovn fill:#e0f2fe,stroke:#56B4E9
|
|
style uplink fill:#e0eef8,stroke:#0072B2
|
|
```
|
|
|
|
### Infrastructure
|
|
|
|
| Component | VMID | IP | Cores | RAM | Disk | Role |
|
|
|-----------|------|-----|-------|-----|------|------|
|
|
| oc-server | 920 | 192.168.102.120/22 | 2 | 4 GiB | 50G | Operations Center |
|
|
| oc-node-01 | 400 | 192.168.102.140/22 | 4 | 20 GiB | 250G | Cluster init + Aether host |
|
|
| oc-node-02 | 401 | 192.168.102.141/22 | 4 | 20 GiB | 100G | AWX + monitoring host |
|
|
| oc-node-03 | 402 | 192.168.102.142/22 | 4 | 20 GiB | 100G | HAProxy backends |
|
|
|
|
**RAM budget**: 64 GiB of 94 GiB (68%). Host: i9-13900HK, 94 GiB RAM,
|
|
881 GiB ZFS pool. Leaves 30 GiB headroom for other VMs on the host.
|
|
|
|
### Inner Cluster Services
|
|
|
|
| Instance | Network | IP | Node | RAM | Description |
|
|
|----------|---------|-----|------|-----|-------------|
|
|
| ovn-central | incusbr0 | DHCP | node-01 | 512 MiB | OVN NB/SB databases |
|
|
| aether | macvlan mgmt | 192.168.102.160 | node-01 | 8 GiB | Management platform |
|
|
| awx | macvlan mgmt | 192.168.102.161 | node-02 | 8 GiB | Ansible automation |
|
|
| ha-web-01 | net-prod | 10.10.10.60 | node-01 | 256 MiB | Nginx backend |
|
|
| ha-web-02 | net-prod | 10.10.10.61 | node-02 | 256 MiB | Nginx backend |
|
|
| ha-web-03 | net-prod | 10.10.10.62 | node-03 | 256 MiB | Nginx backend |
|
|
| haproxy-01 | net-prod | 10.10.10.50 | varies | 512 MiB | HA load balancer |
|
|
| haproxy-02 | net-prod | 10.10.10.51 | varies | 512 MiB | HA load balancer |
|
|
| monitoring | net-prod | 10.10.10.70 | node-02 | 2 GiB | Prometheus + Grafana + Loki |
|
|
| node-exp-01 | net-prod | 10.10.10.71 | node-01 | 128 MiB | Host metrics exporter |
|
|
| node-exp-02 | net-prod | 10.10.10.72 | node-02 | 128 MiB | Host metrics exporter |
|
|
| node-exp-03 | net-prod | 10.10.10.73 | node-03 | 128 MiB | Host metrics exporter |
|
|
|
|
### External IP Allocation
|
|
|
|
OVN external addresses from UPLINK range (192.168.103.200-210). Exclude
|
|
these from your DHCP server's range:
|
|
|
|
| IP | Purpose |
|
|
|-----|---------|
|
|
| 192.168.103.200 | HAProxy VIP (OVN load balancer → haproxy-01/02) |
|
|
| 192.168.103.201 | Observability forward (Grafana :3000, Prometheus :9090) |
|
|
|
|
### Decision Rationale
|
|
|
|
**Why OC-managed clustering?** OC `provisioning cluster add` is the
|
|
production path for Incus deployments. It handles cluster formation,
|
|
update management, and inventory centrally. The deploy scripts
|
|
(`deploy-haproxy`, `deploy-awx`, `deploy-observability`) are built
|
|
for the `oc-node-*` naming and IP scheme.
|
|
|
|
**Why 20 GiB RAM per node?** Aether requires 8 GiB. AWX requires
|
|
4-8 GiB. Monitoring + HAProxy + backends need ~3 GiB total.
|
|
Leaves headroom for mixed workloads and live migration.
|
|
|
|
**Why 250 GiB disk for node-01?** Aether's golden image is 200 GiB
|
|
virtual (qcow2). With ZFS thin provisioning only ~11 GiB is used
|
|
initially, but the pool needs 200 GiB allocatable space.
|
|
|
|
**Why OVN?** Bridge networks are node-local — instances on different
|
|
nodes cannot communicate. OVN provides cross-node L2 overlay with
|
|
sub-ms latency, network isolation, ACLs, load balancers, and network
|
|
forwards — essential for HAProxy HA and distributed workloads.
|
|
|
|
**Why VLAN 69?** Isolates lab traffic from the production LAN. All VMs
|
|
share VLAN 69 (subnet 192.168.100.0/22). The VLAN tag is set at the
|
|
Proxmox level — IncusOS and workloads are unaware of it.
|
|
|
|
### Cross-References
|
|
|
|
This guide integrates techniques from the deep-dive guides:
|
|
|
|
- [Clustering Guide](clustering-guide.md) — manual cluster formation reference
|
|
- [Networking Guide](networking-guide.md) — OVN setup, ACLs, peering
|
|
- [Operations Center Guide](operations-center-guide.md) — OC provisioning, CLI, web UI
|
|
- [Aether Guide](aether-guide.md) — management platform deployment and API
|
|
- [HAProxy Guide](haproxy-guide.md) — HA load balancing with Aether
|
|
- [AWX Guide](awx-guide.md) — Ansible lifecycle automation
|
|
- [Observability Guide](observability-guide.md) — Prometheus, Grafana, Loki stack
|
|
|
|
|
|
## Section 1: Prerequisites
|
|
|
|
### Required Tools
|
|
|
|
Verify all tools are available before starting:
|
|
|
|
```bash
|
|
incus version
|
|
operations-center --version
|
|
bash --version | head -1
|
|
python3 --version
|
|
jq --version
|
|
curl --version | head -1
|
|
genisoimage --version 2>&1 | head -1
|
|
```
|
|
|
|
**Minimum versions**: Incus client 6.3+, Operations Center v0.3.0+.
|
|
|
|
### Aether Browser Automation
|
|
|
|
Several Aether features (HAProxy management, blueprint deployment) are
|
|
not in the JWT API — they use session-authenticated routes with CSRF
|
|
protection. Playwright browser automation is required for Sections 7-9:
|
|
|
|
```bash
|
|
node --version # Node.js 18+
|
|
npx playwright --version
|
|
```
|
|
|
|
Install if missing:
|
|
|
|
```bash
|
|
npm install playwright @playwright/mcp
|
|
npx playwright install chromium
|
|
```
|
|
|
|
The Playwright MCP server (configured in `.mcp.json`) provides browser
|
|
tools when available. The `incusos/helpers/aether-browser` script is
|
|
the standalone alternative.
|
|
|
|
### Aether Golden Image
|
|
|
|
The Aether golden image must be available locally before Section 7:
|
|
|
|
```bash
|
|
ls -la sources/aether-golden-image-v6.tar.gz
|
|
```
|
|
|
|
This is an Ubuntu Noble (24.04) image with 200 GiB virtual disk (~6.6
|
|
GiB compressed). Obtain it from the Aether distribution.
|
|
|
|
### Proxmox Configuration
|
|
|
|
Your `incusos/proxmox.yaml` should contain:
|
|
|
|
```yaml
|
|
host: 192.168.1.29
|
|
method: api
|
|
api_token_id: automation@pve!deploy
|
|
node: pve
|
|
storage: local-zfs
|
|
iso_storage: local
|
|
bridge: vmbr0
|
|
vlan: 69
|
|
gateway: 192.168.100.1
|
|
dns: 192.168.100.1
|
|
pool: IncusLab
|
|
```
|
|
|
|
The `env` file at the repository root must export `PROXMOX_TOKEN_SECRET`
|
|
and `AETHER_ADMIN_PASSWORD`. Scripts auto-discover them.
|
|
|
|
### Client Certificates
|
|
|
|
Incus client certificates are used for Incus, OC, and Prometheus
|
|
(metrics scraping) connections:
|
|
|
|
```bash
|
|
# Verify cert exists (auto-generated on first incus command)
|
|
ls -la ~/.config/incus/client.crt ~/.config/incus/client.key
|
|
```
|
|
|
|
Copy certs for OC CLI:
|
|
|
|
```bash
|
|
mkdir -p ~/.config/operations-center
|
|
cp ~/.config/incus/client.crt ~/.config/operations-center/
|
|
cp ~/.config/incus/client.key ~/.config/operations-center/
|
|
```
|
|
|
|
For OC web UI browser access, generate a PKCS#12 bundle:
|
|
|
|
```bash
|
|
openssl pkcs12 -export \
|
|
-out ~/.config/incus/client.pfx \
|
|
-inkey ~/.config/incus/client.key \
|
|
-in ~/.config/incus/client.crt \
|
|
-name "Incus Client"
|
|
```
|
|
|
|
Import `client.pfx` into your browser's certificate store (Firefox:
|
|
Settings → Privacy & Security → View Certificates → Import).
|
|
|
|
### Doctor Check
|
|
|
|
Run the environment check to verify everything is in order:
|
|
|
|
```bash
|
|
cd incusos
|
|
./incusos-proxmox --doctor
|
|
```
|
|
|
|
Expected output includes tool versions, IncusOS CDN reachability,
|
|
`proxmox.yaml` discovery, and Proxmox API connectivity.
|
|
|
|
|
|
## Section 2: Deploy OC Server
|
|
|
|
### Configuration File
|
|
|
|
```yaml
|
|
# incusos/examples/lab-oc-deploy.yaml
|
|
defaults:
|
|
cores: 2
|
|
memory: 4096
|
|
disk: 50
|
|
start_vmid: 920
|
|
|
|
proxmox:
|
|
gateway: 192.168.100.1
|
|
dns: 192.168.100.1
|
|
|
|
vms:
|
|
- name: oc-server
|
|
app: operations-center
|
|
apply_defaults: true
|
|
ip: 192.168.102.120/22
|
|
```
|
|
|
|
### Deploy
|
|
|
|
```bash
|
|
./incusos-proxmox --yes incusos/examples/lab-oc-deploy.yaml
|
|
```
|
|
|
|
Actual output (key lines):
|
|
|
|
```
|
|
[ok] VM 'oc-server' created (VMID 920)
|
|
[ok] VM 'oc-server' installed and running at 192.168.102.120
|
|
```
|
|
|
|
### Set Up OC CLI Remote
|
|
|
|
```bash
|
|
# Accept the TLS certificate when prompted
|
|
operations-center remote add oc-lab https://192.168.102.120:8443 --auth-type tls
|
|
operations-center remote switch oc-lab
|
|
```
|
|
|
|
**Important**: The OC CLI does **not** support the `remote:` suffix syntax
|
|
that the Incus CLI uses. Switch to the remote first, then run commands
|
|
without a remote suffix.
|
|
|
|
### Verify OC
|
|
|
|
```bash
|
|
operations-center admin os show
|
|
```
|
|
|
|
Actual output (uptime will vary):
|
|
|
|
```
|
|
WARNING: The IncusOS API and configuration is subject to change
|
|
|
|
environment:
|
|
hostname: oc-server
|
|
os_name: IncusOS
|
|
os_version: "202602230420"
|
|
os_version_next: ""
|
|
uptime: 63
|
|
```
|
|
|
|
### Wait for Updates
|
|
|
|
OC downloads IncusOS update packages from upstream. At least one update
|
|
must reach `ready` state before ISOs can be generated:
|
|
|
|
```bash
|
|
# Poll until at least one update shows "ready"
|
|
operations-center provisioning update list
|
|
```
|
|
|
|
Actual output (after ~8 minutes; UUIDs are stable across deployments):
|
|
|
|
| UUID | Origin | Channels | Version | Severity | Status |
|
|
|------|--------|----------|---------|----------|--------|
|
|
| 82aefab7-fec7-5122-89fd-8412d3d2174c | linuxcontainers.org | stable | 202602200553 | none | ready |
|
|
| 5d6b1018-e534-5e54-aeb5-c9e6027ab31d | linuxcontainers.org | stable | 202602210344 | none | ready |
|
|
| c912a390-c38b-5bd9-b46f-ccaeba6da68a | linuxcontainers.org | stable | 202602230420 | none | ready |
|
|
|
|
The table also includes `Upstream Channels` and `Published At` columns
|
|
(omitted for width). Not all updates may be ready simultaneously — at
|
|
least one `ready` is sufficient to proceed.
|
|
|
|
### Web UI Access
|
|
|
|
Open `https://192.168.102.120:8443/ui/` in your browser (with client.pfx
|
|
imported from Section 1). The web UI provides a dashboard view of the OC
|
|
server, update status, provisioning tokens, and system configuration.
|
|
|
|
---
|
|
|
|
## Section 3: Provision Nodes
|
|
|
|
### 3.1 Create Provisioning Token
|
|
|
|
```bash
|
|
operations-center provisioning token add --uses 5 --description "Production lab cluster"
|
|
operations-center provisioning token list
|
|
```
|
|
|
|
Actual output (UUID changes every run):
|
|
|
|
| UUID | Uses Remaining | Expire At | Channel | Description |
|
|
|------|----------------|-----------|---------|-------------|
|
|
| <token-UUID> | 5 | <30 days from now> | stable | Production lab cluster |
|
|
|
|
Save the `<token-UUID>` — you'll need it for the next steps.
|
|
|
|
### 3.2 Create Token Seed (No force_reboot)
|
|
|
|
**Critical**: the token seed must NOT include `force_reboot`. On Proxmox,
|
|
`incusos-proxmox` handles the install lifecycle externally (blockstat
|
|
detection + media removal). `force_reboot` triggers SysRq-B which causes
|
|
the crontab bug (~50% failure rate).
|
|
|
|
```yaml
|
|
# /tmp/oc-preseed.yaml
|
|
install:
|
|
version: "1"
|
|
force_install: true
|
|
```
|
|
|
|
**Important**: use the structured format with section keys (`install:`). A
|
|
flat format (`version: "1"` at root) maps fields to empty `{}` and they
|
|
don't get assigned to any section.
|
|
|
|
```bash
|
|
operations-center provisioning token seed add <token-UUID> proxmox-preseed \
|
|
/tmp/oc-preseed.yaml --description "No force_reboot for Proxmox"
|
|
```
|
|
|
|
### 3.3 Generate OC-Provisioned ISO (Older Version)
|
|
|
|
**Critical discovery**: nodes deployed from an ISO matching the latest OC
|
|
update version are tracked as `needs_update: true` by OC because the OS
|
|
was never delivered through OC's update pipeline. The fix: generate the ISO
|
|
from an older channel so OC can push the real update after deployment.
|
|
|
|
```bash
|
|
# Create the old-stable channel (must exist before assigning updates to it)
|
|
operations-center provisioning channel add old-stable \
|
|
--description "Older stable versions for initial provisioning"
|
|
|
|
# Assign the second-latest update to the old-stable channel
|
|
# (use the UUID for 202602210344 from `provisioning update list`)
|
|
operations-center provisioning update assign-channels <older-UUID> --channel old-stable
|
|
|
|
# Generate ISO from the older channel
|
|
operations-center provisioning token seed get-image <token-UUID> proxmox-preseed \
|
|
/tmp/IncusOS-oc.iso --type iso --architecture x86_64 --channel old-stable
|
|
```
|
|
|
|
Actual output:
|
|
|
|
```
|
|
Successfully written 3433074688 bytes to "/tmp/IncusOS-oc.iso"
|
|
```
|
|
|
|
The ISO contains IncusOS 202602210344 (one version behind). OC will push
|
|
the latest (202602230420) after nodes register.
|
|
|
|
### 3.4 Node Configuration
|
|
|
|
```yaml
|
|
# incusos/examples/lab-oc-nodes.yaml
|
|
defaults:
|
|
cores: 4
|
|
memory: 20480
|
|
disk: 100
|
|
start_vmid: 400
|
|
|
|
proxmox:
|
|
gateway: 192.168.100.1
|
|
dns: 192.168.100.1
|
|
|
|
vms:
|
|
- name: oc-node-01
|
|
app: incus
|
|
apply_defaults: false
|
|
disk: 250
|
|
ip: 192.168.102.140/22
|
|
|
|
- name: oc-node-02
|
|
app: incus
|
|
apply_defaults: false
|
|
ip: 192.168.102.141/22
|
|
|
|
- name: oc-node-03
|
|
app: incus
|
|
apply_defaults: false
|
|
ip: 192.168.102.142/22
|
|
```
|
|
|
|
**Key decisions**:
|
|
|
|
- **20 GiB RAM** per node: Aether needs 8 GiB, AWX needs 4-8 GiB
|
|
- **250 GiB disk** for node-01: hosts Aether's 200 GiB virtual image
|
|
- **100 GiB disk** for nodes 02-03: sufficient for AWX, monitoring, HAProxy
|
|
- **`apply_defaults: false`** for all nodes: OC's Terraform handles
|
|
resource creation during cluster formation
|
|
|
|
### 3.5 Deploy Nodes (Hybrid Approach)
|
|
|
|
The hybrid approach uses `incusos-proxmox --iso` to combine OC
|
|
auto-registration (from the boot ISO token) with `incusos-proxmox` VM
|
|
creation, per-node SEED_DATA (hostname, static IP), install monitoring,
|
|
and media cleanup.
|
|
|
|
```bash
|
|
./incusos/incusos-proxmox --iso /tmp/IncusOS-oc.iso --yes incusos/examples/lab-oc-nodes.yaml
|
|
```
|
|
|
|
Actual output (key lines):
|
|
|
|
```
|
|
[ok] ISO uploaded: IncusOS-oc.iso
|
|
[ok] VM 'oc-node-01' installed and running at 192.168.102.140
|
|
[ok] Remote 'oc-node-01' added (192.168.102.140)
|
|
[ok] VM 'oc-node-02' installed and running at 192.168.102.141
|
|
[ok] Remote 'oc-node-02' added (192.168.102.141)
|
|
[ok] VM 'oc-node-03' installed and running at 192.168.102.142
|
|
[ok] Remote 'oc-node-03' added (192.168.102.142)
|
|
[ok] All post-deployment checks passed
|
|
```
|
|
|
|
All 3 nodes: 876 MiB blockstat detection, clean install, no crontab bug.
|
|
|
|
### 3.6 Verify Auto-Registration
|
|
|
|
Nodes auto-register with OC within ~30 seconds of first boot. The update
|
|
from 202602210344 to 202602230420 happens automatically:
|
|
|
|
```bash
|
|
operations-center provisioning server list
|
|
```
|
|
|
|
Actual output (key columns; full table includes Type, Channel, Certificate
|
|
Fingerprint, Public Connection URL, Last Updated, Last Seen):
|
|
|
|
| Cluster | Name | Connection URL | Status | Update Status |
|
|
|---------|------|----------------|--------|---------------|
|
|
| | oc-node-01 | https://192.168.102.140:8443 | ready | up to date |
|
|
| | oc-node-02 | https://192.168.102.141:8443 | ready | up to date |
|
|
| | oc-node-03 | https://192.168.102.142:8443 | ready | up to date |
|
|
| | operations-center | https://[::1]:8443 | ready | update pending |
|
|
|
|
**Key**: all 3 nodes show **"up to date"** because OC delivered the
|
|
202602230420 update through its pipeline. This is what unlocks clustering.
|
|
Nodes may already be up to date by the time the last node finishes
|
|
deploying — the update gets pushed while `incusos-proxmox` deploys
|
|
subsequent nodes sequentially.
|
|
|
|
### 3.7 Verify Scrub Schedules
|
|
|
|
```bash
|
|
for node in oc-node-01 oc-node-02 oc-node-03; do
|
|
incus query ${node}:/os/1.0/system/storage | python3 -c \
|
|
"import sys,json; print('${node}:', json.load(sys.stdin)['config']['scrub_schedule'])"
|
|
done
|
|
```
|
|
|
|
Actual output:
|
|
|
|
```
|
|
oc-node-01: 0 4 * * 0
|
|
oc-node-02: 0 4 * * 0
|
|
oc-node-03: 0 4 * * 0
|
|
```
|
|
|
|
All healthy. No crontab bug (force_reboot was not used).
|
|
|
|
---
|
|
|
|
## Section 4: Form Cluster via Operations Center
|
|
|
|
### 4.1 The needs_update Blocker
|
|
|
|
OC requires all nodes to show `needs_update: false` before clustering.
|
|
Nodes deployed from an ISO matching the latest version are tracked as
|
|
`needs_update: true` because the OS was never delivered through OC's update
|
|
pipeline. The `needs_update` flag is server-side computed and cannot be
|
|
overridden via REST API PUT.
|
|
|
|
**Solution**: deploy from an older ISO version (Section 3.3). OC then pushes
|
|
the real update to nodes through its pipeline, clearing the flag.
|
|
|
|
### 4.2 Form Cluster
|
|
|
|
**Important**: if the client certificate was already injected via SEED_DATA,
|
|
use an empty application seed config to avoid "Certificate already in trust
|
|
store" Terraform errors:
|
|
|
|
```bash
|
|
echo '{}' > /tmp/oc-app-config.yaml
|
|
|
|
operations-center provisioning cluster add oc-cluster \
|
|
https://192.168.102.140:8443 \
|
|
--server-names oc-node-01,oc-node-02,oc-node-03 \
|
|
--server-type incus \
|
|
--application-seed-config /tmp/oc-app-config.yaml
|
|
```
|
|
|
|
OC orchestrates the full cluster formation:
|
|
1. Sets `core.https_address` to each node's specific IP
|
|
2. Enables clustering on oc-node-01
|
|
3. Joins oc-node-02 and oc-node-03
|
|
4. Creates storage pool (`local`), networks (`incusbr0`, `meshbr0`)
|
|
5. Runs Terraform/OpenTofu for post-cluster configuration
|
|
|
|
### 4.3 Fix Remotes After Clustering
|
|
|
|
Clustering regenerates TLS certificates. Re-add the remotes:
|
|
|
|
```bash
|
|
incus remote remove oc-node-01
|
|
incus remote remove oc-node-02
|
|
incus remote remove oc-node-03
|
|
incus remote add oc-node-01 https://192.168.102.140:8443 --accept-certificate
|
|
incus remote add oc-node-02 https://192.168.102.141:8443 --accept-certificate
|
|
incus remote add oc-node-03 https://192.168.102.142:8443 --accept-certificate
|
|
```
|
|
|
|
### 4.4 Verify Cluster
|
|
|
|
```bash
|
|
incus cluster list oc-node-01:
|
|
```
|
|
|
|
Actual output (key columns; full table includes FAILURE DOMAIN, DESCRIPTION):
|
|
|
|
| NAME | URL | ROLES | ARCHITECTURE | STATUS | MESSAGE |
|
|
|------|-----|-------|--------------|--------|---------|
|
|
| oc-node-01 | https://192.168.102.140:8443 | database-leader, database | x86_64 | ONLINE | Fully operational |
|
|
| oc-node-02 | https://192.168.102.141:8443 | database | x86_64 | ONLINE | Fully operational |
|
|
| oc-node-03 | https://192.168.102.142:8443 | database | x86_64 | ONLINE | Fully operational |
|
|
|
|
All 3 nodes ONLINE and Fully operational. The `ovn-chassis` role is added
|
|
later in Section 6.4.
|
|
|
|
### 4.5 Cluster Resources Created by OC
|
|
|
|
```bash
|
|
incus storage list oc-node-01:
|
|
incus network list oc-node-01:
|
|
```
|
|
|
|
Actual output (incusbr0 subnet varies per deployment):
|
|
|
|
| NAME | DRIVER | DESCRIPTION | USED BY | STATE |
|
|
|------|--------|-------------|---------|-------|
|
|
| local | zfs | Local storage pool (on system drive) | 8 | CREATED |
|
|
|
|
| NAME | TYPE | MANAGED | IPV4 | DESCRIPTION | USED BY |
|
|
|------|------|---------|------|-------------|---------|
|
|
| incusbr0 | bridge | YES | 10.x.x.1/24 | Local network bridge (NAT) | 1 |
|
|
| meshbr0 | bridge | YES | none | Internal mesh network bridge | 1 |
|
|
|
|
OC creates: `local` storage pool (ZFS), `incusbr0` bridge (NAT), and
|
|
`meshbr0` (OC-specific mesh network for inter-node communication). The
|
|
table also includes IPv6 and STATE columns.
|
|
|
|
---
|
|
|
|
## Section 5: Bridge Networking Baseline
|
|
|
|
Before setting up OVN, establish the baseline: bridge networks are
|
|
node-local. This demonstrates why OVN is needed.
|
|
|
|
### Same-Node Communication
|
|
|
|
Launch 2 containers on the same node. **Important**: use `--target` to force
|
|
placement — without it, the cluster scheduler may place containers on
|
|
different nodes automatically.
|
|
|
|
**Important**: launch containers **one at a time**, not chained with `&&`.
|
|
The first launch on a fresh cluster downloads the image (~1 GB), which
|
|
takes 2-3 minutes. Subsequent launches on the same node use the cached
|
|
image and are instant. Launches targeting a different node trigger another
|
|
image transfer to that node.
|
|
|
|
```bash
|
|
incus launch images:debian/12 oc-node-01:test-bridge-a --target oc-node-01
|
|
incus launch images:debian/12 oc-node-01:test-bridge-b --target oc-node-01
|
|
```
|
|
|
|
Wait for them to get IPs:
|
|
|
|
```bash
|
|
incus list oc-node-01: --columns ns4 --format csv | grep test-bridge
|
|
```
|
|
|
|
Ping between them:
|
|
|
|
```bash
|
|
IP_B=$(incus list oc-node-01:test-bridge-b --columns 4 --format csv | cut -d' ' -f1)
|
|
incus exec oc-node-01:test-bridge-a -- ping -c 3 "$IP_B"
|
|
```
|
|
|
|
Actual result: 0% packet loss, ~0.024ms latency. Same bridge, same node — works.
|
|
|
|
### Cross-Node Communication (Fails)
|
|
|
|
Launch a container on a different node:
|
|
|
|
```bash
|
|
incus launch images:debian/12 oc-node-01:test-bridge-c --target oc-node-02
|
|
```
|
|
|
|
Wait for IP:
|
|
|
|
```bash
|
|
incus list oc-node-01: --columns ns4 --format csv | grep test-bridge
|
|
```
|
|
|
|
Ping from node-01 to node-02:
|
|
|
|
```bash
|
|
IP_C=$(incus list oc-node-01:test-bridge-c --columns 4 --format csv | cut -d' ' -f1)
|
|
incus exec oc-node-01:test-bridge-a -- ping -c 3 -W 2 "$IP_C"
|
|
```
|
|
|
|
Actual result: **100% packet loss**. Bridge networks are node-local — there is
|
|
no L2 path between `incusbr0` on node-01 and `incusbr0` on node-02. Each
|
|
node's bridge has the same subnet (e.g., 10.251.22.1/24) but they are
|
|
separate L2 domains.
|
|
|
|
### Internet Access
|
|
|
|
NAT to the internet works from any node:
|
|
|
|
```bash
|
|
incus exec oc-node-01:test-bridge-a -- ping -c 3 1.1.1.1
|
|
```
|
|
|
|
Actual result: 0% packet loss, ~10ms latency. Each bridge provides NAT via
|
|
the host's management interface.
|
|
|
|
### Cleanup
|
|
|
|
```bash
|
|
incus delete oc-node-01:test-bridge-a --force
|
|
incus delete oc-node-01:test-bridge-b --force
|
|
incus delete oc-node-01:test-bridge-c --force
|
|
```
|
|
|
|
|
|
## Section 6: OVN Overlay Networking
|
|
|
|
OVN provides a cross-node L2 overlay using Geneve tunnels. After this
|
|
section, containers on any node can communicate transparently.
|
|
|
|
### 6.1 Deploy OVN Control Plane
|
|
|
|
Launch a Debian container on node-01 to host the OVN central services:
|
|
|
|
```bash
|
|
incus launch images:debian/12 oc-node-01:ovn-central --target oc-node-01
|
|
```
|
|
|
|
Install OVN:
|
|
|
|
```bash
|
|
incus exec oc-node-01:ovn-central -- bash -c \
|
|
"apt-get update -qq && apt-get install -y -qq ovn-central"
|
|
```
|
|
|
|
Configure OVN to listen on all interfaces:
|
|
|
|
```bash
|
|
incus exec oc-node-01:ovn-central -- ovn-nbctl set-connection ptcp:6641:0.0.0.0
|
|
incus exec oc-node-01:ovn-central -- ovn-sbctl set-connection ptcp:6642:0.0.0.0
|
|
```
|
|
|
|
Add proxy devices to expose NB (6641) and SB (6642) on the host's LAN IP:
|
|
|
|
```bash
|
|
incus config device add oc-node-01:ovn-central \
|
|
nb-proxy proxy listen=tcp:192.168.102.140:6641 connect=tcp:127.0.0.1:6641
|
|
incus config device add oc-node-01:ovn-central \
|
|
sb-proxy proxy listen=tcp:192.168.102.140:6642 connect=tcp:127.0.0.1:6642
|
|
```
|
|
|
|
### 6.2 Enable OVN on All IncusOS Nodes
|
|
|
|
Enable OVN services via the IncusOS REST API (`/os/1.0/services/ovn`).
|
|
The `database` field is the **southbound** DB (port 6642), not northbound.
|
|
|
|
```bash
|
|
for node_ip in 192.168.102.140 192.168.102.141 192.168.102.142; do
|
|
remote="oc-node-$(echo $node_ip | cut -d. -f4 | sed 's/140/01/;s/141/02/;s/142/03/')"
|
|
incus query ${remote}:/os/1.0/services/ovn --request PUT --data "{
|
|
\"config\": {
|
|
\"database\": \"tcp:192.168.102.140:6642\",
|
|
\"enabled\": true,
|
|
\"tunnel_address\": \"${node_ip}\",
|
|
\"tunnel_protocol\": \"geneve\"
|
|
},
|
|
\"state\": {}
|
|
}"
|
|
done
|
|
```
|
|
|
|
Each call should return `{}` on success.
|
|
|
|
### 6.3 Configure Incus OVN Connection
|
|
|
|
Point Incus to the **northbound** DB (port 6641):
|
|
|
|
```bash
|
|
incus config set oc-node-01: network.ovn.northbound_connection tcp:192.168.102.140:6641
|
|
```
|
|
|
|
### 6.4 Assign OVN Chassis Role
|
|
|
|
Every node that will host OVN workloads needs the `ovn-chassis` role:
|
|
|
|
```bash
|
|
for node in oc-node-01 oc-node-02 oc-node-03; do
|
|
incus cluster role add oc-node-01:${node} ovn-chassis
|
|
done
|
|
```
|
|
|
|
Verify:
|
|
|
|
```bash
|
|
incus cluster list oc-node-01:
|
|
```
|
|
|
|
The ROLES column should now include `ovn-chassis` for each member.
|
|
|
|
### 6.5 Create UPLINK Physical Network
|
|
|
|
The UPLINK network provides the bridge between OVN virtual networks and
|
|
the physical LAN. It uses the two-step cluster pattern: per-member
|
|
`--target` first, then cluster-wide create.
|
|
|
|
**Important**: IncusOS names its management NIC `mgmt`, NOT `ens18`. Using
|
|
`parent=ens18` will fail with "Parent interface 'ens18' not found".
|
|
|
|
```bash
|
|
# Per-target (parent is member-specific)
|
|
for node in oc-node-01 oc-node-02 oc-node-03; do
|
|
incus network create oc-node-01:UPLINK --type=physical --target=${node} parent=mgmt
|
|
done
|
|
|
|
# Cluster-wide config
|
|
incus network create oc-node-01:UPLINK --type=physical \
|
|
ipv4.ovn.ranges=192.168.103.200-192.168.103.210 \
|
|
ipv4.gateway=192.168.100.1/22 \
|
|
dns.nameservers=192.168.100.1
|
|
```
|
|
|
|
### 6.6 Create OVN Network (net-prod)
|
|
|
|
```bash
|
|
incus network create oc-node-01:net-prod --type=ovn \
|
|
network=UPLINK ipv4.address=10.10.10.1/24 ipv4.nat=true
|
|
```
|
|
|
|
Actual output:
|
|
|
|
```
|
|
Network net-prod created
|
|
```
|
|
|
|
net-prod is assigned external IP `192.168.103.200` from the UPLINK range.
|
|
|
|
### 6.7 Verify Cross-Node OVN Connectivity
|
|
|
|
```bash
|
|
incus launch images:debian/12 oc-node-01:test-1 --target oc-node-01 -n net-prod
|
|
incus launch images:debian/12 oc-node-01:test-2 --target oc-node-02 -n net-prod
|
|
incus exec oc-node-01:test-1 -- ping -c 3 10.10.10.3
|
|
```
|
|
|
|
Actual output:
|
|
|
|
```
|
|
64 bytes from 10.10.10.3: icmp_seq=1 ttl=64 time=0.669 ms
|
|
64 bytes from 10.10.10.3: icmp_seq=2 ttl=64 time=0.136 ms
|
|
64 bytes from 10.10.10.3: icmp_seq=3 ttl=64 time=0.194 ms
|
|
```
|
|
|
|
Sub-millisecond cross-node latency via Geneve tunnels. Clean up test
|
|
containers after verification:
|
|
|
|
```bash
|
|
incus delete oc-node-01:test-1 oc-node-01:test-2 --force
|
|
```
|
|
|
|
### 6.8 Final Network State
|
|
|
|
```bash
|
|
incus network list oc-node-01:
|
|
```
|
|
|
|
Actual output:
|
|
|
|
| NAME | TYPE | MANAGED | IPV4 | DESCRIPTION | USED BY |
|
|
|------|------|---------|------|-------------|---------|
|
|
| UPLINK | physical | YES | | | 1 |
|
|
| incusbr0 | bridge | YES | 10.x.x.1/24 | Local network bridge (NAT) | 2 |
|
|
| meshbr0 | bridge | YES | none | Internal mesh network bridge | 1 |
|
|
| net-prod | ovn | YES | 10.10.10.1/24 | | 0 |
|
|
|
|
The incusbr0 subnet is randomly assigned per deployment. The USED BY count
|
|
for net-prod is 0 at this point (test containers deleted); it increases as
|
|
workloads are added in subsequent sections.
|
|
|
|
---
|
|
|
|
## Section 7: Mixed Workloads
|
|
|
|
Deploy a realistic workload mix: web servers, application containers, and
|
|
VMs configured for live migration.
|
|
|
|
### 7.1 Containers on net-prod
|
|
|
|
Deploy containers with targeted placement across nodes. Run each launch
|
|
command **one at a time** — each new target node needs to download the
|
|
image from the cluster (~1 GB transfer, 2-3 minutes per node):
|
|
|
|
```bash
|
|
incus launch images:debian/12 oc-node-01:prod-web-01 --network net-prod --target oc-node-01
|
|
incus launch images:debian/12 oc-node-01:prod-web-02 --network net-prod --target oc-node-02
|
|
incus launch images:debian/12 oc-node-01:prod-api-01 --network net-prod --target oc-node-03
|
|
```
|
|
|
|
Install nginx on the web servers:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-web-01 -- bash -c "apt-get update && apt-get install -y nginx"
|
|
incus exec oc-node-01:prod-web-02 -- bash -c "apt-get update && apt-get install -y nginx"
|
|
```
|
|
|
|
Set distinct content to verify load balancing later:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-web-01 -- bash -c "echo 'Server: prod-web-01' > /var/www/html/index.html"
|
|
incus exec oc-node-01:prod-web-02 -- bash -c "echo 'Server: prod-web-02' > /var/www/html/index.html"
|
|
```
|
|
|
|
Install nginx on the API container:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-api-01 -- bash -c "apt-get update && apt-get install -y nginx"
|
|
incus exec oc-node-01:prod-api-01 -- bash -c "echo 'API: prod-api-01' > /var/www/html/index.html"
|
|
```
|
|
|
|
### 7.2 VMs (Migration-Ready)
|
|
|
|
Deploy VMs with live migration configuration:
|
|
|
|
```bash
|
|
incus launch images:debian/12 oc-node-01:prod-db-01 --vm --network net-prod --target oc-node-01
|
|
incus launch images:debian/12 oc-node-01:prod-app-01 --vm --network net-prod --target oc-node-02
|
|
```
|
|
|
|
VMs may take longer to boot than containers (~30-60s for image download +
|
|
boot). If the VMs show as STOPPED, start them explicitly:
|
|
|
|
```bash
|
|
incus start oc-node-01:prod-db-01
|
|
incus start oc-node-01:prod-app-01
|
|
```
|
|
|
|
Wait for the VM agent to become available, then verify:
|
|
|
|
```bash
|
|
# Check VM agent is running
|
|
incus exec oc-node-01:prod-db-01 -- uname -a
|
|
incus exec oc-node-01:prod-app-01 -- uname -a
|
|
```
|
|
|
|
### 7.3 Configure VMs for Live Migration
|
|
|
|
**Critical**: use `limits.cpu` as a **range** (e.g., `0-1`), not an integer.
|
|
Without the range, QEMU sets `maxcpus` based on the host's CPU count, which
|
|
varies across nodes and breaks migration with `Missing section footer for
|
|
ICH9LPC`.
|
|
|
|
Stop VMs before configuring `migration.stateful`:
|
|
|
|
```bash
|
|
incus stop oc-node-01:prod-db-01
|
|
incus stop oc-node-01:prod-app-01
|
|
```
|
|
|
|
Configure migration settings:
|
|
|
|
```bash
|
|
# prod-db-01
|
|
incus config set oc-node-01:prod-db-01 limits.cpu=0-1
|
|
incus config set oc-node-01:prod-db-01 migration.stateful=true
|
|
incus config device override oc-node-01:prod-db-01 root size.state=2GiB
|
|
|
|
# prod-app-01
|
|
incus config set oc-node-01:prod-app-01 limits.cpu=0-1
|
|
incus config set oc-node-01:prod-app-01 migration.stateful=true
|
|
incus config device override oc-node-01:prod-app-01 root size.state=2GiB
|
|
```
|
|
|
|
**Important**: use `device override` (not `device set`) because the `root`
|
|
device comes from the default profile. `device set` fails with "Device from
|
|
profile(s) cannot be modified for individual instance".
|
|
|
|
Start the VMs:
|
|
|
|
```bash
|
|
incus start oc-node-01:prod-db-01
|
|
incus start oc-node-01:prod-app-01
|
|
```
|
|
|
|
### 7.4 Workload Distribution
|
|
|
|
View the full workload distribution:
|
|
|
|
```bash
|
|
incus list oc-node-01: --columns nstL4 --format table
|
|
```
|
|
|
|
Expected layout:
|
|
|
|
| NAME | STATE | TYPE | LOCATION | IPV4 |
|
|
|-------------|---------|-----------------|-------------|-----------------------|
|
|
| ovn-central | RUNNING | CONTAINER | oc-node-01 | ... |
|
|
| prod-web-01 | RUNNING | CONTAINER | oc-node-01 | 10.10.10.x (net-prod) |
|
|
| prod-db-01 | RUNNING | VIRTUAL-MACHINE | oc-node-01 | 10.10.10.x (net-prod) |
|
|
| prod-web-02 | RUNNING | CONTAINER | oc-node-02 | 10.10.10.x (net-prod) |
|
|
| prod-app-01 | RUNNING | VIRTUAL-MACHINE | oc-node-02 | 10.10.10.x (net-prod) |
|
|
| prod-api-01 | RUNNING | CONTAINER | oc-node-03 | 10.10.10.x (net-prod) |
|
|
|
|
|
|
## Section 8: Network Isolation & Security
|
|
|
|
### 8.1 Create Isolated Network
|
|
|
|
```bash
|
|
incus network create oc-node-01:net-isolated --type=ovn network=UPLINK \
|
|
ipv4.address=10.10.20.1/24 \
|
|
ipv4.nat=true \
|
|
ipv6.address=none
|
|
```
|
|
|
|
### 8.2 Launch Isolated Containers
|
|
|
|
```bash
|
|
incus launch images:debian/12 oc-node-01:iso-app-01 --network net-isolated --target oc-node-01
|
|
incus launch images:debian/12 oc-node-01:iso-app-02 --network net-isolated --target oc-node-02
|
|
```
|
|
|
|
### 8.3 Verify Network Isolation
|
|
|
|
Containers on net-isolated can reach each other:
|
|
|
|
```bash
|
|
IP_ISO2=$(incus list oc-node-01:iso-app-02 --columns 4 --format csv | cut -d' ' -f1)
|
|
incus exec oc-node-01:iso-app-01 -- ping -c 3 "$IP_ISO2"
|
|
```
|
|
|
|
Actual result: 0% packet loss, ~0.15-0.5ms latency. Containers on the
|
|
same OVN network can reach each other across nodes.
|
|
|
|
But net-prod **cannot** reach net-isolated:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-web-01 -- ping -c 3 -W 2 "$IP_ISO2"
|
|
```
|
|
|
|
Actual result: **100% packet loss**. Different OVN networks are fully
|
|
isolated — separate L2 domains, no routing between them.
|
|
|
|
### 8.4 Create Network ACL
|
|
|
|
Create an ACL that blocks ICMP from a specific source:
|
|
|
|
```bash
|
|
incus network acl create oc-node-01:block-ping
|
|
incus network acl rule add oc-node-01:block-ping ingress \
|
|
action=drop protocol=icmp4 \
|
|
source=10.10.10.0/24 \
|
|
description="Block ICMP from net-prod subnet"
|
|
```
|
|
|
|
### 8.5 Apply and Test ACL
|
|
|
|
Apply the ACL to net-isolated:
|
|
|
|
```bash
|
|
incus network set oc-node-01:net-isolated security.acls=block-ping
|
|
```
|
|
|
|
Verify ICMP is blocked between net-isolated containers (since they match
|
|
the source range — adjust the ACL source for targeted blocking):
|
|
|
|
```bash
|
|
incus exec oc-node-01:iso-app-01 -- ping -c 3 -W 2 "$IP_ISO2"
|
|
```
|
|
|
|
Remove the ACL:
|
|
|
|
```bash
|
|
incus network unset oc-node-01:net-isolated security.acls
|
|
```
|
|
|
|
Verify ICMP works again:
|
|
|
|
```bash
|
|
incus exec oc-node-01:iso-app-01 -- ping -c 3 "$IP_ISO2"
|
|
```
|
|
|
|
### 8.6 Network Peering
|
|
|
|
Connect net-prod and net-isolated so containers on both networks can
|
|
communicate. Peering is bilateral — create a peer on both sides:
|
|
|
|
```bash
|
|
# From net-prod's perspective
|
|
incus network peer create oc-node-01:net-prod peer-to-isolated net-isolated \
|
|
--description "Peer to isolated network"
|
|
|
|
# From net-isolated's perspective
|
|
incus network peer create oc-node-01:net-isolated peer-to-prod net-prod \
|
|
--description "Peer to production network"
|
|
```
|
|
|
|
### 8.7 Verify Peering
|
|
|
|
Cross-network ping (prod → isolated):
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-web-01 -- ping -c 3 "$IP_ISO2"
|
|
```
|
|
|
|
Actual result: 0% packet loss with TTL=62 (64 - 2 router hops),
|
|
confirming traffic traverses the OVN routers on both sides of the peering.
|
|
|
|
Cross-network ping (isolated → prod):
|
|
|
|
```bash
|
|
IP_WEB1=$(incus list oc-node-01:prod-web-01 --columns 4 --format csv | cut -d' ' -f1)
|
|
incus exec oc-node-01:iso-app-01 -- ping -c 3 "$IP_WEB1"
|
|
```
|
|
|
|
### 8.8 Remove Peering
|
|
|
|
```bash
|
|
incus network peer delete oc-node-01:net-prod peer-to-isolated
|
|
incus network peer delete oc-node-01:net-isolated peer-to-prod
|
|
```
|
|
|
|
Verify isolation is restored:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-web-01 -- ping -c 3 -W 2 "$IP_ISO2"
|
|
```
|
|
|
|
Expected: 100% packet loss. Networks are isolated again.
|
|
|
|
Clean up isolated containers:
|
|
|
|
```bash
|
|
incus delete oc-node-01:iso-app-01 --force
|
|
incus delete oc-node-01:iso-app-02 --force
|
|
```
|
|
|
|
|
|
## Section 9: Load Balancers & Network Forwards
|
|
|
|
### 9.1 Create OVN Load Balancer
|
|
|
|
Create a load balancer with a VIP from the UPLINK range:
|
|
|
|
```bash
|
|
incus network load-balancer create oc-node-01:net-prod 192.168.103.200
|
|
```
|
|
|
|
Add backend servers. **Important**: backends require the instance's **IP
|
|
address**, not its name. Get the IPs first:
|
|
|
|
```bash
|
|
WEB1_IP=$(incus list oc-node-01:prod-web-01 --columns 4 --format csv | cut -d' ' -f1)
|
|
WEB2_IP=$(incus list oc-node-01:prod-web-02 --columns 4 --format csv | cut -d' ' -f1)
|
|
echo "prod-web-01: $WEB1_IP, prod-web-02: $WEB2_IP"
|
|
```
|
|
|
|
Add backends using IP addresses:
|
|
|
|
```bash
|
|
incus network load-balancer backend add oc-node-01:net-prod 192.168.103.200 \
|
|
web-01 "$WEB1_IP" 80
|
|
incus network load-balancer backend add oc-node-01:net-prod 192.168.103.200 \
|
|
web-02 "$WEB2_IP" 80
|
|
```
|
|
|
|
Add a port mapping:
|
|
|
|
```bash
|
|
incus network load-balancer port add oc-node-01:net-prod 192.168.103.200 \
|
|
tcp 80 web-01,web-02
|
|
```
|
|
|
|
### 9.2 Test Load Balancer
|
|
|
|
From your dev machine (must be on the same VLAN or have routing to
|
|
192.168.103.0/24):
|
|
|
|
```bash
|
|
for i in $(seq 1 6); do
|
|
curl -s http://192.168.103.200
|
|
done
|
|
```
|
|
|
|
Actual output:
|
|
|
|
```
|
|
Server: prod-web-01
|
|
Server: prod-web-01
|
|
Server: prod-web-01
|
|
Server: prod-web-02
|
|
Server: prod-web-02
|
|
Server: prod-web-02
|
|
```
|
|
|
|
OVN uses connection-based hashing (not round-robin). Multiple requests from
|
|
the same source will typically hit the same backend. Different source ports
|
|
or connections may hit different backends.
|
|
|
|
### 9.3 Create Network Forward
|
|
|
|
Network forwards expose internal services on LAN IPs. Forward
|
|
tcp:8080 → prod-api-01:80. **Like LB backends, forwards require IP addresses**:
|
|
|
|
```bash
|
|
API_IP=$(incus list oc-node-01:prod-api-01 --columns 4 --format csv | cut -d' ' -f1)
|
|
|
|
incus network forward create oc-node-01:net-prod 192.168.103.201
|
|
incus network forward port add oc-node-01:net-prod 192.168.103.201 \
|
|
tcp 8080 "$API_IP" 80
|
|
```
|
|
|
|
### 9.4 Test Network Forward
|
|
|
|
```bash
|
|
curl -s http://192.168.103.201:8080
|
|
```
|
|
|
|
Actual output: `API: prod-api-01`
|
|
|
|
### 9.5 DNS Resolution
|
|
|
|
OVN provides per-network DNS. Containers can resolve each other by
|
|
hostname:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-web-01 -- bash -c "apt-get install -y dnsutils && dig +short prod-web-02.incus"
|
|
```
|
|
|
|
Actual output: `10.10.10.3` — OVN DNS resolves instance names within
|
|
each network.
|
|
|
|
|
|
## Section 10: Live Migration
|
|
|
|
### 10.1 Verify Migration Readiness
|
|
|
|
Check that VMs have the required configuration:
|
|
|
|
```bash
|
|
for vm in prod-db-01 prod-app-01; do
|
|
echo "=== $vm ==="
|
|
incus config get oc-node-01:$vm limits.cpu
|
|
incus config get oc-node-01:$vm migration.stateful
|
|
incus config device get oc-node-01:$vm root size.state
|
|
done
|
|
```
|
|
|
|
Expected: `0-1`, `true`, `2GiB` for each VM.
|
|
|
|
### 10.2 Create Heartbeat Service
|
|
|
|
Create a simple counter in prod-db-01 to verify state continuity across
|
|
migration:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-db-01 -- bash -c '
|
|
mkdir -p /tmp/heartbeat
|
|
nohup bash -c "i=0; while true; do echo \$i > /tmp/heartbeat/counter; i=\$((i+1)); sleep 1; done" \
|
|
> /dev/null 2>&1 &
|
|
echo "Heartbeat started"
|
|
'
|
|
```
|
|
|
|
Read the counter:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-db-01 -- cat /tmp/heartbeat/counter
|
|
```
|
|
|
|
Note the value. After migration, the counter should continue from where
|
|
it left off (live migration preserves running state).
|
|
|
|
### 10.3 Live Migration Round-Trip
|
|
|
|
Check current location:
|
|
|
|
```bash
|
|
incus list oc-node-01:prod-db-01 --columns nL --format csv
|
|
```
|
|
|
|
**Migrate node-01 → node-02:**
|
|
|
|
```bash
|
|
time incus move oc-node-01:prod-db-01 --target oc-node-02
|
|
```
|
|
|
|
Actual result: 7.347s (~140 MB/s). Wait for the VM agent to reconnect:
|
|
|
|
```bash
|
|
sleep 4
|
|
incus exec oc-node-01:prod-db-01 -- cat /tmp/heartbeat/counter
|
|
```
|
|
|
|
Counter went from 9 → 25. The heartbeat process was never interrupted —
|
|
it continued counting during migration.
|
|
|
|
**Migrate node-02 → node-03:**
|
|
|
|
```bash
|
|
time incus move oc-node-01:prod-db-01 --target oc-node-03
|
|
sleep 4
|
|
incus exec oc-node-01:prod-db-01 -- cat /tmp/heartbeat/counter
|
|
```
|
|
|
|
Actual result: 7.379s. Counter went to 41.
|
|
|
|
**Migrate node-03 → node-01 (back to origin):**
|
|
|
|
```bash
|
|
time incus move oc-node-01:prod-db-01 --target oc-node-01
|
|
sleep 4
|
|
incus exec oc-node-01:prod-db-01 -- cat /tmp/heartbeat/counter
|
|
```
|
|
|
|
Actual result: 6.896s. Counter went to 56.
|
|
|
|
Verify the VM is back on node-01:
|
|
|
|
```bash
|
|
incus list oc-node-01:prod-db-01 --columns nL --format csv
|
|
```
|
|
|
|
### 10.4 Active I/O During Migration
|
|
|
|
Start a continuous write inside the VM:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-db-01 -- bash -c '
|
|
dd if=/dev/urandom of=/tmp/testfile bs=1M count=100 &
|
|
echo "Write started, PID: $!"
|
|
'
|
|
```
|
|
|
|
Migrate while I/O is active:
|
|
|
|
```bash
|
|
time incus move oc-node-01:prod-db-01 --target oc-node-02
|
|
sleep 4
|
|
```
|
|
|
|
Verify the file exists and is intact:
|
|
|
|
```bash
|
|
incus exec oc-node-01:prod-db-01 -- ls -la /tmp/testfile
|
|
incus exec oc-node-01:prod-db-01 -- md5sum /tmp/testfile
|
|
```
|
|
|
|
Move back:
|
|
|
|
```bash
|
|
incus move oc-node-01:prod-db-01 --target oc-node-01
|
|
sleep 4
|
|
```
|
|
|
|
### 10.5 Stateful Stop/Restore
|
|
|
|
Stateful stop saves VM memory to disk. On start, the VM resumes exactly
|
|
where it was:
|
|
|
|
```bash
|
|
# Note the heartbeat counter
|
|
incus exec oc-node-01:prod-app-01 -- bash -c '
|
|
mkdir -p /tmp/heartbeat
|
|
echo 42 > /tmp/heartbeat/counter
|
|
cat /tmp/heartbeat/counter
|
|
'
|
|
```
|
|
|
|
Stateful stop:
|
|
|
|
```bash
|
|
incus stop oc-node-01:prod-app-01 --stateful
|
|
```
|
|
|
|
Start (resumes from saved state):
|
|
|
|
```bash
|
|
incus start oc-node-01:prod-app-01
|
|
sleep 4
|
|
incus exec oc-node-01:prod-app-01 -- cat /tmp/heartbeat/counter
|
|
```
|
|
|
|
Expected: `42` — the file (and entire VM state) is preserved.
|
|
|
|
**If the restore fails** (e.g., from a `limits.cpu` mismatch), discard the
|
|
saved state:
|
|
|
|
```bash
|
|
incus start oc-node-01:prod-app-01 --stateless
|
|
```
|
|
|
|
|
|
## Section 11: Cluster Lifecycle
|
|
|
|
### 11.1 Evacuation & Restore
|
|
|
|
Evacuate node-02. All workloads are moved to other nodes:
|
|
|
|
```bash
|
|
incus cluster evacuate oc-node-01:oc-node-02 --force
|
|
```
|
|
|
|
Check workload distribution — nothing on node-02:
|
|
|
|
```bash
|
|
incus list oc-node-01: --columns nstL --format table
|
|
```
|
|
|
|
Actual behavior: VMs with `migration.stateful=true` are live-migrated
|
|
(prod-app-01 migrated to oc-node-03). Containers are stopped and moved
|
|
(prod-web-02 stopped, moved to oc-node-03, then started). The `--force`
|
|
flag skips confirmation prompts.
|
|
|
|
**Note**: if VMs lack the `limits.cpu` range fix, use `--action stop`
|
|
instead to avoid migration failures:
|
|
```bash
|
|
incus cluster evacuate oc-node-01:oc-node-02 --force --action stop
|
|
```
|
|
|
|
Verify node-02 shows EVACUATED:
|
|
|
|
```bash
|
|
incus cluster list oc-node-01:
|
|
```
|
|
|
|
Restore node-02 (workloads return):
|
|
|
|
```bash
|
|
incus cluster restore oc-node-01:oc-node-02 --force
|
|
```
|
|
|
|
Verify all workloads are back:
|
|
|
|
```bash
|
|
incus list oc-node-01: --columns nstL --format table
|
|
incus cluster list oc-node-01:
|
|
```
|
|
|
|
All nodes should show ONLINE.
|
|
|
|
### 11.2 Node Failure Simulation
|
|
|
|
A Proxmox hard-stop on a VM simulates a crash. The Incus cluster heartbeat
|
|
detects the failure in ~40 seconds. After the node is restarted:
|
|
|
|
1. The node auto-rejoins the cluster (~60s)
|
|
2. Containers auto-start
|
|
3. VMs that were running resume
|
|
|
|
**Procedure** (document only — do not execute while OVN is running unless
|
|
you can tolerate temporary network disruption):
|
|
|
|
```bash
|
|
# Simulate crash: hard-stop via Proxmox API
|
|
# incusos/helpers/proxmox-api POST /nodes/pve/qemu/401/status/stop
|
|
|
|
# Wait for heartbeat detection (~40s)
|
|
# incus cluster list oc-node-01:
|
|
# → oc-node-02 shows OFFLINE
|
|
|
|
# Restart via Proxmox
|
|
# incusos/helpers/proxmox-api POST /nodes/pve/qemu/401/status/start
|
|
|
|
# Wait for auto-rejoin (~60s)
|
|
# incus cluster list oc-node-01:
|
|
# → oc-node-02 shows ONLINE
|
|
```
|
|
|
|
### 11.3 Node Replacement
|
|
|
|
Full procedure: evacuate a node, remove it from the cluster, destroy the
|
|
VM, deploy a fresh node, and join it back. This tests the complete lifecycle.
|
|
|
|
**Step 1: Evacuate node-03:**
|
|
|
|
```bash
|
|
incus cluster evacuate oc-node-01:oc-node-03 --force --action stop
|
|
```
|
|
|
|
**Step 2: Remove from cluster:**
|
|
|
|
```bash
|
|
printf "yes\n" | incus cluster remove oc-node-01:oc-node-03 --force
|
|
```
|
|
|
|
**Note**: `incus cluster remove` prompts "Are you really sure?" even with
|
|
`--force`. The `printf` pipes `yes` for automation.
|
|
|
|
**Step 3: Clean up the remote:**
|
|
|
|
```bash
|
|
incus remote remove oc-node-03
|
|
```
|
|
|
|
**Step 4: Destroy and redeploy the VM.** Use `incusos-proxmox` to destroy
|
|
just node-03 (VMID 402) and redeploy it. Create a single-VM config:
|
|
|
|
```yaml
|
|
# /tmp/lab-replace-node03.yaml
|
|
defaults:
|
|
cores: 4
|
|
memory: 20480
|
|
disk: 100
|
|
start_vmid: 402
|
|
|
|
vms:
|
|
- name: oc-node-03
|
|
app: incus
|
|
apply_defaults: false
|
|
ip: 192.168.102.142/22
|
|
```
|
|
|
|
```bash
|
|
./incusos-proxmox --cleanup --yes /tmp/lab-replace-node03.yaml
|
|
./incusos-proxmox --iso /tmp/IncusOS-oc.iso --yes /tmp/lab-replace-node03.yaml
|
|
```
|
|
|
|
**Step 5: Join the fresh node to the cluster:**
|
|
|
|
```bash
|
|
# Set specific IP
|
|
incus config set oc-node-03: core.https_address 192.168.102.142:8443
|
|
|
|
# Generate join token
|
|
incus cluster add oc-node-01:oc-node-03
|
|
|
|
# Join
|
|
printf '\n\nyes\nlocal/incus\nlocal/incus\n' | incus cluster join oc-node-01: oc-node-03:
|
|
|
|
# Fix remote
|
|
incus remote remove oc-node-03
|
|
incus remote add oc-node-03 https://192.168.102.142:8443 --accept-certificate
|
|
```
|
|
|
|
**Step 6: Re-enable OVN on the replacement node:**
|
|
|
|
```bash
|
|
incus query oc-node-03:/os/1.0/services/ovn --request PUT --data '{
|
|
"config": {
|
|
"database": "tcp:192.168.102.140:6642",
|
|
"enabled": true,
|
|
"tunnel_address": "192.168.102.142",
|
|
"tunnel_protocol": "geneve"
|
|
},
|
|
"state": {}
|
|
}'
|
|
|
|
incus cluster role add oc-node-01:oc-node-03 ovn-chassis
|
|
```
|
|
|
|
**Step 7: Verify:**
|
|
|
|
```bash
|
|
incus cluster list oc-node-01:
|
|
```
|
|
|
|
All 3 nodes should be ONLINE with `ovn-chassis` role.
|
|
|
|
### 11.4 Cluster Rebalancing
|
|
|
|
Enable automatic workload rebalancing. When a new node joins (or workloads
|
|
are unevenly distributed), Incus redistributes VMs:
|
|
|
|
```bash
|
|
incus config set oc-node-01: cluster.rebalance.interval=1
|
|
incus config set oc-node-01: cluster.rebalance.threshold=10
|
|
incus config set oc-node-01: cluster.rebalance.batch=2
|
|
incus config set oc-node-01: cluster.rebalance.cooldown=5m
|
|
```
|
|
|
|
**Important**: only VMs with `migration.stateful=true` are rebalanced.
|
|
Containers are NOT auto-rebalanced.
|
|
|
|
Monitor rebalancing:
|
|
|
|
```bash
|
|
incus list oc-node-01: --columns nstL --format table
|
|
```
|
|
|
|
Disable rebalancing when done testing:
|
|
|
|
```bash
|
|
incus config unset oc-node-01: cluster.rebalance.interval
|
|
incus config unset oc-node-01: cluster.rebalance.threshold
|
|
```
|
|
|
|
|
|
## Section 12: OC Dashboard
|
|
|
|
**Important**: Switch to the OC remote first. The OC CLI does not support
|
|
`remote:` suffix syntax:
|
|
|
|
```bash
|
|
operations-center remote switch oc-lab
|
|
```
|
|
|
|
### OC Server Information
|
|
|
|
```bash
|
|
operations-center admin os show
|
|
```
|
|
|
|
Actual output (version and uptime will vary):
|
|
|
|
```
|
|
environment:
|
|
hostname: oc-server
|
|
os_name: IncusOS
|
|
os_version: "202602240349"
|
|
os_version_next: ""
|
|
uptime: 3600
|
|
```
|
|
|
|
### Provisioning Status
|
|
|
|
OC manages all 3 cluster nodes. Verify they're registered and up to date:
|
|
|
|
```bash
|
|
operations-center provisioning server list
|
|
```
|
|
|
|
All nodes should show `ready` status and `up to date` update status.
|
|
|
|
### Web UI
|
|
|
|
The OC web UI at `https://192.168.102.120:8443/ui/` provides:
|
|
|
|
- **Dashboard**: server overview with resource utilization
|
|
- **Updates**: available IncusOS updates and delivery status
|
|
- **Provisioning**: token management, server list, cluster formation
|
|
- **System**: OC configuration and certificates
|
|
|
|
Because the nodes were deployed from an OC-provisioned ISO (Section 3),
|
|
OC has full visibility and management of the cluster — including update
|
|
delivery, server inventory, and cluster formation.
|
|
|
|
|
|
## Section 13: Cleanup
|
|
|
|
### Delete All Workloads
|
|
|
|
```bash
|
|
# Delete containers
|
|
for c in prod-web-01 prod-web-02 prod-api-01; do
|
|
incus delete oc-node-01:$c --force
|
|
done
|
|
|
|
# Delete VMs
|
|
for vm in prod-db-01 prod-app-01; do
|
|
incus delete oc-node-01:$vm --force
|
|
done
|
|
```
|
|
|
|
### Remove OVN Networks
|
|
|
|
```bash
|
|
# Delete OVN networks
|
|
incus network delete oc-node-01:net-prod
|
|
incus network delete oc-node-01:net-isolated 2>/dev/null || true
|
|
|
|
# Delete UPLINK
|
|
incus network delete oc-node-01:UPLINK
|
|
```
|
|
|
|
### Remove OVN Control Plane
|
|
|
|
```bash
|
|
incus delete oc-node-01:ovn-central --force
|
|
```
|
|
|
|
### Disable OVN Services
|
|
|
|
```bash
|
|
for node in oc-node-01 oc-node-02 oc-node-03; do
|
|
incus query "$node":/os/1.0/services/ovn --request PUT --data '{
|
|
"config": {
|
|
"enabled": false
|
|
},
|
|
"state": {}
|
|
}'
|
|
done
|
|
```
|
|
|
|
### Infrastructure Options
|
|
|
|
**Keep infrastructure** (stop VMs, keep on disk for later):
|
|
|
|
```bash
|
|
./incusos-proxmox --lab-down examples/lab-oc-nodes.yaml
|
|
./incusos-proxmox --lab-down examples/lab-oc-deploy.yaml
|
|
```
|
|
|
|
Restart later with:
|
|
|
|
```bash
|
|
./incusos-proxmox --lab-up examples/lab-oc-deploy.yaml
|
|
./incusos-proxmox --lab-up examples/lab-oc-nodes.yaml
|
|
```
|
|
|
|
**Full teardown** (destroy all VMs, remove ISOs, remotes, cache):
|
|
|
|
```bash
|
|
./incusos-proxmox --cleanup-all --deep --yes
|
|
```
|
|
|
|
|
|
## Section 14: Verification Checklist
|
|
|
|
| # | Check | Command | Expected |
|
|
|---|-------|---------|----------|
|
|
| 1 | All VMs running | `incusos-proxmox --status examples/lab-oc-nodes.yaml` | 4 VMs running, port 8443 open |
|
|
| 2 | Scrub schedule healthy | `incus query oc-node-01:/os/1.0/system/storage` | `scrub_schedule: "0 4 * * 0"` |
|
|
| 3 | OC accessible | `operations-center remote switch oc-lab && operations-center admin os show` | Shows version, uptime |
|
|
| 4 | Cluster formed | `incus cluster list oc-node-01:` | 3 nodes ONLINE |
|
|
| 5 | Storage pool | `incus storage list oc-node-01:` | `local` pool on all members |
|
|
| 6 | Bridge isolation | Ping cross-node on incusbr0 | 100% loss (expected) |
|
|
| 7 | OVN connectivity | Ping cross-node on net-prod | 0% loss |
|
|
| 8 | Internet via OVN | `ping 1.1.1.1` from OVN container | 0% loss |
|
|
| 9 | Network isolation | Ping net-prod → net-isolated | 100% loss (expected) |
|
|
| 10 | Network peering | Peer + ping cross-network | 0% loss, TTL=62 |
|
|
| 11 | Load balancer | `curl http://192.168.103.200` | Backend response |
|
|
| 12 | Network forward | `curl http://192.168.103.201:8080` | API response |
|
|
| 13 | DNS resolution | `dig prod-web-02.incus` from container | Resolves to 10.10.10.x |
|
|
| 14 | VM live migration | `incus move` VM between nodes | State preserved |
|
|
| 15 | Cluster evacuation | `incus cluster evacuate` + `restore` | Workloads moved and returned |
|
|
| 16 | Stateful stop/start | `incus stop --stateful` + `start` | VM state preserved |
|
|
|
|
|
|
## Section 15: Quick Reference
|
|
|
|
### Cluster Command Syntax
|
|
|
|
| Command | Arguments | Notes |
|
|
|---------|-----------|-------|
|
|
| `incus cluster enable` | `remote: member-name` | TWO args (space between) |
|
|
| `incus cluster add` | `remote:member-name` | ONE arg (no space) |
|
|
| `incus cluster join` | `init-remote: joining-remote:` | TWO args (space between) |
|
|
| `incus cluster remove` | `remote:member-name --force` | ONE arg; prompts even with `--force` |
|
|
| `incus cluster evacuate` | `remote:member-name` | ONE arg (no space) |
|
|
| `incus cluster restore` | `remote:member-name` | ONE arg (no space) |
|
|
| `incus config set` | `remote: key value` | Remote with trailing colon + space |
|
|
| `incus storage show` | `remote:pool` | ONE arg (no space) |
|
|
| `incus storage show` | `remote:pool --target member` | `--target` for member-specific |
|
|
|
|
### OVN Setup Cheat Sheet
|
|
|
|
```bash
|
|
# 1. Deploy OVN container
|
|
incus launch images:debian/12 REMOTE:ovn-central --target NODE
|
|
incus exec REMOTE:ovn-central -- apt-get install -y ovn-central ovn-host
|
|
incus exec REMOTE:ovn-central -- ovn-nbctl set-connection ptcp:6641:0.0.0.0
|
|
incus exec REMOTE:ovn-central -- ovn-sbctl set-connection ptcp:6642:0.0.0.0
|
|
incus config device add REMOTE:ovn-central nb-proxy proxy listen=tcp:HOST_IP:6641 connect=tcp:127.0.0.1:6641
|
|
incus config device add REMOTE:ovn-central sb-proxy proxy listen=tcp:HOST_IP:6642 connect=tcp:127.0.0.1:6642
|
|
|
|
# 2. Enable OVN on each IncusOS node
|
|
incus query NODE:/os/1.0/services/ovn --request PUT --data '{"config":{"database":"tcp:HOST_IP:6642","enabled":true,"tunnel_address":"NODE_IP","tunnel_protocol":"geneve"},"state":{}}'
|
|
|
|
# 3. Configure Incus
|
|
incus config set REMOTE: network.ovn.northbound_connection tcp:HOST_IP:6641
|
|
incus cluster role add REMOTE:MEMBER ovn-chassis # for each member
|
|
|
|
# 4. Create UPLINK (per-member then cluster-wide)
|
|
incus network create REMOTE:UPLINK --type physical --target MEMBER parent=mgmt # each member
|
|
incus network create REMOTE:UPLINK --type physical ipv4.ovn.ranges=RANGE ipv4.gateway=GW/PREFIX
|
|
|
|
# 5. Create OVN network
|
|
incus network create REMOTE:net-name --type=ovn network=UPLINK ipv4.address=SUBNET ipv4.nat=true
|
|
```
|
|
|
|
### Migration Readiness Checklist
|
|
|
|
| Setting | Value | Why |
|
|
|---------|-------|-----|
|
|
| `limits.cpu` | Range (e.g., `0-1`) | Fixed QEMU topology across hosts |
|
|
| `migration.stateful` | `true` | Enables live migration |
|
|
| `root size.state` | `2GiB` (or `4GiB` for 3-4 vCPUs) | Space for memory state file |
|
|
|
|
Configure while VM is **stopped**:
|
|
|
|
```bash
|
|
incus stop REMOTE:VM
|
|
incus config set REMOTE:VM limits.cpu=0-1
|
|
incus config set REMOTE:VM migration.stateful=true
|
|
incus config device override REMOTE:VM root size.state=2GiB
|
|
incus start REMOTE:VM
|
|
```
|
|
|
|
### Troubleshooting
|
|
|
|
| Symptom | Cause | Fix |
|
|
|---------|-------|-----|
|
|
| Port 8443 not reachable after boot | Boot still in progress or crontab bug | Wait 180s; check scrub_schedule via API |
|
|
| `scrub_schedule` empty | Crontab race condition | `incusos-proxmox --status` auto-heals |
|
|
| `Missing section footer for ICH9LPC` on migration | `limits.cpu` set as integer | Set as range: `limits.cpu=0-1` |
|
|
| `VM agent isn't currently running` after migration | Agent reconnecting | `sleep 4` after migration |
|
|
| `db.sock not found` on OVN config | OVN service not enabled on IncusOS | Enable via `/os/1.0/services/ovn` API |
|
|
| Cross-node ping fails (bridge) | Bridge networks are node-local | Use OVN network instead |
|
|
| `zfs load-key: Raw key too short` | TPM corruption from premature VM stop | Destroy and redeploy VM |
|
|
| Cluster join fails with "pool already exists" | `apply_defaults: true` on joining node | Use `apply_defaults: false` or run 8-command cleanup |
|
|
| OC cannot manage cluster nodes | Nodes deployed with standard ISO | Use OC-provisioned ISO for full integration |
|
|
| `CPUID vnmi` warning during migration | Cosmetic QEMU check | Safe to ignore |
|
|
| "Parent interface 'ens18' not found" | IncusOS names its NIC `mgmt` | Use `parent=mgmt` for UPLINK network |
|
|
| "Invalid target address" on LB backend | Backend needs IP, not instance name | Use instance IP address (e.g., `10.10.10.2`) |
|
|
| OC CLI "Invalid number of arguments" | OC CLI doesn't support `remote:` suffix | Use `operations-center remote switch NAME` first |
|
|
| Container placed on wrong node | Cluster auto-schedules without `--target` | Use `--target NODE` for explicit placement |
|
|
| "Device from profile(s) cannot be modified" | `root` device comes from default profile | Use `incus config device override` instead of `device set` |
|
|
| `incus launch` hangs or times out | Image download to new node takes 2-3 min | Run launches one at a time, not chained with `&&` |
|