Skip to content

Commit bf3d8a3

Browse files
committed
Direct Routed (L3) guest networks: route public IPv4/IPv6 directly to Instances
Adds a new guest network type in which the hypervisor performs L3 routing for the Instance: no Virtual Router, no NAT and no DHCP. Each Instance receives a public IPv4 address as a /32 and/or an IPv6 address as a /128, with a shared, host-independent gateway (169.254.0.1 and fe80::1) that every hypervisor carries on the network's bridge. All addressing reaches the Instance exclusively via ConfigDrive/cloud-init; a routing daemon on the host (FRR, BIRD, ...) advertises the addresses to the fabric and is deliberately out of scope for CloudStack. Management server: - GuestType.L3; the guest_type column is char(32), so no schema change. - Offering validation: UserData via ConfigDrive is mandatory, Dns optional but ConfigDrive-only, SecurityGroup permitted (now allowed for L3 alongside Shared), Dhcp rejected as not supported and not needed. Network mode, specifyVlan and VPC use are rejected. - DirectRoutedNetworkGuru subclasses DirectNetworkGuru, inheriting the Shared-network address lifecycle. canHandle() selects on the offering's guest type alone; design() produces a Native broadcast domain with no isolation id. After allocation the NicProfile is forced into host-route form, which is also the signature by which the agent and ConfigDrive recognise these NICs. - createNetwork treats L3 like Shared for the subnet: explicit IP range mandatory, vlan/IP-range row created at network creation, IPv6 accepted without the /64 restriction, aclType Account. - Zone-wide IPv4 overlap validation for L3 ranges: all L3 subnets share one host routing table and one fabric, so an overlap is an address conflict. The IPv6 vlan check was already zone-wide. ConfigDrive: - Network data is always generated for a direct routed NIC; the historical gate (Dhcp or Dns supported) held while ConfigDrive supplemented a VR but would leave these NICs with no addressing at all. Route generation itself is unchanged: cloud-init detects an IPv4 gateway inside 169.254.0.0/16 and sets on-link on the rendered route by itself. KVM agent: - One uplink-less bridge per network, brdr-<network id>, created and removed by the new modifybrdr.sh (flock'd, idempotent, refuses to remove a bridge still in use). The bridge carries the gateway addresses, forwarding and strict rp_filter; separate bridges make isolation between networks topological rather than a filtering concern. - BridgeVifDriver plugs direct routed NICs into their brdr bridge and runs the existing modifymacip.sh hook per NIC to install the static neighbour entry and host route, regardless of the host-wide EVPN property, whose meaning is unchanged. The design document, including the decision log and the verification notes behind each choice, is added under docs/design/.
1 parent 4f11707 commit bf3d8a3

31 files changed

Lines changed: 3604 additions & 192 deletions

File tree

api/src/main/java/com/cloud/network/Network.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
public interface Network extends ControlledEntity, StateObject<Network.State>, InternalIdentity, Identity, Serializable, Displayable {
4444

4545
enum GuestType {
46-
Shared, Isolated, L2;
46+
Shared, Isolated, L2, L3;
4747

4848
public static GuestType fromValue(String type) {
4949
if (StringUtils.isBlank(type)) {
@@ -54,6 +54,8 @@ public static GuestType fromValue(String type) {
5454
return Isolated;
5555
} else if (type.equalsIgnoreCase("L2")) {
5656
return L2;
57+
} else if (type.equalsIgnoreCase("L3")) {
58+
return L3;
5759
} else {
5860
throw new InvalidParameterValueException("Unexpected Guest type : " + type);
5961
}

api/src/main/java/org/apache/cloudstack/api/command/user/network/CreateNetworkCmd.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,10 @@ public Long getPhysicalNetworkId() {
340340
}
341341
}
342342
if (physicalNetworkId != null) {
343-
if ((offering.getGuestType() == GuestType.Shared) || (offering.getGuestType() == GuestType.L2)) {
343+
if ((offering.getGuestType() == GuestType.Shared) || (offering.getGuestType() == GuestType.L2) || (offering.getGuestType() == GuestType.L3)) {
344344
return physicalNetworkId;
345345
} else {
346-
throw new InvalidParameterValueException("Physical network ID can be specified for networks of guest IP type " + GuestType.Shared + " or " + GuestType.L2 + " only.");
346+
throw new InvalidParameterValueException(String.format("Physical network ID can be specified for networks of guest IP type %s, %s or %s only.", GuestType.Shared, GuestType.L2, GuestType.L3));
347347
}
348348
} else {
349349
if (zoneId == null) {

api/src/main/java/org/apache/cloudstack/api/command/user/vm/RemoveIpFromVmNicCmd.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ public NetworkType getNetworkType() {
124124
}
125125

126126

127+
/**
128+
* A Direct Routed (L3) network needs the agent told when a secondary IP goes away, so the
129+
* host route and neighbour entry are removed - otherwise the host keeps routing an address
130+
* the Instance no longer owns, and the routing daemon keeps advertising it.
131+
*/
132+
private boolean isDirectRoutedNetwork() {
133+
Network ntwk = _entityMgr.findById(Network.class, getNetworkId());
134+
return ntwk != null && Network.GuestType.L3.equals(ntwk.getGuestType());
135+
}
136+
127137
private boolean isZoneSGEnabled() {
128138
Network ntwk = _entityMgr.findById(Network.class, getNetworkId());
129139
DataCenter dc = _entityMgr.findById(DataCenter.class, ntwk.getDataCenterId());
@@ -144,7 +154,7 @@ public void execute() throws InvalidParameterValueException {
144154
secIp = nicSecIp.getIp6Address();
145155
}
146156

147-
if (isZoneSGEnabled()) {
157+
if (isZoneSGEnabled() || isDirectRoutedNetwork()) {
148158
//remove the security group rules for this secondary ip
149159
boolean success = false;
150160
success = _securityGroupService.securityGroupRulesForVmSecIp(nicSecIp.getNicId(), secIp, false);

core/src/main/java/com/cloud/agent/api/NetworkRulesVmSecondaryIpCommand.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,28 @@ public class NetworkRulesVmSecondaryIpCommand extends Command {
2828
private String vmSecIp;
2929
private String vmMac;
3030
private String action;
31+
private boolean directRouted;
32+
private boolean applySecurityGroupRules = true;
3133

3234
public NetworkRulesVmSecondaryIpCommand(String vmName, VirtualMachine.Type type) {
3335
this.vmName = vmName;
3436
this.type = type;
3537
}
3638

39+
public NetworkRulesVmSecondaryIpCommand(String vmName, String vmMac, String secondaryIp, boolean action, boolean directRouted, boolean applySecurityGroupRules) {
40+
this(vmName, vmMac, secondaryIp, action);
41+
this.directRouted = directRouted;
42+
this.applySecurityGroupRules = applySecurityGroupRules;
43+
}
44+
45+
public boolean isDirectRouted() {
46+
return directRouted;
47+
}
48+
49+
public boolean isApplySecurityGroupRules() {
50+
return applySecurityGroupRules;
51+
}
52+
3753
public NetworkRulesVmSecondaryIpCommand(String vmName, String vmMac, String secondaryIp, boolean action) {
3854
this.vmName = vmName;
3955
this.vmMac = vmMac;

docs/design/direct-routed-networks.md

Lines changed: 1258 additions & 0 deletions
Large diffs are not rendered by default.

engine/storage/configdrive/src/main/java/org/apache/cloudstack/storage/configdrive/ConfigDriveBuilder.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949
import com.cloud.network.NetworkModel;
5050
import com.cloud.utils.exception.CloudRuntimeException;
51+
import com.cloud.utils.net.NetUtils;
5152
import com.cloud.utils.script.Script;
5253
import com.google.gson.JsonArray;
5354
import com.google.gson.JsonElement;
@@ -249,8 +250,15 @@ static void writeVmMetadata(List<String[]> vmData, String tempDirName, File open
249250
*/
250251
static void writeNetworkData(List<NicProfile> nics, Map<Long, List<Network.Service>> supportedServices, File openStackFolder) {
251252
JsonObject finalNetworkData = new JsonObject();
252-
if (needForGeneratingNetworkData(supportedServices)) {
253+
// A direct routed NIC always needs its network data written: ConfigDrive is the only
254+
// channel that carries its addressing, whatever services the offering does or does not
255+
// have. For every other NIC the historical gate (Dhcp or Dns supported) is unchanged.
256+
boolean generateForAllNics = needForGeneratingNetworkData(supportedServices);
257+
if (generateForAllNics || nics.stream().anyMatch(ConfigDriveBuilder::isDirectRoutedNic)) {
253258
for (NicProfile nic : nics) {
259+
if (!generateForAllNics && !isDirectRoutedNic(nic)) {
260+
continue;
261+
}
254262
List<Network.Service> supportedService = supportedServices.get(nic.getId());
255263
JsonObject networkData = getNetworkDataJsonObjectForNic(nic, supportedService);
256264

@@ -267,6 +275,24 @@ static boolean needForGeneratingNetworkData(Map<Long, List<Network.Service>> sup
267275
return supportedServices.values().stream().anyMatch(services -> services.contains(Network.Service.Dhcp) || services.contains(Network.Service.Dns));
268276
}
269277

278+
/**
279+
* A NIC on a Direct Routed (L3) network is recognised by the form of its addressing, not by a
280+
* flag: an IPv4 host netmask with a link-local gateway, or an IPv6 /128 with the fixed
281+
* link-local gateway. No other network type produces this combination. ConfigDrive is the
282+
* only channel that carries such a NIC's network configuration (there is no DHCP and no RA),
283+
* so network data must always be generated for it, whatever services the offering carries.
284+
*/
285+
static boolean isDirectRoutedNic(NicProfile nic) {
286+
if (nic == null) {
287+
return false;
288+
}
289+
boolean directRoutedIpv4 = StringUtils.isNotBlank(nic.getIPv4Address()) && NetUtils.IPV4_HOST_NETMASK.equals(nic.getIPv4Netmask())
290+
&& StringUtils.isNotBlank(nic.getIPv4Gateway()) && NetUtils.isIpWithInCidrRange(nic.getIPv4Gateway(), NetUtils.getLinkLocalCIDR());
291+
boolean directRoutedIpv6 = StringUtils.isNotBlank(nic.getIPv6Address()) && StringUtils.isNotBlank(nic.getIPv6Cidr())
292+
&& nic.getIPv6Cidr().endsWith("/" + NetUtils.IPV6_HOST_PREFIX_LENGTH) && NetUtils.getIpv6LinkLocalGateway().equals(nic.getIPv6Gateway());
293+
return directRoutedIpv4 || directRoutedIpv6;
294+
}
295+
270296
/**
271297
* Writes an empty JSON file named vendor_data.json in openStackFolder
272298
*

engine/storage/configdrive/src/test/java/org/apache/cloudstack/storage/configdrive/ConfigDriveBuilderTest.java

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252

5353
import com.cloud.utils.exception.CloudRuntimeException;
5454
import com.cloud.utils.script.Script;
55+
import com.google.gson.JsonArray;
5556
import com.google.gson.JsonObject;
5657

5758
@RunWith(MockitoJUnitRunner.class)
@@ -659,4 +660,96 @@ public void testWriteNetworkDataEmptyJson() throws Exception {
659660
Assert.assertEquals(expectedJsonObject, actualJson);
660661
folder.delete();
661662
}
662-
}
663+
664+
private NicProfile directRoutedNicProfile() {
665+
NicProfile nic = new NicProfile();
666+
nic.setId(1L);
667+
nic.setDeviceId(0);
668+
nic.setMacAddress("02:00:4c:5f:00:01");
669+
nic.setIPv4Address("203.0.113.55");
670+
nic.setIPv4Netmask("255.255.255.255");
671+
nic.setIPv4Gateway("169.254.0.1");
672+
nic.setIPv6Address("2001:db8:1::55");
673+
nic.setIPv6Cidr("2001:db8:1::55/128");
674+
nic.setIPv6Gateway("fe80::1");
675+
nic.setIPv4Dns1("8.8.8.8");
676+
return nic;
677+
}
678+
679+
private NicProfile sharedNicProfile() {
680+
NicProfile nic = new NicProfile();
681+
nic.setId(1L);
682+
nic.setDeviceId(0);
683+
nic.setMacAddress("02:00:4c:5f:00:02");
684+
nic.setIPv4Address("10.1.1.55");
685+
nic.setIPv4Netmask("255.255.255.0");
686+
nic.setIPv4Gateway("10.1.1.1");
687+
return nic;
688+
}
689+
690+
@Test
691+
public void isDirectRoutedNicRecognisesHostRouteForm() {
692+
Assert.assertTrue(ConfigDriveBuilder.isDirectRoutedNic(directRoutedNicProfile()));
693+
}
694+
695+
@Test
696+
public void isDirectRoutedNicRecognisesIpv6OnlyForm() {
697+
NicProfile nic = directRoutedNicProfile();
698+
nic.setIPv4Address(null);
699+
nic.setIPv4Netmask(null);
700+
nic.setIPv4Gateway(null);
701+
Assert.assertTrue(ConfigDriveBuilder.isDirectRoutedNic(nic));
702+
}
703+
704+
@Test
705+
public void isDirectRoutedNicRejectsOrdinaryNics() {
706+
Assert.assertFalse(ConfigDriveBuilder.isDirectRoutedNic(sharedNicProfile()));
707+
Assert.assertFalse(ConfigDriveBuilder.isDirectRoutedNic(null));
708+
// a /32 with an ordinary gateway is not direct routed
709+
NicProfile hostMaskOnly = sharedNicProfile();
710+
hostMaskOnly.setIPv4Netmask("255.255.255.255");
711+
Assert.assertFalse(ConfigDriveBuilder.isDirectRoutedNic(hostMaskOnly));
712+
}
713+
714+
@Test
715+
public void ordinaryNicRouteGenerationIsUnchanged() {
716+
JsonArray networks = ConfigDriveBuilder.getNetworksJsonArrayForNic(sharedNicProfile());
717+
JsonObject ipv4Network = networks.get(0).getAsJsonObject();
718+
JsonArray routes = ipv4Network.getAsJsonArray("routes");
719+
Assert.assertEquals(1, routes.size());
720+
JsonObject defaultRoute = routes.get(0).getAsJsonObject();
721+
Assert.assertEquals("0.0.0.0", defaultRoute.get("network").getAsString());
722+
Assert.assertEquals("0.0.0.0", defaultRoute.get("netmask").getAsString());
723+
Assert.assertEquals("10.1.1.1", defaultRoute.get("gateway").getAsString());
724+
}
725+
726+
@Test
727+
public void networkDataIsGeneratedForDirectRoutedNicWithoutDhcpOrDns() throws Exception {
728+
TemporaryFolder folder = new TemporaryFolder();
729+
folder.create();
730+
try {
731+
Map<Long, List<Network.Service>> userDataOnly = Map.of(1L, List.of(Network.Service.UserData));
732+
ConfigDriveBuilder.writeNetworkData(List.of(directRoutedNicProfile()), userDataOnly, folder.getRoot());
733+
String json = FileUtils.readFileToString(new File(folder.getRoot(), "network_data.json"), com.cloud.utils.StringUtils.getPreferredCharset());
734+
Assert.assertTrue("direct routed nic must appear in network_data.json", json.contains("203.0.113.55"));
735+
Assert.assertTrue(json.contains("169.254.0.1"));
736+
} finally {
737+
folder.delete();
738+
}
739+
}
740+
741+
@Test
742+
public void networkDataStaysEmptyForOrdinaryNicWithoutDhcpOrDns() throws Exception {
743+
TemporaryFolder folder = new TemporaryFolder();
744+
folder.create();
745+
try {
746+
Map<Long, List<Network.Service>> userDataOnly = Map.of(1L, List.of(Network.Service.UserData));
747+
ConfigDriveBuilder.writeNetworkData(List.of(sharedNicProfile()), userDataOnly, folder.getRoot());
748+
String json = FileUtils.readFileToString(new File(folder.getRoot(), "network_data.json"), com.cloud.utils.StringUtils.getPreferredCharset());
749+
Assert.assertEquals("historical gate must be preserved for ordinary nics", "{}", json);
750+
} finally {
751+
folder.delete();
752+
}
753+
}
754+
755+
}

0 commit comments

Comments
 (0)