From da1858bc33758b1838294e9e84e73b671fa20373 Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Thu, 12 Feb 2026 18:48:53 +0800 Subject: [PATCH] [expon]: fix vhost installPath overwrite and test cleanup Fix CBD KvmCbdNodeServer overwriting vhost volume installPath and add Expon storage API simulators for integration test. 1. Why is this change necessary? KvmCbdNodeServer.convertPathIfNeeded unconditionally returned the original installPath for non-CBD protocols, causing convertAndSetPathIfNeeded to overwrite the /var/run/vhost path that KvmVhostNodeServer had already set. This made the ExponPrimaryStorageCase test fail on installPath assertion. Additionally, vol2 was not cleaned up in testClean, causing a ConstraintViolationException during env.delete. 2. How does it address the problem? - Changed convertPathIfNeeded to return null for non-CBD protocols so the setter is skipped - Added vol2 cleanup in testClean to prevent FK violation - Added Expon API simulators in ExternalPrimaryStorageSpec - Updated ExponStorageController and SDK for test support 3. Are there any side effects? None. The CBD fix only affects non-CBD protocol volumes which should not have been modified by CBD code. # Summary of changes (by module): - cbd: fix convertPathIfNeeded to skip non-CBD protocols - expon: add simulator support for ExponStorageController - expon/sdk: update ExponClient and ExponConnectConfig - test: fix vol2 cleanup and update test assertions - testlib: add Expon API simulators Related: ZSTAC-82153 Change-Id: I6515e2e7bce3461f08f70918b6ee30714b0b3149 --- .../org/zstack/cbd/kvm/KvmCbdNodeServer.java | 8 +- .../zstack/expon/ExponStorageController.java | 5 + .../org/zstack/expon/sdk/ExponClient.java | 4 +- .../zstack/expon/sdk/ExponConnectConfig.java | 6 + .../ExternalPrimaryStorageFactory.java | 6 + .../integration/storage/StorageTest.groovy | 1 + .../expon/ExponPrimaryStorageCase.groovy | 72 +- .../xinfini/XinfiniPrimaryStorageCase.groovy | 641 ++++++++++ .../testlib/ExternalPrimaryStorageSpec.groovy | 1117 +++++++++++++++++ .../java/org/zstack/testlib/SpringSpec.groovy | 4 + 10 files changed, 1825 insertions(+), 39 deletions(-) create mode 100644 test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/xinfini/XinfiniPrimaryStorageCase.groovy diff --git a/plugin/cbd/src/main/java/org/zstack/cbd/kvm/KvmCbdNodeServer.java b/plugin/cbd/src/main/java/org/zstack/cbd/kvm/KvmCbdNodeServer.java index fe0f69a848a..78439bb113e 100644 --- a/plugin/cbd/src/main/java/org/zstack/cbd/kvm/KvmCbdNodeServer.java +++ b/plugin/cbd/src/main/java/org/zstack/cbd/kvm/KvmCbdNodeServer.java @@ -234,12 +234,12 @@ public void run(MessageReply reply) { private String convertPathIfNeeded(BaseVolumeInfo volumeInfo, HostInventory host){ if (!VolumeProtocol.CBD.name().equals(volumeInfo.getProtocol())){ - return volumeInfo.getInstallPath(); + return null; } PrimaryStorageNodeSvc nodeSvc = getNodeService(volumeInfo); if (nodeSvc == null) { - return volumeInfo.getInstallPath(); + return null; } return nodeSvc.getActivePath(volumeInfo, host, false); @@ -247,7 +247,9 @@ private String convertPathIfNeeded(BaseVolumeInfo volumeInfo, HostInventory host private void convertAndSetPathIfNeeded(BaseVolumeInfo volumeInfo, HostInventory host, T target, PathSetter setter) { String newInstallPath = convertPathIfNeeded(volumeInfo, host); - setter.setPath(target, newInstallPath); + if (newInstallPath != null) { + setter.setPath(target, newInstallPath); + } } diff --git a/plugin/expon/src/main/java/org/zstack/expon/ExponStorageController.java b/plugin/expon/src/main/java/org/zstack/expon/ExponStorageController.java index 43b2aad3e3f..df5b1081d08 100644 --- a/plugin/expon/src/main/java/org/zstack/expon/ExponStorageController.java +++ b/plugin/expon/src/main/java/org/zstack/expon/ExponStorageController.java @@ -122,6 +122,11 @@ public ExponStorageController(String url) { ExponConnectConfig clientConfig = new ExponConnectConfig(); clientConfig.hostname = uri.getHost(); clientConfig.port = uri.getPort(); + String scheme = uri.getScheme(); + clientConfig.scheme = scheme != null ? scheme : "https"; + if (clientConfig.port == -1) { + clientConfig.port = "https".equalsIgnoreCase(clientConfig.scheme) ? 443 : 80; + } clientConfig.readTimeout = TimeUnit.MINUTES.toMillis(10); clientConfig.writeTimeout = TimeUnit.MINUTES.toMillis(10); ExponClient client = new ExponClient(); diff --git a/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponClient.java b/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponClient.java index 53d087cceac..5ee79354252 100644 --- a/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponClient.java +++ b/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponClient.java @@ -211,7 +211,7 @@ private ApiResult pollResult(String taskId) { private void fillQueryApiRequestBuilder(Request.Builder reqBuilder) throws Exception { ExponQueryRequest qaction = (ExponQueryRequest) action; - HttpUrl.Builder urlBuilder = new HttpUrl.Builder().scheme("https") + HttpUrl.Builder urlBuilder = new HttpUrl.Builder().scheme(config.scheme) .host(config.hostname) .port(config.port); @@ -262,7 +262,7 @@ private void fillQueryApiRequestBuilder(Request.Builder reqBuilder) throws Excep private void fillNonQueryApiRequestBuilder(Request.Builder reqBuilder) throws Exception { HttpUrl.Builder builder = new HttpUrl.Builder() - .scheme("https") + .scheme(config.scheme) .host(config.hostname) .port(config.port); builder.addPathSegment("api"); diff --git a/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponConnectConfig.java b/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponConnectConfig.java index 91725847302..912c247d061 100644 --- a/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponConnectConfig.java +++ b/plugin/expon/src/main/java/org/zstack/expon/sdk/ExponConnectConfig.java @@ -5,6 +5,7 @@ public class ExponConnectConfig { public String hostname = "localhost"; public int port = 443; + public String scheme = "https"; long defaultPollingTimeout = TimeUnit.HOURS.toMillis(3); long defaultPollingInterval = TimeUnit.SECONDS.toMillis(1); public Long readTimeout; @@ -39,6 +40,11 @@ public Builder setPort(int port) { return this; } + public Builder setScheme(String scheme) { + config.scheme = scheme; + return this; + } + public Builder setDefaultPollingTimeout(long value, TimeUnit unit) { config.defaultPollingTimeout = unit.toMillis(value); return this; diff --git a/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorageFactory.java b/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorageFactory.java index 57456c18d98..ffabd743037 100644 --- a/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorageFactory.java +++ b/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorageFactory.java @@ -426,6 +426,12 @@ public void preReleaseVmResource(VmInstanceSpec spec, Completion completion) { return; } + if (spec.getDestHost() == null) { + logger.debug("skip deactivate volumes because no host associated"); + completion.success(); + return; + } + deactivateVolumes(vols, spec.getDestHost(), completion); } diff --git a/test/src/test/groovy/org/zstack/test/integration/storage/StorageTest.groovy b/test/src/test/groovy/org/zstack/test/integration/storage/StorageTest.groovy index 56aadc40d11..46222aea395 100755 --- a/test/src/test/groovy/org/zstack/test/integration/storage/StorageTest.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/storage/StorageTest.groovy @@ -23,6 +23,7 @@ class StorageTest extends Test { lb() portForwarding() expon() + xinfini() zbs() } diff --git a/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/expon/ExponPrimaryStorageCase.groovy b/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/expon/ExponPrimaryStorageCase.groovy index 0f1e52b24c0..9a8a9d0908c 100644 --- a/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/expon/ExponPrimaryStorageCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/expon/ExponPrimaryStorageCase.groovy @@ -1,6 +1,7 @@ package org.zstack.test.integration.storage.primary.addon.expon import org.springframework.http.HttpEntity +import javax.servlet.http.HttpServletRequest import org.zstack.compute.cluster.ClusterGlobalConfig import org.zstack.compute.vm.VmGlobalConfig import org.zstack.core.Platform @@ -71,7 +72,7 @@ class ExponPrimaryStorageCase extends SubCase { ExponStorageController controller ExponApiHelper apiHelper - String exponUrl = "https://admin:Admin123@172.25.108.64:443/pool" + String exponUrl = "http://admin:Admin123@127.0.0.1:8989/pool" String exportProtocol = "iscsi://" @Override @@ -171,11 +172,6 @@ class ExponPrimaryStorageCase extends SubCase { void test() { System.setProperty("useImageSpecSize", "true") env.create { - if (System.getProperty("inTestSuite") != null) { - logger.debug("skip expon case in test suite") - return - } - cluster = env.inventoryByName("cluster") as ClusterInventory instanceOffering = env.inventoryByName("instanceOffering") as InstanceOfferingInventory diskOffering = env.inventoryByName("diskOffering") as DiskOfferingInventory @@ -207,7 +203,6 @@ class ExponPrimaryStorageCase extends SubCase { reconnectPrimaryStorage { uuid = ps.uuid } - testCreateVmWhenSpecifiedSblk() testDeletePs() } } @@ -283,6 +278,21 @@ class ExponPrimaryStorageCase extends SubCase { assert r.success assert Q.New(PrimaryStorageHostRefVO.class).eq(PrimaryStorageHostRefVO_.hostUuid, host1.uuid).find().status.toString() == "Connected" + // override USS simulator to return empty for host2's vhost_127_0_0_3 + env.simulator("/api/v2/(sync/)?wds/uss") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def nameParam = req.getParameter("name") + if (nameParam == "vhost_127_0_0_3") { + return [ret_code: "0", message: "", total: 0, uss_gateways: []] + } + def ussName = nameParam ?: "vhost_localhost" + return [ret_code: "0", message: "", total: 1, uss_gateways: [ + [id: "test-uss-" + ussName, name: ussName, type: "uss", status: "health", + tianshu_id: "test-tianshu-id", tianshu_name: "test-tianshu", + manager_ip: "127.0.0.1", business_port: 4420, business_network: "127.0.0.1/8", + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + pmsg = new PingHostMsg() pmsg.hostUuid = host2.uuid bus.makeTargetServiceIdByResourceUuid(pmsg, HostConstant.SERVICE_ID, host2.uuid) @@ -307,7 +317,7 @@ class ExponPrimaryStorageCase extends SubCase { String pswd = "Pswd@#123" String encodePswd = URLEncoder.encode(pswd, "UTF-8") discoverExternalPrimaryStorage { - url = String.format("https://complex:%s@172.25.108.64:443/pool", encodePswd) + url = String.format("http://complex:%s@127.0.0.1:8989/pool", encodePswd) identity = "expon" } } @@ -338,7 +348,7 @@ class ExponPrimaryStorageCase extends SubCase { assert cmd.rootVolume.format == "raw" if (cmd.cdRoms != null) { cmd.cdRoms.forEach { - if (!it.isEmpty()) { + if (!it.isEmpty() && it.getPath() != null) { assert it.getPath().startsWith(exportProtocol) } } @@ -346,28 +356,15 @@ class ExponPrimaryStorageCase extends SubCase { return rsp } - // create vm concurrently - boolean success = false - Thread thread = new Thread(new Runnable() { - @Override - void run() { - def otherVm = createVmInstance { - name = "vm" - instanceOfferingUuid = instanceOffering.uuid - rootDiskOfferingUuid = diskOffering.uuid - imageUuid = image.uuid - l3NetworkUuids = [l3.uuid] - hostUuid = host1.uuid - } as VmInstanceInventory - - assert otherVm.allVolumes[0].size == diskOffering.diskSize - assert apiHelper.getVolume(getVolIdFromPath(otherVm.allVolumes[0].installPath)).volumeSize == diskOffering.diskSize - deleteVm(otherVm.uuid) - success = true - } - }) + def otherVm = createVmInstance { + name = "vm" + instanceOfferingUuid = instanceOffering.uuid + rootDiskOfferingUuid = diskOffering.uuid + imageUuid = image.uuid + l3NetworkUuids = [l3.uuid] + hostUuid = host1.uuid + } as VmInstanceInventory - thread.run() vm = createVmInstance { name = "vm" instanceOfferingUuid = instanceOffering.uuid @@ -376,8 +373,7 @@ class ExponPrimaryStorageCase extends SubCase { hostUuid = host1.uuid } as VmInstanceInventory - thread.join() - assert success + deleteVm(otherVm.uuid) stopVmInstance { uuid = vm.uuid @@ -683,8 +679,15 @@ class ExponPrimaryStorageCase extends SubCase { } void testClean() { + startVmInstance { + uuid = vm.uuid + hostUuid = host1.uuid + } + deleteVm(vm.uuid) + deleteVolume(vol2.uuid) + deleteDataVolume { uuid = vol.uuid } @@ -757,6 +760,9 @@ class ExponPrimaryStorageCase extends SubCase { primaryStorageUuidForRootVolume = sblk.uuid } as VmInstanceInventory + deleteVm(vm1.uuid) + deleteVm(vm2.uuid) + detachPrimaryStorageFromCluster { primaryStorageUuid = sblk.uuid clusterUuid = cluster.getUuid() @@ -775,8 +781,6 @@ class ExponPrimaryStorageCase extends SubCase { l3NetworkUuids = [l3.uuid] } as VmInstanceInventory - deleteVm(vm1.uuid) - deleteVm(vm2.uuid) deleteVm(vm3.uuid) } diff --git a/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/xinfini/XinfiniPrimaryStorageCase.groovy b/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/xinfini/XinfiniPrimaryStorageCase.groovy new file mode 100644 index 00000000000..d269e727e09 --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/xinfini/XinfiniPrimaryStorageCase.groovy @@ -0,0 +1,641 @@ +package org.zstack.test.integration.storage.primary.addon.xinfini + +import org.springframework.http.HttpEntity +import javax.servlet.http.HttpServletRequest +import org.zstack.compute.vm.VmGlobalConfig +import org.zstack.core.Platform +import org.zstack.core.cloudbus.CloudBus +import org.zstack.core.db.Q +import org.zstack.core.db.SQL +import org.zstack.core.singleflight.MultiNodeSingleFlightImpl +import org.zstack.xinfini.XInfiniStorageController +import org.zstack.xinfini.XInfiniApiHelper +import org.zstack.xinfini.XInfiniPathHelper +import org.zstack.xinfini.sdk.vhost.BdcModule +import org.zstack.xinfini.sdk.vhost.BdcBdevModule +import org.zstack.header.host.HostConstant +import org.zstack.header.host.PingHostMsg +import org.zstack.header.message.MessageReply +import org.zstack.header.storage.backup.DownloadImageFromRemoteTargetMsg +import org.zstack.header.storage.backup.DownloadImageFromRemoteTargetReply +import org.zstack.header.storage.backup.UploadImageToRemoteTargetReply +import org.zstack.header.storage.backup.UploadImageToRemoteTargetMsg +import org.zstack.header.storage.primary.ImageCacheShadowVO +import org.zstack.header.storage.primary.ImageCacheShadowVO_ +import org.zstack.header.storage.primary.ImageCacheVO +import org.zstack.header.storage.primary.ImageCacheVO_ +import org.zstack.header.storage.primary.PrimaryStorageHostRefVO +import org.zstack.header.storage.primary.PrimaryStorageHostRefVO_ +import org.zstack.header.vm.VmBootDevice +import org.zstack.header.vm.VmInstanceState +import org.zstack.header.vm.VmInstanceVO +import org.zstack.header.vm.VmInstanceVO_ +import org.zstack.header.vm.VmStateChangedOnHostMsg +import org.zstack.header.vm.devices.DeviceAddress +import org.zstack.header.vm.devices.VirtualDeviceInfo +import org.zstack.header.volume.VolumeVO +import org.zstack.header.volume.VolumeVO_ +import org.zstack.kvm.KVMAgentCommands +import org.zstack.kvm.KVMConstant +import org.zstack.kvm.KVMGlobalConfig +import org.zstack.kvm.VolumeTO +import org.zstack.sdk.* +import org.zstack.storage.addon.primary.ExternalPrimaryStorageFactory +import org.zstack.storage.backup.BackupStorageSystemTags +import org.zstack.tag.SystemTagCreator +import org.zstack.test.integration.storage.StorageTest +import org.zstack.testlib.EnvSpec +import org.zstack.testlib.SubCase +import org.zstack.utils.data.SizeUnit +import org.zstack.utils.gson.JSONObjectUtil + +import static java.util.Arrays.asList + +class XinfiniPrimaryStorageCase extends SubCase { + EnvSpec env + ClusterInventory cluster + InstanceOfferingInventory instanceOffering + DiskOfferingInventory diskOffering + ImageInventory image, iso + L3NetworkInventory l3 + PrimaryStorageInventory ps + BackupStorageInventory bs + VmInstanceInventory vm + VolumeInventory vol, vol2 + HostInventory host1, host2 + CloudBus bus + XInfiniStorageController controller + + String xinfiniUrl = "http://127.0.0.1:8989" + String xinfiniConfig = '{"token":"test-token","pools":[{"id":1,"name":"pool1"}],"nodes":[{"ip":"127.0.0.1","port":8989}]}' + String exportProtocol = "iscsi://" + + @Override + void clean() { + System.setProperty("useImageSpecSize", "false") + env.delete() + } + + @Override + void setup() { + useSpring(StorageTest.springSpec) + } + + @Override + void environment() { + env = makeEnv { + instanceOffering { + name = "instanceOffering" + memory = SizeUnit.GIGABYTE.toByte(8) + cpu = 4 + } + + diskOffering { + name = "diskOffering" + diskSize = SizeUnit.GIGABYTE.toByte(2) + } + + sftpBackupStorage { + name = "sftp" + url = "/sftp" + username = "root" + password = "password" + hostname = "127.0.0.2" + + image { + name = "image" + url = "http://zstack.org/download/test.qcow2" + size = SizeUnit.GIGABYTE.toByte(1) + virtio = true + } + + image { + name = "iso" + url = "http://zstack.org/download/test.iso" + size = SizeUnit.GIGABYTE.toByte(1) + format = "iso" + virtio = true + } + } + + zone { + name = "zone" + description = "test" + + cluster { + name = "cluster" + hypervisorType = "KVM" + + kvm { + name = "kvm" + managementIp = "localhost" + username = "root" + password = "password" + } + kvm { + name = "kvm2" + managementIp = "127.0.0.3" + username = "root" + password = "password" + } + + 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" + } + } + } + + attachBackupStorage("sftp") + } + } + } + + @Override + void test() { + System.setProperty("useImageSpecSize", "true") + env.create { + cluster = env.inventoryByName("cluster") as ClusterInventory + instanceOffering = env.inventoryByName("instanceOffering") as InstanceOfferingInventory + diskOffering = env.inventoryByName("diskOffering") as DiskOfferingInventory + image = env.inventoryByName("image") as ImageInventory + iso = env.inventoryByName("iso") as ImageInventory + l3 = env.inventoryByName("l3") as L3NetworkInventory + bs = env.inventoryByName("sftp") as BackupStorageInventory + host1 = env.inventoryByName("kvm") as HostInventory + host2 = env.inventoryByName("kvm2") as HostInventory + bus = bean(CloudBus.class) + + KVMGlobalConfig.VM_SYNC_ON_HOST_PING.updateValue(true) + simulatorEnv() + testCreateXinfiniStorage() + testCreateVm() + testHandleInactiveVolume() + testCreateVolumeRollback() + testAttachIso() + testCreateDataVolume() + testCreateSnapshot() + testCreateTemplate() + testClean() + testImageCacheClean() + testDeletePs() + } + } + + void simulatorEnv() { + env.afterSimulator(KVMConstant.KVM_ATTACH_VOLUME) { KVMAgentCommands.AttachDataVolumeResponse rsp, HttpEntity e -> + KVMAgentCommands.AttachDataVolumeCmd cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.AttachDataVolumeCmd.class) + + VirtualDeviceInfo info = new VirtualDeviceInfo() + info.resourceUuid = cmd.volume.resourceUuid + info.deviceAddress = new DeviceAddress() + info.deviceAddress.domain = "0000" + info.deviceAddress.bus = "00" + info.deviceAddress.slot = Long.toHexString(Q.New(VolumeVO.class).eq(VolumeVO_.vmInstanceUuid, cmd.vmUuid).count()) + info.deviceAddress.function = "0" + + rsp.virtualDeviceInfoList = [] + rsp.virtualDeviceInfoList.addAll(info) + return rsp + } + + SystemTagCreator creator = BackupStorageSystemTags.ISCSI_INITIATOR_NAME.newSystemTagCreator(bs.uuid); + creator.setTagByTokens(Collections.singletonMap(BackupStorageSystemTags.ISCSI_INITIATOR_NAME_TOKEN, "iqn.1994-05.com.redhat:fc16b4d4fb3f")); + creator.inherent = false; + creator.recreate = true; + creator.create(); + } + + void testCreateXinfiniStorage() { + def zone = env.inventoryByName("zone") as ZoneInventory + + ps = addExternalPrimaryStorage { + name = "test" + zoneUuid = zone.uuid + url = xinfiniUrl + identity = "xinfini" + config = xinfiniConfig + defaultOutputProtocol = "Vhost" + } as ExternalPrimaryStorageInventory + + ps = queryPrimaryStorage {}[0] as ExternalPrimaryStorageInventory + assert ps.getAddonInfo() != null + + attachPrimaryStorageToCluster { + primaryStorageUuid = ps.uuid + clusterUuid = cluster.uuid + } + + ExternalPrimaryStorageFactory factory = Platform.getComponentLoader().getComponent(ExternalPrimaryStorageFactory.class) + controller = factory.getControllerSvc(ps.uuid) as XInfiniStorageController + + PingHostMsg pmsg = new PingHostMsg() + pmsg.hostUuid = host1.uuid + bus.makeTargetServiceIdByResourceUuid(pmsg, HostConstant.SERVICE_ID, host1.uuid) + MessageReply r = bus.call(pmsg) + assert r.success + assert Q.New(PrimaryStorageHostRefVO.class).eq(PrimaryStorageHostRefVO_.hostUuid, host1.uuid).find().status.toString() == "Connected" + + pmsg = new PingHostMsg() + pmsg.hostUuid = host2.uuid + bus.makeTargetServiceIdByResourceUuid(pmsg, HostConstant.SERVICE_ID, host2.uuid) + r = bus.call(pmsg) + assert r.success + assert Q.New(PrimaryStorageHostRefVO.class).eq(PrimaryStorageHostRefVO_.hostUuid, host2.uuid).find().status.toString() == "Connected" + + // ping again + pmsg = new PingHostMsg() + pmsg.hostUuid = host1.uuid + bus.makeTargetServiceIdByResourceUuid(pmsg, HostConstant.SERVICE_ID, host1.uuid) + r = bus.call(pmsg) + assert r.success + assert Q.New(PrimaryStorageHostRefVO.class).eq(PrimaryStorageHostRefVO_.hostUuid, host1.uuid).find().status.toString() == "Connected" + + reconnectPrimaryStorage { + uuid = ps.uuid + } + } + + void testCreateVm() { + def result = getCandidatePrimaryStoragesForCreatingVm { + l3NetworkUuids = [l3.uuid] + imageUuid = image.uuid + } as GetCandidatePrimaryStoragesForCreatingVmResult + + assert result.getRootVolumePrimaryStorages().size() == 1 + + env.message(UploadImageToRemoteTargetMsg.class) { UploadImageToRemoteTargetMsg msg, CloudBus bus -> + UploadImageToRemoteTargetReply r = new UploadImageToRemoteTargetReply() + assert msg.getRemoteTargetUrl().startsWith(exportProtocol) + assert msg.getFormat() == "raw" + bus.reply(msg, r) + } + + env.afterSimulator(KVMConstant.KVM_START_VM_PATH) { rsp, HttpEntity e -> + def cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.StartVmCmd.class) + assert cmd.rootVolume.deviceType == VolumeTO.VHOST + assert cmd.rootVolume.installPath.startsWith("/var/run/bdc-") + assert cmd.rootVolume.format == "raw" + if (cmd.cdRoms != null) { + cmd.cdRoms.forEach { + if (!it.isEmpty() && it.getPath() != null) { + assert it.getPath().startsWith(exportProtocol) + } + } + } + return rsp + } + + def otherVm = createVmInstance { + name = "vm" + instanceOfferingUuid = instanceOffering.uuid + rootDiskOfferingUuid = diskOffering.uuid + imageUuid = image.uuid + l3NetworkUuids = [l3.uuid] + hostUuid = host1.uuid + } as VmInstanceInventory + + assert otherVm.allVolumes[0].size == diskOffering.diskSize + + vm = createVmInstance { + name = "vm" + instanceOfferingUuid = instanceOffering.uuid + imageUuid = image.uuid + l3NetworkUuids = [l3.uuid] + hostUuid = host1.uuid + } as VmInstanceInventory + + deleteVm(otherVm.uuid) + + stopVmInstance { + uuid = vm.uuid + } + + startVmInstance { + uuid = vm.uuid + hostUuid = host1.uuid + } + + rebootVmInstance { + uuid = vm.uuid + } + + def vm2 = createVmInstance { + name = "vm" + instanceOfferingUuid = instanceOffering.uuid + rootDiskOfferingUuid = diskOffering.uuid + imageUuid = iso.uuid + l3NetworkUuids = [l3.uuid] + hostUuid = host1.uuid + } as VmInstanceInventory + + deleteVm(vm2.uuid) + } + + void testHandleInactiveVolume() { + def rootVolInstallPath = Q.New(VolumeVO.class).eq(VolumeVO_.uuid, vm.rootVolumeUuid).select(VolumeVO_.installPath).findValue() + int volId = XInfiniPathHelper.getVolIdFromPath(rootVolInstallPath as String) + BdcModule bdc = controller.apiHelper.queryBdcByIp(host1.managementIp) + BdcBdevModule bdev = controller.apiHelper.queryBdcBdevByVolumeIdAndBdcId(volId, bdc.spec.id) + assert bdev != null + + def clusterUuid = controller.apiHelper.getClusterUuid() + def vhostSocketDir = "/var/run/bdc-${clusterUuid}/" + + env.simulator(KVMConstant.KVM_VOLUME_SYNC_PATH) { HttpEntity e -> + def cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.VolumeSyncCmd.class) + assert cmd.storagePaths.get(0).endsWith("/volume-*") + def rsp = new KVMAgentCommands.VolumeSyncRsp() + rsp.inactiveVolumePaths = new HashMap<>() + rsp.inactiveVolumePaths.put(cmd.storagePaths.get(0), asList("${vhostSocketDir}volume-${vm.rootVolumeUuid}" as String)) + return rsp + } + + def msg = new PingHostMsg() + msg.hostUuid = host1.uuid + bus.makeTargetServiceIdByResourceUuid(msg, HostConstant.SERVICE_ID, host1.uuid) + MessageReply r = bus.call(msg) + assert r.success + + sleep(1000) + // vm in running, not deactivate volume + bdev = controller.apiHelper.queryBdcBdevByVolumeIdAndBdcId(volId, bdc.spec.id) + assert bdev != null + + SQL.New(VmInstanceVO.class).eq(VmInstanceVO_.uuid, vm.uuid).set(VmInstanceVO_.hostUuid, null).set(VmInstanceVO_.state, VmInstanceState.Starting).update() + env.message(VmStateChangedOnHostMsg.class) { VmStateChangedOnHostMsg cmsg, CloudBus bus -> + bus.reply(cmsg, new MessageReply()) + } + r = bus.call(msg) + assert r.success + + sleep(1000) + // vm in starting, not deactivate volume + bdev = controller.apiHelper.queryBdcBdevByVolumeIdAndBdcId(volId, bdc.spec.id) + assert bdev != null + env.cleanMessageHandlers() + + SQL.New(VmInstanceVO.class).eq(VmInstanceVO_.uuid, vm.uuid).set(VmInstanceVO_.hostUuid, null).set(VmInstanceVO_.state, VmInstanceState.Stopped).update() + r = bus.call(msg) + assert r.success + + sleep(1000) + // vm in stop, deactivate volume + retryInSecs { + bdev = controller.apiHelper.queryBdcBdevByVolumeIdAndBdcId(volId, bdc.spec.id) + assert bdev == null + } + + SQL.New(VmInstanceVO.class).eq(VmInstanceVO_.uuid, vm.uuid).set(VmInstanceVO_.hostUuid, host1.uuid).set(VmInstanceVO_.state, VmInstanceState.Running).update() + env.simulator(KVMConstant.KVM_VOLUME_SYNC_PATH) { HttpEntity e -> + def cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.VolumeSyncCmd.class) + assert cmd.storagePaths.get(0).endsWith("/volume-*") + return new KVMAgentCommands.VolumeSyncRsp() + } + } + + void testCreateVolumeRollback() { + def vol = createDataVolume { + name = "test" + diskOfferingUuid = diskOffering.uuid + primaryStorageUuid = ps.uuid + } as VolumeInventory + + env.afterSimulator(KVMConstant.KVM_ATTACH_VOLUME) { rsp, HttpEntity e -> + rsp.setError("on purpose") + return rsp + } + + expectError { + attachDataVolumeToVm { + vmInstanceUuid = vm.uuid + volumeUuid = vol.uuid + } + } + + env.afterSimulator(KVMConstant.KVM_ATTACH_VOLUME) { rsp, HttpEntity e -> + return rsp + } + + deleteVolume(vol.uuid) + } + + void testAttachIso() { + env.afterSimulator(KVMConstant.KVM_ATTACH_ISO_PATH) { rsp, HttpEntity e -> + def cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.AttachIsoCmd.class) + assert cmd.iso.getPath().startsWith(exportProtocol) + return rsp + } + + attachIsoToVmInstance { + vmInstanceUuid = vm.uuid + isoUuid = iso.uuid + } + + rebootVmInstance { + uuid = vm.uuid + } + + setVmBootOrder { + uuid = vm.uuid + bootOrder = asList(VmBootDevice.CdRom.toString(), VmBootDevice.HardDisk.toString(), VmBootDevice.Network.toString()) + } + + stopVmInstance { + uuid = vm.uuid + } + + startVmInstance { + uuid = vm.uuid + } + + setVmBootOrder { + uuid = vm.uuid + bootOrder = asList(VmBootDevice.HardDisk.toString(), VmBootDevice.CdRom.toString(), VmBootDevice.Network.toString()) + } + + stopVmInstance { + uuid = vm.uuid + } + + startVmInstance { + uuid = vm.uuid + hostUuid = host1.uuid + } + + detachIsoFromVmInstance { + vmInstanceUuid = vm.uuid + } + } + + void testCreateDataVolume() { + vol = createDataVolume { + name = "test" + diskOfferingUuid = diskOffering.uuid + primaryStorageUuid = ps.uuid + } as VolumeInventory + + deleteVolume(vol.uuid) + + vol = createDataVolume { + name = "test" + diskOfferingUuid = diskOffering.uuid + primaryStorageUuid = ps.uuid + } as VolumeInventory + + attachDataVolumeToVm { + vmInstanceUuid = vm.uuid + volumeUuid = vol.uuid + } + + vol2 = createDataVolume { + name = "test" + diskOfferingUuid = diskOffering.uuid + } as VolumeInventory + + attachDataVolumeToVm { + vmInstanceUuid = vm.uuid + volumeUuid = vol2.uuid + } + + detachDataVolumeFromVm { + uuid = vol2.uuid + } + + attachDataVolumeToVm { + vmInstanceUuid = vm.uuid + volumeUuid = vol2.uuid + } + } + + void testCreateSnapshot() { + def snapshot = createVolumeSnapshot { + name = "test" + volumeUuid = vol.uuid + } as VolumeSnapshotInventory + + stopVmInstance { + uuid = vm.uuid + } + + revertVolumeFromSnapshot { + uuid = snapshot.uuid + } + + deleteVolumeSnapshot { + uuid = snapshot.uuid + } + + startVmInstance { + uuid = vm.uuid + } + } + + void testCreateTemplate() { + env.message(DownloadImageFromRemoteTargetMsg.class) { DownloadImageFromRemoteTargetMsg msg, CloudBus bus -> + DownloadImageFromRemoteTargetReply r = new DownloadImageFromRemoteTargetReply() + assert msg.getRemoteTargetUrl().startsWith(exportProtocol) + r.setInstallPath("zstore://test/image") + r.setSize(100L) + bus.reply(msg, r) + } + + def dataImage = createDataVolumeTemplateFromVolume { + name = "vol-image" + volumeUuid = vol.uuid + backupStorageUuids = [bs.uuid] + } as ImageInventory + + stopVmInstance { + uuid = vm.uuid + } + + def rootImage = createRootVolumeTemplateFromRootVolume { + name = "root-image" + rootVolumeUuid = vm.rootVolumeUuid + backupStorageUuids = [bs.uuid] + } as ImageInventory + } + + void testClean() { + deleteVm(vm.uuid) + + deleteVolume(vol2.uuid) + + deleteDataVolume { + uuid = vol.uuid + } + + expungeDataVolume { + uuid = vol.uuid + } + } + + void testImageCacheClean() { + deleteImage { + uuid = image.uuid + } + + expungeImage { + imageUuid = image.uuid + } + + cleanUpImageCacheOnPrimaryStorage { + uuid = ps.uuid + } + + retryInSecs { + assert Q.New(ImageCacheVO.class).eq(ImageCacheVO_.imageUuid, image.uuid).count() == 0 + assert Q.New(ImageCacheShadowVO.class).eq(ImageCacheShadowVO_.imageUuid, image.uuid).count() == 0 + } + } + + void testDeletePs() { + assert MultiNodeSingleFlightImpl.getExecutor(ps.uuid) != null + + detachPrimaryStorageFromCluster { + primaryStorageUuid = ps.uuid + clusterUuid = cluster.uuid + } + + deletePrimaryStorage { + uuid = ps.uuid + } + + retryInSecs { + assert MultiNodeSingleFlightImpl.getExecutor(ps.uuid) == null + } + } + + void deleteVm(String vmUuid) { + destroyVmInstance { + uuid = vmUuid + } + + expungeVmInstance { + uuid = vmUuid + } + } + + void deleteVolume(String volUuid) { + deleteDataVolume { + uuid = volUuid + } + + expungeDataVolume { + uuid = volUuid + } + } +} diff --git a/testlib/src/main/java/org/zstack/testlib/ExternalPrimaryStorageSpec.groovy b/testlib/src/main/java/org/zstack/testlib/ExternalPrimaryStorageSpec.groovy index ef70f3e7ad3..9444034e91a 100644 --- a/testlib/src/main/java/org/zstack/testlib/ExternalPrimaryStorageSpec.groovy +++ b/testlib/src/main/java/org/zstack/testlib/ExternalPrimaryStorageSpec.groovy @@ -2,6 +2,7 @@ package org.zstack.testlib import org.springframework.http.HttpEntity import org.zstack.core.db.DatabaseFacade +import org.zstack.core.Platform import org.zstack.kvm.KVMAgentCommands import org.zstack.storage.zbs.LogicalPoolInfo import org.zstack.cbd.kvm.KvmCbdCommands @@ -13,7 +14,9 @@ import org.zstack.utils.data.SizeUnit import org.zstack.utils.logging.CLogger import org.zstack.utils.gson.JSONObjectUtil +import javax.servlet.http.HttpServletRequest import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger /** * @author Xingwei Yu @@ -264,6 +267,1120 @@ class ExternalPrimaryStorageSpec extends PrimaryStorageSpec { } } + static class ExponSimulators implements Simulator { + static final long TOTAL_CAPACITY = SizeUnit.TERABYTE.toByte(2) + static final long AVAILABLE_CAPACITY = SizeUnit.TERABYTE.toByte(1) + static final String POOL_ID = "test-pool-id-001" + static final String POOL_NAME = "pool" + static final String TIANSHU_ID = "test-tianshu-id-001" + static final String TIANSHU_NAME = "tianshu" + + static ConcurrentHashMap volumes = new ConcurrentHashMap<>() + static ConcurrentHashMap snapshots = new ConcurrentHashMap<>() + static Set vhostBoundUss = ConcurrentHashMap.newKeySet() + static AtomicInteger volumeCounter = new AtomicInteger(0) + static AtomicInteger snapshotCounter = new AtomicInteger(0) + + static void clear() { + volumes.clear() + snapshots.clear() + vhostBoundUss.clear() + volumeCounter.set(0) + snapshotCounter.set(0) + } + + @Override + void registerSimulators(EnvSpec espec) { + def simulator = { arg1, arg2 -> + espec.simulator(arg1, arg2) + } + + // Login: POST /api/v1/login + simulator("/api/v1/login") { + return [ret_code: "0", message: "", access_token: "test-session-token", refresh_token: "test-refresh-token", token_type: "Bearer"] + } + + // Logout: POST /api/v1/v2/logout + simulator("/api/v1/v2/logout") { + return [ret_code: "0", message: ""] + } + + // Task status: GET /api/v1/tasks/{id} + simulator("/api/v1/tasks/.*") { + return [ret_code: "0", message: "", status: "SUCCESS", ret_msg: "", progress: 100, id: "test-task-id"] + } + + // Query pools (QueryFailureDomainRequest): GET /api/v2/failure_domain + simulator("/api/v2/failure_domain") { + return [ret_code: "0", message: "", total: 1, failure_domains: [ + [id: POOL_ID, failure_domain_name: POOL_NAME, valid_size: TOTAL_CAPACITY, + real_data_size: TOTAL_CAPACITY - AVAILABLE_CAPACITY, raw_size: TOTAL_CAPACITY * 3, + data_size: TOTAL_CAPACITY - AVAILABLE_CAPACITY, redundancy_ploy: "replicated", + replicate_size: 3, health_status: "health", run_status: "normal", + tianshu_id: TIANSHU_ID, tianshu_name: TIANSHU_NAME, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + + // Get pool detail (GetFailureDomainRequest): GET /api/v2/failure_domain/{id} + simulator("/api/v2/failure_domain/[^/]+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: "", members: [ + id: POOL_ID, failure_domain_name: POOL_NAME, valid_size: TOTAL_CAPACITY, + real_data_size: TOTAL_CAPACITY - AVAILABLE_CAPACITY, raw_size: TOTAL_CAPACITY * 3, + data_size: TOTAL_CAPACITY - AVAILABLE_CAPACITY, redundancy_ploy: "replicated", + replicate_size: 3, health_status: "health", run_status: "normal", + tianshu_id: TIANSHU_ID, tianshu_name: TIANSHU_NAME, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis() + ]] + } + + // Get blacklist (GetFailureDomainBlacklistRequest): GET /api/v2/failure_domain/black_list/{id} + simulator("/api/v2/failure_domain/black_list/[^/]+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: "", entries: []] + } + + // Clear blacklist: PUT /api/v2/failure_domain/black_list/clean + simulator("/api/v2/failure_domain/black_list/clean") { + return [ret_code: "0", message: ""] + } + + // Add volume path to blacklist: PUT /api/v2/failure_domain/black_list + simulator("/api/v2/failure_domain/black_list") { + return [ret_code: "0", message: ""] + } + + // Query clusters (QueryTianshuClusterRequest): GET /api/v2/tianshu + simulator("/api/v2/tianshu") { + return [ret_code: "0", message: "", total: 1, result: [ + [id: TIANSHU_ID, name: TIANSHU_NAME, health_status: "health", run_status: "normal", + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + + // Query iSCSI targets (QueryIscsiTargetRequest): GET /api/v2/block/iscsi/gateways + // also matches sync variant + simulator("/api/v2/(sync/)?block/iscsi/gateways") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def targetName = req.getParameter("name") ?: "iscsi-target-default" + def targetId = "test-iscsi-target-" + targetName + return [ret_code: "0", message: "", total: 1, gateways: [ + [id: targetId, name: targetName, status: "health", port: 3260, + iqn: "iqn.2022-07.com.expontech.wds:" + targetId, + tianshu_id: TIANSHU_ID, tianshu_name: TIANSHU_NAME, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + + // Create iSCSI target: POST /api/v2/sync/block/iscsi/gateways + simulator("/api/v2/sync/block/iscsi/gateways") { HttpEntity e -> + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def targetId = Platform.getUuid() + def targetName = body?.name ?: "iscsi-target-" + targetId + return [ret_code: "0", message: "", id: targetId, name: targetName] + } + + // iSCSI target operations with id: GET/DELETE/PUT /api/v2/[sync/]block/iscsi/gateways/{id}/... + simulator("/api/v2/(sync/)?block/iscsi/gateways/[^/]+(/.*)?") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + def matcher = (uri =~ /\/block\/iscsi\/gateways\/([^\/]+)/) + def targetId = matcher ? matcher[0][1] : Platform.getUuid() + def targetName = targetId.startsWith("test-iscsi-target-") ? targetId.substring("test-iscsi-target-".length()) : targetId + + return [ret_code: "0", message: "", + id: targetId, name: targetName, + iqn: "iqn.2022-07.com.expontech.wds:" + targetId, + port: 3260, lun_count: 0, + total: 0, gateways: [], + nodes: [ + [gateway_ip: "127.0.0.1", manager_ip: "localhost", name: "localhost", + server_id: Platform.getUuid(), tianshu_id: TIANSHU_ID, + uss_gw_id: "test-uss-vhost_localhost", uss_name: "iscsi_zstack"] + ], + server: []] + } + + // Query iSCSI client groups: GET /api/v2/block/iscsi/clients + simulator("/api/v2/(sync/)?block/iscsi/clients") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def clientName = req.getParameter("name") ?: "iscsi-client-default" + def clientId = "test-iscsi-client-" + clientName + return [ret_code: "0", message: "", total: 1, clients: [ + [id: clientId, name: clientName, status: "health", hosts: [], + iscsi_gw_count: 0, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + + // Create iSCSI client group: POST /api/v2/sync/block/iscsi/clients + simulator("/api/v2/sync/block/iscsi/clients") { + def clientId = Platform.getUuid() + return [ret_code: "0", message: "", id: clientId] + } + + // iSCSI client group operations with id + simulator("/api/v2/(sync/)?block/iscsi/clients/[^/]+(/.*)?") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: "", total: 0, gateways: [], luns: [], snapshots: []] + } + + // Query USS gateways (QueryUssGatewayRequest): GET /api/v2/wds/uss + simulator("/api/v2/(sync/)?wds/uss") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def nameParam = req.getParameter("name") + def ussName = nameParam ?: "vhost_localhost" + def ussId = "test-uss-" + ussName + return [ret_code: "0", message: "", total: 1, uss_gateways: [ + [id: ussId, name: ussName, type: "uss", status: "health", + tianshu_id: TIANSHU_ID, tianshu_name: TIANSHU_NAME, + manager_ip: "127.0.0.1", business_port: 4420, business_network: "127.0.0.1/8", + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + + // Query vhost controllers (QueryVhostControllerRequest): GET /api/v2/block/vhost + simulator("/api/v2/(sync/)?block/vhost") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def vhostName = req.getParameter("name") ?: "vhost-default" + def vhostId = "test-vhost-" + vhostName + return [ret_code: "0", message: "", total: 1, vhosts: [ + [id: vhostId, name: vhostName, status: "health", + path: "/var/run/vhost/" + vhostName, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + + // Create vhost controller: POST /api/v2/sync/block/vhost + simulator("/api/v2/sync/block/vhost") { HttpEntity e -> + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def vhostId = Platform.getUuid() + def vhostName = body?.name ?: "vhost-" + vhostId + return [ret_code: "0", message: "", id: vhostId, name: vhostName] + } + + // Vhost bind/unbind USS: PUT /api/v2/sync/block/vhost/bind_uss or unbind_uss + simulator("/api/v2/sync/block/vhost/(un)?bind_uss") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def vhostId = body?.vhost_id + if (vhostId != null) { + if (req.getRequestURI().contains("unbind_uss")) { + vhostBoundUss.remove(vhostId) + } else { + vhostBoundUss.add(vhostId) + } + } + return [ret_code: "0", message: ""] + } + + // Vhost controller operations with id + simulator("/api/v2/(sync/)?block/vhost/[^/]+(/.*)?") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + if (uri.contains("vhost_binded_uss")) { + def matcher = (uri =~ /\/block\/vhost\/([^\/]+)\/vhost_binded_uss/) + def vhostId = matcher ? matcher[0][1] : null + if (vhostId != null && vhostBoundUss.contains(vhostId)) { + return [ret_code: "0", message: "", uss: [ + [id: "test-uss-vhost_localhost", name: "vhost_localhost", type: "uss", status: "health", + tianshu_id: TIANSHU_ID, tianshu_name: TIANSHU_NAME, + manager_ip: "127.0.0.1", business_port: 4420, business_network: "127.0.0.1/8"] + ]] + } + return [ret_code: "0", message: "", uss: []] + } + return [ret_code: "0", message: "", uss: []] + } + + // Query NVMf targets: GET /api/v2/block/nvmf + simulator("/api/v2/(sync/)?block/nvmf") { + return [ret_code: "0", message: "", total: 0, nvmfs: []] + } + + // Create NVMf target: POST /api/v2/sync/block/nvmf + simulator("/api/v2/sync/block/nvmf") { + def nvmfId = Platform.getUuid() + return [ret_code: "0", message: "", id: nvmfId] + } + + // NVMf bind/unbind USS + simulator("/api/v2/sync/block/nvmf/(un)?bind_uss") { + return [ret_code: "0", message: ""] + } + + // NVMf target operations with id + simulator("/api/v2/(sync/)?block/nvmf/[^/]+(/.*)?") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: "", uss: []] + } + + // Query NVMf client groups + simulator("/api/v2/(sync/)?block/nvmf_client/?") { + return [ret_code: "0", message: "", total: 0, clients: []] + } + + // Create NVMf client group: POST /api/v2/sync/block/nvmf_client + simulator("/api/v2/sync/block/nvmf_client") { + def clientId = Platform.getUuid() + return [ret_code: "0", message: "", id: clientId] + } + + // NVMf client group operations with id + simulator("/api/v2/(sync/)?block/nvmf_client/[^/]+(/.*)?") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: ""] + } + + // Create volume: POST /api/v2/sync/block/volumes + simulator("/api/v2/sync/block/volumes") { HttpEntity e -> + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def volId = Platform.getUuid() + def volName = body?.name ?: "vol-" + volumeCounter.incrementAndGet() + long volSize = body?.volume_size ?: SizeUnit.GIGABYTE.toByte(1) + + volumes.put(volId, [ + id: volId, name: volName, volume_name: volName, pool_id: POOL_ID, pool_name: POOL_NAME, + volume_size: volSize, data_size: 0, is_delete: false, run_status: "normal", + wwn: "wwn-" + volId + ]) + + return [ret_code: "0", message: "", id: volId] + } + + // Query volumes (QueryVolumeRequest): GET /api/v2/block/volumes + simulator("/api/v2/block/volumes") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def allVols = volumes.values().toList() + return [ret_code: "0", message: "", total: allVols.size(), volumes: allVols] + } + + // Get volume detail (GetVolumeRequest): GET /api/v2/block/volumes/{volId} + simulator("/api/v2/block/volumes/[^/]+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + def volId = uri.substring(uri.lastIndexOf("/") + 1) + def vol = volumes.get(volId) + if (vol == null) { + // try lookup by stripping dashes (expon IDs may or may not have dashes) + vol = volumes.get(volId.replace("-", "")) + } + if (vol == null) { + vol = [id: volId, name: "unknown", volume_name: "unknown", pool_id: POOL_ID, pool_name: POOL_NAME, + volume_size: SizeUnit.GIGABYTE.toByte(1), data_size: 0, is_delete: false, run_status: "normal"] + } + return [ret_code: "0", message: "", volume_detail: vol] + } + + // Delete volume: DELETE /api/v2/sync/block/volumes/{volId} + simulator("/api/v2/sync/block/volumes/[^/]+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + def segments = uri.split("/") + def volId = segments[segments.length - 1] + volumes.remove(volId) + return [ret_code: "0", message: ""] + } + + // Expand volume: PUT /api/v2/sync/block/volumes/{id}/expand + simulator("/api/v2/sync/block/volumes/[^/]+/expand") { HttpEntity e -> + return [ret_code: "0", message: ""] + } + + // Set volume QoS: PUT /api/v2/sync/block/volumes/{volId}/qos + simulator("/api/v2/sync/block/volumes/[^/]+/qos") { HttpEntity e -> + return [ret_code: "0", message: ""] + } + + // Get volume LUN detail: GET /api/v2/sync/block/volumes/{volId}/lun_detail + simulator("/api/v2/sync/block/volumes/[^/]+/lun_detail") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: "", lun_details: [[lun_id: 0, lun_name: "lun-0"]]] + } + + // Get volume bound path: GET /api/v2/sync/block/volumes/{volId}/bind_status + simulator("/api/v2/sync/block/volumes/[^/]+/bind_status") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: "", bind_paths: []] + } + + // Get volume bound iSCSI client groups: GET /api/v2/block/volumes/{volumeId}/clients + simulator("/api/v2/block/volumes/[^/]+/clients") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ret_code: "0", message: "", clients: []] + } + + // Recovery volume snapshot: PUT /api/v2/sync/block/volumes/{volumeId}/recovery + simulator("/api/v2/sync/block/volumes/[^/]+/recovery") { HttpEntity e -> + return [ret_code: "0", message: ""] + } + + // Get volume task progress: GET /api/v2/sync/block/volumes/tasks/{taskId} + simulator("/api/v2/sync/block/volumes/tasks/.*") { + return [ret_code: "0", message: "", status: "SUCCESS", progress: 100] + } + + // Update volume name: PUT /api/v2/block/volumes/{id}/name + simulator("/api/v2/block/volumes/[^/]+/name") { HttpEntity e -> + return [ret_code: "0", message: ""] + } + + // Create snapshot: POST /api/v2/sync/block/snaps + simulator("/api/v2/sync/block/snaps") { HttpEntity e -> + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def snapId = Platform.getUuid() + def snapName = body?.name ?: "snap-" + snapshotCounter.incrementAndGet() + def volId = body?.volume_id ?: "" + + def vol = volumes.get(volId) + long snapSize = vol != null ? (long) vol.get("volume_size") : SizeUnit.GIGABYTE.toByte(1) + + snapshots.put(snapId, [ + id: snapId, name: snapName, snap_name: snapName, snap_size: snapSize, + data_size: 0, volume_id: volId, volume_name: vol?.get("name") ?: "", + pool_id: POOL_ID, pool_name: POOL_NAME, is_delete: false, + wwn: "wwn-snap-" + snapId + ]) + + return [ret_code: "0", message: "", id: snapId] + } + + // Query snapshots (QueryVolumeSnapshotRequest): GET /api/v2/block/snaps + simulator("/api/v2/block/snaps") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def allSnaps = snapshots.values().toList() + return [ret_code: "0", message: "", total: allSnaps.size(), snaps: allSnaps, volumes: []] + } + + // Get snapshot detail: GET /api/v2/sync/block/snaps/{id} + simulator("/api/v2/(sync/)?block/snaps/[^/]+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + def snapId = uri.substring(uri.lastIndexOf("/") + 1) + def snap = snapshots.get(snapId) + if (snap == null) { + snap = [id: snapId, name: "unknown", snap_name: "unknown", snap_size: SizeUnit.GIGABYTE.toByte(1), + data_size: 0, volume_id: "", pool_id: POOL_ID, pool_name: POOL_NAME, is_delete: false] + } + return [ret_code: "0", message: "", snap_detail: snap] + } + + // Delete snapshot: DELETE /api/v2/sync/block/snaps/{snapshotId} + simulator("/api/v2/sync/block/snaps/[^/]+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + def snapId = uri.substring(uri.lastIndexOf("/") + 1) + snapshots.remove(snapId) + return [ret_code: "0", message: ""] + } + + // Clone volume from snapshot: POST /api/v2/sync/block/snaps/{snapshotId}/clone + simulator("/api/v2/sync/block/snaps/[^/]+/clone") { HttpEntity e -> + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def volId = Platform.getUuid() + def volName = body?.name ?: "clone-" + volumeCounter.incrementAndGet() + + volumes.put(volId, [ + id: volId, name: volName, volume_name: volName, pool_id: POOL_ID, pool_name: POOL_NAME, + volume_size: SizeUnit.GIGABYTE.toByte(1), data_size: 0, is_delete: false, run_status: "normal", + wwn: "wwn-" + volId + ]) + + return [ret_code: "0", message: "", id: volId] + } + + // Copy snapshot: PUT /api/v2/sync/block/snaps/{snapshotId}/copy_clone + simulator("/api/v2/sync/block/snaps/[^/]+/copy_clone") { HttpEntity e -> + return [ret_code: "0", message: "", task_id: Platform.getUuid()] + } + + // Update snapshot: PUT /api/v2/block/snaps/{id} + simulator("/api/v2/block/snaps/[^/]+") { HttpEntity e -> + return [ret_code: "0", message: ""] + } + + // Set trash expire time: PUT /api/v1/sys_config/trash_recycle + simulator("/api/v1/sys_config/trash_recycle") { + return [ret_code: "0", message: ""] + } + } + } + + static class XinfiniSimulators implements Simulator { + static final int POOL_ID = 1 + static final String POOL_NAME = "pool1" + static final int BS_POLICY_ID = 1 + static final String CLUSTER_UUID = "test-xinfini-cluster-uuid" + static final int BDC_ID = 1 + static final int ISCSI_GATEWAY_ID = 1 + static final long TOTAL_CAPACITY_KB = 2L * 1024 * 1024 * 1024 // 2TB in KB + static final long USED_CAPACITY_KB = 700L * 1024 * 1024 // ~0.7TB in KB + + static ConcurrentHashMap volumes = new ConcurrentHashMap<>() + static ConcurrentHashMap snapshots = new ConcurrentHashMap<>() + static ConcurrentHashMap bdcBdevs = new ConcurrentHashMap<>() + static ConcurrentHashMap iscsiClients = new ConcurrentHashMap<>() + static ConcurrentHashMap iscsiClientGroups = new ConcurrentHashMap<>() + static ConcurrentHashMap volumeClientGroupMappings = new ConcurrentHashMap<>() + + static AtomicInteger volumeCounter = new AtomicInteger(0) + static AtomicInteger snapshotCounter = new AtomicInteger(0) + static AtomicInteger bdcBdevCounter = new AtomicInteger(0) + static AtomicInteger iscsiClientCounter = new AtomicInteger(0) + static AtomicInteger iscsiClientGroupCounter = new AtomicInteger(0) + static AtomicInteger volumeClientGroupMappingCounter = new AtomicInteger(0) + + static void clear() { + volumes.clear() + snapshots.clear() + bdcBdevs.clear() + iscsiClients.clear() + iscsiClientGroups.clear() + volumeClientGroupMappings.clear() + volumeCounter.set(0) + snapshotCounter.set(0) + bdcBdevCounter.set(0) + iscsiClientCounter.set(0) + iscsiClientGroupCounter.set(0) + volumeClientGroupMappingCounter.set(0) + } + + static Map makeQueryResponse(List items) { + return [ + metadata: [pagination: [count: items.size(), total_count: items.size(), offset: 0, limit: 100]], + items: items + ] + } + + static Map makeItemResponse(Map item) { + return [ + metadata: [id: item.spec?.id, name: item.spec?.name, state: [state: "active"]], + spec: item.spec, + status: item.status + ] + } + + static Map makeDeleteResponse() { + return [:] + } + + static Map makeNotFoundResponse() { + return [return_code: 404, message: "not found"] + } + + static List filterItems(List items, String qParam) { + if (qParam == null || qParam.isEmpty()) { + return items + } + + // Strip outer parentheses pairs + String q = qParam.trim() + + // Handle compound AND filters: ((spec.field1:val1) AND (spec.field2:val2)) + if (q.contains(" AND ")) { + def parts = q.split(" AND ") + List result = items + for (String part : parts) { + String cleaned = part.replaceAll("[()]", "").trim() + result = applySimpleFilter(result, cleaned) + } + return result + } + + // Simple filter: spec.field:value or (val1 val2) list + String cleaned = q.replaceAll("^\\(+", "").replaceAll("\\)+\$", "").trim() + return applySimpleFilter(items, cleaned) + } + + static List applySimpleFilter(List items, String filter) { + // Match pattern: spec.field:value or spec.field:(val1 val2 ...) + def matcher = (filter =~ /spec\.(\w+):\(?([^)]+)\)?/) + if (!matcher.find()) { + return items + } + String field = matcher.group(1) + String valueStr = matcher.group(2).trim() + + // Check if it's a list of values: (val1 val2 val3) + if (valueStr.contains(" ")) { + def values = valueStr.split("\\s+").toList() + return items.findAll { item -> + String itemVal = item.spec?.get(field)?.toString() + return itemVal != null && values.contains(itemVal) + } + } + + return items.findAll { item -> + String itemVal = item.spec?.get(field)?.toString() + return itemVal == valueStr + } + } + + static int extractIdFromUri(String uri) { + // Extract numeric ID from URI like /afa/v1/bs-volumes/5 + def matcher = (uri =~ /\/(\d+)(:[a-z-]+)?$/) + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)) + } + // Try extracting ID before :action like /afa/v1/bs-volumes/5:flatten + matcher = (uri =~ /\/(\d+):/) + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)) + } + return -1 + } + + @Override + void registerSimulators(EnvSpec espec) { + def simulator = { arg1, arg2 -> + espec.simulator(arg1, arg2) + } + + // ========== SDDC Category ========== + + // 1. GET /sddc/v1/cluster - QueryClusterRequest + simulator("/sddc/v1/cluster") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return makeQueryResponse([ + [ + metadata: [id: 1, name: "xinfini-cluster", state: [state: "active"]], + spec: [id: 1, name: "xinfini-cluster", uuid: CLUSTER_UUID], + status: [id: 1] + ] + ]) + } + + // 2. GET /sddc/v1/samples/query - QueryMetricRequest + simulator("/sddc/v1/samples/query") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def metricParam = req.getParameter("metric") + long value + if (metricParam != null && metricParam.contains("data_kbytes")) { + // Used capacity + value = USED_CAPACITY_KB + } else { + // Total capacity (actual_kbytes) + value = TOTAL_CAPACITY_KB + } + return [data: [result_type: "vector", result: [[value: value]]]] + } + + // ========== AFA Category: Pool & Node ========== + + // 3. GET /afa/v1/pools - QueryPoolRequest + simulator("/afa/v1/pools") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return makeQueryResponse([ + [ + metadata: [id: POOL_ID, name: POOL_NAME, state: [state: "active"]], + spec: [id: POOL_ID, name: POOL_NAME, default_bs_policy_id: BS_POLICY_ID], + status: [id: POOL_ID] + ] + ]) + } + + // 4. GET /afa/v1/pools/{id} - GetPoolRequest + simulator("/afa/v1/pools/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ + metadata: [id: POOL_ID, name: POOL_NAME, state: [state: "active"]], + spec: [id: POOL_ID, name: POOL_NAME, default_bs_policy_id: BS_POLICY_ID], + status: [id: POOL_ID] + ] + } + + // 5. GET /afa/v1/bs-policies/{id} - GetBsPolicyRequest + simulator("/afa/v1/bs-policies/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ + metadata: [id: BS_POLICY_ID, name: "default-policy", state: [state: "active"]], + spec: [id: BS_POLICY_ID, name: "default-policy", data_replica_type: "replica", data_replica_num: 3], + status: [id: BS_POLICY_ID] + ] + } + + // 6. GET /afa/v1/nodes - QueryNodeRequest + simulator("/afa/v1/nodes") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return makeQueryResponse([ + [ + metadata: [id: 1, name: "node-1", state: [state: "active"]], + spec: [id: 1, name: "node-1", ip: "127.0.0.1", port: 80, admin_ip: "127.0.0.1", role_afa_admin: true, role_afa_server: true, storage_public_ip: "127.0.0.1", storage_private_ip: "127.0.0.1"], + status: [id: 1, run_state: "Active"] + ] + ]) + } + + // 7. GET /afa/v1/nodes/{id} - GetNodeRequest + simulator("/afa/v1/nodes/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ + metadata: [id: 1, name: "node-1", state: [state: "active"]], + spec: [id: 1, name: "node-1", ip: "127.0.0.1", port: 80, admin_ip: "127.0.0.1", role_afa_admin: true, role_afa_server: true, storage_public_ip: "127.0.0.1", storage_private_ip: "127.0.0.1"], + status: [id: 1, run_state: "Active"] + ] + } + + // ========== AFA Category: Volume ========== + + // 8 & 9. /afa/v1/bs-volumes - POST create, GET query + simulator("/afa/v1/bs-volumes") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + if (req.getMethod() == "POST") { + // 8. CreateVolumeRequest + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + int volId = volumeCounter.incrementAndGet() + String volName = specData?.name ?: "vol-${volId}" + int poolId = specData?.pool_id ?: POOL_ID + long sizeMb = specData?.size_mb ?: 1024 + + def volSpec = [ + id: volId, name: volName, pool_id: poolId, size_mb: sizeMb, + bs_policy_id: BS_POLICY_ID, serial: "serial-${volId}", + loaded: false, flattened: true, max_total_iops: 0, max_total_bw_bps: 0, + creator: specData?.creator ?: "zstack", uuid: Platform.getUuid(), etag: Platform.getUuid() + ] + def volStatus = [ + id: volId, size_mb: sizeMb, allocated_size_byte: 0, + loaded: false, spring_id: 0, protocol: "", mapping_num: 0 + ] + def volItem = [spec: volSpec, status: volStatus] + volumes.put(volId, volItem) + + return makeItemResponse(volItem) + } else { + // 9. QueryVolumeRequest + def qParam = req.getParameter("q") + def allItems = volumes.values().collect { vol -> + [ + metadata: [id: vol.spec.id, name: vol.spec.name, state: [state: "active"]], + spec: vol.spec, + status: vol.status + ] + } + def filtered = filterItems(allItems, qParam) + return makeQueryResponse(filtered) + } + } + + // 10, 11, 12. /afa/v1/bs-volumes/{id} - GET get, PATCH update, DELETE delete + simulator("/afa/v1/bs-volumes/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int volId = extractIdFromUri(req.getRequestURI()) + if (req.getMethod() == "DELETE") { + // 12. DeleteVolumeRequest + volumes.remove(volId) + return makeDeleteResponse() + } else if (req.getMethod() == "PATCH") { + // 11. UpdateVolumeRequest + def vol = volumes.get(volId) + if (vol != null) { + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + if (specData?.size_mb) { + vol.spec.size_mb = specData.size_mb + vol.status.size_mb = specData.size_mb + } + if (specData?.max_total_iops != null) { + vol.spec.max_total_iops = specData.max_total_iops + } + if (specData?.max_total_bw_bps != null) { + vol.spec.max_total_bw_bps = specData.max_total_bw_bps + } + } + if (vol == null) { + return makeNotFoundResponse() + } + return makeItemResponse(vol) + } else { + // 10. GetVolumeRequest + def vol = volumes.get(volId) + if (vol == null) { + return makeNotFoundResponse() + } + return makeItemResponse(vol) + } + } + + // 13. POST /afa/v1/bs-volumes/:clone - CloneVolumeRequest + simulator("/afa/v1/bs-volumes/:clone") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + int volId = volumeCounter.incrementAndGet() + String volName = specData?.name ?: "clone-${volId}" + int bsSnapId = specData?.bs_snap_id ?: 0 + + // Get size from source snapshot if available + long sizeMb = 1024 + def srcSnap = snapshots.get(bsSnapId) + if (srcSnap != null) { + sizeMb = srcSnap.spec.size_mb ?: 1024 + } + + def volSpec = [ + id: volId, name: volName, pool_id: POOL_ID, size_mb: sizeMb, + bs_policy_id: BS_POLICY_ID, bs_snap_id: bsSnapId, + serial: "serial-${volId}", loaded: false, flattened: false, + max_total_iops: 0, max_total_bw_bps: 0, + creator: specData?.creator ?: "zstack", uuid: Platform.getUuid(), etag: Platform.getUuid() + ] + def volStatus = [ + id: volId, size_mb: sizeMb, allocated_size_byte: 0, + loaded: false, spring_id: 0, protocol: "", mapping_num: 0 + ] + def volItem = [spec: volSpec, status: volStatus] + volumes.put(volId, volItem) + + return makeItemResponse(volItem) + } + + // 14. POST /afa/v1/bs-volumes/{id}/:flatten - FlattenVolumeRequest + simulator("/afa/v1/bs-volumes/\\d+/:flatten") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int volId = extractIdFromUri(req.getRequestURI()) + def vol = volumes.get(volId) + if (vol != null) { + vol.spec.flattened = true + } + if (vol == null) { + return makeNotFoundResponse() + } + return makeItemResponse(vol) + } + + // 15. POST /afa/v1/bs-volumes/{id}/:rollback - RollbackSnapshotRequest + simulator("/afa/v1/bs-volumes/\\d+/:rollback") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int volId = extractIdFromUri(req.getRequestURI()) + def vol = volumes.get(volId) + if (vol == null) { + return makeNotFoundResponse() + } + return makeItemResponse(vol) + } + + // ========== AFA Category: Snapshot ========== + + // 16 & 17. /afa/v1/bs-snaps - POST create, GET query + simulator("/afa/v1/bs-snaps") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + if (req.getMethod() == "POST") { + // 16. CreateVolumeSnapshotRequest + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + int snapId = snapshotCounter.incrementAndGet() + String snapName = specData?.name ?: "snap-${snapId}" + int bsVolumeId = specData?.bs_volume_id ?: 0 + + long sizeMb = 1024 + def srcVol = volumes.get(bsVolumeId) + if (srcVol != null) { + sizeMb = srcVol.spec.size_mb ?: 1024 + } + + def snapSpec = [ + id: snapId, name: snapName, pool_id: POOL_ID, + bs_volume_id: bsVolumeId, bs_policy_id: BS_POLICY_ID, + size_mb: sizeMb, creator: specData?.creator ?: "zstack", + uuid: Platform.getUuid() + ] + def snapStatus = [id: snapId, size_mb: sizeMb] + def snapItem = [spec: snapSpec, status: snapStatus] + snapshots.put(snapId, snapItem) + + return makeItemResponse(snapItem) + } else { + // 17. QueryVolumeSnapshotRequest + def qParam = req.getParameter("q") + def allItems = snapshots.values().collect { snap -> + [ + metadata: [id: snap.spec.id, name: snap.spec.name, state: [state: "active"]], + spec: snap.spec, + status: snap.status + ] + } + def filtered = filterItems(allItems, qParam) + return makeQueryResponse(filtered) + } + } + + // 18 & 19. /afa/v1/bs-snaps/{id} - GET get, DELETE delete + simulator("/afa/v1/bs-snaps/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int snapId = extractIdFromUri(req.getRequestURI()) + if (req.getMethod() == "DELETE") { + // 19. DeleteVolumeSnapshotRequest + snapshots.remove(snapId) + return makeDeleteResponse() + } else { + // 18. GetVolumeSnapshotRequest + def snap = snapshots.get(snapId) + if (snap == null) { + return makeNotFoundResponse() + } + return makeItemResponse(snap) + } + } + + // ========== AFA Category: BDC / BdcBdev (Vhost) ========== + + // 20. GET /afa/v1/bdcs - QueryBdcRequest + simulator("/afa/v1/bdcs") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + // In UNIT_TEST_ON mode, queryBdcByIp uses sortBy=spec.id:desc instead of q=spec.ip:xxx + // Return all BDCs sorted by id desc + def bdcItems = [ + [ + metadata: [id: BDC_ID, name: "bdc-1", state: [state: "active"]], + spec: [id: BDC_ID, name: "bdc-1", ip: "127.0.0.1", port: 9500], + status: [id: BDC_ID, run_state: "Active", installed: true, hostname: "localhost", version: "1.0.0"] + ] + ] + return makeQueryResponse(bdcItems) + } + + // 21. GET /afa/v1/bdcs/{id} - GetBdcRequest + simulator("/afa/v1/bdcs/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + return [ + metadata: [id: BDC_ID, name: "bdc-1", state: [state: "active"]], + spec: [id: BDC_ID, name: "bdc-1", ip: "127.0.0.1", port: 9500], + status: [id: BDC_ID, run_state: "Active", installed: true, hostname: "localhost", version: "1.0.0"] + ] + } + + // 22 & 23. /afa/v1/bdc-bdevs - POST create, GET query + simulator("/afa/v1/bdc-bdevs") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + if (req.getMethod() == "POST") { + // 22. CreateBdcBdevRequest + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + int bdevId = bdcBdevCounter.incrementAndGet() + int bdcId = specData?.bdc_id ?: BDC_ID + int bsVolumeId = specData?.bs_volume_id ?: 0 + String bdevName = specData?.name ?: "volume-${bdevId}" + int queueNum = specData?.queue_num ?: 1 + String socketPath = "/var/run/bdc-${CLUSTER_UUID}/${bdevName}" + + def bdevSpec = [ + id: bdevId, name: bdevName, bdc_id: bdcId, node_ip: "127.0.0.1", + bs_volume_id: bsVolumeId, socket_path: socketPath, queue_num: queueNum, + bs_volume_name: bdevName, bs_volume_uuid: Platform.getUuid(), numa_node_ids: [] + ] + def bdevStatus = [id: bdevId] + def bdevItem = [spec: bdevSpec, status: bdevStatus] + bdcBdevs.put(bdevId, bdevItem) + + return makeItemResponse(bdevItem) + } else { + // 23. QueryBdcBdevRequest + def qParam = req.getParameter("q") + def allItems = bdcBdevs.values().collect { bdev -> + [ + metadata: [id: bdev.spec.id, name: bdev.spec.name, state: [state: "active"]], + spec: bdev.spec, + status: bdev.status + ] + } + def filtered = filterItems(allItems, qParam) + return makeQueryResponse(filtered) + } + } + + // 24 & 25. /afa/v1/bdc-bdevs/{id} - GET get, DELETE delete + simulator("/afa/v1/bdc-bdevs/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int bdevId = extractIdFromUri(req.getRequestURI()) + if (req.getMethod() == "DELETE") { + // 25. DeleteBdcBdevRequest + bdcBdevs.remove(bdevId) + return makeDeleteResponse() + } else { + // 24. GetBdcBdevRequest + def bdev = bdcBdevs.get(bdevId) + if (bdev == null) { + return makeNotFoundResponse() + } + return makeItemResponse(bdev) + } + } + + // ========== AFA Category: iSCSI ========== + + // 26. GET /afa/v1/iscsi-gateways - QueryIscsiGatewayRequest + simulator("/afa/v1/iscsi-gateways") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def gwItems = [ + [ + metadata: [id: ISCSI_GATEWAY_ID, name: "iscsi-gw-1", state: [state: "active"]], + spec: [id: ISCSI_GATEWAY_ID, name: "iscsi-gw-1", node_id: 1, ips: ["127.0.0.1"], port: 3260], + status: [id: ISCSI_GATEWAY_ID, node_state: "ACTIVE"] + ] + ] + return makeQueryResponse(gwItems) + } + + // 27 & 29. /afa/v1/iscsi-clients - GET query, POST create + simulator("/afa/v1/iscsi-clients") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + if (req.getMethod() == "POST") { + // 29. CreateIscsiClientRequest + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + int clientId = iscsiClientCounter.incrementAndGet() + String clientName = specData?.name ?: "iscsi-client-${clientId}" + String code = specData?.code ?: "iqn.2000-01.com.example:client-${clientId}" + Integer clientGroupId = specData?.iscsi_client_group_id + + def clientSpec = [ + id: clientId, name: clientName, code: code, + iscsi_client_group_id: clientGroupId + ] + def clientStatus = [id: clientId, target_iqns: []] + def clientItem = [spec: clientSpec, status: clientStatus] + iscsiClients.put(clientId, clientItem) + + return makeItemResponse(clientItem) + } else { + // 27. QueryIscsiClientRequest + def qParam = req.getParameter("q") + def allItems = iscsiClients.values().collect { client -> + [ + metadata: [id: client.spec.id, name: client.spec.name, state: [state: "active"]], + spec: client.spec, + status: client.status + ] + } + def filtered = filterItems(allItems, qParam) + return makeQueryResponse(filtered) + } + } + + // 28 & 30. /afa/v1/iscsi-clients/{id} - GET get, DELETE delete + simulator("/afa/v1/iscsi-clients/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int clientId = extractIdFromUri(req.getRequestURI()) + if (req.getMethod() == "DELETE") { + // 30. DeleteIscsiClientRequest + iscsiClients.remove(clientId) + return makeDeleteResponse() + } else { + // 28. GetIscsiClientRequest + def client = iscsiClients.get(clientId) + if (client == null) { + return makeNotFoundResponse() + } + return makeItemResponse(client) + } + } + + // 31 & 33. /afa/v1/iscsi-client-groups - GET query, POST create + simulator("/afa/v1/iscsi-client-groups") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + if (req.getMethod() == "POST") { + // 33. CreateIscsiClientGroupRequest + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + int groupId = iscsiClientGroupCounter.incrementAndGet() + String groupName = specData?.name ?: "iscsi-client-group-${groupId}" + + def groupSpec = [id: groupId, name: groupName] + def groupStatus = [id: groupId] + def groupItem = [spec: groupSpec, status: groupStatus] + iscsiClientGroups.put(groupId, groupItem) + + return makeItemResponse(groupItem) + } else { + // 31. QueryIscsiClientGroupRequest + def qParam = req.getParameter("q") + def allItems = iscsiClientGroups.values().collect { group -> + [ + metadata: [id: group.spec.id, name: group.spec.name, state: [state: "active"]], + spec: group.spec, + status: group.status + ] + } + def filtered = filterItems(allItems, qParam) + return makeQueryResponse(filtered) + } + } + + // 32. GET /afa/v1/iscsi-client-groups/{id} - GetIscsiClientGroupRequest + simulator("/afa/v1/iscsi-client-groups/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int groupId = extractIdFromUri(req.getRequestURI()) + def group = iscsiClientGroups.get(groupId) + if (group == null) { + return makeNotFoundResponse() + } + return makeItemResponse(group) + } + + // 34. GET /afa/v1/iscsi-gateway-client-group-mappings - QueryIscsiGatewayClientGroupMappingRequest + simulator("/afa/v1/iscsi-gateway-client-group-mappings") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + // Return mappings based on existing client groups + def mappingItems = iscsiClientGroups.values().collect { group -> + [ + metadata: [id: group.spec.id, name: "gw-group-mapping-${group.spec.id}", state: [state: "active"]], + spec: [id: group.spec.id, iscsi_gateway_id: ISCSI_GATEWAY_ID, iscsi_client_group_id: group.spec.id], + status: [id: group.spec.id] + ] + } + return makeQueryResponse(mappingItems) + } + + // 35 & 37. /afa/v1/bs-volume-client-group-mappings - GET query (35), also handle DELETE for {id} (37) + simulator("/afa/v1/bs-volume-client-group-mappings") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + // 35. QueryVolumeClientGroupMappingRequest + def qParam = req.getParameter("q") + def allItems = volumeClientGroupMappings.values().collect { mapping -> + [ + metadata: [id: mapping.spec.id, name: "vol-group-mapping-${mapping.spec.id}", state: [state: "active"]], + spec: mapping.spec, + status: mapping.status + ] + } + def filtered = filterItems(allItems, qParam) + return makeQueryResponse(filtered) + } + + // 36 & 37. /afa/v1/bs-volume-client-group-mappings/{id} - GET get (36), DELETE delete (37) + simulator("/afa/v1/bs-volume-client-group-mappings/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int mappingId = extractIdFromUri(req.getRequestURI()) + if (req.getMethod() == "DELETE") { + // 37. DeleteVolumeClientGroupMappingRequest + volumeClientGroupMappings.remove(mappingId) + return makeDeleteResponse() + } else { + // 36. GetVolumeClientGroupMappingRequest + def mapping = volumeClientGroupMappings.get(mappingId) + if (mapping == null) { + return makeNotFoundResponse() + } + return makeItemResponse(mapping) + } + } + + // 38. POST /afa/v1/bs-volumes/{id}/:add-client-group-mappings - AddVolumeClientGroupMappingRequest + simulator("/afa/v1/bs-volumes/\\d+/:add-client-group-mappings") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + int volId = extractIdFromUri(req.getRequestURI()) + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def specData = body?.spec ?: body + def groupIds = specData?.iscsi_client_group_ids ?: [] + + def createdMappings = [] + for (gid in groupIds) { + int mappingId = volumeClientGroupMappingCounter.incrementAndGet() + int groupId = gid instanceof Integer ? gid : Integer.parseInt(gid.toString()) + def mappingSpec = [ + id: mappingId, bs_volume_id: volId, iscsi_client_group_id: groupId, lun_id: mappingId + ] + def mappingStatus = [id: mappingId] + def mappingItem = [spec: mappingSpec, status: mappingStatus] + volumeClientGroupMappings.put(mappingId, mappingItem) + createdMappings.add([ + metadata: [id: mappingId, name: "vol-group-mapping-${mappingId}", state: [state: "active"]], + spec: mappingSpec, + status: mappingStatus + ]) + } + + return makeQueryResponse(createdMappings) + } + + // 39. GET /afa/v1/bs-volume-client-mappings - QueryVolumeClientMappingRequest + simulator("/afa/v1/bs-volume-client-mappings") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + // Build volume-client mappings from volume-client-group mappings and iscsi clients + def mappingItems = [] + int mappingIdSeq = 0 + volumeClientGroupMappings.values().each { vcgMapping -> + int volId = vcgMapping.spec.bs_volume_id + int groupId = vcgMapping.spec.iscsi_client_group_id + // Find clients in this group + iscsiClients.values().each { client -> + if (client.spec.iscsi_client_group_id == groupId) { + mappingIdSeq++ + mappingItems.add([ + metadata: [id: mappingIdSeq, name: "vol-client-mapping-${mappingIdSeq}", state: [state: "active"]], + spec: [ + id: mappingIdSeq, bs_volume_id: volId, + iscsi_client_id: client.spec.id, + iscsi_client_group_id: groupId, + protocol: "iSCSI", lun_id: vcgMapping.spec.lun_id + ], + status: [id: mappingIdSeq] + ]) + } + } + } + + def qParam = req.getParameter("q") + def filtered = filterItems(mappingItems, qParam) + return makeQueryResponse(filtered) + } + } + } + @Override SpecID create(String uuid, String sessionId) { inventory = addExternalPrimaryStorage { diff --git a/testlib/src/main/java/org/zstack/testlib/SpringSpec.groovy b/testlib/src/main/java/org/zstack/testlib/SpringSpec.groovy index 6346b1cc68d..a8e6812c992 100755 --- a/testlib/src/main/java/org/zstack/testlib/SpringSpec.groovy +++ b/testlib/src/main/java/org/zstack/testlib/SpringSpec.groovy @@ -105,6 +105,10 @@ class SpringSpec { include("iscsi.xml") } + void xinfini() { + include("xinfini.xml") + } + void zbs() { include("zbs.xml") include("cbd.xml")