Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions doc/config_options.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3860,6 +3860,23 @@ User keys can be used in search.
```

<!-- config group network_bridge-common end -->
<!-- config group network_bridge-ovn start -->
```{config:option} ovn.dynamic_routing network_bridge-ovn
:condition: "standard mode"
:default: "`false`"
:shortdesc: "Have downstream OVN networks export their prefixes into a host VRF on the active gateway chassis"
:type: "bool"

```

```{config:option} ovn.dynamic_routing.vrf.id network_bridge-ovn
:condition: "`ovn.dynamic_routing`"
:shortdesc: "Routing table ID of the pre-created host VRF OVN exports into (required when `ovn.dynamic_routing` is enabled)"
:type: "integer"

```

<!-- config group network_bridge-ovn end -->
<!-- config group network_forward-common start -->
```{config:option} target_address network_forward-common
:shortdesc: "Default target address for anything not covered through a port definition"
Expand Down Expand Up @@ -4467,6 +4484,21 @@ User keys can be used in search.

<!-- config group network_physical-ipv6 end -->
<!-- config group network_physical-ovn start -->
```{config:option} ovn.dynamic_routing network_physical-ovn
:condition: "standard mode"
:defaultdesc: "`false`"
:shortdesc: "Have downstream OVN networks export their prefixes into a host VRF on the active gateway chassis"
:type: "bool"

```

```{config:option} ovn.dynamic_routing.vrf.id network_physical-ovn
:condition: "`ovn.dynamic_routing`"
:shortdesc: "Routing table ID of the pre-created host VRF OVN exports into (required when `ovn.dynamic_routing` is enabled)"
:type: "integer"

```

