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")