diff --git a/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java b/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java index 92bb139450c..9b49e6ddc9d 100755 --- a/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java +++ b/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java @@ -478,7 +478,7 @@ public boolean shouldUseExistingIpv6(String ip6, UsedIpVO existingIp) { * Resolve IPv4 netmask and gateway based on 4 cases: * (a) Both netmask+gateway provided: validate gateway is in CIDR(ip/netmask), then use user input * (b) Gateway provided, no netmask: if ip and gateway both in L3 CIDR, use CIDR netmask; else error - * (c) Netmask provided, no gateway: if netmask == CIDR netmask, use CIDR gateway; else gateway="" + * (c) Netmask provided, no gateway: if netmask == CIDR netmask, use CIDR gateway; else if default/sole NIC, error; else gateway="" * (d) Neither provided: if existingIp usable, use it; else if in L3 CIDR, use CIDR; else error */ public String[] resolveIpv4NetmaskAndGateway(String ip, String userNetmask, String userGateway, @@ -515,6 +515,11 @@ public String[] resolveIpv4NetmaskAndGateway(String ip, String userNetmask, Stri if (matchedRange != null && userNetmask.equals(matchedRange.getNetmask())) { return new String[]{matchedRange.getNetmask(), matchedRange.getGateway()}; } + if (nicRole.isDefaultNic || nicRole.isOnlyNic) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10324, + "netmask[%s] does not match L3 CIDR netmask and the NIC is the default or sole network, gateway must be specified", + userNetmask)); + } return new String[]{userNetmask, ""}; } diff --git a/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java b/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java index 06d175a8d13..f5f30f60ed0 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java @@ -346,7 +346,11 @@ private void validate(APIChangeVmNicNetworkMsg msg) { } } - // Build NicRoleContext for resolve logic + // Build NicRoleContext for resolve logic. + // Note: isDefaultNic is based on srcL3Uuid (the NIC's current L3), not destL3Uuid. + // The NIC's role (default/sole) is determined by where it currently IS, not where + // it is being moved to. The old code compared destL3Uuid which was almost always + // false (you change TO a different L3), making the check a no-op. String defaultL3Uuid = Q.New(VmInstanceVO.class) .select(VmInstanceVO_.defaultL3NetworkUuid) .eq(VmInstanceVO_.uuid, vmUuid) diff --git a/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java b/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java index d2661d5cab8..031f624218c 100644 --- a/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java +++ b/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java @@ -72,6 +72,8 @@ public ErrorCode stopAnsible() { return useNcatAndNmapToTestConnection(ssh); } catch (SshException e) { return operr(ORG_ZSTACK_CORE_ANSIBLE_10004, e.getMessage()); + } finally { + ssh.close(); } } diff --git a/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java b/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java index 6f0b796e5b8..40786eb7b2e 100755 --- a/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java +++ b/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java @@ -123,6 +123,12 @@ private void validate(APICreateL2NetworkMsg msg) { } catch (Exception e) { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_L2_10012, "unsupported vSwitch type[%s]", msg.getvSwitchType())); } + + if (L2NetworkConstant.VSWITCH_TYPE_LINUX_BRIDGE.equals(msg.getvSwitchType()) + && (msg.getPhysicalInterface() == null || msg.getPhysicalInterface().trim().isEmpty())) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_L2_10021, + "physicalInterface is required when vSwitchType is [%s]", msg.getvSwitchType())); + } } private void validate(APIChangeL2NetworkVlanIdMsg msg) { diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java index a8a1378288b..2a36bb5aba3 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java @@ -3805,6 +3805,26 @@ public static class MigrateVmCmd extends AgentCommand implements HasThreadContex private boolean reload; @GrayVersion(value = "5.0.0") private long bandwidth; + @GrayVersion(value = "5.5.12") + private boolean useTls; + @GrayVersion(value = "5.5.12") + private String srcHostManagementIp; + + public String getSrcHostManagementIp() { + return srcHostManagementIp; + } + + public void setSrcHostManagementIp(String srcHostManagementIp) { + this.srcHostManagementIp = srcHostManagementIp; + } + + public boolean isUseTls() { + return useTls; + } + + public void setUseTls(boolean useTls) { + this.useTls = useTls; + } public Integer getDownTime() { return downTime; diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java index 8cdd2f54167..ee765aad40f 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java @@ -139,6 +139,10 @@ public class KVMGlobalConfig { @BindResourceConfig({HostVO.class, ClusterVO.class}) public static GlobalConfig RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE = new GlobalConfig(CATEGORY, "reconnect.host.restart.libvirtd.service"); + @GlobalConfigValidation(validValues = {"true", "false"}) + @GlobalConfigDef(defaultValue = "true", type = Boolean.class, description = "enable TLS encryption for libvirt remote connections (migration)") + public static GlobalConfig LIBVIRT_TLS_ENABLED = new GlobalConfig(CATEGORY, "libvirt.tls.enabled"); + @GlobalConfigValidation public static GlobalConfig KVMAGENT_PHYSICAL_MEMORY_USAGE_ALARM_THRESHOLD = new GlobalConfig(CATEGORY, "kvmagent.physicalmemory.usage.alarm.threshold"); diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java index a245757517d..b8b38d1b802 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java @@ -3163,6 +3163,7 @@ public void run(final FlowTrigger trigger, Map data) { cmd.setDestHostIp(dstHostMigrateIp); cmd.setSrcHostIp(srcHostMigrateIp); cmd.setDestHostManagementIp(dstHostMnIp); + cmd.setSrcHostManagementIp(srcHostMnIp); cmd.setMigrateFromDestination(migrateFromDestination); cmd.setStorageMigrationPolicy(storageMigrationPolicy == null ? null : storageMigrationPolicy.toString()); cmd.setVmUuid(vmUuid); @@ -3174,6 +3175,8 @@ public void run(final FlowTrigger trigger, Map data) { cmd.setDownTime(s.downTime); cmd.setBandwidth(s.bandwidth); cmd.setNics(nicTos); + cmd.setUseTls(KVMGlobalConfig.LIBVIRT_TLS_ENABLED.value(Boolean.class) + && rcf.getResourceConfigValue(KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE, self.getUuid(), Boolean.class)); if (s.diskMigrationMap != null) { Map diskMigrationMap = new HashMap<>(); @@ -5570,10 +5573,10 @@ public boolean skip(Map data) { @Override public void run(FlowTrigger trigger, Map data) { + Ssh ssh = new Ssh().setUsername(getSelf().getUsername()) + .setPassword(getSelf().getPassword()).setPort(getSelf().getPort()) + .setHostname(getSelf().getManagementIp()); try { - Ssh ssh = new Ssh().setUsername(getSelf().getUsername()) - .setPassword(getSelf().getPassword()).setPort(getSelf().getPort()) - .setHostname(getSelf().getManagementIp()); ssh.command(String.format("grep -i ^uuid %s | sed 's/uuid://g'", hostTakeOverFlagPath)); SshResult hostRet = ssh.run(); if (hostRet.isSshFailure() || hostRet.getReturnCode() != 0) { @@ -5622,6 +5625,8 @@ public void run(FlowTrigger trigger, Map data) { logger.warn(e.getMessage(), e); trigger.next(); return; + } finally { + ssh.close(); } } }); @@ -5815,6 +5820,16 @@ public void run(final FlowTrigger trigger, Map data) { deployArguments.setSkipPackages(info.getSkipPackages()); deployArguments.setUpdatePackages(String.valueOf(CoreGlobalProperty.UPDATE_PKG_WHEN_CONNECT)); + // Build TLS cert IP list: management IP + extra IPs (migration network etc.) + String managementIp = getSelf().getManagementIp(); + String extraIps = HostSystemTags.EXTRA_IPS.getTokenByResourceUuid( + self.getUuid(), HostSystemTags.EXTRA_IPS_TOKEN); + if (extraIps != null && !extraIps.isEmpty()) { + deployArguments.setTlsCertIps(managementIp + "," + extraIps); + } else { + deployArguments.setTlsCertIps(managementIp); + } + if (deployArguments.isForceRun()) { runner.setForceRun(true); } diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java index 71fb8a9769e..f2bb79c110b 100644 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java @@ -39,6 +39,8 @@ public class KVMHostDeployArguments extends SyncTimeRequestedDeployArguments { private String restartLibvirtd; @SerializedName("extra_packages") private String extraPackages; + @SerializedName("tls_cert_ips") + private String tlsCertIps; private transient boolean forceRun = false; @@ -135,6 +137,14 @@ public void setExtraPackages(String extraPackages) { this.extraPackages = extraPackages; } + public String getTlsCertIps() { + return tlsCertIps; + } + + public void setTlsCertIps(String tlsCertIps) { + this.tlsCertIps = tlsCertIps; + } + public String getEnableSpiceTls() { return enableSpiceTls; } diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java index acb129fe6dc..0188c920a83 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java @@ -1,5 +1,6 @@ package org.zstack.kvm; +import org.apache.commons.io.FileUtils; import org.apache.commons.lang.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.util.UriComponentsBuilder; @@ -9,6 +10,8 @@ import org.zstack.compute.vm.VmNicManager; import org.zstack.core.CoreGlobalProperty; import org.zstack.core.ansible.AnsibleFacade; +import org.zstack.core.jsonlabel.JsonLabel; +import org.zstack.core.jsonlabel.JsonLabelInventory; import org.zstack.core.cloudbus.CloudBus; import org.zstack.core.cloudbus.CloudBusListCallBack; import org.zstack.core.cloudbus.CloudBusSteppingCallback; @@ -75,6 +78,7 @@ import org.zstack.resourceconfig.ResourceConfigFacade; import org.zstack.utils.CollectionUtils; import org.zstack.utils.IpRangeSet; +import org.zstack.utils.ShellUtils; import org.zstack.utils.SizeUtils; import org.zstack.utils.Utils; import org.zstack.utils.data.SizeUnit; @@ -85,6 +89,7 @@ import org.zstack.utils.logging.CLogger; import javax.persistence.Tuple; +import java.io.File; import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; @@ -122,6 +127,10 @@ public class KVMHostFactory extends AbstractService implements HypervisorFactory HypervisorMessageFactory { private static final CLogger logger = Utils.getLogger(KVMHostFactory.class); + private static final String LIBVIRT_TLS_CA_KEY = "libvirtTLSCA"; + private static final String LIBVIRT_TLS_PRIVATE_KEY = "libvirtTLSPrivateKey"; + private static final String CA_DIR = "/var/lib/zstack/pki/CA"; + public static final HypervisorType hypervisorType = new HypervisorType(KVMConstant.KVM_HYPERVISOR_TYPE); public static final VolumeFormat QCOW2_FORMAT = new VolumeFormat(VolumeConstant.VOLUME_FORMAT_QCOW2, hypervisorType); public static final VolumeFormat RAW_FORMAT = new VolumeFormat(VolumeConstant.VOLUME_FORMAT_RAW, hypervisorType); @@ -458,8 +467,56 @@ private void processKvmagentPhysicalMemUsageAbnormal(HostProcessPhysicalMemoryUs bus.send(restartKvmAgentMsg); } + private void initLibvirtTlsCA() { + if (CoreGlobalProperty.UNIT_TEST_ON) { + return; + } + + try { + ShellUtils.run(String.format("mkdir -p %s", CA_DIR)); + ShellUtils.run("chown -R zstack:zstack /var/lib/zstack/pki"); + + File caFile = new File(CA_DIR + "/cacert.pem"); + File keyFile = new File(CA_DIR + "/cakey.pem"); + + // Local CA missing — generate with openssl + // NOTE: ShellUtils.run() prepends sudo only to the first command in &&-chains, + // so each command must be a separate call. + if (!caFile.exists() || !keyFile.exists()) { + ShellUtils.run(String.format( + "openssl genrsa -out %s/cakey.pem 4096", CA_DIR)); + ShellUtils.run(String.format( + "openssl req -new -x509 -days 3650 -key %s/cakey.pem " + + "-out %s/cacert.pem -subj '/O=ZStack/CN=ZStack Libvirt CA'", + CA_DIR, CA_DIR)); + ShellUtils.run(String.format("chown zstack:zstack %s/cakey.pem %s/cacert.pem", + CA_DIR, CA_DIR)); + ShellUtils.run(String.format("chmod 600 %s/cakey.pem", CA_DIR)); + ShellUtils.run(String.format("chmod 644 %s/cacert.pem", CA_DIR)); + } + + String ca = FileUtils.readFileToString(caFile).trim(); + String key = FileUtils.readFileToString(keyFile).trim(); + + // createIfAbsent: DB has no record → write; DB has record → return DB value + JsonLabelInventory caInv = new JsonLabel().createIfAbsent(LIBVIRT_TLS_CA_KEY, ca); + JsonLabelInventory keyInv = new JsonLabel().createIfAbsent(LIBVIRT_TLS_PRIVATE_KEY, key); + + // Use DB as source of truth — overwrite local files (HA: MN2 uses MN1's CA from DB) + FileUtils.writeStringToFile(caFile, caInv.getLabelValue()); + FileUtils.writeStringToFile(keyFile, keyInv.getLabelValue()); + ShellUtils.run(String.format("chmod 600 %s/cakey.pem", CA_DIR)); + ShellUtils.run(String.format("chmod 644 %s/cacert.pem", CA_DIR)); + + logger.info("Libvirt TLS CA initialized and persisted to database"); + } catch (Exception e) { + logger.warn("Failed to initialize libvirt TLS CA", e); + } + } + @Override public boolean start() { + initLibvirtTlsCA(); deployAnsibleModule(); populateExtensions(); configKVMDeviceType(); diff --git a/test/src/test/groovy/org/zstack/test/integration/kvm/vm/migrate/LibvirtTlsMigrateCase.groovy b/test/src/test/groovy/org/zstack/test/integration/kvm/vm/migrate/LibvirtTlsMigrateCase.groovy new file mode 100644 index 00000000000..d4a30cef78e --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/kvm/vm/migrate/LibvirtTlsMigrateCase.groovy @@ -0,0 +1,267 @@ +package org.zstack.test.integration.kvm.vm.migrate + +import org.springframework.http.HttpEntity +import org.zstack.kvm.KVMAgentCommands +import org.zstack.kvm.KVMConstant +import org.zstack.kvm.KVMGlobalConfig +import org.zstack.sdk.HostInventory +import org.zstack.sdk.UpdateGlobalConfigAction +import org.zstack.sdk.VmInstanceInventory +import org.zstack.test.integration.kvm.KvmTest +import org.zstack.testlib.EnvSpec +import org.zstack.testlib.SubCase +import org.zstack.testlib.Test +import org.zstack.utils.data.SizeUnit +import org.zstack.utils.gson.JSONObjectUtil + +/** + * Verify that the libvirt TLS configuration (ZSTAC-81343) is correctly + * propagated in the MigrateVmCmd sent to kvmagent. + * + * Key logic under test (KVMHost.java): + * cmd.setUseTls(LIBVIRT_TLS_ENABLED && RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE) + * cmd.setSrcHostManagementIp(srcHostMnIp) + */ +class LibvirtTlsMigrateCase extends SubCase { + EnvSpec env + + @Override + void clean() { + env.delete() + } + + @Override + void setup() { + useSpring(KvmTest.springSpec) + } + + @Override + void environment() { + env = env { + instanceOffering { + name = "instanceOffering" + memory = SizeUnit.GIGABYTE.toByte(8) + cpu = 4 + } + + zone { + name = "zone" + cluster { + name = "cluster" + hypervisorType = "KVM" + + kvm { + name = "kvm1" + managementIp = "127.0.0.1" + username = "root" + password = "password" + usedMem = 1000 + totalCpu = 10 + } + kvm { + name = "kvm2" + managementIp = "127.0.0.2" + username = "root" + password = "password" + usedMem = 1000 + totalCpu = 10 + } + + attachPrimaryStorage("ps") + attachL2Network("l2") + } + + l2NoVlanNetwork { + name = "l2" + physicalInterface = "eth0" + + l3Network { + name = "l3" + ip { + startIp = "192.168.100.10" + endIp = "192.168.100.100" + netmask = "255.255.255.0" + gateway = "192.168.100.1" + } + } + } + + cephPrimaryStorage { + name = "ps" + totalCapacity = SizeUnit.GIGABYTE.toByte(100) + availableCapacity = SizeUnit.GIGABYTE.toByte(100) + url = "ceph://pri" + fsid = "7ff218d9-f525-435f-8a40-3618d1772a64" + monUrls = ["root:password@localhost/?monPort=7777"] + } + + attachBackupStorage("bs") + } + + cephBackupStorage { + name = "bs" + totalCapacity = SizeUnit.GIGABYTE.toByte(100) + availableCapacity = SizeUnit.GIGABYTE.toByte(100) + url = "/bk" + fsid = "7ff218d9-f525-435f-8a40-3618d1772a64" + monUrls = ["root:password@localhost/?monPort=7777"] + + image { + name = "image" + url = "http://zstack.org/download/image.qcow2" + } + } + + vm { + name = "vm" + useCluster("cluster") + useHost("kvm1") + useL3Networks("l3") + useInstanceOffering("instanceOffering") + useImage("image") + } + } + } + + @Override + void test() { + env.create { + testMigrateWithTlsEnabled() + testMigrateWithTlsDisabled() + testMigrateWithRestartLibvirtdDisabled() + testGlobalConfigValidation() + } + } + + /** + * Case 1: Both LIBVIRT_TLS_ENABLED=true and RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE=true + * => useTls should be true, srcHostManagementIp should be set + */ + void testMigrateWithTlsEnabled() { + def vm = env.inventoryByName("vm") as VmInstanceInventory + def host1 = env.inventoryByName("kvm1") as HostInventory + def host2 = env.inventoryByName("kvm2") as HostInventory + + // Ensure TLS is enabled (default is true) + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("true") + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("true") + + KVMAgentCommands.MigrateVmCmd cmd = null + env.afterSimulator(KVMConstant.KVM_MIGRATE_VM_PATH) { rsp, HttpEntity e -> + cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.MigrateVmCmd.class) + return rsp + } + + // Migrate vm from kvm1 to kvm2 + migrateVm { + vmInstanceUuid = vm.uuid + hostUuid = host2.uuid + } + + assert cmd != null : "MigrateVmCmd should have been captured" + assert cmd.useTls : "useTls should be true when both TLS and restartLibvirtd are enabled" + assert cmd.srcHostManagementIp == host1.managementIp : + "srcHostManagementIp should be source host management IP" + assert cmd.destHostManagementIp == host2.managementIp : + "destHostManagementIp should be dest host management IP" + } + + /** + * Case 2: LIBVIRT_TLS_ENABLED=false => useTls should be false regardless of restartLibvirtd + */ + void testMigrateWithTlsDisabled() { + def vm = env.inventoryByName("vm") as VmInstanceInventory + def host1 = env.inventoryByName("kvm1") as HostInventory + + // Disable TLS + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("false") + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("true") + + KVMAgentCommands.MigrateVmCmd cmd = null + env.afterSimulator(KVMConstant.KVM_MIGRATE_VM_PATH) { rsp, HttpEntity e -> + cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.MigrateVmCmd.class) + return rsp + } + + // Migrate back to kvm1 + migrateVm { + vmInstanceUuid = vm.uuid + hostUuid = host1.uuid + } + + assert cmd != null : "MigrateVmCmd should have been captured" + assert !cmd.useTls : "useTls should be false when TLS config is disabled" + + // Restore default + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("true") + } + + /** + * Case 3: LIBVIRT_TLS_ENABLED=true but RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE=false + * => useTls should be false (AND logic: both must be true) + * + * This is a critical boundary: TLS config is on, but libvirtd was not restarted + * with TLS certs deployed, so we must NOT tell kvmagent to use TLS. + */ + void testMigrateWithRestartLibvirtdDisabled() { + def vm = env.inventoryByName("vm") as VmInstanceInventory + def host2 = env.inventoryByName("kvm2") as HostInventory + + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("true") + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("false") + + KVMAgentCommands.MigrateVmCmd cmd = null + env.afterSimulator(KVMConstant.KVM_MIGRATE_VM_PATH) { rsp, HttpEntity e -> + cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.MigrateVmCmd.class) + return rsp + } + + migrateVm { + vmInstanceUuid = vm.uuid + hostUuid = host2.uuid + } + + assert cmd != null : "MigrateVmCmd should have been captured" + assert !cmd.useTls : + "useTls should be false when restartLibvirtd is disabled (TLS certs not deployed)" + + // Restore default + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("true") + } + + /** + * Case 4: Validate that libvirt.tls.enabled GlobalConfig only accepts true/false + */ + void testGlobalConfigValidation() { + // Valid values via SDK action + updateGlobalConfig { + category = "kvm" + name = "libvirt.tls.enabled" + value = "true" + } + assert KVMGlobalConfig.LIBVIRT_TLS_ENABLED.value(Boolean.class) == true + + updateGlobalConfig { + category = "kvm" + name = "libvirt.tls.enabled" + value = "false" + } + assert KVMGlobalConfig.LIBVIRT_TLS_ENABLED.value(Boolean.class) == false + + // Invalid value should be rejected + def action = new UpdateGlobalConfigAction() + action.category = "kvm" + action.name = "libvirt.tls.enabled" + action.value = "invalid" + action.sessionId = Test.currentEnvSpec.session.uuid + UpdateGlobalConfigAction.Result res = action.call() + assert res.error != null : "Setting an invalid value for libvirt.tls.enabled should fail" + + // Restore default + updateGlobalConfig { + category = "kvm" + name = "libvirt.tls.enabled" + value = "true" + } + } +} diff --git a/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy b/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy index f31d19bae92..7d5e8bbfa1d 100644 --- a/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy @@ -94,6 +94,7 @@ public class AttachL2NetworkCase extends SubCase{ @Override public void test() { env.create { + testCreateL2NetworkWithoutPhysicalInterface() testAttachL2NoVlanNetwork() testAttachL2ValnNetwork() testAttachL2NoVlanNetworkSynchronously() @@ -103,6 +104,36 @@ public class AttachL2NetworkCase extends SubCase{ } + void testCreateL2NetworkWithoutPhysicalInterface() { + ZoneInventory zone = env.inventoryByName("zone") + + // creating L2 NoVlan network without physicalInterface should fail when vSwitchType is LinuxBridge + expect(AssertionError.class) { + createL2NoVlanNetwork { + name = "test-no-physical-interface" + zoneUuid = zone.uuid + } + } + + // creating L2 NoVlan network with empty physicalInterface should also fail + expect(AssertionError.class) { + createL2NoVlanNetwork { + name = "test-empty-physical-interface" + zoneUuid = zone.uuid + physicalInterface = "" + } + } + + // creating L2 Vlan network without physicalInterface should fail when vSwitchType is LinuxBridge + expect(AssertionError.class) { + createL2VlanNetwork { + name = "test-vlan-no-physical-interface" + zoneUuid = zone.uuid + vlan = 100 + } + } + } + void testAttachL2NoVlanNetwork(){ L2NetworkInventory l21 = env.inventoryByName("l2-1") L2NetworkInventory l22 = env.inventoryByName("l2-2") diff --git a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy index 90875ddbf6a..05f5bb40dab 100644 --- a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy @@ -314,6 +314,8 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { testResolve_B2_ipInCidrButGatewayNotInCidr_error() testResolve_B3_ipNotInAnyCidr_error() testResolve_C1_netmaskMatchesCidr_success() + testResolve_C2_netmaskMismatch_defaultNic_error() + testResolve_C3_netmaskMismatch_soleNic_error() testResolve_C4_netmaskMismatch_nonDefaultNonSole_success() testResolve_D1_ipInCidr_success() testResolve_D2_ipOutsideCidr_error() @@ -1095,6 +1097,64 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { assert nicVO.gateway == "192.168.100.1" : "gateway should be inferred from L3 CIDR" } + /** + * c-2: netmask != CIDR, NIC is default NIC (single-NIC VM on default L3). + * Expected: error — default NIC must have gateway. + */ + void testResolve_C2_netmaskMismatch_defaultNic_error() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + // Single-NIC VM: srcL3 is both default and sole + VmInstanceInventory vm = createVmOnL3("vm-resolve-c2", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + String defaultL3 = Q.New(VmInstanceVO.class) + .select(VmInstanceVO_.defaultL3NetworkUuid) + .eq(VmInstanceVO_.uuid, vm.uuid) + .findValue() + assert defaultL3 == srcL3.uuid : "srcL3 should be the default L3" + + expect(AssertionError.class) { + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.70", destL3.uuid), + String.format("ipv4Netmask::%s::255.255.0.0", destL3.uuid) + ] + } + } + } + + /** + * c-3: netmask != CIDR, NIC is the sole NIC (VM has only 1 NIC, on non-default L3). + * Since a single-NIC VM's L3 is always the default, this effectively tests + * the isOnlyNic branch (vmNicCount == 1). + * Expected: error — sole NIC must have gateway. + */ + void testResolve_C3_netmaskMismatch_soleNic_error() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory secondL3 = env.inventoryByName("flatL3_second") + + // Single-NIC VM on secondL3 + VmInstanceInventory vm = createVmOnL3("vm-resolve-c3", secondL3.uuid) + assert vm.vmNics.size() == 1 + + VmNicInventory vmNic = vm.vmNics[0] + + expect(AssertionError.class) { + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.80", destL3.uuid), + String.format("ipv4Netmask::%s::255.255.0.0", destL3.uuid) + ] + } + } + } + /** * c-4: netmask != CIDR, non-default & non-sole. * VM has 2 NICs [flatL3_dest(default), flatL3_second]. @@ -1159,6 +1219,13 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { assert nicVO.ip == "192.168.100.100" assert nicVO.netmask == "255.255.255.0" : "netmask should be inferred from L3 CIDR" assert nicVO.gateway == "192.168.100.1" : "gateway should be inferred from L3 CIDR" + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vmNic.uuid) + .eq(UsedIpVO_.ip, "192.168.100.100") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" } /** diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index 80d13d1a949..ac2eeb7fd8f 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -1022,6 +1022,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_L2_10020 = "ORG_ZSTACK_NETWORK_L2_10020"; + public static final String ORG_ZSTACK_NETWORK_L2_10021 = "ORG_ZSTACK_NETWORK_L2_10021"; + public static final String ORG_ZSTACK_CONSOLE_10000 = "ORG_ZSTACK_CONSOLE_10000"; public static final String ORG_ZSTACK_CONSOLE_10001 = "ORG_ZSTACK_CONSOLE_10001";