```{config:option} ovn.ingress_mode network_physical-ovn
:condition: "standard mode"
:defaultdesc: "`l2proxy`"
Expand Down
42 changes: 42 additions & 0 deletions doc/howto/network_ovn_dynamic_routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
(network-ovn-dynamic-routing)=
# How to export OVN networks through OVN-native dynamic routing

```{note}
This feature requires OVN 25.03 or later.
```

OVN can export an OVN network's prefixes into the host's routing stack itself, as an alternative to announcing them through the {ref}`Incus BGP server <network-bgp>`.

Instead of announcing over BGP, OVN programs the network's prefixes into a Linux VRF table, but only on the cluster member that currently hosts the active OVN gateway chassis.
An external routing daemon on each host then redistributes the routes from that VRF towards the fabric.
On failover, OVN moves the routes to the new active gateway member automatically.

To match what the Incus BGP server announces for OVN networks, this covers:

- the network's connected subnets,
- its network forward and load-balancer addresses,
- and (for NATed networks) the SNAT external addresses.

The next-hop is the OVN router's uplink address.

## Requirements

- OVN 25.03 or later.
- An OVN network that has an uplink.
- A host VRF that already exists on every cluster member.

## Configure dynamic routing

Dynamic routing is configured on the uplink network rather than on each OVN network, so a single VRF id applies to every downstream OVN network sharing that uplink.

Set the following configuration options on the uplink network:

- `ovn.dynamic_routing=true`
- `ovn.dynamic_routing.vrf.id` - the routing table ID of the host VRF. This is required when `ovn.dynamic_routing` is enabled, and must match the table of the VRF you pre-created on every member.

```bash
incus network set UPLINK ovn.dynamic_routing=true ovn.dynamic_routing.vrf.id=100
```

Once enabled, the downstream OVN networks export their prefixes into the host VRF on whichever member currently hosts each network's active gateway chassis.
The routes appear in the configured routing table (`proto ovn`) on that member only, and move automatically on failover.
1 change: 1 addition & 0 deletions doc/networks.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Configure network forwards </howto/network_forwards>
Configure network integrations </howto/network_integrations>
Configure network zones </howto/network_zones>
Configure Incus as BGP server </howto/network_bgp>
Export OVN networks through dynamic routing </howto/network_ovn_dynamic_routing>
Display Incus IPAM information </howto/network_ipam>
/reference/network_bridge
/reference/network_ovn
Expand Down
38 changes: 38 additions & 0 deletions internal/server/metadata/configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -4296,6 +4296,27 @@
}
}
]
},
"ovn": {
"keys": [
{
"ovn.dynamic_routing": {
"condition": "standard mode",
"default": "`false`",
"longdesc": "",
"shortdesc": "Have downstream OVN networks export their prefixes into a host VRF on the active gateway chassis",
"type": "bool"
}
},
{
"ovn.dynamic_routing.vrf.id": {
"condition": "`ovn.dynamic_routing`",
"longdesc": "",
"shortdesc": "Routing table ID of the pre-created host VRF OVN exports into (required when `ovn.dynamic_routing` is enabled)",
"type": "integer"
}
}
]
}
},
"network_forward": {
Expand Down Expand Up @@ -5015,6 +5036,23 @@
},
"ovn": {
"keys": [
{
"ovn.dynamic_routing": {
"condition": "standard mode",
"defaultdesc": "`false`",
"longdesc": "",
"shortdesc": "Have downstream OVN networks export their prefixes into a host VRF on the active gateway chassis",
"type": "bool"
}
},
{
"ovn.dynamic_routing.vrf.id": {
"condition": "`ovn.dynamic_routing`",
"longdesc": "",
"shortdesc": "Routing table ID of the pre-created host VRF OVN exports into (required when `ovn.dynamic_routing` is enabled)",
"type": "integer"
}
},
{
"ovn.ingress_mode": {
"condition": "standard mode",
Expand Down
22 changes: 22 additions & 0 deletions internal/server/network/driver_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,23 @@ func (n *bridge) Validate(config map[string]string, clientType request.ClientTyp
// shortdesc: Whether to advertise a /128 route for the IPv6 address of each running instance
"bgp.ipv6.instances": validate.Optional(validate.IsBool),

// gendoc:generate(entity=network_bridge, group=ovn, key=ovn.dynamic_routing)
//
// ---
// type: bool
// condition: standard mode
// default: `false`
// shortdesc: Have downstream OVN networks export their prefixes into a host VRF on the active gateway chassis
"ovn.dynamic_routing": validate.Optional(validate.IsBool),

// gendoc:generate(entity=network_bridge, group=ovn, key=ovn.dynamic_routing.vrf.id)
//
// ---
// type: integer
// condition: `ovn.dynamic_routing`
// shortdesc: Routing table ID of the pre-created host VRF OVN exports into (required when `ovn.dynamic_routing` is enabled)
"ovn.dynamic_routing.vrf.id": validate.Optional(validate.IsUint32),

// gendoc:generate(entity=network_bridge, group=common, key=bridge.driver)
//
// ---
Expand Down Expand Up @@ -767,6 +784,11 @@ func (n *bridge) Validate(config map[string]string, clientType request.ClientTyp

// Perform composite key checks after per-key validation.

// Require explicit VRF id
if util.IsTrue(config["ovn.dynamic_routing"]) && config["ovn.dynamic_routing.vrf.id"] == "" {
return errors.New("ovn.dynamic_routing requires ovn.dynamic_routing.vrf.id to be set")
}

// Validate DNS zone names.
err = n.validateZoneNames(config)
if err != nil {
Expand Down
68 changes: 61 additions & 7 deletions internal/server/network/driver_ovn.go
Original file line number Diff line number Diff line change
Expand Up @@ -2589,6 +2589,54 @@ func (n *ovn) getDHCPv4Reservations() ([]iprange.Range, error) {
return dhcpReserveIPv4s, nil
}

// setupDynamicRouting configures (or clears) OVN-native dynamic routing on the logical router
// based on the uplink network's ovn.dynamic_routing.* config. The keys live on the uplink so a
// single VRF id applies to every OVN network sharing that uplink. When enabled, OVN syncs the
// router's connected prefixes, load-balancer/forward VIPs and (for NATed networks) SNAT addresses
// into a host VRF on the active gateway chassis, using the router's external uplink IPs as
// next-hops. The host VRF must already exist on the members, OVN only adds routes into it.
func (n *ovn) setupDynamicRouting(uplinkConfig map[string]string, routerExtPortIPv4 net.IP, routerExtPortIPv6 net.IP) error {
// Managed keys default to empty (delete) so disabling the feature clears prior state.
routerOpts := map[string]string{
"dynamic-routing": "",
"dynamic-routing-redistribute": "",
"dynamic-routing-vrf-id": "",
"dynamic-routing-v4-prefix-nexthop": "",
"dynamic-routing-v6-prefix-nexthop": "",
}

if util.IsTrue(uplinkConfig["ovn.dynamic_routing"]) {
routerOpts["dynamic-routing"] = "true"

// Always redistribute connected and lb. Redistribute nat only when enabled.
redistribute := []string{"connected", "lb"}
if util.IsTrue(n.config["ipv4.nat"]) || util.IsTrue(n.config["ipv6.nat"]) {
redistribute = append(redistribute, "nat")
}

routerOpts["dynamic-routing-redistribute"] = strings.Join(redistribute, ",")

if routerExtPortIPv4 != nil {
routerOpts["dynamic-routing-v4-prefix-nexthop"] = routerExtPortIPv4.String()
}

if routerExtPortIPv6 != nil {
routerOpts["dynamic-routing-v6-prefix-nexthop"] = routerExtPortIPv6.String()
}

if uplinkConfig["ovn.dynamic_routing.vrf.id"] != "" {
routerOpts["dynamic-routing-vrf-id"] = uplinkConfig["ovn.dynamic_routing.vrf.id"]
}
}

err := n.ovnnb.UpdateLogicalRouterOptions(context.TODO(), n.getRouterName(), routerOpts)
if err != nil {
return fmt.Errorf("Failed configuring dynamic routing on router: %w", err)
}

return nil
}

func (n *ovn) setup(update bool) error {
// If we are in mock mode, just no-op.
if n.state.OS.MockMode {
Expand Down Expand Up @@ -2782,6 +2830,18 @@ func (n *ovn) setup(update bool) error {
})
}

// Load the uplink network.
// We have an external router port, so the network must have an uplink.
uplinkNetworkObj, err := LoadByName(n.state, api.ProjectDefaultName, n.config["network"])
if err != nil {
return fmt.Errorf("Failed loading uplink network %q: %w", n.config["network"], err)
}

err = n.setupDynamicRouting(uplinkNetworkObj.Config(), routerExtPortIPv4, routerExtPortIPv6)
if err != nil {
return err
}

// Create external switch port and link to router port.
err = n.ovnnb.CreateLogicalSwitchPort(context.TODO(), n.getExtSwitchName(), n.getExtSwitchRouterPortName(), nil, update)
if err != nil {
Expand Down Expand Up @@ -2862,12 +2922,6 @@ func (n *ovn) setup(update bool) error {

// Check if uplink network states its gateway mac for static MAC binding.
if uplinkNet != nil && n.config["network"] != "none" {
// Load the uplink network.
uplinkNetworkObj, err := LoadByName(n.state, api.ProjectDefaultName, n.config["network"])
if err != nil {
return fmt.Errorf("Failed loading uplink network %q: %w", n.config["network"], err)
}

uplinkConfig := uplinkNetworkObj.Config()

// Handle IPv4 MAC.
Expand Down Expand Up @@ -5940,7 +5994,7 @@ func (n *ovn) handleDependencyChange(uplinkName string, uplinkConfig map[string]
break // Only run setup once per notification (all changes will be applied).
}

watchedKeys := []string{"dns.nameservers", "ipv4.gateway", "ipv6.gateway", "ipv4.gateway.hwaddr", "ipv6.gateway.hwaddr"}
watchedKeys := []string{"dns.nameservers", "ipv4.gateway", "ipv6.gateway", "ipv4.gateway.hwaddr", "ipv6.gateway.hwaddr", "ovn.dynamic_routing", "ovn.dynamic_routing.vrf.id"}
for _, k := range append(watchedKeys, uplinkKeys...) {
if !slices.Contains(changedKeys, k) {
continue
Expand Down
22 changes: 22 additions & 0 deletions internal/server/network/driver_physical.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ func (n *physical) Validate(config map[string]string, clientType request.ClientT
// shortdesc: Sets the method how OVN NIC external IPs will be advertised on uplink network: `l2proxy` (proxy ARP/NDP) or `routed`
"ovn.ingress_mode": validate.Optional(validate.IsOneOf("l2proxy", "routed")),

// gendoc:generate(entity=network_physical, group=ovn, key=ovn.dynamic_routing)
//
// ---
// type: bool
// condition: standard mode
// defaultdesc: `false`
// shortdesc: Have downstream OVN networks export their prefixes into a host VRF on the active gateway chassis
"ovn.dynamic_routing": validate.Optional(validate.IsBool),

// gendoc:generate(entity=network_physical, group=ovn, key=ovn.dynamic_routing.vrf.id)
//
// ---
// type: integer
// condition: `ovn.dynamic_routing`
// shortdesc: Routing table ID of the pre-created host VRF OVN exports into (required when `ovn.dynamic_routing` is enabled)
"ovn.dynamic_routing.vrf.id": validate.Optional(validate.IsUint32),

"volatile.last_state.created": validate.Optional(validate.IsBool),
}

Expand Down Expand Up @@ -235,6 +252,11 @@ func (n *physical) Validate(config map[string]string, clientType request.ClientT
return err
}

// Require explicit VRF id
if util.IsTrue(config["ovn.dynamic_routing"]) && config["ovn.dynamic_routing.vrf.id"] == "" {
return errors.New("ovn.dynamic_routing requires ovn.dynamic_routing.vrf.id to be set")
}

return nil
}

Expand Down
41 changes: 41 additions & 0 deletions internal/server/network/ovn/ovn_nb_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,47 @@ func (o *NB) UpdateLogicalRouterPort(ctx context.Context, portName OVNRouterPort
return nil
}

// UpdateLogicalRouterOptions sets or deletes the given keys in a logical router's options.
// A key whose value is an empty string is deleted from the options; the other keys are set.
// Options not present in the map are left untouched.
func (o *NB) UpdateLogicalRouterOptions(ctx context.Context, routerName OVNRouter, options map[string]string) error {
logicalRouter, err := o.GetLogicalRouter(ctx, routerName)
if err != nil {
return err
}

if logicalRouter.Options == nil {
logicalRouter.Options = map[string]string{}
}

for k, v := range options {
if v == "" {
delete(logicalRouter.Options, k)
} else {
logicalRouter.Options[k] = v
}
}

// Update the record.
operations, err := o.client.Where(logicalRouter).Update(logicalRouter)
if err != nil {
return err
}

// Apply the changes.
resp, err := o.client.Transact(ctx, operations...)
if err != nil {
return err
}

_, err = ovsdb.CheckOperationResults(resp, operations)
if err != nil {
return err
}

return nil
}

// GetLogicalSwitch gets the OVN database record for the switch.
func (o *NB) GetLogicalSwitch(ctx context.Context, switchName OVNSwitch) (*ovnNB.LogicalSwitch, error) {
logicalSwitch := &ovnNB.LogicalSwitch{
Expand Down
Loading