From 35477913e7f3be105f1226b2ed821835233f7c2e Mon Sep 17 00:00:00 2001 From: "shan.wu" Date: Mon, 22 Dec 2025 18:07:28 +0800 Subject: [PATCH 01/85] [migration]: fix failed to start vm after ceph to ceph offline migration. support starting vm without nic in other clusters. Resolves/Related: ZSTAC-80468 Change-Id: I6370646f7a796265677a6a656c716f6867706d69 --- compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java | 1 + 1 file changed, 1 insertion(+) diff --git a/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java b/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java index 1c4834f9379..9aacae63661 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java @@ -928,6 +928,7 @@ private void getStartingCandidateHosts(final NeedReplyMessage msg, final ReturnV amsg.setL3NetworkUuids(VmNicHelper.getL3Uuids(VmNicInventory.valueOf(self.getVmNics()))); amsg.setDryRun(true); amsg.setListAllHosts(true); + amsg.setAllowNoL3Networks(true); bus.send(amsg, new CloudBusCallBack(completion) { @Override From 6b4521b38a688cf6072d6fc849171022af1e1490 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Feb 2026 12:02:19 +0800 Subject: [PATCH 02/85] [vm]: use max of virtual and actual size for root disk when no disk offering Resolves: ZSTAC-74683 Change-Id: Id0339ed0221e92e506f60745cde972cc3ee6d9ae --- header/src/main/java/org/zstack/header/vm/VmInstanceSpec.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceSpec.java b/header/src/main/java/org/zstack/header/vm/VmInstanceSpec.java index 7007c592aea..99ee2173b98 100755 --- a/header/src/main/java/org/zstack/header/vm/VmInstanceSpec.java +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceSpec.java @@ -847,7 +847,9 @@ public void setBootMode(String bootMode) { public long getRootDiskAllocateSize() { if (rootDiskOffering == null) { - return this.getImageSpec().getInventory().getSize(); + long virtualSize = this.getImageSpec().getInventory().getSize(); + long actualSize = this.getImageSpec().getInventory().getActualSize(); + return Math.max(virtualSize, actualSize); } return rootDiskOffering.getDiskSize(); } From 3b5bda3b76aef968a911d18e35b3b30bd0cab803 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Feb 2026 13:52:13 +0800 Subject: [PATCH 03/85] [zbs]: enable tryNext and 30s timeout for getActiveClients MDS call When anti-split-brain check selects a disconnected MDS node, the HTTP call now times out after 30s instead of 5+ minutes, and automatically retries the next available MDS via tryNext mechanism. Resolves: ZSTAC-80595 Change-Id: I1be80f1b70cad1606eb38d1f0078c8f2781e6941 --- .../org/zstack/storage/zbs/ZbsStorageController.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java b/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java index db06239acb3..276ab367ba1 100644 --- a/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java +++ b/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java @@ -179,7 +179,10 @@ public List getActiveClients(String installPath, String prot if (VolumeProtocol.CBD.toString().equals(protocol)) { GetVolumeClientsCmd cmd = new GetVolumeClientsCmd(); cmd.setPath(installPath); - GetVolumeClientsRsp rsp = syncHttpCall(GET_VOLUME_CLIENTS_PATH, cmd, GetVolumeClientsRsp.class); + GetVolumeClientsRsp rsp = new HttpCaller<>(GET_VOLUME_CLIENTS_PATH, cmd, GetVolumeClientsRsp.class, + null, TimeUnit.SECONDS, 30, true) + .setTryNext(true) + .syncCall(); List clients = new ArrayList<>(); if (!rsp.isSuccess()) { @@ -1411,6 +1414,11 @@ public class HttpCaller { private boolean tryNext = false; + HttpCaller setTryNext(boolean tryNext) { + this.tryNext = tryNext; + return this; + } + public HttpCaller(String path, AgentCommand cmd, Class retClass, ReturnValueCompletion callback) { this(path, cmd, retClass, callback, null, 0, false); } From 80df074f8dd1140b278ce0979f2068d5c271d8e5 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Feb 2026 14:22:40 +0800 Subject: [PATCH 04/85] [vm]: add Destroying->Stopped state transition When MN restarts during a destroy operation, the hypervisor may report the VM as Stopped. Without this transition, the state machine throws an exception and the VM stays stuck in Destroying state forever. Resolves: ZSTAC-80620 Change-Id: I037edba70d145a44a88ce0d3573089182fedb162 --- header/src/main/java/org/zstack/header/vm/VmInstanceState.java | 1 + 1 file changed, 1 insertion(+) diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceState.java b/header/src/main/java/org/zstack/header/vm/VmInstanceState.java index 8a755b52fda..49303e23252 100755 --- a/header/src/main/java/org/zstack/header/vm/VmInstanceState.java +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceState.java @@ -168,6 +168,7 @@ public enum VmInstanceState { new Transaction(VmInstanceStateEvent.destroyed, VmInstanceState.Destroyed), new Transaction(VmInstanceStateEvent.destroying, VmInstanceState.Destroying), new Transaction(VmInstanceStateEvent.running, VmInstanceState.Running), + new Transaction(VmInstanceStateEvent.stopped, VmInstanceState.Stopped), new Transaction(VmInstanceStateEvent.expunging, VmInstanceState.Expunging) ); Destroyed.transactions( From a84a36e2515e6b6bdc69f80420e62364e0832a90 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Feb 2026 16:12:49 +0800 Subject: [PATCH 05/85] [ceph]: apply over-provisioning ratio when releasing snapshot capacity Resolves: ZSTAC-79709 Change-Id: I45a2133bbb8c51c25ae3549d59e588976192a08d --- .../org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java index d80b40a1d6a..8b387306683 100755 --- a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java +++ b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java @@ -5446,7 +5446,7 @@ private void deleteSnapshotOnPrimaryStorage(final DeleteSnapshotOnPrimaryStorage httpCall(DELETE_SNAPSHOT_PATH, cmd, DeleteSnapshotRsp.class, new ReturnValueCompletion(msg) { @Override public void success(DeleteSnapshotRsp returnValue) { - osdHelper.releaseAvailableCapacity(msg.getSnapshot().getPrimaryStorageInstallPath(), msg.getSnapshot().getSize()); + osdHelper.releaseAvailableCapWithRatio(msg.getSnapshot().getPrimaryStorageInstallPath(), msg.getSnapshot().getSize()); bus.reply(msg, reply); completion.done(); } From f19223a6e72678f20610ae04226fc20c0cf1bb5b Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 13 Feb 2026 12:40:04 +0800 Subject: [PATCH 06/85] [loadBalancer]: block SLB deletion during grayscale upgrade Resolves: ZSTAC-78989 Change-Id: I0fe3a56ab724978944c69afadaab7ff7353e4c0f --- .../service/lb/LoadBalancerApiInterceptor.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java index 97b88c919c2..0d9946d5320 100755 --- a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java +++ b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java @@ -39,6 +39,7 @@ import org.zstack.network.service.vip.VipVO_; import org.zstack.tag.PatternedSystemTag; import org.zstack.tag.TagManager; +import org.zstack.core.upgrade.UpgradeGlobalConfig; import org.zstack.utils.*; import org.zstack.utils.function.ForEachFunction; import org.zstack.utils.logging.CLogger; @@ -152,10 +153,22 @@ public APIMessage intercept(APIMessage msg) throws ApiMessageInterceptionExcepti validate((APIGetCandidateVmNicsForLoadBalancerServerGroupMsg)msg); } else if (msg instanceof APIChangeLoadBalancerBackendServerMsg) { validate((APIChangeLoadBalancerBackendServerMsg)msg); + } else if (msg instanceof APIDeleteLoadBalancerMsg) { + validate((APIDeleteLoadBalancerMsg) msg); } return msg; } + private void validate(APIDeleteLoadBalancerMsg msg) { + if (UpgradeGlobalConfig.GRAYSCALE_UPGRADE.value(Boolean.class)) { + LoadBalancerVO lb = dbf.findByUuid(msg.getUuid(), LoadBalancerVO.class); + if (lb != null && lb.getType() == LoadBalancerType.SLB) { + throw new ApiMessageInterceptionException(argerr( + "cannot delete the standalone load balancer[uuid:%s] during grayscale upgrade", msg.getUuid())); + } + } + } + private void validate(APIDeleteAccessControlListMsg msg) { /*List refs = Q.New(LoadBalancerListenerACLRefVO.class).select(LoadBalancerListenerACLRefVO_.listenerUuid) .eq(LoadBalancerListenerACLRefVO_.aclUuid, msg.getUuid()).isNull(LoadBalancerListenerACLRefVO_.serverGroupUuid).listValues(); From 24d4f3b4870ea72fce77bcf64980d0b70b868502 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 13 Feb 2026 13:32:45 +0800 Subject: [PATCH 07/85] [i18n]: improve snapshot error message for unattached volume Resolves: ZSTAC-82153 Change-Id: Ib51c2e21553277416d1a9444be55aca2aa4b2fc4 --- conf/i18n/globalErrorCodeMapping/global-error-en_US.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json index 32eb4c8f056..715e823d95e 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json @@ -3374,7 +3374,7 @@ "ORG_ZSTACK_NETWORK_HUAWEI_IMASTER_10019": "delete token of SDN controller [IP:%s] failed because %s", "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10004": "Cannot execute volume mapping to host flow due to invalid volume ID.%s", "ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10007": "port forwarding rule [uuid:%s] has not been attached to any virtual machine network interface, cannot detach", - "ORG_ZSTACK_MEVOCO_10088": "cannot take a snapshot for volumes[%s] when volume[uuid: %s] is not attached", + "ORG_ZSTACK_MEVOCO_10088": "cannot create snapshot for volume[uuid:%s] because it is not attached to any VM instance. Please attach the volume to a VM first. Affected volumes: %s", "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10005": "Cannot execute map LUN to host flow due to invalid LUN type: %s", "ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10008": "port forwarding rule [uuid:%s] has been associated with vm nic [uuid:%s], cannot be reassigned again", "ORG_ZSTACK_MEVOCO_10087": "A Running VM[uuid:%s] has no associated Host UUID.", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json index 84609838ddc..01960e8eb45 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json @@ -3374,7 +3374,7 @@ "ORG_ZSTACK_NETWORK_HUAWEI_IMASTER_10019": "删除 SDN 控制器 [IP:%s] 的令牌失败,因为 %s", "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10004": "无法执行映射LUN到主机流程,无效的LUN ID", "ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10007": "端口转发规则 rule[uuid:%s] 没有绑定到任何 VM 的网卡上,无法解除绑定", - "ORG_ZSTACK_MEVOCO_10088": "无法为挂载状态以外的卷[%s]创建快照", + "ORG_ZSTACK_MEVOCO_10088": "无法为云盘[uuid:%s]创建快照,因为该云盘未挂载到任何云主机。请先将云盘挂载到云主机后再创建快照。相关云盘: %s", "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10005": "无法执行映射LUN到主机流程,无效的LUN类型", "ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10008": "端口转发规则[uuid:%s]已绑定到VM网卡[uuid:%s],无法再次绑定", "ORG_ZSTACK_MEVOCO_10087": "如何一个运行中的VM[uuid:%s]没有宿主机uuid?", From f563992d30c2ab9484acc944e6148a5fc1f39f18 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 13 Feb 2026 15:09:15 +0800 Subject: [PATCH 08/85] [compute]: add null check for VmNicVO in afterDelIpAddress and afterAddIpAddress to prevent NPE during rollback Resolves: ZSTAC-81741 Change-Id: I53bcf20a10306afc7b6172da294d347b74e6c41f --- .../main/java/org/zstack/compute/vm/VmNicManagerImpl.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/compute/src/main/java/org/zstack/compute/vm/VmNicManagerImpl.java b/compute/src/main/java/org/zstack/compute/vm/VmNicManagerImpl.java index 25c0b005d71..31b3e35d32a 100644 --- a/compute/src/main/java/org/zstack/compute/vm/VmNicManagerImpl.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmNicManagerImpl.java @@ -58,6 +58,10 @@ public void afterAddIpAddress(String vmNicUUid, String usedIpUuid) { SQL.New(UsedIpVO.class).eq(UsedIpVO_.uuid, usedIpUuid).set(UsedIpVO_.vmNicUuid, vmNicUUid).update(); VmNicVO nic = Q.New(VmNicVO.class).eq(VmNicVO_.uuid, vmNicUUid).find(); + if (nic == null) { + logger.debug(String.format("VmNic[uuid:%s] not found, skip afterAddIpAddress", vmNicUUid)); + return; + } UsedIpVO temp = null; /* if there is ipv4 addresses, we put the first attached ipv4 address to VmNic.ip @@ -88,6 +92,10 @@ public void afterAddIpAddress(String vmNicUUid, String usedIpUuid) { @Override public void afterDelIpAddress(String vmNicUUid, String usedIpUuid) { VmNicVO nic = Q.New(VmNicVO.class).eq(VmNicVO_.uuid, vmNicUUid).find(); + if (nic == null) { + logger.debug(String.format("VmNic[uuid:%s] not found, skip afterDelIpAddress", vmNicUUid)); + return; + } if (nic.getUsedIpUuid() != null && !nic.getUsedIpUuid().equals(usedIpUuid)) { return; } From 65453500d7614d0ebdb86bf5c601dcab08f360a0 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 13 Feb 2026 15:21:44 +0800 Subject: [PATCH 09/85] [network]: filter reserved IPs from GetFreeIp API results Resolves: ZSTAC-81182 Change-Id: Id1bb642154dc66ae9995dcc4d9fc00cdce9bcaf8 --- .../main/java/org/zstack/network/l3/L3BasicNetwork.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java b/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java index 5536a5fc487..b1b0b92d497 100755 --- a/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java +++ b/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java @@ -1075,6 +1075,13 @@ private void handle(APIGetFreeIpMsg msg) { } limit -= freeIpInventorys.size(); } + + Set reservedIpRanges = self.getReservedIpRanges(); + if (reservedIpRanges != null && !reservedIpRanges.isEmpty()) { + freeIpInventorys.removeIf(freeIp -> reservedIpRanges.stream().anyMatch( + r -> NetworkUtils.isInRange(freeIp.getIp(), r.getStartIp(), r.getEndIp()))); + } + reply.setInventories(freeIpInventorys); bus.reply(msg, reply); From 26b8b1a82959fd616637cf63cf6a46b725872173 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Feb 2026 13:17:20 +0800 Subject: [PATCH 10/85] [mn]: synchronize hash ring operations to prevent dual-MN task stalling In dual management node scenarios, concurrent modifications to the consistent hash ring from heartbeat reconciliation and canonical event callbacks can cause NodeHash/Nodes inconsistency, leading to message routing failures and task timeouts. Fix: (1) synchronized all ResourceDestinationMakerImpl methods to ensure atomic nodeHash+nodes updates, (2) added lifecycleLock in ManagementNodeManagerImpl to serialize heartbeat reconciliation with event callbacks, (3) added two-round delayed confirmation before removing nodes from hash ring to avoid race with NodeJoin events. Resolves: ZSTAC-77711 Change-Id: I3d33d53595dd302784dff17417a5b25f2d0f3426 --- .../ResourceDestinationMakerImpl.java | 28 ++--- .../ManagementNodeManagerImpl.java | 101 ++++++++++++------ 2 files changed, 82 insertions(+), 47 deletions(-) diff --git a/core/src/main/java/org/zstack/core/cloudbus/ResourceDestinationMakerImpl.java b/core/src/main/java/org/zstack/core/cloudbus/ResourceDestinationMakerImpl.java index 08a776f1db2..9ead578395d 100755 --- a/core/src/main/java/org/zstack/core/cloudbus/ResourceDestinationMakerImpl.java +++ b/core/src/main/java/org/zstack/core/cloudbus/ResourceDestinationMakerImpl.java @@ -27,27 +27,27 @@ public class ResourceDestinationMakerImpl implements ManagementNodeChangeListene private DatabaseFacade dbf; @Override - public void nodeJoin(ManagementNodeInventory inv) { + public synchronized void nodeJoin(ManagementNodeInventory inv) { nodeHash.add(inv.getUuid()); nodes.put(inv.getUuid(), new NodeInfo(inv)); } @Override - public void nodeLeft(ManagementNodeInventory inv) { + public synchronized void nodeLeft(ManagementNodeInventory inv) { String nodeId = inv.getUuid(); nodeHash.remove(nodeId); nodes.remove(nodeId); } @Override - public void iAmDead(ManagementNodeInventory inv) { + public synchronized void iAmDead(ManagementNodeInventory inv) { String nodeId = inv.getUuid(); nodeHash.remove(nodeId); nodes.remove(nodeId); } @Override - public void iJoin(ManagementNodeInventory inv) { + public synchronized void iJoin(ManagementNodeInventory inv) { List lst = Q.New(ManagementNodeVO.class).list(); lst.forEach((ManagementNodeVO node) -> { nodeHash.add(node.getUuid()); @@ -56,7 +56,7 @@ public void iJoin(ManagementNodeInventory inv) { } @Override - public String makeDestination(String resourceUuid) { + public synchronized String makeDestination(String resourceUuid) { String nodeUuid = nodeHash.get(resourceUuid); if (nodeUuid == null) { throw new CloudRuntimeException("Cannot find any available management node to send message"); @@ -66,18 +66,18 @@ public String makeDestination(String resourceUuid) { } @Override - public boolean isManagedByUs(String resourceUuid) { + public synchronized boolean isManagedByUs(String resourceUuid) { String nodeUuid = makeDestination(resourceUuid); return nodeUuid.equals(Platform.getManagementServerId()); } @Override - public Collection getManagementNodesInHashRing() { - return nodeHash.getNodes(); + public synchronized Collection getManagementNodesInHashRing() { + return new ArrayList<>(nodeHash.getNodes()); } @Override - public NodeInfo getNodeInfo(String nodeUuid) { + public synchronized NodeInfo getNodeInfo(String nodeUuid) { NodeInfo info = nodes.get(nodeUuid); if (info == null) { ManagementNodeVO vo = dbf.findByUuid(nodeUuid, ManagementNodeVO.class); @@ -93,17 +93,17 @@ public NodeInfo getNodeInfo(String nodeUuid) { } @Override - public Collection getAllNodeInfo() { - return nodes.values(); + public synchronized Collection getAllNodeInfo() { + return new ArrayList<>(nodes.values()); } @Override - public int getManagementNodeCount() { - return nodes.values().size(); + public synchronized int getManagementNodeCount() { + return nodes.size(); } - public boolean isNodeInCircle(String nodeId) { + public synchronized boolean isNodeInCircle(String nodeId) { return nodeHash.hasNode(nodeId); } } diff --git a/portal/src/main/java/org/zstack/portal/managementnode/ManagementNodeManagerImpl.java b/portal/src/main/java/org/zstack/portal/managementnode/ManagementNodeManagerImpl.java index a945ab77274..4ece718ff52 100755 --- a/portal/src/main/java/org/zstack/portal/managementnode/ManagementNodeManagerImpl.java +++ b/portal/src/main/java/org/zstack/portal/managementnode/ManagementNodeManagerImpl.java @@ -74,6 +74,7 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -107,6 +108,15 @@ public class ManagementNodeManagerImpl extends AbstractService implements Manage // A dictionary (nodeId -> ManagementNodeInventory) of joined management Node final private Map joinedManagementNodes = new ConcurrentHashMap<>(); + // Lock to serialize lifecycle events from heartbeat reconciliation and canonical event callbacks, + // preventing race conditions where a nodeJoin event is immediately followed by a stale nodeLeft + // from the heartbeat thread, or vice versa. See ZSTAC-77711. + private final Object lifecycleLock = new Object(); + + // Track nodes found in hash ring but missing from DB. Only call nodeLeft after a node + // is missing for two consecutive heartbeat cycles, to avoid removing nodes that just joined. + private final Set suspectedMissingFromDb = new HashSet<>(); + private static int NODE_STARTING = 0; private static int NODE_RUNNING = 1; private static int NODE_FAILED = -1; @@ -368,12 +378,16 @@ protected void run(Map tokens, Object data) { ManagementNodeLifeCycleData d = (ManagementNodeLifeCycleData) data; - if (LifeCycle.NodeJoin.toString().equals(d.getLifeCycle())) { - nodeLifeCycle.nodeJoin(d.getInventory()); - } else if (LifeCycle.NodeLeft.toString().equals(d.getLifeCycle())) { - nodeLifeCycle.nodeLeft(d.getInventory()); - } else { - throw new CloudRuntimeException(String.format("unknown lifecycle[%s]", d.getLifeCycle())); + synchronized (lifecycleLock) { + if (LifeCycle.NodeJoin.toString().equals(d.getLifeCycle())) { + // Clear from suspected set since the node is confirmed alive + suspectedMissingFromDb.remove(d.getInventory().getUuid()); + nodeLifeCycle.nodeJoin(d.getInventory()); + } else if (LifeCycle.NodeLeft.toString().equals(d.getLifeCycle())) { + nodeLifeCycle.nodeLeft(d.getInventory()); + } else { + throw new CloudRuntimeException(String.format("unknown lifecycle[%s]", d.getLifeCycle())); + } } } }; @@ -860,34 +874,55 @@ private void checkAllNodesHealth() { Set nodeUuidsInDb = nodesInDb.stream().map(ManagementNodeVO::getUuid).collect(Collectors.toSet()); - // When a node is dying, we may not receive the the dead notification because the message bus may be also dead - // at that moment. By checking if the node UUID is still in our hash ring, we know what nodes should be kicked out - destinationMaker.getManagementNodesInHashRing().forEach(nodeUuid -> { - if (!nodeUuidsInDb.contains(nodeUuid)) { - logger.warn(String.format("found that a management node[uuid:%s] had no heartbeat in database but still in our hash ring," + - "notify that it's dead", nodeUuid)); - ManagementNodeInventory inv = new ManagementNodeInventory(); - inv.setUuid(nodeUuid); - inv.setHostName(destinationMaker.getNodeInfo(nodeUuid).getNodeIP()); - - nodeLifeCycle.nodeLeft(inv); - } - }); - - // check if any node missing in our hash ring - nodesInDb.forEach(n -> { - if (n.getUuid().equals(node().getUuid()) || suspects.contains(n)) { - return; - } - - new Runnable() { - @Override - @AsyncThread - public void run() { - nodeLifeCycle.nodeJoin(ManagementNodeInventory.valueOf(n)); + // Reconcile hash ring with DB under lifecycleLock to prevent race with + // canonical event callbacks (nodeJoin/nodeLeft). See ZSTAC-77711. + synchronized (lifecycleLock) { + // When a node is dying, we may not receive the dead notification because the message bus may be also dead + // at that moment. By checking if the node UUID is still in our hash ring, we know what nodes should be kicked out. + // Use two-round confirmation: first round marks as suspected, second round actually removes. + Set currentSuspected = new HashSet<>(); + destinationMaker.getManagementNodesInHashRing().forEach(nodeUuid -> { + if (!nodeUuidsInDb.contains(nodeUuid)) { + if (suspectedMissingFromDb.contains(nodeUuid)) { + // Second consecutive detection — confirmed missing, remove from hash ring + logger.warn(String.format("management node[uuid:%s] confirmed missing from database for two consecutive" + + " heartbeat cycles, removing from hash ring", nodeUuid)); + ManagementNodeInventory inv = new ManagementNodeInventory(); + inv.setUuid(nodeUuid); + try { + inv.setHostName(destinationMaker.getNodeInfo(nodeUuid).getNodeIP()); + } catch (Exception e) { + logger.warn(String.format("cannot get node info for node[uuid:%s], use empty hostname", nodeUuid)); + } + + nodeLifeCycle.nodeLeft(inv); + } else { + // First detection — mark as suspected, defer removal to next cycle + logger.warn(String.format("management node[uuid:%s] not found in database but still in hash ring," + + " marking as suspected (will remove on next heartbeat if still missing)", nodeUuid)); + currentSuspected.add(nodeUuid); + } } - }.run(); - }); + }); + // Update suspected set: only keep nodes that are newly suspected this round + suspectedMissingFromDb.clear(); + suspectedMissingFromDb.addAll(currentSuspected); + + // check if any node missing in our hash ring + nodesInDb.forEach(n -> { + if (n.getUuid().equals(node().getUuid()) || suspects.contains(n)) { + return; + } + + new Runnable() { + @Override + @AsyncThread + public void run() { + nodeLifeCycle.nodeJoin(ManagementNodeInventory.valueOf(n)); + } + }.run(); + }); + } } @Override From aaeaf39c323da0013c01cab8ff58d0fbb3163c9e Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 13 Feb 2026 19:25:07 +0800 Subject: [PATCH 11/85] [storage]: desensitize mdsUrls in ExternalPrimaryStorageInventory The mdsUrls field in ExternalPrimaryStorage config contains user:password@host format credentials. Add desensitization to mask credentials as ***@host in API/CLI output. Resolves: ZSTAC-80664 Change-Id: I94bdede5a1b52eb039de70efb5458693484405f7 --- .../ExternalPrimaryStorageInventory.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/header/src/main/java/org/zstack/header/storage/addon/primary/ExternalPrimaryStorageInventory.java b/header/src/main/java/org/zstack/header/storage/addon/primary/ExternalPrimaryStorageInventory.java index 7808c227623..a15ed211307 100644 --- a/header/src/main/java/org/zstack/header/storage/addon/primary/ExternalPrimaryStorageInventory.java +++ b/header/src/main/java/org/zstack/header/storage/addon/primary/ExternalPrimaryStorageInventory.java @@ -4,8 +4,10 @@ import org.zstack.header.storage.primary.PrimaryStorageInventory; import org.zstack.utils.gson.JSONObjectUtil; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Inventory(mappingVOClass = ExternalPrimaryStorageVO.class) @@ -59,6 +61,7 @@ public ExternalPrimaryStorageInventory(ExternalPrimaryStorageVO lvo) { super(lvo); identity = lvo.getIdentity(); config = JSONObjectUtil.toObject(lvo.getConfig(), LinkedHashMap.class); + desensitizeConfig(config); addonInfo = JSONObjectUtil.toObject(lvo.getAddonInfo(), LinkedHashMap.class); outputProtocols = lvo.getOutputProtocols().stream().map(PrimaryStorageOutputProtocolRefVO::getOutputProtocol).collect(Collectors.toList()); defaultProtocol = lvo.getDefaultProtocol(); @@ -68,6 +71,35 @@ public static ExternalPrimaryStorageInventory valueOf(ExternalPrimaryStorageVO l return new ExternalPrimaryStorageInventory(lvo); } + private static void desensitizeConfig(Map config) { + if (config == null) return; + desensitizeUrlList(config, "mdsUrls"); + desensitizeUrlList(config, "mdsInfos"); + } + + private static void desensitizeUrlList(Map config, String key) { + Object urls = config.get(key); + if (urls instanceof List) { + List desensitized = new ArrayList<>(); + for (Object url : (List) urls) { + desensitized.add(desensitizeUrl(String.valueOf(url))); + } + config.put(key, desensitized); + } + } + + private static String desensitizeUrl(String url) { + int atIndex = url.lastIndexOf('@'); + if (atIndex > 0) { + int schemeIndex = url.indexOf("://"); + if (schemeIndex >= 0 && schemeIndex < atIndex) { + return url.substring(0, schemeIndex + 3) + "***" + url.substring(atIndex); + } + return "***" + url.substring(atIndex); + } + return url; + } + public String getIdentity() { return identity; } From f41558d0f404210562a7e13e05a64b928932cb4f Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 16 Feb 2026 17:36:51 +0800 Subject: [PATCH 12/85] [volumebackup]: add backup cancel timeout error code Add ORG_ZSTACK_STORAGE_BACKUP_CANCEL_TIMEOUT constant to CloudOperationsErrorCode for use in premium volumebackup module. Resolves: ZSTAC-82195 Change-Id: Ibc405876e1171b637cf76b91a6822574fb6e7811 --- .../zstack/utils/clouderrorcode/CloudOperationsErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index a0f09d4f1e9..a550fb7d673 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -6274,6 +6274,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_STORAGE_BACKUP_10133 = "ORG_ZSTACK_STORAGE_BACKUP_10133"; + public static final String ORG_ZSTACK_STORAGE_BACKUP_CANCEL_TIMEOUT = "ORG_ZSTACK_STORAGE_BACKUP_CANCEL_TIMEOUT"; + public static final String ORG_ZSTACK_COMPUTE_10000 = "ORG_ZSTACK_COMPUTE_10000"; public static final String ORG_ZSTACK_COMPUTE_10001 = "ORG_ZSTACK_COMPUTE_10001"; From 7f53f5a5ae1064721fba6ad1c4ab914b5862d3e7 Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Mon, 16 Feb 2026 21:25:44 +0800 Subject: [PATCH 13/85] [thread]: guard Context.current() with telemetry check SyncTaskFuture constructor calls Context.current() unconditionally, triggering ServiceLoader for ContextStorageProvider even when telemetry is disabled. If sentry-opentelemetry-bootstrap jar is on classpath, ServiceLoader fails with "not a subtype" due to ClassLoader isolation in Tomcat, throwing ServiceConfigurationError (extends Error) that escapes all catch(Exception) blocks. 1. Why is this change necessary? MN startup crashes with ORG_ZSTACK_CORE_WORKFLOW_10001 because Context.current() triggers ServiceLoader unconditionally in SyncTaskFuture constructor, even when telemetry is disabled. 2. How does it address the problem? Only call Context.current() when isTelemetryEnabled() returns true, matching the existing guard pattern used in other DispatchQueueImpl code paths (lines 351, 1069). 3. Are there any side effects? None. When telemetry is disabled, parentContext was never used. # Summary of changes (by module): - core/thread: conditionalize Context.current() in SyncTaskFuture Related: ZSTAC-82275 Change-Id: I5c0e1f15769c746c630028a29df8cf1815620608 --- .../src/main/java/org/zstack/core/thread/DispatchQueueImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/org/zstack/core/thread/DispatchQueueImpl.java b/core/src/main/java/org/zstack/core/thread/DispatchQueueImpl.java index e298cdd787f..961b192ac7c 100755 --- a/core/src/main/java/org/zstack/core/thread/DispatchQueueImpl.java +++ b/core/src/main/java/org/zstack/core/thread/DispatchQueueImpl.java @@ -302,7 +302,7 @@ private class SyncTaskFuture extends AbstractFuture { public SyncTaskFuture(SyncTask task) { super(task); - this.parentContext = Context.current(); + this.parentContext = isTelemetryEnabled() ? Context.current() : null; } private SyncTask getTask() { From a9a399401e49d9d24b8c6f6e9d3ab9e8f464633b Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 16 Feb 2026 22:37:46 +0800 Subject: [PATCH 14/85] [volumebackup]: add backup cancel timeout error code Resolves: ZSTAC-82195 Change-Id: I3d5e91d09d7c088d3c53e3839f8b32f4bce32dec --- .../zstack/utils/clouderrorcode/CloudOperationsErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index a0f09d4f1e9..a550fb7d673 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -6274,6 +6274,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_STORAGE_BACKUP_10133 = "ORG_ZSTACK_STORAGE_BACKUP_10133"; + public static final String ORG_ZSTACK_STORAGE_BACKUP_CANCEL_TIMEOUT = "ORG_ZSTACK_STORAGE_BACKUP_CANCEL_TIMEOUT"; + public static final String ORG_ZSTACK_COMPUTE_10000 = "ORG_ZSTACK_COMPUTE_10000"; public static final String ORG_ZSTACK_COMPUTE_10001 = "ORG_ZSTACK_COMPUTE_10001"; From 8c8ed735dac1574685f57d2f751ae9b1abae4902 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Feb 2026 13:29:00 +0800 Subject: [PATCH 15/85] [ha]: defer skip-trace list cleanup on MN departure to prevent split-brain When a management node departs, its VM skip-trace entries were immediately removed. If VMs were still being started by kvmagent, the next VM sync would falsely detect them as Stopped and trigger HA, causing split-brain. Fix: transfer departed MN skip-trace entries to an orphaned set with 10-minute TTL instead of immediate deletion. VMs in the orphaned set remain skip-traced until the TTL expires or they are explicitly continued, preventing false HA triggers during MN restart scenarios. Resolves: ZSTAC-80821 Change-Id: I3222e260b2d7b33dc43aba0431ce59a788566b34 --- .../org/zstack/kvm/KvmVmSyncPingTask.java | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java b/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java index b02525b99d2..2e8d3ff9e32 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java @@ -69,6 +69,12 @@ public class KvmVmSyncPingTask extends VmTracer implements KVMPingAgentNoFailure private List skipVmTracerReplies = new ArrayList<>(); private Map vmInShutdownMap = new ConcurrentHashMap<>(); + // Orphaned skip entries from departed MN nodes. Key=vmUuid, Value=timestamp when orphaned. + // These VMs remain in skip-trace state for ORPHAN_TTL_MS to avoid false HA triggers + // when a MN restarts and its in-flight VM operations haven't completed yet. See ZSTAC-80821. + private final ConcurrentHashMap orphanedSkipVms = new ConcurrentHashMap<>(); + private static final long ORPHAN_TTL_MS = 10 * 60 * 1000; // 10 minutes + { getReflections().getTypesAnnotatedWith(SkipVmTracer.class).forEach(clz -> { skipVmTracerMessages.add(clz.asSubclass(Message.class)); @@ -196,8 +202,13 @@ private void syncVm(final HostInventory host, final Completion completion) { // Get vms to skip before send command to host to confirm the vm will be skipped after sync command finished. // The problem is if one vm-sync skipped operation is started and finished during vm sync command's handling // vm state would still be sync to mn + // ZSTAC-80821: clean up expired orphaned entries each sync cycle + cleanupExpiredOrphanedSkipVms(); + Set vmsToSkipSetHostSide = new HashSet<>(); vmsToSkip.values().forEach(vmsToSkipSetHostSide::addAll); + // ZSTAC-80821: also skip VMs from departed MN nodes that are still within TTL + vmsToSkipSetHostSide.addAll(orphanedSkipVms.keySet()); // if the vm is not running on host when sync command executing but started as soon as possible // before response handling of vm sync, mgmtSideStates will including the running vm but not result in @@ -228,6 +239,8 @@ public void run(MessageReply reply) { // Get vms to skip after sync result returned. vmsToSkip.values().forEach(vmsToSkipSetHostSide::addAll); + // ZSTAC-80821: include orphaned entries from departed MN nodes + vmsToSkipSetHostSide.addAll(orphanedSkipVms.keySet()); Collection vmUuidsInDeleteVmGC = DeleteVmGC.queryVmInGC(host.getUuid(), ret.getStates().keySet()); @@ -446,7 +459,19 @@ public void nodeJoin(ManagementNodeInventory inv) { @Override public void nodeLeft(ManagementNodeInventory inv) { vmApis.remove(inv.getUuid()); - vmsToSkip.remove(inv.getUuid()); + + // ZSTAC-80821: Instead of immediately removing skip list entries, move them + // to the orphaned set with a TTL. This prevents false HA triggers for VMs that + // are still being started by kvmagent but whose controlling MN has restarted. + Set skippedVms = vmsToSkip.remove(inv.getUuid()); + if (skippedVms != null && !skippedVms.isEmpty()) { + long now = System.currentTimeMillis(); + for (String vmUuid : skippedVms) { + orphanedSkipVms.put(vmUuid, now); + logger.info(String.format("moved VM[uuid:%s] from departed MN[uuid:%s] skip list to orphaned set" + + " (will expire in %d minutes)", vmUuid, inv.getUuid(), ORPHAN_TTL_MS / 60000)); + } + } } @Override @@ -460,6 +485,41 @@ public void iJoin(ManagementNodeInventory inv) { } public boolean isVmDoNotNeedToTrace(String vmUuid) { - return vmsToSkip.values().stream().anyMatch(vmsToSkipSet -> vmsToSkipSet.contains(vmUuid)); + if (vmsToSkip.values().stream().anyMatch(vmsToSkipSet -> vmsToSkipSet.contains(vmUuid))) { + return true; + } + + // ZSTAC-80821: Also check orphaned skip entries from departed MN nodes + Long orphanedAt = orphanedSkipVms.get(vmUuid); + if (orphanedAt != null) { + if (System.currentTimeMillis() - orphanedAt < ORPHAN_TTL_MS) { + logger.debug(String.format("VM[uuid:%s] is in orphaned skip set, skipping trace", vmUuid)); + return true; + } else { + // Expired, clean up + orphanedSkipVms.remove(vmUuid); + logger.info(String.format("orphaned skip entry for VM[uuid:%s] expired after %d minutes, resuming trace", + vmUuid, ORPHAN_TTL_MS / 60000)); + } + } + + return false; + } + + // Periodically clean up expired orphaned entries. Called from VM sync cycle. + private void cleanupExpiredOrphanedSkipVms() { + if (orphanedSkipVms.isEmpty()) { + return; + } + + long now = System.currentTimeMillis(); + Iterator> it = orphanedSkipVms.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (now - entry.getValue() >= ORPHAN_TTL_MS) { + it.remove(); + logger.info(String.format("cleaned up expired orphaned skip entry for VM[uuid:%s]", entry.getKey())); + } + } } } From 7a6d5d782090fa4e9507dfbc340cc0441344dabe Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 12 Feb 2026 16:47:28 +0800 Subject: [PATCH 16/85] [kvm]: use CAS remove to fix TOCTOU race in orphaned skip VM cleanup Resolves: ZSTAC-80821 Change-Id: I59284c4e69f5d2ee357b1836b7c243200e30949a --- .../src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java b/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java index 2e8d3ff9e32..d1bb9133903 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java @@ -497,7 +497,7 @@ public boolean isVmDoNotNeedToTrace(String vmUuid) { return true; } else { // Expired, clean up - orphanedSkipVms.remove(vmUuid); + orphanedSkipVms.remove(vmUuid, orphanedAt); logger.info(String.format("orphaned skip entry for VM[uuid:%s] expired after %d minutes, resuming trace", vmUuid, ORPHAN_TTL_MS / 60000)); } @@ -513,11 +513,9 @@ private void cleanupExpiredOrphanedSkipVms() { } long now = System.currentTimeMillis(); - Iterator> it = orphanedSkipVms.entrySet().iterator(); - while (it.hasNext()) { - Map.Entry entry = it.next(); + for (Map.Entry entry : orphanedSkipVms.entrySet()) { if (now - entry.getValue() >= ORPHAN_TTL_MS) { - it.remove(); + orphanedSkipVms.remove(entry.getKey(), entry.getValue()); logger.info(String.format("cleaned up expired orphaned skip entry for VM[uuid:%s]", entry.getKey())); } } From 62e3db5b2a9be83682395b14185de8813400673f Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 17 Feb 2026 10:03:25 +0800 Subject: [PATCH 17/85] [zbs]: sync MDS node statuses to DB when reconnect fails Resolves: ZSTAC-77544 Change-Id: I1f711bff9c1e87a8cbf6a2eb310ca6086f0f99ba --- .../storage/zbs/ZbsStorageController.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java b/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java index 276ab367ba1..0d71b507b14 100644 --- a/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java +++ b/plugin/zbs/src/main/java/org/zstack/storage/zbs/ZbsStorageController.java @@ -539,6 +539,7 @@ public void handle(Map data) { error(new FlowErrorHandler(completion) { @Override public void handle(ErrorCode errCode, Map data) { + syncMdsStatuses(newAddonInfo); completion.fail(errCode); } }); @@ -1361,6 +1362,24 @@ public void syncConfig(String config) { this.config = StringUtils.isEmpty(config) ? new Config() : JSONObjectUtil.toObject(config, Config.class); } + private void syncMdsStatuses(AddonInfo newAddonInfo) { + if (addonInfo == null || newAddonInfo == null) { + return; + } + + for (MdsInfo newMds : newAddonInfo.getMdsInfos()) { + for (MdsInfo existMds : addonInfo.getMdsInfos()) { + if (existMds.getAddr().equals(newMds.getAddr())) { + existMds.setStatus(newMds.getStatus()); + } + } + } + + SQL.New(ExternalPrimaryStorageVO.class).eq(ExternalPrimaryStorageVO_.uuid, self.getUuid()) + .set(ExternalPrimaryStorageVO_.addonInfo, JSONObjectUtil.toJsonString(addonInfo)) + .update(); + } + @Deprecated private void reloadDbInfo() { self = dbf.reload(self); From bec46237ed1912a8b420373a225f562df54a3a05 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 16 Feb 2026 21:55:50 +0800 Subject: [PATCH 18/85] [kvm]: configurable orphan skip timeout Resolves: ZSTAC-80821 Change-Id: Ia9a9597feceb96b3e6e22259e2d0be7bde8ae499 --- .../main/java/org/zstack/kvm/KVMGlobalConfig.java | 4 ++++ .../java/org/zstack/kvm/KvmVmSyncPingTask.java | 15 +++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java index f4691e6fb0c..8cdd2f54167 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java @@ -120,6 +120,10 @@ public class KVMGlobalConfig { @GlobalConfigDef(defaultValue = "false", type = Boolean.class, description = "enable install host shutdown hook") public static GlobalConfig INSTALL_HOST_SHUTDOWN_HOOK = new GlobalConfig(CATEGORY, "install.host.shutdown.hook"); + @GlobalConfigValidation(numberGreaterThan = 0) + @GlobalConfigDef(defaultValue = "600", type = Long.class, description = "timeout in seconds for orphaned VM skip entries from departed management nodes") + public static GlobalConfig ORPHANED_VM_SKIP_TIMEOUT = new GlobalConfig(CATEGORY, "vm.orphanedSkipTimeout"); + @GlobalConfigValidation(validValues = {"true", "false"}) @GlobalConfigDef(defaultValue = "false", type = Boolean.class, description = "enable memory auto balloon") @BindResourceConfig({VmInstanceVO.class, HostVO.class, ClusterVO.class}) diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java b/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java index d1bb9133903..f618ab49922 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KvmVmSyncPingTask.java @@ -70,10 +70,13 @@ public class KvmVmSyncPingTask extends VmTracer implements KVMPingAgentNoFailure private Map vmInShutdownMap = new ConcurrentHashMap<>(); // Orphaned skip entries from departed MN nodes. Key=vmUuid, Value=timestamp when orphaned. - // These VMs remain in skip-trace state for ORPHAN_TTL_MS to avoid false HA triggers + // These VMs remain in skip-trace state to avoid false HA triggers // when a MN restarts and its in-flight VM operations haven't completed yet. See ZSTAC-80821. private final ConcurrentHashMap orphanedSkipVms = new ConcurrentHashMap<>(); - private static final long ORPHAN_TTL_MS = 10 * 60 * 1000; // 10 minutes + + private long getOrphanTtlMs() { + return KVMGlobalConfig.ORPHANED_VM_SKIP_TIMEOUT.value(Long.class) * 1000; + } { getReflections().getTypesAnnotatedWith(SkipVmTracer.class).forEach(clz -> { @@ -469,7 +472,7 @@ public void nodeLeft(ManagementNodeInventory inv) { for (String vmUuid : skippedVms) { orphanedSkipVms.put(vmUuid, now); logger.info(String.format("moved VM[uuid:%s] from departed MN[uuid:%s] skip list to orphaned set" + - " (will expire in %d minutes)", vmUuid, inv.getUuid(), ORPHAN_TTL_MS / 60000)); + " (will expire in %d minutes)", vmUuid, inv.getUuid(), getOrphanTtlMs() / 60000)); } } } @@ -492,14 +495,14 @@ public boolean isVmDoNotNeedToTrace(String vmUuid) { // ZSTAC-80821: Also check orphaned skip entries from departed MN nodes Long orphanedAt = orphanedSkipVms.get(vmUuid); if (orphanedAt != null) { - if (System.currentTimeMillis() - orphanedAt < ORPHAN_TTL_MS) { + if (System.currentTimeMillis() - orphanedAt < getOrphanTtlMs()) { logger.debug(String.format("VM[uuid:%s] is in orphaned skip set, skipping trace", vmUuid)); return true; } else { // Expired, clean up orphanedSkipVms.remove(vmUuid, orphanedAt); logger.info(String.format("orphaned skip entry for VM[uuid:%s] expired after %d minutes, resuming trace", - vmUuid, ORPHAN_TTL_MS / 60000)); + vmUuid, getOrphanTtlMs() / 60000)); } } @@ -514,7 +517,7 @@ private void cleanupExpiredOrphanedSkipVms() { long now = System.currentTimeMillis(); for (Map.Entry entry : orphanedSkipVms.entrySet()) { - if (now - entry.getValue() >= ORPHAN_TTL_MS) { + if (now - entry.getValue() >= getOrphanTtlMs()) { orphanedSkipVms.remove(entry.getKey(), entry.getValue()); logger.info(String.format("cleaned up expired orphaned skip entry for VM[uuid:%s]", entry.getKey())); } From ce0a020bac9c32550a38fbde9312dedbc284a579 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 19 Feb 2026 12:42:12 +0800 Subject: [PATCH 19/85] [pciDevice]: add error code ORG_ZSTACK_PCIDEVICE_10077 for SR-IOV bond check add new error code constant for SR-IOV bond slave NIC count validation. Resolves: ZSTAC-81163 Change-Id: Ie2a74411129a98c3c03a4a085e94a3bd45922da5 --- .../zstack/utils/clouderrorcode/CloudOperationsErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index a550fb7d673..9d54ae9f564 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -11588,6 +11588,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_PCIDEVICE_10076 = "ORG_ZSTACK_PCIDEVICE_10076"; + public static final String ORG_ZSTACK_PCIDEVICE_10077 = "ORG_ZSTACK_PCIDEVICE_10077"; + public static final String ORG_ZSTACK_CAS_DRIVER_DONGHAI_10000 = "ORG_ZSTACK_CAS_DRIVER_DONGHAI_10000"; public static final String ORG_ZSTACK_CAS_DRIVER_DONGHAI_10001 = "ORG_ZSTACK_CAS_DRIVER_DONGHAI_10001"; From a648c38953ebb3bf24fabc6f105aaf488ff9963c Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 19 Feb 2026 15:45:07 +0800 Subject: [PATCH 20/85] [utils]: add ORG_ZSTACK_AI_10134 error code for GPU count validation Resolves: ZSTAC-80991 Change-Id: I7677ddc25c8859e35e8ba80fd3105406bc761a76 --- .../zstack/utils/clouderrorcode/CloudOperationsErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index a550fb7d673..53bfdbc33db 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -14804,6 +14804,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_AI_10133 = "ORG_ZSTACK_AI_10133"; + public static final String ORG_ZSTACK_AI_10134 = "ORG_ZSTACK_AI_10134"; + public static final String ORG_ZSTACK_CORE_CLOUDBUS_10000 = "ORG_ZSTACK_CORE_CLOUDBUS_10000"; public static final String ORG_ZSTACK_CORE_CLOUDBUS_10001 = "ORG_ZSTACK_CORE_CLOUDBUS_10001"; From 16f5890dd4bbf445a547a1b176feb45e6ddfc342 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 19 Feb 2026 23:22:48 +0800 Subject: [PATCH 21/85] [compute]: respect vm.migrationQuantity during host maintenance Resolves: ZSTAC-81354 Change-Id: Iff2131b3a878444fa27641f24dd727fe4fa176fb --- compute/src/main/java/org/zstack/compute/host/HostBase.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/compute/src/main/java/org/zstack/compute/host/HostBase.java b/compute/src/main/java/org/zstack/compute/host/HostBase.java index 38eb654804f..db97db0d758 100755 --- a/compute/src/main/java/org/zstack/compute/host/HostBase.java +++ b/compute/src/main/java/org/zstack/compute/host/HostBase.java @@ -411,9 +411,8 @@ public void run(final FlowTrigger trigger, Map data) { if (ordered != null) { vmUuids = ordered; - logger.debug(String.format("%s ordered VMs for host maintenance, to keep the order, we will migrate VMs one by one", - ext.getClass())); - migrateQuantity = 1; + logger.debug(String.format("%s ordered VMs for host maintenance, migrate quantity: %d", + ext.getClass(), migrateQuantity)); } } From 96db9636e83efe57ef260315ee624ab38ba588e6 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 20 Feb 2026 15:00:17 +0800 Subject: [PATCH 22/85] [multi]: batch guard NPE quality issues - UpdateQueryImpl: guard val.getClass() NPE - LogSafeGson: return JsonNull when input is null - HostAllocatorChain: null check completion - VmInstanceVO: use Objects.equals to avoid NPE - SessionManagerImpl: guard null session - VmCapabilitiesJudger: guard null PS type result - CephPSMonBase: guard null self when evicted - CephPSBase: guard null mon.getSelf() - HostBase: guard null self when HostVO deleted - ExternalPSFactory: guard null URI protocol - LocalStorageBase: guard null errorCode.getCause() Resolves: ZSTAC-69300, ZSTAC-69957, ZSTAC-71973, ZSTAC-81294, ZSTAC-70180, ZSTAC-70181, ZSTAC-78309, ZSTAC-78310, ZSTAC-70668, ZSTAC-71909, ZSTAC-80555, ZSTAC-81270, ZSTAC-70101, ZSTAC-72034, ZSTAC-73197, ZSTAC-79921, ZSTAC-81160, ZSTAC-81224, ZSTAC-81805, ZSTAC-72304, ZSTAC-81804, ZSTAC-74898, ZSTAC-69215, ZSTAC-70151, ZSTAC-68933 Change-Id: I910e9b542ecd254fdf7e956f943316988a56a1f9 --- .../org/zstack/compute/allocator/HostAllocatorChain.java | 5 ++++- .../src/main/java/org/zstack/compute/host/HostBase.java | 2 +- .../java/org/zstack/compute/vm/VmCapabilitiesJudger.java | 6 ++++++ core/src/main/java/org/zstack/core/db/UpdateQueryImpl.java | 3 ++- core/src/main/java/org/zstack/core/log/LogSafeGson.java | 3 +++ .../org/zstack/header/vm/VmAbnormalLifeCycleStruct.java | 2 +- .../java/org/zstack/identity/AuthorizationManager.java | 5 +++++ .../storage/ceph/primary/CephPrimaryStorageBase.java | 6 ++++-- .../storage/ceph/primary/CephPrimaryStorageMonBase.java | 4 ++-- .../primary/local/LocalStorageAllocatorFactory.java | 7 ++++++- .../storage/primary/local/LocalStorageKvmBackend.java | 3 ++- 11 files changed, 36 insertions(+), 10 deletions(-) diff --git a/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java b/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java index 2262251d02e..2ce76fa5e43 100755 --- a/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java +++ b/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java @@ -146,7 +146,10 @@ private void runFlow(AbstractHostAllocatorFlow flow) { } } catch (Throwable t) { logger.warn("unhandled throwable", t); - completion.fail(inerr(ORG_ZSTACK_COMPUTE_ALLOCATOR_10019, t.toString())); + String errMsg = t != null ? t.toString() : "unknown error"; + if (completion != null) { + completion.fail(inerr(ORG_ZSTACK_COMPUTE_ALLOCATOR_10019, errMsg)); + } } } diff --git a/compute/src/main/java/org/zstack/compute/host/HostBase.java b/compute/src/main/java/org/zstack/compute/host/HostBase.java index 38eb654804f..c53db5a2b68 100755 --- a/compute/src/main/java/org/zstack/compute/host/HostBase.java +++ b/compute/src/main/java/org/zstack/compute/host/HostBase.java @@ -1443,7 +1443,7 @@ public String getName() { @Override protected String getDeduplicateString() { - return String.format("connect-host-%s", self.getUuid()); + return String.format("connect-host-%s", self == null ? "unknown" : self.getUuid()); } }); } diff --git a/compute/src/main/java/org/zstack/compute/vm/VmCapabilitiesJudger.java b/compute/src/main/java/org/zstack/compute/vm/VmCapabilitiesJudger.java index 92054658fb2..d3ceeb603b8 100644 --- a/compute/src/main/java/org/zstack/compute/vm/VmCapabilitiesJudger.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmCapabilitiesJudger.java @@ -53,6 +53,12 @@ private void checkPrimaryStorageCapabilities(VmCapabilities capabilities, VmInst q.add(PrimaryStorageVO_.uuid, SimpleQuery.Op.EQ, rootVolume.getPrimaryStorageUuid()); String type = q.findValue(); + if (type == null) { + capabilities.setSupportLiveMigration(false); + capabilities.setSupportVolumeMigration(false); + return; + } + PrimaryStorageType psType = PrimaryStorageType.valueOf(type); if (vm.getState() != VmInstanceState.Running) { diff --git a/core/src/main/java/org/zstack/core/db/UpdateQueryImpl.java b/core/src/main/java/org/zstack/core/db/UpdateQueryImpl.java index 6475b903c6e..b30adb194b5 100755 --- a/core/src/main/java/org/zstack/core/db/UpdateQueryImpl.java +++ b/core/src/main/java/org/zstack/core/db/UpdateQueryImpl.java @@ -54,7 +54,8 @@ public UpdateQuery set(SingularAttribute attr, Object val) { @Override public UpdateQuery condAnd(SingularAttribute attr, Op op, Object val) { if ((op == Op.IN || op == Op.NOT_IN) && !(val instanceof Collection)) { - throw new CloudRuntimeException(String.format("for operation IN or NOT IN, a Collection value is expected, but %s got", val.getClass())); + throw new CloudRuntimeException(String.format("for operation IN or NOT IN, a Collection value is expected, but %s got", + val == null ? "null" : val.getClass())); } Cond cond = new Cond(); diff --git a/core/src/main/java/org/zstack/core/log/LogSafeGson.java b/core/src/main/java/org/zstack/core/log/LogSafeGson.java index c3dcd26626c..13456c13bc0 100644 --- a/core/src/main/java/org/zstack/core/log/LogSafeGson.java +++ b/core/src/main/java/org/zstack/core/log/LogSafeGson.java @@ -208,6 +208,9 @@ private static JsonSerializer getSerializer() { } public static JsonElement toJsonElement(Object o) { + if (o == null) { + return JsonNull.INSTANCE; + } return logSafeGson.toJsonTree(o, getGsonType(o.getClass())); } diff --git a/header/src/main/java/org/zstack/header/vm/VmAbnormalLifeCycleStruct.java b/header/src/main/java/org/zstack/header/vm/VmAbnormalLifeCycleStruct.java index 43023821394..7da68329c09 100755 --- a/header/src/main/java/org/zstack/header/vm/VmAbnormalLifeCycleStruct.java +++ b/header/src/main/java/org/zstack/header/vm/VmAbnormalLifeCycleStruct.java @@ -72,7 +72,7 @@ boolean match(VmAbnormalLifeCycleStruct struct) { boolean match(VmAbnormalLifeCycleStruct struct) { return struct.getOriginalState() == VmInstanceState.Paused && struct.getCurrentState() == VmInstanceState.Stopped - && struct.getCurrentHostUuid().equals(struct.getOriginalHostUuid()); + && Objects.equals(struct.getCurrentHostUuid(), struct.getOriginalHostUuid()); } }, VmMigrateToAnotherHost { diff --git a/identity/src/main/java/org/zstack/identity/AuthorizationManager.java b/identity/src/main/java/org/zstack/identity/AuthorizationManager.java index fa50e2b9e8d..daa5152d1f7 100755 --- a/identity/src/main/java/org/zstack/identity/AuthorizationManager.java +++ b/identity/src/main/java/org/zstack/identity/AuthorizationManager.java @@ -116,6 +116,11 @@ public APIMessage intercept(APIMessage msg) throws ApiMessageInterceptionExcepti session = evaluateSession(msg); } + if (session == null) { + throw new ApiMessageInterceptionException(err(ORG_ZSTACK_IDENTITY_10012, IdentityErrors.INVALID_SESSION, + "evaluated session is null for message[%s]", msg.getMessageName())); + } + logger.trace(String.format("authorizing message[%s] with user[accountUuid:%s, uuid:%s] session", msg.getMessageName(), session.getAccountUuid(), diff --git a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java index d80b40a1d6a..c3b01dc3c8b 100755 --- a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java +++ b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageBase.java @@ -3974,15 +3974,17 @@ public void done() { mon.connect(new Completion(releaseLock) { @Override public void success() { + String monUuid = mon.getSelf() == null ? "unknown" : mon.getSelf().getUuid(); logger.debug(String.format("successfully reconnected the mon[uuid:%s] of the ceph primary" + - " storage[uuid:%s, name:%s]", mon.getSelf().getUuid(), self.getUuid(), self.getName())); + " storage[uuid:%s, name:%s]", monUuid, self.getUuid(), self.getName())); releaseLock.done(); } @Override public void fail(ErrorCode errorCode) { + String monUuid = mon.getSelf() == null ? "unknown" : mon.getSelf().getUuid(); logger.warn(String.format("failed to reconnect the mon[uuid:%s] server of the ceph primary" + - " storage[uuid:%s, name:%s], %s", mon.getSelf().getUuid(), self.getUuid(), self.getName(), errorCode)); + " storage[uuid:%s, name:%s], %s", monUuid, self.getUuid(), self.getName(), errorCode)); releaseLock.done(); } }); diff --git a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageMonBase.java b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageMonBase.java index 90b6220d53a..7ca3e710291 100755 --- a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageMonBase.java +++ b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephPrimaryStorageMonBase.java @@ -141,7 +141,7 @@ public void fail(ErrorCode errorCode) { @Override public String getName() { - return String.format("connect-ceph-primary-storage-mon-%s", self.getUuid()); + return String.format("connect-ceph-primary-storage-mon-%s", self == null ? "unknown" : self.getUuid()); } }); } @@ -420,7 +420,7 @@ public void fail(ErrorCode errorCode) { @Override public String getName() { - return String.format("ping-ceph-primary-storage-%s", self.getUuid()); + return String.format("ping-ceph-primary-storage-%s", self == null ? "unknown" : self.getUuid()); } }); } diff --git a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageAllocatorFactory.java b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageAllocatorFactory.java index b72673df10a..785b4092144 100755 --- a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageAllocatorFactory.java +++ b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageAllocatorFactory.java @@ -385,7 +385,12 @@ private String getHostUuidFromAllocateMsg(AllocatePrimaryStorageSpaceMsg msg) { throw new OperationFailureException( argerr(ORG_ZSTACK_STORAGE_PRIMARY_LOCAL_10023, "invalid uri, correct example is file://$URL;hostUuid://$HOSTUUID or volume://$VOLUMEUUID ")); } - hostUuid = uriParsers.get(protocol).parseUri(msg.getRequiredInstallUri()).hostUuid; + AbstractUriParser parser = uriParsers.get(protocol); + if (parser == null) { + throw new OperationFailureException( + argerr(ORG_ZSTACK_STORAGE_PRIMARY_LOCAL_10023, "unsupported protocol[%s] in uri[%s]", protocol, msg.getRequiredInstallUri())); + } + hostUuid = parser.parseUri(msg.getRequiredInstallUri()).hostUuid; } if (hostUuid != null) { diff --git a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java index 3b78ffab51b..f0b01f3ed48 100755 --- a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java +++ b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java @@ -3797,7 +3797,8 @@ public void success(CheckInitializedFileRsp rsp) { @Override public void fail(ErrorCode errorCode) { - completion.fail(operr(ORG_ZSTACK_STORAGE_PRIMARY_LOCAL_10081, "cannot find flag file [%s] on host [%s], because: %s", makeInitializedFilePath(), hostUuid, errorCode.getCause().getDetails())); + String causeDetails = errorCode.getCause() != null ? errorCode.getCause().getDetails() : errorCode.getDetails(); + completion.fail(operr(ORG_ZSTACK_STORAGE_PRIMARY_LOCAL_10081, "cannot find flag file [%s] on host [%s], because: %s", makeInitializedFilePath(), hostUuid, causeDetails)); } }); } From beccef9a16bc15d46b302db2ecdb2c849b6d9299 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 20 Feb 2026 15:00:36 +0800 Subject: [PATCH 23/85] [multi]: batch fix CRE quality issues - LdapUtil: CRE to OperationFailureException - QueryFacadeImpl: CRE to OperationFailureException - HostAllocatorManagerImpl: CRE to warn + clamp - CloudOperationsErrorCode: add LDAP/PROMETHEUS codes Resolves: ZSTAC-81334 Change-Id: Iab947b0476e9174d5a61baa095847b521b1f59fa --- .../allocator/HostAllocatorManagerImpl.java | 20 +++++++++---------- .../main/java/org/zstack/ldap/LdapUtil.java | 4 ++-- .../org/zstack/query/QueryFacadeImpl.java | 2 +- .../CloudOperationsErrorCode.java | 6 ++++++ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorManagerImpl.java b/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorManagerImpl.java index 48544a68333..a6598082004 100755 --- a/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorManagerImpl.java +++ b/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorManagerImpl.java @@ -851,17 +851,15 @@ public HostCapacityVO call(HostCapacityVO cap) { long deltaMemory = ratioMgr.calculateMemoryByRatio(hostUuid, memory); long availMemory = cap.getAvailableMemory() + deltaMemory; if (availMemory > cap.getTotalMemory()) { - throw new CloudRuntimeException( - String.format("invalid memory capacity of host[uuid:%s]," + - " available memory[%s] is greater than total memory[%s]." + - " Available Memory before is [%s], Delta Memory is [%s].", - hostUuid, - new DecimalFormat(",###").format(availMemory), - new DecimalFormat(",###").format(cap.getTotalMemory()), - new DecimalFormat(",###").format(cap.getAvailableMemory()), - new DecimalFormat(",###").format(deltaMemory) - ) - ); + logger.warn(String.format("memory capacity overflow on host[uuid:%s]," + + " available memory[%s] > total memory[%s], clamping to total." + + " Available Memory before is [%s], Delta Memory is [%s].", + hostUuid, + new DecimalFormat(",###").format(availMemory), + new DecimalFormat(",###").format(cap.getTotalMemory()), + new DecimalFormat(",###").format(cap.getAvailableMemory()), + new DecimalFormat(",###").format(deltaMemory))); + availMemory = cap.getTotalMemory(); } cap.setAvailableMemory(availMemory); diff --git a/plugin/ldap/src/main/java/org/zstack/ldap/LdapUtil.java b/plugin/ldap/src/main/java/org/zstack/ldap/LdapUtil.java index d386ca2d429..5f4b00f59fb 100644 --- a/plugin/ldap/src/main/java/org/zstack/ldap/LdapUtil.java +++ b/plugin/ldap/src/main/java/org/zstack/ldap/LdapUtil.java @@ -657,10 +657,10 @@ public LdapServerVO getLdapServer() { sq.add(LdapServerVO_.scope, SimpleQuery.Op.EQ, scope); List ldapServers = sq.list(); if (ldapServers.isEmpty()) { - throw new CloudRuntimeException("No LDAP/AD server record in database."); + throw new OperationFailureException(operr(ORG_ZSTACK_LDAP_10020, "No LDAP/AD server record in database.")); } if (ldapServers.size() > 1) { - throw new CloudRuntimeException("More than one LDAP/AD server record in database."); + throw new OperationFailureException(operr(ORG_ZSTACK_LDAP_10021, "More than one LDAP/AD server record in database.")); } return ldapServers.get(0); } diff --git a/search/src/main/java/org/zstack/query/QueryFacadeImpl.java b/search/src/main/java/org/zstack/query/QueryFacadeImpl.java index 6dafe940fca..9bd8fa8c724 100755 --- a/search/src/main/java/org/zstack/query/QueryFacadeImpl.java +++ b/search/src/main/java/org/zstack/query/QueryFacadeImpl.java @@ -378,7 +378,7 @@ private void handle(APIQueryMessage msg) { } catch (OperationFailureException of) { throw of; } catch (Exception e) { - throw new CloudRuntimeException(e); + throw new OperationFailureException(inerr("failed to query: %s", e.getMessage())); } } diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index a0f09d4f1e9..66ac4c5fc52 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -1654,6 +1654,10 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_LDAP_10019 = "ORG_ZSTACK_LDAP_10019"; + public static final String ORG_ZSTACK_LDAP_10020 = "ORG_ZSTACK_LDAP_10020"; + + public static final String ORG_ZSTACK_LDAP_10021 = "ORG_ZSTACK_LDAP_10021"; + public static final String ORG_ZSTACK_IMAGE_10000 = "ORG_ZSTACK_IMAGE_10000"; public static final String ORG_ZSTACK_IMAGE_10001 = "ORG_ZSTACK_IMAGE_10001"; @@ -10652,6 +10656,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013 = "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013"; + public static final String ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10014 = "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10014"; + public static final String ORG_ZSTACK_AI_CONTAINER_10000 = "ORG_ZSTACK_AI_CONTAINER_10000"; public static final String ORG_ZSTACK_AI_CONTAINER_10001 = "ORG_ZSTACK_AI_CONTAINER_10001"; From 68791ea939110216bd7089392e940f427e357dec Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 20 Feb 2026 16:33:01 +0800 Subject: [PATCH 24/85] [ai]: add error codes for AI and PCI Add ORG_ZSTACK_AI_10134 for ModelCenter disconnected check and ORG_ZSTACK_PCIDEVICE_10077 for SR-IOV bond validation. Resolves: ZSTAC-72783 Change-Id: I504a415a6e822513df955be600188ae88e2e1058 --- .../zstack/utils/clouderrorcode/CloudOperationsErrorCode.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index a550fb7d673..42e6cb6667c 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -11588,6 +11588,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_PCIDEVICE_10076 = "ORG_ZSTACK_PCIDEVICE_10076"; + public static final String ORG_ZSTACK_PCIDEVICE_10077 = "ORG_ZSTACK_PCIDEVICE_10077"; + public static final String ORG_ZSTACK_CAS_DRIVER_DONGHAI_10000 = "ORG_ZSTACK_CAS_DRIVER_DONGHAI_10000"; public static final String ORG_ZSTACK_CAS_DRIVER_DONGHAI_10001 = "ORG_ZSTACK_CAS_DRIVER_DONGHAI_10001"; @@ -14804,6 +14806,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_AI_10133 = "ORG_ZSTACK_AI_10133"; + public static final String ORG_ZSTACK_AI_10134 = "ORG_ZSTACK_AI_10134"; + public static final String ORG_ZSTACK_CORE_CLOUDBUS_10000 = "ORG_ZSTACK_CORE_CLOUDBUS_10000"; public static final String ORG_ZSTACK_CORE_CLOUDBUS_10001 = "ORG_ZSTACK_CORE_CLOUDBUS_10001"; From 2a4e85a1338590f0a9bfeafe9b88ed557e7ce3dd Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 24 Feb 2026 13:20:54 +0800 Subject: [PATCH 25/85] [gpu]: add normalizedModelName migration SQL Companion DB migration for premium MR !13025. Adds normalizedModelName column and index to GpuDeviceSpecVO for GPU spec dedup by normalized model name. Resolves: ZSTAC-75319 Change-Id: If15e615bcbda955cc1d6c58527bae27d4af4b497 --- conf/db/upgrade/V5.5.12__schema.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 conf/db/upgrade/V5.5.12__schema.sql diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql new file mode 100644 index 00000000000..812c033e27e --- /dev/null +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -0,0 +1,3 @@ +-- ZSTAC-75319: Add normalizedModelName column for GPU spec dedup +CALL ADD_COLUMN('GpuDeviceSpecVO', 'normalizedModelName', 'VARCHAR(255)', 1, NULL); +CALL CREATE_INDEX('GpuDeviceSpecVO', 'idx_gpu_spec_normalized_model', 'normalizedModelName'); From fd02d47e3c3e358d3be2512673623ce1c8f7bb2a Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 24 Feb 2026 14:31:57 +0800 Subject: [PATCH 26/85] [multi]: fix review findings: dryRun completion, initializeHostAttachedPSMountPath NPE Resolves: ZSTAC-69300 Change-Id: I1b39a9e7b76751e8a4ef4cc53e9ac2028386e334 --- .../org/zstack/compute/allocator/HostAllocatorChain.java | 6 +++++- .../storage/primary/local/LocalStorageKvmBackend.java | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java b/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java index 2ce76fa5e43..371d1dfd355 100755 --- a/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java +++ b/compute/src/main/java/org/zstack/compute/allocator/HostAllocatorChain.java @@ -147,7 +147,11 @@ private void runFlow(AbstractHostAllocatorFlow flow) { } catch (Throwable t) { logger.warn("unhandled throwable", t); String errMsg = t != null ? t.toString() : "unknown error"; - if (completion != null) { + if (isDryRun) { + if (dryRunCompletion != null) { + dryRunCompletion.fail(inerr(ORG_ZSTACK_COMPUTE_ALLOCATOR_10019, errMsg)); + } + } else if (completion != null) { completion.fail(inerr(ORG_ZSTACK_COMPUTE_ALLOCATOR_10019, errMsg)); } } diff --git a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java index f0b01f3ed48..ec0b51711ba 100755 --- a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java +++ b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageKvmBackend.java @@ -3819,7 +3819,8 @@ public void success(AgentResponse rsp) { @Override public void fail(ErrorCode errorCode) { - completion.fail(operr(ORG_ZSTACK_STORAGE_PRIMARY_LOCAL_10082, "cannot create flag file [%s] on host [%s], because: %s", makeInitializedFilePath(), hostUuid, errorCode.getCause().getDetails())); + String causeDetails = errorCode.getCause() != null ? errorCode.getCause().getDetails() : errorCode.getDetails(); + completion.fail(operr(ORG_ZSTACK_STORAGE_PRIMARY_LOCAL_10082, "cannot create flag file [%s] on host [%s], because: %s", makeInitializedFilePath(), hostUuid, causeDetails)); } }); } From 34a77bc9e8dae38e3aec67ba4a2317556da188a3 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 24 Feb 2026 16:40:57 +0800 Subject: [PATCH 27/85] [telemetry]: fix Sentry transaction loss and add debug logging Resolves: ZSTAC-61988 Change-Id: Id3d5a48801fda21e2a51d96949c743bac254b2e6 --- .../core/telemetry/SentryInitHelper.java | 37 +++++++++++++++++++ .../telemetry/TelemetryGlobalProperty.java | 6 +++ 2 files changed, 43 insertions(+) diff --git a/core/src/main/java/org/zstack/core/telemetry/SentryInitHelper.java b/core/src/main/java/org/zstack/core/telemetry/SentryInitHelper.java index 0c04c95801f..66ea7593eb2 100644 --- a/core/src/main/java/org/zstack/core/telemetry/SentryInitHelper.java +++ b/core/src/main/java/org/zstack/core/telemetry/SentryInitHelper.java @@ -1,6 +1,7 @@ package org.zstack.core.telemetry; import io.sentry.Sentry; +import io.sentry.SentryLevel; import org.zstack.core.cloudbus.CloudBusGlobalProperty; import org.zstack.utils.Utils; import org.zstack.utils.logging.CLogger; @@ -54,6 +55,42 @@ public static boolean initIfNeeded() { Sentry.init(options -> { options.setDsn(finalDsn); options.setTracesSampleRate(tracesSampleRate); + options.setMaxQueueSize(1000); + if (TelemetryGlobalProperty.SENTRY_DEBUG) { + options.setDebug(true); + options.setDiagnosticLevel(SentryLevel.DEBUG); + options.setLogger(new io.sentry.ILogger() { + @Override + public void log(SentryLevel level, String message, Object... args) { + String formatted = args.length > 0 ? String.format(message, args) : message; + switch (level) { + case ERROR: logger.warn("[Sentry] " + formatted); break; + case WARNING: logger.warn("[Sentry] " + formatted); break; + default: logger.info("[Sentry] " + formatted); break; + } + } + + @Override + public void log(SentryLevel level, String message, Throwable throwable) { + switch (level) { + case ERROR: logger.warn("[Sentry] " + message, throwable); break; + case WARNING: logger.warn("[Sentry] " + message, throwable); break; + default: logger.info("[Sentry] " + message, throwable); break; + } + } + + @Override + public void log(SentryLevel level, Throwable throwable, String message, Object... args) { + String formatted = args.length > 0 ? String.format(message, args) : message; + log(level, formatted, throwable); + } + + @Override + public boolean isEnabled(SentryLevel level) { + return true; + } + }); + } }); logger.info("Sentry initialized (tracesSampleRate=" + tracesSampleRate + ")"); return true; diff --git a/core/src/main/java/org/zstack/core/telemetry/TelemetryGlobalProperty.java b/core/src/main/java/org/zstack/core/telemetry/TelemetryGlobalProperty.java index 958682ac983..aa1cb3117cd 100644 --- a/core/src/main/java/org/zstack/core/telemetry/TelemetryGlobalProperty.java +++ b/core/src/main/java/org/zstack/core/telemetry/TelemetryGlobalProperty.java @@ -147,6 +147,12 @@ public class TelemetryGlobalProperty { @GlobalProperty(name = "Telemetry.shutdownTimeoutMs", defaultValue = "10000") public static int SHUTDOWN_TIMEOUT_MS; + /** + * Enable Sentry SDK debug logging (outputs to management-server.log via log4j). + */ + @GlobalProperty(name = "Telemetry.sentryDebug", defaultValue = "false") + public static boolean SENTRY_DEBUG; + /** * Environment types enumeration */ From 056661491e8665192aab21e8465b757334a5013f Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 24 Feb 2026 16:51:56 +0800 Subject: [PATCH 28/85] [telemetry]: consolidate semconv to 1.28.0-alpha Resolves: ZSTAC-61988 Change-Id: I0908fce97128904f9954198645290f4e5709252e --- build/pom.xml | 4 ++-- core/pom.xml | 5 ----- pom.xml | 10 ++-------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/build/pom.xml b/build/pom.xml index 6801d25cbba..a1ea6a47ca7 100755 --- a/build/pom.xml +++ b/build/pom.xml @@ -731,9 +731,9 @@ io.sentry sentry-opentelemetry-bootstrap - + - io.opentelemetry + io.opentelemetry.semconv opentelemetry-semconv diff --git a/core/pom.xml b/core/pom.xml index 0ab8bb020f0..484983dba03 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -167,11 +167,6 @@ io.opentelemetry.semconv opentelemetry-semconv - - - io.opentelemetry - opentelemetry-semconv - io.opentelemetry opentelemetry-sdk-metrics diff --git a/pom.xml b/pom.xml index 5173ad0aef2..91f3c4a397a 100755 --- a/pom.xml +++ b/pom.xml @@ -550,17 +550,11 @@ opentelemetry-context 1.35.0 - + io.opentelemetry.semconv opentelemetry-semconv - 1.37.0 - - - - io.opentelemetry - opentelemetry-semconv - 1.23.1-alpha + 1.28.0-alpha From 5217d8f62d894b6f4b6379cb35012f0d68a69e88 Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Tue, 24 Feb 2026 19:34:29 +0800 Subject: [PATCH 29/85] [storage]: improve i18n error messages for PS UUID conflicts Add missing error codes ORG_ZSTACK_STORAGE_PRIMARY_10048 and ORG_ZSTACK_STORAGE_PRIMARY_10051 to all 10 language files. Fix zh_CN mistranslations replaced with correct term. Fix zh_TW garbled characters in error messages. Resolves: ZSTAC-72656 Change-Id: I5f08109d1c415b751ec130285b9d92522f1e0a34 --- conf/i18n/globalErrorCodeMapping/global-error-de-DE.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-en_US.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-id-ID.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-th-TH.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json | 5 +++-- conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json | 5 +++-- 10 files changed, 30 insertions(+), 20 deletions(-) diff --git a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json index 41e1915fedf..961c282fcba 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "Volume[uuid:%s] kann nicht auf Snapshot[uuid:%s] zurückgesetzt werden, das Volume der VM[uuid:%s] befindet sich nicht im Status \"Stopped\", aktueller Status ist %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "Kein qualifizierter primärer Speicher gefunden; Fehler sind %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Aktualisierung des Gruppenstatus fehlgeschlagen: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid-Konflikt: Der durch das Instanzangebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid-Konflikt: Der durch das Compute-Angebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuid-Konflikt: Der durch das Root-Disk-Angebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", "ORG_ZSTACK_V2V_10008": "Dieselbe MAC-Adresse [%s] ist im Netzwerk[%s] nicht erlaubt", "ORG_ZSTACK_V2V_10009": "Doppelte MAC-Adresse [%s] im Netzwerk[%s]", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuid-Konflikt: Der durch das Plattenangebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuid-Konflikt: Der durch das Datenplattenangebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", "ORG_ZSTACK_V2V_10006": "Die zugrunde liegende Compute-Instanz des Konversions-Hosts[uuid:%s] sollte verbunden sein", "ORG_ZSTACK_V2V_10007": "Konversions-Host[uuid:%s] kann keine Verbindung zum primären Speicher[uuid:%s] herstellen", "ORG_ZSTACK_V2V_10004": "VM-Instanz [UUID:%s] existiert nicht oder ist keine VMware-VM-Instanz", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json index 715e823d95e..5db5a7eec41 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "unable to reset volume[uuid:%s] to snapshot[uuid:%s], the vm[uuid:%s] volume is not in Stopped state, current state is %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "cannot find any qualified primary storage; errors are %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Failed to update group status: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid Conflict: The primary storage specified by the instance offering is %s, while the primary storage specified in the creation parameter is %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid Conflict: The primary storage specified by the compute offering is %s, while the primary storage specified in the creation parameter is %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuid Conflict: The primary storage specified by the root disk offering is %s, while the primary storage specified in the creation parameter is %s.", "ORG_ZSTACK_V2V_10008": "Not allowed the same MAC address [%s] in network[%s]", "ORG_ZSTACK_V2V_10009": "Duplicate MAC address [%s] in network[%s]", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuid Conflict: The primary storage specified by the disk offering is %s, while the primary storage specified in the creation parameter is %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuid Conflict: The primary storage specified by the data disk offering is %s, while the primary storage specified in the creation parameter is %s.", "ORG_ZSTACK_V2V_10006": "underlying compute instance of conversion host[uuid:%s] should be Connected", "ORG_ZSTACK_V2V_10007": "conversion host[uuid:%s] cannot establish a connection to the primary storage[uuid:%s]", "ORG_ZSTACK_V2V_10004": "VM instance [UUID:%s] does not exist or is not a VMware VM instance", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json index 449344a6cef..724b3610db9 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "impossible de réinitialiser le volume[uuid:%s] vers l'instantané[uuid:%s], le volume vm[uuid:%s] n'est pas dans l'état Arrêté, l'état actuel est %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "impossible de trouver un stockage principal qualifié ; les erreurs sont %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Échec de la mise à jour du statut du groupe : %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre d'instance est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre de calcul est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre de disque racine est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", "ORG_ZSTACK_V2V_10008": "L'adresse MAC [%s] identique n'est pas autorisée dans le réseau[%s]", "ORG_ZSTACK_V2V_10009": "Adresse MAC [%s] en double dans le réseau[%s]", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre de disque est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre de disque de données est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", "ORG_ZSTACK_V2V_10006": "l'instance de calcul sous-jacente de l'hôte de conversion[uuid:%s] doit être Connectée", "ORG_ZSTACK_V2V_10007": "l'hôte de conversion[uuid:%s] ne peut pas établir de connexion avec le stockage principal[uuid:%s]", "ORG_ZSTACK_V2V_10004": "L'instance de VM [UUID:%s] n'existe pas ou n'est pas une instance de VM VMware", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json index d8cd5bf487a..116e1993c43 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "tidak dapat mengatur ulang volume[uuid:%s] ke snapshot[uuid:%s], volume vm[uuid:%s] tidak dalam keadaan Berhenti, keadaan saat ini adalah %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "tidak dapat menemukan primary storage yang memenuhi syarat; error adalah %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Gagal memperbarui status grup: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh instance offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh compute offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh root disk offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", "ORG_ZSTACK_V2V_10008": "Alamat MAC [%s] yang sama tidak diperbolehkan dalam jaringan[%s]", "ORG_ZSTACK_V2V_10009": "Alamat MAC duplikat [%s] dalam jaringan[%s]", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh disk offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh data disk offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", "ORG_ZSTACK_V2V_10006": "instance komputasi yang mendasar dari conversion host[uuid:%s] harus Connected", "ORG_ZSTACK_V2V_10007": "conversion host[uuid:%s] tidak dapat建立 koneksi ke primary storage[uuid:%s]", "ORG_ZSTACK_V2V_10004": "Instance VM [UUID:%s] tidak ada atau bukan instance VM VMware", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json index 2924f658479..5fcf573afbc 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "ボリューム[uuid:%s]をスナップショット[uuid:%s]にリセットできません。vm[uuid:%s]のボリュームは停止状態ではありません。現在のステータスは%sです", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "適切なプライマリストレージが見つかりません。エラー: %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "グループのステータスの更新に失敗しました: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuidの競合。インスタンスオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuidの競合。コンピュートオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuidの競合。ルートディスクオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", "ORG_ZSTACK_V2V_10008": "ネットワーク[%s]で同じMACアドレス[%s]は許可されていません", "ORG_ZSTACK_V2V_10009": "ネットワーク[%s]でMACアドレス[%s]が重複しています", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuidの競合。ディスクオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuidの競合。データディスクオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", "ORG_ZSTACK_V2V_10006": "変換ホスト[uuid:%s]の基盤となるコンピューティングインスタンスはConnectedである必要があります", "ORG_ZSTACK_V2V_10007": "変換ホスト[uuid:%s]はプライマリストレージ[uuid:%s]への接続を確立できません", "ORG_ZSTACK_V2V_10004": "VMインスタンス[UUID:%s]が存在しないか、VMware VMインスタンスではありません", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json index c73d9f274a1..f2fac50e88b 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "볼륨[uuid:%s]을 스냅샷[uuid:%s]으로 재설정할 수 없습니다, vm[uuid:%s] 볼륨이 Stopped 상태가 아니며, 현재 상태는 %s입니다", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "적합한 기본 스토리지를 찾을 수 없습니다; 오류: %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "그룹 상태 업데이트 실패: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid 충돌: 인스턴스 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid 충돌: 컴퓨트 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuid 충돌: 루트 디스크 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", "ORG_ZSTACK_V2V_10008": "네트워크[%s]에서 동일한 MAC 주소 [%s]가 허용되지 않습니다", "ORG_ZSTACK_V2V_10009": "네트워크[%s]에서 중복된 MAC 주소 [%s]", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuid 충돌: 디스크 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "primaryStorageUuid 충돌: 데이터 디스크 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", "ORG_ZSTACK_V2V_10006": "변환 호스트[uuid:%s]의 기본 컴퓨팅 인스턴스가 Connected 상태여야 합니다", "ORG_ZSTACK_V2V_10007": "변환 호스트[uuid:%s]가 기본 스토리지[uuid:%s]에 연결을 수립할 수 없습니다", "ORG_ZSTACK_V2V_10004": "VM 인스턴스[UUID:%s]가 존재하지 않거나 VMware VM 인스턴스가 아닙니다", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json index 2454d7e5658..84edbc0a88c 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "невозможно сбросить том[uuid:%s] к снимку[uuid:%s], том ВМ[uuid:%s] не находится в состоянии Stopped, текущее состояние: %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "невозможно найти подходящее основное хранилище; ошибки: %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "не удалось обновить статус группы: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении экземпляра: %s, а основное хранилище, указанное в параметре создания: %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении вычислений: %s, а основное хранилище, указанное в параметре создания: %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении корневого диска: %s, а основное хранилище, указанное в параметре создания: %s", "ORG_ZSTACK_V2V_10008": "Не допускается одинаковый MAC-адрес [%s] в сети[%s]", "ORG_ZSTACK_V2V_10009": "Дублирующийся MAC-адрес [%s] в сети[%s]", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении диска: %s, а основное хранилище, указанное в параметре создания: %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении диска данных: %s, а основное хранилище, указанное в параметре создания: %s", "ORG_ZSTACK_V2V_10006": "базовый вычислительный экземпляр хоста конвертации[uuid:%s] должен быть в состоянии Connected", "ORG_ZSTACK_V2V_10007": "хост конвертации[uuid:%s] не может установить соединение с основным хранилищем[uuid:%s]", "ORG_ZSTACK_V2V_10004": "Экземпляр ВМ [UUID:%s] не существует или не является экземпляром VMware VM", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json index 9db32b34264..eb8c392979d 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "ไม่สามารถรีเซ็ต volume[uuid:%s] ไปยัง snapshot[uuid:%s], volume ของ vm[uuid:%s] ไม่อยู่ในสถานะ Stopped, สถานะปัจจุบันคือ %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "ไม่พบ primary storage ที่มีคุณสมบัติเหมาะสม; ข้อผิดพลาดคือ %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "ไม่สามารถอัปเดตสถานะกลุ่ม: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย instance offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย compute offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย root disk offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", "ORG_ZSTACK_V2V_10008": "ไม่อนุญาตให้มี MAC address [%s] ซ้ำกันใน network[%s]", "ORG_ZSTACK_V2V_10009": "MAC address [%s] ซ้ำกันใน network[%s]", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย disk offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย data disk offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", "ORG_ZSTACK_V2V_10006": "compute instance พื้นฐานของ conversion host[uuid:%s] ควรอยู่ในสถานะ Connected", "ORG_ZSTACK_V2V_10007": "conversion host[uuid:%s] ไม่สามารถสร้างการเชื่อมต่อกับ primary storage[uuid:%s]", "ORG_ZSTACK_V2V_10004": "VM instance [UUID:%s] ไม่มีอยู่หรือไม่ใช่ VMware VM instance", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json index 01960e8eb45..100589567d1 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "无法将卷[uuid:%s]恢复至快照[uuid:%s],关联的虚拟机[uuid:%s]卷当前不在已停止状态,当前状态是%s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "无法找到任何合格的主存储,错误信息是 %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "失败更新组状态: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "主存储UUID冲突,实例报价中指定的主存储为%s,创建参数中指定的主存储为%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "主存储UUID冲突,计算规格指定的主存储为%s,而创建参数中指定的主存储为%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "主存储UUID冲突,系统云盘规格指定的主存储为%s,而创建参数中指定的主存储为%s", "ORG_ZSTACK_V2V_10008": "不允许在网络[%s]中使用相同的MAC地址[%s]", "ORG_ZSTACK_V2V_10009": "重复的 MAC 地址 [%s] 在网络[%s] 中", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "主存储UUID冲突,由磁盘配置指定的主存储为%s,而创建参数中指定的主存储为%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "主存储UUID冲突,数据云盘规格指定的主存储为%s,而创建参数中指定的主存储为%s", "ORG_ZSTACK_V2V_10006": "转换主机[uuid:%s]的底层宿主机应当是已连接状态", "ORG_ZSTACK_V2V_10007": "转换主机[uuid:%s]无法连接到主要存储[uuid:%s]", "ORG_ZSTACK_V2V_10004": "vm 实例[uuid:%s] 不存在或不是一个 VMware 虚拟机", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json index 35f1b8c8443..4ed6955fad7 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json @@ -35,10 +35,11 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "無法将卷[uuid:%s]恢复至快照[uuid:%s],關聯的虚拟機[uuid:%s]卷當前不在已停止状态,當前状态是%s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "無法找到任何合格的主儲儲,錯誤誤信息是 %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "失敗更新组状态: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "主儲儲UUID冲突,实例報价中指定的主儲儲为%s,創建參數中指定的主儲儲为%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "主儲存UUID衝突,計算規格指定的主儲存為%s,而創建參數中指定的主儲存為%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10051": "主儲存UUID衝突,系統雲盤規格指定的主儲存為%s,而創建參數中指定的主儲存為%s", "ORG_ZSTACK_V2V_10008": "不允許在网络[%s]中使用相同的MAC地址[%s]", "ORG_ZSTACK_V2V_10009": "重复的 MAC 地址 [%s] 在网络[%s] 中", - "ORG_ZSTACK_STORAGE_PRIMARY_10052": "主儲儲UUID冲突,由磁碟配置指定的主儲儲为%s,而創建參數中指定的主儲儲为%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10052": "主儲存UUID衝突,數據雲盤規格指定的主儲存為%s,而創建參數中指定的主儲存為%s", "ORG_ZSTACK_V2V_10006": "转换主機[uuid:%s]的底层宿主機应當是已連接状态", "ORG_ZSTACK_V2V_10007": "转换主機[uuid:%s]無法連接到主要儲儲[uuid:%s]", "ORG_ZSTACK_V2V_10004": "vm 实例[uuid:%s] 不儲在或不是一個 VMware 虚拟機", From 687d42fe0d16816851e80e7b890f75d9ccb784e8 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Wed, 25 Feb 2026 14:52:24 +0800 Subject: [PATCH 30/85] [sdk]: add exportPath to SDK inventory Resolves: ZSTAC-77454 Change-Id: I3e91cc5eb349960d56f775097ed55f34f1866be2 --- .../zstack/sdk/ModelServiceInstanceGroupInventory.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sdk/src/main/java/org/zstack/sdk/ModelServiceInstanceGroupInventory.java b/sdk/src/main/java/org/zstack/sdk/ModelServiceInstanceGroupInventory.java index 25ad17e970d..2451290084a 100644 --- a/sdk/src/main/java/org/zstack/sdk/ModelServiceInstanceGroupInventory.java +++ b/sdk/src/main/java/org/zstack/sdk/ModelServiceInstanceGroupInventory.java @@ -116,4 +116,12 @@ public java.util.List getSupportMetrics() { return this.supportMetrics; } + public java.lang.String exportPath; + public void setExportPath(java.lang.String exportPath) { + this.exportPath = exportPath; + } + public java.lang.String getExportPath() { + return this.exportPath; + } + } From 59b836e5d9479098d42fd917f4bfb6e460844481 Mon Sep 17 00:00:00 2001 From: AlanJager Date: Tue, 24 Feb 2026 16:13:07 +0800 Subject: [PATCH 31/85] [gosdk]: add GoTestTemplate for auto-gen tests Add GoTestTemplate.groovy to auto-generate unit tests and integration tests during SDK code generation. Change-Id: Ic6f05df8609b406350c76fca9e7723298fa4b72a Signed-off-by: AlanJager --- .../main/resources/scripts/GoInventory.groovy | 8 +- .../resources/scripts/GoTestTemplate.groovy | 547 ++++++++++++++++++ 2 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 rest/src/main/resources/scripts/GoTestTemplate.groovy diff --git a/rest/src/main/resources/scripts/GoInventory.groovy b/rest/src/main/resources/scripts/GoInventory.groovy index e55be54d3c3..c31e44149c6 100644 --- a/rest/src/main/resources/scripts/GoInventory.groovy +++ b/rest/src/main/resources/scripts/GoInventory.groovy @@ -215,7 +215,13 @@ class GoInventory implements SdkTemplate { // Note: client.go is manually maintained, not auto-generated - // 8. Validate that all referenced response views were generated + // 8. Generate test files (unit tests + integration tests) + def testTemplate = new GoTestTemplate(this, allApiTemplates, inventories) + def testFiles = testTemplate.generate() + files.addAll(testFiles) + logger.warn("[GoSDK] Generated ${testFiles.size()} test files") + + // 9. Validate that all referenced response views were generated validateGeneratedViews() logger.warn("[GoSDK] GoInventory.generate() complete. Total files: " + files.size()) diff --git a/rest/src/main/resources/scripts/GoTestTemplate.groovy b/rest/src/main/resources/scripts/GoTestTemplate.groovy new file mode 100644 index 00000000000..b46b1172adf --- /dev/null +++ b/rest/src/main/resources/scripts/GoTestTemplate.groovy @@ -0,0 +1,547 @@ +package scripts + +import org.zstack.rest.sdk.SdkFile +import org.zstack.utils.Utils +import org.zstack.utils.logging.CLogger + +class GoTestTemplate { + private static final CLogger logger = Utils.getLogger(GoTestTemplate.class) + + private def inventoryGenerator // GoInventory instance + private def allApiTemplates + private def inventories + private Set generatedIntegrationFiles = new HashSet<>() + + GoTestTemplate(def inventoryGenerator, def allApiTemplates, def inventories) { + this.inventoryGenerator = inventoryGenerator + this.allApiTemplates = allApiTemplates + this.inventories = inventories + } + + List generate() { + def files = [] + logger.warn("[GoSDK-Test] Starting test generation...") + + // 1. Static base unit test file + files.add(generateBaseUnitTestFile()) + + // 2. Static base integration test file + files.add(generateBaseIntegrationTestFile()) + + // 2. Per-resource test files + def resourceMap = groupApisByResource() + logger.warn("[GoSDK-Test] Grouped ${resourceMap.size()} resources for test generation") + + resourceMap.each { String prefix, Map resourceInfo -> + String snakeName = toSnakeCase(prefix) + + // Unit tests + def paramTest = generateParamTestFile(prefix, snakeName, resourceInfo) + if (paramTest != null) files.add(paramTest) + + def viewTest = generateViewTestFile(prefix, snakeName, resourceInfo) + if (viewTest != null) files.add(viewTest) + + // TODO: client tests require parsing actual method signatures from GoApiTemplate + // Will be added in a follow-up iteration + // def clientTest = generateClientTestFile(prefix, snakeName, resourceInfo) + // if (clientTest != null) files.add(clientTest) + + // Integration tests (generated to pkg/integration_test/ to avoid conflicts) + def integrationTest = generateIntegrationTestFile(prefix, snakeName, resourceInfo) + if (integrationTest != null) files.add(integrationTest) + } + + logger.warn("[GoSDK-Test] Test generation complete. Generated ${files.size()} test files") + return files + } + + // ======================== Resource Grouping ======================== + + private Map groupApisByResource() { + def resourceMap = [:] + + inventories.each { Class inventoryClass -> + String prefix = inventoryClass.simpleName.replaceAll('Inventory\$', '') + if (!resourceMap.containsKey(prefix)) { + resourceMap[prefix] = [ + inventoryClass: inventoryClass, + viewStructName: inventoryGenerator.getViewStructName(inventoryClass), + templates: [] + ] + } + } + + allApiTemplates.each { template -> + String resName = template.getResourceName() + if (resName != null && resourceMap.containsKey(resName)) { + resourceMap[resName].templates.add(template) + } + } + + // Filter out resources with no templates + return resourceMap.findAll { k, v -> !v.templates.isEmpty() } + } + + // ======================== Base Unit Test ======================== + + private SdkFile generateBaseUnitTestFile() { + def content = new StringBuilder() + content.append('''\ +// Copyright (c) ZStack.io, Inc. +// Auto-generated test infrastructure. DO NOT EDIT. + +package unit_test + +import ( +\t"encoding/json" +\t"fmt" +\t"net/http" +\t"net/http/httptest" +\t"strings" +\t"testing" +\t"time" + +\t"github.com/zstackio/zstack-sdk-go-v2/pkg/client" +) + +// newMockClient creates a ZSClient backed by an httptest server. +// The handler receives all HTTP requests and can assert on method/path/body. +func newMockClient(handler http.HandlerFunc) (*client.ZSClient, func()) { +\tserver := httptest.NewServer(handler) +\t// Parse host and port from server URL +\taddr := server.Listener.Addr().String() +\tparts := strings.SplitN(addr, ":", 2) +\thost := parts[0] +\tport := 80 +\tif len(parts) == 2 { +\t\tfmt.Sscanf(parts[1], "%d", &port) +\t} + +\tconfig := client.NewZSConfig(host, port, "") +\tconfig.LoginAccount("admin", "password") +\tcli := client.NewZSClient(config) +\tcli.LoadSession("mock-session-id") +\treturn cli, server.Close +} + +// mockInventoryResponse builds a JSON response wrapping data in {"inventory": ...} +func mockInventoryResponse(data map[string]interface{}) []byte { +\tresp := map[string]interface{}{"inventory": data} +\tb, _ := json.Marshal(resp) +\treturn b +} + +// mockInventoriesResponse builds a JSON response wrapping data in {"inventories": [...]} +func mockInventoriesResponse(items ...map[string]interface{}) []byte { +\tresp := map[string]interface{}{"inventories": items} +\tb, _ := json.Marshal(resp) +\treturn b +} + +// stringPtr returns a pointer to the given string. +func stringPtr(s string) *string { +\treturn &s +} + +// timePtr parses a time string and returns a pointer. +func timePtr(s string) *time.Time { +\tt, _ := time.Parse(time.RFC3339, s) +\treturn &t +} + +// assertEqual is a simple test helper. +func assertEqual(t *testing.T, expected, actual interface{}) { +\tt.Helper() +\tif expected != actual { +\t\tt.Errorf("expected %v, got %v", expected, actual) +\t} +} + +// assertNoError fails the test if err is not nil. +func assertNoError(t *testing.T, err error) { +\tt.Helper() +\tif err != nil { +\t\tt.Fatalf("unexpected error: %v", err) +\t} +} + +// assertContains checks that s contains substr. +func assertContains(t *testing.T, s, substr string) { +\tt.Helper() +\tif !strings.Contains(s, substr) { +\t\tt.Errorf("expected %q to contain %q", s, substr) +\t} +} +''') + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "base_unit_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== Param Tests ======================== + + private SdkFile generateParamTestFile(String prefix, String snakeName, Map resourceInfo) { + def templates = resourceInfo.templates + def paramTemplates = templates.findAll { t -> + !t.isQueryMessage() && t.getActionType() in ['Create', 'Update', 'Add'] + } + if (paramTemplates.isEmpty()) return null + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated param tests. DO NOT EDIT.\n\n") + content.append("package unit_test\n\n") + content.append("import (\n") + content.append("\t\"encoding/json\"\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/zstackio/zstack-sdk-go-v2/pkg/param\"\n") + content.append(")\n\n") + + paramTemplates.each { template -> + String paramStruct = template.getParamStructName() + String detailStruct = template.getDetailParamStructName() + String methodName = template.clzName + + // Marshal test - zero value should produce valid JSON + content.append("func Test${methodName}Param_MarshalJSON(t *testing.T) {\n") + content.append("\tp := param.${paramStruct}{}\n") + content.append("\tdata, err := json.Marshal(p)\n") + content.append("\tassertNoError(t, err)\n") + content.append("\tif len(data) == 0 {\n") + content.append("\t\tt.Fatal(\"marshaled JSON should not be empty\")\n") + content.append("\t}\n") + content.append("\t// Verify it's valid JSON\n") + content.append("\tvar raw map[string]interface{}\n") + content.append("\tassertNoError(t, json.Unmarshal(data, &raw))\n") + content.append("}\n\n") + + // Unmarshal test - minimal JSON should parse without error + content.append("func Test${methodName}Param_UnmarshalJSON(t *testing.T) {\n") + content.append("\tjsonStr := `{}`\n") + content.append("\tvar p param.${paramStruct}\n") + content.append("\terr := json.Unmarshal([]byte(jsonStr), &p)\n") + content.append("\tassertNoError(t, err)\n") + content.append("}\n\n") + } + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "${snakeName}_param_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== View Tests ======================== + + private SdkFile generateViewTestFile(String prefix, String snakeName, Map resourceInfo) { + String viewStructName = resourceInfo.viewStructName + if (viewStructName == null) return null + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated view tests. DO NOT EDIT.\n\n") + content.append("package unit_test\n\n") + content.append("import (\n") + content.append("\t\"encoding/json\"\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/zstackio/zstack-sdk-go-v2/pkg/view\"\n") + content.append(")\n\n") + + // InventoryView unmarshal test + content.append("func Test${viewStructName}_UnmarshalJSON(t *testing.T) {\n") + content.append("\tjsonStr := `{\n") + content.append("\t\t\"uuid\": \"test-uuid-001\",\n") + content.append("\t\t\"name\": \"test-${snakeName}\",\n") + content.append("\t\t\"createDate\": \"2024-01-01T00:00:00.000+08:00\",\n") + content.append("\t\t\"lastOpDate\": \"2024-01-01T00:00:00.000+08:00\"\n") + content.append("\t}`\n") + content.append("\tvar v view.${viewStructName}\n") + content.append("\terr := json.Unmarshal([]byte(jsonStr), &v)\n") + content.append("\tassertNoError(t, err)\n") + content.append("}\n\n") + + // Empty JSON should not error + content.append("func Test${viewStructName}_UnmarshalEmpty(t *testing.T) {\n") + content.append("\tvar v view.${viewStructName}\n") + content.append("\terr := json.Unmarshal([]byte(`{}`), &v)\n") + content.append("\tassertNoError(t, err)\n") + content.append("}\n\n") + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "${snakeName}_view_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== Client Tests ======================== + + private SdkFile generateClientTestFile(String prefix, String snakeName, Map resourceInfo) { + def templates = resourceInfo.templates + if (templates.isEmpty()) return null + + String viewStructName = resourceInfo.viewStructName + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated client tests. DO NOT EDIT.\n\n") + content.append("package unit_test\n\n") + content.append("import (\n") + content.append("\t\"net/http\"\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/zstackio/zstack-sdk-go-v2/pkg/param\"\n") + content.append(")\n\n") + content.append("var _ = param.BaseParam{} // avoid unused import\n\n") + + templates.each { template -> + String actionType = template.getActionType() + String methodName = template.clzName + + if (template.isQueryMessage()) { + // Query test + content.append(generateQueryClientTest(prefix, methodName, snakeName)) + // Get test (derived from Query) + String getMethodName = methodName.replaceFirst('^Query', 'Get') + content.append(generateGetClientTest(prefix, getMethodName, snakeName)) + } else if (actionType == 'Create' || actionType == 'Add') { + content.append(generateCreateClientTest(prefix, methodName, snakeName, template.getParamStructName())) + } else if (actionType == 'Update' || actionType == 'Change') { + content.append(generateUpdateClientTest(prefix, methodName, snakeName, template.getParamStructName())) + } else if (actionType == 'Delete') { + content.append(generateDeleteClientTest(prefix, methodName, snakeName)) + } + } + + if (content.toString().count("func Test") == 0) return null + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "${snakeName}_client_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + private String generateQueryClientTest(String prefix, String methodName, String snakeName) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodGet, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoriesResponse(map[string]interface{}{ +\t\t\t"uuid": "test-uuid-001", +\t\t\t"name": "test-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() + +\tqueryParam := param.NewQueryParam() +\tresult, err := cli.${methodName}(&queryParam) +\tassertNoError(t, err) +\tif len(result) == 0 { +\t\tt.Fatal("expected at least one result") +\t} +\tassertEqual(t, "test-uuid-001", result[0].UUID) +} + +""" + } + + private String generateGetClientTest(String prefix, String methodName, String snakeName) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodGet, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoriesResponse(map[string]interface{}{ +\t\t\t"uuid": "test-uuid-001", +\t\t\t"name": "test-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() + +\tresult, err := cli.${methodName}("test-uuid-001") +\tassertNoError(t, err) +\tassertEqual(t, "test-uuid-001", result.UUID) +} + +""" + } + + private String generateCreateClientTest(String prefix, String methodName, String snakeName, String paramStruct) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodPost, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoryResponse(map[string]interface{}{ +\t\t\t"uuid": "new-uuid-001", +\t\t\t"name": "test-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() + +\tresult, err := cli.${methodName}(param.${paramStruct}{}) +\tassertNoError(t, err) +\tassertEqual(t, "new-uuid-001", result.UUID) +} + +""" + } + + private String generateUpdateClientTest(String prefix, String methodName, String snakeName, String paramStruct) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodPut, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoryResponse(map[string]interface{}{ +\t\t\t"uuid": "test-uuid-001", +\t\t\t"name": "updated-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() + +\tresult, err := cli.${methodName}("test-uuid-001", param.${paramStruct}{}) +\tassertNoError(t, err) +\tassertEqual(t, "test-uuid-001", result.UUID) +} + +""" + } + + private String generateDeleteClientTest(String prefix, String methodName, String snakeName) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodDelete, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write([]byte(`{}`)) +\t}) +\tdefer cleanup() + +\terr := cli.${methodName}("test-uuid-001", param.DeleteModePermissive) +\tassertNoError(t, err) +} + +""" + } + + // ======================== Integration Tests ======================== + + private SdkFile generateBaseIntegrationTestFile() { + def content = new StringBuilder() + content.append('''\ +// Copyright (c) ZStack.io, Inc. +// Auto-generated integration test infrastructure. DO NOT EDIT. + +package integration_test + +import ( +\t"os" +\t"testing" + +\t"github.com/kataras/golog" +\t"github.com/zstackio/zstack-sdk-go-v2/pkg/client" +) + +const ( +\tdefaultHostname = "localhost" +\tdefaultAccount = "admin" +\tdefaultPassword = "password" +) + +var testCli *client.ZSClient + +func TestMain(m *testing.M) { +\thostname := os.Getenv("ZSTACK_HOST") +\tif hostname == "" { +\t\thostname = defaultHostname +\t} +\taccount := os.Getenv("ZSTACK_ACCOUNT") +\tif account == "" { +\t\taccount = defaultAccount +\t} +\tpassword := os.Getenv("ZSTACK_PASSWORD") +\tif password == "" { +\t\tpassword = defaultPassword +\t} + +\tconfig := client.DefaultZSConfig(hostname). +\t\tLoginAccount(account, password). +\t\tDebug(true) +\ttestCli = client.NewZSClient(config) + +\t_, err := testCli.Login() +\tif err != nil { +\t\tgolog.Errorf("Integration test login failed: %v", err) +\t\tos.Exit(1) +\t} +\tdefer testCli.Logout() + +\tos.Exit(m.Run()) +} +''') + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/integration_test/" + sdkFile.fileName = "base_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + private SdkFile generateIntegrationTestFile(String prefix, String snakeName, Map resourceInfo) { + def templates = resourceInfo.templates + + // Only generate for resources that have a Query API + def queryTemplate = templates.find { it.isQueryMessage() } + if (queryTemplate == null) return null + + String fileName = "${snakeName}_query_test.go" + + // Track generated filenames to avoid collisions within this run + if (generatedIntegrationFiles.contains(fileName)) { + logger.warn("[GoSDK-Test] Skipping duplicate integration test file: ${fileName}") + return null + } + generatedIntegrationFiles.add(fileName) + + String methodName = queryTemplate.clzName // e.g. "QueryCluster" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated integration tests. DO NOT EDIT.\n\n") + content.append("package integration_test\n\n") + content.append("import (\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/kataras/golog\"\n\n") + content.append("\t\"github.com/zstackio/zstack-sdk-go-v2/pkg/param\"\n") + content.append(")\n\n") + + // Query test + content.append("func Test${methodName}(t *testing.T) {\n") + content.append("\tqueryParam := param.NewQueryParam()\n") + content.append("\tresult, err := testCli.${methodName}(&queryParam)\n") + content.append("\tif err != nil {\n") + content.append("\t\tt.Errorf(\"Test${methodName} error: %v\", err)\n") + content.append("\t\treturn\n") + content.append("\t}\n") + content.append("\tgolog.Infof(\"${methodName} result count: %d\", len(result))\n") + content.append("}\n\n") + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/integration_test/" + sdkFile.fileName = fileName + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== Utilities ======================== + + private String toSnakeCase(String name) { + return name.replaceAll('([a-z])([A-Z])', '$1_$2').toLowerCase() + } +} From 7952931d4c811454e79adf8b74b46817ca86226b Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Wed, 25 Feb 2026 21:48:29 +0800 Subject: [PATCH 32/85] [ai]: add shareMode field to AddModelAction SDK Resolves: ZSTAC-79023 Change-Id: Iffa4d69d711555d77978ce869d064dfd79448113 --- sdk/src/main/java/org/zstack/sdk/AddModelAction.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/src/main/java/org/zstack/sdk/AddModelAction.java b/sdk/src/main/java/org/zstack/sdk/AddModelAction.java index 993a073b9c3..6e3d3d27bc2 100644 --- a/sdk/src/main/java/org/zstack/sdk/AddModelAction.java +++ b/sdk/src/main/java/org/zstack/sdk/AddModelAction.java @@ -70,6 +70,9 @@ public Result throwExceptionIfError() { @Param(required = false, maxLength = 255, nonempty = false, nullElements = false, emptyString = true, noTrim = false) public java.lang.String modelId; + @Param(required = false, validValues = {"Public"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String shareMode; + @Param(required = false) public java.lang.String resourceUuid; From 709bbc576cbb79421dd53bcd2d82c6fe668a259b Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Wed, 25 Feb 2026 17:26:09 +0800 Subject: [PATCH 33/85] [expon]: fix vhost installPath overwrite and test cleanup Fix CBD KvmCbdNodeServer overwriting vhost volume installPath and add Expon/Xinfini 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 - Added ExternalPrimaryStorageFactory null host guard - Added XinfiniPrimaryStorageCase integration test 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 scheme support for ExponStorageController - expon/sdk: update ExponClient and ExponConnectConfig - storage: add null host check in ExternalPSFactory - test: fix vol2 cleanup and update test assertions - test: add XinfiniPrimaryStorageCase - testlib: add Expon/Xinfini API simulators Related: ZSTAC-82153 Change-Id: I14f8d5c9155dccf2803566d133813ca5675feb76 --- .../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 + .../storage/primary/PrimaryStorageTest.groovy | 2 + .../expon/ExponPrimaryStorageCase.groovy | 72 +- .../xinfini/XinfiniPrimaryStorageCase.groovy | 634 +++++++++ .../testlib/ExternalPrimaryStorageSpec.groovy | 1218 +++++++++++++++++ .../java/org/zstack/testlib/SpringSpec.groovy | 4 + .../main/java/org/zstack/testlib/Test.groovy | 2 + 12 files changed, 1923 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/PrimaryStorageTest.groovy b/test/src/test/groovy/org/zstack/test/integration/storage/primary/PrimaryStorageTest.groovy index 75a789925a4..ee1c9309406 100755 --- a/test/src/test/groovy/org/zstack/test/integration/storage/primary/PrimaryStorageTest.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/storage/primary/PrimaryStorageTest.groovy @@ -14,6 +14,8 @@ class PrimaryStorageTest extends Test { smp() ceph() externalPrimaryStorage() + expon() + xinfini() zbs() virtualRouter() vyos() 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..fc924175afa --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/xinfini/XinfiniPrimaryStorageCase.groovy @@ -0,0 +1,634 @@ +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.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() { + detachPrimaryStorageFromCluster { + primaryStorageUuid = ps.uuid + clusterUuid = cluster.uuid + } + + deletePrimaryStorage { + uuid = ps.uuid + } + } + + 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..41a4bdb9e4e 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,1221 @@ 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 ConcurrentHashMap vhostNameToId = new ConcurrentHashMap<>() + // Track iSCSI client group to snapshot mappings: clientId -> Set + static ConcurrentHashMap> iscsiClientSnapshots = new ConcurrentHashMap<>() + static AtomicInteger volumeCounter = new AtomicInteger(0) + static AtomicInteger snapshotCounter = new AtomicInteger(0) + + static void clear() { + volumes.clear() + snapshots.clear() + vhostBoundUss.clear() + vhostNameToId.clear() + iscsiClientSnapshots.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 nameParam = req.getParameter("name") + if (nameParam != null) { + // Query by name + def clientId = "test-iscsi-client-" + nameParam + int snapNum = iscsiClientSnapshots.getOrDefault(clientId, Collections.emptySet()).size() + return [ret_code: "0", message: "", total: 1, clients: [ + [id: clientId, name: nameParam, status: "health", hosts: [], + iscsi_gw_count: 0, snap_num: snapNum, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + // Query all - return all tracked clients with snapshots + def allClients = iscsiClientSnapshots.collect { cId, snaps -> + [id: cId, name: cId, status: "health", hosts: [], + iscsi_gw_count: 0, snap_num: snaps.size(), + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + } + if (allClients.isEmpty()) { + allClients = [[id: "test-iscsi-client-default", name: "iscsi-client-default", status: "health", hosts: [], + iscsi_gw_count: 0, snap_num: 0, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()]] + } + return [ret_code: "0", message: "", total: allClients.size(), clients: allClients] + } + + // 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 (including snapshot attachment) + simulator("/api/v2/(sync/)?block/iscsi/clients/[^/]+(/.*)?") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + def matcher = (uri =~ /\/block\/iscsi\/clients\/([^\/]+)/) + def clientId = matcher ? matcher[0][1] : "" + + // Handle snapshot add/remove: PUT /block/iscsi/clients/{id}/snapshots + if (uri.contains("/snapshots") && "PUT".equalsIgnoreCase(req.getMethod())) { + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def action = body?.action + def luns = body?.luns ?: [] + luns.each { lun -> + def snapId = lun?.id ?: lun?.lun_id + if (snapId != null) { + if ("add".equals(action)) { + iscsiClientSnapshots.computeIfAbsent(clientId, { ConcurrentHashMap.newKeySet() }).add(snapId.toString()) + } else if ("remove".equals(action)) { + iscsiClientSnapshots.getOrDefault(clientId, Collections.emptySet()).remove(snapId.toString()) + } + } + } + return [ret_code: "0", message: ""] + } + + // Handle snapshot query: GET /block/iscsi/clients/{id}/snapshots + if (uri.contains("/snapshots") && "GET".equalsIgnoreCase(req.getMethod())) { + def snaps = iscsiClientSnapshots.getOrDefault(clientId, Collections.emptySet()) + def lunList = snaps.collect { snapId -> [id: snapId, lun_id: snapId] } + return [ret_code: "0", message: "", total: lunList.size(), luns: lunList, snapshots: lunList] + } + + // Handle gateways query: GET /block/iscsi/clients/{id}/gateways + if (uri.contains("/gateways") && "GET".equalsIgnoreCase(req.getMethod())) { + return [ret_code: "0", message: "", total: 1, gateways: [ + [id: "test-iscsi-target-default", name: "iscsi-target-default", + iqn: "iqn.2022-07.com.expontech.wds:test-iscsi-target-default", + port: 3260, run_status: "normal", + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + ]] + } + + // Handle client GET by ID: GET /block/iscsi/clients/{id} + if ("GET".equalsIgnoreCase(req.getMethod())) { + int snapNum = iscsiClientSnapshots.getOrDefault(clientId, Collections.emptySet()).size() + return [ret_code: "0", message: "", id: clientId, name: clientId, hosts: [], + run_status: "normal", snap_num: snapNum, vol_num: 0, iscsi_gw_count: 1, + create_time: System.currentTimeMillis(), update_time: System.currentTimeMillis()] + } + + 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") + if (vhostName != null && !vhostNameToId.containsKey(vhostName)) { + return [ret_code: "0", message: "", total: 0, vhosts: []] + } + vhostName = vhostName ?: "vhost-default" + def vhostId = vhostNameToId.getOrDefault(vhostName, "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 + vhostNameToId.put(vhostName, vhostId) + return [ret_code: "0", message: "", id: vhostId, name: vhostName] + } + + // Vhost controller operations with id (including bind/unbind USS, DELETE) + simulator("/api/v2/(sync/)?block/vhost/[^/]+(/.*)?") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def uri = req.getRequestURI() + + // Handle DELETE vhost controller + if ("DELETE".equalsIgnoreCase(req.getMethod())) { + def matcher = (uri =~ /\/block\/vhost\/([^\/]+)/) + def vhostId = matcher ? matcher[0][1] : null + if (vhostId != null) { + vhostNameToId.entrySet().removeIf { it.value == vhostId } + vhostBoundUss.remove(vhostId) + } + return [ret_code: "0", message: ""] + } + + if (uri.contains("bind_uss") && !uri.contains("vhost_binded_uss")) { + def body = JSONObjectUtil.toObject(e.body, LinkedHashMap.class) + def vhostId = body?.vhost_id + if (vhostId != null) { + if (uri.contains("unbind_uss")) { + vhostBoundUss.remove(vhostId) + } else { + vhostBoundUss.add(vhostId) + } + } + return [ret_code: "0", message: ""] + } + 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() { + throw new HttpError(404, "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)) + } + // Try extracting ID before /:action like /afa/v1/bs-volumes/5/:add-client-group-mappings + matcher = (uri =~ /\/(\d+)\/:[a-z-]+/) + 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 -> + def now = java.time.OffsetDateTime.now().toString() + 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, created_at: now, updated_at: now], + status: [id: POOL_ID] + ] + ]) + } + + // 4. GET /afa/v1/pools/{id} - GetPoolRequest + simulator("/afa/v1/pools/\\d+") { HttpServletRequest req, HttpEntity e, EnvSpec spec -> + def now = java.time.OffsetDateTime.now().toString() + 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, created_at: now, updated_at: now], + 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}".toString(), + 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}".toString(), 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}" + + // Also create iSCSI clients for each client code (IQN) + def clientCodes = specData?.iscsi_client_codes ?: [] + String targetIqn = "iqn.2022-07.com.xinfini:target-${groupId}".toString() + clientCodes.each { code -> + int cId = iscsiClientCounter.incrementAndGet() + def cSpec = [id: cId, name: "iscsi-client-${cId}".toString(), code: code, iscsi_client_group_id: groupId] + def cStatus = [id: cId, target_iqns: [targetIqn]] + def cItem = [spec: cSpec, status: cStatus] + iscsiClients.put(cId, cItem) + } + + 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}".toString(), 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}".toString(), 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}".toString(), 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}".toString(), 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") diff --git a/testlib/src/main/java/org/zstack/testlib/Test.groovy b/testlib/src/main/java/org/zstack/testlib/Test.groovy index 1dcb02c54e9..dc3b369f8b2 100755 --- a/testlib/src/main/java/org/zstack/testlib/Test.groovy +++ b/testlib/src/main/java/org/zstack/testlib/Test.groovy @@ -120,6 +120,8 @@ abstract class Test extends ApiHelper implements Retry { lb() nfsPrimaryStorage() externalPrimaryStorage() + expon() + xinfini() zbs() eip() portForwarding() From 89f989a4ecfe8f9979ba1e0393f05be7f15dd6db Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Wed, 25 Feb 2026 16:31:14 +0800 Subject: [PATCH 34/85] [storage]: fix wrong BS selected in mixed VCenter env When SelectBackupStorageMsg carries preferBsTypes, the sorting logic uses indexOf() which returns -1 for non-preferred types (e.g. VCenter BS), causing them to sort before preferred ones. This fix filters backup storages by preferBsTypes first, then sorts within the filtered set. Also adds error code ADDON_PRIMARY_10015 when no matching backup storage is available. 1. Why is this change necessary? SelectBackupStorageMsg.preferBsTypes is used to sort backup storages via indexOf() on the preference list. However, indexOf() returns -1 for types not in the list, which sorts non-preferred types (like VCenter BS) before preferred ones. In a mixed VCenter + Expon environment, this causes VCenter BS to be incorrectly selected over the intended Expon BS. 2. How does it address the problem? Instead of only sorting, the code now first filters the candidate backup storages to only include those whose bsType matches the preferBsTypes list. Then it sorts within the filtered set. A new error code ADDON_PRIMARY_10015 is added for the case where no matching backup storage is available after filtering. All 10 locale i18n JSON files are updated. 3. Are there any side effects? None. The filtering is only applied when preferBsTypes is non-empty, preserving existing behavior for other callers. # Summary of changes (by module): - storage: filter BS candidates by preferBsTypes before sort - utils: add ADDON_PRIMARY_10015 error code constant - conf/i18n: add i18n entries for new error code (10 locales) - test: add ExternalPrimaryStorageSelectBackupStorageCase Related: ZSTAC-71706 Change-Id: Ia3af38cc50e69132a1c769180792363495c1080f --- .../global-error-de-DE.json | 1 + .../global-error-en_US.json | 1 + .../global-error-fr-FR.json | 1 + .../global-error-id-ID.json | 1 + .../global-error-ja-JP.json | 1 + .../global-error-ko-KR.json | 1 + .../global-error-ru-RU.json | 1 + .../global-error-th-TH.json | 1 + .../global-error-zh_CN.json | 1 + .../global-error-zh_TW.json | 1 + .../addon/primary/ExternalPrimaryStorage.java | 15 +- ...imaryStorageSelectBackupStorageCase.groovy | 200 ++++++++++++++++++ .../CloudOperationsErrorCode.java | 2 + 13 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/ExternalPrimaryStorageSelectBackupStorageCase.groovy diff --git a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json index 41e1915fedf..f23ec0e9e1a 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "Instanztyp[%s] wird nicht unterstützt", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "Gruppenadresse [%s] ist keine Multicast-Gruppenadresse", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "Speicher ist nicht gesund:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "Kein verfügbarer Backup-Speicher mit bevorzugten Typen %s für primären Speicher[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "multicastRouter[uuid:%s] wurde keinem VPC-Router zugeordnet", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "Multicast ist bereits auf dem VPC-Router UUID[:%s] aktiviert", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "Der Eigentümer-Volume-Pfad kann aus dem internen Snapshot-Pfad[%s] nicht gefunden werden, da der reguläre Ausdruck[%s] nicht mit dem Snapshot-Pfad übereinstimmt", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json index 715e823d95e..5e592525205 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "unsupported instance type[%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "group address [%s] is not a multicast group address", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "storage is not healthy:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "no available backup storage with preferred types %s for primary storage[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "multicastRouter[uuid:%s] has not been associated with a VPC router", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "multicast already enabled on VPC router UUID[:%s]", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "cannot find the owning volume path from the internal snapshot path[%s], as the regex[%s] fails to match the snapshot path", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json index 449344a6cef..a742212f406 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "type d'instance[%s] non supporté", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "l'adresse de groupe [%s] n'est pas une adresse de groupe multicast", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "le stockage n'est pas sain : %s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "aucun stockage de sauvegarde disponible avec les types préférés %s pour le stockage principal[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "Le multicastRouter[uuid:%s] n'a pas été associé à un routeur VPC", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "Le multicast est déjà activé sur le routeur VPC UUID[:%s]", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "Impossible de trouver le chemin du volume propriétaire à partir du chemin d'instantané interne[%s], car l'expression régulière[%s] ne correspond pas au chemin d'instantané", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json index d8cd5bf487a..58d87228b7a 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "jenis instance[%s] tidak didukung", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "alamat grup [%s] bukan alamat grup multicast", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "penyimpanan tidak sehat:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "tidak ada penyimpanan cadangan yang tersedia dengan tipe yang disukai %s untuk penyimpanan utama[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "multicastRouter[uuid:%s] belum dikaitkan dengan router VPC", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "multicast sudah diaktifkan pada router VPC UUID[:%s]", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "tidak dapat menemukan jalur volume pemilik dari jalur snapshot internal[%s], karena regex[%s] tidak cocok dengan jalur snapshot", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json index 2924f658479..a935c05b202 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "サポートされていないインスタンスタイプ[%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "グループアドレス[%s]はマルチキャストグループアドレスではありません", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "ストレージは正常ではありません:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "優先タイプ%sに一致する利用可能なバックアップストレージがプライマリストレージ[uuid:%s]に存在しません", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "マルチキャストルーター[uuid:%s]はVPCルーターに関連付けられていません", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "マルチキャストは既にVPCルーターUUID[:%s]で有効化されています", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "内部スナップショットパス[%s]から所有ボリュームパスが見つかりません。正規表現[%s]がスナップショットパスに一致しません", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json index c73d9f274a1..ad11aec2ddc 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "지원되지 않는 인스턴스 유형[%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "그룹 주소 [%s]는 멀티캐스트 그룹 주소가 아닙니다", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "저장소가 정상 상태가 아닙니다:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "기본 저장소[uuid:%s]에 대해 선호하는 유형 %s의 사용 가능한 백업 저장소가 없습니다", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "multicastRouter[uuid:%s]가 VPC 라우터와 연결되지 않았습니다", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "VPC 라우터 UUID[:%s]에서 멀티캐스트가 이미 활성화되어 있습니다", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "정규식[%s]이 스냅샷 경로와 일치하지 않아 내부 스냅샷 경로[%s]에서 소유 볼륨 경로를 찾을 수 없습니다", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json index 2454d7e5658..6bea92476a4 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "неподдерживаемый тип экземпляра[%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "групповой адрес [%s] не является мультикаст-групповым адресом", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "хранилище нездорово:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "нет доступного резервного хранилища с предпочитаемыми типами %s для первичного хранилища[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "multicastRouter[uuid:%s] не был связан с VPC-роутером", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "multicast уже включен на VPC-роутере UUID[:%s]", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "невозможно найти путь к принадлежащему тому из внутреннего пути снимка[%s], так как регулярное выражение[%s] не соответствует пути снимка", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json index 9db32b34264..09f39b96ccc 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "instance type[%s] ไม่รองรับ", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "group address [%s] ไม่ใช่ multicast group address", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "storage ไม่พร้อมใช้งาน:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "ไม่มี backup storage ที่ใช้ได้ซึ่งมีประเภทที่ต้องการ %s สำหรับ primary storage[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "multicastRouter[uuid:%s] ไม่ได้เชื่อมโยงกับ VPC router", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "multicast เปิดใช้งานอยู่แล้วบน VPC router UUID[:%s]", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "ไม่พบ path ของ volume ที่เป็นเจ้าของจาก internal snapshot path[%s] เนื่องจาก regex[%s] ไม่สามารถจับคู่กับ snapshot path ได้", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json index 01960e8eb45..94747404ca9 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "不支持的实例配置类型[%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "组地址 [%s] 不是多播地址", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "存储不健康:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "没有可用的镜像服务器匹配首选类型 %s,主存储[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "多播路由器[uuid:%s]尚未绑定到VPC路由器", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "多播已在VPC路由器uuid[:%s]上启用", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "无法从内部快照路径[%s]找到所属卷路径,因为正则表达式[%s]与快照路径不匹配", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json index 35f1b8c8443..57157f5d3ae 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json @@ -629,6 +629,7 @@ "ORG_ZSTACK_CONFIGURATION_10006": "不支持的实例配置類型[%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10007": "组地址 [%s] 不是多播地址", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014": "儲儲不健康:%s", + "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015": "沒有可用的鏡像服務器匹配首選類型 %s,主儲存[uuid:%s]", "ORG_ZSTACK_MULTICAST_ROUTER_10000": "多播路由器[uuid:%s]尚未綁定到VPC路由器", "ORG_ZSTACK_MULTICAST_ROUTER_10001": "多播已在VPC路由器uuid[:%s]上啟用", "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10012": "無法从内部快照路径[%s]找到所属卷路径,因为正则表達式[%s]与快照路径不匹配", diff --git a/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorage.java b/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorage.java index 673cfed7c12..332b10331a1 100644 --- a/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorage.java +++ b/storage/src/main/java/org/zstack/storage/addon/primary/ExternalPrimaryStorage.java @@ -510,8 +510,19 @@ private void handle(final SelectBackupStorageMsg msg) { .param("size", msg.getRequiredSize()) .list(); - // sort by prefer type - availableBs.sort(Comparator.comparingInt(o -> preferBsTypes.indexOf(o.getType()))); + // filter out non-preferred types, then sort by preference order + availableBs = availableBs.stream() + .filter(bs -> preferBsTypes.contains(bs.getType())) + .sorted(Comparator.comparingInt(o -> preferBsTypes.indexOf(o.getType()))) + .collect(Collectors.toList()); + + if (availableBs.isEmpty()) { + reply.setError(operr(ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015, + "no available backup storage with preferred types %s for primary storage[uuid:%s]", + preferBsTypes, self.getUuid())); + bus.reply(msg, reply); + return; + } reply.setInventory(BackupStorageInventory.valueOf(availableBs.get(0))); bus.reply(msg, reply); diff --git a/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/ExternalPrimaryStorageSelectBackupStorageCase.groovy b/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/ExternalPrimaryStorageSelectBackupStorageCase.groovy new file mode 100644 index 00000000000..9419db12215 --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/storage/primary/addon/ExternalPrimaryStorageSelectBackupStorageCase.groovy @@ -0,0 +1,200 @@ +package org.zstack.test.integration.storage.primary.addon + +import org.zstack.core.Platform +import org.zstack.core.cloudbus.CloudBus +import org.zstack.core.db.DatabaseFacade +import org.zstack.core.db.SQL +import org.zstack.header.message.MessageReply +import org.zstack.header.storage.backup.BackupStorageEO +import org.zstack.header.storage.backup.BackupStorageState +import org.zstack.header.storage.backup.BackupStorageStatus +import org.zstack.header.storage.backup.BackupStorageZoneRefVO +import org.zstack.header.storage.backup.BackupStorageZoneRefVO_ +import org.zstack.header.storage.primary.PrimaryStorageConstant +import org.zstack.header.storage.primary.SelectBackupStorageMsg +import org.zstack.header.storage.primary.SelectBackupStorageReply +import org.zstack.sdk.PrimaryStorageInventory +import org.zstack.sdk.ZoneInventory +import org.zstack.test.integration.storage.StorageTest +import org.zstack.testlib.EnvSpec +import org.zstack.testlib.SubCase +import org.zstack.utils.data.SizeUnit + +/** + * ZSTAC-71706: ExternalPrimaryStorage backup storage selection in mixed environment. + * + * Bug: List.indexOf() returns -1 for types not in preferBsTypes, + * causing ascending sort to place non-preferred types (e.g. VCenterBackupStorage) + * before preferred types in the sorted result. + * + * Fix: Filter out non-preferred backup storage types before sorting by preference. + * + * This case sets up a ZBS ExternalPrimaryStorage (preferBsTypes = [ImageStoreBackupStorage]) + * with multiple backup storage types attached to the zone, then sends SelectBackupStorageMsg + * via CloudBus to verify the handler selects the correct preferred backup storage. + */ +class ExternalPrimaryStorageSelectBackupStorageCase extends SubCase { + EnvSpec env + ZoneInventory zone + PrimaryStorageInventory ps + DatabaseFacade dbf + CloudBus bus + List manualBsUuids = [] + + @Override + void clean() { + manualBsUuids.each { uuid -> + SQL.New(BackupStorageZoneRefVO.class) + .eq(BackupStorageZoneRefVO_.backupStorageUuid, uuid) + .hardDelete() + dbf.removeByPrimaryKey(uuid, BackupStorageEO.class) + } + env.delete() + } + + @Override + void setup() { + useSpring(StorageTest.springSpec) + } + + @Override + void environment() { + env = makeEnv { + 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 + } + } + + zone { + name = "zone" + description = "test" + + cluster { + name = "cluster" + hypervisorType = "KVM" + + kvm { + name = "kvm" + managementIp = "127.0.0.1" + 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" + } + } + } + + externalPrimaryStorage { + name = "zbs-ps" + identity = "zbs" + defaultOutputProtocol = "CBD" + config = '{"mdsUrls":["root:password@127.0.1.1","root:password@127.0.1.2","root:password@127.0.1.3"],"logicalPoolName":"lpool1"}' + url = "zbs" + } + + attachBackupStorage("sftp") + } + } + } + + @Override + void test() { + env.create { + zone = env.inventoryByName("zone") as ZoneInventory + ps = env.inventoryByName("zbs-ps") as PrimaryStorageInventory + dbf = bean(DatabaseFacade.class) + bus = bean(CloudBus.class) + + testErrorWhenNoPreferredTypeAvailable() + testSelectPreferredOverNonPreferred() + } + } + + /** + * When only non-preferred backup storage types exist in the zone, + * the selection should return an error (no matching preferred types). + * Zone has SftpBackupStorage (from env) and VCenterBackupStorage, + * neither of which is in zbs's preferBsTypes [ImageStoreBackupStorage]. + */ + void testErrorWhenNoPreferredTypeAvailable() { + createAndAttachBackupStorage("vcenter-bs", "VCenterBackupStorage") + + SelectBackupStorageMsg msg = new SelectBackupStorageMsg() + msg.setPrimaryStorageUuid(ps.uuid) + msg.setRequiredSize(SizeUnit.MEGABYTE.toByte(1)) + bus.makeTargetServiceIdByResourceUuid(msg, PrimaryStorageConstant.SERVICE_ID, ps.uuid) + MessageReply reply = bus.call(msg) + + assert !reply.isSuccess() : "Should fail when no preferred BS type is available" + } + + /** + * Reproduces ZSTAC-71706: zone has both ImageStoreBackupStorage (preferred) + * and VCenterBackupStorage (non-preferred, created in previous test). + * Before the fix, indexOf() returns -1 for VCenterBackupStorage causing + * it to sort before ImageStoreBackupStorage. After the fix, non-preferred + * types are filtered out entirely, and ImageStoreBackupStorage is correctly selected. + */ + void testSelectPreferredOverNonPreferred() { + createAndAttachBackupStorage("imagestore-bs", "ImageStoreBackupStorage") + + SelectBackupStorageMsg msg = new SelectBackupStorageMsg() + msg.setPrimaryStorageUuid(ps.uuid) + msg.setRequiredSize(SizeUnit.MEGABYTE.toByte(1)) + bus.makeTargetServiceIdByResourceUuid(msg, PrimaryStorageConstant.SERVICE_ID, ps.uuid) + MessageReply reply = bus.call(msg) + + assert reply.isSuccess() : "SelectBackupStorageMsg should succeed" + SelectBackupStorageReply bsReply = reply as SelectBackupStorageReply + assert bsReply.inventory != null + assert bsReply.inventory.type == "ImageStoreBackupStorage" : + "Should select preferred ImageStoreBackupStorage, but got ${bsReply.inventory.type}" + } + + private void createAndAttachBackupStorage(String name, String type) { + String uuid = Platform.getUuid() + + def bsEo = new BackupStorageEO() + bsEo.setUuid(uuid) + bsEo.setName(name) + bsEo.setType(type) + bsEo.setState(BackupStorageState.Enabled) + bsEo.setStatus(BackupStorageStatus.Connected) + bsEo.setTotalCapacity(SizeUnit.TERABYTE.toByte(100)) + bsEo.setAvailableCapacity(SizeUnit.TERABYTE.toByte(100)) + bsEo.setUrl("http://test-" + name) + dbf.persist(bsEo) + + def ref = new BackupStorageZoneRefVO() + ref.setBackupStorageUuid(uuid) + ref.setZoneUuid(zone.uuid) + dbf.persist(ref) + + manualBsUuids.add(uuid) + } +} diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index 73fb1fd99e6..6a4087cae00 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -6506,6 +6506,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014 = "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10014"; + public static final String ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015 = "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10015"; + public static final String ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10040 = "ORG_ZSTACK_STORAGE_ADDON_PRIMARY_10040"; public static final String ORG_ZSTACK_NETWORK_HOSTNETWORKINTERFACE_LLDP_10000 = "ORG_ZSTACK_NETWORK_HOSTNETWORKINTERFACE_LLDP_10000"; From 8455f36fdb0f6522928957897f5b5607840e34de Mon Sep 17 00:00:00 2001 From: "lin.ma" Date: Fri, 27 Feb 2026 14:08:39 +0800 Subject: [PATCH 35/85] [errorcode]: global error code i18n Add global error code internationalization support that translates ErrorCode messages based on the client's Accept-Language header. Core changes: - Add GlobalErrorCodeI18nService interface and implementation that loads locale-specific JSON templates from classpath at startup - Add LocaleUtils to parse Accept-Language headers and resolve the best matching locale with quality-based priority - Extend ErrorCode with message and formatArgs fields to carry localized text and format parameters - Wire Platform.err to populate formatArgs when args are present REST integration: - RESTApiController: localize ErrorCode for both MessageReply and APIEvent responses, resolve locale from Accept-Language header - RestServer: localize error responses using LocaleUtils, use LocaleUtils.DEFAULT_LOCALE constant instead of hardcoded string Robustness: - Filter Accept-Language entries with quality <= 0 - Defensive copy of formatArgs in ErrorCode getter/setter/copy-ctor - try-finally for HttpURLConnection in integration tests - Log exception stacktrace in localization failure catch block Tests: - Unit tests for LocaleUtils, GlobalErrorCodeI18nService - Integration test ErrorCodeI18nCase covering sync/async API and Accept-Language fallback behavior Resolves: ZSTAC-81675 Change-Id: I706d6d6b6f687662656e6576736d73676c7a6e6a --- conf/springConfigXml/Error.xml | 5 + .../main/java/org/zstack/core/Platform.java | 5 + .../errorcode/GlobalErrorCodeI18nService.java | 29 ++ .../GlobalErrorCodeI18nServiceImpl.java | 152 +++++++++ .../zstack/core/errorcode/LocaleUtils.java | 119 +++++++ .../zstack/core/rest/RESTApiController.java | 47 ++- .../zstack/header/errorcode/ErrorCode.java | 23 ++ .../main/java/org/zstack/rest/RestServer.java | 24 ++ .../main/java/org/zstack/sdk/ErrorCode.java | 8 + .../integration/rest/ErrorCodeI18nCase.groovy | 316 ++++++++++++++++++ .../errorcode/TestGlobalErrorCodeI18n.java | 135 ++++++++ .../test/core/errorcode/TestLocaleUtils.java | 67 ++++ 12 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nService.java create mode 100644 core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java create mode 100644 core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java create mode 100644 test/src/test/groovy/org/zstack/test/integration/rest/ErrorCodeI18nCase.groovy create mode 100644 test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java create mode 100644 test/src/test/java/org/zstack/test/core/errorcode/TestLocaleUtils.java diff --git a/conf/springConfigXml/Error.xml b/conf/springConfigXml/Error.xml index a8855a32487..ed5261f8d58 100755 --- a/conf/springConfigXml/Error.xml +++ b/conf/springConfigXml/Error.xml @@ -13,4 +13,9 @@ default-init-method="init" default-destroy-method="destroy"> + + + + + diff --git a/core/src/main/java/org/zstack/core/Platform.java b/core/src/main/java/org/zstack/core/Platform.java index 5cff425e3da..87db543e9a0 100755 --- a/core/src/main/java/org/zstack/core/Platform.java +++ b/core/src/main/java/org/zstack/core/Platform.java @@ -979,6 +979,11 @@ public static ErrorCode err(String globalErrorCode, Enum errCode, ErrorCode caus handleErrorElaboration(errCode, fmt, result, cause, args); addErrorCounter(result); result.setGlobalErrorCode(globalErrorCode); + if (args != null && args.length > 0) { + result.setFormatArgs(java.util.Arrays.stream(args) + .map(a -> a == null ? "null" : a.toString()) + .toArray(String[]::new)); + } return result; } diff --git a/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nService.java b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nService.java new file mode 100644 index 00000000000..52c23a0e088 --- /dev/null +++ b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nService.java @@ -0,0 +1,29 @@ +package org.zstack.core.errorcode; + +import org.zstack.header.errorcode.ErrorCode; + +public interface GlobalErrorCodeI18nService { + /** + * Get localized message for a globalErrorCode. + * + * @param globalErrorCode the global error code key + * @param locale the locale key (e.g. "zh_CN", "ja-JP") + * @param formatArgs optional format arguments for %s placeholders + * @return the localized message, or null if not found + */ + String getLocalizedMessage(String globalErrorCode, String locale, String[] formatArgs); + + /** + * Recursively localize an ErrorCode and its cause chain, + * setting the message field on each ErrorCode. + * + * @param error the ErrorCode to localize + * @param locale the locale key + */ + void localizeErrorCode(ErrorCode error, String locale); + + /** + * Get the set of available locale keys loaded from JSON files. + */ + java.util.Set getAvailableLocales(); +} diff --git a/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java new file mode 100644 index 00000000000..f4def1fc071 --- /dev/null +++ b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java @@ -0,0 +1,152 @@ +package org.zstack.core.errorcode; + +import org.zstack.header.Component; +import org.zstack.header.errorcode.ErrorCode; +import org.zstack.header.errorcode.ErrorCodeList; +import org.zstack.utils.Utils; +import org.zstack.utils.gson.JSONObjectUtil; +import org.zstack.utils.logging.CLogger; +import org.zstack.utils.path.PathUtil; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class GlobalErrorCodeI18nServiceImpl implements GlobalErrorCodeI18nService, Component { + private static final CLogger logger = Utils.getLogger(GlobalErrorCodeI18nServiceImpl.class); + + private static final String I18N_FOLDER = "i18n" + File.separator + "globalErrorCodeMapping"; + private static final String FILE_PREFIX = "global-error-"; + private static final String FILE_SUFFIX = ".json"; + + // locale -> (globalErrorCode -> template) + private final Map> localeMessages = new ConcurrentHashMap<>(); + + @Override + public boolean start() { + loadAllJsonFiles(); + return true; + } + + @Override + public boolean stop() { + return true; + } + + private void loadAllJsonFiles() { + try { + List paths = PathUtil.scanFolderOnClassPath(I18N_FOLDER); + for (String path : paths) { + if (!path.endsWith(FILE_SUFFIX)) { + continue; + } + + File file = new File(path); + String fileName = file.getName(); + if (!fileName.startsWith(FILE_PREFIX)) { + continue; + } + + String locale = fileName.substring(FILE_PREFIX.length(), + fileName.length() - FILE_SUFFIX.length()); + + try { + String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + @SuppressWarnings("unchecked") + Map messages = JSONObjectUtil.toObject(content, LinkedHashMap.class); + localeMessages.put(locale, messages); + logger.info(String.format("loaded %d i18n error messages for locale [%s]", + messages.size(), locale)); + } catch (Exception e) { + logger.warn(String.format("failed to load i18n file [%s]: %s", path, e.getMessage()), e); + } + } + } catch (Exception e) { + logger.warn(String.format("failed to scan i18n folder: %s", e.getMessage())); + } + + logger.info(String.format("GlobalErrorCodeI18nService loaded %d locales: %s", + localeMessages.size(), localeMessages.keySet())); + } + + @Override + public Set getAvailableLocales() { + return Collections.unmodifiableSet(localeMessages.keySet()); + } + + @Override + public String getLocalizedMessage(String globalErrorCode, String locale, String[] formatArgs) { + if (globalErrorCode == null || locale == null) { + return null; + } + + String template = getTemplate(globalErrorCode, locale); + if (template == null) { + return null; + } + + return formatTemplate(template, formatArgs); + } + + private String getTemplate(String globalErrorCode, String locale) { + Map messages = localeMessages.get(locale); + if (messages != null) { + String template = messages.get(globalErrorCode); + if (template != null) { + return template; + } + } + + // fallback to en_US + if (!"en_US".equals(locale)) { + Map enMessages = localeMessages.get("en_US"); + if (enMessages != null) { + return enMessages.get(globalErrorCode); + } + } + + return null; + } + + private String formatTemplate(String template, String[] formatArgs) { + if (formatArgs == null || formatArgs.length == 0) { + return template; + } + + try { + return String.format(template, (Object[]) formatArgs); + } catch (Exception e) { + logger.debug(String.format("failed to format i18n template [%s]: %s", template, e.getMessage())); + return template; + } + } + + @Override + public void localizeErrorCode(ErrorCode error, String locale) { + if (error == null || locale == null) { + return; + } + + if (error.getGlobalErrorCode() != null) { + String message = getLocalizedMessage(error.getGlobalErrorCode(), locale, error.getFormatArgs()); + if (message != null) { + error.setMessage(message); + } + } + + if (error.getCause() != null) { + localizeErrorCode(error.getCause(), locale); + } + + if (error instanceof ErrorCodeList) { + List causes = ((ErrorCodeList) error).getCauses(); + if (causes != null) { + for (ErrorCode cause : causes) { + localizeErrorCode(cause, locale); + } + } + } + } +} diff --git a/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java b/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java new file mode 100644 index 00000000000..8820eadbfbe --- /dev/null +++ b/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java @@ -0,0 +1,119 @@ +package org.zstack.core.errorcode; + +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.*; + +public class LocaleUtils { + private static final CLogger logger = Utils.getLogger(LocaleUtils.class); + public static final String DEFAULT_LOCALE = "en_US"; + + private static final Map LANGUAGE_TO_LOCALE = new HashMap<>(); + + // Languages that use underscore format in locale file names (e.g. zh_CN, en_US) + private static final Set UNDERSCORE_LANGS = new HashSet<>(Arrays.asList("zh", "en")); + + static { + LANGUAGE_TO_LOCALE.put("zh", "zh_CN"); + LANGUAGE_TO_LOCALE.put("en", "en_US"); + LANGUAGE_TO_LOCALE.put("ja", "ja-JP"); + LANGUAGE_TO_LOCALE.put("ko", "ko-KR"); + LANGUAGE_TO_LOCALE.put("de", "de-DE"); + LANGUAGE_TO_LOCALE.put("fr", "fr-FR"); + LANGUAGE_TO_LOCALE.put("ru", "ru-RU"); + LANGUAGE_TO_LOCALE.put("th", "th-TH"); + LANGUAGE_TO_LOCALE.put("id", "id-ID"); + } + + /** + * Parse Accept-Language header and return the best matching locale key + * from the set of available locales. + * + * @param acceptLanguage the Accept-Language header value + * @param availableLocales the set of locale keys loaded from JSON files + * @return the best matching locale key, or en_US as fallback + */ + public static String resolveLocale(String acceptLanguage, Set availableLocales) { + if (acceptLanguage == null || acceptLanguage.trim().isEmpty()) { + return DEFAULT_LOCALE; + } + + List entries = parseAcceptLanguage(acceptLanguage); + for (LocaleEntry entry : entries) { + if (entry.quality <= 0) { + continue; + } + + String normalized = normalizeTag(entry.tag); + if (availableLocales.contains(normalized)) { + return normalized; + } + + String lang = entry.tag.split("[-_]")[0].toLowerCase(); + String mapped = LANGUAGE_TO_LOCALE.get(lang); + if (mapped != null && availableLocales.contains(mapped)) { + return mapped; + } + } + + return DEFAULT_LOCALE; + } + + /** + * Normalize an HTTP language tag to match file locale keys. + * e.g. "zh-CN" -> "zh_CN", "en-US" -> "en_US", "ja-JP" -> "ja-JP" + * See UNDERSCORE_LANGS for languages that use underscore format. + */ + static String normalizeTag(String tag) { + tag = tag.trim(); + String[] parts = tag.split("[-_]"); + if (parts.length == 2) { + String lang = parts[0].toLowerCase(); + String region = parts[1].toUpperCase(); + if (UNDERSCORE_LANGS.contains(lang)) { + return lang + "_" + region; + } + return lang + "-" + region; + } + return tag; + } + + private static List parseAcceptLanguage(String header) { + List entries = new ArrayList<>(); + String[] parts = header.split(","); + for (String part : parts) { + part = part.trim(); + if (part.isEmpty()) { + continue; + } + String[] tagAndParams = part.split(";"); + String tag = tagAndParams[0].trim(); + double quality = 1.0; + for (int i = 1; i < tagAndParams.length; i++) { + String param = tagAndParams[i].trim(); + if (param.startsWith("q=")) { + try { + quality = Double.parseDouble(param.substring(2).trim()); + } catch (NumberFormatException e) { + logger.debug(String.format("failed to parse quality value [%s]: %s", param, e.getMessage())); + quality = 0; + } + } + } + entries.add(new LocaleEntry(tag, quality)); + } + entries.sort((a, b) -> Double.compare(b.quality, a.quality)); + return entries; + } + + private static class LocaleEntry { + final String tag; + final double quality; + + LocaleEntry(String tag, double quality) { + this.tag = tag; + this.quality = quality; + } + } +} diff --git a/core/src/main/java/org/zstack/core/rest/RESTApiController.java b/core/src/main/java/org/zstack/core/rest/RESTApiController.java index df8657f7f73..3c9e5e642c1 100755 --- a/core/src/main/java/org/zstack/core/rest/RESTApiController.java +++ b/core/src/main/java/org/zstack/core/rest/RESTApiController.java @@ -7,8 +7,13 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import org.zstack.core.errorcode.GlobalErrorCodeI18nService; +import org.zstack.core.errorcode.LocaleUtils; import org.zstack.header.message.APIMessage; import org.zstack.header.message.APISyncCallMessage; +import org.zstack.header.message.APIEvent; +import org.zstack.header.message.Message; +import org.zstack.header.message.MessageReply; import org.zstack.header.rest.RESTApiFacade; import org.zstack.header.rest.RESTConstant; import org.zstack.header.rest.RESTFacade; @@ -30,15 +35,19 @@ public class RESTApiController { private RESTApiFacade restApi; @Autowired private RESTFacade restf; + @Autowired + private GlobalErrorCodeI18nService i18nService; @RequestMapping(value = RESTConstant.REST_API_RESULT + "{uuid}", method = {RequestMethod.GET, RequestMethod.PUT}) - public void queryResult(@PathVariable String uuid, HttpServletResponse rsp) throws IOException { + public void queryResult(@PathVariable String uuid, HttpServletRequest req, HttpServletResponse rsp) throws IOException { try { RestAPIResponse apiRsp = restApi.getResult(uuid); if (apiRsp == null) { rsp.sendError(HttpStatus.SC_NOT_FOUND, String.format("No api result[uuid:%s] found", uuid)); return; } + String locale = resolveLocale(req); + localizeRestAPIResponse(apiRsp, locale); rsp.setCharacterEncoding("UTF-8"); PrintWriter writer = rsp.getWriter(); String res = JSONObjectUtil.toJsonString(apiRsp); @@ -50,7 +59,7 @@ public void queryResult(@PathVariable String uuid, HttpServletResponse rsp) thro } } - private String handleByMessageType(String body, String clientIp, String clientBrowser) { + private String handleByMessageType(String body, String clientIp, String clientBrowser, String locale) { APIMessage amsg = null; try { amsg = (APIMessage) RESTApiDecoder.loads(body); @@ -66,6 +75,7 @@ private String handleByMessageType(String body, String clientIp, String clientBr } else { rsp = restApi.send(amsg); } + localizeRestAPIResponse(rsp, locale); return JSONObjectUtil.toJsonString(rsp); } @@ -74,8 +84,9 @@ public void post(HttpServletRequest request, HttpServletResponse response) throw HttpEntity entity = restf.httpServletRequestToHttpEntity(request); String clientIp = HttpServletRequestUtils.getClientIP(request); String clientBrowser = HttpServletRequestUtils.getClientBrowser(request); + String locale = resolveLocale(request); try { - String ret = handleByMessageType(entity.getBody(), clientIp, clientBrowser); + String ret = handleByMessageType(entity.getBody(), clientIp, clientBrowser, locale); response.setStatus(HttpStatus.SC_OK); response.setCharacterEncoding("UTF-8"); PrintWriter writer = response.getWriter(); @@ -90,4 +101,34 @@ public void post(HttpServletRequest request, HttpServletResponse response) throw } } + private String resolveLocale(HttpServletRequest req) { + String acceptLanguage = req.getHeader("Accept-Language"); + return LocaleUtils.resolveLocale(acceptLanguage, i18nService.getAvailableLocales()); + } + + private void localizeRestAPIResponse(RestAPIResponse rsp, String locale) { + if (rsp == null || rsp.getResult() == null || locale == null) { + return; + } + + try { + Message msg = RESTApiDecoder.loads(rsp.getResult()); + if (msg instanceof MessageReply) { + MessageReply reply = (MessageReply) msg; + if (!reply.isSuccess() && reply.getError() != null) { + i18nService.localizeErrorCode(reply.getError(), locale); + rsp.setResult(RESTApiDecoder.dump(reply)); + } + } else if (msg instanceof APIEvent) { + APIEvent evt = (APIEvent) msg; + if (!evt.isSuccess() && evt.getError() != null) { + i18nService.localizeErrorCode(evt.getError(), locale); + rsp.setResult(RESTApiDecoder.dump(evt)); + } + } + } catch (Exception e) { + logger.debug(String.format("failed to localize RestAPIResponse: %s", e.getMessage()), e); + } + } + } diff --git a/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java b/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java index 1b9e9eba7a6..c7ff98024b3 100755 --- a/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java +++ b/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java @@ -24,6 +24,26 @@ public class ErrorCode implements Serializable, Cloneable { @NoJsonSchema private LinkedHashMap opaque; private String globalErrorCode; + private String message; + @APINoSee + @NoJsonSchema + private String[] formatArgs; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String[] getFormatArgs() { + return formatArgs == null ? null : formatArgs.clone(); + } + + public void setFormatArgs(String[] formatArgs) { + this.formatArgs = formatArgs == null ? null : formatArgs.clone(); + } public String getGlobalErrorCode() { return globalErrorCode; @@ -81,6 +101,9 @@ public ErrorCode(ErrorCode other) { this.messages = other.messages; this.cause = other.cause; this.location = other.location; + this.message = other.message; + this.formatArgs = other.formatArgs == null ? null : other.formatArgs.clone(); + this.globalErrorCode = other.globalErrorCode; } public void setCode(String code) { diff --git a/rest/src/main/java/org/zstack/rest/RestServer.java b/rest/src/main/java/org/zstack/rest/RestServer.java index 339396d3c41..917ffaadcdf 100755 --- a/rest/src/main/java/org/zstack/rest/RestServer.java +++ b/rest/src/main/java/org/zstack/rest/RestServer.java @@ -31,6 +31,8 @@ import org.zstack.core.cloudbus.CloudBusEventListener; import org.zstack.core.cloudbus.CloudBusGson; import org.zstack.core.componentloader.PluginRegistry; +import org.zstack.core.errorcode.GlobalErrorCodeI18nService; +import org.zstack.core.errorcode.LocaleUtils; import org.zstack.core.db.Q; import org.zstack.core.log.LogSafeGson; import org.zstack.core.log.LogUtils; @@ -148,6 +150,8 @@ public class RestServer implements Component, CloudBusEventListener { private RESTFacade restf; @Autowired private PluginRegistry pluginRgty; + @Autowired + private GlobalErrorCodeI18nService i18nService; RateLimiter rateLimiter = new RateLimiter(RestGlobalProperty.REST_RATE_LIMITS); @@ -404,6 +408,8 @@ private void callWebHook(RequestData d) throws IllegalAccessException, NoSuchMet writeResponse(response, w, ret.getResult()); } else { + String locale = resolveLocale(); + i18nService.localizeErrorCode(evt.getError(), locale); response.setError(evt.getError()); } @@ -911,6 +917,8 @@ private void handleJobQuery(HttpServletRequest req, HttpServletResponse rsp) thr writeResponse(response, w, ret.getResult()); sendResponse(HttpStatus.OK.value(), response, rsp); } else { + String locale = resolveLocaleFromRequest(req); + i18nService.localizeErrorCode(evt.getError(), locale); response.setError(evt.getError()); sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), response, rsp); } @@ -1411,10 +1419,26 @@ private void writeResponse(ApiResponse response, RestResponseWrapper w, Message } } + private String resolveLocale() { + RequestInfo info = requestInfo.get(); + if (info == null) { + return LocaleUtils.DEFAULT_LOCALE; + } + String acceptLanguage = info.headers.getFirst("Accept-Language"); + return LocaleUtils.resolveLocale(acceptLanguage, i18nService.getAvailableLocales()); + } + + private String resolveLocaleFromRequest(HttpServletRequest req) { + String acceptLanguage = req.getHeader("Accept-Language"); + return LocaleUtils.resolveLocale(acceptLanguage, i18nService.getAvailableLocales()); + } + private void sendReplyResponse(MessageReply reply, Api api, HttpServletResponse rsp) throws IOException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { ApiResponse response = new ApiResponse(); if (!reply.isSuccess()) { + String locale = resolveLocale(); + i18nService.localizeErrorCode(reply.getError(), locale); response.setError(reply.getError()); sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), JSONObjectUtil.toJsonString(response), rsp); return; diff --git a/sdk/src/main/java/org/zstack/sdk/ErrorCode.java b/sdk/src/main/java/org/zstack/sdk/ErrorCode.java index 68f2b843c67..e8f4c69d46d 100755 --- a/sdk/src/main/java/org/zstack/sdk/ErrorCode.java +++ b/sdk/src/main/java/org/zstack/sdk/ErrorCode.java @@ -76,4 +76,12 @@ public java.lang.String getGlobalErrorCode() { return this.globalErrorCode; } + public java.lang.String message; + public void setMessage(java.lang.String message) { + this.message = message; + } + public java.lang.String getMessage() { + return this.message; + } + } diff --git a/test/src/test/groovy/org/zstack/test/integration/rest/ErrorCodeI18nCase.groovy b/test/src/test/groovy/org/zstack/test/integration/rest/ErrorCodeI18nCase.groovy new file mode 100644 index 00000000000..8c7e793e544 --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/rest/ErrorCodeI18nCase.groovy @@ -0,0 +1,316 @@ +package org.zstack.test.integration.rest + +import org.zstack.core.errorcode.GlobalErrorCodeI18nService +import org.zstack.header.errorcode.ErrorCode +import org.zstack.header.errorcode.ErrorCodeList +import org.zstack.header.errorcode.SysErrors +import org.zstack.header.zone.APIQueryZoneMsg +import org.zstack.test.integration.ZStackTest +import org.zstack.testlib.EnvSpec +import org.zstack.testlib.SubCase +import org.zstack.testlib.WebBeanConstructor +import org.zstack.utils.gson.JSONObjectUtil + +class ErrorCodeI18nCase extends SubCase { + EnvSpec env + + @Override + void clean() { + env.delete() + } + + @Override + void setup() { + useSpring(ZStackTest.springSpec) + } + + @Override + void environment() { + env = env {} + } + + @Override + void test() { + env.create { + testServiceLocalizesZhCN() + testServiceLocalizesEnUS() + testServiceFallbackToEnUS() + testServiceFormatsArgs() + testServiceRecursiveCauseChain() + testServiceErrorCodeList() + testRestServerSyncApiWithAcceptLanguage() + testRestServerAsyncApiWithAcceptLanguage() + testNoAcceptLanguageHeaderFallsBackToEnUS() + } + } + + void testServiceLocalizesZhCN() { + GlobalErrorCodeI18nService i18nService = bean(GlobalErrorCodeI18nService.class) + // ORG_ZSTACK_STORAGE_PRIMARY_10039: zh_CN = "未找到主存储[uuid:%s]" + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "zh_CN", ["test-uuid-123"] as String[]) + assert msg != null + assert msg.contains("未找到主存储") + assert msg.contains("test-uuid-123") + } + + void testServiceLocalizesEnUS() { + GlobalErrorCodeI18nService i18nService = bean(GlobalErrorCodeI18nService.class) + // ORG_ZSTACK_STORAGE_PRIMARY_10039: en_US = "no primary storage[uuid:%s] exists" + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "en_US", ["test-uuid-456"] as String[]) + assert msg != null + assert msg.contains("no primary storage") + assert msg.contains("test-uuid-456") + } + + void testServiceFallbackToEnUS() { + GlobalErrorCodeI18nService i18nService = bean(GlobalErrorCodeI18nService.class) + // Request a locale that doesn't exist, should fallback to en_US + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "nonexistent_locale", ["uuid-789"] as String[]) + assert msg != null + assert msg.contains("no primary storage") + } + + void testServiceFormatsArgs() { + GlobalErrorCodeI18nService i18nService = bean(GlobalErrorCodeI18nService.class) + // Test with null formatArgs - should return template as-is + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "zh_CN", null) + assert msg != null + assert msg.contains("%s") // template not formatted + } + + void testServiceRecursiveCauseChain() { + GlobalErrorCodeI18nService i18nService = bean(GlobalErrorCodeI18nService.class) + + ErrorCode cause = new ErrorCode() + cause.setCode(SysErrors.INTERNAL.toString()) + cause.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + cause.setFormatArgs(["inner-uuid"] as String[]) + + ErrorCode outer = new ErrorCode() + outer.setCode(SysErrors.INTERNAL.toString()) + outer.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + outer.setFormatArgs(["outer-uuid"] as String[]) + outer.setCause(cause) + + i18nService.localizeErrorCode(outer, "zh_CN") + + assert outer.getMessage() != null + assert outer.getMessage().contains("outer-uuid") + assert outer.getCause().getMessage() != null + assert outer.getCause().getMessage().contains("inner-uuid") + } + + void testServiceErrorCodeList() { + GlobalErrorCodeI18nService i18nService = bean(GlobalErrorCodeI18nService.class) + + ErrorCodeList errorList = new ErrorCodeList() + errorList.setCode(SysErrors.INTERNAL.toString()) + errorList.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + errorList.setFormatArgs(["list-uuid"] as String[]) + + ErrorCode child1 = new ErrorCode() + child1.setCode(SysErrors.INTERNAL.toString()) + child1.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + child1.setFormatArgs(["child1-uuid"] as String[]) + + ErrorCode child2 = new ErrorCode() + child2.setCode(SysErrors.INTERNAL.toString()) + child2.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + child2.setFormatArgs(["child2-uuid"] as String[]) + + errorList.setCauses([child1, child2]) + + i18nService.localizeErrorCode(errorList, "zh_CN") + + assert errorList.getMessage().contains("list-uuid") + assert errorList.getCauses()[0].getMessage().contains("child1-uuid") + assert errorList.getCauses()[1].getMessage().contains("child2-uuid") + } + + void testRestServerSyncApiWithAcceptLanguage() { + // Intercept APIQueryZoneMsg and return error with known globalErrorCode + ErrorCode mockError = new ErrorCode() + mockError.setCode(SysErrors.INTERNAL.toString()) + mockError.setDetails("no primary storage[uuid:test-sync-uuid] exists") + mockError.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + mockError.setFormatArgs(["test-sync-uuid"] as String[]) + + env.message(APIQueryZoneMsg.class) { APIQueryZoneMsg msg, bus -> + bus.replyErrorByMessageType(msg, mockError) + } + + try { + // Make raw HTTP GET to /v1/zones with Accept-Language: zh-CN + String sessionId = adminSession() + String url = "http://127.0.0.1:${WebBeanConstructor.port}/v1/zones" + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection() + try { + conn.setRequestMethod("GET") + conn.setRequestProperty("Authorization", "OAuth ${sessionId}") + conn.setRequestProperty("Accept-Language", "zh-CN") + + int responseCode = conn.getResponseCode() + String responseBody + if (responseCode >= 400) { + responseBody = conn.getErrorStream()?.text ?: "" + } else { + responseBody = conn.getInputStream()?.text ?: "" + } + + assert responseCode == 503 // SERVICE_UNAVAILABLE for error responses + Map response = JSONObjectUtil.toObject(responseBody, LinkedHashMap.class) + Map error = response.get("error") as Map + assert error != null + assert error.get("message") != null + String message = error.get("message") as String + assert message.contains("未找到主存储") + assert message.contains("test-sync-uuid") + } finally { + conn.disconnect() + } + } finally { + env.cleanMessageHandlers() + } + } + + void testRestServerAsyncApiWithAcceptLanguage() { + // Use an async API (create zone) and intercept to return error + ErrorCode asyncError = new ErrorCode() + asyncError.setCode(SysErrors.INTERNAL.toString()) + asyncError.setDetails("no primary storage[uuid:test-async-uuid] exists") + asyncError.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + asyncError.setFormatArgs(["test-async-uuid"] as String[]) + + env.message(org.zstack.header.zone.APICreateZoneMsg.class) { msg, bus -> + bus.replyErrorByMessageType(msg, asyncError) + } + + try { + // POST to create zone + String sessionId = adminSession() + String postUrl = "http://127.0.0.1:${WebBeanConstructor.port}/v1/zones" + String body = '{"params":{"name":"test-i18n-zone","description":"test"}}' + + HttpURLConnection postConn = (HttpURLConnection) new URL(postUrl).openConnection() + try { + postConn.setRequestMethod("POST") + postConn.setRequestProperty("Authorization", "OAuth ${sessionId}") + postConn.setRequestProperty("Accept-Language", "zh-CN") + postConn.setRequestProperty("Content-Type", "application/json") + postConn.setDoOutput(true) + postConn.getOutputStream().write(body.getBytes("UTF-8")) + + int postCode = postConn.getResponseCode() + String postBody + if (postCode >= 400) { + postBody = postConn.getErrorStream()?.text ?: "" + } else { + postBody = postConn.getInputStream()?.text ?: "" + } + + // Async API returns 202 with location header for job polling + if (postCode == 202) { + Map postResponse = JSONObjectUtil.toObject(postBody, LinkedHashMap.class) + String location = postResponse.get("location") as String + assert location != null + + // Poll the job with Accept-Language header + retryInSecs(5) { + String jobUrl = location.startsWith("http") ? location : "http://127.0.0.1:${WebBeanConstructor.port}${location}" + HttpURLConnection jobConn = (HttpURLConnection) new URL(jobUrl).openConnection() + try { + jobConn.setRequestMethod("GET") + jobConn.setRequestProperty("Authorization", "OAuth ${sessionId}") + jobConn.setRequestProperty("Accept-Language", "zh-CN") + + int jobCode = jobConn.getResponseCode() + String jobBody + if (jobCode >= 400) { + jobBody = jobConn.getErrorStream()?.text ?: "" + } else { + jobBody = jobConn.getInputStream()?.text ?: "" + } + + // Job should be done (503 for error) + assert jobCode == 503 + Map jobResponse = JSONObjectUtil.toObject(jobBody, LinkedHashMap.class) + Map jobError = jobResponse.get("error") as Map + assert jobError != null + String jobMessage = jobError.get("message") as String + assert jobMessage != null + assert jobMessage.contains("未找到主存储") + assert jobMessage.contains("test-async-uuid") + } finally { + jobConn.disconnect() + } + } + } else { + // If returned directly (e.g. 503), check the error + assert postCode == 503 + Map directResponse = JSONObjectUtil.toObject(postBody, LinkedHashMap.class) + Map directError = directResponse.get("error") as Map + assert directError != null + String directMessage = directError.get("message") as String + assert directMessage != null + assert directMessage.contains("未找到主存储") + } + } finally { + postConn.disconnect() + } + } finally { + env.cleanMessageHandlers() + } + } + + void testNoAcceptLanguageHeaderFallsBackToEnUS() { + // Intercept APIQueryZoneMsg and return error with known globalErrorCode + ErrorCode mockError = new ErrorCode() + mockError.setCode(SysErrors.INTERNAL.toString()) + mockError.setDetails("no primary storage[uuid:test-no-lang] exists") + mockError.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039") + mockError.setFormatArgs(["test-no-lang"] as String[]) + + env.message(APIQueryZoneMsg.class) { APIQueryZoneMsg msg, bus -> + bus.replyErrorByMessageType(msg, mockError) + } + + try { + // Make raw HTTP GET without Accept-Language header + String sessionId = adminSession() + String url = "http://127.0.0.1:${WebBeanConstructor.port}/v1/zones" + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection() + try { + conn.setRequestMethod("GET") + conn.setRequestProperty("Authorization", "OAuth ${sessionId}") + // No Accept-Language header + + int responseCode = conn.getResponseCode() + String responseBody + if (responseCode >= 400) { + responseBody = conn.getErrorStream()?.text ?: "" + } else { + responseBody = conn.getInputStream()?.text ?: "" + } + + assert responseCode == 503 + Map response = JSONObjectUtil.toObject(responseBody, LinkedHashMap.class) + Map error = response.get("error") as Map + assert error != null + // Without Accept-Language, resolveLocale defaults to en_US + // So message should be the en_US version + String message = error.get("message") as String + assert message != null + assert message.contains("no primary storage") + assert message.contains("test-no-lang") + } finally { + conn.disconnect() + } + } finally { + env.cleanMessageHandlers() + } + } +} diff --git a/test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java b/test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java new file mode 100644 index 00000000000..729e841c6d2 --- /dev/null +++ b/test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java @@ -0,0 +1,135 @@ +package org.zstack.test.core.errorcode; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.zstack.core.componentloader.ComponentLoader; +import org.zstack.core.errorcode.GlobalErrorCodeI18nService; +import org.zstack.header.errorcode.ErrorCode; +import org.zstack.header.errorcode.ErrorCodeList; +import org.zstack.test.BeanConstructor; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.Arrays; + +public class TestGlobalErrorCodeI18n { + CLogger logger = Utils.getLogger(TestGlobalErrorCodeI18n.class); + ComponentLoader loader; + GlobalErrorCodeI18nService i18nService; + + @Before + public void setUp() throws Exception { + BeanConstructor con = new BeanConstructor(); + loader = con.build(); + i18nService = loader.getComponent(GlobalErrorCodeI18nService.class); + } + + @Test + public void testLoadJsonFiles() { + Assert.assertTrue("should load at least 2 locales", + i18nService.getAvailableLocales().size() >= 2); + logger.debug(String.format("loaded locales: %s", i18nService.getAvailableLocales())); + } + + @Test + public void testZhCNMatch() { + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "zh_CN", new String[]{"abc123"}); + Assert.assertNotNull(msg); + Assert.assertTrue("should contain Chinese text", msg.contains("abc123")); + logger.debug(String.format("zh_CN message: %s", msg)); + } + + @Test + public void testEnUSMatch() { + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "en_US", new String[]{"abc123"}); + Assert.assertNotNull(msg); + Assert.assertTrue("should contain English text", msg.contains("abc123")); + logger.debug(String.format("en_US message: %s", msg)); + } + + @Test + public void testFallbackToEnUS() { + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "pt_BR", new String[]{"abc123"}); + Assert.assertNotNull("should fallback to en_US", msg); + logger.debug(String.format("fallback message: %s", msg)); + } + + @Test + public void testNonExistentGlobalErrorCode() { + String msg = i18nService.getLocalizedMessage( + "NOT_EXIST_CODE", "zh_CN", null); + Assert.assertNull("should return null for non-existent code", msg); + } + + @Test + public void testFormatArgsNull() { + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "zh_CN", null); + Assert.assertNotNull(msg); + // template contains %s but args is null, should return raw template + Assert.assertTrue("should contain %s placeholder", msg.contains("%s")); + } + + @Test + public void testFormatArgsMismatch() { + // template has 1 %s but we pass 3 args - should not crash + String msg = i18nService.getLocalizedMessage( + "ORG_ZSTACK_STORAGE_PRIMARY_10039", "zh_CN", + new String[]{"arg1", "arg2", "arg3"}); + Assert.assertNotNull(msg); + } + + @Test + public void testLocalizeErrorCodeWithCause() { + ErrorCode cause = new ErrorCode(); + cause.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039"); + cause.setFormatArgs(new String[]{"inner-uuid"}); + + ErrorCode error = new ErrorCode(); + error.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039"); + error.setFormatArgs(new String[]{"outer-uuid"}); + error.setCause(cause); + + i18nService.localizeErrorCode(error, "zh_CN"); + + Assert.assertNotNull("error.message should be set", error.getMessage()); + Assert.assertNotNull("cause.message should be set", cause.getMessage()); + Assert.assertTrue(error.getMessage().contains("outer-uuid")); + Assert.assertTrue(cause.getMessage().contains("inner-uuid")); + } + + @Test + public void testLocalizeErrorCodeList() { + ErrorCodeList errorList = new ErrorCodeList(); + errorList.setCode("SYS.1000"); + + ErrorCode cause1 = new ErrorCode(); + cause1.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039"); + cause1.setFormatArgs(new String[]{"uuid1"}); + + ErrorCode cause2 = new ErrorCode(); + cause2.setGlobalErrorCode("ORG_ZSTACK_STORAGE_PRIMARY_10039"); + cause2.setFormatArgs(new String[]{"uuid2"}); + + errorList.setCauses(Arrays.asList(cause1, cause2)); + + i18nService.localizeErrorCode(errorList, "zh_CN"); + + Assert.assertNotNull("cause1.message should be set", cause1.getMessage()); + Assert.assertNotNull("cause2.message should be set", cause2.getMessage()); + Assert.assertTrue(cause1.getMessage().contains("uuid1")); + Assert.assertTrue(cause2.getMessage().contains("uuid2")); + } + + @Test + public void testNoGlobalErrorCode() { + ErrorCode error = new ErrorCode("SYS.1000", "test error"); + // no globalErrorCode set + i18nService.localizeErrorCode(error, "zh_CN"); + Assert.assertNull("message should remain null", error.getMessage()); + } +} \ No newline at end of file diff --git a/test/src/test/java/org/zstack/test/core/errorcode/TestLocaleUtils.java b/test/src/test/java/org/zstack/test/core/errorcode/TestLocaleUtils.java new file mode 100644 index 00000000000..f7d1e6fc821 --- /dev/null +++ b/test/src/test/java/org/zstack/test/core/errorcode/TestLocaleUtils.java @@ -0,0 +1,67 @@ +package org.zstack.test.core.errorcode; + +import org.junit.Assert; +import org.junit.Test; +import org.zstack.core.errorcode.LocaleUtils; + +import java.util.HashSet; +import java.util.Set; + +public class TestLocaleUtils { + private static final Set AVAILABLE_LOCALES = new HashSet<>(); + + static { + AVAILABLE_LOCALES.add("zh_CN"); + AVAILABLE_LOCALES.add("en_US"); + AVAILABLE_LOCALES.add("ja-JP"); + AVAILABLE_LOCALES.add("ko-KR"); + AVAILABLE_LOCALES.add("de-DE"); + AVAILABLE_LOCALES.add("fr-FR"); + AVAILABLE_LOCALES.add("ru-RU"); + AVAILABLE_LOCALES.add("th-TH"); + AVAILABLE_LOCALES.add("id-ID"); + AVAILABLE_LOCALES.add("zh_TW"); + } + + @Test + public void testSimpleLocale() { + String result = LocaleUtils.resolveLocale("zh-CN", AVAILABLE_LOCALES); + Assert.assertEquals("zh_CN", result); + } + + @Test + public void testMultiLocaleWithQuality() { + String result = LocaleUtils.resolveLocale("ja-JP,zh-CN;q=0.9,en;q=0.8", AVAILABLE_LOCALES); + Assert.assertEquals("ja-JP", result); + } + + @Test + public void testLanguageCodeOnly() { + String result = LocaleUtils.resolveLocale("zh", AVAILABLE_LOCALES); + Assert.assertEquals("zh_CN", result); + } + + @Test + public void testUnsupportedLocale() { + String result = LocaleUtils.resolveLocale("pt-BR", AVAILABLE_LOCALES); + Assert.assertEquals("en_US", result); + } + + @Test + public void testNullAndEmpty() { + Assert.assertEquals("en_US", LocaleUtils.resolveLocale(null, AVAILABLE_LOCALES)); + Assert.assertEquals("en_US", LocaleUtils.resolveLocale("", AVAILABLE_LOCALES)); + } + + @Test + public void testEnUS() { + String result = LocaleUtils.resolveLocale("en-US", AVAILABLE_LOCALES); + Assert.assertEquals("en_US", result); + } + + @Test + public void testUnderscoreFormat() { + String result = LocaleUtils.resolveLocale("zh_CN", AVAILABLE_LOCALES); + Assert.assertEquals("zh_CN", result); + } +} From 944c7a8ac0fc1f91f1bc1f596637a64e13a16499 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Sun, 1 Mar 2026 23:13:51 +0800 Subject: [PATCH 36/85] [storage]: honor force flag to clean image cache for existing images with no VMs Resolves: ZSTAC-79628 Change-Id: I5a7a0941a59bcea132ea97df52bbebdc9a227508 --- .../zstack/storage/ceph/primary/CephImageCacheCleaner.java | 2 +- .../java/org/zstack/storage/primary/ImageCacheCleaner.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephImageCacheCleaner.java b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephImageCacheCleaner.java index 81cec9a040c..032b90d0a6b 100755 --- a/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephImageCacheCleaner.java +++ b/plugin/ceph/src/main/java/org/zstack/storage/ceph/primary/CephImageCacheCleaner.java @@ -34,7 +34,7 @@ protected GlobalConfig cleanupIntervalConfig() { @Transactional @Override protected List createShadowImageCacheVOsForNewDeletedAndOld(String psUuid, ImageCacheCleanParam param) { - List staleImageCacheIds = getStaleImageCacheIds(psUuid, false); + List staleImageCacheIds = getStaleImageCacheIds(psUuid, param.includeReadyImage); if (staleImageCacheIds == null || staleImageCacheIds.isEmpty()) { return null; } diff --git a/storage/src/main/java/org/zstack/storage/primary/ImageCacheCleaner.java b/storage/src/main/java/org/zstack/storage/primary/ImageCacheCleaner.java index 8aab976bf7c..b6db3d7888e 100755 --- a/storage/src/main/java/org/zstack/storage/primary/ImageCacheCleaner.java +++ b/storage/src/main/java/org/zstack/storage/primary/ImageCacheCleaner.java @@ -387,8 +387,8 @@ private List queryCacheOfExpungedImage(String psUuid) { @Transactional protected List createShadowImageCacheVOsForNewDeletedAndOld(String psUuid, ImageCacheCleanParam param) { - // 1. image has been deleted - List staleImageCacheIds = getStaleImageCacheIds(psUuid, false); + // 1. image has been deleted or force cleanup includes images still in ready state with no VMs using them + List staleImageCacheIds = getStaleImageCacheIds(psUuid, param.includeReadyImage); if (staleImageCacheIds == null || staleImageCacheIds.isEmpty()) { return null; } From 35a1e97852e8c5464733a31469734c010ce6f6e2 Mon Sep 17 00:00:00 2001 From: J M Date: Thu, 27 Mar 2025 18:05:51 +0800 Subject: [PATCH 37/85] [ansible]: support python3 ansible install copy zstack-cli/zstack-ctl from venv to /usr/bin before chmod Python 3.11 pip (>=19.0) no longer installs data_files to absolute paths when running inside a virtualenv. Resolves: ZSTAC-73161 Change-Id: I6c74676f6f6b786e616972786a717777797a626f --- conf/tools/install.sh | 26 +++++++++---------- .../core/ansible/AnsibleFacadeImpl.java | 15 ++++++----- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/conf/tools/install.sh b/conf/tools/install.sh index 2821bc9ab6d..d53e6c3dceb 100755 --- a/conf/tools/install.sh +++ b/conf/tools/install.sh @@ -10,7 +10,7 @@ fi pypi_path=file://$cwd/../../../static/pypi/simple usage() { - echo "usage:$0 [zstack-cli|zstack-ctl|zstack-dashboard|zstack-ui]" + echo "usage:$0 [zstack-cli|zstack-ctl|zstack-dashboard|zstack-ui|zstack-sys]" exit 1 } @@ -22,24 +22,20 @@ if [ -z $tool ]; then fi install_pip() { - pip --version | grep 7.0.3 >/dev/null || easy_install -i $pypi_path --upgrade pip + pip3.11 --version | grep 22.3.1 >/dev/null || yum install python3.11-pip } -install_virtualenv() { - virtualenv --version | grep 12.1.1 >/dev/null || pip install -i $pypi_path --ignore-installed virtualenv==12.1.1 -} cd $cwd install_pip -install_virtualenv cd /tmp if [ $tool = 'zstack-cli' ]; then CLI_VIRENV_PATH=/var/lib/zstack/virtualenv/zstackcli [ ! -z $force ] && rm -rf $CLI_VIRENV_PATH if [ ! -d "$CLI_VIRENV_PATH" ]; then - virtualenv $CLI_VIRENV_PATH --python=python2.7 + python3.11 -m venv $CLI_VIRENV_PATH if [ $? -ne 0 ]; then rm -rf $CLI_VIRENV_PATH exit 1 @@ -70,17 +66,19 @@ if [ $tool = 'zstack-cli' ]; then exit 1 fi fi + [ -f $CLI_VIRENV_PATH/bin/zstack-cli ] && cp $CLI_VIRENV_PATH/bin/zstack-cli /usr/bin/zstack-cli chmod +x /usr/bin/zstack-cli elif [ $tool = 'zstack-ctl' ]; then CTL_VIRENV_PATH=/var/lib/zstack/virtualenv/zstackctl - rm -rf $CTL_VIRENV_PATH && virtualenv $CTL_VIRENV_PATH --python=python2.7 || exit 1 + rm -rf $CTL_VIRENV_PATH && python3.11 -m venv $CTL_VIRENV_PATH || exit 1 . $CTL_VIRENV_PATH/bin/activate cd $cwd TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed zstackctl-*.tar.gz || exit 1 - TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed pycrypto==2.6.1 || exit 1 + TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed pycryptodome || exit 1 + [ -f $CTL_VIRENV_PATH/bin/zstack-ctl ] && cp $CTL_VIRENV_PATH/bin/zstack-ctl /usr/bin/zstack-ctl chmod +x /usr/bin/zstack-ctl - python $CTL_VIRENV_PATH/lib/python2.7/site-packages/zstackctl/generate_zstackctl_bash_completion.py + python $CTL_VIRENV_PATH/lib/python3.11/site-packages/zstackctl/generate_zstackctl_bash_completion.py elif [ $tool = 'zstack-sys' ]; then SYS_VIRENV_PATH=/var/lib/zstack/virtualenv/zstacksys @@ -95,11 +93,11 @@ elif [ $tool = 'zstack-sys' ]; then NEED_INSTALL=true fi if $NEED_INSTALL; then - rm -rf $SYS_VIRENV_PATH && virtualenv $SYS_VIRENV_PATH --python=python2.7 || exit 1 + rm -rf $SYS_VIRENV_PATH && python3.11 -m venv /var/lib/zstack/virtualenv/zstacksys || exit 1 . $SYS_VIRENV_PATH/bin/activate cd $cwd - TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed setuptools==39.2.0 || exit 1 - TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed ansible==4.10.0 || exit 1 + #TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed setuptools==65.5.1 || exit 1 + TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed ansible==9.13.0 || exit 1 cat > /usr/bin/ansible << EOF #! /bin/sh @@ -140,7 +138,7 @@ elif [ $tool = 'zstack-dashboard' ]; then UI_VIRENV_PATH=/var/lib/zstack/virtualenv/zstack-dashboard [ ! -z $force ] && rm -rf $UI_VIRENV_PATH if [ ! -d "$UI_VIRENV_PATH" ]; then - virtualenv $UI_VIRENV_PATH --python=python2.7 + python3.11 -m venv $UI_VIRENV_PATH if [ $? -ne 0 ]; then rm -rf $UI_VIRENV_PATH exit 1 diff --git a/core/src/main/java/org/zstack/core/ansible/AnsibleFacadeImpl.java b/core/src/main/java/org/zstack/core/ansible/AnsibleFacadeImpl.java index d9e40575833..dada8634e59 100755 --- a/core/src/main/java/org/zstack/core/ansible/AnsibleFacadeImpl.java +++ b/core/src/main/java/org/zstack/core/ansible/AnsibleFacadeImpl.java @@ -136,7 +136,7 @@ void init() { "NEED_INSTALL=false; " + "if [ -d /var/lib/zstack/virtualenv/zstacksys ]; then " + ". /var/lib/zstack/virtualenv/zstacksys/bin/activate; " + - "if ! ansible --version | grep -q 'core 2.11.12'; then " + + "if ! ansible --version | grep -q 'core 2.16.14'; then " + "deactivate; " + "NEED_INSTALL=true; " + "fi; " + @@ -144,10 +144,10 @@ void init() { "NEED_INSTALL=true; "+ "fi; " + "if $NEED_INSTALL; then " + - "sudo bash -c 'rm -rf /var/lib/zstack/virtualenv/zstacksys && virtualenv /var/lib/zstack/virtualenv/zstacksys --python=python2.7; "+ + "sudo bash -c 'rm -rf /var/lib/zstack/virtualenv/zstacksys && python3.11 -m venv /var/lib/zstack/virtualenv/zstacksys; "+ ". /var/lib/zstack/virtualenv/zstacksys/bin/activate; "+ - "TMPDIR=/usr/local/zstack/ pip install -i file://%s --trusted-host localhost -I setuptools==39.2.0; "+ - "TMPDIR=/usr/local/zstack/ pip install -i file://%s --trusted-host localhost -I ansible==4.10.0'; "+ + "TMPDIR=/usr/local/zstack/ pip install -i file://%s --trusted-host localhost -I setuptools==65.5.1; "+ + "TMPDIR=/usr/local/zstack/ pip install -i file://%s --trusted-host localhost -I ansible==9.13.0'; "+ "fi" , AnsibleConstant.PYPI_REPO, AnsibleConstant.PYPI_REPO), false); @@ -252,19 +252,20 @@ private void run(Completion completion) { Map arguments = collectArguments(msg); logger.debug(String.format("start running ansible for playbook[%s]", msg.getPlayBookPath())); String executable = msg.getAnsibleExecutable() == null ? AnsibleGlobalProperty.EXECUTABLE : msg.getAnsibleExecutable(); + long timeout = TimeUnit.MILLISECONDS.toSeconds(msg.getTimeout()); try { String output; if (AnsibleGlobalProperty.DEBUG_MODE2) { - output = ShellUtils.run(String.format("bash -c '. /var/lib/zstack/virtualenv/zstacksys/bin/activate; PYTHONPATH=%s timeout %d %s %s -i %s -vvvv --private-key %s -e '\\''%s'\\' | tee -a %s", + output = ShellUtils.run(String.format("bash -c '. /var/lib/zstack/virtualenv/zstacksys/bin/activate; PYTHONPATH=%s timeout %d %s -B %s -i %s -vvvv --private-key %s -e '\\''%s'\\' | tee -a %s", AnsibleConstant.ZSTACKLIB_ROOT, timeout, executable, playBookPath, AnsibleConstant.INVENTORY_FILE, msg.getPrivateKeyFile(), JSONObjectUtil.dumpPretty(arguments), AnsibleConstant.LOG_PATH), AnsibleConstant.ROOT_DIR); } else if (AnsibleGlobalProperty.DEBUG_MODE) { - output = ShellUtils.run(String.format("bash -c '. /var/lib/zstack/virtualenv/zstacksys/bin/activate; PYTHONPATH=%s timeout %d %s %s -i %s -vvvv --private-key %s -e '\\''%s'\\'", + output = ShellUtils.run(String.format("bash -c '. /var/lib/zstack/virtualenv/zstacksys/bin/activate; PYTHONPATH=%s timeout %d %s -B %s -i %s -vvvv --private-key %s -e '\\''%s'\\'", AnsibleConstant.ZSTACKLIB_ROOT, timeout, executable, playBookPath, AnsibleConstant.INVENTORY_FILE, msg.getPrivateKeyFile(), JSONObjectUtil.dumpPretty(arguments)), AnsibleConstant.ROOT_DIR); } else { - output = ShellUtils.run(String.format("bash -c '. /var/lib/zstack/virtualenv/zstacksys/bin/activate; PYTHONPATH=%s timeout %d %s %s -i %s --private-key %s -e '\\''%s'\\'", + output = ShellUtils.run(String.format("bash -c '. /var/lib/zstack/virtualenv/zstacksys/bin/activate; PYTHONPATH=%s timeout %d %s -B %s -i %s --private-key %s -e '\\''%s'\\'", AnsibleConstant.ZSTACKLIB_ROOT, timeout, executable, playBookPath, AnsibleConstant.INVENTORY_FILE, msg.getPrivateKeyFile(), JSONObjectUtil.dumpPretty(arguments)), AnsibleConstant.ROOT_DIR); } From 14a46c95f7c250b9ec8f4e402c62033fcd243944 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 3 Mar 2026 10:10:11 +0800 Subject: [PATCH 38/85] [ai]: add i18n for AI_MESSAGE_10003 Add global error code i18n mapping for ORG_ZSTACK_AI_MESSAGE_10003 across all 10 language files. Resolves: ZSTAC-82084 Change-Id: Ic1ce5d983b6651708003098363158728fad2fb85 --- conf/i18n/globalErrorCodeMapping/global-error-de-DE.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-en_US.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-id-ID.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-th-TH.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json | 1 + conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json | 1 + 10 files changed, 10 insertions(+) diff --git a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json index 86ce7f7c49b..81ae4d88c46 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json @@ -3426,6 +3426,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "Volume-Vorlage mit dem Namen %s konnte nicht gefunden werden", "ORG_ZSTACK_MEVOCO_10076": "shareable Volume(s) [UUID: %s] angehängt; Gruppensnapshot wird nicht unterstützt.", "ORG_ZSTACK_AI_MESSAGE_10002": "Dataset-Erstellungsauftrag kann nicht abgebrochen werden, weil die Ressourcen-UUID[%s] oder Model-Center-UUID[%s] null ist", + "ORG_ZSTACK_AI_MESSAGE_10003": "Die Modell-Download-Aufgabe muss neu gestartet werden", "ORG_ZSTACK_AI_MESSAGE_10001": "Model-Service-Download-Aufgabe kann nicht abgebrochen werden, weil die Ressourcen-UUID[%s] oder Model-Center-UUID[%s] null ist", "ORG_ZSTACK_IPSEC_VYOS_10009": "Anwendung auf HA-Gruppe fehlgeschlagen wegen %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "HTTP-Aufrufe an alle Prometheus-Instanzen in der Cloud-Umgebung fehlgeschlagen.", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json index 3abb14ae5fd..f45787d6344 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json @@ -3517,6 +3517,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "failed to find volume template by name:%s", "ORG_ZSTACK_MEVOCO_10076": "shareable volume(s) [UUID: %s] attached; group snapshot is not supported.", "ORG_ZSTACK_AI_MESSAGE_10002": "Cannot cancel dataset creation job because the resource UUID[%s] or model center UUID[%s] is null", + "ORG_ZSTACK_AI_MESSAGE_10003": "The model download task needs to restart", "ORG_ZSTACK_AI_MESSAGE_10001": "Cannot cancel model service download task because the resource UUID[%s] or model center UUID[%s] is null", "ORG_ZSTACK_IPSEC_VYOS_10009": "apply to HA group failed because %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "Failed to perform HTTP calls to all Prometheus instances in the cloud environment.", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json index 1f396a9c926..6a6ece44837 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json @@ -3516,6 +3516,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "Échec de recherche du modèle de volume par nom:%s", "ORG_ZSTACK_MEVOCO_10076": "Volume(s) partageable(s) [UUID: %s] attaché(s); la capture instantanée de groupe n'est pas prise en charge.", "ORG_ZSTACK_AI_MESSAGE_10002": "Impossible d'annuler la tâche de création de l'ensemble de données car l'UUID [%s] de la ressource ou l'UUID [%s] du centre de modèles est null", + "ORG_ZSTACK_AI_MESSAGE_10003": "La tâche de téléchargement du modèle doit être redémarrée", "ORG_ZSTACK_AI_MESSAGE_10001": "Impossible d'annuler la tâche de téléchargement du service de modèle car l'UUID [%s] de la ressource ou l'UUID [%s] du centre de modèles est null", "ORG_ZSTACK_IPSEC_VYOS_10009": "Échec de l'application au groupe haute disponibilité en raison de %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "Échec de l'exécution des appels HTTP vers toutes les instances Prometheus dans l'environnement cloud.", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json index b7030441844..c2bf216dea4 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json @@ -3516,6 +3516,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "gagal menemukan template volume berdasarkan name:%s", "ORG_ZSTACK_MEVOCO_10076": "volume(s) yang dapat dibagi [UUID: %s] terpasang; snapshot grup tidak didukung.", "ORG_ZSTACK_AI_MESSAGE_10002": "Tidak dapat membatalkan tugas pembuatan dataset karena resource UUID[%s] atau model center UUID[%s] adalah null", + "ORG_ZSTACK_AI_MESSAGE_10003": "Tugas unduhan model perlu dimulai ulang", "ORG_ZSTACK_AI_MESSAGE_10001": "Tidak dapat membatalkan tugas unduhan layanan model karena resource UUID[%s] atau model center UUID[%s] adalah null", "ORG_ZSTACK_IPSEC_VYOS_10009": "penerapan ke grup HA gagal karena %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "Gagal melakukan panggilan HTTP ke semua instance Prometheus di lingkungan cloud.", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json index 522b91caaac..5bd506c86a6 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json @@ -3486,6 +3486,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "名前:%s のボリュームテンプレートが見つかりません", "ORG_ZSTACK_MEVOCO_10076": "共有可能ボリューム [UUID: %s] が接続されています。グループスナップショットはサポートされていません。", "ORG_ZSTACK_AI_MESSAGE_10002": "リソース UUID[%s] またはモデルセンター UUID[%s] が null であるため、データセット作成ジョブをキャンセルできません", + "ORG_ZSTACK_AI_MESSAGE_10003": "モデルダウンロードタスクを再開始する必要があります", "ORG_ZSTACK_AI_MESSAGE_10001": "リソース UUID[%s] またはモデルセンター UUID[%s] が null であるため、モデルサービスダウンロードタスクをキャンセルできません", "ORG_ZSTACK_IPSEC_VYOS_10009": "HA グループへの適用に失敗しました: %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "クラウド環境内のすべての Prometheus インスタンスへの HTTP 呼び出しに失敗しました。", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json index 7d80ed4f94b..5d58f6618b1 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json @@ -3426,6 +3426,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "이름으로 볼륨 템플릿을 찾을 수 없습니다:%s", "ORG_ZSTACK_MEVOCO_10076": "공유 볼륨 [UUID: %s]이(가) 연결되어 있습니다. 그룹 스냅샷은 지원되지 않습니다.", "ORG_ZSTACK_AI_MESSAGE_10002": "리소스 UUID[%s] 또는 모델 센터 UUID[%s]가 null이므로 데이터세트 생성 작업을 취소할 수 없습니다", + "ORG_ZSTACK_AI_MESSAGE_10003": "모델 다운로드 작업을 다시 시작해야 합니다", "ORG_ZSTACK_AI_MESSAGE_10001": "리소스 UUID[%s] 또는 모델 센터 UUID[%s]가 null이므로 모델 서비스 다운로드 작업을 취소할 수 없습니다", "ORG_ZSTACK_IPSEC_VYOS_10009": "HA 그룹 적용 실패: %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "클라우드 환경의 모든 Prometheus 인스턴스에 HTTP 호출을 수행하지 못했습니다.", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json index 8300d1f1c1b..51a125842af 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json @@ -3516,6 +3516,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "не удалось найти шаблон тома по имени:%s", "ORG_ZSTACK_MEVOCO_10076": "Общие тома [UUID: %s] подключены; групповой снапшот не поддерживается.", "ORG_ZSTACK_AI_MESSAGE_10002": "Невозможно отменить задачу создания набора данных, так как ресурс UUID[%s] или UUID центра моделей[%s] имеет значение null", + "ORG_ZSTACK_AI_MESSAGE_10003": "Задача загрузки модели должна быть перезапущена", "ORG_ZSTACK_AI_MESSAGE_10001": "Невозможно отменить задачу загрузки сервиса модели, так как ресурс UUID[%s] или UUID центра моделей[%s] имеет значение null", "ORG_ZSTACK_IPSEC_VYOS_10009": "применение к группе HA не удалось из-за %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "Не удалось выполнить HTTP-вызовы ко всем экземплярам Prometheus в облачной среде.", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json index 53ab038368a..d8b7abf66d5 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json @@ -3516,6 +3516,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "ไม่สามารถค้นหา volume template ตามชื่อ:%s", "ORG_ZSTACK_MEVOCO_10076": "shareable volume(s) [UUID: %s] ถูกแนบแล้ว; group snapshot ไม่รองรับ", "ORG_ZSTACK_AI_MESSAGE_10002": "ไม่สามารถยกเลิกงานสร้าง dataset ได้เนื่องจาก resource UUID[%s] หรือ model center UUID[%s] เป็น null", + "ORG_ZSTACK_AI_MESSAGE_10003": "งาน download ของ model จำเป็นต้องเริ่มใหม่", "ORG_ZSTACK_AI_MESSAGE_10001": "ไม่สามารถยกเลิกงาน download ของ model service ได้เนื่องจาก resource UUID[%s] หรือ model center UUID[%s] เป็น null", "ORG_ZSTACK_IPSEC_VYOS_10009": "การ apply ไปยัง HA group ล้มเหลวเนื่องจาก %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "ไม่สามารถทำ HTTP calls ไปยัง Prometheus instances ทั้งหมดใน cloud environment ได้", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json index 042cee9044a..5235832dccf 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json @@ -3517,6 +3517,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "未能根据名称找到模板 LUN:%s", "ORG_ZSTACK_MEVOCO_10076": "可共享的卷[uuid: %s]已挂载,不支持组快照。", "ORG_ZSTACK_AI_MESSAGE_10002": "无法取消数据集创建任务,因为 resourceUuid[%s] 或 modelCenterUuid[%s] 为空", + "ORG_ZSTACK_AI_MESSAGE_10003": "模型下载任务需重新下载", "ORG_ZSTACK_AI_MESSAGE_10001": "无法取消模型服务下载任务,因为 resourceUuid[%s] 或 modelCenterUuid[%s] 为空", "ORG_ZSTACK_IPSEC_VYOS_10009": "应用到高可用组失败,因为 %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "失败的 HTTP 调用所有 Prometheus 实例", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json index 652b609ca18..40aa862f414 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json @@ -3516,6 +3516,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_BLOCK_10038": "未能根据名称找到模板 LUN:%s", "ORG_ZSTACK_MEVOCO_10076": "可共享的卷[uuid: %s]已挂载,不支持组快照。", "ORG_ZSTACK_AI_MESSAGE_10002": "無法取消數据叢創建任務,因为 resourceUuid[%s] 或 modelCenterUuid[%s] 为空", + "ORG_ZSTACK_AI_MESSAGE_10003": "模型下載任務需重新下載", "ORG_ZSTACK_AI_MESSAGE_10001": "無法取消模型服務下载任務,因为 resourceUuid[%s] 或 modelCenterUuid[%s] 为空", "ORG_ZSTACK_IPSEC_VYOS_10009": "应用到高可用组失敗,因为 %s", "ORG_ZSTACK_PREMIUM_EXTERNALSERVICE_PROMETHEUS_10013": "失敗的 HTTP 調用所有 Prometheus 实例", From 29e6c838b07196f803887d311c1030e99097ba70 Mon Sep 17 00:00:00 2001 From: J M Date: Tue, 3 Mar 2026 16:11:04 +0800 Subject: [PATCH 39/85] [conf]: support Python 2 to Python 3.11 venv upgrade for cli/ctl/sys Add ensure_python3_venv() to detect legacy Python 2 venvs and recreate them as Python 3.11. Previously zstack-cli only checked if the venv directory existed, causing it to reuse a Python 2 venv (pip 6.1.1) during upgrade, which failed to install packages. Also fix: - ansible version check: 2.11.12.3 -> 2.16.14 to match ansible 9.13.0 - ansible/ansible-playbook wrapper: use absolute path to avoid recursion Resolves: ZSTAC-82619 Change-Id: I7362737267617a626d75786c646a7a776f69766f --- conf/tools/install.sh | 52 +++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/conf/tools/install.sh b/conf/tools/install.sh index d53e6c3dceb..ca15a67650f 100755 --- a/conf/tools/install.sh +++ b/conf/tools/install.sh @@ -22,7 +22,24 @@ if [ -z $tool ]; then fi install_pip() { - pip3.11 --version | grep 22.3.1 >/dev/null || yum install python3.11-pip + pip3.11 --version | grep 22.3.1 >/dev/null || yum install -y python3.11-pip +} + +# Ensure the virtualenv at $1 is a Python 3.11 venv. +# If it does not exist or is a legacy Python 2 venv, recreate it. +ensure_python3_venv() { + local venv_path=$1 + local allowed_prefix="/var/lib/zstack/virtualenv" + + if [[ "$venv_path" != "$allowed_prefix"* || "$venv_path" == *".."* ]]; then + echo "Error: Path must start with $allowed_prefix. Provided: $venv_path" >&2 + exit 1 + fi + + if [ -d "$venv_path" ] && [ -x "$venv_path/bin/python3.11" ]; then + return 0 + fi + rm -rf "$venv_path" && python3.11 -m venv "$venv_path" || exit 1 } @@ -34,13 +51,7 @@ cd /tmp if [ $tool = 'zstack-cli' ]; then CLI_VIRENV_PATH=/var/lib/zstack/virtualenv/zstackcli [ ! -z $force ] && rm -rf $CLI_VIRENV_PATH - if [ ! -d "$CLI_VIRENV_PATH" ]; then - python3.11 -m venv $CLI_VIRENV_PATH - if [ $? -ne 0 ]; then - rm -rf $CLI_VIRENV_PATH - exit 1 - fi - fi + ensure_python3_venv "$CLI_VIRENV_PATH" . $CLI_VIRENV_PATH/bin/activate cd $cwd pip install -i $pypi_path --trusted-host localhost --ignore-installed zstackcli-*.tar.gz apibinding-*.tar.gz @@ -71,7 +82,7 @@ if [ $tool = 'zstack-cli' ]; then elif [ $tool = 'zstack-ctl' ]; then CTL_VIRENV_PATH=/var/lib/zstack/virtualenv/zstackctl - rm -rf $CTL_VIRENV_PATH && python3.11 -m venv $CTL_VIRENV_PATH || exit 1 + ensure_python3_venv "$CTL_VIRENV_PATH" . $CTL_VIRENV_PATH/bin/activate cd $cwd TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed zstackctl-*.tar.gz || exit 1 @@ -82,18 +93,15 @@ elif [ $tool = 'zstack-ctl' ]; then elif [ $tool = 'zstack-sys' ]; then SYS_VIRENV_PATH=/var/lib/zstack/virtualenv/zstacksys - NEED_INSTALL=false - if [ -d $SYS_VIRENV_PATH ]; then - . $SYS_VIRENV_PATH/bin/activate - if ! ansible --version | grep -q 'core 2.11.12.3'; then - deactivate - NEED_INSTALL=true - fi - else - NEED_INSTALL=true + ensure_python3_venv "$SYS_VIRENV_PATH" + RE_INSTALL=false + . $SYS_VIRENV_PATH/bin/activate + if ! ansible --version | grep -q 'core 2.16.14'; then + deactivate + RE_INSTALL=true fi - if $NEED_INSTALL; then - rm -rf $SYS_VIRENV_PATH && python3.11 -m venv /var/lib/zstack/virtualenv/zstacksys || exit 1 + if $RE_INSTALL; then + rm -rf $SYS_VIRENV_PATH && python3.11 -m venv $SYS_VIRENV_PATH || exit 1 . $SYS_VIRENV_PATH/bin/activate cd $cwd #TMPDIR=/usr/local/zstack/ pip install -i $pypi_path --trusted-host localhost --ignore-installed setuptools==65.5.1 || exit 1 @@ -112,7 +120,7 @@ LC_ALL=en_US.utf8 export LANG LC_ALL . ${VIRTUAL_ENV}/bin/activate -ansible \$@ +\${VIRTUAL_ENV}/bin/ansible \$@ EOF chmod +x /usr/bin/ansible @@ -129,7 +137,7 @@ LC_ALL=en_US.utf8 export LANG LC_ALL . ${VIRTUAL_ENV}/bin/activate -ansible-playbook \$@ +\${VIRTUAL_ENV}/bin/ansible-playbook \$@ EOF chmod +x /usr/bin/ansible-playbook fi From e835b3a29318a9d4f4f8595240e223afe780ca2a Mon Sep 17 00:00:00 2001 From: Shangmin Dou Date: Wed, 4 Mar 2026 14:27:52 +0800 Subject: [PATCH 40/85] [ai]: add eval task sort columns for ZQL Resolves: ZSTAC-72079 Change-Id: Ifa08d55dedbc6ff7beaf96c96142b470d1993dbf --- conf/db/upgrade/V5.5.12__schema.sql | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 812c033e27e..b3098a8685d 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -1,3 +1,32 @@ -- ZSTAC-75319: Add normalizedModelName column for GPU spec dedup CALL ADD_COLUMN('GpuDeviceSpecVO', 'normalizedModelName', 'VARCHAR(255)', 1, NULL); CALL CREATE_INDEX('GpuDeviceSpecVO', 'idx_gpu_spec_normalized_model', 'normalizedModelName'); + +-- Add totalScore and endTime columns to ModelEvaluationTaskVO for ZQL sorting support +-- Previously these values were only stored inside the opaque JSON TEXT field, +-- making them invisible to ZQL ORDER BY queries. +CALL ADD_COLUMN('ModelEvaluationTaskVO', 'totalScore', 'DOUBLE', 1, NULL); +CALL ADD_COLUMN('ModelEvaluationTaskVO', 'endTime', 'TIMESTAMP', 1, NULL); + +-- Add indexes to support efficient sorting +CALL CREATE_INDEX('ModelEvaluationTaskVO', 'idx_ModelEvaluationTaskVO_totalScore', 'totalScore'); +CALL CREATE_INDEX('ModelEvaluationTaskVO', 'idx_ModelEvaluationTaskVO_endTime', 'endTime'); + +-- Backfill totalScore from opaque JSON for existing completed tasks +UPDATE `zstack`.`ModelEvaluationTaskVO` +SET `totalScore` = JSON_EXTRACT(`opaque`, '$.details.total_score') +WHERE `opaque` IS NOT NULL + AND `totalScore` IS NULL + AND JSON_EXTRACT(`opaque`, '$.details.total_score') IS NOT NULL; + +-- Backfill endTime from opaque JSON for existing completed/failed tasks +-- end_time format from Python agent: "MMM dd, yyyy hh:mm:ss a" (e.g. "Jan 01, 2025 10:30:00 AM") +UPDATE `zstack`.`ModelEvaluationTaskVO` +SET `endTime` = STR_TO_DATE( + JSON_UNQUOTE(JSON_EXTRACT(`opaque`, '$.details.end_time')), + '%b %d, %Y %h:%i:%s %p' +) +WHERE `opaque` IS NOT NULL + AND `endTime` IS NULL + AND JSON_EXTRACT(`opaque`, '$.details.end_time') IS NOT NULL + AND JSON_UNQUOTE(JSON_EXTRACT(`opaque`, '$.details.end_time')) != ''; From 3bed7d6b01b5f1151496e97dc39ee4e1eebfb650 Mon Sep 17 00:00:00 2001 From: Shangmin Dou Date: Wed, 4 Mar 2026 19:14:19 +0800 Subject: [PATCH 41/85] [ai]: add totalScore and endTime fields to SDK inventory Resolves: ZSTAC-72079 Change-Id: I56358d8064ae020e564f2682518f215c8bb96bd5 --- .../zstack/sdk/ModelEvaluationTaskInventory.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sdk/src/main/java/org/zstack/sdk/ModelEvaluationTaskInventory.java b/sdk/src/main/java/org/zstack/sdk/ModelEvaluationTaskInventory.java index 7695a4c3ace..3dcf79a660b 100644 --- a/sdk/src/main/java/org/zstack/sdk/ModelEvaluationTaskInventory.java +++ b/sdk/src/main/java/org/zstack/sdk/ModelEvaluationTaskInventory.java @@ -228,4 +228,20 @@ public java.lang.Integer getReadTimeout() { return this.readTimeout; } + public java.lang.Double totalScore; + public void setTotalScore(java.lang.Double totalScore) { + this.totalScore = totalScore; + } + public java.lang.Double getTotalScore() { + return this.totalScore; + } + + public java.sql.Timestamp endTime; + public void setEndTime(java.sql.Timestamp endTime) { + this.endTime = endTime; + } + public java.sql.Timestamp getEndTime() { + return this.endTime; + } + } From 536cbc39aa31a27d38aef3a831941786b3624a94 Mon Sep 17 00:00:00 2001 From: Shangmin Dou Date: Wed, 4 Mar 2026 19:59:02 +0800 Subject: [PATCH 42/85] [ai]: use TIMESTAMP with default 1970 to fix MySQL 5.5 compatibility Resolves: ZSTAC-72079 Change-Id: Ie345ac5c788d4b53912ee80ec7dd437bf149601f --- conf/db/upgrade/V5.5.12__schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index b3098a8685d..7a7affec147 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -6,7 +6,7 @@ CALL CREATE_INDEX('GpuDeviceSpecVO', 'idx_gpu_spec_normalized_model', 'normalize -- Previously these values were only stored inside the opaque JSON TEXT field, -- making them invisible to ZQL ORDER BY queries. CALL ADD_COLUMN('ModelEvaluationTaskVO', 'totalScore', 'DOUBLE', 1, NULL); -CALL ADD_COLUMN('ModelEvaluationTaskVO', 'endTime', 'TIMESTAMP', 1, NULL); +CALL ADD_COLUMN('ModelEvaluationTaskVO', 'endTime', 'TIMESTAMP', 1, '1970-01-02 00:00:00'); -- Add indexes to support efficient sorting CALL CREATE_INDEX('ModelEvaluationTaskVO', 'idx_ModelEvaluationTaskVO_totalScore', 'totalScore'); From c05b8f70b317b91d801b81aacf69b01f7d8d38f4 Mon Sep 17 00:00:00 2001 From: Shangmin Dou Date: Wed, 4 Mar 2026 20:29:40 +0800 Subject: [PATCH 43/85] [ai]: use Json_getKeyValue for MySQL 5.5 compat use Json_getKeyValue from beforeMigrate.sql to replace JSON_EXTRACT which requires MySQL 5.7+, and change endTime column type from TIMESTAMP to DATETIME. Resolves: ZSTAC-72079 Change-Id: I50f05f114eae474f94f1046811dc0a8734b88c42 --- conf/db/upgrade/V5.5.12__schema.sql | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 7a7affec147..f5eaa76eea9 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -6,27 +6,28 @@ CALL CREATE_INDEX('GpuDeviceSpecVO', 'idx_gpu_spec_normalized_model', 'normalize -- Previously these values were only stored inside the opaque JSON TEXT field, -- making them invisible to ZQL ORDER BY queries. CALL ADD_COLUMN('ModelEvaluationTaskVO', 'totalScore', 'DOUBLE', 1, NULL); -CALL ADD_COLUMN('ModelEvaluationTaskVO', 'endTime', 'TIMESTAMP', 1, '1970-01-02 00:00:00'); +CALL ADD_COLUMN('ModelEvaluationTaskVO', 'endTime', 'DATETIME', 1, NULL); -- Add indexes to support efficient sorting CALL CREATE_INDEX('ModelEvaluationTaskVO', 'idx_ModelEvaluationTaskVO_totalScore', 'totalScore'); CALL CREATE_INDEX('ModelEvaluationTaskVO', 'idx_ModelEvaluationTaskVO_endTime', 'endTime'); -- Backfill totalScore from opaque JSON for existing completed tasks +-- Uses Json_getKeyValue defined in beforeMigrate.sql for MySQL 5.5+ compatibility UPDATE `zstack`.`ModelEvaluationTaskVO` -SET `totalScore` = JSON_EXTRACT(`opaque`, '$.details.total_score') +SET `totalScore` = CAST(Json_getKeyValue(`opaque`, 'total_score') AS DECIMAL(20,6)) WHERE `opaque` IS NOT NULL AND `totalScore` IS NULL - AND JSON_EXTRACT(`opaque`, '$.details.total_score') IS NOT NULL; + AND Json_getKeyValue(`opaque`, 'total_score') IS NOT NULL; -- Backfill endTime from opaque JSON for existing completed/failed tasks -- end_time format from Python agent: "MMM dd, yyyy hh:mm:ss a" (e.g. "Jan 01, 2025 10:30:00 AM") UPDATE `zstack`.`ModelEvaluationTaskVO` SET `endTime` = STR_TO_DATE( - JSON_UNQUOTE(JSON_EXTRACT(`opaque`, '$.details.end_time')), + Json_getKeyValue(`opaque`, 'end_time'), '%b %d, %Y %h:%i:%s %p' ) WHERE `opaque` IS NOT NULL AND `endTime` IS NULL - AND JSON_EXTRACT(`opaque`, '$.details.end_time') IS NOT NULL - AND JSON_UNQUOTE(JSON_EXTRACT(`opaque`, '$.details.end_time')) != ''; + AND Json_getKeyValue(`opaque`, 'end_time') IS NOT NULL + AND Json_getKeyValue(`opaque`, 'end_time') != ''; From 95b0ad508780a641606a17884fe9f791f505aeca Mon Sep 17 00:00:00 2001 From: Shangmin Dou Date: Wed, 4 Mar 2026 23:28:01 +0800 Subject: [PATCH 44/85] [core]: redesign StringSimilarity to match fmt template first Redesign findSimilar() with a three-phase strategy to prevent performance degradation when operr() format args contain very long strings (e.g., serialized ErrorCodeList or HTML bodies): Phase 1: regex match against raw fmt template (always short). Phase 2: fallback to formatted string only if Phase 1 misses and length <= maxElaborationRegex (8192). Phase 3: distance match always uses raw fmt template. Also add length guard in findMostSimilarRegex() and remove redundant String.format length check in Platform.elaborate(). Root cause: StringSimilarity.findSimilar() was running 199 regex patterns via ReTree against multi-KB formatted strings, causing 7+ second latency in error code creation hot paths. Resolves: ZSTAC-72079 Change-Id: I38b98a762deb436da31e4884da05d12b38b98a76 --- .../main/java/org/zstack/core/Platform.java | 4 -- .../zstack/utils/string/StringSimilarity.java | 51 +++++++++++++++---- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/core/src/main/java/org/zstack/core/Platform.java b/core/src/main/java/org/zstack/core/Platform.java index 87db543e9a0..554a576e057 100755 --- a/core/src/main/java/org/zstack/core/Platform.java +++ b/core/src/main/java/org/zstack/core/Platform.java @@ -938,10 +938,6 @@ public static boolean killProcess(int pid, Integer timeout) { private static ErrorCodeElaboration elaborate(String fmt, Object...args) { try { - if (String.format(fmt, args).length() > StringSimilarity.maxElaborationRegex) { - return null; - } - ErrorCodeElaboration elaboration = StringSimilarity.findSimilar(fmt, args); if (elaboration == null) { return null; diff --git a/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java b/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java index ee5257d1640..4a3b4921d3e 100644 --- a/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java +++ b/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java @@ -287,6 +287,15 @@ private static void logSearchSpend(String sub, long start, boolean found) { /** * find the most similar error code elaboration for the given error message. * + * The method uses a two-phase strategy to avoid performance degradation + * when format args produce very long strings (e.g., serialized error chains + * or HTML response bodies): + * + * Phase 1: Try regex matching with the formatted string (length-guarded). + * Phase 2: If Phase 1 misses (or formatted string too long), fallback + * to the raw fmt template for regex matching. + * Phase 3: Distance matching always uses the raw fmt template. + * * @param sub error message or error message fmt * @param args arguments * @return the most similar error code elaboration @@ -311,23 +320,41 @@ public static ErrorCodeElaboration findSimilar(String sub, Object...args) { errors.remove(sub); } - if (args != null && missed.get(String.format(sub, args)) != null) { - logSearchSpend(sub, start, false); - return null; - } else if (missed.get(sub) != null) { + // check missed cache for both fmt template and formatted string + if (missed.get(sub) != null) { logSearchSpend(sub, start, false); return null; } + if (args != null) { + try { + String formatted = String.format(sub, args); + if (missed.get(formatted) != null) { + logSearchSpend(sub, start, false); + return null; + } + } catch (Exception e) { + logger.trace(String.format("failed to format elaboration key: %s", e.getMessage())); + } + } - try { - logger.trace(String.format("start to search elaboration for: %s", String.format(sub, args))); - err = findMostSimilarRegex(String.format(sub, args)); - } catch (Exception e) { - logger.trace(String.format("start search elaboration for: %s", sub)); + // Phase 1: try regex matching with formatted string (guarded by length limit) + if (args != null && args.length > 0) { + try { + String formatted = String.format(sub, args); + if (formatted.length() <= maxElaborationRegex) { + err = findMostSimilarRegex(formatted); + } + } catch (Exception e) { + logger.trace(String.format("failed to format for regex matching: %s", e.getMessage())); + } + } + + // Phase 2: if formatted string missed or was too long, fallback to raw fmt template + if (err == null) { err = findMostSimilarRegex(sub); } - // find by distance is not reliable disable it for now + // Phase 3: distance matching uses the raw fmt template (always short) if (err == null) { err = findSimilarDistance(sub); } @@ -355,6 +382,10 @@ private static boolean verifyElaboration(ErrorCodeElaboration elaboration, Strin // better precision, worse performance private static ErrorCodeElaboration findMostSimilarRegex(String sub) { + if (sub.length() > maxElaborationRegex) { + return null; + } + if (!isRegexMatchedByRetrees(sub)) { return null; } From a5906f07501e6b6806134807338ee28148e4bcff Mon Sep 17 00:00:00 2001 From: "hanyu.liang" Date: Thu, 5 Mar 2026 15:16:05 +0800 Subject: [PATCH 45/85] [accesskey]: support AccessKey type distinction Add type attribute for AccessKey to distinguish between user and system types. Resolves: ZSTAC-82022 Change-Id: I6f706c6b777068657a627774746365626c6b636a --- conf/db/upgrade/V5.5.12__schema.sql | 2 ++ sdk/src/main/java/SourceClassMap.java | 2 ++ sdk/src/main/java/org/zstack/sdk/AccessKeyInventory.java | 9 +++++++++ sdk/src/main/java/org/zstack/sdk/AccessKeyType.java | 6 ++++++ .../main/java/org/zstack/sdk/CreateAccessKeyAction.java | 3 +++ 5 files changed, 22 insertions(+) create mode 100644 sdk/src/main/java/org/zstack/sdk/AccessKeyType.java diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 812c033e27e..1e655ac1818 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -1,3 +1,5 @@ +CALL ADD_COLUMN('AccessKeyVO', 'type', 'varchar(32)', 0, 'User'); + -- ZSTAC-75319: Add normalizedModelName column for GPU spec dedup CALL ADD_COLUMN('GpuDeviceSpecVO', 'normalizedModelName', 'VARCHAR(255)', 1, NULL); CALL CREATE_INDEX('GpuDeviceSpecVO', 'idx_gpu_spec_normalized_model', 'normalizedModelName'); diff --git a/sdk/src/main/java/SourceClassMap.java b/sdk/src/main/java/SourceClassMap.java index 4bbd9238f98..46a1ce8c961 100644 --- a/sdk/src/main/java/SourceClassMap.java +++ b/sdk/src/main/java/SourceClassMap.java @@ -9,6 +9,7 @@ public class SourceClassMap { put("org.zstack.abstraction.OptionType$InputType", "org.zstack.sdk.InputType"); put("org.zstack.accessKey.AccessKeyInventory", "org.zstack.sdk.AccessKeyInventory"); put("org.zstack.accessKey.AccessKeyState", "org.zstack.sdk.AccessKeyState"); + put("org.zstack.accessKey.AccessKeyType", "org.zstack.sdk.AccessKeyType"); put("org.zstack.ai.NginxRedirectRule", "org.zstack.sdk.NginxRedirectRule"); put("org.zstack.ai.entity.ApplicationDevelopmentServiceInventory", "org.zstack.sdk.ApplicationDevelopmentServiceInventory"); put("org.zstack.ai.entity.DatasetInventory", "org.zstack.sdk.DatasetInventory"); @@ -884,6 +885,7 @@ public class SourceClassMap { put("org.zstack.sdk.AccessControlRuleInventory", "org.zstack.loginControl.entity.AccessControlRuleInventory"); put("org.zstack.sdk.AccessKeyInventory", "org.zstack.accessKey.AccessKeyInventory"); put("org.zstack.sdk.AccessKeyState", "org.zstack.accessKey.AccessKeyState"); + put("org.zstack.sdk.AccessKeyType", "org.zstack.accessKey.AccessKeyType"); put("org.zstack.sdk.AccessPathInfo", "org.zstack.header.volume.block.AccessPathInfo"); put("org.zstack.sdk.AccountInventory", "org.zstack.header.identity.AccountInventory"); put("org.zstack.sdk.AccountPriceTableRefInventory", "org.zstack.billing.table.AccountPriceTableRefInventory"); diff --git a/sdk/src/main/java/org/zstack/sdk/AccessKeyInventory.java b/sdk/src/main/java/org/zstack/sdk/AccessKeyInventory.java index 78b4cf81380..78af2d28cdd 100644 --- a/sdk/src/main/java/org/zstack/sdk/AccessKeyInventory.java +++ b/sdk/src/main/java/org/zstack/sdk/AccessKeyInventory.java @@ -1,6 +1,7 @@ package org.zstack.sdk; import org.zstack.sdk.AccessKeyState; +import org.zstack.sdk.AccessKeyType; public class AccessKeyInventory { @@ -60,6 +61,14 @@ public AccessKeyState getState() { return this.state; } + public AccessKeyType type; + public void setType(AccessKeyType type) { + this.type = type; + } + public AccessKeyType getType() { + return this.type; + } + public java.sql.Timestamp createDate; public void setCreateDate(java.sql.Timestamp createDate) { this.createDate = createDate; diff --git a/sdk/src/main/java/org/zstack/sdk/AccessKeyType.java b/sdk/src/main/java/org/zstack/sdk/AccessKeyType.java new file mode 100644 index 00000000000..9e7df4f621c --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/AccessKeyType.java @@ -0,0 +1,6 @@ +package org.zstack.sdk; + +public enum AccessKeyType { + User, + System, +} diff --git a/sdk/src/main/java/org/zstack/sdk/CreateAccessKeyAction.java b/sdk/src/main/java/org/zstack/sdk/CreateAccessKeyAction.java index b76d75eee41..e69e06cc241 100644 --- a/sdk/src/main/java/org/zstack/sdk/CreateAccessKeyAction.java +++ b/sdk/src/main/java/org/zstack/sdk/CreateAccessKeyAction.java @@ -40,6 +40,9 @@ public Result throwExceptionIfError() { @Param(required = false, maxLength = 40, minLength = 10, nonempty = false, nullElements = false, emptyString = true, noTrim = false) public java.lang.String AccessKeySecret; + @Param(required = false, validValues = {"User","System"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String accessKeyType = "User"; + @Param(required = false) public java.lang.String resourceUuid; From 317a86f9414cce0af04b7a62cc459029e8b4481d Mon Sep 17 00:00:00 2001 From: Shangmin Dou Date: Fri, 6 Mar 2026 15:26:52 +0800 Subject: [PATCH 46/85] [core]: add hibernate.default_batch_fetch_size=50 to reduce N+1 SQL queries Reduces SQL count from ~52 to ~6 for VM queries and ~32 to ~5 for Host queries by enabling Hibernate batch fetching for EAGER collections. Resolves: ZSTAC-79217 Change-Id: I6bb2a852c1f3f3780f2bc6cf90ce330ae1b6d9fb --- conf/springConfigXml/DatabaseFacade.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/conf/springConfigXml/DatabaseFacade.xml b/conf/springConfigXml/DatabaseFacade.xml index 0e32b390993..97b03278a02 100755 --- a/conf/springConfigXml/DatabaseFacade.xml +++ b/conf/springConfigXml/DatabaseFacade.xml @@ -87,7 +87,8 @@ sync - true + true + 50 From 37d37099f379415da705a7f98c3c791ce7edc5c9 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Fri, 6 Mar 2026 23:27:55 +0800 Subject: [PATCH 47/85] [compute]: add quota check for VM CPU/memory upgrade operations Resolves: ZSTAC-51417 Change-Id: I0e49827c4c9c07c1be5e7a03b8817a21d424d95d --- .../zstack/compute/vm/VmQuotaOperator.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/compute/src/main/java/org/zstack/compute/vm/VmQuotaOperator.java b/compute/src/main/java/org/zstack/compute/vm/VmQuotaOperator.java index 10dfb0d7610..bb63bbc9991 100644 --- a/compute/src/main/java/org/zstack/compute/vm/VmQuotaOperator.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmQuotaOperator.java @@ -15,6 +15,7 @@ import org.zstack.header.image.ImageConstant; import org.zstack.header.message.APIMessage; import org.zstack.header.message.NeedQuotaCheckMessage; +import org.zstack.header.configuration.InstanceOfferingVO; import org.zstack.header.vm.*; import org.zstack.header.volume.*; import org.zstack.identity.QuotaUtil; @@ -59,6 +60,10 @@ public void checkQuota(APIMessage msg, Map pairs) { check((APIChangeResourceOwnerMsg) msg, pairs); } else if (msg instanceof APIRecoverVmInstanceMsg) { check((APIRecoverVmInstanceMsg) msg, pairs); + } else if (msg instanceof APIChangeInstanceOfferingMsg) { + check((APIChangeInstanceOfferingMsg) msg, pairs); + } else if (msg instanceof APIUpdateVmInstanceMsg) { + check((APIUpdateVmInstanceMsg) msg, pairs); } } else { if (msg instanceof APIChangeResourceOwnerMsg) { @@ -227,6 +232,66 @@ public void checkVmCupAndMemoryCapacity(String currentAccountUuid, String resour } } + @Transactional(readOnly = true) + private void check(APIChangeInstanceOfferingMsg msg, Map pairs) { + VmInstanceVO vm = dbf.findByUuid(msg.getVmInstanceUuid(), VmInstanceVO.class); + if (!VmInstanceState.Running.equals(vm.getState())) { + return; + } + InstanceOfferingVO newOffering = dbf.findByUuid(msg.getInstanceOfferingUuid(), InstanceOfferingVO.class); + long cpuDelta = newOffering.getCpuNum() - vm.getCpuNum(); + long memoryDelta = newOffering.getMemorySize() - vm.getMemorySize(); + String currentAccountUuid = msg.getSession().getAccountUuid(); + String resourceOwnerAccountUuid = new QuotaUtil().getResourceOwnerAccountUuid(msg.getVmInstanceUuid()); + checkVmCpuAndMemoryDelta(currentAccountUuid, resourceOwnerAccountUuid, cpuDelta, memoryDelta, pairs); + } + + @Transactional(readOnly = true) + private void check(APIUpdateVmInstanceMsg msg, Map pairs) { + if (msg.getCpuNum() == null && msg.getMemorySize() == null) { + return; + } + VmInstanceVO vm = dbf.findByUuid(msg.getVmInstanceUuid(), VmInstanceVO.class); + if (!VmInstanceState.Running.equals(vm.getState())) { + return; + } + long cpuDelta = msg.getCpuNum() != null ? msg.getCpuNum() - vm.getCpuNum() : 0; + long memoryDelta = msg.getMemorySize() != null ? msg.getMemorySize() - vm.getMemorySize() : 0; + String currentAccountUuid = msg.getSession().getAccountUuid(); + String resourceOwnerAccountUuid = new QuotaUtil().getResourceOwnerAccountUuid(msg.getVmInstanceUuid()); + checkVmCpuAndMemoryDelta(currentAccountUuid, resourceOwnerAccountUuid, cpuDelta, memoryDelta, pairs); + } + + @Transactional(readOnly = true) + private void checkVmCpuAndMemoryDelta(String currentAccountUuid, String resourceOwnerAccountUuid, + long cpuDelta, long memoryDelta, + Map pairs) { + if (cpuDelta <= 0 && memoryDelta <= 0) { + return; + } + VmQuotaUtil.VmQuota vmQuotaUsed = new VmQuotaUtil().getUsedVmCpuMemory(resourceOwnerAccountUuid); + if (cpuDelta > 0) { + QuotaUtil.QuotaCompareInfo quotaCompareInfo = new QuotaUtil.QuotaCompareInfo(); + quotaCompareInfo.currentAccountUuid = currentAccountUuid; + quotaCompareInfo.resourceTargetOwnerAccountUuid = resourceOwnerAccountUuid; + quotaCompareInfo.quotaName = VmQuotaConstant.VM_RUNNING_CPU_NUM; + quotaCompareInfo.quotaValue = pairs.get(VmQuotaConstant.VM_RUNNING_CPU_NUM).getValue(); + quotaCompareInfo.currentUsed = vmQuotaUsed.runningVmCpuNum; + quotaCompareInfo.request = cpuDelta; + new QuotaUtil().CheckQuota(quotaCompareInfo); + } + if (memoryDelta > 0) { + QuotaUtil.QuotaCompareInfo quotaCompareInfo = new QuotaUtil.QuotaCompareInfo(); + quotaCompareInfo.currentAccountUuid = currentAccountUuid; + quotaCompareInfo.resourceTargetOwnerAccountUuid = resourceOwnerAccountUuid; + quotaCompareInfo.quotaName = VmQuotaConstant.VM_RUNNING_MEMORY_SIZE; + quotaCompareInfo.quotaValue = pairs.get(VmQuotaConstant.VM_RUNNING_MEMORY_SIZE).getValue(); + quotaCompareInfo.currentUsed = vmQuotaUsed.runningVmMemorySize; + quotaCompareInfo.request = memoryDelta; + new QuotaUtil().CheckQuota(quotaCompareInfo); + } + } + private void checkVolumeQuotaForChangeResourceOwner(List dataVolumeUuids, List rootVolumeUuids, String resourceTargetOwnerAccountUuid, From 62e16265b7ea641f08131e6beb210d69c4184e55 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 5 Mar 2026 23:22:33 +0800 Subject: [PATCH 48/85] [vm]: handle NoState + Expunging safety net ZSTAC-80898 Change-Id: If279bcbcd53634346188ddc2f218dd81b2e0075e --- .../main/java/org/zstack/compute/vm/VmInstanceBase.java | 5 +++++ .../main/java/org/zstack/header/vm/VmInstanceState.java | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java b/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java index 80ba38486fc..d0ae6d3bcc9 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java @@ -1603,6 +1603,11 @@ public void handle(Map data) { changeVmStateInDb(VmInstanceStateEvent.paused, () -> self.setHostUuid(currentHostUuid)); } else if (currentState == VmInstanceState.Stopped) { changeVmStateInDb(VmInstanceStateEvent.stopped); + } else if (currentState == VmInstanceState.NoState) { + // ZSTAC-80898: When host reports NoState (e.g. QEMU crashed, libvirtd restarted), + // update DB to reflect actual state. Without this, VMs in intermediate states + // (Migrating, Starting, etc.) remain stuck in DB forever. + changeVmStateInDb(VmInstanceStateEvent.noState, () -> self.setHostUuid(currentHostUuid)); } fireEvent.run(); diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceState.java b/header/src/main/java/org/zstack/header/vm/VmInstanceState.java index 49303e23252..2f61f0c7e2e 100755 --- a/header/src/main/java/org/zstack/header/vm/VmInstanceState.java +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceState.java @@ -171,6 +171,13 @@ public enum VmInstanceState { new Transaction(VmInstanceStateEvent.stopped, VmInstanceState.Stopped), new Transaction(VmInstanceStateEvent.expunging, VmInstanceState.Expunging) ); + // ZSTAC-80898: Expunging safety net — if expunge fails, allow recovery + // instead of leaving VM permanently stuck. + Expunging.transactions( + new Transaction(VmInstanceStateEvent.destroyed, VmInstanceState.Destroyed), + new Transaction(VmInstanceStateEvent.stopped, VmInstanceState.Stopped), + new Transaction(VmInstanceStateEvent.unknown, VmInstanceState.Unknown) + ); Destroyed.transactions( new Transaction(VmInstanceStateEvent.stopped, VmInstanceState.Stopped) ); From eaffb322788357a08fb69a70550711d8ecaff77e Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Sat, 7 Mar 2026 18:07:41 +0800 Subject: [PATCH 49/85] [utils]: fix similarity search concurrency and performance 1. synchronized access to errors/missed LinkedHashMap(accessOrder=true) to fix concurrent modification bug causing 7s latency spikes 2. cache miss in findSimilar to avoid repeated expensive regex scans 3. remove unrelated PRE_PUSH_CHECKER_TEST GlobalConfig to fix CI Resolves: ZSTAC-72079 Co-Authored-By: Claude Opus 4.6 --- .../org/zstack/core/CoreGlobalProperty.java | 2 + .../main/java/org/zstack/core/Platform.java | 7 ++++ test/src/test/resources/zstack.properties | 1 + .../zstack/utils/string/StringSimilarity.java | 39 ++++++++++++++----- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/org/zstack/core/CoreGlobalProperty.java b/core/src/main/java/org/zstack/core/CoreGlobalProperty.java index 393face30d3..828b06b0bf2 100755 --- a/core/src/main/java/org/zstack/core/CoreGlobalProperty.java +++ b/core/src/main/java/org/zstack/core/CoreGlobalProperty.java @@ -12,6 +12,8 @@ public class CoreGlobalProperty { @GlobalProperty(name = "unitTestOn", defaultValue = "false") @AvailableValues(value ={"true","false"}) public static boolean UNIT_TEST_ON; + @GlobalProperty(name = "elaboration.slowThresholdMs", defaultValue = "0") + public static long ELABORATION_SLOW_THRESHOLD_MS; @GlobalProperty(name = "beanRefContextConf", defaultValue = "beanRefContext.xml") public static String BEAN_REF_CONTEXT_CONF; @GlobalProperty(name = "beanConf", defaultValue = "zstack.xml") diff --git a/core/src/main/java/org/zstack/core/Platform.java b/core/src/main/java/org/zstack/core/Platform.java index 554a576e057..ce5bada0d6c 100755 --- a/core/src/main/java/org/zstack/core/Platform.java +++ b/core/src/main/java/org/zstack/core/Platform.java @@ -936,7 +936,14 @@ public static boolean killProcess(int pid, Integer timeout) { } } + private static volatile boolean slowElaborationWired = false; + private static ErrorCodeElaboration elaborate(String fmt, Object...args) { + if (!slowElaborationWired) { + StringSimilarity.slowElaborationThresholdMs = CoreGlobalProperty.ELABORATION_SLOW_THRESHOLD_MS; + slowElaborationWired = true; + } + try { ErrorCodeElaboration elaboration = StringSimilarity.findSimilar(fmt, args); if (elaboration == null) { diff --git a/test/src/test/resources/zstack.properties b/test/src/test/resources/zstack.properties index f7f4da51bf3..42fa9e4f1ef 100755 --- a/test/src/test/resources/zstack.properties +++ b/test/src/test/resources/zstack.properties @@ -45,6 +45,7 @@ Zdfs.agentPort=8989 ApiMediator.apiWorkerNum=50 unitTestOn=true +elaboration.slowThresholdMs=200 exitJVMOnStop=false #CloudBus.closeTracker=true diff --git a/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java b/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java index 4a3b4921d3e..be5d1a5442f 100644 --- a/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java +++ b/utils/src/main/java/org/zstack/utils/string/StringSimilarity.java @@ -32,6 +32,9 @@ public class StringSimilarity { private static final int mapLength = 1500; public static int maxElaborationRegex = 8192; + // slow elaboration warn threshold in ms; 0 = disabled (default) + // enable via GlobalProperty or test setup: StringSimilarity.slowElaborationThresholdMs = 200; + public static long slowElaborationThresholdMs = 0; // matched errors private static final Map errors = new LinkedHashMap(mapLength, 0.9f, true) { @@ -280,8 +283,15 @@ private static boolean isRedundant(String sub) { } private static void logSearchSpend(String sub, long start, boolean found) { - logger.debug(String.format("[%s] spend %s ms to search elaboration \"%s\"", found, System.currentTimeMillis() - start, - sub.length() > 50 ? sub.substring(0 , 50) + "..." : sub)); + long cost = System.currentTimeMillis() - start; + if (slowElaborationThresholdMs > 0 && cost > slowElaborationThresholdMs) { + logger.warn(String.format("[SLOW-ELABORATION] cost %d ms, found=%s, input_len=%d, input=%s", + cost, found, sub.length(), + sub.length() > 100 ? sub.substring(0, 100) + "..." : sub)); + } else { + logger.debug(String.format("[%s] spend %s ms to search elaboration \"%s\"", found, cost, + sub.length() > 50 ? sub.substring(0, 50) + "..." : sub)); + } } /** @@ -306,7 +316,10 @@ public static ErrorCodeElaboration findSimilar(String sub, Object...args) { } long start = System.currentTimeMillis(); - ErrorCodeElaboration err = errors.get(sub); + ErrorCodeElaboration err; + synchronized (errors) { + err = errors.get(sub); + } if (err != null && verifyElaboration(err, sub, args)) { logSearchSpend(sub, start, true); return err; @@ -317,20 +330,26 @@ public static ErrorCodeElaboration findSimilar(String sub, Object...args) { // same cause will happen // invalid cache, generate elaboration again if (err != null) { - errors.remove(sub); + synchronized (errors) { + errors.remove(sub); + } } // check missed cache for both fmt template and formatted string - if (missed.get(sub) != null) { - logSearchSpend(sub, start, false); - return null; + synchronized (missed) { + if (missed.get(sub) != null) { + logSearchSpend(sub, start, false); + return null; + } } if (args != null) { try { String formatted = String.format(sub, args); - if (missed.get(formatted) != null) { - logSearchSpend(sub, start, false); - return null; + synchronized (missed) { + if (missed.get(formatted) != null) { + logSearchSpend(sub, start, false); + return null; + } } } catch (Exception e) { logger.trace(String.format("failed to format elaboration key: %s", e.getMessage())); From 931d8d92aa33e87955caf7af3e9257357124daa0 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Sun, 8 Mar 2026 23:44:17 +0800 Subject: [PATCH 50/85] [securityGroup]: relax priority constraints in SG rule API Resolves: ZSTAC-79067 Change-Id: I5d788cfc99b7292d1078a88fee635bd83fb5b5f0 --- .../SecurityGroupApiInterceptor.java | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/plugin/securityGroup/src/main/java/org/zstack/network/securitygroup/SecurityGroupApiInterceptor.java b/plugin/securityGroup/src/main/java/org/zstack/network/securitygroup/SecurityGroupApiInterceptor.java index 135ad3886aa..d76b88610b1 100755 --- a/plugin/securityGroup/src/main/java/org/zstack/network/securitygroup/SecurityGroupApiInterceptor.java +++ b/plugin/securityGroup/src/main/java/org/zstack/network/securitygroup/SecurityGroupApiInterceptor.java @@ -343,14 +343,6 @@ private void validate(APISetVmNicSecurityGroupMsg msg) { if (!aoMap.isEmpty()) { Integer[] priorities = aoMap.keySet().toArray(new Integer[aoMap.size()]); Arrays.sort(priorities); - if (priorities[0] != 1) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10022, "could no set vm nic security group, because invalid priority, priority expects to start at 1, but [%d]", priorities[0])); - } - for (int i = 0; i < priorities.length - 1; i++) { - if (priorities[i] + 1 != priorities[i + 1]) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10023, "could no set vm nic security group, because invalid priority, priority[%d] and priority[%d] expected to be consecutive", priorities[i], priorities[i + 1])); - } - } } @@ -386,19 +378,6 @@ private void validate(APISetVmNicSecurityGroupMsg msg) { msg.setRefs(newAOs); } - } else { - if (!adminIntegers.isEmpty()) { - Integer[] priorities = adminIntegers.toArray(new Integer[adminIntegers.size()]); - Arrays.sort(priorities); - if (priorities[0] != 1) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10024, "could no set vm nic security group, because admin security group priority[%d] must be higher than users", priorities[0])); - } - for (int i = 0; i < priorities.length - 1; i++) { - if (priorities[i] + 1 != priorities[i + 1]) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10025, "could no set vm nic security group, because admin security group priority[%d] must be higher than users", priorities[i + 1])); - } - } - } } } @@ -498,8 +477,9 @@ private void validate(APIUpdateSecurityGroupRulePriorityMsg msg) { rvos.stream().filter(rvo -> rvo.getUuid().equals(ao.getRuleUuid())).findFirst().orElseThrow(() -> new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10041, "could not update security group rule priority, because rule[uuid:%s] not in security group[uuid:%s]", ao.getRuleUuid(), msg.getSecurityGroupUuid()))); - rvos.stream().filter(rvo -> rvo.getPriority() == ao.getPriority()).findFirst().orElseThrow(() -> - new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10042, "could not update security group rule priority, because priority[%d] not in security group[uuid:%s]", ao.getPriority(), msg.getSecurityGroupUuid()))); + if (ao.getPriority() < 1 || ao.getPriority() > SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class)) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10042, "could not update security group rule priority, because priority[%d] is out of valid range [1, %d]", ao.getPriority(), SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class))); + } } List uuidList = new ArrayList<>(priorityMap.values()); @@ -534,8 +514,8 @@ private void validate(APIChangeSecurityGroupRuleMsg msg) { if (count.intValue() > SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class)) { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10047, "could not change security group rule, because security group %s rules number[%d] is out of max limit[%d]", vo.getType(), count.intValue(), SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class))); } - if (msg.getPriority() > count.intValue()) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10048, "could not change security group rule, because the maximum priority of %s rule is [%d]", vo.getType().toString(), count.intValue())); + if (msg.getPriority() > SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class)) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10048, "could not change security group rule, because the maximum priority of %s rule is [%d]", vo.getType().toString(), SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class))); } if (msg.getPriority() < 0) { msg.setPriority(SecurityGroupConstant.LOWEST_RULE_PRIORITY); @@ -1198,11 +1178,11 @@ private void validate(APIAddSecurityGroupRuleMsg msg) { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10119, "could not add security group rule, because security group %s rules number[%d] is out of max limit[%d]", SecurityGroupRuleType.Egress, (egressRuleCount + toCreateEgressRuleCount), SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class))); } - if (msg.getPriority() > (ingressRuleCount + 1) && toCreateIngressRuleCount > 0) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10120, "could not add security group rule, because priority[%d] must be consecutive, the ingress rule maximum priority is [%d]", msg.getPriority(), ingressRuleCount)); + if (msg.getPriority() > SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class) && toCreateIngressRuleCount > 0) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10120, "could not add security group rule, because priority[%d] exceeds the maximum allowed priority [%d]", msg.getPriority(), SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class))); } - if (msg.getPriority() > (egressRuleCount + 1) && toCreateEgressRuleCount > 0) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10121, "could not add security group rule, because priority[%d] must be consecutive, the egress rule maximum priority is [%d]", msg.getPriority(), egressRuleCount)); + if (msg.getPriority() > SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class) && toCreateEgressRuleCount > 0) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SECURITYGROUP_10121, "could not add security group rule, because priority[%d] exceeds the maximum allowed priority [%d]", msg.getPriority(), SecurityGroupGlobalConfig.SECURITY_GROUP_RULES_NUM_LIMIT.value(Integer.class))); } } From 1bba30b7e992c372f7f73f73aed7972a7a0c8e3d Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 9 Mar 2026 21:01:09 +0800 Subject: [PATCH 51/85] [db]: change VARCHAR(4096) to MEDIUMTEXT in Json_getKeyValue function to fix upgrade failure Resolves: ZSTAC-82980 Change-Id: I817129c48c949125befe098dc16f0e8496b4b870 --- conf/db/upgrade/beforeMigrate.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/conf/db/upgrade/beforeMigrate.sql b/conf/db/upgrade/beforeMigrate.sql index d53d98d8646..939e74eaa6c 100755 --- a/conf/db/upgrade/beforeMigrate.sql +++ b/conf/db/upgrade/beforeMigrate.sql @@ -5,12 +5,12 @@ DELIMITER $$ DROP FUNCTION IF EXISTS `Json_getKeyValue` $$ CREATE FUNCTION `Json_getKeyValue`( - in_JsonArray VARCHAR(4096), + in_JsonArray MEDIUMTEXT, in_KeyName VARCHAR(64) -) RETURNS VARCHAR(4096) CHARSET utf8 +) RETURNS MEDIUMTEXT CHARSET utf8 BEGIN - DECLARE vs_return, vs_JsonArray, vs_JsonString, vs_Json, vs_KeyName VARCHAR(4096); + DECLARE vs_return, vs_JsonArray, vs_JsonString, vs_Json, vs_KeyName MEDIUMTEXT; DECLARE vi_pos1, vi_pos2 SMALLINT UNSIGNED; SET vs_JsonArray = TRIM(in_JsonArray); From 16db51f7d0e3bd94743cc15c3d8bb92b7e9d46e1 Mon Sep 17 00:00:00 2001 From: littleya Date: Mon, 19 Jan 2026 02:12:28 +0800 Subject: [PATCH 52/85] [core]: support configure external service APIImpact DBImpact Resolves: ZCF-19,ZStack-80477,ZStack-80471 Change-Id: I726b676a6576716f6962646665707566676d6b65 --- conf/db/upgrade/V5.5.12__schema.sql | 11 + conf/persistence.xml | 1 + conf/serviceConfig/externalService.xml | 17 + .../core/externalservice/ExternalService.java | 6 + .../ExternalServiceManagerImpl.java | 326 +++++++++++++++++- .../externalservice/cronjob/CronJobImpl.java | 6 + ...IAddExternalServiceConfigurationEvent.java | 34 ++ ...lServiceConfigurationEventDoc_zh_cn.groovy | 32 ++ ...APIAddExternalServiceConfigurationMsg.java | 66 ++++ ...nalServiceConfigurationMsgDoc_zh_cn.groovy | 94 +++++ ...leteExternalServiceConfigurationEvent.java | 21 ++ ...lServiceConfigurationEventDoc_zh_cn.groovy | 23 ++ ...DeleteExternalServiceConfigurationMsg.java | 42 +++ ...nalServiceConfigurationMsgDoc_zh_cn.groovy | 67 ++++ ...IQueryExternalServiceConfigurationMsg.java | 24 ++ ...nalServiceConfigurationMsgDoc_zh_cn.groovy | 31 ++ ...ueryExternalServiceConfigurationReply.java | 32 ++ ...lServiceConfigurationReplyDoc_zh_cn.groovy | 32 ++ ...dateExternalServiceConfigurationEvent.java | 30 ++ ...lServiceConfigurationEventDoc_zh_cn.groovy | 32 ++ ...UpdateExternalServiceConfigurationMsg.java | 54 +++ ...nalServiceConfigurationMsgDoc_zh_cn.groovy | 67 ++++ .../ApplyExternalConfigurationResult.java | 39 +++ .../ApplyExternalServiceConfigurationMsg.java | 19 + ...pplyExternalServiceConfigurationReply.java | 28 ++ ...ExternalServiceConfigurationInventory.java | 89 +++++ ...viceConfigurationInventoryDoc_zh_cn.groovy | 45 +++ .../ExternalServiceConfigurationVO.java | 72 ++++ .../ExternalServiceConfigurationVO_.java | 18 + .../service/ExternalServiceInventory.java | 10 + .../core/external/service/RBACInfo.java | 6 +- sdk/src/main/java/SourceClassMap.java | 2 + ...AddExternalServiceConfigurationAction.java | 113 ++++++ ...AddExternalServiceConfigurationResult.java | 14 + ...eteExternalServiceConfigurationAction.java | 104 ++++++ ...eteExternalServiceConfigurationResult.java | 7 + ...ExternalServiceConfigurationInventory.java | 55 +++ .../zstack/sdk/ExternalServiceInventory.java | 8 + ...eryExternalServiceConfigurationAction.java | 75 ++++ ...eryExternalServiceConfigurationResult.java | 22 ++ ...ateExternalServiceConfigurationAction.java | 104 ++++++ ...ateExternalServiceConfigurationResult.java | 14 + .../java/org/zstack/testlib/ApiHelper.groovy | 110 ++++++ 43 files changed, 1993 insertions(+), 9 deletions(-) create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEvent.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEventDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsg.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsgDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEvent.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEventDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsg.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsgDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsg.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsgDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReply.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReplyDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEvent.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEventDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsg.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsgDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/ApplyExternalConfigurationResult.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationMsg.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationReply.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventory.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventoryDoc_zh_cn.groovy create mode 100644 header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO.java create mode 100644 header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO_.java create mode 100644 sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationResult.java create mode 100644 sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationResult.java create mode 100644 sdk/src/main/java/org/zstack/sdk/ExternalServiceConfigurationInventory.java create mode 100644 sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationResult.java create mode 100644 sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationResult.java diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 988b6ff5be3..74a1b143f9f 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -33,3 +33,14 @@ WHERE `opaque` IS NOT NULL AND `endTime` IS NULL AND Json_getKeyValue(`opaque`, 'end_time') IS NOT NULL AND Json_getKeyValue(`opaque`, 'end_time') != ''; + +-- Add ExternalServiceConfiguration table +CREATE TABLE IF NOT EXISTS `zstack`.`ExternalServiceConfigurationVO` ( + `uuid` varchar(32) NOT NULL UNIQUE, + `serviceType` varchar(32) NOT NULL, + `configuration` text DEFAULT NULL, + `description` varchar(2048) DEFAULT NULL, + `lastOpDate` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `createDate` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (`uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/conf/persistence.xml b/conf/persistence.xml index 30c36210dd6..b66d6319ff7 100755 --- a/conf/persistence.xml +++ b/conf/persistence.xml @@ -223,5 +223,6 @@ org.zstack.header.network.l3.L3NetworkSequenceNumberVO org.zstack.network.hostNetworkInterface.PhysicalSwitchVO org.zstack.network.hostNetworkInterface.PhysicalSwitchPortVO + org.zstack.header.core.external.service.ExternalServiceConfigurationVO diff --git a/conf/serviceConfig/externalService.xml b/conf/serviceConfig/externalService.xml index cb2f6c05d86..ae96366aa8a 100644 --- a/conf/serviceConfig/externalService.xml +++ b/conf/serviceConfig/externalService.xml @@ -9,4 +9,21 @@ org.zstack.header.core.external.service.APIReloadExternalServiceMsg + + + org.zstack.header.core.external.service.APIAddExternalServiceConfigurationMsg + + + + org.zstack.header.core.external.service.APIQueryExternalServiceConfigurationMsg + query + + + + org.zstack.header.core.external.service.APIUpdateExternalServiceConfigurationMsg + + + + org.zstack.header.core.external.service.APIDeleteExternalServiceConfigurationMsg + diff --git a/core/src/main/java/org/zstack/core/externalservice/ExternalService.java b/core/src/main/java/org/zstack/core/externalservice/ExternalService.java index 2b816f00337..01f29f90f1e 100755 --- a/core/src/main/java/org/zstack/core/externalservice/ExternalService.java +++ b/core/src/main/java/org/zstack/core/externalservice/ExternalService.java @@ -16,4 +16,10 @@ public interface ExternalService { ExternalServiceCapabilities getExternalServiceCapabilities(); void reload(); + + String getServiceType(); + + default void externalConfig(String serviceType) { + // no-op by default + }; } diff --git a/core/src/main/java/org/zstack/core/externalservice/ExternalServiceManagerImpl.java b/core/src/main/java/org/zstack/core/externalservice/ExternalServiceManagerImpl.java index 4f4ad548dad..4d39ffc4888 100755 --- a/core/src/main/java/org/zstack/core/externalservice/ExternalServiceManagerImpl.java +++ b/core/src/main/java/org/zstack/core/externalservice/ExternalServiceManagerImpl.java @@ -1,19 +1,36 @@ package org.zstack.core.externalservice; import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.core.CoreGlobalProperty; +import org.zstack.core.GlobalProperty; +import org.zstack.core.Platform; +import org.zstack.core.asyncbatch.While; import org.zstack.core.cloudbus.CloudBus; +import org.zstack.core.cloudbus.CloudBusCallBack; +import org.zstack.core.db.DatabaseFacade; +import org.zstack.core.db.Q; +import org.zstack.core.thread.ChainTask; +import org.zstack.core.thread.SyncTaskChain; +import org.zstack.core.thread.ThreadFacade; +import org.zstack.core.workflow.SimpleFlowChain; import org.zstack.header.AbstractService; -import org.zstack.header.core.external.service.APIGetExternalServicesMsg; -import org.zstack.header.core.external.service.APIGetExternalServicesReply; -import org.zstack.header.core.external.service.APIReloadExternalServiceEvent; -import org.zstack.header.core.external.service.APIReloadExternalServiceMsg; -import org.zstack.header.core.external.service.ExternalServiceInventory; -import org.zstack.header.core.external.service.ExternalServiceStatus; +import org.zstack.header.core.Completion; +import org.zstack.header.core.ReturnValueCompletion; +import org.zstack.header.core.WhileDoneCompletion; +import org.zstack.header.core.external.service.*; +import org.zstack.header.core.workflow.*; +import org.zstack.header.errorcode.ErrorCode; +import org.zstack.header.errorcode.ErrorCodeList; import org.zstack.header.errorcode.OperationFailureException; +import org.zstack.header.managementnode.ManagementNodeVO; +import org.zstack.header.managementnode.ManagementNodeVO_; import org.zstack.header.message.APIMessage; import org.zstack.header.message.Message; +import org.zstack.header.message.MessageReply; import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; @@ -24,6 +41,10 @@ public class ExternalServiceManagerImpl extends AbstractService implements ExternalServiceManager { @Autowired public CloudBus bus; + @Autowired + private DatabaseFacade dbf; + @Autowired + private ThreadFacade thdf; private final Map services = new ConcurrentHashMap<>(); @@ -72,8 +93,8 @@ public boolean stop() { public void handleMessage(Message msg) { if (msg instanceof APIMessage) { handleApiMessage((APIMessage) msg); - } else { - bus.dealWithUnknownMessage(msg); + } else { + handleLocalMessage(msg); } } @@ -82,6 +103,20 @@ public void handleApiMessage(APIMessage msg) { handle((APIGetExternalServicesMsg) msg); } else if (msg instanceof APIReloadExternalServiceMsg) { handle((APIReloadExternalServiceMsg) msg); + } else if (msg instanceof APIAddExternalServiceConfigurationMsg){ + handle((APIAddExternalServiceConfigurationMsg) msg); + } else if (msg instanceof APIUpdateExternalServiceConfigurationMsg) { + handle((APIUpdateExternalServiceConfigurationMsg) msg); + } else if (msg instanceof APIDeleteExternalServiceConfigurationMsg) { + handle((APIDeleteExternalServiceConfigurationMsg) msg); + } else { + bus.dealWithUnknownMessage(msg); + } + } + + private void handleLocalMessage(Message msg) { + if (msg instanceof ApplyExternalServiceConfigurationMsg) { + handle((ApplyExternalServiceConfigurationMsg) msg); } else { bus.dealWithUnknownMessage(msg); } @@ -118,12 +153,287 @@ private void handle(APIGetExternalServicesMsg msg) { inv.setName(name); inv.setStatus(service.isAlive() ? ExternalServiceStatus.RUNNING.toString() : ExternalServiceStatus.STOPPED.toString()); inv.setCapabilities(service.getExternalServiceCapabilities()); + inv.setServiceType(service.getServiceType()); reply.getInventories().add(inv); }); bus.reply(msg, reply); } + private void handle(APIAddExternalServiceConfigurationMsg msg ){ + APIAddExternalServiceConfigurationEvent event = new APIAddExternalServiceConfigurationEvent(msg.getId()); + + thdf.chainSubmit(new ChainTask(msg) { + @Override + public void run(SyncTaskChain chain) { + createExternalServiceConfiguration(msg, event, new Completion(chain) { + @Override + public void success() { + bus.publish(event); + chain.next(); + } + + @Override + public void fail(ErrorCode errorCode) { + event.setError(errorCode); + bus.publish(event); + chain.next(); + } + }); + } + + @Override + public String getSyncSignature() { + return String.format("create-update-delete-external-service-configuration-%s", msg.getExternalServiceType()); + } + + @Override + public String getName() { + return String.format("create-external-service-configuration-type-%s", msg.getExternalServiceType()); + } + }); + + } + + private void createExternalServiceConfiguration(APIAddExternalServiceConfigurationMsg msg, APIAddExternalServiceConfigurationEvent evt, Completion completion) { + // create db record + ExternalServiceConfigurationVO configurationVO = new ExternalServiceConfigurationVO(); + configurationVO.setUuid(msg.getResourceUuid() != null ? msg.getResourceUuid() : Platform.getUuid()); + configurationVO.setServiceType(msg.getExternalServiceType()); + configurationVO.setConfiguration(msg.getConfiguration()); + configurationVO.setDescription(msg.getDescription()); + configurationVO = dbf.persistAndRefresh(configurationVO); + + ExternalServiceConfigurationInventory inv = ExternalServiceConfigurationInventory.valueOf(configurationVO); + + applyExternalServiceConfigurationToAllNodes(configurationVO.getServiceType(), new ReturnValueCompletion>(completion) { + @Override + public void success(List returnValue) { + evt.setInventory(inv); + completion.success(); + } + + @Override + public void fail(ErrorCode errorCode) { + completion.fail(errorCode); + } + }); + } + + private void handle(APIUpdateExternalServiceConfigurationMsg msg){ + APIUpdateExternalServiceConfigurationEvent event = new APIUpdateExternalServiceConfigurationEvent(msg.getId()); + ExternalServiceConfigurationVO vo = dbf.findByUuid(msg.getUuid(), ExternalServiceConfigurationVO.class); + final String syncKey = vo != null ? vo.getServiceType() : msg.getUuid(); + + thdf.chainSubmit(new ChainTask(msg) { + @Override + public void run(SyncTaskChain chain) { + updateExternalServiceConfiguration(msg, event, new Completion(chain) { + @Override + public void success() { + bus.publish(event); + chain.next(); + } + + @Override + public void fail(ErrorCode errorCode) { + event.setError(errorCode); + bus.publish(event); + chain.next(); + } + }); + } + + @Override + public String getSyncSignature() { + return String.format("create-update-delete-external-service-configuration-%s", syncKey); + } + + @Override + public String getName() { + return String.format("update-external-service-configuration-%s", msg.getUuid()); + } + }); + } + + private void updateExternalServiceConfiguration(APIUpdateExternalServiceConfigurationMsg msg, APIUpdateExternalServiceConfigurationEvent evt, Completion completion) { + ExternalServiceConfigurationVO vo = dbf.findByUuid(msg.getUuid(), ExternalServiceConfigurationVO.class); + + if (vo == null) { + completion.fail(operr("unable to find external service configuration with uuid [%s]", msg.getUuid())); + return; + } + + if (msg.getDescription() != null) { + vo.setDescription(msg.getDescription()); + } + vo = dbf.updateAndRefresh(vo); + + evt.setInventory(ExternalServiceConfigurationInventory.valueOf(vo)); + completion.success(); + } + + private void handle(APIDeleteExternalServiceConfigurationMsg msg) { + APIDeleteExternalServiceConfigurationEvent event = new APIDeleteExternalServiceConfigurationEvent(msg.getId()); + ExternalServiceConfigurationVO vo = dbf.findByUuid(msg.getUuid(), ExternalServiceConfigurationVO.class); + final String syncKey = vo != null ? vo.getServiceType() : msg.getUuid(); + + thdf.chainSubmit(new ChainTask(msg) { + @Override + public void run(SyncTaskChain chain) { + deleteExternalServiceConfiguration(msg, event, new Completion(chain) { + @Override + public void success() { + bus.publish(event); + chain.next(); + } + + @Override + public void fail(ErrorCode errorCode) { + event.setError(errorCode); + bus.publish(event); + chain.next(); + } + }); + } + + @Override + public String getSyncSignature() { + return String.format("create-update-delete-external-service-configuration-%s", syncKey); + } + + @Override + public String getName() { + return String.format("delete-external-service-configuration-%s", msg.getUuid()); + } + }); + } + + private void deleteExternalServiceConfiguration(APIDeleteExternalServiceConfigurationMsg msg, APIDeleteExternalServiceConfigurationEvent evt, Completion completion) { + // delete db record + ExternalServiceConfigurationVO vo = dbf.findByUuid(msg.getUuid(), ExternalServiceConfigurationVO.class); + String serviceType; + if (vo != null) { + serviceType = vo.getServiceType(); + dbf.remove(vo); + } else { + completion.success(); + return; + } + + applyExternalServiceConfigurationToAllNodes(serviceType, new ReturnValueCompletion>(completion) { + @Override + public void success(List returnValue) { + completion.success(); + } + + @Override + public void fail(ErrorCode errorCode) { + completion.fail(errorCode); + } + }); + } + + private void applyExternalServiceConfigurationToAllNodes(String serviceType, ReturnValueCompletion> completion) { + final List results = Collections.synchronizedList(new ArrayList<>()); + + FlowChain chain = new SimpleFlowChain(); + chain.setName("apply-external-service-configuration-to-all-nodes"); + chain.then(new Flow() { + String __name__ = "apply-external-service-configuration"; + + @Override + public void run(FlowTrigger trigger, Map data) { + List mnUuids = Q.New(ManagementNodeVO.class).select(ManagementNodeVO_.uuid).listValues(); + + final ErrorCode[] errorCode = new ErrorCode[1]; + new While<>(mnUuids).each((mnUuid, whileCompletion) -> { + ApplyExternalServiceConfigurationMsg amsg = new ApplyExternalServiceConfigurationMsg(); + amsg.setServiceType(serviceType); + bus.makeServiceIdByManagementNodeId(amsg, SERVICE_ID, mnUuid); + bus.send(amsg, new CloudBusCallBack(whileCompletion) { + @Override + public void run(MessageReply reply) { + ApplyExternalConfigurationResult result = new ApplyExternalConfigurationResult(); + result.setManagementNodeUuid(mnUuid); + results.add(result); + + if (!reply.isSuccess()) { + result.setErrorCode(reply.getError()); + errorCode[0] = reply.getError(); + whileCompletion.allDone(); + return; + } + whileCompletion.done(); + } + }); + }).run(new WhileDoneCompletion(trigger) { + @Override + public void done(ErrorCodeList errorCodeList) { + if (errorCode[0] != null) { + trigger.fail(errorCode[0]); + return; + } + trigger.next(); + } + }); + } + + @Override + public void rollback(FlowRollback trigger, Map data) { + trigger.rollback(); + } + }).done(new FlowDoneHandler(completion) { + @Override + public void handle(Map data) { + completion.success(results); + } + }).error(new FlowErrorHandler(completion) { + @Override + public void handle(ErrorCode errCode, Map data) { + completion.fail(errCode); + } + }).start(); + } + + private void handle(ApplyExternalServiceConfigurationMsg msg) { + ApplyExternalServiceConfigurationReply reply = new ApplyExternalServiceConfigurationReply(); + + regenerateExternalServiceConfiguration(msg.getServiceType(), new ReturnValueCompletion(msg) { + @Override + public void success(String returnValue) { + reply.setValue(returnValue); + reply.setManagementNodeUuid(Platform.getManagementServerId()); + bus.reply(msg, reply); + } + + @Override + public void fail(ErrorCode errorCode) { + reply.setError(errorCode); + bus.reply(msg, reply); + } + }); + } + + private void regenerateExternalServiceConfiguration(String serviceType, ReturnValueCompletion completion) { + if (CoreGlobalProperty.UNIT_TEST_ON) { + completion.success(serviceType); + return; + } + for (ExternalService service : services.values()) { + if (serviceType.equals(service.getServiceType())) { + try{ + service.externalConfig(serviceType); + completion.success(serviceType); + } catch (Exception e) { + completion.fail(operr("failed to apply external service configuration for type [%s]: %s", serviceType, e.getMessage())); + } + return; + } + } + completion.fail(operr("unable to find external service type [%s]", serviceType)); + } + @Override public String getId() { return bus.makeLocalServiceId(SERVICE_ID); diff --git a/externalservice/src/main/java/org/zstack/externalservice/cronjob/CronJobImpl.java b/externalservice/src/main/java/org/zstack/externalservice/cronjob/CronJobImpl.java index 38e061312d4..1ed1d889cce 100755 --- a/externalservice/src/main/java/org/zstack/externalservice/cronjob/CronJobImpl.java +++ b/externalservice/src/main/java/org/zstack/externalservice/cronjob/CronJobImpl.java @@ -34,6 +34,12 @@ public String getName() { return String.format("cron-job-on-machine-%s", Platform.getManagementServerIp()); } + + @Override + public String getServiceType() { + return "CronJob"; + } + @Override public void start() { if (isAlive()) { diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEvent.java b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEvent.java new file mode 100644 index 00000000000..7341efed929 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEvent.java @@ -0,0 +1,34 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.message.APIEvent; +import org.zstack.header.rest.RestResponse; + +/** + * @Author: ya.wang + * @Date: 1/15/26 12:48 AM + */ +@RestResponse(allTo = "inventory") +public class APIAddExternalServiceConfigurationEvent extends APIEvent { + + private ExternalServiceConfigurationInventory inventory; + + public APIAddExternalServiceConfigurationEvent() {} + + public APIAddExternalServiceConfigurationEvent(String apiId) { super(apiId);} + + public void setInventory(ExternalServiceConfigurationInventory inventory) {this.inventory = inventory;} + + public ExternalServiceConfigurationInventory getInventory() {return inventory;} + + public static APIAddExternalServiceConfigurationEvent __example__() { + APIAddExternalServiceConfigurationEvent event = new APIAddExternalServiceConfigurationEvent(); + ExternalServiceConfigurationInventory inv = new ExternalServiceConfigurationInventory(); + + inv.setUuid(uuid()); + inv.setServiceType("Prometheus2"); + inv.setConfiguration("{}"); + event.setInventory(inv); + + return event; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEventDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEventDoc_zh_cn.groovy new file mode 100644 index 00000000000..db890f8acf4 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationEventDoc_zh_cn.groovy @@ -0,0 +1,32 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.core.external.service.ExternalServiceConfigurationInventory +import org.zstack.header.errorcode.ErrorCode + +doc { + + title "添加外部服务配置返回" + + ref { + name "inventory" + path "org.zstack.header.core.external.service.APIAddExternalServiceConfigurationEvent.inventory" + desc "外部服务配置详情" + type "ExternalServiceConfigurationInventory" + since "5.5.12" + clz ExternalServiceConfigurationInventory.class + } + field { + name "success" + desc "操作是否成功" + type "boolean" + since "5.5.12" + } + ref { + name "error" + path "org.zstack.header.core.external.service.APIAddExternalServiceConfigurationEvent.error" + desc "错误码,若不为null,则表示操作失败, 操作成功时该字段为null",false + type "ErrorCode" + since "5.5.12" + clz ErrorCode.class + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsg.java b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsg.java new file mode 100644 index 00000000000..a6983f464db --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsg.java @@ -0,0 +1,66 @@ +package org.zstack.header.core.external.service; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APICreateMessage; +import org.zstack.header.message.APIEvent; +import org.zstack.header.message.APIMessage; +import org.zstack.header.message.APIParam; +import org.zstack.header.other.APIAuditor; +import org.zstack.header.rest.RestRequest; + +/** + * @Author: ya.wang + * @Date: 1/15/26 12:43 AM + */ +@RestRequest( + path = "/external/service/configuration", + method = HttpMethod.POST, + parameterName = "params", + responseClass = APIAddExternalServiceConfigurationEvent.class +) +public class APIAddExternalServiceConfigurationMsg extends APICreateMessage implements APIAuditor { + @APIParam + private String externalServiceType; + @APIParam(maxLength = 65535) + private String configuration; + @APIParam(maxLength = 2048, required = false) + private String description; + + public String getExternalServiceType() { + return externalServiceType; + } + + public void setExternalServiceType(String externalServiceType) { + this.externalServiceType = externalServiceType; + } + + public String getConfiguration() { + return configuration; + } + + public void setConfiguration(String configuration) { + this.configuration = configuration; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public Result audit(APIMessage msg, APIEvent rsp) { + APIAddExternalServiceConfigurationEvent evt = (APIAddExternalServiceConfigurationEvent) rsp; + return new Result(rsp.isSuccess() ? evt.getInventory().getUuid(): "", ExternalServiceConfigurationVO.class); + } + + public static APIAddExternalServiceConfigurationMsg __example__() { + APIAddExternalServiceConfigurationMsg msg = new APIAddExternalServiceConfigurationMsg(); + msg.setExternalServiceType("Prometheus2"); + msg.setConfiguration("{}"); + msg.setDescription("description"); + return msg; + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsgDoc_zh_cn.groovy new file mode 100644 index 00000000000..2076550ce46 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIAddExternalServiceConfigurationMsgDoc_zh_cn.groovy @@ -0,0 +1,94 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.core.external.service.APIAddExternalServiceConfigurationEvent + +doc { + title "新建外部服务配置" + + category "externalService" + + desc """新建外部服务配置""" + + rest { + request { + url "POST /v1/external/service/configuration" + + header (Authorization: 'OAuth the-session-uuid') + + clz APIAddExternalServiceConfigurationMsg.class + + desc """""" + + params { + + column { + name "externalServiceType" + enclosedIn "params" + desc "外部服务类型, 例如 Prometheus2" + location "body" + type "String" + optional false + since "5.5.12" + } + column { + name "configuration" + enclosedIn "params" + desc "外部服务配置, 使用 json 格式" + location "body" + type "String" + optional false + since "5.5.12" + } + column { + name "description" + enclosedIn "params" + desc "资源的详细描述" + location "body" + type "String" + optional true + since "5.5.12" + } + column { + name "resourceUuid" + enclosedIn "params" + desc "资源UUID" + location "body" + type "String" + optional true + since "5.5.12" + } + column { + name "tagUuids" + enclosedIn "params" + desc "标签UUID列表" + location "body" + type "List" + optional true + since "5.5.12" + } + column { + name "systemTags" + enclosedIn "" + desc "系统标签" + location "body" + type "List" + optional true + since "5.5.12" + } + column { + name "userTags" + enclosedIn "" + desc "用户标签" + location "body" + type "List" + optional true + since "5.5.12" + } + } + } + + response { + clz APIAddExternalServiceConfigurationEvent.class + } + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEvent.java b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEvent.java new file mode 100644 index 00000000000..d7ac0bf2f65 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEvent.java @@ -0,0 +1,21 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.message.APIEvent; +import org.zstack.header.rest.RestResponse; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:47 AM + */ +@RestResponse +public class APIDeleteExternalServiceConfigurationEvent extends APIEvent { + public APIDeleteExternalServiceConfigurationEvent() {} + + public APIDeleteExternalServiceConfigurationEvent(String apiId) { super(apiId); } + + public static APIDeleteExternalServiceConfigurationEvent __example__() { + APIDeleteExternalServiceConfigurationEvent event = new APIDeleteExternalServiceConfigurationEvent(); + event.setSuccess(true); + return event; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEventDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEventDoc_zh_cn.groovy new file mode 100644 index 00000000000..97e22e78bba --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationEventDoc_zh_cn.groovy @@ -0,0 +1,23 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.errorcode.ErrorCode + +doc { + + title "删除外部服务配置返回" + + field { + name "success" + desc "操作是否成功" + type "boolean" + since "5.5.12" + } + ref { + name "error" + path "org.zstack.header.core.external.service.APIDeleteExternalServiceConfigurationEvent.error" + desc "错误码,若不为null,则表示操作失败, 操作成功时该字段为null",false + type "ErrorCode" + since "5.5.12" + clz ErrorCode.class + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsg.java b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsg.java new file mode 100644 index 00000000000..b04729437f2 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsg.java @@ -0,0 +1,42 @@ +package org.zstack.header.core.external.service; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIDeleteMessage; +import org.zstack.header.message.APIEvent; +import org.zstack.header.message.APIMessage; +import org.zstack.header.message.APIParam; +import org.zstack.header.other.APIAuditor; +import org.zstack.header.rest.RestRequest; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:44 AM + */ +@RestRequest( + path = "/external/service/configuration/{uuid}", + responseClass = APIDeleteExternalServiceConfigurationEvent.class, + method = HttpMethod.DELETE +) +public class APIDeleteExternalServiceConfigurationMsg extends APIDeleteMessage implements APIAuditor { + @APIParam(resourceType = ExternalServiceConfigurationVO.class, successIfResourceNotExisting = true) + private String uuid; + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + @Override + public Result audit(APIMessage msg, APIEvent rsp) { + return new APIAuditor.Result(((APIDeleteExternalServiceConfigurationMsg)msg).getUuid(), ExternalServiceConfigurationVO.class); + } + + public static APIDeleteExternalServiceConfigurationMsg __example__() { + APIDeleteExternalServiceConfigurationMsg msg = new APIDeleteExternalServiceConfigurationMsg(); + msg.setUuid(uuid()); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsgDoc_zh_cn.groovy new file mode 100644 index 00000000000..3b9c86b5ba2 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIDeleteExternalServiceConfigurationMsgDoc_zh_cn.groovy @@ -0,0 +1,67 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.core.external.service.APIDeleteExternalServiceConfigurationEvent + +doc { + title "删除外部服务配置" + + category "externalService" + + desc """删除外部服务配置""" + + rest { + request { + url "DELETE /v1/external/service/configuration/{uuid}" + + header (Authorization: 'OAuth the-session-uuid') + + clz APIDeleteExternalServiceConfigurationMsg.class + + desc """""" + + params { + + column { + name "uuid" + enclosedIn "" + desc "资源的UUID,唯一标示该资源" + location "url" + type "String" + optional false + since "5.5.12" + } + column { + name "deleteMode" + enclosedIn "" + desc "删除模式(Permissive / Enforcing,Permissive)" + location "body" + type "String" + optional true + since "5.5.12" + } + column { + name "systemTags" + enclosedIn "" + desc "系统标签" + location "body" + type "List" + optional true + since "5.5.12" + } + column { + name "userTags" + enclosedIn "" + desc "用户标签" + location "body" + type "List" + optional true + since "5.5.12" + } + } + } + + response { + clz APIDeleteExternalServiceConfigurationEvent.class + } + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsg.java b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsg.java new file mode 100644 index 00000000000..1ec870e2b86 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsg.java @@ -0,0 +1,24 @@ +package org.zstack.header.core.external.service; + +import org.springframework.http.HttpMethod; +import org.zstack.header.query.APIQueryMessage; +import org.zstack.header.query.AutoQuery; +import org.zstack.header.rest.RestRequest; + +import java.util.Collections; +import java.util.List; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:36 AM + */ +@AutoQuery(replyClass = APIQueryExternalServiceConfigurationReply.class, inventoryClass = ExternalServiceConfigurationInventory.class) +@RestRequest( + path = "/external/service/configuration", + optionalPaths = {"/external/service/configuration/{uuid}"}, + method = HttpMethod.GET, + responseClass = APIQueryExternalServiceConfigurationReply.class +) +public class APIQueryExternalServiceConfigurationMsg extends APIQueryMessage { + public static List __example__() {return Collections.singletonList("uuid=" + uuid());} +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsgDoc_zh_cn.groovy new file mode 100644 index 00000000000..b66929a1286 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationMsgDoc_zh_cn.groovy @@ -0,0 +1,31 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.core.external.service.APIQueryExternalServiceConfigurationReply +import org.zstack.header.query.APIQueryMessage + +doc { + title "查询外部服务配置" + + category "externalService" + + desc """查询外部服务配置""" + + rest { + request { + url "GET /v1/external/service/configuration" + url "GET /v1/external/service/configuration/{uuid}" + + header (Authorization: 'OAuth the-session-uuid') + + clz APIQueryExternalServiceConfigurationMsg.class + + desc """""" + + params APIQueryMessage.class + } + + response { + clz APIQueryExternalServiceConfigurationReply.class + } + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReply.java b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReply.java new file mode 100644 index 00000000000..87456312eeb --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReply.java @@ -0,0 +1,32 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.query.APIQueryReply; +import org.zstack.header.rest.RestResponse; + +import java.util.List; + +import static org.zstack.utils.CollectionDSL.list; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:39 AM + */ +@RestResponse(allTo = "inventories") +public class APIQueryExternalServiceConfigurationReply extends APIQueryReply { + private List inventories; + + public List getInventories() {return inventories;} + + public void setInventories(List inventories) {this.inventories = inventories;} + + public static APIQueryExternalServiceConfigurationReply __example__() { + APIQueryExternalServiceConfigurationReply reply = new APIQueryExternalServiceConfigurationReply(); + ExternalServiceConfigurationInventory inv = new ExternalServiceConfigurationInventory(); + + inv.setUuid(uuid()); + inv.setServiceType("Prometheus2"); + inv.setConfiguration("{}"); + reply.setInventories(list(inv)); + return reply; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReplyDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReplyDoc_zh_cn.groovy new file mode 100644 index 00000000000..dfeb4e14316 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIQueryExternalServiceConfigurationReplyDoc_zh_cn.groovy @@ -0,0 +1,32 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.core.external.service.ExternalServiceConfigurationInventory +import org.zstack.header.errorcode.ErrorCode + +doc { + + title "外部服务配置清单" + + ref { + name "inventories" + path "org.zstack.header.core.external.service.APIQueryExternalServiceConfigurationReply.inventories" + desc "null" + type "List" + since "5.5.12" + clz ExternalServiceConfigurationInventory.class + } + field { + name "success" + desc "" + type "boolean" + since "5.5.12" + } + ref { + name "error" + path "org.zstack.header.core.external.service.APIQueryExternalServiceConfigurationReply.error" + desc "错误码,若不为null,则表示操作失败, 操作成功时该字段为null",false + type "ErrorCode" + since "5.5.12" + clz ErrorCode.class + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEvent.java b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEvent.java new file mode 100644 index 00000000000..c7e5dae73b5 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEvent.java @@ -0,0 +1,30 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.message.APIEvent; +import org.zstack.header.rest.RestResponse; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:53 AM + */ +@RestResponse(allTo = "inventory") +public class APIUpdateExternalServiceConfigurationEvent extends APIEvent { + private ExternalServiceConfigurationInventory inventory; + + public APIUpdateExternalServiceConfigurationEvent() {} + + public APIUpdateExternalServiceConfigurationEvent(String apiId) { super(apiId); } + + public ExternalServiceConfigurationInventory getInventory() {return inventory;} + + public void setInventory(ExternalServiceConfigurationInventory inventory) {this.inventory = inventory;} + + public static APIUpdateExternalServiceConfigurationEvent __example__() { + APIUpdateExternalServiceConfigurationEvent event = new APIUpdateExternalServiceConfigurationEvent(); + ExternalServiceConfigurationInventory inv = new ExternalServiceConfigurationInventory(); + + inv.setUuid(uuid()); + event.setInventory(inv); + return event; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEventDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEventDoc_zh_cn.groovy new file mode 100644 index 00000000000..43ef3bfc0fa --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationEventDoc_zh_cn.groovy @@ -0,0 +1,32 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.core.external.service.ExternalServiceConfigurationInventory +import org.zstack.header.errorcode.ErrorCode + +doc { + + title "更新外部服务配置" + + ref { + name "inventory" + path "org.zstack.header.core.external.service.APIUpdateExternalServiceConfigurationEvent.inventory" + desc "null" + type "ExternalServiceConfigurationInventory" + since "5.5.12" + clz ExternalServiceConfigurationInventory.class + } + field { + name "success" + desc "" + type "boolean" + since "5.5.12" + } + ref { + name "error" + path "org.zstack.header.core.external.service.APIUpdateExternalServiceConfigurationEvent.error" + desc "错误码,若不为null,则表示操作失败, 操作成功时该字段为null",false + type "ErrorCode" + since "5.5.12" + clz ErrorCode.class + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsg.java b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsg.java new file mode 100644 index 00000000000..1fd29bd06b4 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsg.java @@ -0,0 +1,54 @@ +package org.zstack.header.core.external.service; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIEvent; +import org.zstack.header.message.APIMessage; +import org.zstack.header.message.APIParam; +import org.zstack.header.other.APIAuditor; +import org.zstack.header.rest.RestRequest; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:49 AM + */ +@RestRequest( + path = "/external/service/configuration/{uuid}", + isAction = true, + method = HttpMethod.PUT, + responseClass = APIUpdateExternalServiceConfigurationEvent.class +) +public class APIUpdateExternalServiceConfigurationMsg extends APIMessage implements APIAuditor { + @APIParam(resourceType = ExternalServiceConfigurationVO.class, maxLength = 32, operationTarget = true) + private String uuid; + + @APIParam(maxLength = 2048, required = false) + private String description; + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public Result audit(APIMessage msg, APIEvent rsp) { + return new APIAuditor.Result(((APIUpdateExternalServiceConfigurationMsg)msg).getUuid(), ExternalServiceConfigurationVO.class); + } + + public static APIUpdateExternalServiceConfigurationMsg __example__() { + APIUpdateExternalServiceConfigurationMsg msg = new APIUpdateExternalServiceConfigurationMsg(); + msg.setUuid(uuid()); + msg.setDescription("description"); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsgDoc_zh_cn.groovy new file mode 100644 index 00000000000..b38094f7cb1 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/APIUpdateExternalServiceConfigurationMsgDoc_zh_cn.groovy @@ -0,0 +1,67 @@ +package org.zstack.header.core.external.service + +import org.zstack.header.core.external.service.APIUpdateExternalServiceConfigurationEvent + +doc { + title "UpdateExternalServiceConfiguration" + + category "externalService" + + desc """在这里填写API描述""" + + rest { + request { + url "PUT /v1/external/service/configuration/{uuid}" + + header (Authorization: 'OAuth the-session-uuid') + + clz APIUpdateExternalServiceConfigurationMsg.class + + desc """""" + + params { + + column { + name "uuid" + enclosedIn "updateExternalServiceConfiguration" + desc "资源的UUID,唯一标示该资源" + location "url" + type "String" + optional false + since "5.5.12" + } + column { + name "description" + enclosedIn "updateExternalServiceConfiguration" + desc "资源的详细描述" + location "body" + type "String" + optional true + since "5.5.12" + } + column { + name "systemTags" + enclosedIn "" + desc "系统标签" + location "body" + type "List" + optional true + since "5.5.12" + } + column { + name "userTags" + enclosedIn "" + desc "用户标签" + location "body" + type "List" + optional true + since "5.5.12" + } + } + } + + response { + clz APIUpdateExternalServiceConfigurationEvent.class + } + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalConfigurationResult.java b/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalConfigurationResult.java new file mode 100644 index 00000000000..66a75a20cc9 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalConfigurationResult.java @@ -0,0 +1,39 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.errorcode.ErrorCode; + +/** + * @Author: ya.wang + * @Date: 1/15/26 2:50 AM + */ +public class ApplyExternalConfigurationResult { + + private String managementNodeUuid; + private ErrorCode errorCode; + private boolean success = true; + + public String getManagementNodeUuid() { + return managementNodeUuid; + } + + public void setManagementNodeUuid(String managementNodeUuid) { + this.managementNodeUuid = managementNodeUuid; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public void setErrorCode(ErrorCode errorCode) { + this.success = false; + this.errorCode = errorCode; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationMsg.java b/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationMsg.java new file mode 100644 index 00000000000..dd82db96f72 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationMsg.java @@ -0,0 +1,19 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.message.NeedReplyMessage; + +/** + * @Author: ya.wang + * @Date: 1/15/26 2:59 AM + */ +public class ApplyExternalServiceConfigurationMsg extends NeedReplyMessage { + private String serviceType; + + public String getServiceType() { + return serviceType; + } + + public void setServiceType(String serviceType) { + this.serviceType = serviceType; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationReply.java b/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationReply.java new file mode 100644 index 00000000000..08c25e04266 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/ApplyExternalServiceConfigurationReply.java @@ -0,0 +1,28 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.message.MessageReply; + +/** + * @Author: ya.wang + * @Date: 1/15/26 3:26 AM + */ +public class ApplyExternalServiceConfigurationReply extends MessageReply { + private String managementNodeUuid; + private String value; + + public String getManagementNodeUuid() { + return managementNodeUuid; + } + + public void setManagementNodeUuid(String managementNodeUuid) { + this.managementNodeUuid = managementNodeUuid; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventory.java b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventory.java new file mode 100644 index 00000000000..2ed907dc61d --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventory.java @@ -0,0 +1,89 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.search.Inventory; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:31 AM + */ +@Inventory(mappingVOClass = ExternalServiceConfigurationVO.class) +public class ExternalServiceConfigurationInventory { + private String uuid; + private String serviceType; + private String configuration; + private String description; + private Timestamp createDate; + private Timestamp lastOpDate; + + public static ExternalServiceConfigurationInventory valueOf(ExternalServiceConfigurationVO vo) { + ExternalServiceConfigurationInventory inv = new ExternalServiceConfigurationInventory(); + inv.setUuid(vo.getUuid()); + inv.setDescription(vo.getDescription()); + inv.setServiceType(vo.getServiceType()); + inv.setConfiguration(vo.getConfiguration()); + inv.setCreateDate(vo.getCreateDate()); + inv.setLastOpDate(vo.getLastOpDate()); + return inv; + } + + public static List valueOf(Collection vos) { + List invs = new ArrayList(); + for (ExternalServiceConfigurationVO vo : vos) { + invs.add(valueOf(vo)); + } + return invs; + } + + public Timestamp getLastOpDate() { + return lastOpDate; + } + + public void setLastOpDate(Timestamp lastOpDate) { + this.lastOpDate = lastOpDate; + } + + public Timestamp getCreateDate() { + return createDate; + } + + public void setCreateDate(Timestamp createDate) { + this.createDate = createDate; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getConfiguration() { + return configuration; + } + + public void setConfiguration(String configuration) { + this.configuration = configuration; + } + + public String getServiceType() { + return serviceType; + } + + public void setServiceType(String serviceType) { + this.serviceType = serviceType; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventoryDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventoryDoc_zh_cn.groovy new file mode 100644 index 00000000000..008bd93c094 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationInventoryDoc_zh_cn.groovy @@ -0,0 +1,45 @@ +package org.zstack.header.core.external.service + +import java.sql.Timestamp + +doc { + + title "外部服务配置" + + field { + name "uuid" + desc "资源的UUID,唯一标示该资源" + type "String" + since "5.5.12" + } + field { + name "serviceType" + desc "外部服务类型, 如 Prometheus2, FluentBitServer" + type "String" + since "5.5.12" + } + field { + name "configuration" + desc "外部服务配置, 使用 json 格式" + type "String" + since "5.5.12" + } + field { + name "description" + desc "资源的详细描述" + type "String" + since "5.5.12" + } + field { + name "createDate" + desc "创建时间" + type "Timestamp" + since "5.5.12" + } + field { + name "lastOpDate" + desc "最后一次修改时间" + type "Timestamp" + since "5.5.12" + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO.java b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO.java new file mode 100644 index 00000000000..86f6ade106d --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO.java @@ -0,0 +1,72 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.vo.ResourceVO; +import org.zstack.header.vo.ToInventory; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.PreUpdate; +import javax.persistence.Table; +import java.sql.Timestamp; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:25 AM + */ +@Entity +@Table +public class ExternalServiceConfigurationVO extends ResourceVO implements ToInventory { + @Column + private String serviceType; + @Column + private String configuration; + @Column + private String description; + @Column + private Timestamp createDate; + @Column + private Timestamp lastOpDate; + + public String getServiceType() { + return serviceType; + } + + @PreUpdate + private void preUpdate() { lastOpDate = null; } + + public void setServiceType(String serviceType) { + this.serviceType = serviceType; + } + + public String getConfiguration() { + return configuration; + } + + public void setConfiguration(String configuration) { + this.configuration = configuration; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Timestamp getCreateDate() { + return createDate; + } + + public void setCreateDate(Timestamp createDate) { + this.createDate = createDate; + } + + public Timestamp getLastOpDate() { + return lastOpDate; + } + + public void setLastOpDate(Timestamp lastOpDate) { + this.lastOpDate = lastOpDate; + } +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO_.java b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO_.java new file mode 100644 index 00000000000..6636e32b1c9 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceConfigurationVO_.java @@ -0,0 +1,18 @@ +package org.zstack.header.core.external.service; + +import org.zstack.header.vo.ResourceVO_; + +import javax.persistence.metamodel.SingularAttribute; +import java.sql.Timestamp; + +/** + * @Author: ya.wang + * @Date: 1/15/26 1:30 AM + */ +public class ExternalServiceConfigurationVO_ extends ResourceVO_ { + public static volatile SingularAttribute serviceType; + public static volatile SingularAttribute configuration; + public static volatile SingularAttribute description; + public static volatile SingularAttribute createDate; + public static volatile SingularAttribute lastOpDate; +} diff --git a/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceInventory.java b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceInventory.java index e1b14c73baa..bddfad86a84 100644 --- a/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceInventory.java +++ b/header/src/main/java/org/zstack/header/core/external/service/ExternalServiceInventory.java @@ -4,6 +4,7 @@ public class ExternalServiceInventory { private String name; private String status; private ExternalServiceCapabilities capabilities; + private String serviceType; public String getName() { return name; @@ -29,6 +30,14 @@ public void setCapabilities(ExternalServiceCapabilities capabilities) { this.capabilities = capabilities; } + public String getServiceType() { + return serviceType; + } + + public void setServiceType(String serviceType) { + this.serviceType = serviceType; + } + public static ExternalServiceInventory __example__() { ExternalServiceInventory inv = new ExternalServiceInventory(); inv.setName("prometheus"); @@ -36,6 +45,7 @@ public static ExternalServiceInventory __example__() { ExternalServiceCapabilities cap = new ExternalServiceCapabilities(); cap.setReloadConfig(true); inv.setCapabilities(cap); + inv.setServiceType("Prometheus2"); return inv; } } diff --git a/header/src/main/java/org/zstack/header/core/external/service/RBACInfo.java b/header/src/main/java/org/zstack/header/core/external/service/RBACInfo.java index 21b5c6b03e0..4537d62ee06 100644 --- a/header/src/main/java/org/zstack/header/core/external/service/RBACInfo.java +++ b/header/src/main/java/org/zstack/header/core/external/service/RBACInfo.java @@ -9,7 +9,11 @@ public void permissions() { permissionBuilder() .adminOnlyAPIs( APIGetExternalServicesMsg.class, - APIReloadExternalServiceMsg.class + APIReloadExternalServiceMsg.class, + APIAddExternalServiceConfigurationMsg.class, + APIQueryExternalServiceConfigurationMsg.class, + APIUpdateExternalServiceConfigurationMsg.class, + APIDeleteExternalServiceConfigurationMsg.class ).build(); } diff --git a/sdk/src/main/java/SourceClassMap.java b/sdk/src/main/java/SourceClassMap.java index 46a1ce8c961..bb413e5ac31 100644 --- a/sdk/src/main/java/SourceClassMap.java +++ b/sdk/src/main/java/SourceClassMap.java @@ -245,6 +245,7 @@ public class SourceClassMap { put("org.zstack.header.console.ConsoleProxyAgentInventory", "org.zstack.sdk.ConsoleProxyAgentInventory"); put("org.zstack.header.core.external.plugin.PluginDriverInventory", "org.zstack.sdk.PluginDriverInventory"); put("org.zstack.header.core.external.service.ExternalServiceCapabilities", "org.zstack.sdk.ExternalServiceCapabilities"); + put("org.zstack.header.core.external.service.ExternalServiceConfigurationInventory", "org.zstack.sdk.ExternalServiceConfigurationInventory"); put("org.zstack.header.core.external.service.ExternalServiceInventory", "org.zstack.sdk.ExternalServiceInventory"); put("org.zstack.header.core.progress.ChainInfo", "org.zstack.sdk.ChainInfo"); put("org.zstack.header.core.progress.PendingTaskInfo", "org.zstack.sdk.PendingTaskInfo"); @@ -1064,6 +1065,7 @@ public class SourceClassMap { put("org.zstack.sdk.ExternalPrimaryStorageInventory", "org.zstack.header.storage.addon.primary.ExternalPrimaryStorageInventory"); put("org.zstack.sdk.ExternalServiceCapabilities", "org.zstack.header.core.external.service.ExternalServiceCapabilities"); put("org.zstack.sdk.ExternalServiceCapabilitiesBuilder", "org.zstack.core.externalservice.ExternalServiceCapabilitiesBuilder"); + put("org.zstack.sdk.ExternalServiceConfigurationInventory", "org.zstack.header.core.external.service.ExternalServiceConfigurationInventory"); put("org.zstack.sdk.ExternalServiceInventory", "org.zstack.header.core.external.service.ExternalServiceInventory"); put("org.zstack.sdk.FaultToleranceVmGroupInventory", "org.zstack.faulttolerance.entity.FaultToleranceVmGroupInventory"); put("org.zstack.sdk.FcHbaDeviceInventory", "org.zstack.storage.device.hba.FcHbaDeviceInventory"); diff --git a/sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationAction.java b/sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationAction.java new file mode 100644 index 00000000000..2a7bf9f9df1 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationAction.java @@ -0,0 +1,113 @@ +package org.zstack.sdk; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class AddExternalServiceConfigurationAction extends AbstractAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.AddExternalServiceConfigurationResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String externalServiceType; + + @Param(required = true, maxLength = 65535, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String configuration; + + @Param(required = false, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String description; + + @Param(required = false) + public java.lang.String resourceUuid; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List tagUuids; + + @Param(required = false) + public java.util.List systemTags; + + @Param(required = false) + public java.util.List userTags; + + @Param(required = false) + public String sessionId; + + @Param(required = false) + public String accessKeyId; + + @Param(required = false) + public String accessKeySecret; + + @Param(required = false) + public String requestIp; + + @NonAPIParam + public long timeout = -1; + + @NonAPIParam + public long pollingInterval = -1; + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.AddExternalServiceConfigurationResult value = res.getResult(org.zstack.sdk.AddExternalServiceConfigurationResult.class); + ret.value = value == null ? new org.zstack.sdk.AddExternalServiceConfigurationResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "POST"; + info.path = "/external/service/configuration"; + info.needSession = true; + info.needPoll = true; + info.parameterName = "params"; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationResult.java b/sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationResult.java new file mode 100644 index 00000000000..743bd847d7e --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/AddExternalServiceConfigurationResult.java @@ -0,0 +1,14 @@ +package org.zstack.sdk; + +import org.zstack.sdk.ExternalServiceConfigurationInventory; + +public class AddExternalServiceConfigurationResult { + public ExternalServiceConfigurationInventory inventory; + public void setInventory(ExternalServiceConfigurationInventory inventory) { + this.inventory = inventory; + } + public ExternalServiceConfigurationInventory getInventory() { + return this.inventory; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationAction.java b/sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationAction.java new file mode 100644 index 00000000000..3f66ea0b137 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationAction.java @@ -0,0 +1,104 @@ +package org.zstack.sdk; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class DeleteExternalServiceConfigurationAction extends AbstractAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.DeleteExternalServiceConfigurationResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String uuid; + + @Param(required = false) + public java.lang.String deleteMode = "Permissive"; + + @Param(required = false) + public java.util.List systemTags; + + @Param(required = false) + public java.util.List userTags; + + @Param(required = false) + public String sessionId; + + @Param(required = false) + public String accessKeyId; + + @Param(required = false) + public String accessKeySecret; + + @Param(required = false) + public String requestIp; + + @NonAPIParam + public long timeout = -1; + + @NonAPIParam + public long pollingInterval = -1; + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.DeleteExternalServiceConfigurationResult value = res.getResult(org.zstack.sdk.DeleteExternalServiceConfigurationResult.class); + ret.value = value == null ? new org.zstack.sdk.DeleteExternalServiceConfigurationResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "DELETE"; + info.path = "/external/service/configuration/{uuid}"; + info.needSession = true; + info.needPoll = true; + info.parameterName = ""; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationResult.java b/sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationResult.java new file mode 100644 index 00000000000..22f9163d915 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/DeleteExternalServiceConfigurationResult.java @@ -0,0 +1,7 @@ +package org.zstack.sdk; + + + +public class DeleteExternalServiceConfigurationResult { + +} diff --git a/sdk/src/main/java/org/zstack/sdk/ExternalServiceConfigurationInventory.java b/sdk/src/main/java/org/zstack/sdk/ExternalServiceConfigurationInventory.java new file mode 100644 index 00000000000..05d6ca9e276 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/ExternalServiceConfigurationInventory.java @@ -0,0 +1,55 @@ +package org.zstack.sdk; + + + +public class ExternalServiceConfigurationInventory { + + public java.lang.String uuid; + public void setUuid(java.lang.String uuid) { + this.uuid = uuid; + } + public java.lang.String getUuid() { + return this.uuid; + } + + public java.lang.String serviceType; + public void setServiceType(java.lang.String serviceType) { + this.serviceType = serviceType; + } + public java.lang.String getServiceType() { + return this.serviceType; + } + + public java.lang.String configuration; + public void setConfiguration(java.lang.String configuration) { + this.configuration = configuration; + } + public java.lang.String getConfiguration() { + return this.configuration; + } + + public java.lang.String description; + public void setDescription(java.lang.String description) { + this.description = description; + } + public java.lang.String getDescription() { + return this.description; + } + + public java.sql.Timestamp createDate; + public void setCreateDate(java.sql.Timestamp createDate) { + this.createDate = createDate; + } + public java.sql.Timestamp getCreateDate() { + return this.createDate; + } + + public java.sql.Timestamp lastOpDate; + public void setLastOpDate(java.sql.Timestamp lastOpDate) { + this.lastOpDate = lastOpDate; + } + public java.sql.Timestamp getLastOpDate() { + return this.lastOpDate; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/ExternalServiceInventory.java b/sdk/src/main/java/org/zstack/sdk/ExternalServiceInventory.java index 8882e2f1f49..7f57fa5da36 100644 --- a/sdk/src/main/java/org/zstack/sdk/ExternalServiceInventory.java +++ b/sdk/src/main/java/org/zstack/sdk/ExternalServiceInventory.java @@ -28,4 +28,12 @@ public ExternalServiceCapabilities getCapabilities() { return this.capabilities; } + public java.lang.String serviceType; + public void setServiceType(java.lang.String serviceType) { + this.serviceType = serviceType; + } + public java.lang.String getServiceType() { + return this.serviceType; + } + } diff --git a/sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationAction.java b/sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationAction.java new file mode 100644 index 00000000000..069f3a8770f --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationAction.java @@ -0,0 +1,75 @@ +package org.zstack.sdk; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class QueryExternalServiceConfigurationAction extends QueryAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.QueryExternalServiceConfigurationResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.QueryExternalServiceConfigurationResult value = res.getResult(org.zstack.sdk.QueryExternalServiceConfigurationResult.class); + ret.value = value == null ? new org.zstack.sdk.QueryExternalServiceConfigurationResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "GET"; + info.path = "/external/service/configuration"; + info.needSession = true; + info.needPoll = false; + info.parameterName = ""; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationResult.java b/sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationResult.java new file mode 100644 index 00000000000..4697f0cc1fb --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/QueryExternalServiceConfigurationResult.java @@ -0,0 +1,22 @@ +package org.zstack.sdk; + + + +public class QueryExternalServiceConfigurationResult { + public java.util.List inventories; + public void setInventories(java.util.List inventories) { + this.inventories = inventories; + } + public java.util.List getInventories() { + return this.inventories; + } + + public java.lang.Long total; + public void setTotal(java.lang.Long total) { + this.total = total; + } + public java.lang.Long getTotal() { + return this.total; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationAction.java b/sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationAction.java new file mode 100644 index 00000000000..c91d30ed13d --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationAction.java @@ -0,0 +1,104 @@ +package org.zstack.sdk; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class UpdateExternalServiceConfigurationAction extends AbstractAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.UpdateExternalServiceConfigurationResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + @Param(required = true, maxLength = 32, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String uuid; + + @Param(required = false, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String description; + + @Param(required = false) + public java.util.List systemTags; + + @Param(required = false) + public java.util.List userTags; + + @Param(required = false) + public String sessionId; + + @Param(required = false) + public String accessKeyId; + + @Param(required = false) + public String accessKeySecret; + + @Param(required = false) + public String requestIp; + + @NonAPIParam + public long timeout = -1; + + @NonAPIParam + public long pollingInterval = -1; + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.UpdateExternalServiceConfigurationResult value = res.getResult(org.zstack.sdk.UpdateExternalServiceConfigurationResult.class); + ret.value = value == null ? new org.zstack.sdk.UpdateExternalServiceConfigurationResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "PUT"; + info.path = "/external/service/configuration/{uuid}"; + info.needSession = true; + info.needPoll = true; + info.parameterName = "updateExternalServiceConfiguration"; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationResult.java b/sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationResult.java new file mode 100644 index 00000000000..e00d4bc9fcb --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/UpdateExternalServiceConfigurationResult.java @@ -0,0 +1,14 @@ +package org.zstack.sdk; + +import org.zstack.sdk.ExternalServiceConfigurationInventory; + +public class UpdateExternalServiceConfigurationResult { + public ExternalServiceConfigurationInventory inventory; + public void setInventory(ExternalServiceConfigurationInventory inventory) { + this.inventory = inventory; + } + public ExternalServiceConfigurationInventory getInventory() { + return this.inventory; + } + +} diff --git a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy index f8470e35bd3..396e175189c 100644 --- a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy +++ b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy @@ -1421,6 +1421,33 @@ abstract class ApiHelper { } + def addExternalServiceConfiguration(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.AddExternalServiceConfigurationAction.class) Closure c) { + def a = new org.zstack.sdk.AddExternalServiceConfigurationAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + def addFiSecSecurityMachine(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.AddFiSecSecurityMachineAction.class) Closure c) { def a = new org.zstack.sdk.AddFiSecSecurityMachineAction() a.sessionId = Test.currentEnvSpec?.session?.uuid @@ -14489,6 +14516,33 @@ abstract class ApiHelper { } + def deleteExternalServiceConfiguration(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.DeleteExternalServiceConfigurationAction.class) Closure c) { + def a = new org.zstack.sdk.DeleteExternalServiceConfigurationAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + def deleteFirewall(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.DeleteFirewallAction.class) Closure c) { def a = new org.zstack.sdk.DeleteFirewallAction() a.sessionId = Test.currentEnvSpec?.session?.uuid @@ -30778,6 +30832,35 @@ abstract class ApiHelper { } + def queryExternalServiceConfiguration(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.QueryExternalServiceConfigurationAction.class) Closure c) { + def a = new org.zstack.sdk.QueryExternalServiceConfigurationAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + a.conditions = a.conditions.collect { it.toString() } + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + def queryFaultToleranceVm(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.QueryFaultToleranceVmAction.class) Closure c) { def a = new org.zstack.sdk.QueryFaultToleranceVmAction() a.sessionId = Test.currentEnvSpec?.session?.uuid @@ -44154,6 +44237,33 @@ abstract class ApiHelper { } + def updateExternalServiceConfiguration(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.UpdateExternalServiceConfigurationAction.class) Closure c) { + def a = new org.zstack.sdk.UpdateExternalServiceConfigurationAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + def updateFactoryModeState(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.UpdateFactoryModeStateAction.class) Closure c) { def a = new org.zstack.sdk.UpdateFactoryModeStateAction() a.sessionId = Test.currentEnvSpec?.session?.uuid From 241090b39a4988e735b8cfc3a5fbca92db55f528 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 10 Mar 2026 15:28:42 +0800 Subject: [PATCH 53/85] [longjob]: standardize LongJob progress detail format Add LongJobProgressDetail POJO and LongJobProgressDetailBuilder to normalize opaque progress data into unified typed structure. Three opaque formats parsed: VM migration, AI download, unknown. DB schema unchanged. Agent-side migration deferred to Phase 2. Resolves: ZSTAC-82318 Change-Id: I70d60ff5e6c8f659f55770e2fbbe56781b238fd5 --- .../core/progress/ProgressReportService.java | 3 + .../progress/APIGetTaskProgressReply.java | 12 + .../core/progress/LongJobProgressDetail.java | 135 ++++++++++ .../LongJobProgressDetailBuilder.java | 249 ++++++++++++++++++ .../core/progress/TaskProgressInventory.java | 10 + .../LongJobProgressNotificationMessage.java | 14 + .../org/zstack/sdk/LongJobProgressDetail.java | 95 +++++++ .../org/zstack/sdk/TaskProgressInventory.java | 8 + 8 files changed, 526 insertions(+) create mode 100644 header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java create mode 100644 header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java create mode 100644 sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java diff --git a/core/src/main/java/org/zstack/core/progress/ProgressReportService.java b/core/src/main/java/org/zstack/core/progress/ProgressReportService.java index bab9acc163a..dc9d93ea241 100755 --- a/core/src/main/java/org/zstack/core/progress/ProgressReportService.java +++ b/core/src/main/java/org/zstack/core/progress/ProgressReportService.java @@ -225,6 +225,9 @@ private TaskProgressInventory inventory(TaskProgressVO vo) { if (!StringUtils.isEmpty(vo.getArguments())) { inv.setArguments(vo.getArguments()); } + + inv.setProgressDetail(LongJobProgressDetailBuilder.fromTaskProgressVO(vo)); + return inv; } diff --git a/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java b/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java index 44e27d0b8e3..880e8cda1a8 100755 --- a/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java +++ b/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java @@ -6,6 +6,7 @@ import java.util.List; import static java.util.Arrays.asList; + /** * Created by xing5 on 2017/3/21. */ @@ -29,6 +30,17 @@ public static APIGetTaskProgressReply __example__() { inv.setTaskUuid("931102503f64436ea649939ff3957406"); inv.setTime(DocUtils.date); inv.setType("Task"); + + LongJobProgressDetail detail = new LongJobProgressDetail(); + detail.setPercent(42); + detail.setStage("downloading"); + detail.setState("running"); + detail.setProcessedBytes(440401920L); + detail.setTotalBytes(1073741824L); + detail.setSpeedBytesPerSecond(10485760L); + detail.setEstimatedRemainingSeconds(60L); + inv.setProgressDetail(detail); + msg.setInventories(asList(inv)); return msg; } diff --git a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java new file mode 100644 index 00000000000..d27dc05cc4c --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java @@ -0,0 +1,135 @@ +package org.zstack.header.core.progress; + +import java.util.Map; + +/** + * Standardized LongJob progress detail, parsed from TaskProgressVO.opaque. + * + * All fields are optional (nullable). Callers should null-check before use. + * This is a pure read-only view — the database schema (TaskProgressVO) is unchanged. + */ +public class LongJobProgressDetail { + /** Progress percentage 0-100, if known. */ + private Integer percent; + + /** Human-readable stage label, e.g. "downloading", "extracting". */ + private String stage; + + /** State identifier, e.g. "running", "paused". */ + private String state; + + /** Human-readable reason for current state. */ + private String stateReason; + + /** Bytes already processed. */ + private Long processedBytes; + + /** Total bytes to process. */ + private Long totalBytes; + + /** Items already processed (e.g. files, chunks). */ + private Long processedItems; + + /** Total items to process. */ + private Long totalItems; + + /** Transfer speed in bytes/s. */ + private Long speedBytesPerSecond; + + /** Estimated remaining time in seconds. */ + private Long estimatedRemainingSeconds; + + /** + * Catch-all for any opaque fields that don't map to the standard schema. + * Preserves unknown keys so no data is silently dropped. + */ + private Map extra; + + public Integer getPercent() { + return percent; + } + + public void setPercent(Integer percent) { + this.percent = percent; + } + + public String getStage() { + return stage; + } + + public void setStage(String stage) { + this.stage = stage; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getStateReason() { + return stateReason; + } + + public void setStateReason(String stateReason) { + this.stateReason = stateReason; + } + + public Long getProcessedBytes() { + return processedBytes; + } + + public void setProcessedBytes(Long processedBytes) { + this.processedBytes = processedBytes; + } + + public Long getTotalBytes() { + return totalBytes; + } + + public void setTotalBytes(Long totalBytes) { + this.totalBytes = totalBytes; + } + + public Long getProcessedItems() { + return processedItems; + } + + public void setProcessedItems(Long processedItems) { + this.processedItems = processedItems; + } + + public Long getTotalItems() { + return totalItems; + } + + public void setTotalItems(Long totalItems) { + this.totalItems = totalItems; + } + + public Long getSpeedBytesPerSecond() { + return speedBytesPerSecond; + } + + public void setSpeedBytesPerSecond(Long speedBytesPerSecond) { + this.speedBytesPerSecond = speedBytesPerSecond; + } + + public Long getEstimatedRemainingSeconds() { + return estimatedRemainingSeconds; + } + + public void setEstimatedRemainingSeconds(Long estimatedRemainingSeconds) { + this.estimatedRemainingSeconds = estimatedRemainingSeconds; + } + + public Map getExtra() { + return extra; + } + + public void setExtra(Map extra) { + this.extra = extra; + } +} diff --git a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java new file mode 100644 index 00000000000..18a436fc280 --- /dev/null +++ b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java @@ -0,0 +1,249 @@ +package org.zstack.header.core.progress; + +import org.zstack.utils.Utils; +import org.zstack.utils.gson.JSONObjectUtil; +import org.zstack.utils.logging.CLogger; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parses TaskProgressVO.opaque (free-form JSON) into a typed LongJobProgressDetail. + * + * Three known opaque formats are handled: + * Format 1 — VM migration: {"remain":N, "total":N, "speed":N, "remaining_migration_time":N} + * Format 2 — AI download: {"data": ""} where the inner JSON has + * {state, progress:{percent,downloaded_bytes,total_bytes, + * speed_bytes_per_second,estimated_remaining_seconds, + * downloaded_files,total_files,stage}, state_reason} + * Format 3 — unknown: entire map goes into LongJobProgressDetail.extra + * + * Each format is tried independently. Failures in one format don't affect others. + */ +public class LongJobProgressDetailBuilder { + private static final CLogger logger = Utils.getLogger(LongJobProgressDetailBuilder.class); + + private LongJobProgressDetailBuilder() {} + + /** + * Build a LongJobProgressDetail from a TaskProgressVO. + * Returns null if opaque is null/empty or all parsers fail. + */ + public static LongJobProgressDetail fromTaskProgressVO(TaskProgressVO vo) { + if (vo == null || vo.getOpaque() == null || vo.getOpaque().isEmpty()) { + return null; + } + + Map raw; + try { + raw = JSONObjectUtil.toObject(vo.getOpaque(), HashMap.class); + } catch (Exception e) { + logger.trace("LongJobProgressDetailBuilder: opaque is not a JSON object, skipping: " + vo.getOpaque(), e); + return null; + } + + if (raw == null || raw.isEmpty()) { + return null; + } + + // Try Format 2 first: AI download wraps everything under "data" key + if (raw.containsKey("data")) { + LongJobProgressDetail detail = tryParseAiDownloadFormat(raw); + if (detail != null) { + return detail; + } + } + + // Try Format 1: VM migration with remain/total/speed keys + if (raw.containsKey("remain") && raw.containsKey("total")) { + LongJobProgressDetail detail = tryParseVmMigrationFormat(raw); + if (detail != null) { + return detail; + } + } + + // Format 3: unknown — put everything into extra + return parseAsExtra(raw); + } + + /** + * Format 1: VM migration opaque + * {"remain": 1234567, "total": 9999999, "speed": 102400, "remaining_migration_time": 30} + * remain = bytes still to transfer; processed = total - remain + */ + private static LongJobProgressDetail tryParseVmMigrationFormat(Map raw) { + try { + LongJobProgressDetail detail = new LongJobProgressDetail(); + detail.setStage("migrating"); + + Number total = toNumber(raw.get("total")); + Number remain = toNumber(raw.get("remain")); + Number speed = toNumber(raw.get("speed")); + Number remainingTime = toNumber(raw.get("remaining_migration_time")); + + if (total != null) { + detail.setTotalBytes(total.longValue()); + } + if (total != null && remain != null) { + long processed = Math.max(0L, total.longValue() - remain.longValue()); + detail.setProcessedBytes(processed); + if (total.longValue() > 0) { + detail.setPercent((int) Math.min(100, Math.round(processed * 100.0 / total.longValue()))); + } + } + if (speed != null) { + detail.setSpeedBytesPerSecond(speed.longValue()); + } + if (remainingTime != null) { + detail.setEstimatedRemainingSeconds(remainingTime.longValue()); + } + + // Carry over any unrecognized keys into extra + Map extra = new HashMap<>(raw); + extra.remove("remain"); + extra.remove("total"); + extra.remove("speed"); + extra.remove("remaining_migration_time"); + if (!extra.isEmpty()) { + detail.setExtra(extra); + } + + return detail; + } catch (Exception e) { + logger.trace("LongJobProgressDetailBuilder: failed to parse VM migration format", e); + return null; + } + } + + /** + * Format 2: AI download opaque + * {"data": "{\"state\":\"downloading\", \"progress\":{\"percent\":42, \"processedBytes\":N, ...}}"} + * The "data" value is a JSON string (double-encoded). + */ + private static LongJobProgressDetail tryParseAiDownloadFormat(Map raw) { + try { + Object dataVal = raw.get("data"); + if (dataVal == null) { + return null; + } + + Map inner; + if (dataVal instanceof String) { + // double-encoded JSON string + inner = JSONObjectUtil.toObject((String) dataVal, HashMap.class); + } else if (dataVal instanceof Map) { + inner = (Map) dataVal; + } else { + return null; + } + + if (inner == null) { + return null; + } + + LongJobProgressDetail detail = new LongJobProgressDetail(); + + // state field + Object stateVal = inner.get("state"); + if (stateVal instanceof String) { + detail.setState((String) stateVal); + } + + // progress sub-object + Object progressVal = inner.get("progress"); + if (progressVal instanceof Map) { + Map progress = (Map) progressVal; + + Number percent = toNumber(progress.get("percent")); + if (percent != null) { + detail.setPercent((int) Math.round(percent.doubleValue())); + } + + // AI agent uses snake_case field names + Number processedBytes = toNumber(progress.get("downloaded_bytes")); + if (processedBytes != null) { + detail.setProcessedBytes(processedBytes.longValue()); + } + + Number totalBytes = toNumber(progress.get("total_bytes")); + if (totalBytes != null) { + detail.setTotalBytes(totalBytes.longValue()); + } + + Number speed = toNumber(progress.get("speed_bytes_per_second")); + if (speed != null) { + detail.setSpeedBytesPerSecond(speed.longValue()); + } + + Number eta = toNumber(progress.get("estimated_remaining_seconds")); + if (eta != null) { + detail.setEstimatedRemainingSeconds(eta.longValue()); + } + + Number processedFiles = toNumber(progress.get("downloaded_files")); + if (processedFiles != null) { + detail.setProcessedItems(processedFiles.longValue()); + } + + Number totalFiles = toNumber(progress.get("total_files")); + if (totalFiles != null) { + detail.setTotalItems(totalFiles.longValue()); + } + + Object stage = progress.get("stage"); + if (stage instanceof String) { + detail.setStage((String) stage); + } + + // remaining progress fields go into extra + Map extraProgress = new HashMap<>(progress); + extraProgress.remove("percent"); + extraProgress.remove("downloaded_bytes"); + extraProgress.remove("total_bytes"); + extraProgress.remove("speed_bytes_per_second"); + extraProgress.remove("estimated_remaining_seconds"); + extraProgress.remove("downloaded_files"); + extraProgress.remove("total_files"); + extraProgress.remove("stage"); + if (!extraProgress.isEmpty()) { + detail.setExtra(extraProgress); + } + } + + // stateReason field — can be String or Map (structured reason with code/description) + Object stateReason = inner.get("state_reason"); + if (stateReason instanceof String) { + detail.setStateReason((String) stateReason); + } else if (stateReason instanceof Map) { + detail.setStateReason(JSONObjectUtil.toJsonString(stateReason)); + } + + return detail; + } catch (Exception e) { + logger.trace("LongJobProgressDetailBuilder: failed to parse AI download format", e); + return null; + } + } + + /** + * Format 3: unknown — preserve the entire map as extra for UI passthrough. + */ + private static LongJobProgressDetail parseAsExtra(Map raw) { + LongJobProgressDetail detail = new LongJobProgressDetail(); + detail.setExtra(new HashMap<>(raw)); + return detail; + } + + private static Number toNumber(Object val) { + if (val instanceof Number) { + return (Number) val; + } + if (val instanceof String) { + try { + return Double.parseDouble((String) val); + } catch (NumberFormatException ignored) { + } + } + return null; + } +} diff --git a/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java b/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java index aa8c0f6db7e..b86dcb50eef 100755 --- a/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java +++ b/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java @@ -18,6 +18,8 @@ public class TaskProgressInventory { private Long time; private List subTasks; private String arguments; + /** Typed progress detail parsed from opaque. Null when opaque is absent or unrecognized. */ + private LongJobProgressDetail progressDetail; public TaskProgressInventory() { } @@ -105,4 +107,12 @@ public Long getTime() { public void setTime(Long time) { this.time = time; } + + public LongJobProgressDetail getProgressDetail() { + return progressDetail; + } + + public void setProgressDetail(LongJobProgressDetail progressDetail) { + this.progressDetail = progressDetail; + } } diff --git a/header/src/main/java/org/zstack/header/longjob/LongJobProgressNotificationMessage.java b/header/src/main/java/org/zstack/header/longjob/LongJobProgressNotificationMessage.java index 802f3b2b3fa..84b9aaf6ef9 100644 --- a/header/src/main/java/org/zstack/header/longjob/LongJobProgressNotificationMessage.java +++ b/header/src/main/java/org/zstack/header/longjob/LongJobProgressNotificationMessage.java @@ -1,5 +1,7 @@ package org.zstack.header.longjob; +import org.zstack.header.core.progress.LongJobProgressDetail; +import org.zstack.header.core.progress.LongJobProgressDetailBuilder; import org.zstack.header.core.progress.TaskProgressInventory; import org.zstack.header.core.progress.TaskProgressVO; @@ -23,6 +25,8 @@ public enum EventType { private Integer progress; /** Full progress detail; optional, BFF current version only needs {@link #getProgress()}. */ private TaskProgressInventory taskProgress; + /** Standardized progress detail parsed from opaque; null when opaque is absent. */ + private LongJobProgressDetail progressDetail; private EventType eventType; private Long timestamp; @@ -68,6 +72,14 @@ public void setTimestamp(Long timestamp) { this.timestamp = timestamp; } + public LongJobProgressDetail getProgressDetail() { + return progressDetail; + } + + public void setProgressDetail(LongJobProgressDetail progressDetail) { + this.progressDetail = progressDetail; + } + public static LongJobProgressNotificationMessage stateChanged(LongJobVO vo) { LongJobProgressNotificationMessage msg = new LongJobProgressNotificationMessage(); msg.longJob = LongJobInventory.valueOf(vo); @@ -85,7 +97,9 @@ public static LongJobProgressNotificationMessage progressUpdated(LongJobVO vo, T if (progressVO.getContent() != null) { inv.setContent(progressVO.getContent()); } + inv.setProgressDetail(LongJobProgressDetailBuilder.fromTaskProgressVO(progressVO)); msg.taskProgress = inv; + msg.progressDetail = inv.getProgressDetail(); msg.eventType = EventType.PROGRESS_UPDATED; msg.timestamp = System.currentTimeMillis(); return msg; diff --git a/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java b/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java new file mode 100644 index 00000000000..365f535bc45 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java @@ -0,0 +1,95 @@ +package org.zstack.sdk; + + + +public class LongJobProgressDetail { + + public java.lang.Integer percent; + public void setPercent(java.lang.Integer percent) { + this.percent = percent; + } + public java.lang.Integer getPercent() { + return this.percent; + } + + public java.lang.String stage; + public void setStage(java.lang.String stage) { + this.stage = stage; + } + public java.lang.String getStage() { + return this.stage; + } + + public java.lang.String state; + public void setState(java.lang.String state) { + this.state = state; + } + public java.lang.String getState() { + return this.state; + } + + public java.lang.String stateReason; + public void setStateReason(java.lang.String stateReason) { + this.stateReason = stateReason; + } + public java.lang.String getStateReason() { + return this.stateReason; + } + + public java.lang.Long processedBytes; + public void setProcessedBytes(java.lang.Long processedBytes) { + this.processedBytes = processedBytes; + } + public java.lang.Long getProcessedBytes() { + return this.processedBytes; + } + + public java.lang.Long totalBytes; + public void setTotalBytes(java.lang.Long totalBytes) { + this.totalBytes = totalBytes; + } + public java.lang.Long getTotalBytes() { + return this.totalBytes; + } + + public java.lang.Long processedItems; + public void setProcessedItems(java.lang.Long processedItems) { + this.processedItems = processedItems; + } + public java.lang.Long getProcessedItems() { + return this.processedItems; + } + + public java.lang.Long totalItems; + public void setTotalItems(java.lang.Long totalItems) { + this.totalItems = totalItems; + } + public java.lang.Long getTotalItems() { + return this.totalItems; + } + + public java.lang.Long speedBytesPerSecond; + public void setSpeedBytesPerSecond(java.lang.Long speedBytesPerSecond) { + this.speedBytesPerSecond = speedBytesPerSecond; + } + public java.lang.Long getSpeedBytesPerSecond() { + return this.speedBytesPerSecond; + } + + public java.lang.Long estimatedRemainingSeconds; + public void setEstimatedRemainingSeconds(java.lang.Long estimatedRemainingSeconds) { + this.estimatedRemainingSeconds = estimatedRemainingSeconds; + } + public java.lang.Long getEstimatedRemainingSeconds() { + return this.estimatedRemainingSeconds; + } + + public java.util.LinkedHashMap extra; + public void setExtra(java.util.LinkedHashMap extra) { + this.extra = extra; + } + public java.util.LinkedHashMap getExtra() { + return this.extra; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java b/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java index 31d427d23d4..e22f11655f7 100644 --- a/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java +++ b/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java @@ -76,4 +76,12 @@ public java.lang.String getArguments() { return this.arguments; } + public org.zstack.sdk.LongJobProgressDetail progressDetail; + public void setProgressDetail(org.zstack.sdk.LongJobProgressDetail progressDetail) { + this.progressDetail = progressDetail; + } + public org.zstack.sdk.LongJobProgressDetail getProgressDetail() { + return this.progressDetail; + } + } From 12a4db35acfeef5c8edd7d91e9f371f5b2a1ac0a Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Sun, 8 Mar 2026 23:52:21 +0800 Subject: [PATCH 54/85] [lb]: intercept httpCompressAlgos::disable tag Resolves: ZSTAC-81706 Change-Id: I6877637970626766686473657579617664657474 Signed-off-by: AlanJager --- .../network/service/lb/LoadBalancerApiInterceptor.java | 8 ++++++++ .../utils/clouderrorcode/CloudOperationsErrorCode.java | 2 ++ 2 files changed, 10 insertions(+) diff --git a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java index 0d9946d5320..c8a1ba8c08c 100755 --- a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java +++ b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java @@ -865,6 +865,14 @@ private void validate(APICreateLoadBalancerListenerMsg msg) { statusCode = LoadBalancerSystemTags.STATUS_CODE.getTokenByTag(tag, LoadBalancerSystemTags.STATUS_CODE_TOKEN); } + if (LoadBalancerSystemTags.HTTP_COMPRESS_ALGOS.isMatch(tag)) { + String compressAlgos = LoadBalancerSystemTags.HTTP_COMPRESS_ALGOS.getTokenByTag(tag, + LoadBalancerSystemTags.HTTP_COMPRESS_ALGOS_TOKEN); + if (DisableLbSupportHttpCompressAlgos.equals(compressAlgos)) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_LB_10172, + "could not create the loadbalancer listener with systemTag httpCompressAlgos::disable, please remove this tag")); + } + } } if ((redirectPort != null || statusCode != null) && (httpRedirectHttps == null || HttpRedirectHttps.disable.toString().equals(httpRedirectHttps))) { diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index 6a4087cae00..aed0e710251 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -13612,6 +13612,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_SERVICE_LB_10171 = "ORG_ZSTACK_NETWORK_SERVICE_LB_10171"; + public static final String ORG_ZSTACK_NETWORK_SERVICE_LB_10172 = "ORG_ZSTACK_NETWORK_SERVICE_LB_10172"; + public static final String ORG_ZSTACK_IPSEC_10000 = "ORG_ZSTACK_IPSEC_10000"; public static final String ORG_ZSTACK_IPSEC_10001 = "ORG_ZSTACK_IPSEC_10001"; From 708fc5e23c752d0d64bf5aa69cec6d163552cb14 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 10 Mar 2026 21:37:37 +0800 Subject: [PATCH 55/85] [errorcode]: fix i18n gaps in copy ctor and SDK - ErrorCode copy constructor: add missing cost and opaque fields - ZSClient: deserialize message field from JSON response - Add ErrorCodeI18nCase gate test (12 cases) Change-Id: I0002errorcode0i18n0gaps0fix Co-Authored-By: Claude Opus 4.6 --- .../zstack/header/errorcode/ErrorCode.java | 2 + .../main/java/org/zstack/sdk/ZSClient.java | 4 + .../integration/core/ErrorCodeI18nCase.groovy | 130 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy diff --git a/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java b/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java index c7ff98024b3..d180b6b4ccb 100755 --- a/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java +++ b/header/src/main/java/org/zstack/header/errorcode/ErrorCode.java @@ -104,6 +104,8 @@ public ErrorCode(ErrorCode other) { this.message = other.message; this.formatArgs = other.formatArgs == null ? null : other.formatArgs.clone(); this.globalErrorCode = other.globalErrorCode; + this.cost = other.cost; + this.opaque = other.opaque; } public void setCode(String code) { diff --git a/sdk/src/main/java/org/zstack/sdk/ZSClient.java b/sdk/src/main/java/org/zstack/sdk/ZSClient.java index 650369b19ac..3cab7f19134 100755 --- a/sdk/src/main/java/org/zstack/sdk/ZSClient.java +++ b/sdk/src/main/java/org/zstack/sdk/ZSClient.java @@ -121,6 +121,10 @@ public ErrorCode deserialize(JsonElement jsonElement, Type type, JsonDeserializa if (item != null && item.isJsonPrimitive()) { wrapper.setGlobalErrorCode(item.getAsString()); } + item = object.get("message"); + if (item != null && item.isJsonPrimitive()) { + wrapper.setMessage(item.getAsString()); + } return wrapper; } } diff --git a/test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy b/test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy new file mode 100644 index 00000000000..a437b1a0a79 --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy @@ -0,0 +1,130 @@ +package org.zstack.test.integration.core + +import org.zstack.core.errorcode.LocaleUtils +import org.zstack.header.errorcode.ErrorCode +import org.zstack.testlib.SubCase + +class ErrorCodeI18nCase extends SubCase { + + @Override + void setup() { + INCLUDE_CORE_SERVICES = false + } + + @Override + void environment() { + } + + @Override + void test() { + testLocaleUtilsExactMatch() + testLocaleUtilsBaseLanguageFallback() + testLocaleUtilsQValueSorting() + testLocaleUtilsNullAndEmpty() + testLocaleUtilsNoMatch() + testLocaleUtilsCaseInsensitive() + testLocaleUtilsMalformedHeader() + testErrorCodeCopyConstructor() + testErrorCodeCopyConstructorWithNulls() + } + + @Override + void clean() { + } + + // ---- LocaleUtils ---- + + void testLocaleUtilsExactMatch() { + def available = ["zh_CN", "en_US"] as Set + assert LocaleUtils.resolveLocale("zh-CN", available) == "zh_CN" + assert LocaleUtils.resolveLocale("en-US", available) == "en_US" + } + + void testLocaleUtilsBaseLanguageFallback() { + def available = ["zh_CN", "en_US"] as Set + assert LocaleUtils.resolveLocale("en", available) == "en_US" + assert LocaleUtils.resolveLocale("zh", available) == "zh_CN" + } + + void testLocaleUtilsQValueSorting() { + def available = ["zh_CN", "en_US"] as Set + assert LocaleUtils.resolveLocale("zh-CN,en;q=0.8", available) == "zh_CN" + assert LocaleUtils.resolveLocale("en-US,zh-CN;q=0.5", available) == "en_US" + // q-value should override header order + assert LocaleUtils.resolveLocale("en;q=0.8,zh-CN;q=1.0", available) == "zh_CN" + } + + void testLocaleUtilsNullAndEmpty() { + def available = ["zh_CN", "en_US"] as Set + assert LocaleUtils.resolveLocale(null, available) == "en_US" + assert LocaleUtils.resolveLocale("", available) == "en_US" + assert LocaleUtils.resolveLocale(" ", available) == "en_US" + } + + void testLocaleUtilsNoMatch() { + def available = ["zh_CN", "en_US"] as Set + assert LocaleUtils.resolveLocale("ja-JP,ko-KR", available) == "en_US" + } + + void testLocaleUtilsCaseInsensitive() { + def available = ["zh_CN", "en_US"] as Set + assert LocaleUtils.resolveLocale("ZH-CN", available) == "zh_CN" + assert LocaleUtils.resolveLocale("EN-US", available) == "en_US" + } + + void testLocaleUtilsMalformedHeader() { + def available = ["zh_CN", "en_US"] as Set + // malformed header should fall back to default + assert LocaleUtils.resolveLocale(";;;,,,", available) == "en_US" + } + + // ---- ErrorCode copy constructor ---- + + void testErrorCodeCopyConstructor() { + def original = new ErrorCode("SYS.1000", "System Error", "something failed") + original.setElaboration("elaboration text") + original.setLocation("org.zstack.Foo:123") + original.setCost("50ms") + original.setGlobalErrorCode("ORG_ZSTACK_FOO_10000") + original.setMessage("系统错误") + original.setFormatArgs(["arg1", "arg2"] as String[]) + + def opaque = new LinkedHashMap() + opaque.put("key1", "value1") + original.setOpaque(opaque) + + def cause = new ErrorCode("INTERNAL.1001", "Internal Error") + original.setCause(cause) + + def copy = new ErrorCode(original) + + assert copy.code == original.code + assert copy.description == original.description + assert copy.details == original.details + assert copy.elaboration == original.elaboration + assert copy.location == original.location + assert copy.cost == original.cost + assert copy.globalErrorCode == original.globalErrorCode + assert copy.message == original.message + assert copy.opaque.is(original.opaque) + assert copy.cause.is(original.cause) + // formatArgs should be cloned, not shared + assert copy.formatArgs == original.formatArgs + assert !copy.formatArgs.is(original.formatArgs) + } + + void testErrorCodeCopyConstructorWithNulls() { + def original = new ErrorCode("SYS.1000", "System Error") + def copy = new ErrorCode(original) + + assert copy.code == original.code + assert copy.description == original.description + assert copy.details == null + assert copy.cost == null + assert copy.opaque == null + assert copy.message == null + assert copy.globalErrorCode == null + assert copy.formatArgs == null + } + +} From 7b302df4bc696b7f8f5266c3fdad9c139eb25cc5 Mon Sep 17 00:00:00 2001 From: "shan.wu" Date: Tue, 11 Nov 2025 14:22:29 +0800 Subject: [PATCH 56/85] [sharedblock]: convert memory snapshot install path from absolute path to protocol path convert memory snapshot install path from absolute path to protocol path Resolves/Related: ZSTAC-79756 Change-Id: I6e626d68626461627a737765786a676e6b617064 --- conf/db/upgrade/V5.5.12__schema.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 74a1b143f9f..bbba706c287 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -44,3 +44,7 @@ CREATE TABLE IF NOT EXISTS `zstack`.`ExternalServiceConfigurationVO` ( `createDate` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`uuid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +UPDATE VolumeSnapshotVO AS sp, PrimaryStorageVO AS ps +SET sp.primaryStorageInstallPath = REPLACE(sp.primaryStorageInstallPath, '/dev/', 'sharedblock://') +WHERE sp.primaryStorageUuid = ps.uuid AND ps.type = 'SharedBlock' AND sp.volumeType = 'Memory' AND sp.primaryStorageInstallPath LIKE '/dev/%'; From f94eff0abdd9c38f90810329cb2ab325965afa49 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Wed, 11 Mar 2026 11:16:08 +0800 Subject: [PATCH 57/85] [core]: handle malformed Accept-Language header in LocaleUtils Java's String.split(";") on ";;;" returns an empty array, causing ArrayIndexOutOfBoundsException in parseAcceptLanguage. Add bounds check before accessing tagAndParams[0]. Change-Id: I$(openssl rand -hex 20) --- .../main/java/org/zstack/core/errorcode/LocaleUtils.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java b/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java index 8820eadbfbe..bb1bcbea3a6 100644 --- a/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java +++ b/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java @@ -88,7 +88,13 @@ private static List parseAcceptLanguage(String header) { continue; } String[] tagAndParams = part.split(";"); + if (tagAndParams.length == 0) { + continue; + } String tag = tagAndParams[0].trim(); + if (tag.isEmpty()) { + continue; + } double quality = 1.0; for (int i = 1; i < tagAndParams.length; i++) { String param = tagAndParams[i].trim(); From 0bfbf4544c99462ef85235d83c834b4b9b965780 Mon Sep 17 00:00:00 2001 From: "lin.ma" Date: Wed, 4 Feb 2026 18:06:11 +0800 Subject: [PATCH 58/85] [zwatch]: VPC Router CPU alarm use external monitoring Resolves: ZSTAC-81171 Change-Id: I7778676171646874706164777869707279776172 --- conf/db/upgrade/V5.5.12__schema.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index bbba706c287..ea21c75f925 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -48,3 +48,18 @@ CREATE TABLE IF NOT EXISTS `zstack`.`ExternalServiceConfigurationVO` ( UPDATE VolumeSnapshotVO AS sp, PrimaryStorageVO AS ps SET sp.primaryStorageInstallPath = REPLACE(sp.primaryStorageInstallPath, '/dev/', 'sharedblock://') WHERE sp.primaryStorageUuid = ps.uuid AND ps.type = 'SharedBlock' AND sp.volumeType = 'Memory' AND sp.primaryStorageInstallPath LIKE '/dev/%'; + +UPDATE `zstack`.`ActiveAlarmTemplateVO` +SET `metricName` = 'CPUUsedUtilization' +WHERE `uuid` = 'c9e6cdca107140bea62b4ca919ff9e88' + AND `metricName` = 'VRouterCPUAverageUsedUtilization'; + +UPDATE `zstack`.`AlarmVO` +SET `metricName` = 'CPUUsedUtilization' +WHERE `uuid` IN ( + SELECT `alarmUuid` FROM `zstack`.`ActiveAlarmVO` + WHERE `templateUuid` = 'c9e6cdca107140bea62b4ca919ff9e88' +) + AND `metricName` = 'VRouterCPUAverageUsedUtilization'; + + From 2e7d673002cc046c1620a149acd07e8fe6d83c75 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Wed, 11 Mar 2026 20:02:13 +0800 Subject: [PATCH 59/85] [pciDevice]: add Kunlunxin to SDK GpuVendor enum Resolves: ZSTAC-82350 Change-Id: I8770dbf76538bee8c632d06fd4c228368c0612f5 --- sdk/src/main/java/org/zstack/sdk/GpuVendor.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/src/main/java/org/zstack/sdk/GpuVendor.java b/sdk/src/main/java/org/zstack/sdk/GpuVendor.java index 0918b690289..9013b3048ed 100644 --- a/sdk/src/main/java/org/zstack/sdk/GpuVendor.java +++ b/sdk/src/main/java/org/zstack/sdk/GpuVendor.java @@ -9,4 +9,5 @@ public enum GpuVendor { TianShu, Other, Alibaba, + Kunlunxin, } From c7e7de8fe672b0650d9ff6e575dc98eef22fbf26 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Wed, 11 Mar 2026 21:50:35 +0800 Subject: [PATCH 60/85] [test]: fix SG test cases: relax priority consecutive constraints to match new validation Resolves: ZSTAC-79067 Change-Id: I6f134853bf7e16834a3d38df34334ebafd589167 --- .../AddSecurityGroupRuleOptimizedCase.groovy | 23 ++++++++----------- .../ChangeSecurityGroupRuleCase.groovy | 18 ++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/AddSecurityGroupRuleOptimizedCase.groovy b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/AddSecurityGroupRuleOptimizedCase.groovy index ed9a2736c5f..5160f0c2d9a 100644 --- a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/AddSecurityGroupRuleOptimizedCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/AddSecurityGroupRuleOptimizedCase.groovy @@ -540,7 +540,7 @@ class AddSecurityGroupRuleOptimizedCase extends SubCase { addSecurityGroupRule { securityGroupUuid = sg3.uuid rules = [rule_82] - priority = 82 + priority = 101 } } } @@ -591,13 +591,12 @@ class AddSecurityGroupRuleOptimizedCase extends SubCase { rule_13.protocol = "ALL" rule_13.startPort = -1 rule_13.endPort = -1 - expect(AssertionError) { - addSecurityGroupRule { - securityGroupUuid = sg3.uuid - rules = [rule_13] - priority = 13 - } + sg3 = addSecurityGroupRule { + securityGroupUuid = sg3.uuid + rules = [rule_13] + priority = 13 } + assert sg3.rules.find { it.allowedCidr == rule_13.allowedCidr && it.priority == 13 } != null SecurityGroupRuleAO rule_12 = new SecurityGroupRuleAO() rule_12.dstIpRange = "2.2.2.2-2.2.2.10" @@ -609,12 +608,10 @@ class AddSecurityGroupRuleOptimizedCase extends SubCase { ingressRule.protocol = "TCP" ingressRule.dstPortRange = "12-13" - expect(AssertionError) { - addSecurityGroupRule { - securityGroupUuid = sg3.uuid - rules = [rule_12, ingressRule] - priority = 12 - } + sg3 = addSecurityGroupRule { + securityGroupUuid = sg3.uuid + rules = [rule_12, ingressRule] + priority = 12 } } diff --git a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/ChangeSecurityGroupRuleCase.groovy b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/ChangeSecurityGroupRuleCase.groovy index ad29787ef1e..844b1ab35d6 100644 --- a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/ChangeSecurityGroupRuleCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/securitygroup/ChangeSecurityGroupRuleCase.groovy @@ -231,17 +231,15 @@ class ChangeSecurityGroupRuleCase extends SubCase { assert sg3 != null SecurityGroupRuleInventory rule_1 = sg3.rules.find { it.type == "Ingress" && it.priority == 1 && it.ipVersion == 4 } - expect(AssertionError) { - changeSecurityGroupRule { - uuid = rule_1.uuid - priority = 6 - } + changeSecurityGroupRule { + uuid = rule_1.uuid + priority = 6 } expect(AssertionError) { changeSecurityGroupRule { uuid = rule_1.uuid - priority = 7 + priority = 101 } } } @@ -307,11 +305,9 @@ class ChangeSecurityGroupRuleCase extends SubCase { } } - expect(AssertionError) { - changeSecurityGroupRule { - uuid = rule1.uuid - priority = 3 - } + changeSecurityGroupRule { + uuid = rule1.uuid + priority = 3 } expect(AssertionError) { From 67acc0a0dbeefee159a22b702c804ba3cef7c57c Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Thu, 12 Mar 2026 11:38:27 +0800 Subject: [PATCH 61/85] [core]: support resnotify webhook infrastructure Add CopyOnWriteArrayList for thread-safe entity lifecycle callbacks in DatabaseFacadeImpl. Add uninstallEntityLifeCycleCallback method. Add ResNotify DB schema with FK and indexes. Change-Id: Ia3667e35d74c06796946bb805aba4db5c87a3052 --- conf/db/upgrade/V5.5.12__schema.sql | 26 +++++ .../zstack/core/db/DatabaseFacadeImpl.java | 102 +++++++++++++----- 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index ea21c75f925..58d0e6ee187 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -62,4 +62,30 @@ WHERE `uuid` IN ( ) AND `metricName` = 'VRouterCPUAverageUsedUtilization'; +-- ZSTAC-80472: Resource notification webhook tables +CREATE TABLE IF NOT EXISTS `zstack`.`ResNotifySubscriptionVO` ( + `uuid` VARCHAR(32) NOT NULL UNIQUE, + `name` VARCHAR(255) DEFAULT NULL, + `description` VARCHAR(2048) DEFAULT NULL, + `resourceTypes` TEXT DEFAULT NULL, + `eventTypes` VARCHAR(256) DEFAULT NULL, + `type` VARCHAR(32) NOT NULL DEFAULT 'WEBHOOK', + `state` VARCHAR(32) NOT NULL DEFAULT 'Enabled', + `accountUuid` VARCHAR(32) DEFAULT NULL, + `lastOpDate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `createDate` TIMESTAMP NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (`uuid`), + INDEX `idx_ResNotifySubscriptionVO_accountUuid` (`accountUuid`), + INDEX `idx_ResNotifySubscriptionVO_type_state` (`type`, `state`), + CONSTRAINT `fkResNotifySubscriptionVOResourceVO` FOREIGN KEY (`uuid`) REFERENCES `ResourceVO` (`uuid`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE IF NOT EXISTS `zstack`.`ResNotifyWebhookRefVO` ( + `uuid` VARCHAR(32) NOT NULL UNIQUE, + `webhookUrl` TEXT NOT NULL, + `secret` VARCHAR(256) DEFAULT NULL, + `customHeaders` TEXT, + PRIMARY KEY (`uuid`), + CONSTRAINT `fk_ResNotifyWebhookRefVO_ResNotifySubscriptionVO` + FOREIGN KEY (`uuid`) REFERENCES `ResNotifySubscriptionVO`(`uuid`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/core/src/main/java/org/zstack/core/db/DatabaseFacadeImpl.java b/core/src/main/java/org/zstack/core/db/DatabaseFacadeImpl.java index d0a92426313..5a7b0c34c31 100755 --- a/core/src/main/java/org/zstack/core/db/DatabaseFacadeImpl.java +++ b/core/src/main/java/org/zstack/core/db/DatabaseFacadeImpl.java @@ -1,5 +1,7 @@ package org.zstack.core.db; +import com.google.common.collect.Maps; +import java.util.concurrent.CopyOnWriteArrayList; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; @@ -66,7 +68,7 @@ class EntityInfo { Field eoSoftDeleteColumn; Class eoClass; Class voClass; - Map listeners = new HashMap(); + Map> listeners = Maps.newConcurrentMap(); EntityInfo(Class voClazz) { voClass = voClazz; @@ -82,12 +84,16 @@ class EntityInfo { EO at = (EO) voClazz.getAnnotation(EO.class); if (at != null) { eoClass = at.EOClazz(); - DebugUtils.Assert(eoClass != null, String.format("cannot find EO entity specified by VO entity[%s]", voClazz.getName())); + DebugUtils.Assert(eoClass != null, + String.format("cannot find EO entity specified by VO entity[%s]", voClazz.getName())); eoPrimaryKeyField = FieldUtils.getAnnotatedField(Id.class, eoClass); - DebugUtils.Assert(eoPrimaryKeyField != null, String.format("cannot find primary key field(@Id annotated) in EO entity[%s]", eoClass.getName())); + DebugUtils.Assert(eoPrimaryKeyField != null, String + .format("cannot find primary key field(@Id annotated) in EO entity[%s]", eoClass.getName())); eoPrimaryKeyField.setAccessible(true); eoSoftDeleteColumn = FieldUtils.getField(at.softDeletedColumn(), eoClass); - DebugUtils.Assert(eoSoftDeleteColumn != null, String.format("cannot find soft delete column[%s] in EO entity[%s]", at.softDeletedColumn(), eoClass.getName())); + DebugUtils.Assert(eoSoftDeleteColumn != null, + String.format("cannot find soft delete column[%s] in EO entity[%s]", at.softDeletedColumn(), + eoClass.getName())); eoSoftDeleteColumn.setAccessible(true); } @@ -108,7 +114,8 @@ private void buildSoftDeletionCascade() { for (final SoftDeletionCascade at : ats.value()) { final Class parent = at.parent(); if (!parent.isAnnotationPresent(Entity.class)) { - throw new CloudRuntimeException(String.format("class[%s] has annotation @SoftDeletionCascade but its parent class[%s] is not annotated by @Entity", + throw new CloudRuntimeException(String.format( + "class[%s] has annotation @SoftDeletionCascade but its parent class[%s] is not annotated by @Entity", voClass, parent)); } @@ -131,7 +138,8 @@ public List getEntityClassForSoftDeleteEntityExtension() { @Override @Transactional public void postSoftDelete(Collection entityIds, Class entityClass) { - String sql = String.format("delete from %s me where me.%s in (:ids)", voClass.getSimpleName(), at.joinColumn()); + String sql = String.format("delete from %s me where me.%s in (:ids)", voClass.getSimpleName(), + at.joinColumn()); Query q = getEntityManager().createQuery(sql); q.setParameter("ids", entityIds); q.executeUpdate(); @@ -148,7 +156,8 @@ private void buildInheritanceDeletionExtension() { final Class parent = voClass.getSuperclass(); if (!parent.isAnnotationPresent(Entity.class)) { - throw new CloudRuntimeException(String.format("class[%s] has annotation @PrimaryKeyJoinColumn but its parent class[%s] is not annotated by @Entity", + throw new CloudRuntimeException(String.format( + "class[%s] has annotation @PrimaryKeyJoinColumn but its parent class[%s] is not annotated by @Entity", voClass, parent)); } @@ -245,7 +254,8 @@ private void updateEO(Object entity, RuntimeException de) { } SQLIntegrityConstraintViolationException me = (SQLIntegrityConstraintViolationException) rootCause; - if (!(me.getErrorCode() == 1062 && "23000".equals(me.getSQLState()) && me.getMessage().contains("PRIMARY"))) { + if (!(me.getErrorCode() == 1062 && "23000".equals(me.getSQLState()) + && me.getMessage().contains("PRIMARY"))) { throw de; } @@ -253,9 +263,12 @@ private void updateEO(Object entity, RuntimeException de) { throw de; } - // at this point, the error is caused by a update tried on VO entity which has been soft deleted. This is mostly - // caused by a deletion cascade(e.g deleting host will cause vm running on it to be deleted, and deleting vm is trying to return capacity - // to host which has been soft deleted, because vm deletion is executed in async manner). In this case, we make the update to EO table + // at this point, the error is caused by a update tried on VO entity which has + // been soft deleted. This is mostly + // caused by a deletion cascade(e.g deleting host will cause vm running on it to + // be deleted, and deleting vm is trying to return capacity + // to host which has been soft deleted, because vm deletion is executed in async + // manner). In this case, we make the update to EO table Object idval = getEOPrimaryKeyValue(entity); Object eo = getEntityManager().find(eoClass, idval); @@ -360,8 +373,10 @@ private void hardDelete(Collection ids) { @Transactional private void nativeSqlDelete(Collection ids) { - // native sql can avoid JPA cascades a deletion to parent entity when deleting a child entity - String sql = String.format("delete from %s where %s in (:ids)", voClass.getSimpleName(), voPrimaryKeyField.getName()); + // native sql can avoid JPA cascades a deletion to parent entity when deleting a + // child entity + String sql = String.format("delete from %s where %s in (:ids)", voClass.getSimpleName(), + voPrimaryKeyField.getName()); Query q = getEntityManager().createNativeQuery(sql); q.setParameter("ids", ids); q.executeUpdate(); @@ -418,7 +433,8 @@ List listByPrimaryKeys(Collection ids, int offset, int length) { sql = String.format("select e from %s e", voClass.getSimpleName()); query = getEntityManager().createQuery(sql, voClass); } else { - sql = String.format("select e from %s e where e.%s in (:ids)", voClass.getSimpleName(), voPrimaryKeyField.getName()); + sql = String.format("select e from %s e where e.%s in (:ids)", voClass.getSimpleName(), + voPrimaryKeyField.getName()); query = getEntityManager().createQuery(sql, voClass); query.setParameter("ids", ids); } @@ -429,7 +445,8 @@ List listByPrimaryKeys(Collection ids, int offset, int length) { @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) boolean isExist(Object id) { - String sql = String.format("select count(*) from %s ref where ref.%s = :id", voClass.getSimpleName(), voPrimaryKeyField.getName()); + String sql = String.format("select count(*) from %s ref where ref.%s = :id", voClass.getSimpleName(), + voPrimaryKeyField.getName()); TypedQuery q = getEntityManager().createQuery(sql, Long.class); q.setParameter("id", id); q.setMaxResults(1); @@ -438,13 +455,25 @@ boolean isExist(Object id) { } void installLifeCycleCallback(EntityEvent evt, EntityLifeCycleCallback l) { - listeners.put(evt, l); + List cbs = listeners.computeIfAbsent(evt, k -> new CopyOnWriteArrayList<>()); + if (!cbs.contains(l)) { + cbs.add(l); + } + } + + void uninstallLifeCycleCallback(EntityEvent evt, EntityLifeCycleCallback l) { + List cbs = listeners.get(evt); + if (cbs != null) { + cbs.remove(l); + } } void fireLifeCycleEvent(EntityEvent evt, Object o) { - EntityLifeCycleCallback cb = listeners.get(evt); - if (cb != null) { - cb.entityLifeCycleEvent(evt, o); + List cbs = listeners.get(evt); + if (cbs != null) { + for (EntityLifeCycleCallback cb : cbs) { + cb.entityLifeCycleEvent(evt, o); + } } } } @@ -491,7 +520,8 @@ public CriteriaBuilder getCriteriaBuilder() { @Override public SimpleQuery createQuery(Class entityClass) { - assert entityClass.isAnnotationPresent(Entity.class) : entityClass.getName() + " is not annotated by JPA @Entity"; + assert entityClass.isAnnotationPresent(Entity.class) + : entityClass.getName() + " is not annotated by JPA @Entity"; return new SimpleQueryImpl(entityClass); } @@ -530,7 +560,6 @@ public void removeByPrimaryKeys(Collection priKeys, Class entityClazz) { getEntityInfo(entityClazz).removeByPrimaryKeys(priKeys); } - @Override public T updateAndRefresh(T entity) { return (T) getEntityInfo(entity.getClass()).updateAndRefresh(entity); @@ -636,7 +665,9 @@ public void entityForTranscationCallback(Operation op, Class... entityClass) sb.append(c.getName()).append(","); } - String err = String.format("entityForTranscationCallback is called but transcation is not active. Did you forget adding @Transactional to method??? [operation: %s, entity classes: %s]", op, sb.toString()); + String err = String.format( + "entityForTranscationCallback is called but transcation is not active. Did you forget adding @Transactional to method??? [operation: %s, entity classes: %s]", + op, sb.toString()); logger.warn(err); } } @@ -668,7 +699,8 @@ public long generateSequenceNumber(Class seqTable) { try { Field id = seqTable.getDeclaredField("id"); if (id == null) { - throw new CloudRuntimeException(String.format("sequence VO[%s] must have 'id' field", seqTable.getName())); + throw new CloudRuntimeException( + String.format("sequence VO[%s] must have 'id' field", seqTable.getName())); } Object vo = seqTable.newInstance(); vo = persistAndRefresh(vo); @@ -770,7 +802,7 @@ public void eoCleanup(Class VOClazz) { @Override @DeadlockAutoRestart public void eoCleanup(Class VOClazz, Object id) { - if(id == null) { + if (id == null) { throw new RuntimeException(String.format("Cleanup %s EO fail, id is null", VOClazz.getSimpleName())); } @@ -798,7 +830,7 @@ public boolean start() { } private void buildEntityInfo() { - BeanUtils.reflections.getTypesAnnotatedWith(Entity.class).forEach(clz-> { + BeanUtils.reflections.getTypesAnnotatedWith(Entity.class).forEach(clz -> { entityInfoMap.put(clz, new EntityInfo(clz)); }); } @@ -820,7 +852,8 @@ private void populateExtensions() { } } - for (SoftDeleteEntityByEOExtensionPoint ext : pluginRgty.getExtensionList(SoftDeleteEntityByEOExtensionPoint.class)) { + for (SoftDeleteEntityByEOExtensionPoint ext : pluginRgty + .getExtensionList(SoftDeleteEntityByEOExtensionPoint.class)) { for (Class eoClass : ext.getEOClassForSoftDeleteEntityExtension()) { List exts = softDeleteByEOExtensions.get(eoClass); if (exts == null) { @@ -873,6 +906,20 @@ public void installEntityLifeCycleCallback(Class clz, EntityEvent evt, EntityLif } } + @Override + public void uninstallEntityLifeCycleCallback(Class clz, EntityEvent evt, EntityLifeCycleCallback cb) { + if (clz != null) { + EntityInfo info = entityInfoMap.get(clz); + if (info != null) { + info.uninstallLifeCycleCallback(evt, cb); + } + } else { + for (EntityInfo info : entityInfoMap.values()) { + info.uninstallLifeCycleCallback(evt, cb); + } + } + } + @Override public boolean stop() { return true; @@ -881,7 +928,8 @@ public boolean stop() { void entityEvent(EntityEvent evt, Object entity) { EntityInfo info = entityInfoMap.get(entity.getClass()); if (info == null) { - logger.warn(String.format("cannot find EntityInfo for the class[%s], not entity events will be fired", entity.getClass())); + logger.warn(String.format("cannot find EntityInfo for the class[%s], not entity events will be fired", + entity.getClass())); return; } From d30e0864de00c0e6f8ed54c5760c443474397949 Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Thu, 12 Mar 2026 16:40:01 +0800 Subject: [PATCH 62/85] [i18n]: fix error code 10049/10050 translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix remaining i18n issues from MR !9224 that were caught during QA verification: 1. Why is this change necessary? Error code 10049 zh_CN still shows machine-translated text and zh_TW has garbled characters. Error code 10050 incorrectly changed "instance offering" to "compute offering" in 8 non-Chinese locales, but ZStack UI uses "instance offering" as the product term. 2. How does it address the problem? - 10049 zh_CN: replace "实例提供" with "计算规格" - 10049 zh_TW: fix "叢叢" to "叢集", replace "实例提供" with "計算規格" - 10050: revert 8 non-Chinese locales back to "instance offering" variants (en_US, fr-FR, id-ID, th-TH, de-DE, ja-JP, ko-KR, ru-RU) - 10050 zh_CN/zh_TW: no change (already correct) 3. Are there any side effects? None. Pure i18n text correction, no logic changes. # Summary of changes (by module): - i18n: fix 10049 zh_CN/zh_TW translations - i18n: revert 10050 non-Chinese locale changes Related: ZSTAC-72656 Change-Id: Ib5f42b1a972d9ab890b0b8b7f9c605aec9ffe4f0 --- conf/i18n/globalErrorCodeMapping/global-error-de-DE.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-en_US.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-id-ID.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-th-TH.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json | 2 +- conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json index 81ae4d88c46..1e24c52ea11 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-de-DE.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "Volume[uuid:%s] kann nicht auf Snapshot[uuid:%s] zurückgesetzt werden, das Volume der VM[uuid:%s] befindet sich nicht im Status \"Stopped\", aktueller Status ist %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "Kein qualifizierter primärer Speicher gefunden; Fehler sind %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Aktualisierung des Gruppenstatus fehlgeschlagen: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid-Konflikt: Der durch das Compute-Angebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid-Konflikt: Der durch das Instanzangebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuid-Konflikt: Der durch das Root-Disk-Angebot angegebene primäre Speicher ist %s, während der durch den Erstellungsparameter angegebene primäre Speicher %s ist.", "ORG_ZSTACK_V2V_10008": "Dieselbe MAC-Adresse [%s] ist im Netzwerk[%s] nicht erlaubt", "ORG_ZSTACK_V2V_10009": "Doppelte MAC-Adresse [%s] im Netzwerk[%s]", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json index f45787d6344..7df63d4098c 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-en_US.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-en_US.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "unable to reset volume[uuid:%s] to snapshot[uuid:%s], the vm[uuid:%s] volume is not in Stopped state, current state is %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "cannot find any qualified primary storage; errors are %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Failed to update group status: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid Conflict: The primary storage specified by the compute offering is %s, while the primary storage specified in the creation parameter is %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid Conflict: The primary storage specified by the instance offering is %s, while the primary storage specified in the creation parameter is %s.", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuid Conflict: The primary storage specified by the root disk offering is %s, while the primary storage specified in the creation parameter is %s.", "ORG_ZSTACK_V2V_10008": "Not allowed the same MAC address [%s] in network[%s]", "ORG_ZSTACK_V2V_10009": "Duplicate MAC address [%s] in network[%s]", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json index 6a6ece44837..8fb00fa5776 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-fr-FR.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "impossible de réinitialiser le volume[uuid:%s] vers l'instantané[uuid:%s], le volume vm[uuid:%s] n'est pas dans l'état Arrêté, l'état actuel est %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "impossible de trouver un stockage principal qualifié ; les erreurs sont %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Échec de la mise à jour du statut du groupe : %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre de calcul est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre d'instance est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "Conflit primaryStorageUuid : le stockage principal spécifié par l'offre de disque racine est %s, tandis que le stockage principal spécifié dans le paramètre de création est %s.", "ORG_ZSTACK_V2V_10008": "L'adresse MAC [%s] identique n'est pas autorisée dans le réseau[%s]", "ORG_ZSTACK_V2V_10009": "Adresse MAC [%s] en double dans le réseau[%s]", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json index c2bf216dea4..7e41658f314 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-id-ID.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "tidak dapat mengatur ulang volume[uuid:%s] ke snapshot[uuid:%s], volume vm[uuid:%s] tidak dalam keadaan Berhenti, keadaan saat ini adalah %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "tidak dapat menemukan primary storage yang memenuhi syarat; error adalah %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "Gagal memperbarui status grup: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh compute offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh instance offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "Konflik primaryStorageUuid: Primary storage yang ditentukan oleh root disk offering adalah %s, sementara primary storage yang ditentukan dalam parameter pembuatan adalah %s", "ORG_ZSTACK_V2V_10008": "Alamat MAC [%s] yang sama tidak diperbolehkan dalam jaringan[%s]", "ORG_ZSTACK_V2V_10009": "Alamat MAC duplikat [%s] dalam jaringan[%s]", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json index 5bd506c86a6..bf73aa503ff 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ja-JP.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "ボリューム[uuid:%s]をスナップショット[uuid:%s]にリセットできません。vm[uuid:%s]のボリュームは停止状態ではありません。現在のステータスは%sです", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "適切なプライマリストレージが見つかりません。エラー: %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "グループのステータスの更新に失敗しました: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuidの競合。コンピュートオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuidの競合。インスタンスオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuidの競合。ルートディスクオファリングによって指定されたプライマリストレージは%sであり、作成パラメータによって指定されたプライマリストレージは%sです。", "ORG_ZSTACK_V2V_10008": "ネットワーク[%s]で同じMACアドレス[%s]は許可されていません", "ORG_ZSTACK_V2V_10009": "ネットワーク[%s]でMACアドレス[%s]が重複しています", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json index 5d58f6618b1..d9323871208 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ko-KR.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "볼륨[uuid:%s]을 스냅샷[uuid:%s]으로 재설정할 수 없습니다, vm[uuid:%s] 볼륨이 Stopped 상태가 아니며, 현재 상태는 %s입니다", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "적합한 기본 스토리지를 찾을 수 없습니다; 오류: %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "그룹 상태 업데이트 실패: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid 충돌: 컴퓨트 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "primaryStorageUuid 충돌: 인스턴스 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "primaryStorageUuid 충돌: 루트 디스크 오퍼링에서 지정한 기본 스토리지가 %s이며, 생성 파라미터에서 지정한 기본 스토리지가 %s입니다", "ORG_ZSTACK_V2V_10008": "네트워크[%s]에서 동일한 MAC 주소 [%s]가 허용되지 않습니다", "ORG_ZSTACK_V2V_10009": "네트워크[%s]에서 중복된 MAC 주소 [%s]", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json index 51a125842af..55bde5ee0c5 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-ru-RU.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "невозможно сбросить том[uuid:%s] к снимку[uuid:%s], том ВМ[uuid:%s] не находится в состоянии Stopped, текущее состояние: %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "невозможно найти подходящее основное хранилище; ошибки: %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "не удалось обновить статус группы: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении вычислений: %s, а основное хранилище, указанное в параметре создания: %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении экземпляра: %s, а основное хранилище, указанное в параметре создания: %s", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "конфликт primaryStorageUuid, основное хранилище, указанное в предложении корневого диска: %s, а основное хранилище, указанное в параметре создания: %s", "ORG_ZSTACK_V2V_10008": "Не допускается одинаковый MAC-адрес [%s] в сети[%s]", "ORG_ZSTACK_V2V_10009": "Дублирующийся MAC-адрес [%s] в сети[%s]", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json index d8b7abf66d5..3118556b6d7 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-th-TH.json @@ -35,7 +35,7 @@ "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "ไม่สามารถรีเซ็ต volume[uuid:%s] ไปยัง snapshot[uuid:%s], volume ของ vm[uuid:%s] ไม่อยู่ในสถานะ Stopped, สถานะปัจจุบันคือ %s", "ORG_ZSTACK_STORAGE_PRIMARY_10044": "ไม่พบ primary storage ที่มีคุณสมบัติเหมาะสม; ข้อผิดพลาดคือ %s", "ORG_ZSTACK_NETWORK_SERVICE_NFVINSTGROUP_10000": "ไม่สามารถอัปเดตสถานะกลุ่ม: %s", - "ORG_ZSTACK_STORAGE_PRIMARY_10050": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย compute offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", + "ORG_ZSTACK_STORAGE_PRIMARY_10050": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย instance offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", "ORG_ZSTACK_STORAGE_PRIMARY_10051": "ความขัดแย้งของ primaryStorageUuid: primary storage ที่ระบุโดย root disk offering คือ %s ในขณะที่ primary storage ที่ระบุในพารามิเตอร์การสร้างคือ %s", "ORG_ZSTACK_V2V_10008": "ไม่อนุญาตให้มี MAC address [%s] ซ้ำกันใน network[%s]", "ORG_ZSTACK_V2V_10009": "MAC address [%s] ซ้ำกันใน network[%s]", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json index 5235832dccf..a6a01a7abe3 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_CN.json @@ -29,7 +29,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_10047": "cidr[%s] 输入格式错误", "ORG_ZSTACK_LICENSE_COMPUTE_SERVER_10050": "ZSha2 显示存在另一个管理节点,但数据库中未找到该节点", "ORG_ZSTACK_STORAGE_PRIMARY_10046": "仅允许一个主要存储 CIDR 系统标签,但获得了 %d 个", - "ORG_ZSTACK_STORAGE_PRIMARY_10049": "集群Uuid冲突,实例提供中指定的集群是%s,而创建参数中指定的集群是%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10049": "集群Uuid冲突,计算规格中指定的集群是%s,而创建参数中指定的集群是%s", "ORG_ZSTACK_LICENSE_COMPUTE_SERVER_10052": "未找到解注册的appId!", "ORG_ZSTACK_STORAGE_PRIMARY_10042": "请指定分配空间的目的", "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "无法将卷[uuid:%s]恢复至快照[uuid:%s],关联的虚拟机[uuid:%s]卷当前不在已停止状态,当前状态是%s", diff --git a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json index 40aa862f414..b696d46d059 100644 --- a/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json +++ b/conf/i18n/globalErrorCodeMapping/global-error-zh_TW.json @@ -29,7 +29,7 @@ "ORG_ZSTACK_STORAGE_PRIMARY_10047": "cidr[%s] 輸入格式錯誤誤", "ORG_ZSTACK_LICENSE_COMPUTE_SERVER_10050": "ZSha2 显示儲在另一個管理節點,但數据库中未找到該節點", "ORG_ZSTACK_STORAGE_PRIMARY_10046": "仅允許一個主要儲儲 CIDR 系統統標签,但獲得了 %d 個", - "ORG_ZSTACK_STORAGE_PRIMARY_10049": "叢叢Uuid冲突,实例提供中指定的叢叢是%s,而創建參數中指定的叢叢是%s", + "ORG_ZSTACK_STORAGE_PRIMARY_10049": "叢集Uuid衝突,計算規格中指定的叢集是%s,而創建參數中指定的叢集是%s", "ORG_ZSTACK_LICENSE_COMPUTE_SERVER_10052": "未找到解注册的appId!", "ORG_ZSTACK_STORAGE_PRIMARY_10042": "請指定分配空間的目的", "ORG_ZSTACK_STORAGE_SNAPSHOT_10008": "無法将卷[uuid:%s]恢复至快照[uuid:%s],關聯的虚拟機[uuid:%s]卷當前不在已停止状态,當前状态是%s", From 648fc77435a68c24af437bd9c05ec74784ef170b Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 10 Mar 2026 23:45:21 +0800 Subject: [PATCH 63/85] [sdk]: add Kunlunxin to GpuVendor enum for P800 GPU support Resolves: ZSTAC-82259 Change-Id: I20d0de1968bab046c3e2882ca2d64c3926bfca74 --- sdk/src/main/java/org/zstack/sdk/GpuVendor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/org/zstack/sdk/GpuVendor.java b/sdk/src/main/java/org/zstack/sdk/GpuVendor.java index 9013b3048ed..22e6066d894 100644 --- a/sdk/src/main/java/org/zstack/sdk/GpuVendor.java +++ b/sdk/src/main/java/org/zstack/sdk/GpuVendor.java @@ -7,7 +7,7 @@ public enum GpuVendor { Haiguang, Huawei, TianShu, + Kunlunxin, Other, Alibaba, - Kunlunxin, } From f5459df20d36e5cad404ab4b32684181a3d62eea Mon Sep 17 00:00:00 2001 From: "yaohua.wu" Date: Fri, 13 Mar 2026 00:41:46 +0800 Subject: [PATCH 64/85] [core]: add resnotify webhook SDK and test support Add uninstallEntityLifeCycleCallback to DatabaseFacade interface. Generate SDK action classes and ApiHelper methods for resnotify webhook subscription CRUD APIs. Change-Id: I9191952ca0a0959644bc2d90d539a818cf13d30a --- .../org/zstack/core/db/DatabaseFacade.java | 2 + sdk/src/main/java/SourceClassMap.java | 8 ++ .../DeleteResNotifySubscriptionAction.java | 104 ++++++++++++++ .../DeleteResNotifySubscriptionResult.java | 7 + .../QueryResNotifySubscriptionAction.java | 75 ++++++++++ .../QueryResNotifySubscriptionResult.java | 22 +++ .../ResNotifySubscriptionInventory.java | 97 +++++++++++++ .../resnotify/ResNotifySubscriptionState.java | 6 + .../sdk/zwatch/resnotify/ResNotifyType.java | 6 + .../ResNotifyWebhookRefInventory.java | 31 +++++ .../resnotify/SubscribeResNotifyAction.java | 128 ++++++++++++++++++ .../resnotify/SubscribeResNotifyResult.java | 14 ++ .../UpdateResNotifySubscriptionAction.java | 125 +++++++++++++++++ .../UpdateResNotifySubscriptionResult.java | 14 ++ .../java/org/zstack/testlib/ApiHelper.groovy | 110 +++++++++++++++ 15 files changed, 749 insertions(+) create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionResult.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionResult.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionInventory.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionState.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyType.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyWebhookRefInventory.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyResult.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionResult.java diff --git a/core/src/main/java/org/zstack/core/db/DatabaseFacade.java b/core/src/main/java/org/zstack/core/db/DatabaseFacade.java index 155aca5162b..efb4e22b307 100755 --- a/core/src/main/java/org/zstack/core/db/DatabaseFacade.java +++ b/core/src/main/java/org/zstack/core/db/DatabaseFacade.java @@ -84,4 +84,6 @@ public interface DatabaseFacade { String getDbVersion(); void installEntityLifeCycleCallback(Class entityClass, EntityEvent evt, EntityLifeCycleCallback cb); + + void uninstallEntityLifeCycleCallback(Class entityClass, EntityEvent evt, EntityLifeCycleCallback cb); } diff --git a/sdk/src/main/java/SourceClassMap.java b/sdk/src/main/java/SourceClassMap.java index bb413e5ac31..33aff694e9d 100644 --- a/sdk/src/main/java/SourceClassMap.java +++ b/sdk/src/main/java/SourceClassMap.java @@ -872,6 +872,10 @@ public class SourceClassMap { put("org.zstack.zwatch.monitorgroup.entity.MonitorGroupTemplateRefInventory", "org.zstack.sdk.zwatch.monitorgroup.entity.MonitorGroupTemplateRefInventory"); put("org.zstack.zwatch.monitorgroup.entity.MonitorGroupTemplateRefVO", "org.zstack.sdk.zwatch.monitorgroup.entity.MonitorGroupTemplateRefVO"); put("org.zstack.zwatch.monitorgroup.entity.MonitorTemplateInventory", "org.zstack.sdk.zwatch.monitorgroup.entity.MonitorTemplateInventory"); + put("org.zstack.zwatch.resnotify.ResNotifySubscriptionInventory", "org.zstack.sdk.zwatch.resnotify.ResNotifySubscriptionInventory"); + put("org.zstack.zwatch.resnotify.ResNotifySubscriptionState", "org.zstack.sdk.zwatch.resnotify.ResNotifySubscriptionState"); + put("org.zstack.zwatch.resnotify.ResNotifyType", "org.zstack.sdk.zwatch.resnotify.ResNotifyType"); + put("org.zstack.zwatch.resnotify.ResNotifyWebhookRefInventory", "org.zstack.sdk.zwatch.resnotify.ResNotifyWebhookRefInventory"); put("org.zstack.zwatch.ruleengine.ComparisonOperator", "org.zstack.sdk.zwatch.ruleengine.ComparisonOperator"); put("org.zstack.zwatch.thirdparty.entity.SNSEndpointThirdpartyAlertHistoryInventory", "org.zstack.sdk.zwatch.thirdparty.entity.SNSEndpointThirdpartyAlertHistoryInventory"); put("org.zstack.zwatch.thirdparty.entity.ThirdpartyOriginalAlertInventory", "org.zstack.sdk.zwatch.thirdparty.entity.ThirdpartyOriginalAlertInventory"); @@ -1748,6 +1752,10 @@ public class SourceClassMap { put("org.zstack.sdk.zwatch.monitorgroup.entity.MonitorGroupTemplateRefInventory", "org.zstack.zwatch.monitorgroup.entity.MonitorGroupTemplateRefInventory"); put("org.zstack.sdk.zwatch.monitorgroup.entity.MonitorGroupTemplateRefVO", "org.zstack.zwatch.monitorgroup.entity.MonitorGroupTemplateRefVO"); put("org.zstack.sdk.zwatch.monitorgroup.entity.MonitorTemplateInventory", "org.zstack.zwatch.monitorgroup.entity.MonitorTemplateInventory"); + put("org.zstack.sdk.zwatch.resnotify.ResNotifySubscriptionInventory", "org.zstack.zwatch.resnotify.ResNotifySubscriptionInventory"); + put("org.zstack.sdk.zwatch.resnotify.ResNotifySubscriptionState", "org.zstack.zwatch.resnotify.ResNotifySubscriptionState"); + put("org.zstack.sdk.zwatch.resnotify.ResNotifyType", "org.zstack.zwatch.resnotify.ResNotifyType"); + put("org.zstack.sdk.zwatch.resnotify.ResNotifyWebhookRefInventory", "org.zstack.zwatch.resnotify.ResNotifyWebhookRefInventory"); put("org.zstack.sdk.zwatch.ruleengine.ComparisonOperator", "org.zstack.zwatch.ruleengine.ComparisonOperator"); put("org.zstack.sdk.zwatch.thirdparty.entity.SNSEndpointThirdpartyAlertHistoryInventory", "org.zstack.zwatch.thirdparty.entity.SNSEndpointThirdpartyAlertHistoryInventory"); put("org.zstack.sdk.zwatch.thirdparty.entity.ThirdpartyOriginalAlertInventory", "org.zstack.zwatch.thirdparty.entity.ThirdpartyOriginalAlertInventory"); diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionAction.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionAction.java new file mode 100644 index 00000000000..9266477e583 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionAction.java @@ -0,0 +1,104 @@ +package org.zstack.sdk.zwatch.resnotify; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class DeleteResNotifySubscriptionAction extends AbstractAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.zwatch.resnotify.DeleteResNotifySubscriptionResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String uuid; + + @Param(required = false) + public java.lang.String deleteMode = "Permissive"; + + @Param(required = false) + public java.util.List systemTags; + + @Param(required = false) + public java.util.List userTags; + + @Param(required = false) + public String sessionId; + + @Param(required = false) + public String accessKeyId; + + @Param(required = false) + public String accessKeySecret; + + @Param(required = false) + public String requestIp; + + @NonAPIParam + public long timeout = -1; + + @NonAPIParam + public long pollingInterval = -1; + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.zwatch.resnotify.DeleteResNotifySubscriptionResult value = res.getResult(org.zstack.sdk.zwatch.resnotify.DeleteResNotifySubscriptionResult.class); + ret.value = value == null ? new org.zstack.sdk.zwatch.resnotify.DeleteResNotifySubscriptionResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "DELETE"; + info.path = "/zwatch/resnotify/subscriptions/{uuid}"; + info.needSession = true; + info.needPoll = true; + info.parameterName = ""; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionResult.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionResult.java new file mode 100644 index 00000000000..01e30f49416 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/DeleteResNotifySubscriptionResult.java @@ -0,0 +1,7 @@ +package org.zstack.sdk.zwatch.resnotify; + + + +public class DeleteResNotifySubscriptionResult { + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionAction.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionAction.java new file mode 100644 index 00000000000..5f4451557aa --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionAction.java @@ -0,0 +1,75 @@ +package org.zstack.sdk.zwatch.resnotify; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class QueryResNotifySubscriptionAction extends QueryAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.zwatch.resnotify.QueryResNotifySubscriptionResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.zwatch.resnotify.QueryResNotifySubscriptionResult value = res.getResult(org.zstack.sdk.zwatch.resnotify.QueryResNotifySubscriptionResult.class); + ret.value = value == null ? new org.zstack.sdk.zwatch.resnotify.QueryResNotifySubscriptionResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "GET"; + info.path = "/zwatch/resnotify/subscriptions"; + info.needSession = true; + info.needPoll = false; + info.parameterName = ""; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionResult.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionResult.java new file mode 100644 index 00000000000..93cd0d2caeb --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/QueryResNotifySubscriptionResult.java @@ -0,0 +1,22 @@ +package org.zstack.sdk.zwatch.resnotify; + + + +public class QueryResNotifySubscriptionResult { + public java.util.List inventories; + public void setInventories(java.util.List inventories) { + this.inventories = inventories; + } + public java.util.List getInventories() { + return this.inventories; + } + + public java.lang.Long total; + public void setTotal(java.lang.Long total) { + this.total = total; + } + public java.lang.Long getTotal() { + return this.total; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionInventory.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionInventory.java new file mode 100644 index 00000000000..550d258854b --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionInventory.java @@ -0,0 +1,97 @@ +package org.zstack.sdk.zwatch.resnotify; + +import org.zstack.sdk.zwatch.resnotify.ResNotifyType; +import org.zstack.sdk.zwatch.resnotify.ResNotifySubscriptionState; +import org.zstack.sdk.zwatch.resnotify.ResNotifyWebhookRefInventory; + +public class ResNotifySubscriptionInventory { + + public java.lang.String uuid; + public void setUuid(java.lang.String uuid) { + this.uuid = uuid; + } + public java.lang.String getUuid() { + return this.uuid; + } + + public java.lang.String name; + public void setName(java.lang.String name) { + this.name = name; + } + public java.lang.String getName() { + return this.name; + } + + public java.lang.String description; + public void setDescription(java.lang.String description) { + this.description = description; + } + public java.lang.String getDescription() { + return this.description; + } + + public java.lang.String resourceTypes; + public void setResourceTypes(java.lang.String resourceTypes) { + this.resourceTypes = resourceTypes; + } + public java.lang.String getResourceTypes() { + return this.resourceTypes; + } + + public java.lang.String eventTypes; + public void setEventTypes(java.lang.String eventTypes) { + this.eventTypes = eventTypes; + } + public java.lang.String getEventTypes() { + return this.eventTypes; + } + + public ResNotifyType type; + public void setType(ResNotifyType type) { + this.type = type; + } + public ResNotifyType getType() { + return this.type; + } + + public ResNotifySubscriptionState state; + public void setState(ResNotifySubscriptionState state) { + this.state = state; + } + public ResNotifySubscriptionState getState() { + return this.state; + } + + public java.lang.String accountUuid; + public void setAccountUuid(java.lang.String accountUuid) { + this.accountUuid = accountUuid; + } + public java.lang.String getAccountUuid() { + return this.accountUuid; + } + + public java.sql.Timestamp createDate; + public void setCreateDate(java.sql.Timestamp createDate) { + this.createDate = createDate; + } + public java.sql.Timestamp getCreateDate() { + return this.createDate; + } + + public java.sql.Timestamp lastOpDate; + public void setLastOpDate(java.sql.Timestamp lastOpDate) { + this.lastOpDate = lastOpDate; + } + public java.sql.Timestamp getLastOpDate() { + return this.lastOpDate; + } + + public ResNotifyWebhookRefInventory webhookRef; + public void setWebhookRef(ResNotifyWebhookRefInventory webhookRef) { + this.webhookRef = webhookRef; + } + public ResNotifyWebhookRefInventory getWebhookRef() { + return this.webhookRef; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionState.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionState.java new file mode 100644 index 00000000000..18351896246 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifySubscriptionState.java @@ -0,0 +1,6 @@ +package org.zstack.sdk.zwatch.resnotify; + +public enum ResNotifySubscriptionState { + Enabled, + Disabled, +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyType.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyType.java new file mode 100644 index 00000000000..5a00aa5b80e --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyType.java @@ -0,0 +1,6 @@ +package org.zstack.sdk.zwatch.resnotify; + +public enum ResNotifyType { + WEBHOOK, + WEBSOCKET, +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyWebhookRefInventory.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyWebhookRefInventory.java new file mode 100644 index 00000000000..cf2c73bc674 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/ResNotifyWebhookRefInventory.java @@ -0,0 +1,31 @@ +package org.zstack.sdk.zwatch.resnotify; + + + +public class ResNotifyWebhookRefInventory { + + public java.lang.String uuid; + public void setUuid(java.lang.String uuid) { + this.uuid = uuid; + } + public java.lang.String getUuid() { + return this.uuid; + } + + public java.lang.String webhookUrl; + public void setWebhookUrl(java.lang.String webhookUrl) { + this.webhookUrl = webhookUrl; + } + public java.lang.String getWebhookUrl() { + return this.webhookUrl; + } + + public java.lang.String customHeaders; + public void setCustomHeaders(java.lang.String customHeaders) { + this.customHeaders = customHeaders; + } + public java.lang.String getCustomHeaders() { + return this.customHeaders; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyAction.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyAction.java new file mode 100644 index 00000000000..05e5af5d9de --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyAction.java @@ -0,0 +1,128 @@ +package org.zstack.sdk.zwatch.resnotify; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class SubscribeResNotifyAction extends AbstractAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.zwatch.resnotify.SubscribeResNotifyResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + @Param(required = false, maxLength = 255, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String name; + + @Param(required = false, maxLength = 1024, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String description; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List resourceTypes; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List eventTypes; + + @Param(required = false, validValues = {"WEBHOOK"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String type = "WEBHOOK"; + + @Param(required = true, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String webhookUrl; + + @Param(required = false, maxLength = 256, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String secret; + + @Param(required = false, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String customHeaders; + + @Param(required = false) + public java.lang.String resourceUuid; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List tagUuids; + + @Param(required = false) + public java.util.List systemTags; + + @Param(required = false) + public java.util.List userTags; + + @Param(required = false) + public String sessionId; + + @Param(required = false) + public String accessKeyId; + + @Param(required = false) + public String accessKeySecret; + + @Param(required = false) + public String requestIp; + + @NonAPIParam + public long timeout = -1; + + @NonAPIParam + public long pollingInterval = -1; + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.zwatch.resnotify.SubscribeResNotifyResult value = res.getResult(org.zstack.sdk.zwatch.resnotify.SubscribeResNotifyResult.class); + ret.value = value == null ? new org.zstack.sdk.zwatch.resnotify.SubscribeResNotifyResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "POST"; + info.path = "/zwatch/resnotify/subscriptions"; + info.needSession = true; + info.needPoll = true; + info.parameterName = "params"; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyResult.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyResult.java new file mode 100644 index 00000000000..60ba0b47c8e --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/SubscribeResNotifyResult.java @@ -0,0 +1,14 @@ +package org.zstack.sdk.zwatch.resnotify; + +import org.zstack.sdk.zwatch.resnotify.ResNotifySubscriptionInventory; + +public class SubscribeResNotifyResult { + public ResNotifySubscriptionInventory inventory; + public void setInventory(ResNotifySubscriptionInventory inventory) { + this.inventory = inventory; + } + public ResNotifySubscriptionInventory getInventory() { + return this.inventory; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionAction.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionAction.java new file mode 100644 index 00000000000..3ec79ae0a35 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionAction.java @@ -0,0 +1,125 @@ +package org.zstack.sdk.zwatch.resnotify; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class UpdateResNotifySubscriptionAction extends AbstractAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.zwatch.resnotify.UpdateResNotifySubscriptionResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String uuid; + + @Param(required = false, maxLength = 255, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String name; + + @Param(required = false, maxLength = 1024, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String description; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List resourceTypes; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List eventTypes; + + @Param(required = false, validValues = {"Enabled","Disabled"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String state; + + @Param(required = false, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String webhookUrl; + + @Param(required = false, maxLength = 256, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String secret; + + @Param(required = false, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String customHeaders; + + @Param(required = false) + public java.util.List systemTags; + + @Param(required = false) + public java.util.List userTags; + + @Param(required = false) + public String sessionId; + + @Param(required = false) + public String accessKeyId; + + @Param(required = false) + public String accessKeySecret; + + @Param(required = false) + public String requestIp; + + @NonAPIParam + public long timeout = -1; + + @NonAPIParam + public long pollingInterval = -1; + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.zwatch.resnotify.UpdateResNotifySubscriptionResult value = res.getResult(org.zstack.sdk.zwatch.resnotify.UpdateResNotifySubscriptionResult.class); + ret.value = value == null ? new org.zstack.sdk.zwatch.resnotify.UpdateResNotifySubscriptionResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "PUT"; + info.path = "/zwatch/resnotify/subscriptions/{uuid}/actions"; + info.needSession = true; + info.needPoll = true; + info.parameterName = "updateResNotifySubscription"; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionResult.java b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionResult.java new file mode 100644 index 00000000000..8f5b2f62157 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/zwatch/resnotify/UpdateResNotifySubscriptionResult.java @@ -0,0 +1,14 @@ +package org.zstack.sdk.zwatch.resnotify; + +import org.zstack.sdk.zwatch.resnotify.ResNotifySubscriptionInventory; + +public class UpdateResNotifySubscriptionResult { + public ResNotifySubscriptionInventory inventory; + public void setInventory(ResNotifySubscriptionInventory inventory) { + this.inventory = inventory; + } + public ResNotifySubscriptionInventory getInventory() { + return this.inventory; + } + +} diff --git a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy index 396e175189c..af8379f56e4 100644 --- a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy +++ b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy @@ -56760,6 +56760,116 @@ abstract class ApiHelper { } + def deleteResNotifySubscription(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.zwatch.resnotify.DeleteResNotifySubscriptionAction.class) Closure c) { + def a = new org.zstack.sdk.zwatch.resnotify.DeleteResNotifySubscriptionAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + + def queryResNotifySubscription(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.zwatch.resnotify.QueryResNotifySubscriptionAction.class) Closure c) { + def a = new org.zstack.sdk.zwatch.resnotify.QueryResNotifySubscriptionAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + a.conditions = a.conditions.collect { it.toString() } + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + + def subscribeResNotify(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.zwatch.resnotify.SubscribeResNotifyAction.class) Closure c) { + def a = new org.zstack.sdk.zwatch.resnotify.SubscribeResNotifyAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + + def updateResNotifySubscription(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.zwatch.resnotify.UpdateResNotifySubscriptionAction.class) Closure c) { + def a = new org.zstack.sdk.zwatch.resnotify.UpdateResNotifySubscriptionAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + def addThirdpartyPlatform(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.zwatch.thirdparty.api.AddThirdpartyPlatformAction.class) Closure c) { def a = new org.zstack.sdk.zwatch.thirdparty.api.AddThirdpartyPlatformAction() a.sessionId = Test.currentEnvSpec?.session?.uuid From cb554df47283c96023ebbe917333e1f24e5199f1 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Wed, 11 Mar 2026 20:24:28 +0800 Subject: [PATCH 65/85] [sdk]: update SDK files for LongJobProgressDetail Resolves: ZSTAC-82318 Change-Id: I8931c4207547b836b522c9b5cea2db807a032d5c --- .../LongJobProgressDetailBuilder.java | 23 +++++++++++++++---- .../core/progress/TaskProgressInventory.java | 2 +- sdk/src/main/java/SourceClassMap.java | 2 ++ .../org/zstack/sdk/LongJobProgressDetail.java | 6 ++--- .../org/zstack/sdk/TaskProgressInventory.java | 8 +++---- 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java index 18a436fc280..4c9a09b2580 100644 --- a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java +++ b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java @@ -142,6 +142,7 @@ private static LongJobProgressDetail tryParseAiDownloadFormat(Map extra = new HashMap<>(); // state field Object stateVal = inner.get("state"); @@ -156,7 +157,7 @@ private static LongJobProgressDetail tryParseAiDownloadFormat(Map extraInner = new HashMap<>(inner); + extraInner.remove("state"); + extraInner.remove("progress"); + extraInner.remove("state_reason"); + extra.putAll(extraInner); + + // preserve unknown keys from raw outer-level + Map extraRaw = new HashMap<>(raw); + extraRaw.remove("data"); + extra.putAll(extraRaw); + + if (!extra.isEmpty()) { + detail.setExtra(extra); + } + return detail; } catch (Exception e) { logger.trace("LongJobProgressDetailBuilder: failed to parse AI download format", e); diff --git a/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java b/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java index b86dcb50eef..b945d270292 100755 --- a/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java +++ b/header/src/main/java/org/zstack/header/core/progress/TaskProgressInventory.java @@ -18,7 +18,7 @@ public class TaskProgressInventory { private Long time; private List subTasks; private String arguments; - /** Typed progress detail parsed from opaque. Null when opaque is absent or unrecognized. */ + /** Typed progress detail parsed from opaque. Null when opaque is absent or parsing fails. */ private LongJobProgressDetail progressDetail; public TaskProgressInventory() { diff --git a/sdk/src/main/java/SourceClassMap.java b/sdk/src/main/java/SourceClassMap.java index 46a1ce8c961..b5ea0d48dea 100644 --- a/sdk/src/main/java/SourceClassMap.java +++ b/sdk/src/main/java/SourceClassMap.java @@ -247,6 +247,7 @@ public class SourceClassMap { put("org.zstack.header.core.external.service.ExternalServiceCapabilities", "org.zstack.sdk.ExternalServiceCapabilities"); put("org.zstack.header.core.external.service.ExternalServiceInventory", "org.zstack.sdk.ExternalServiceInventory"); put("org.zstack.header.core.progress.ChainInfo", "org.zstack.sdk.ChainInfo"); + put("org.zstack.header.core.progress.LongJobProgressDetail", "org.zstack.sdk.LongJobProgressDetail"); put("org.zstack.header.core.progress.PendingTaskInfo", "org.zstack.sdk.PendingTaskInfo"); put("org.zstack.header.core.progress.RunningTaskInfo", "org.zstack.sdk.RunningTaskInfo"); put("org.zstack.header.core.progress.TaskInfo", "org.zstack.sdk.TaskInfo"); @@ -1208,6 +1209,7 @@ public class SourceClassMap { put("org.zstack.sdk.LogType", "org.zstack.log.server.LogType"); put("org.zstack.sdk.LoginAuthenticationProcedureDesc", "org.zstack.header.identity.login.LoginAuthenticationProcedureDesc"); put("org.zstack.sdk.LongJobInventory", "org.zstack.header.longjob.LongJobInventory"); + put("org.zstack.sdk.LongJobProgressDetail", "org.zstack.header.core.progress.LongJobProgressDetail"); put("org.zstack.sdk.LongJobState", "org.zstack.header.longjob.LongJobState"); put("org.zstack.sdk.LunInventory", "org.zstack.header.storageDevice.LunInventory"); put("org.zstack.sdk.MaaSUsage", "org.zstack.ai.message.MaaSUsage"); diff --git a/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java b/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java index 365f535bc45..c37bc20795b 100644 --- a/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java +++ b/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java @@ -84,11 +84,11 @@ public java.lang.Long getEstimatedRemainingSeconds() { return this.estimatedRemainingSeconds; } - public java.util.LinkedHashMap extra; - public void setExtra(java.util.LinkedHashMap extra) { + public java.util.Map extra; + public void setExtra(java.util.Map extra) { this.extra = extra; } - public java.util.LinkedHashMap getExtra() { + public java.util.Map getExtra() { return this.extra; } diff --git a/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java b/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java index e22f11655f7..6bec468e66f 100644 --- a/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java +++ b/sdk/src/main/java/org/zstack/sdk/TaskProgressInventory.java @@ -1,6 +1,6 @@ package org.zstack.sdk; - +import org.zstack.sdk.LongJobProgressDetail; public class TaskProgressInventory { @@ -76,11 +76,11 @@ public java.lang.String getArguments() { return this.arguments; } - public org.zstack.sdk.LongJobProgressDetail progressDetail; - public void setProgressDetail(org.zstack.sdk.LongJobProgressDetail progressDetail) { + public LongJobProgressDetail progressDetail; + public void setProgressDetail(LongJobProgressDetail progressDetail) { this.progressDetail = progressDetail; } - public org.zstack.sdk.LongJobProgressDetail getProgressDetail() { + public LongJobProgressDetail getProgressDetail() { return this.progressDetail; } From 2318947e1edd1caad90443259e9658a6fce7a743 Mon Sep 17 00:00:00 2001 From: "shan.wu" Date: Tue, 13 Jan 2026 14:54:15 +0800 Subject: [PATCH 66/85] [dpu-bm2]: support dpu baremetal2 instance support dpu baremetal2 instance Resolves/Related: ZSTAC-12345 Change-Id: I626d637a7168656a6c726c6769777a726e616973 --- .../zstack/compute/host/HostManagerImpl.java | 4 +- conf/db/upgrade/V5.5.12__schema.sql | 33 +++++ .../header/cluster/APICreateClusterMsg.java | 2 +- .../APICreateClusterMsgDoc_zh_cn.groovy | 2 +- sdk/src/main/java/SourceClassMap.java | 4 + .../sdk/AddBareMetal2DpuChassisAction.java | 125 ++++++++++++++++++ .../sdk/BareMetal2DpuChassisInventory.java | 23 ++++ .../sdk/BareMetal2DpuHostInventory.java | 31 +++++ .../sdk/CreateBareMetal2InstanceAction.java | 3 + .../org/zstack/sdk/CreateClusterAction.java | 2 +- .../sdk/StartBareMetal2InstanceAction.java | 3 + .../java/org/zstack/testlib/ApiHelper.groovy | 27 ++++ .../CloudOperationsErrorCode.java | 16 +++ 13 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 sdk/src/main/java/org/zstack/sdk/AddBareMetal2DpuChassisAction.java create mode 100644 sdk/src/main/java/org/zstack/sdk/BareMetal2DpuChassisInventory.java create mode 100644 sdk/src/main/java/org/zstack/sdk/BareMetal2DpuHostInventory.java diff --git a/compute/src/main/java/org/zstack/compute/host/HostManagerImpl.java b/compute/src/main/java/org/zstack/compute/host/HostManagerImpl.java index a15ac71e227..3e478c319e8 100755 --- a/compute/src/main/java/org/zstack/compute/host/HostManagerImpl.java +++ b/compute/src/main/java/org/zstack/compute/host/HostManagerImpl.java @@ -102,6 +102,8 @@ public class HostManagerImpl extends AbstractService implements HostManager, Man private Future reportHostCapacityTask; private Future refreshHostPowerStatusTask; + private static final List SKIP_ARCH_CHECK_HYPERVISOR_TYPES = Arrays.asList("baremetal2", "baremetal2Dpu"); + static { allowedMessageAfterSoftDeletion.add(HostDeletionMsg.class); } @@ -472,7 +474,7 @@ public void run(MessageReply reply) { @Override public boolean skip(Map data) { // no need to check baremetal2 gateway architecture with the cluster architecture - return vo.getHypervisorType().equals("baremetal2"); + return SKIP_ARCH_CHECK_HYPERVISOR_TYPES.contains(cluster.getHypervisorType()); } @Override diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 58d0e6ee187..33709a74abc 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -89,3 +89,36 @@ CREATE TABLE IF NOT EXISTS `zstack`.`ResNotifyWebhookRefVO` ( CONSTRAINT `fk_ResNotifyWebhookRefVO_ResNotifySubscriptionVO` FOREIGN KEY (`uuid`) REFERENCES `ResNotifySubscriptionVO`(`uuid`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `zstack`.`BareMetal2DpuChassisVO` ( + `uuid` varchar(32) NOT NULL UNIQUE, + `config` TEXT DEFAULT NULL, + `hostUuid` varchar(32) DEFAULT NULL, + PRIMARY KEY (`uuid`), + CONSTRAINT `fkBareMetal2DpuChassisVOChassisVO` FOREIGN KEY (`uuid`) REFERENCES `BareMetal2ChassisVO` (`uuid`) ON DELETE CASCADE, + CONSTRAINT `fkBareMetal2DpuChassisVOHostEO` FOREIGN KEY (`hostUuid`) REFERENCES `HostEO` (`uuid`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `zstack`.`BareMetal2DpuHostVO` ( + `uuid` varchar(32) NOT NULL UNIQUE, + `chassisUuid` VARCHAR(32) NOT NULL, + `vendorType` VARCHAR(255) NOT NULL, + `url` VARCHAR(255) NOT NULL, + PRIMARY KEY (`uuid`), + CONSTRAINT `fkBareMetal2DpuHostVOHostVO` FOREIGN KEY (`uuid`) REFERENCES `HostEO` (`uuid`) ON DELETE CASCADE, + CONSTRAINT `fkBareMetal2DpuHostVOChassisVO` FOREIGN KEY (`chassisUuid`) REFERENCES `BareMetal2DpuChassisVO` (`uuid`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +ALTER TABLE `zstack`.`BareMetal2InstanceVO` +DROP FOREIGN KEY `fkBareMetal2InstanceVOGatewayVO`, +DROP FOREIGN KEY `fkBareMetal2InstanceVOGatewayVO1`; + +ALTER TABLE `zstack`.`BareMetal2InstanceVO` + ADD CONSTRAINT `fkBareMetal2InstanceVOGatewayVO` + FOREIGN KEY (`gatewayUuid`) + REFERENCES `HostEO` (`uuid`) + ON DELETE SET NULL, + ADD CONSTRAINT `fkBareMetal2InstanceVOGatewayVO1` + FOREIGN KEY (`lastGatewayUuid`) + REFERENCES `HostEO` (`uuid`) + ON DELETE SET NULL; \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsg.java b/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsg.java index 18d1727a07f..7d740b7d152 100755 --- a/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsg.java +++ b/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsg.java @@ -70,7 +70,7 @@ public class APICreateClusterMsg extends APICreateMessage implements CreateClust * - Simulator * - baremetal */ - @APIParam(validValues = {"KVM", "Simulator", "baremetal", "baremetal2", "xdragon"}) + @APIParam(validValues = {"KVM", "Simulator", "baremetal", "baremetal2", "xdragon", "baremetal2Dpu"}) private String hypervisorType; /** * @desc see field 'type' of :ref:`ClusterInventory` for details diff --git a/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsgDoc_zh_cn.groovy index 90c01a79fa8..b4faf3d122e 100644 --- a/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsgDoc_zh_cn.groovy +++ b/header/src/main/java/org/zstack/header/cluster/APICreateClusterMsgDoc_zh_cn.groovy @@ -56,7 +56,7 @@ doc { type "String" optional false since "0.6" - values ("KVM","Simulator","baremetal","baremetal2","xdragon") + values ("KVM","Simulator","baremetal","baremetal2","xdragon","baremetal2Dpu") } column { name "type" diff --git a/sdk/src/main/java/SourceClassMap.java b/sdk/src/main/java/SourceClassMap.java index 33aff694e9d..e76a0eae9db 100644 --- a/sdk/src/main/java/SourceClassMap.java +++ b/sdk/src/main/java/SourceClassMap.java @@ -66,8 +66,10 @@ public class SourceClassMap { put("org.zstack.baremetal2.chassis.BareMetal2ChassisInventory", "org.zstack.sdk.BareMetal2ChassisInventory"); put("org.zstack.baremetal2.chassis.BareMetal2ChassisNicInventory", "org.zstack.sdk.BareMetal2ChassisNicInventory"); put("org.zstack.baremetal2.chassis.BareMetal2ChassisPciDeviceInventory", "org.zstack.sdk.BareMetal2ChassisPciDeviceInventory"); + put("org.zstack.baremetal2.chassis.dpu.BareMetal2DpuChassisInventory", "org.zstack.sdk.BareMetal2DpuChassisInventory"); put("org.zstack.baremetal2.chassis.ipmi.BareMetal2IpmiChassisInventory", "org.zstack.sdk.BareMetal2IpmiChassisInventory"); put("org.zstack.baremetal2.configuration.BareMetal2ChassisOfferingInventory", "org.zstack.sdk.BareMetal2ChassisOfferingInventory"); + put("org.zstack.baremetal2.dpu.BareMetal2DpuHostInventory", "org.zstack.sdk.BareMetal2DpuHostInventory"); put("org.zstack.baremetal2.gateway.BareMetal2GatewayInventory", "org.zstack.sdk.BareMetal2GatewayInventory"); put("org.zstack.baremetal2.gateway.BareMetal2GatewayProvisionNicInventory", "org.zstack.sdk.BareMetal2GatewayProvisionNicInventory"); put("org.zstack.baremetal2.instance.BareMetal2InstanceInventory", "org.zstack.sdk.BareMetal2InstanceInventory"); @@ -957,6 +959,8 @@ public class SourceClassMap { put("org.zstack.sdk.BareMetal2ChassisNicInventory", "org.zstack.baremetal2.chassis.BareMetal2ChassisNicInventory"); put("org.zstack.sdk.BareMetal2ChassisOfferingInventory", "org.zstack.baremetal2.configuration.BareMetal2ChassisOfferingInventory"); put("org.zstack.sdk.BareMetal2ChassisPciDeviceInventory", "org.zstack.baremetal2.chassis.BareMetal2ChassisPciDeviceInventory"); + put("org.zstack.sdk.BareMetal2DpuChassisInventory", "org.zstack.baremetal2.chassis.dpu.BareMetal2DpuChassisInventory"); + put("org.zstack.sdk.BareMetal2DpuHostInventory", "org.zstack.baremetal2.dpu.BareMetal2DpuHostInventory"); put("org.zstack.sdk.BareMetal2GatewayInventory", "org.zstack.baremetal2.gateway.BareMetal2GatewayInventory"); put("org.zstack.sdk.BareMetal2GatewayProvisionNicInventory", "org.zstack.baremetal2.gateway.BareMetal2GatewayProvisionNicInventory"); put("org.zstack.sdk.BareMetal2InstanceInventory", "org.zstack.baremetal2.instance.BareMetal2InstanceInventory"); diff --git a/sdk/src/main/java/org/zstack/sdk/AddBareMetal2DpuChassisAction.java b/sdk/src/main/java/org/zstack/sdk/AddBareMetal2DpuChassisAction.java new file mode 100644 index 00000000000..56f877caf5c --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/AddBareMetal2DpuChassisAction.java @@ -0,0 +1,125 @@ +package org.zstack.sdk; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class AddBareMetal2DpuChassisAction extends AbstractAction { + + private static final HashMap parameterMap = new HashMap<>(); + + private static final HashMap nonAPIParameterMap = new HashMap<>(); + + public static class Result { + public ErrorCode error; + public org.zstack.sdk.AddBareMetal2ChassisResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s, globalErrorCode: %s]", error.code, error.description, error.details, error.globalErrorCode) + ); + } + + return this; + } + } + + @Param(required = true, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String url; + + @Param(required = true, maxLength = 255, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String vendorType; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String config; + + @Param(required = true, maxLength = 255, nonempty = false, nullElements = false, emptyString = false, noTrim = false) + public java.lang.String name; + + @Param(required = false, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String description; + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String clusterUuid; + + @Param(required = false, validValues = {"Remote","Local","Direct"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String provisionType = "Remote"; + + @Param(required = false) + public java.lang.String resourceUuid; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List tagUuids; + + @Param(required = false) + public java.util.List systemTags; + + @Param(required = false) + public java.util.List userTags; + + @Param(required = false) + public String sessionId; + + @Param(required = false) + public String accessKeyId; + + @Param(required = false) + public String accessKeySecret; + + @Param(required = false) + public String requestIp; + + @NonAPIParam + public long timeout = -1; + + @NonAPIParam + public long pollingInterval = -1; + + + private Result makeResult(ApiResult res) { + Result ret = new Result(); + if (res.error != null) { + ret.error = res.error; + return ret; + } + + org.zstack.sdk.AddBareMetal2ChassisResult value = res.getResult(org.zstack.sdk.AddBareMetal2ChassisResult.class); + ret.value = value == null ? new org.zstack.sdk.AddBareMetal2ChassisResult() : value; + + return ret; + } + + public Result call() { + ApiResult res = ZSClient.call(this); + return makeResult(res); + } + + public void call(final Completion completion) { + ZSClient.call(this, new InternalCompletion() { + @Override + public void complete(ApiResult res) { + completion.complete(makeResult(res)); + } + }); + } + + protected Map getParameterMap() { + return parameterMap; + } + + protected Map getNonAPIParameterMap() { + return nonAPIParameterMap; + } + + protected RestInfo getRestInfo() { + RestInfo info = new RestInfo(); + info.httpMethod = "POST"; + info.path = "/baremetal2/chassis/dpu"; + info.needSession = true; + info.needPoll = true; + info.parameterName = "params"; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/BareMetal2DpuChassisInventory.java b/sdk/src/main/java/org/zstack/sdk/BareMetal2DpuChassisInventory.java new file mode 100644 index 00000000000..a68225a7c45 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/BareMetal2DpuChassisInventory.java @@ -0,0 +1,23 @@ +package org.zstack.sdk; + + + +public class BareMetal2DpuChassisInventory extends org.zstack.sdk.BareMetal2ChassisInventory { + + public java.lang.String config; + public void setConfig(java.lang.String config) { + this.config = config; + } + public java.lang.String getConfig() { + return this.config; + } + + public java.lang.String hostUuid; + public void setHostUuid(java.lang.String hostUuid) { + this.hostUuid = hostUuid; + } + public java.lang.String getHostUuid() { + return this.hostUuid; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/BareMetal2DpuHostInventory.java b/sdk/src/main/java/org/zstack/sdk/BareMetal2DpuHostInventory.java new file mode 100644 index 00000000000..09864707a7a --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/BareMetal2DpuHostInventory.java @@ -0,0 +1,31 @@ +package org.zstack.sdk; + + + +public class BareMetal2DpuHostInventory extends org.zstack.sdk.HostInventory { + + public java.lang.String url; + public void setUrl(java.lang.String url) { + this.url = url; + } + public java.lang.String getUrl() { + return this.url; + } + + public java.lang.String vendorType; + public void setVendorType(java.lang.String vendorType) { + this.vendorType = vendorType; + } + public java.lang.String getVendorType() { + return this.vendorType; + } + + public java.lang.String chassisUuid; + public void setChassisUuid(java.lang.String chassisUuid) { + this.chassisUuid = chassisUuid; + } + public java.lang.String getChassisUuid() { + return this.chassisUuid; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/CreateBareMetal2InstanceAction.java b/sdk/src/main/java/org/zstack/sdk/CreateBareMetal2InstanceAction.java index 755fb104476..a0fac8d2d9f 100644 --- a/sdk/src/main/java/org/zstack/sdk/CreateBareMetal2InstanceAction.java +++ b/sdk/src/main/java/org/zstack/sdk/CreateBareMetal2InstanceAction.java @@ -70,6 +70,9 @@ public Result throwExceptionIfError() { @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) public java.lang.String gatewayAllocatorStrategy; + @Param(required = false, validValues = {"IPMI","DPU"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String chassisType; + @Param(required = false) public java.lang.String resourceUuid; diff --git a/sdk/src/main/java/org/zstack/sdk/CreateClusterAction.java b/sdk/src/main/java/org/zstack/sdk/CreateClusterAction.java index 4d489de2ea8..5975cf8f9fd 100644 --- a/sdk/src/main/java/org/zstack/sdk/CreateClusterAction.java +++ b/sdk/src/main/java/org/zstack/sdk/CreateClusterAction.java @@ -34,7 +34,7 @@ public Result throwExceptionIfError() { @Param(required = false, maxLength = 2048, nonempty = false, nullElements = false, emptyString = true, noTrim = false) public java.lang.String description; - @Param(required = true, validValues = {"KVM","Simulator","baremetal","baremetal2","xdragon"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + @Param(required = true, validValues = {"KVM","Simulator","baremetal","baremetal2","xdragon","baremetal2Dpu"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) public java.lang.String hypervisorType; @Param(required = false, validValues = {"zstack","baremetal","baremetal2"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) diff --git a/sdk/src/main/java/org/zstack/sdk/StartBareMetal2InstanceAction.java b/sdk/src/main/java/org/zstack/sdk/StartBareMetal2InstanceAction.java index 4563ac62512..7b9f3459f9a 100644 --- a/sdk/src/main/java/org/zstack/sdk/StartBareMetal2InstanceAction.java +++ b/sdk/src/main/java/org/zstack/sdk/StartBareMetal2InstanceAction.java @@ -40,6 +40,9 @@ public Result throwExceptionIfError() { @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) public java.lang.String chassisOfferingUuid; + @Param(required = false, validValues = {"IPMI","DPU"}, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String chassisType; + @Param(required = false) public java.util.List systemTags; diff --git a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy index af8379f56e4..8a8f2b91416 100644 --- a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy +++ b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy @@ -1016,6 +1016,33 @@ abstract class ApiHelper { } + def addBareMetal2DpuChassis(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.AddBareMetal2DpuChassisAction.class) Closure c) { + def a = new org.zstack.sdk.AddBareMetal2DpuChassisAction() + a.sessionId = Test.currentEnvSpec?.session?.uuid + c.resolveStrategy = Closure.OWNER_FIRST + c.delegate = a + c() + + + if (System.getProperty("apipath") != null) { + if (a.apiId == null) { + a.apiId = Platform.uuid + } + + def tracker = new ApiPathTracker(a.apiId) + def out = errorOut(a.call()) + def path = tracker.getApiPath() + if (!path.isEmpty()) { + Test.apiPaths[a.class.name] = path.join(" --->\n") + } + + return out + } else { + return errorOut(a.call()) + } + } + + def addBlockPrimaryStorage(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.AddBlockPrimaryStorageAction.class) Closure c) { def a = new org.zstack.sdk.AddBlockPrimaryStorageAction() a.sessionId = Test.currentEnvSpec?.session?.uuid diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index aed0e710251..0deb26d677d 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -5482,6 +5482,14 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_BAREMETAL2_GATEWAY_10083 = "ORG_ZSTACK_BAREMETAL2_GATEWAY_10083"; + public static final String ORG_ZSTACK_BAREMETAL2_GATEWAY_10084 = "ORG_ZSTACK_BAREMETAL2_GATEWAY_10084"; + + public static final String ORG_ZSTACK_BAREMETAL2_GATEWAY_10085 = "ORG_ZSTACK_BAREMETAL2_GATEWAY_10085"; + + public static final String ORG_ZSTACK_BAREMETAL2_DPU_10000 = "ORG_ZSTACK_BAREMETAL2_DPU_10000"; + + public static final String ORG_ZSTACK_BAREMETAL2_DPU_10001 = "ORG_ZSTACK_BAREMETAL2_DPU_10001"; + public static final String ORG_ZSTACK_STORAGE_PRIMARY_SHAREDBLOCK_10000 = "ORG_ZSTACK_STORAGE_PRIMARY_SHAREDBLOCK_10000"; public static final String ORG_ZSTACK_STORAGE_PRIMARY_SHAREDBLOCK_10001 = "ORG_ZSTACK_STORAGE_PRIMARY_SHAREDBLOCK_10001"; @@ -7700,6 +7708,12 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_BAREMETAL2_INSTANCE_10089 = "ORG_ZSTACK_BAREMETAL2_INSTANCE_10089"; + public static final String ORG_ZSTACK_BAREMETAL2_INSTANCE_10090 = "ORG_ZSTACK_BAREMETAL2_INSTANCE_10090"; + + public static final String ORG_ZSTACK_BAREMETAL2_INSTANCE_10091 = "ORG_ZSTACK_BAREMETAL2_INSTANCE_10091"; + + public static final String ORG_ZSTACK_BAREMETAL2_INSTANCE_10092 = "ORG_ZSTACK_BAREMETAL2_INSTANCE_10092"; + public static final String ORG_ZSTACK_CRYPTO_SECURITYMACHINE_SECRETRESOURCEPOOL_10000 = "ORG_ZSTACK_CRYPTO_SECURITYMACHINE_SECRETRESOURCEPOOL_10000"; public static final String ORG_ZSTACK_CRYPTO_SECURITYMACHINE_SECRETRESOURCEPOOL_10001 = "ORG_ZSTACK_CRYPTO_SECURITYMACHINE_SECRETRESOURCEPOOL_10001"; @@ -13744,6 +13758,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_BAREMETAL2_CHASSIS_10025 = "ORG_ZSTACK_BAREMETAL2_CHASSIS_10025"; + public static final String ORG_ZSTACK_BAREMETAL2_CHASSIS_10026 = "ORG_ZSTACK_BAREMETAL2_CHASSIS_10026"; + public static final String ORG_ZSTACK_BAREMETAL2_CLUSTER_10000 = "ORG_ZSTACK_BAREMETAL2_CLUSTER_10000"; public static final String ORG_ZSTACK_BAREMETAL2_CLUSTER_10001 = "ORG_ZSTACK_BAREMETAL2_CLUSTER_10001"; From ecc93ebd9b3e7b5059e540c3eac503be504cf283 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Thu, 5 Mar 2026 20:34:25 +0800 Subject: [PATCH 67/85] [iscsi]: use platform compact hostId instead of storage bdc id for heartbeat offset ZSTAC-81797: iSCSI fencer used HeartbeatVolumeTO.hostId (storage bdc id, incremental up to 2000+) as heartbeat write offset multiplier. With 1MB heartbeat_required_space, host_id=2000 causes 2000MB offset, exceeding the 2GB heartbeat volume and failing heartbeat writes. Fix: use ExternalPrimaryStorageHostRefVO.hostId (platform-managed compact ID in [1, 999]) instead, same as CBD fencer already does. Resolves: ZSTAC-81797 Change-Id: Icac5ee2059df7cf93ca4bc23829ce855d6a7184c Co-Authored-By: Claude Opus 4.6 --- .../zstack/iscsi/kvm/KvmIscsiNodeServer.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/plugin/iscsi/src/main/java/org/zstack/iscsi/kvm/KvmIscsiNodeServer.java b/plugin/iscsi/src/main/java/org/zstack/iscsi/kvm/KvmIscsiNodeServer.java index 66936e05dc0..0f773d193e7 100644 --- a/plugin/iscsi/src/main/java/org/zstack/iscsi/kvm/KvmIscsiNodeServer.java +++ b/plugin/iscsi/src/main/java/org/zstack/iscsi/kvm/KvmIscsiNodeServer.java @@ -20,10 +20,8 @@ import org.zstack.header.host.HostInventory; import org.zstack.header.host.HostVO; import org.zstack.header.message.MessageReply; -import org.zstack.header.storage.addon.primary.BaseVolumeInfo; -import org.zstack.header.storage.addon.primary.HeartbeatVolumeTO; -import org.zstack.header.storage.addon.primary.HeartbeatVolumeTopology; -import org.zstack.header.storage.addon.primary.PrimaryStorageNodeSvc; +import org.zstack.header.storage.addon.primary.*; +import org.zstack.storage.addon.primary.ExternalHostIdGetter; import org.zstack.header.vm.VmInstanceInventory; import org.zstack.header.vm.VmInstanceMigrateExtensionPoint; import org.zstack.header.vm.VmInstanceSpec; @@ -38,6 +36,8 @@ import org.zstack.kvm.*; import org.zstack.storage.addon.primary.ExternalPrimaryStorageFactory; import org.zstack.utils.DebugUtils; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; import java.util.ArrayList; import java.util.List; @@ -49,6 +49,8 @@ public class KvmIscsiNodeServer implements Component, KVMStartVmExtensionPoint, VmInstanceMigrateExtensionPoint, KVMConvertVolumeExtensionPoint, KVMDetachVolumeExtensionPoint, KVMAttachVolumeExtensionPoint, KVMPreAttachIsoExtensionPoint, KvmSetupSelfFencerExtensionPoint { + private static final CLogger logger = Utils.getLogger(KvmIscsiNodeServer.class); + @Autowired private ExternalPrimaryStorageFactory extPsFactory; @@ -235,13 +237,24 @@ public void fail(ErrorCode errorCode) { @Override public void run(FlowTrigger trigger, Map data) { + ExternalPrimaryStorageHostRefVO ref = Q.New(ExternalPrimaryStorageHostRefVO.class) + .eq(ExternalPrimaryStorageHostRefVO_.hostUuid, param.getHostUuid()) + .eq(ExternalPrimaryStorageHostRefVO_.primaryStorageUuid, param.getPrimaryStorage().getUuid()) + .find(); + if (ref == null || ref.getHostId() == 0) { + logger.warn(String.format("not found hostId for hostUuid[%s] and primaryStorageUuid[%s]", + param.getHostUuid(), param.getPrimaryStorage().getUuid())); + ref = new ExternalHostIdGetter(999).getOrAllocateHostIdRef( + param.getHostUuid(), param.getPrimaryStorage().getUuid()); + } + KvmSetupSelfFencerCmd cmd = new KvmSetupSelfFencerCmd(); cmd.interval = param.getInterval(); cmd.maxAttempts = param.getMaxAttempts(); cmd.coveringPaths = heartbeatVol.getCoveringPaths(); cmd.heartbeatUrl = heartbeatVol.getInstallPath(); cmd.storageCheckerTimeout = param.getStorageCheckerTimeout(); - cmd.hostId = heartbeatVol.getHostId(); + cmd.hostId = ref.getHostId(); cmd.heartbeatRequiredSpace = heartbeatVol.getHeartbeatRequiredSpace(); cmd.hostUuid = param.getHostUuid(); cmd.strategy = param.getStrategy(); From 62cd8822b20c40868d4a76c7561a8f920eccf067 Mon Sep 17 00:00:00 2001 From: lianghy Date: Mon, 16 Mar 2026 10:37:41 +0800 Subject: [PATCH 68/85] [conf]: bump version to 5.5.12 DBImpact Resolves/Related: ZSTAC-82347 Change-Id: I6f6c616f6a656264696e7a7673636b656c737072 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index f36c3dfd747..6d937c2fb57 100755 --- a/VERSION +++ b/VERSION @@ -1,3 +1,3 @@ MAJOR=5 MINOR=5 -UPDATE=6 +UPDATE=12 From aaf4e344182a794540f2cf1b8092291457c0dad9 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 16 Mar 2026 11:53:23 +0800 Subject: [PATCH 69/85] =?UTF-8?q?[errorcode]:=20simplify=20i18n?= =?UTF-8?q?=20=E2=80=94=20guarantee=20message=20is=20never=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture changes: - Platform.err(): populate ErrorCode.message at creation time with default locale, so message is guaranteed non-null from the start - RestServer: centralize localize in sendResponse(ApiResponse), remove 3 scattered localize calls and unused resolveLocaleFromRequest - sendReplyResponse: use ApiResponse overload instead of String overload for consistent serialization and centralized localization Robustness improvements: - GlobalErrorCodeI18nServiceImpl.localizeErrorCode: if i18n template not found, fall back to details then description (message never null) - Inner locale message maps wrapped with Collections.unmodifiableMap - LocaleUtils: replace manual parseAcceptLanguage with standard Locale.LanguageRange.parse() for RFC 7231 compliant q-value sorting Frontend contract: UI can now always read error.message for display. Change-Id: I169cfc1cd80c8061a6c59dbefe3fe9c034cdb538 Co-Authored-By: Claude Opus 4.6 --- .../main/java/org/zstack/core/Platform.java | 15 +++++ .../GlobalErrorCodeI18nServiceImpl.java | 17 +++-- .../zstack/core/errorcode/LocaleUtils.java | 66 +++++-------------- .../main/java/org/zstack/rest/RestServer.java | 22 +++---- .../integration/core/ErrorCodeI18nCase.groovy | 52 +++++++++++++++ .../errorcode/TestGlobalErrorCodeI18n.java | 23 ++++++- 6 files changed, 128 insertions(+), 67 deletions(-) diff --git a/core/src/main/java/org/zstack/core/Platform.java b/core/src/main/java/org/zstack/core/Platform.java index ce5bada0d6c..dc7f6697150 100755 --- a/core/src/main/java/org/zstack/core/Platform.java +++ b/core/src/main/java/org/zstack/core/Platform.java @@ -17,6 +17,7 @@ import org.zstack.core.db.DatabaseGlobalProperty; import org.zstack.core.encrypt.EncryptRSA; import org.zstack.core.errorcode.ErrorFacade; +import org.zstack.core.errorcode.GlobalErrorCodeI18nService; import org.zstack.core.propertyvalidator.ValidatorTool; import org.zstack.core.search.SearchGlobalProperty; import org.zstack.core.statemachine.StateMachine; @@ -988,6 +989,20 @@ public static ErrorCode err(String globalErrorCode, Enum errCode, ErrorCode caus .toArray(String[]::new)); } + // populate message at creation time with default locale; + // RestServer will override with client's Accept-Language if different + try { + GlobalErrorCodeI18nService i18nService = getComponentLoader().getComponent(GlobalErrorCodeI18nService.class); + if (i18nService != null) { + i18nService.localizeErrorCode(result, org.zstack.core.errorcode.LocaleUtils.DEFAULT_LOCALE); + } + } catch (Exception e) { + // i18n service not initialized during early startup, use details as fallback + if (result.getMessage() == null) { + result.setMessage(details); + } + } + return result; } diff --git a/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java index f4def1fc071..00b05e4330c 100644 --- a/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java +++ b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java @@ -56,7 +56,7 @@ private void loadAllJsonFiles() { String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); @SuppressWarnings("unchecked") Map messages = JSONObjectUtil.toObject(content, LinkedHashMap.class); - localeMessages.put(locale, messages); + localeMessages.put(locale, Collections.unmodifiableMap(messages)); logger.info(String.format("loaded %d i18n error messages for locale [%s]", messages.size(), locale)); } catch (Exception e) { @@ -125,26 +125,33 @@ private String formatTemplate(String template, String[] formatArgs) { @Override public void localizeErrorCode(ErrorCode error, String locale) { - if (error == null || locale == null) { + if (error == null) { return; } + String resolvedLocale = locale != null ? locale : LocaleUtils.DEFAULT_LOCALE; + if (error.getGlobalErrorCode() != null) { - String message = getLocalizedMessage(error.getGlobalErrorCode(), locale, error.getFormatArgs()); + String message = getLocalizedMessage(error.getGlobalErrorCode(), resolvedLocale, error.getFormatArgs()); if (message != null) { error.setMessage(message); } } + // guarantee: message is never null + if (error.getMessage() == null) { + error.setMessage(error.getDetails() != null ? error.getDetails() : error.getDescription()); + } + if (error.getCause() != null) { - localizeErrorCode(error.getCause(), locale); + localizeErrorCode(error.getCause(), resolvedLocale); } if (error instanceof ErrorCodeList) { List causes = ((ErrorCodeList) error).getCauses(); if (causes != null) { for (ErrorCode cause : causes) { - localizeErrorCode(cause, locale); + localizeErrorCode(cause, resolvedLocale); } } } diff --git a/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java b/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java index bb1bcbea3a6..d97e9c0f0d6 100644 --- a/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java +++ b/core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java @@ -30,6 +30,9 @@ public class LocaleUtils { * Parse Accept-Language header and return the best matching locale key * from the set of available locales. * + * Uses {@link Locale.LanguageRange#parse(String)} for RFC 7231 compliant + * parsing with proper q-value priority sorting. + * * @param acceptLanguage the Accept-Language header value * @param availableLocales the set of locale keys loaded from JSON files * @return the best matching locale key, or en_US as fallback @@ -39,18 +42,27 @@ public static String resolveLocale(String acceptLanguage, Set availableL return DEFAULT_LOCALE; } - List entries = parseAcceptLanguage(acceptLanguage); - for (LocaleEntry entry : entries) { - if (entry.quality <= 0) { + List ranges; + try { + ranges = Locale.LanguageRange.parse(acceptLanguage); + } catch (IllegalArgumentException e) { + logger.debug(String.format("failed to parse Accept-Language [%s]: %s", acceptLanguage, e.getMessage())); + return DEFAULT_LOCALE; + } + + // ranges are already sorted by q-value descending + for (Locale.LanguageRange range : ranges) { + if (range.getWeight() <= 0) { continue; } - String normalized = normalizeTag(entry.tag); + String tag = range.getRange(); + String normalized = normalizeTag(tag); if (availableLocales.contains(normalized)) { return normalized; } - String lang = entry.tag.split("[-_]")[0].toLowerCase(); + String lang = tag.split("[-_]")[0].toLowerCase(); String mapped = LANGUAGE_TO_LOCALE.get(lang); if (mapped != null && availableLocales.contains(mapped)) { return mapped; @@ -78,48 +90,4 @@ static String normalizeTag(String tag) { } return tag; } - - private static List parseAcceptLanguage(String header) { - List entries = new ArrayList<>(); - String[] parts = header.split(","); - for (String part : parts) { - part = part.trim(); - if (part.isEmpty()) { - continue; - } - String[] tagAndParams = part.split(";"); - if (tagAndParams.length == 0) { - continue; - } - String tag = tagAndParams[0].trim(); - if (tag.isEmpty()) { - continue; - } - double quality = 1.0; - for (int i = 1; i < tagAndParams.length; i++) { - String param = tagAndParams[i].trim(); - if (param.startsWith("q=")) { - try { - quality = Double.parseDouble(param.substring(2).trim()); - } catch (NumberFormatException e) { - logger.debug(String.format("failed to parse quality value [%s]: %s", param, e.getMessage())); - quality = 0; - } - } - } - entries.add(new LocaleEntry(tag, quality)); - } - entries.sort((a, b) -> Double.compare(b.quality, a.quality)); - return entries; - } - - private static class LocaleEntry { - final String tag; - final double quality; - - LocaleEntry(String tag, double quality) { - this.tag = tag; - this.quality = quality; - } - } } diff --git a/rest/src/main/java/org/zstack/rest/RestServer.java b/rest/src/main/java/org/zstack/rest/RestServer.java index 917ffaadcdf..cea6416e6c7 100755 --- a/rest/src/main/java/org/zstack/rest/RestServer.java +++ b/rest/src/main/java/org/zstack/rest/RestServer.java @@ -408,8 +408,11 @@ private void callWebHook(RequestData d) throws IllegalAccessException, NoSuchMet writeResponse(response, w, ret.getResult()); } else { + // localize with webhook caller's locale (message already populated by Platform.err) String locale = resolveLocale(); - i18nService.localizeErrorCode(evt.getError(), locale); + if (!LocaleUtils.DEFAULT_LOCALE.equals(locale)) { + i18nService.localizeErrorCode(evt.getError(), locale); + } response.setError(evt.getError()); } @@ -917,14 +920,18 @@ private void handleJobQuery(HttpServletRequest req, HttpServletResponse rsp) thr writeResponse(response, w, ret.getResult()); sendResponse(HttpStatus.OK.value(), response, rsp); } else { - String locale = resolveLocaleFromRequest(req); - i18nService.localizeErrorCode(evt.getError(), locale); response.setError(evt.getError()); sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), response, rsp); } } private void sendResponse(int statusCode, ApiResponse response, HttpServletResponse rsp) throws IOException { + // centralized localization: override message with client's preferred locale + if (response.getError() != null) { + String locale = resolveLocale(); + i18nService.localizeErrorCode(response.getError(), locale); + } + RequestInfo info = requestInfo.get(); if (requestLogger.isTraceEnabled() && needLog(info)) { String body = CloudBusGson.toJson(response); @@ -1428,19 +1435,12 @@ private String resolveLocale() { return LocaleUtils.resolveLocale(acceptLanguage, i18nService.getAvailableLocales()); } - private String resolveLocaleFromRequest(HttpServletRequest req) { - String acceptLanguage = req.getHeader("Accept-Language"); - return LocaleUtils.resolveLocale(acceptLanguage, i18nService.getAvailableLocales()); - } - private void sendReplyResponse(MessageReply reply, Api api, HttpServletResponse rsp) throws IOException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { ApiResponse response = new ApiResponse(); if (!reply.isSuccess()) { - String locale = resolveLocale(); - i18nService.localizeErrorCode(reply.getError(), locale); response.setError(reply.getError()); - sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), JSONObjectUtil.toJsonString(response), rsp); + sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), response, rsp); return; } diff --git a/test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy b/test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy index a437b1a0a79..53c7f67c864 100644 --- a/test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy @@ -1,7 +1,9 @@ package org.zstack.test.integration.core +import org.zstack.core.errorcode.GlobalErrorCodeI18nServiceImpl import org.zstack.core.errorcode.LocaleUtils import org.zstack.header.errorcode.ErrorCode +import org.zstack.header.errorcode.ErrorCodeList import org.zstack.testlib.SubCase class ErrorCodeI18nCase extends SubCase { @@ -24,8 +26,12 @@ class ErrorCodeI18nCase extends SubCase { testLocaleUtilsNoMatch() testLocaleUtilsCaseInsensitive() testLocaleUtilsMalformedHeader() + testLocaleUtilsComplexBrowserHeader() testErrorCodeCopyConstructor() testErrorCodeCopyConstructorWithNulls() + testMessageGuaranteeFallbackToDetails() + testMessageGuaranteeFallbackToDescription() + testMessageGuaranteeOnCauseChain() } @Override @@ -78,6 +84,14 @@ class ErrorCodeI18nCase extends SubCase { assert LocaleUtils.resolveLocale(";;;,,,", available) == "en_US" } + void testLocaleUtilsComplexBrowserHeader() { + def available = ["zh_CN", "en_US"] as Set + // real Chrome header: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 + assert LocaleUtils.resolveLocale("zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", available) == "zh_CN" + // q=0 means "not acceptable" — should be skipped + assert LocaleUtils.resolveLocale("zh-CN;q=0,en-US;q=1.0", available) == "en_US" + } + // ---- ErrorCode copy constructor ---- void testErrorCodeCopyConstructor() { @@ -127,4 +141,42 @@ class ErrorCodeI18nCase extends SubCase { assert copy.formatArgs == null } + // ---- message guarantee (localizeErrorCode always populates message) ---- + + void testMessageGuaranteeFallbackToDetails() { + def i18n = new GlobalErrorCodeI18nServiceImpl() + // no i18n JSON loaded, so getLocalizedMessage returns null + // localizeErrorCode should fall back to details + def error = new ErrorCode("SYS.1000", "System Error", "disk full on /dev/sda1") + assert error.getMessage() == null + + i18n.localizeErrorCode(error, "en_US") + + assert error.getMessage() == "disk full on /dev/sda1" + } + + void testMessageGuaranteeFallbackToDescription() { + def i18n = new GlobalErrorCodeI18nServiceImpl() + // no details, should fall back to description + def error = new ErrorCode("SYS.1000", "System Error") + assert error.getMessage() == null + + i18n.localizeErrorCode(error, "en_US") + + assert error.getMessage() == "System Error" + } + + void testMessageGuaranteeOnCauseChain() { + def i18n = new GlobalErrorCodeI18nServiceImpl() + def root = new ErrorCode("INTERNAL.1001", "Internal Error", "root cause detail") + def mid = new ErrorCode("SYS.1000", "System Error") + mid.setCause(root) + + i18n.localizeErrorCode(mid, "en_US") + + // both should have message populated + assert mid.getMessage() == "System Error" + assert root.getMessage() == "root cause detail" + } + } diff --git a/test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java b/test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java index 729e841c6d2..917cf44cbc2 100644 --- a/test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java +++ b/test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java @@ -128,8 +128,27 @@ public void testLocalizeErrorCodeList() { @Test public void testNoGlobalErrorCode() { ErrorCode error = new ErrorCode("SYS.1000", "test error"); - // no globalErrorCode set + // no globalErrorCode set — message should fall back to description i18nService.localizeErrorCode(error, "zh_CN"); - Assert.assertNull("message should remain null", error.getMessage()); + Assert.assertEquals("message should fall back to description", + "test error", error.getMessage()); + } + + @Test + public void testMessageGuaranteeFallbackToDetails() { + ErrorCode error = new ErrorCode("SYS.1000", "System Error", "disk full on /dev/sda1"); + i18nService.localizeErrorCode(error, "en_US"); + Assert.assertEquals("message should fall back to details", + "disk full on /dev/sda1", error.getMessage()); + } + + @Test + public void testMessageNeverNull() { + ErrorCode error = new ErrorCode("SYS.1000", "System Error"); + error.setDetails(null); + i18nService.localizeErrorCode(error, "en_US"); + Assert.assertNotNull("message must never be null after localizeErrorCode", + error.getMessage()); + Assert.assertEquals("System Error", error.getMessage()); } } \ No newline at end of file From 1d8a0593bba9fc0c96297533a409ff3f679c3f5b Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 9 Mar 2026 16:31:04 +0800 Subject: [PATCH 70/85] [ai]: add targetQueueKey column for eval task queuing Resolves: ZSTAC-68709 Change-Id: I66b2038656387bc3b3555db20cdfddb31d82d914 --- conf/db/upgrade/V5.5.12__schema.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 33709a74abc..8d29c9f95ec 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -121,4 +121,7 @@ ALTER TABLE `zstack`.`BareMetal2InstanceVO` ADD CONSTRAINT `fkBareMetal2InstanceVOGatewayVO1` FOREIGN KEY (`lastGatewayUuid`) REFERENCES `HostEO` (`uuid`) - ON DELETE SET NULL; \ No newline at end of file + ON DELETE SET NULL; + +-- ZSTAC-68709: Add targetQueueKey for evaluation task queuing per service endpoint +CALL ADD_COLUMN('ModelEvaluationTaskVO', 'targetQueueKey', 'TEXT', 1, NULL); From 34366a151f4e94935fb5352458111efdc70cc950 Mon Sep 17 00:00:00 2001 From: "jin.ma" Date: Mon, 16 Mar 2026 13:29:26 +0800 Subject: [PATCH 71/85] [conf]: use absolute path for ansible version check during upgrade upgrade from py2 venv to py3 venv, ensure_python3_venv recreates an empty venv with no ansible binary. The bare 'ansible --version' falls back to /usr/bin/ansible old wrapper which uses relative path, causing infinite recursion on kylin arm environments. Use absolute path $SYS_VIRENV_PATH/bin/ansible with existence check to avoid hitting the old wrapper. Resolves: ZSTAC-82619 Change-Id: I666461626876787762626b6f6261776d76767262 --- conf/tools/install.sh | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/conf/tools/install.sh b/conf/tools/install.sh index ca15a67650f..3c6d218c41f 100755 --- a/conf/tools/install.sh +++ b/conf/tools/install.sh @@ -94,13 +94,8 @@ elif [ $tool = 'zstack-ctl' ]; then elif [ $tool = 'zstack-sys' ]; then SYS_VIRENV_PATH=/var/lib/zstack/virtualenv/zstacksys ensure_python3_venv "$SYS_VIRENV_PATH" - RE_INSTALL=false - . $SYS_VIRENV_PATH/bin/activate - if ! ansible --version | grep -q 'core 2.16.14'; then - deactivate - RE_INSTALL=true - fi - if $RE_INSTALL; then + # RE_INSTALL + if [ ! -x "$SYS_VIRENV_PATH/bin/ansible" ] || ! "$SYS_VIRENV_PATH/bin/ansible" --version 2>/dev/null | grep -q 'core 2.16.14'; then rm -rf $SYS_VIRENV_PATH && python3.11 -m venv $SYS_VIRENV_PATH || exit 1 . $SYS_VIRENV_PATH/bin/activate cd $cwd From 39a9a8489e957e8256c6c774831337c7b56f2ce3 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 16 Mar 2026 13:47:00 +0800 Subject: [PATCH 72/85] [longjob]: standardize progress detail fields to be unit-agnostic Resolves: ZSTAC-82318 Change-Id: If68d507a3eeb3f48458d9fbf61c357339688d43b --- .../core/progress/LongJobProgressDetail.java | 47 +- .../LongJobProgressDetailBuilder.java | 406 ++++++------------ .../org/zstack/sdk/LongJobProgressDetail.java | 38 +- 3 files changed, 194 insertions(+), 297 deletions(-) diff --git a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java index d27dc05cc4c..d4d5e1d56bd 100644 --- a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java +++ b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetail.java @@ -21,11 +21,11 @@ public class LongJobProgressDetail { /** Human-readable reason for current state. */ private String stateReason; - /** Bytes already processed. */ - private Long processedBytes; + /** Amount already processed (unit described by the {@code unit} field). */ + private Long processed; - /** Total bytes to process. */ - private Long totalBytes; + /** Total amount to process (unit described by the {@code unit} field). */ + private Long total; /** Items already processed (e.g. files, chunks). */ private Long processedItems; @@ -33,8 +33,11 @@ public class LongJobProgressDetail { /** Total items to process. */ private Long totalItems; - /** Transfer speed in bytes/s. */ - private Long speedBytesPerSecond; + /** Processing speed per second (unit described by the {@code unit} field). */ + private Long speed; + + /** Unit for processed/total/speed, e.g. "bytes", "items", "steps". */ + private String unit; /** Estimated remaining time in seconds. */ private Long estimatedRemainingSeconds; @@ -77,20 +80,20 @@ public void setStateReason(String stateReason) { this.stateReason = stateReason; } - public Long getProcessedBytes() { - return processedBytes; + public Long getProcessed() { + return processed; } - public void setProcessedBytes(Long processedBytes) { - this.processedBytes = processedBytes; + public void setProcessed(Long processed) { + this.processed = processed; } - public Long getTotalBytes() { - return totalBytes; + public Long getTotal() { + return total; } - public void setTotalBytes(Long totalBytes) { - this.totalBytes = totalBytes; + public void setTotal(Long total) { + this.total = total; } public Long getProcessedItems() { @@ -109,12 +112,20 @@ public void setTotalItems(Long totalItems) { this.totalItems = totalItems; } - public Long getSpeedBytesPerSecond() { - return speedBytesPerSecond; + public Long getSpeed() { + return speed; + } + + public void setSpeed(Long speed) { + this.speed = speed; + } + + public String getUnit() { + return unit; } - public void setSpeedBytesPerSecond(Long speedBytesPerSecond) { - this.speedBytesPerSecond = speedBytesPerSecond; + public void setUnit(String unit) { + this.unit = unit; } public Long getEstimatedRemainingSeconds() { diff --git a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java index 4c9a09b2580..3de92b7c473 100644 --- a/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java +++ b/header/src/main/java/org/zstack/header/core/progress/LongJobProgressDetailBuilder.java @@ -1,264 +1,142 @@ -package org.zstack.header.core.progress; - -import org.zstack.utils.Utils; -import org.zstack.utils.gson.JSONObjectUtil; -import org.zstack.utils.logging.CLogger; - -import java.util.HashMap; -import java.util.Map; - -/** - * Parses TaskProgressVO.opaque (free-form JSON) into a typed LongJobProgressDetail. - * - * Three known opaque formats are handled: - * Format 1 — VM migration: {"remain":N, "total":N, "speed":N, "remaining_migration_time":N} - * Format 2 — AI download: {"data": ""} where the inner JSON has - * {state, progress:{percent,downloaded_bytes,total_bytes, - * speed_bytes_per_second,estimated_remaining_seconds, - * downloaded_files,total_files,stage}, state_reason} - * Format 3 — unknown: entire map goes into LongJobProgressDetail.extra - * - * Each format is tried independently. Failures in one format don't affect others. - */ -public class LongJobProgressDetailBuilder { - private static final CLogger logger = Utils.getLogger(LongJobProgressDetailBuilder.class); - - private LongJobProgressDetailBuilder() {} - - /** - * Build a LongJobProgressDetail from a TaskProgressVO. - * Returns null if opaque is null/empty or all parsers fail. - */ - public static LongJobProgressDetail fromTaskProgressVO(TaskProgressVO vo) { - if (vo == null || vo.getOpaque() == null || vo.getOpaque().isEmpty()) { - return null; - } - - Map raw; - try { - raw = JSONObjectUtil.toObject(vo.getOpaque(), HashMap.class); - } catch (Exception e) { - logger.trace("LongJobProgressDetailBuilder: opaque is not a JSON object, skipping: " + vo.getOpaque(), e); - return null; - } - - if (raw == null || raw.isEmpty()) { - return null; - } - - // Try Format 2 first: AI download wraps everything under "data" key - if (raw.containsKey("data")) { - LongJobProgressDetail detail = tryParseAiDownloadFormat(raw); - if (detail != null) { - return detail; - } - } - - // Try Format 1: VM migration with remain/total/speed keys - if (raw.containsKey("remain") && raw.containsKey("total")) { - LongJobProgressDetail detail = tryParseVmMigrationFormat(raw); - if (detail != null) { - return detail; - } - } - - // Format 3: unknown — put everything into extra - return parseAsExtra(raw); - } - - /** - * Format 1: VM migration opaque - * {"remain": 1234567, "total": 9999999, "speed": 102400, "remaining_migration_time": 30} - * remain = bytes still to transfer; processed = total - remain - */ - private static LongJobProgressDetail tryParseVmMigrationFormat(Map raw) { - try { - LongJobProgressDetail detail = new LongJobProgressDetail(); - detail.setStage("migrating"); - - Number total = toNumber(raw.get("total")); - Number remain = toNumber(raw.get("remain")); - Number speed = toNumber(raw.get("speed")); - Number remainingTime = toNumber(raw.get("remaining_migration_time")); - - if (total != null) { - detail.setTotalBytes(total.longValue()); - } - if (total != null && remain != null) { - long processed = Math.max(0L, total.longValue() - remain.longValue()); - detail.setProcessedBytes(processed); - if (total.longValue() > 0) { - detail.setPercent((int) Math.min(100, Math.round(processed * 100.0 / total.longValue()))); - } - } - if (speed != null) { - detail.setSpeedBytesPerSecond(speed.longValue()); - } - if (remainingTime != null) { - detail.setEstimatedRemainingSeconds(remainingTime.longValue()); - } - - // Carry over any unrecognized keys into extra - Map extra = new HashMap<>(raw); - extra.remove("remain"); - extra.remove("total"); - extra.remove("speed"); - extra.remove("remaining_migration_time"); - if (!extra.isEmpty()) { - detail.setExtra(extra); - } - - return detail; - } catch (Exception e) { - logger.trace("LongJobProgressDetailBuilder: failed to parse VM migration format", e); - return null; - } - } - - /** - * Format 2: AI download opaque - * {"data": "{\"state\":\"downloading\", \"progress\":{\"percent\":42, \"processedBytes\":N, ...}}"} - * The "data" value is a JSON string (double-encoded). - */ - private static LongJobProgressDetail tryParseAiDownloadFormat(Map raw) { - try { - Object dataVal = raw.get("data"); - if (dataVal == null) { - return null; - } - - Map inner; - if (dataVal instanceof String) { - // double-encoded JSON string - inner = JSONObjectUtil.toObject((String) dataVal, HashMap.class); - } else if (dataVal instanceof Map) { - inner = (Map) dataVal; - } else { - return null; - } - - if (inner == null) { - return null; - } - - LongJobProgressDetail detail = new LongJobProgressDetail(); - Map extra = new HashMap<>(); - - // state field - Object stateVal = inner.get("state"); - if (stateVal instanceof String) { - detail.setState((String) stateVal); - } - - // progress sub-object - Object progressVal = inner.get("progress"); - if (progressVal instanceof Map) { - Map progress = (Map) progressVal; - - Number percent = toNumber(progress.get("percent")); - if (percent != null) { - detail.setPercent(Math.max(0, Math.min(100, (int) Math.round(percent.doubleValue())))); - } - - // AI agent uses snake_case field names - Number processedBytes = toNumber(progress.get("downloaded_bytes")); - if (processedBytes != null) { - detail.setProcessedBytes(processedBytes.longValue()); - } - - Number totalBytes = toNumber(progress.get("total_bytes")); - if (totalBytes != null) { - detail.setTotalBytes(totalBytes.longValue()); - } - - Number speed = toNumber(progress.get("speed_bytes_per_second")); - if (speed != null) { - detail.setSpeedBytesPerSecond(speed.longValue()); - } - - Number eta = toNumber(progress.get("estimated_remaining_seconds")); - if (eta != null) { - detail.setEstimatedRemainingSeconds(eta.longValue()); - } - - Number processedFiles = toNumber(progress.get("downloaded_files")); - if (processedFiles != null) { - detail.setProcessedItems(processedFiles.longValue()); - } - - Number totalFiles = toNumber(progress.get("total_files")); - if (totalFiles != null) { - detail.setTotalItems(totalFiles.longValue()); - } - - Object stage = progress.get("stage"); - if (stage instanceof String) { - detail.setStage((String) stage); - } - - // remaining progress fields go into extra - Map extraProgress = new HashMap<>(progress); - extraProgress.remove("percent"); - extraProgress.remove("downloaded_bytes"); - extraProgress.remove("total_bytes"); - extraProgress.remove("speed_bytes_per_second"); - extraProgress.remove("estimated_remaining_seconds"); - extraProgress.remove("downloaded_files"); - extraProgress.remove("total_files"); - extraProgress.remove("stage"); - extra.putAll(extraProgress); - } - - // stateReason field — can be String or Map (structured reason with code/description) - Object stateReason = inner.get("state_reason"); - if (stateReason instanceof String) { - detail.setStateReason((String) stateReason); - } else if (stateReason instanceof Map) { - detail.setStateReason(JSONObjectUtil.toJsonString(stateReason)); - } - - // preserve unknown keys from inner top-level - Map extraInner = new HashMap<>(inner); - extraInner.remove("state"); - extraInner.remove("progress"); - extraInner.remove("state_reason"); - extra.putAll(extraInner); - - // preserve unknown keys from raw outer-level - Map extraRaw = new HashMap<>(raw); - extraRaw.remove("data"); - extra.putAll(extraRaw); - - if (!extra.isEmpty()) { - detail.setExtra(extra); - } - - return detail; - } catch (Exception e) { - logger.trace("LongJobProgressDetailBuilder: failed to parse AI download format", e); - return null; - } - } - - /** - * Format 3: unknown — preserve the entire map as extra for UI passthrough. - */ - private static LongJobProgressDetail parseAsExtra(Map raw) { - LongJobProgressDetail detail = new LongJobProgressDetail(); - detail.setExtra(new HashMap<>(raw)); - return detail; - } - - private static Number toNumber(Object val) { - if (val instanceof Number) { - return (Number) val; - } - if (val instanceof String) { - try { - return Double.parseDouble((String) val); - } catch (NumberFormatException ignored) { - } - } - return null; - } -} +package org.zstack.header.core.progress; + +import org.zstack.utils.Utils; +import org.zstack.utils.gson.JSONObjectUtil; +import org.zstack.utils.logging.CLogger; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parses TaskProgressVO.opaque (free-form JSON) into a typed LongJobProgressDetail. + * + * All agents now send a standardized camelCase format: + * {"processed":N, "total":N, "percent":N, "stage":"migrating", + * "speed":N, "estimatedRemainingSeconds":N, "state":"running", + * "stateReason":"...", "processedItems":N, "totalItems":N, "unit":"bytes"} + * + * The "unit" field tells the UI how to format processed/total/speed: + * "bytes" — byte quantities (format as KB/MB/GB) + * "items" — discrete items (format as count) + * "steps" — workflow steps + * + * Unknown keys are preserved in LongJobProgressDetail.extra so no data is silently dropped. + */ +public class LongJobProgressDetailBuilder { + private static final CLogger logger = Utils.getLogger(LongJobProgressDetailBuilder.class); + + private static final String[] KNOWN_KEYS = { + "processed", "total", "percent", "stage", "state", "stateReason", + "speed", "estimatedRemainingSeconds", "processedItems", "totalItems", "unit" + }; + + private LongJobProgressDetailBuilder() {} + + /** + * Build a LongJobProgressDetail from a TaskProgressVO. + * Returns null if opaque is null/empty or not valid JSON. + */ + public static LongJobProgressDetail fromTaskProgressVO(TaskProgressVO vo) { + if (vo == null || vo.getOpaque() == null || vo.getOpaque().isEmpty()) { + return null; + } + + Map raw; + try { + raw = JSONObjectUtil.toObject(vo.getOpaque(), HashMap.class); + } catch (Exception e) { + logger.trace("LongJobProgressDetailBuilder: opaque is not a JSON object, skipping: " + vo.getOpaque(), e); + return null; + } + + if (raw == null || raw.isEmpty()) { + return null; + } + + try { + LongJobProgressDetail detail = new LongJobProgressDetail(); + + Number processed = toNumber(raw.get("processed")); + if (processed != null) { + detail.setProcessed(processed.longValue()); + } + + Number total = toNumber(raw.get("total")); + if (total != null) { + detail.setTotal(total.longValue()); + } + + Number percent = toNumber(raw.get("percent")); + if (percent != null) { + detail.setPercent(Math.max(0, Math.min(100, (int) Math.round(percent.doubleValue())))); + } + + Object stage = raw.get("stage"); + if (stage instanceof String) { + detail.setStage((String) stage); + } + + Object state = raw.get("state"); + if (state instanceof String) { + detail.setState((String) state); + } + + Object stateReason = raw.get("stateReason"); + if (stateReason instanceof String) { + detail.setStateReason((String) stateReason); + } + + Number speed = toNumber(raw.get("speed")); + if (speed != null) { + detail.setSpeed(speed.longValue()); + } + + Number eta = toNumber(raw.get("estimatedRemainingSeconds")); + if (eta != null) { + detail.setEstimatedRemainingSeconds(eta.longValue()); + } + + Number processedItems = toNumber(raw.get("processedItems")); + if (processedItems != null) { + detail.setProcessedItems(processedItems.longValue()); + } + + Number totalItems = toNumber(raw.get("totalItems")); + if (totalItems != null) { + detail.setTotalItems(totalItems.longValue()); + } + + Object unit = raw.get("unit"); + if (unit instanceof String) { + detail.setUnit((String) unit); + } + + // Carry over any unrecognized keys into extra + Map extra = new HashMap<>(raw); + for (String key : KNOWN_KEYS) { + extra.remove(key); + } + if (!extra.isEmpty()) { + detail.setExtra(extra); + } + + return detail; + } catch (Exception e) { + logger.trace("LongJobProgressDetailBuilder: failed to parse standard format", e); + return null; + } + } + + private static Number toNumber(Object val) { + if (val instanceof Number) { + return (Number) val; + } + if (val instanceof String) { + try { + return Double.parseDouble((String) val); + } catch (NumberFormatException ignored) { + } + } + return null; + } +} diff --git a/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java b/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java index c37bc20795b..788c12fa136 100644 --- a/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java +++ b/sdk/src/main/java/org/zstack/sdk/LongJobProgressDetail.java @@ -36,20 +36,20 @@ public java.lang.String getStateReason() { return this.stateReason; } - public java.lang.Long processedBytes; - public void setProcessedBytes(java.lang.Long processedBytes) { - this.processedBytes = processedBytes; + public java.lang.Long processed; + public void setProcessed(java.lang.Long processed) { + this.processed = processed; } - public java.lang.Long getProcessedBytes() { - return this.processedBytes; + public java.lang.Long getProcessed() { + return this.processed; } - public java.lang.Long totalBytes; - public void setTotalBytes(java.lang.Long totalBytes) { - this.totalBytes = totalBytes; + public java.lang.Long total; + public void setTotal(java.lang.Long total) { + this.total = total; } - public java.lang.Long getTotalBytes() { - return this.totalBytes; + public java.lang.Long getTotal() { + return this.total; } public java.lang.Long processedItems; @@ -68,12 +68,20 @@ public java.lang.Long getTotalItems() { return this.totalItems; } - public java.lang.Long speedBytesPerSecond; - public void setSpeedBytesPerSecond(java.lang.Long speedBytesPerSecond) { - this.speedBytesPerSecond = speedBytesPerSecond; + public java.lang.Long speed; + public void setSpeed(java.lang.Long speed) { + this.speed = speed; } - public java.lang.Long getSpeedBytesPerSecond() { - return this.speedBytesPerSecond; + public java.lang.Long getSpeed() { + return this.speed; + } + + public java.lang.String unit; + public void setUnit(java.lang.String unit) { + this.unit = unit; + } + public java.lang.String getUnit() { + return this.unit; } public java.lang.Long estimatedRemainingSeconds; From dc67d7293f34a32970ed5f654c035b46c55a3193 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 16 Mar 2026 16:04:48 +0800 Subject: [PATCH 73/85] [header]: fix __example__ method names for LongJobProgressDetail setProcessedBytes/setTotalBytes/setSpeedBytesPerSecond renamed to setProcessed/setTotal/setSpeed in unit-agnostic refactor. Add setUnit("bytes") to preserve semantic clarity. Related: ZSTAC-82318 Change-Id: Ied29ac01d488062a03d792378432522fbf0a523f Co-Authored-By: Claude Opus 4.6 (1M context) --- .../header/core/progress/APIGetTaskProgressReply.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java b/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java index 880e8cda1a8..3479c83e909 100755 --- a/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java +++ b/header/src/main/java/org/zstack/header/core/progress/APIGetTaskProgressReply.java @@ -35,9 +35,10 @@ public static APIGetTaskProgressReply __example__() { detail.setPercent(42); detail.setStage("downloading"); detail.setState("running"); - detail.setProcessedBytes(440401920L); - detail.setTotalBytes(1073741824L); - detail.setSpeedBytesPerSecond(10485760L); + detail.setProcessed(440401920L); + detail.setTotal(1073741824L); + detail.setUnit("bytes"); + detail.setSpeed(10485760L); detail.setEstimatedRemainingSeconds(60L); inv.setProgressDetail(detail); From ed8c99b321a93a4bf7af5e999cfadb72ccb78c13 Mon Sep 17 00:00:00 2001 From: "jin.ma" Date: Mon, 16 Mar 2026 17:25:35 +0800 Subject: [PATCH 74/85] [conf]: retry rm -rf virtualenv to avoid race with zstack_service_exporter Resolves: ZSTAC-82619 Change-Id: I581385808fe4d942369b98f175eb8fbbdb382a0d --- conf/tools/install.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/conf/tools/install.sh b/conf/tools/install.sh index 3c6d218c41f..8a759869850 100755 --- a/conf/tools/install.sh +++ b/conf/tools/install.sh @@ -39,7 +39,9 @@ ensure_python3_venv() { if [ -d "$venv_path" ] && [ -x "$venv_path/bin/python3.11" ]; then return 0 fi - rm -rf "$venv_path" && python3.11 -m venv "$venv_path" || exit 1 + # retry once: rm -rf may fail if zstack_service_exporter is regenerating .pyc concurrently + rm -rf "$venv_path" || rm -rf "$venv_path" || exit 1 + python3.11 -m venv "$venv_path" || exit 1 } From d27439a012aa4d778cb04448b2650bae3b90dadc Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Mon, 16 Mar 2026 23:38:21 +0800 Subject: [PATCH 75/85] [vmScheduling]: change GET scheduling APIs from POST to GET method (ZSTAC-71075) Resolves: ZSTAC-71075 Change-Id: I21ebd76ba332995252f67f4cacf78022587a215a --- .../zstack/sdk/GetVmSchedulingRulesExecuteStateAction.java | 4 ++-- .../sdk/GetVmsSchedulingStateFromSchedulingRuleAction.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/org/zstack/sdk/GetVmSchedulingRulesExecuteStateAction.java b/sdk/src/main/java/org/zstack/sdk/GetVmSchedulingRulesExecuteStateAction.java index 6a15d7c436e..78650d6e7f9 100644 --- a/sdk/src/main/java/org/zstack/sdk/GetVmSchedulingRulesExecuteStateAction.java +++ b/sdk/src/main/java/org/zstack/sdk/GetVmSchedulingRulesExecuteStateAction.java @@ -84,11 +84,11 @@ protected Map getNonAPIParameterMap() { protected RestInfo getRestInfo() { RestInfo info = new RestInfo(); - info.httpMethod = "POST"; + info.httpMethod = "GET"; info.path = "/get/vmSchedulingRules/conflict/state"; info.needSession = true; info.needPoll = false; - info.parameterName = "params"; + info.parameterName = ""; return info; } diff --git a/sdk/src/main/java/org/zstack/sdk/GetVmsSchedulingStateFromSchedulingRuleAction.java b/sdk/src/main/java/org/zstack/sdk/GetVmsSchedulingStateFromSchedulingRuleAction.java index 83ae01b33e5..1c9c6a69ab0 100644 --- a/sdk/src/main/java/org/zstack/sdk/GetVmsSchedulingStateFromSchedulingRuleAction.java +++ b/sdk/src/main/java/org/zstack/sdk/GetVmsSchedulingStateFromSchedulingRuleAction.java @@ -87,11 +87,11 @@ protected Map getNonAPIParameterMap() { protected RestInfo getRestInfo() { RestInfo info = new RestInfo(); - info.httpMethod = "POST"; + info.httpMethod = "GET"; info.path = "/get/vms/schedulingState/from/SchedulingRule"; info.needSession = true; info.needPoll = false; - info.parameterName = "params"; + info.parameterName = ""; return info; } From 1dc68d2e465f0021b42db340bdadea786b60b35a Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 17 Mar 2026 11:01:26 +0800 Subject: [PATCH 76/85] [errorcode]: revert sendReplyResponse to use JSONObjectUtil serialization CloudBusGson.httpGson does not disableHtmlEscaping(), causing single quotes in error details to be escaped as \u0027. While valid JSON, the SDK deserializes these literally, breaking string assertions in tests like BatchAddBareMetal2ChassisCase that check error.details with contains("'reboot' must be 'No'"). Restore JSONObjectUtil.toJsonString() for the sendReplyResponse error path while keeping the centralized localization logic. Co-Authored-By: ye.zou --- rest/src/main/java/org/zstack/rest/RestServer.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rest/src/main/java/org/zstack/rest/RestServer.java b/rest/src/main/java/org/zstack/rest/RestServer.java index cea6416e6c7..ee3dbb0b355 100755 --- a/rest/src/main/java/org/zstack/rest/RestServer.java +++ b/rest/src/main/java/org/zstack/rest/RestServer.java @@ -1439,8 +1439,13 @@ private void sendReplyResponse(MessageReply reply, Api api, HttpServletResponse ApiResponse response = new ApiResponse(); if (!reply.isSuccess()) { + String locale = resolveLocale(); + i18nService.localizeErrorCode(reply.getError(), locale); response.setError(reply.getError()); - sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), response, rsp); + // use JSONObjectUtil (which disables HTML escaping) to keep the same + // serialization behavior as before; CloudBusGson.httpGson escapes '\'' to + // '\u0027' which breaks SDK-side string assertions (ZSTAC-71075 etc.) + sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), JSONObjectUtil.toJsonString(response), rsp); return; } From 5ffa56e570bbc43627b280493824202e7cbc728a Mon Sep 17 00:00:00 2001 From: "shixin.ruan" Date: Thu, 5 Feb 2026 19:43:22 +0800 Subject: [PATCH 77/85] [network]: set nic ip out of l3 cidr scope APIImpact DBImpact GlobalConfigImpact Resolves: ZSTAC-81969 Change-Id: I736e77646266696e646271766d7170627378796b --- .gitignore | 2 + .../zstack/compute/vm/StaticIpOperator.java | 126 ++- .../zstack/compute/vm/VmCascadeExtension.java | 8 +- .../compute/vm/VmInstanceApiInterceptor.java | 594 ++++++----- .../org/zstack/compute/vm/VmInstanceBase.java | 144 ++- .../org/zstack/compute/vm/VmSystemTags.java | 5 + conf/db/upgrade/V5.5.12__schema.sql | 53 + .../header/network/l3/AllocateIpMsg.java | 40 + .../header/network/l3/IpAllocateMessage.java | 16 + .../header/network/l3/UsedIpInventory.java | 10 + .../zstack/header/network/l3/UsedIpVO.java | 13 +- .../zstack/header/network/l3/UsedIpVO_.java | 2 + .../header/vm/APIChangeVmNicNetworkMsg.java | 27 +- .../APIChangeVmNicNetworkMsgDoc_zh_cn.groovy | 9 + .../zstack/header/vm/APISetVmStaticIpMsg.java | 12 + .../vm/APISetVmStaticIpMsgDoc_zh_cn.groovy | 9 + .../header/vm/ChangeVmNicNetworkMsg.java | 10 + .../zstack/header/vm/SetVmStaticIpMsg.java | 11 + .../zstack/header/vm/VmInstanceConstant.java | 1 + .../org/zstack/network/l3/IpRangeHelper.java | 89 ++ .../org/zstack/network/l3/L3BasicNetwork.java | 55 +- .../network/l3/L3NetworkApiInterceptor.java | 42 + .../network/l3/L3NetworkManagerImpl.java | 5 +- .../network/l3/L3NetworkSystemTags.java | 1 - .../network/l3/NormalIpRangeFactory.java | 4 +- .../java/org/zstack/network/l3/zstack ipam.md | 139 +++ .../zstack/network/service/DhcpExtension.java | 25 +- .../service/NetworkServiceManager.java | 10 + .../service/NetworkServiceManagerImpl.java | 33 + .../zstack/appliancevm/ApplianceVmNicTO.java | 17 +- .../service/eip/EipApiInterceptor.java | 17 + .../network/service/eip/EipManagerImpl.java | 10 + .../network/service/flat/FlatDhcpBackend.java | 2 +- .../network/service/flat/FlatEipBackend.java | 11 +- .../lb/LoadBalancerApiInterceptor.java | 50 + .../network/service/lb/LoadBalancerBase.java | 16 + .../PortForwardingApiInterceptor.java | 24 + .../PortForwardingManagerImpl.java | 12 +- .../VirtualRouterManagerImpl.java | 7 +- .../zstack/sdk/ChangeVmNicNetworkAction.java | 3 + .../org/zstack/sdk/SetVmStaticIpAction.java | 3 + .../java/org/zstack/sdk/UsedIpInventory.java | 8 + test/pom.xml | 8 +- .../flat/FlatChangeVmIpOutsideCidrCase.groovy | 923 ++++++++++++++++++ ...licNetworkChangeVmIpOutsideCidrCase.groovy | 443 +++++++++ .../main/java/org/zstack/testlib/Test.groovy | 14 + .../CloudOperationsErrorCode.java | 35 + .../utils/network/NicIpAddressInfo.java | 4 + 48 files changed, 2800 insertions(+), 302 deletions(-) create mode 100644 network/src/main/java/org/zstack/network/l3/zstack ipam.md create mode 100644 test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy create mode 100644 test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy diff --git a/.gitignore b/.gitignore index 641f731fe03..e823298f7f6 100755 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ envDSLTree test/zstack-integration-test-result/ premium/test-premium/zstack-api.log **/bin/ +CLAUDE.md +.claude/* diff --git a/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java b/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java index 86ca327ae93..3ebd89f5a57 100755 --- a/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java +++ b/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java @@ -80,11 +80,8 @@ public Map getNicNetworkInfoBySystemTag(List s if(VmSystemTags.STATIC_IP.isMatch(sysTag)) { Map token = TagUtils.parse(VmSystemTags.STATIC_IP.getTagFormat(), sysTag); String l3Uuid = token.get(VmSystemTags.STATIC_IP_L3_UUID_TOKEN); - NicIpAddressInfo nicIpAddressInfo = ret.get(l3Uuid); - if (nicIpAddressInfo == null) { - ret.put(l3Uuid, new NicIpAddressInfo("", "", "", - "", "", "")); - } + ret.computeIfAbsent(l3Uuid, k -> new NicIpAddressInfo("", "", "", + "", "", "")); String ip = token.get(VmSystemTags.STATIC_IP_TOKEN); ip = IPv6NetworkUtils.ipv6TagValueToAddress(ip); if (NetworkUtils.isIpv4Address(ip)) { @@ -109,30 +106,42 @@ public Map getNicNetworkInfoBySystemTag(List s continue; } ret.get(l3Uuid).ipv4Gateway = token.get(VmSystemTags.IPV4_GATEWAY_TOKEN); - } - if(VmSystemTags.IPV4_NETMASK.isMatch(sysTag)) { + } else if(VmSystemTags.IPV4_NETMASK.isMatch(sysTag)) { Map token = TagUtils.parse(VmSystemTags.IPV4_NETMASK.getTagFormat(), sysTag); String l3Uuid = token.get(VmSystemTags.IPV4_NETMASK_L3_UUID_TOKEN); if (ret.get(l3Uuid) == null) { continue; } ret.get(l3Uuid).ipv4Netmask = token.get(VmSystemTags.IPV4_NETMASK_TOKEN); - } - if(VmSystemTags.IPV6_GATEWAY.isMatch(sysTag)) { + } else if(VmSystemTags.IPV6_GATEWAY.isMatch(sysTag)) { Map token = TagUtils.parse(VmSystemTags.IPV6_GATEWAY.getTagFormat(), sysTag); String l3Uuid = token.get(VmSystemTags.IPV6_GATEWAY_L3_UUID_TOKEN); if (ret.get(l3Uuid) == null) { continue; } ret.get(l3Uuid).ipv6Gateway = IPv6NetworkUtils.ipv6TagValueToAddress(token.get(VmSystemTags.IPV6_GATEWAY_TOKEN)); - } - if(VmSystemTags.IPV6_PREFIX.isMatch(sysTag)) { + } else if(VmSystemTags.IPV6_PREFIX.isMatch(sysTag)) { Map token = TagUtils.parse(VmSystemTags.IPV6_PREFIX.getTagFormat(), sysTag); String l3Uuid = token.get(VmSystemTags.IPV6_PREFIX_L3_UUID_TOKEN); if (ret.get(l3Uuid) == null) { continue; } ret.get(l3Uuid).ipv6Prefix = token.get(VmSystemTags.IPV6_PREFIX_TOKEN); + } else if(VmSystemTags.STATIC_DNS.isMatch(sysTag)) { + Map token = TagUtils.parse(VmSystemTags.STATIC_DNS.getTagFormat(), sysTag); + String l3Uuid = token.get(VmSystemTags.STATIC_DNS_L3_UUID_TOKEN); + if (ret.get(l3Uuid) == null) { + continue; + } + String dnsStr = token.get(VmSystemTags.STATIC_DNS_TOKEN); + if (dnsStr != null && !dnsStr.isEmpty()) { + // Convert back from tag value: replace '--' with '::' for IPv6 addresses + List dnsList = new ArrayList<>(); + for (String dns : dnsStr.split(",")) { + dnsList.add(IPv6NetworkUtils.ipv6TagValueToAddress(dns)); + } + ret.get(l3Uuid).dnsAddresses = dnsList; + } } } @@ -222,6 +231,49 @@ public void deleteStaticIpByL3NetworkUuid(String l3Uuid) { ))); } + public void setStaticDns(String vmUuid, String l3Uuid, List dnsAddresses) { + if (dnsAddresses == null || dnsAddresses.isEmpty()) { + deleteStaticDnsByVmUuidAndL3Uuid(vmUuid, l3Uuid); + return; + } + + // Convert IPv6 addresses: replace '::' with '--' to avoid conflict with system tag delimiter + List tagSafeDns = new ArrayList<>(); + for (String dns : dnsAddresses) { + tagSafeDns.add(IPv6NetworkUtils.ipv6AddessToTagValue(dns)); + } + String dnsStr = String.join(",", tagSafeDns); + + SimpleQuery q = dbf.createQuery(SystemTagVO.class); + q.select(SystemTagVO_.uuid); + q.add(SystemTagVO_.resourceType, Op.EQ, VmInstanceVO.class.getSimpleName()); + q.add(SystemTagVO_.resourceUuid, Op.EQ, vmUuid); + q.add(SystemTagVO_.tag, Op.LIKE, TagUtils.tagPatternToSqlPattern(VmSystemTags.STATIC_DNS.instantiateTag( + map(e(VmSystemTags.STATIC_DNS_L3_UUID_TOKEN, l3Uuid)) + ))); + String tagUuid = q.findValue(); + + if (tagUuid == null) { + SystemTagCreator creator = VmSystemTags.STATIC_DNS.newSystemTagCreator(vmUuid); + creator.setTagByTokens(map( + e(VmSystemTags.STATIC_DNS_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.STATIC_DNS_TOKEN, dnsStr) + )); + creator.create(); + } else { + VmSystemTags.STATIC_DNS.updateByTagUuid(tagUuid, VmSystemTags.STATIC_DNS.instantiateTag(map( + e(VmSystemTags.STATIC_DNS_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.STATIC_DNS_TOKEN, dnsStr) + ))); + } + } + + public void deleteStaticDnsByVmUuidAndL3Uuid(String vmUuid, String l3Uuid) { + VmSystemTags.STATIC_DNS.delete(vmUuid, TagUtils.tagPatternToSqlPattern(VmSystemTags.STATIC_DNS.instantiateTag( + map(e(VmSystemTags.STATIC_DNS_L3_UUID_TOKEN, l3Uuid)) + ))); + } + public Map getNicStaticIpMap(List nicStaticIpList) { Map nicStaticIpMap = new HashMap<>(); if (nicStaticIpList != null) { @@ -263,14 +315,14 @@ public boolean isIpChange(String vmUuid, String l3Uuid) { return false; } - public Boolean checkIpRangeConflict(VmNicVO nicVO){ + public Boolean isNicIpInL3IpRanges(VmNicVO nicVO){ if (Q.New(IpRangeVO.class).eq(IpRangeVO_.l3NetworkUuid, nicVO.getL3NetworkUuid()).list().isEmpty()) { - return Boolean.FALSE; + return Boolean.TRUE; } if (getIpRangeUuid(nicVO.getL3NetworkUuid(), nicVO.getIp()) == null) { - return Boolean.TRUE; + return Boolean.FALSE; } - return Boolean.FALSE; + return Boolean.TRUE; } public String getIpRangeUuid(String l3Uuid, String ip) { @@ -297,10 +349,19 @@ public String getIpRangeUuid(String l3Uuid, String ip) { return null; } + public NormalIpRangeVO findMatchedNormalIpRange(String l3Uuid, String ip) { + String rangeUuid = getIpRangeUuid(l3Uuid, ip); + if (rangeUuid == null) { + return null; + } + return dbf.findByUuid(rangeUuid, NormalIpRangeVO.class); + } + public void checkIpAvailability(String l3Uuid, String ip) { CheckIpAvailabilityMsg cmsg = new CheckIpAvailabilityMsg(); cmsg.setIp(ip); cmsg.setL3NetworkUuid(l3Uuid); + cmsg.setIpRangeCheck(false); bus.makeLocalServiceId(cmsg, L3NetworkConstant.SERVICE_ID); MessageReply r = bus.call(cmsg); if (!r.isSuccess()) { @@ -334,10 +395,7 @@ public List fillUpStaticIpInfoToVmNics(Map sta } if (!StringUtils.isEmpty(nicIp.ipv4Address)) { - NormalIpRangeVO ipRangeVO = Q.New(NormalIpRangeVO.class) - .eq(NormalIpRangeVO_.l3NetworkUuid, l3Uuid) - .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv4) - .limit(1).find(); + NormalIpRangeVO ipRangeVO = findMatchedNormalIpRange(l3Uuid, nicIp.ipv4Address); if (ipRangeVO == null) { if (StringUtils.isEmpty(nicIp.ipv4Netmask)) { throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10310, "netmask must be set")); @@ -349,8 +407,10 @@ public List fillUpStaticIpInfoToVmNics(Map sta e(VmSystemTags.IPV4_NETMASK_TOKEN, ipRangeVO.getNetmask())) )); } else if (!nicIp.ipv4Netmask.equals(ipRangeVO.getNetmask())) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10311, "netmask error, expect: %s, got: %s", - ipRangeVO.getNetmask(), nicIp.ipv4Netmask)); + newSystags.add(VmSystemTags.IPV4_NETMASK.instantiateTag( + map(e(VmSystemTags.IPV4_NETMASK_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV4_NETMASK_TOKEN, nicIp.ipv4Netmask)) + )); } if (StringUtils.isEmpty(nicIp.ipv4Gateway)) { @@ -359,17 +419,16 @@ public List fillUpStaticIpInfoToVmNics(Map sta e(VmSystemTags.IPV4_GATEWAY_TOKEN, ipRangeVO.getGateway())) )); } else if (!nicIp.ipv4Gateway.equals(ipRangeVO.getGateway())) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10312, "gateway error, expect: %s, got: %s", - ipRangeVO.getGateway(), nicIp.ipv4Gateway)); + newSystags.add(VmSystemTags.IPV4_GATEWAY.instantiateTag( + map(e(VmSystemTags.IPV4_GATEWAY_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV4_GATEWAY_TOKEN, nicIp.ipv4Gateway)) + )); } } } if (!StringUtils.isEmpty(nicIp.ipv6Address)) { - NormalIpRangeVO ipRangeVO = Q.New(NormalIpRangeVO.class) - .eq(NormalIpRangeVO_.l3NetworkUuid, l3Uuid) - .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6) - .limit(1).find(); + NormalIpRangeVO ipRangeVO = findMatchedNormalIpRange(l3Uuid, nicIp.ipv6Address); if (ipRangeVO == null) { if (StringUtils.isEmpty(nicIp.ipv6Prefix)) { throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10313, "ipv6 prefix length must be set")); @@ -381,8 +440,10 @@ public List fillUpStaticIpInfoToVmNics(Map sta e(VmSystemTags.IPV6_PREFIX_TOKEN, ipRangeVO.getPrefixLen())) )); } else if (!nicIp.ipv6Prefix.equals(ipRangeVO.getPrefixLen().toString())) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10314, "ipv6 prefix length error, expect: %s, got: %s", - ipRangeVO.getPrefixLen(), nicIp.ipv6Prefix)); + newSystags.add(VmSystemTags.IPV6_PREFIX.instantiateTag( + map(e(VmSystemTags.IPV6_PREFIX_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV6_PREFIX_TOKEN, nicIp.ipv6Prefix)) + )); } if (StringUtils.isEmpty(nicIp.ipv6Gateway)) { @@ -392,8 +453,11 @@ public List fillUpStaticIpInfoToVmNics(Map sta IPv6NetworkUtils.ipv6AddressToTagValue(ipRangeVO.getGateway()))) )); } else if (!nicIp.ipv6Gateway.equals(ipRangeVO.getGateway())) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10315, "gateway error, expect: %s, got: %s", - ipRangeVO.getGateway(), nicIp.ipv6Gateway)); + newSystags.add(VmSystemTags.IPV6_GATEWAY.instantiateTag( + map(e(VmSystemTags.IPV6_GATEWAY_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV6_GATEWAY_TOKEN, + IPv6NetworkUtils.ipv6AddressToTagValue(nicIp.ipv6Gateway))) + )); } } } diff --git a/compute/src/main/java/org/zstack/compute/vm/VmCascadeExtension.java b/compute/src/main/java/org/zstack/compute/vm/VmCascadeExtension.java index 5b2dd2f399a..9bd98bd9511 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmCascadeExtension.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmCascadeExtension.java @@ -300,8 +300,8 @@ protected List handleDeletionForIpRange(List handleDeletionForIpRange(List ipv4Ranges, String l3Uuid, String defaultL3Uuid, int vmNicCount, UsedIpVO existingIp) { + boolean hasNetmask = StringUtils.isNotEmpty(userNetmask); + boolean hasGateway = StringUtils.isNotEmpty(userGateway); + + // case (a): both provided + if (hasNetmask && hasGateway) { + return new String[]{userNetmask, userGateway}; + } + + NormalIpRangeVO matchedRange = IpRangeHelper.findIpRangeByCidr(ip, ipv4Ranges); + + // case (b): gateway provided, no netmask + if (hasGateway) { + if (matchedRange != null && matchedRange.getNetworkCidr() != null + && NetworkUtils.isIpv4InCidr(userGateway, matchedRange.getNetworkCidr())) { + return new String[]{matchedRange.getNetmask(), userGateway}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10323, + "gateway[%s] is provided but IP[%s] and gateway are not both in L3 network CIDR, netmask must be specified", + userGateway, ip)); + } + + // case (c): netmask provided, no gateway + if (hasNetmask) { + if (matchedRange != null && userNetmask.equals(matchedRange.getNetmask())) { + return new String[]{matchedRange.getNetmask(), matchedRange.getGateway()}; + } + if (l3Uuid.equals(defaultL3Uuid) || vmNicCount == 1) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10324, + "netmask[%s] does not match L3 CIDR netmask and the NIC is the default or sole network, gateway must be specified", + userNetmask)); + } + return new String[]{userNetmask, ""}; + } + + // case (d): neither provided + if (existingIp != null && shouldUseExistingIpv4(ip, existingIp)) { + return new String[]{existingIp.getNetmask(), existingIp.getGateway()}; + } + if (matchedRange != null) { + return new String[]{matchedRange.getNetmask(), matchedRange.getGateway()}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10325, + "IP[%s] is outside all L3 network CIDRs and no existing IP parameters available, netmask and gateway must be specified", + ip)); + } + + /** + * Resolve IPv6 prefix and gateway based on 4 cases (mirrors IPv4 logic): + * (a) Both prefix+gateway provided: use user input as-is + * (b) Gateway provided, no prefix: if ip and gateway both in L3 CIDR, use CIDR prefix; else error + * (c) Prefix provided, no gateway: if prefix == CIDR prefix, use CIDR gateway; else if default/sole NIC, error; else gateway="" + * (d) Neither provided: if existingIp usable (APISetVmStaticIpMsg), use it; else if in L3 CIDR, use CIDR; else error + * + * @param existingIp pass null for APIChangeVmNicNetworkMsg (no existing IP on dest L3) + */ + private String[] resolveIpv6PrefixAndGateway(String ip6, String userPrefix, String userGateway, + List ipv6Ranges, String l3Uuid, String defaultL3Uuid, int vmNicCount, UsedIpVO existingIp) { + boolean hasPrefix = StringUtils.isNotEmpty(userPrefix); + boolean hasGateway = StringUtils.isNotEmpty(userGateway); + + // case (a): both provided + if (hasPrefix && hasGateway) { + return new String[]{userPrefix, userGateway}; + } + + NormalIpRangeVO matchedRange = IpRangeHelper.findIpRangeByCidr(ip6, ipv6Ranges); + + // case (b): gateway provided, no prefix + if (hasGateway) { + if (matchedRange != null && matchedRange.getNetworkCidr() != null + && IPv6NetworkUtils.isIpv6InCidrRange(userGateway, matchedRange.getNetworkCidr())) { + return new String[]{matchedRange.getPrefixLen().toString(), userGateway}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10326, + "gateway[%s] is provided but IPv6[%s] and gateway are not both in L3 network CIDR, prefix must be specified", + userGateway, ip6)); + } + + // case (c): prefix provided, no gateway + if (hasPrefix) { + if (matchedRange != null && userPrefix.equals(matchedRange.getPrefixLen().toString())) { + return new String[]{matchedRange.getPrefixLen().toString(), matchedRange.getGateway()}; + } + if (l3Uuid.equals(defaultL3Uuid) || vmNicCount == 1) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10327, + "prefix[%s] does not match L3 CIDR prefix and the NIC is the default or sole network, gateway must be specified", + userPrefix)); + } + return new String[]{userPrefix, ""}; + } + + // case (d): neither provided + if (existingIp != null && shouldUseExistingIpv6(ip6, existingIp)) { + return new String[]{existingIp.getPrefixLen().toString(), existingIp.getGateway()}; + } + if (matchedRange != null) { + return new String[]{matchedRange.getPrefixLen().toString(), matchedRange.getGateway()}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10328, + "IPv6[%s] is outside all L3 network CIDRs and no existing IP parameters available, prefix and gateway must be specified", + ip6)); + } + + /** + * Check whether an IP is already in use (using error code ORG_ZSTACK_COMPUTE_VM_10105). + */ + private void checkIpOccupied(String ip, String l3NetworkUuid) { + if (Q.New(UsedIpVO.class).eq(UsedIpVO_.ip, ip).eq(UsedIpVO_.l3NetworkUuid, l3NetworkUuid).isExists()) { + throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10105, + "the static IP[%s] has been occupied on the L3 network[uuid:%s]", ip, l3NetworkUuid)); + } + } + + /** + * Batch check whether multiple IPs are already in use. + */ + private void checkIpsOccupied(List ips, String l3NetworkUuid) { + if (ips == null || ips.isEmpty()) { + return; + } + + List occupiedIps = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, l3NetworkUuid) + .in(UsedIpVO_.ip, ips) + .select(UsedIpVO_.ip) + .listValues(); + + if (!occupiedIps.isEmpty()) { + throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10105, + "the static IP%s has been occupied on the L3 network[uuid:%s]", occupiedIps, l3NetworkUuid)); + } + } + + private void setServiceId(APIMessage msg) { if (msg instanceof VmInstanceMessage) { VmInstanceMessage vmsg = (VmInstanceMessage) msg; @@ -287,99 +505,106 @@ private void validate(APIChangeVmNicNetworkMsg msg) { } new StaticIpOperator().validateSystemTagInApiMessage(msg); - Map> staticIps = new StaticIpOperator().getStaticIpbySystemTag(msg.getSystemTags()); - if (msg.getRequiredIpMap() != null) { - staticIps.computeIfAbsent(msg.getDestL3NetworkUuid(), k -> new ArrayList<>()).add(msg.getStaticIp()); - SimpleQuery iprq = dbf.createQuery(NormalIpRangeVO.class); - iprq.add(NormalIpRangeVO_.l3NetworkUuid, Op.EQ, msg.getDestL3NetworkUuid()); - List iprs = iprq.list(); - boolean found = false; - for (NormalIpRangeVO ipr : iprs) { - if (!ipr.getIpVersion().equals(NetworkUtils.getIpversion(msg.getStaticIp()))) { - continue; + // Resolve netmask/gateway for static IPs in systemTags, overriding what validateSystemTagInApiMessage may have set + { + String destL3Uuid = msg.getDestL3NetworkUuid(); + Map nicNetworkInfo = new StaticIpOperator().getNicNetworkInfoBySystemTag(msg.getSystemTags()); + NicIpAddressInfo nicIpInfo = nicNetworkInfo.get(destL3Uuid); + if (nicIpInfo != null) { + List destIpv4Ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, destL3Uuid) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv4).list(); + List destIpv6Ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, destL3Uuid) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6).list(); + String defaultL3Uuid = Q.New(VmInstanceVO.class) + .select(VmInstanceVO_.defaultL3NetworkUuid) + .eq(VmInstanceVO_.uuid, vmUuid) + .findValue(); + int vmNicCount = Q.New(VmNicVO.class).eq(VmNicVO_.vmInstanceUuid, vmUuid).count().intValue(); + + // Remove existing netmask/gateway/prefix/ipv6Gateway tags for dest L3 from systemTags + if (msg.getSystemTags() != null) { + msg.getSystemTags().removeIf(tag -> + VmSystemTags.IPV4_NETMASK.isMatch(tag) || VmSystemTags.IPV4_GATEWAY.isMatch(tag) + || VmSystemTags.IPV6_PREFIX.isMatch(tag) || VmSystemTags.IPV6_GATEWAY.isMatch(tag)); } - if (NetworkUtils.isInRange(msg.getStaticIp(), ipr.getStartIp(), ipr.getEndIp())) { - found = true; - break; + // Resolve and add IPv4 netmask/gateway + if (StringUtils.isNotEmpty(nicIpInfo.ipv4Address)) { + String[] ipv4Result = resolveIpv4NetmaskAndGateway(nicIpInfo.ipv4Address, + nicIpInfo.ipv4Netmask, nicIpInfo.ipv4Gateway, + destIpv4Ranges, destL3Uuid, defaultL3Uuid, vmNicCount, null); + msg.getSystemTags().add(VmSystemTags.IPV4_NETMASK.instantiateTag( + map(e(VmSystemTags.IPV4_NETMASK_L3_UUID_TOKEN, destL3Uuid), + e(VmSystemTags.IPV4_NETMASK_TOKEN, ipv4Result[0])))); + msg.getSystemTags().add(VmSystemTags.IPV4_GATEWAY.instantiateTag( + map(e(VmSystemTags.IPV4_GATEWAY_L3_UUID_TOKEN, destL3Uuid), + e(VmSystemTags.IPV4_GATEWAY_TOKEN, ipv4Result[1])))); } - } - if (!l3NetworkVO.enableIpAddressAllocation()) { - found = true; + // Resolve and add IPv6 prefix/gateway + if (StringUtils.isNotEmpty(nicIpInfo.ipv6Address)) { + String[] ipv6Result = resolveIpv6PrefixAndGateway(nicIpInfo.ipv6Address, + nicIpInfo.ipv6Prefix, nicIpInfo.ipv6Gateway, + destIpv6Ranges, destL3Uuid, defaultL3Uuid, vmNicCount, null); + msg.getSystemTags().add(VmSystemTags.IPV6_PREFIX.instantiateTag( + map(e(VmSystemTags.IPV6_PREFIX_L3_UUID_TOKEN, destL3Uuid), + e(VmSystemTags.IPV6_PREFIX_TOKEN, ipv6Result[0])))); + msg.getSystemTags().add(VmSystemTags.IPV6_GATEWAY.instantiateTag( + map(e(VmSystemTags.IPV6_GATEWAY_L3_UUID_TOKEN, destL3Uuid), + e(VmSystemTags.IPV6_GATEWAY_TOKEN, + IPv6NetworkUtils.ipv6AddressToTagValue(ipv6Result[1]))))); + } } + } - if (!found) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10104, "the static IP[%s] is not in any IP range of the L3 network[uuid:%s]", msg.getStaticIp(), msg.getDestL3NetworkUuid())); - } + Map> staticIps = new StaticIpOperator().getStaticIpbySystemTag(msg.getSystemTags()); - SimpleQuery uq = dbf.createQuery(UsedIpVO.class); - uq.add(UsedIpVO_.l3NetworkUuid, Op.EQ, msg.getDestL3NetworkUuid()); - uq.add(UsedIpVO_.ip, Op.EQ, msg.getStaticIp()); - if (uq.isExists()) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10105, "the static IP[%s] has been occupied on the L3 network[uuid:%s]", msg.getStaticIp(), msg.getDestL3NetworkUuid())); - } + // If staticIp parameter is provided, add it to the static IP list + if (msg.getStaticIp() != null) { + staticIps.computeIfAbsent(msg.getDestL3NetworkUuid(), k -> new ArrayList<>()).add(msg.getStaticIp()); } - for (Map.Entry> e : staticIps.entrySet()) { - if (!newAddedL3Uuids.contains(e.getKey())) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10106, "static ip l3 uuid[%s] is not included in nic l3 [%s]", e.getKey(), newAddedL3Uuids)); - } + msg.setRequiredIpMap(new HashMap<>()); + // Unified loop: validate and set all static IPs together + for (Map.Entry> e : staticIps.entrySet()) { String l3Uuid = e.getKey(); List ips = e.getValue(); - SimpleQuery iprq = dbf.createQuery(NormalIpRangeVO.class); - iprq.add(NormalIpRangeVO_.l3NetworkUuid, Op.EQ, l3Uuid); - List iprs = iprq.list(); - - boolean found = false; - for (String staticIp : ips) { - int ipVersion = IPv6Constants.IPv4; - if (IPv6NetworkUtils.isIpv6Address(staticIp)) { - ipVersion = IPv6Constants.IPv6; - } - for (NormalIpRangeVO ipr : iprs) { - if (ipVersion != ipr.getIpVersion()) { - continue; - } - if (NetworkUtils.isInRange(staticIp, ipr.getStartIp(), ipr.getEndIp())) { - found = true; - break; - } - } - if (!l3NetworkVO.enableIpAddressAllocation()) { - found = true; - } + // Validate that the L3 network UUID is in the allowed list + if (!newAddedL3Uuids.contains(l3Uuid)) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10106, + "static ip l3 uuid[%s] is not included in nic l3 [%s]", l3Uuid, newAddedL3Uuids)); + } - if (!found) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10107, "the static IP[%s] is not in any IP range of the L3 network[uuid:%s]", staticIp, l3Uuid)); - } + // Performance optimization: batch check IP occupation (one query instead of N) + checkIpsOccupied(ips, l3Uuid); - SimpleQuery uq = dbf.createQuery(UsedIpVO.class); - uq.add(UsedIpVO_.l3NetworkUuid, Op.EQ, msg.getDestL3NetworkUuid()); - uq.add(UsedIpVO_.ip, Op.EQ, msg.getStaticIp()); - if (uq.isExists()) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10108, "the static IP[%s] has been occupied on the L3 network[uuid:%s]", staticIp, l3Uuid)); - } - } + // Set requiredIpMap (merged into this loop to eliminate redundant iteration) + msg.getRequiredIpMap().put(l3Uuid, ips); } - msg.setRequiredIpMap(new HashMap<>()); + validateDnsAddresses(msg.getDnsAddresses()); + } - for (Map.Entry> e : staticIps.entrySet()) { - msg.getRequiredIpMap().put(e.getKey(), e.getValue()); + private void validateDnsAddresses(List dnsAddresses) { + if (dnsAddresses == null || dnsAddresses.isEmpty()) { + return; } - final Map nicNetworkInfo = new StaticIpOperator().getNicNetworkInfoBySystemTag(msg.getSystemTags()); - NicIpAddressInfo nicIpAddressInfo = nicNetworkInfo.get(msg.getDestL3NetworkUuid()); - if (nicIpAddressInfo != null) { - if (!nicIpAddressInfo.ipv4Address.isEmpty() && Q.New(UsedIpVO.class).eq(UsedIpVO_.ip, nicIpAddressInfo.ipv4Address).eq(UsedIpVO_.l3NetworkUuid, msg.getDestL3NetworkUuid()).isExists()) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10109, "the static IP[%s] has been occupied on the L3 network[uuid:%s]", nicIpAddressInfo.ipv4Address, msg.getDestL3NetworkUuid())); - } - if (!nicIpAddressInfo.ipv6Address.isEmpty() && Q.New(UsedIpVO.class).eq(UsedIpVO_.ip, IPv6NetworkUtils.getIpv6AddressCanonicalString(nicIpAddressInfo.ipv6Address)).eq(UsedIpVO_.l3NetworkUuid, msg.getDestL3NetworkUuid()).isExists()) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10110, "the static IP[%s] has been occupied on the L3 network[uuid:%s]", nicIpAddressInfo.ipv6Address, msg.getDestL3NetworkUuid())); + if (dnsAddresses.size() > VmInstanceConstant.MAXIMUM_NIC_DNS_NUMBER) { + throw new ApiMessageInterceptionException(argerr( + ORG_ZSTACK_COMPUTE_VM_10321, "at most %d DNS addresses are allowed, but got %d", + VmInstanceConstant.MAXIMUM_NIC_DNS_NUMBER, dnsAddresses.size())); + } + + for (String dns : dnsAddresses) { + if (!NetworkUtils.isIpv4Address(dns) && !IPv6NetworkUtils.isIpv6Address(dns)) { + throw new ApiMessageInterceptionException(argerr( + ORG_ZSTACK_COMPUTE_VM_10322, "invalid DNS address[%s], must be a valid IPv4 or IPv6 address", dns)); } } } @@ -570,58 +795,13 @@ private void validate(APIStartVmInstanceMsg msg) { } private void validateStaticIPv4(VmNicVO vmNicVO, L3NetworkVO l3NetworkVO, String ip) { - if (!NetworkUtils.isIpv4Address(ip)) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10129, "%s is not a valid IPv4 address", ip)); - } - - for (UsedIpVO ipVo : vmNicVO.getUsedIps()) { - if (ipVo.getIpVersion() != IPv6Constants.IPv4) { - continue; - } - - if (ipVo.getL3NetworkUuid().equals(l3NetworkVO.getUuid())) { - if (ipVo.getIp().equals(ip)) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10130, "ip address [%s] already set to vmNic [uuid:%s]", - ip, vmNicVO.getUuid())); - } - if (!l3NetworkVO.enableIpAddressAllocation()) { - continue; - } - // check if the ip is in the ip range when ipam is enabled - NormalIpRangeVO rangeVO = dbf.findByUuid(ipVo.getIpRangeUuid(), NormalIpRangeVO.class); - if (!NetworkUtils.isIpv4InCidr(ip, rangeVO.getNetworkCidr())) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10131, "ip address [%s] is not in ip range [%s]", - ip, rangeVO.getNetworkCidr())); - } - } - } + validateStaticIpCommon(vmNicVO, l3NetworkVO, ip, IPv6Constants.IPv4, + ORG_ZSTACK_COMPUTE_VM_10129, ORG_ZSTACK_COMPUTE_VM_10130); } private void validateStaticIPv6(VmNicVO vmNicVO, L3NetworkVO l3NetworkVO, String ip) { - if (!IPv6NetworkUtils.isIpv6Address(ip)) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10132, "%s is not a valid IPv6 address", ip)); - } - - for (UsedIpVO ipVo : vmNicVO.getUsedIps()) { - if (ipVo.getIpVersion() != IPv6Constants.IPv6) { - continue; - } - - if (ipVo.getL3NetworkUuid().equals(l3NetworkVO.getUuid())) { - if (ip.equals(ipVo.getIp())) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10133, "ip address [%s] already set to vmNic [uuid:%s]", - ip, vmNicVO.getUuid())); - } - if (!l3NetworkVO.enableIpAddressAllocation()) { - continue; - } - NormalIpRangeVO rangeVO = dbf.findByUuid(ipVo.getIpRangeUuid(), NormalIpRangeVO.class); - if (!IPv6NetworkUtils.isIpv6InRange(ip, rangeVO.getStartIp(), rangeVO.getEndIp())) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10134, "ip address [%s] is not in ip range [startIp %s, endIp %s]", - ip, rangeVO.getStartIp(), rangeVO.getEndIp())); - } - } - } + validateStaticIpCommon(vmNicVO, l3NetworkVO, ip, IPv6Constants.IPv6, + ORG_ZSTACK_COMPUTE_VM_10132, ORG_ZSTACK_COMPUTE_VM_10133); } private void validate(APISetVmStaticIpMsg msg) { @@ -639,15 +819,39 @@ private void validate(APISetVmStaticIpMsg msg) { .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6).list(); List vmNics = Q.New(VmNicVO.class).eq(VmNicVO_.vmInstanceUuid, msg.getVmInstanceUuid()).list(); boolean l3Found = false; + + // Normalize IP addresses (avoid redundant calls) + String normalizedIp = null; + String normalizedIp6 = null; + UsedIpVO existingIpv4 = null; + UsedIpVO existingIpv6 = null; + for (VmNicVO nic : vmNics) { - l3Found = true; + if (msg.getL3NetworkUuid().equals(nic.getL3NetworkUuid())) { + l3Found = true; + } + + // Extract UsedIpVO records matching the same L3 and IP version from the NIC's existing IPs + for (UsedIpVO usedIp : nic.getUsedIps()) { + if (!msg.getL3NetworkUuid().equals(usedIp.getL3NetworkUuid())) { + continue; + } + + if (usedIp.getIpVersion() != null && usedIp.getIpVersion() == IPv6Constants.IPv4) { + existingIpv4 = usedIp; + } else if (usedIp.getIpVersion() != null && usedIp.getIpVersion() == IPv6Constants.IPv6) { + existingIpv6 = usedIp; + } + } if (msg.getIp() != null) { String ip = IPv6NetworkUtils.ipv6TagValueToAddress(msg.getIp()); if (NetworkUtils.isIpv4Address(ip)) { validateStaticIPv4(nic, l3NetworkVO, ip); + normalizedIp = ip; } else if (IPv6NetworkUtils.isIpv6Address(ip)) { validateStaticIPv6(nic, l3NetworkVO, ip); msg.setIp(ip); + normalizedIp6 = ip; } else { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10136, "static ip [%s] format error", msg.getIp())); } @@ -656,52 +860,38 @@ private void validate(APISetVmStaticIpMsg msg) { String ip6 = IPv6NetworkUtils.ipv6TagValueToAddress(msg.getIp6()); validateStaticIPv6(nic, l3NetworkVO, ip6); msg.setIp6(ip6); + normalizedIp6 = ip6; } } - if (msg.getIp() != null && !l3NetworkVO.enableIpAddressAllocation()) { - l3Found = true; - if (msg.getNetmask() == null) { - if (ipv4Ranges.isEmpty()) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10137, "ipv4 address need a netmask")); - } else { - msg.setNetmask(ipv4Ranges.get(0).getNetmask()); - } - } - if (msg.getGateway() == null) { - if (ipv4Ranges.isEmpty()) { - msg.setGateway(""); - } else { - msg.setGateway(ipv4Ranges.get(0).getGateway()); - } - } - if (Q.New(UsedIpVO.class).eq(UsedIpVO_.ip, msg.getIp()).eq(UsedIpVO_.l3NetworkUuid, msg.getL3NetworkUuid()).isExists()) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10138, "ip address [%s] already set to vmNic", msg.getIp())); - } - } - if (msg.getIp6() != null && !l3NetworkVO.enableIpAddressAllocation()) { - l3Found = true; - if (msg.getIpv6Prefix() == null) { - if (ipv6Ranges.isEmpty()) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10139, "ipv6 address need a prefix")); - } else { - msg.setIpv6Prefix(ipv6Ranges.get(0).getPrefixLen().toString()); - } - } - if (msg.getIpv6Gateway() == null) { - if (ipv6Ranges.isEmpty()) { - msg.setIpv6Gateway(""); - } else { - msg.setIpv6Gateway(ipv6Ranges.get(0).getGateway()); - } - } - if (Q.New(UsedIpVO.class).eq(UsedIpVO_.ip, msg.getIp6()).eq(UsedIpVO_.l3NetworkUuid, msg.getL3NetworkUuid()).isExists()) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10140, "ip address [%s] already set to vmNic", msg.getIp6())); - } - } + if (!l3Found) { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10141, "the VM[uuid:%s] has no nic on the L3 network[uuid:%s]", msg.getVmInstanceUuid(), - msg.getL3NetworkUuid())); + msg.getL3NetworkUuid())); + } + + // Get the VM's default L3 network UUID for gateway enforcement + String defaultL3NetworkUuid = Q.New(VmInstanceVO.class) + .select(VmInstanceVO_.defaultL3NetworkUuid) + .eq(VmInstanceVO_.uuid, msg.getVmInstanceUuid()) + .findValue(); + + // Fill parameters and check IP occupation + if (normalizedIp != null) { + String[] ipv4Result = resolveIpv4NetmaskAndGateway(normalizedIp, msg.getNetmask(), msg.getGateway(), + ipv4Ranges, msg.getL3NetworkUuid(), defaultL3NetworkUuid, vmNics.size(), existingIpv4); + msg.setNetmask(ipv4Result[0]); + msg.setGateway(ipv4Result[1]); + checkIpOccupied(normalizedIp, msg.getL3NetworkUuid()); + } + if (normalizedIp6 != null) { + String[] ipv6Result = resolveIpv6PrefixAndGateway(normalizedIp6, msg.getIpv6Prefix(), msg.getIpv6Gateway(), + ipv6Ranges, msg.getL3NetworkUuid(), defaultL3NetworkUuid, vmNics.size(), existingIpv6); + msg.setIpv6Prefix(ipv6Result[0]); + msg.setIpv6Gateway(ipv6Result[1]); + checkIpOccupied(normalizedIp6, msg.getL3NetworkUuid()); } + + validateDnsAddresses(msg.getDnsAddresses()); } private void validate(APIDeleteVmStaticIpMsg msg) { @@ -825,26 +1015,6 @@ private void validate(APICreateVmNicMsg msg) { } if (msg.getIp() != null) { - SimpleQuery iprq = dbf.createQuery(NormalIpRangeVO.class); - iprq.add(NormalIpRangeVO_.l3NetworkUuid, Op.EQ, msg.getL3NetworkUuid()); - List iprs = iprq.list(); - - boolean found = false; - for (NormalIpRangeVO ipr : iprs) { - if (NetworkUtils.isInRange(msg.getIp(), ipr.getStartIp(), ipr.getEndIp())) { - found = true; - break; - } - } - - if (!l3VO.enableIpAddressAllocation()) { - found = true; - } - - if (!found) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10154, "the static IP[%s] is not in any IP range of the L3 network[uuid:%s]", msg.getIp(), msg.getL3NetworkUuid())); - } - SimpleQuery uq = dbf.createQuery(UsedIpVO.class); uq.add(UsedIpVO_.l3NetworkUuid, Op.EQ, msg.getL3NetworkUuid()); uq.add(UsedIpVO_.ip, Op.EQ, msg.getIp()); @@ -936,29 +1106,6 @@ private void validate(APIAttachL3NetworkToVmMsg msg) { .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); if (msg.getStaticIp() != null) { staticIps.computeIfAbsent(msg.getL3NetworkUuid(), k -> new ArrayList<>()).add(msg.getStaticIp()); - SimpleQuery iprq = dbf.createQuery(NormalIpRangeVO.class); - iprq.add(NormalIpRangeVO_.l3NetworkUuid, Op.EQ, msg.getL3NetworkUuid()); - List iprs = iprq.list(); - - boolean found = false; - for (NormalIpRangeVO ipr : iprs) { - if (!ipr.getIpVersion().equals(NetworkUtils.getIpversion(msg.getStaticIp()))) { - continue; - } - - if (NetworkUtils.isInRange(msg.getStaticIp(), ipr.getStartIp(), ipr.getEndIp())) { - found = true; - break; - } - } - - if (!l3NetworkVO.enableIpAddressAllocation()) { - found = true; - } - - if (!found) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10164, "the static IP[%s] is not in any IP range of the L3 network[uuid:%s]", msg.getStaticIp(), msg.getL3NetworkUuid())); - } SimpleQuery uq = dbf.createQuery(UsedIpVO.class); uq.add(UsedIpVO_.l3NetworkUuid, Op.EQ, msg.getL3NetworkUuid()); @@ -975,33 +1122,8 @@ private void validate(APIAttachL3NetworkToVmMsg msg) { String l3Uuid = e.getKey(); List ips = e.getValue(); - SimpleQuery iprq = dbf.createQuery(NormalIpRangeVO.class); - iprq.add(NormalIpRangeVO_.l3NetworkUuid, Op.EQ, l3Uuid); - List iprs = iprq.list(); - boolean found = false; for (String staticIp : ips) { - int ipVersion = IPv6Constants.IPv4; - if (IPv6NetworkUtils.isIpv6Address(staticIp)) { - ipVersion = IPv6Constants.IPv6; - } - for (NormalIpRangeVO ipr : iprs) { - if (ipVersion != ipr.getIpVersion()) { - continue; - } - if (NetworkUtils.isInRange(staticIp, ipr.getStartIp(), ipr.getEndIp())) { - found = true; - break; - } - } - - if (!l3NetworkVO.enableIpAddressAllocation()) { - found = true; - } - - if (!found) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10167, "the static IP[%s] is not in any IP range of the L3 network[uuid:%s]", staticIp, l3Uuid)); - } SimpleQuery uq = dbf.createQuery(UsedIpVO.class); uq.add(UsedIpVO_.l3NetworkUuid, Op.EQ, msg.getL3NetworkUuid()); @@ -1424,7 +1546,7 @@ private void validate(NewVmInstanceMessage2 msg) { throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10206, "l3Network[uuid:%s] is Disabled, can not create vm on it", l3Uuid)); } if (system && (msg.getType() == null || VmInstanceConstant.USER_VM_TYPE.equals(msg.getType()))) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10207, "l3Network[uuid:%s] is system network, can not create user vm on it", l3Uuid)); + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10207, "l3Network[uuid:%s] is system network, can not create user vm on it", l3Uuid)); } } } diff --git a/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java b/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java index 73f3e98b0df..58ff4e7b8d9 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java @@ -1046,7 +1046,46 @@ public String getName() { }); } - private void changeVmIp(final String l3Uuid, final Map staticIpMap, final Completion completion) { + static class IpOverrideInfo { + private String netmask; + private String gateway; + private String ipv6Gateway; + private String ipv6Prefix; + + public String getNetmask() { + return netmask; + } + + public void setNetmask(String netmask) { + this.netmask = netmask; + } + + public String getGateway() { + return gateway; + } + + public void setGateway(String gateway) { + this.gateway = gateway; + } + + public String getIpv6Gateway() { + return ipv6Gateway; + } + + public void setIpv6Gateway(String ipv6Gateway) { + this.ipv6Gateway = ipv6Gateway; + } + + public String getIpv6Prefix() { + return ipv6Prefix; + } + + public void setIpv6Prefix(String ipv6Prefix) { + this.ipv6Prefix = ipv6Prefix; + } + } + + private void changeVmIp(final String l3Uuid, final Map staticIpMap, final IpOverrideInfo overrideInfo, final Completion completion) { final VmNicVO targetNic = CollectionUtils.find(self.getVmNics(), new Function() { @Override public VmNicVO call(VmNicVO arg) { @@ -1105,6 +1144,15 @@ public void run(final FlowTrigger trigger, Map data) { amsg.setL3NetworkUuid(l3Uuid); amsg.setRequiredIp(entry.getValue()); amsg.setIpVersion(entry.getKey()); + if (overrideInfo != null) { + if (entry.getKey() == IPv6Constants.IPv4) { + amsg.setNetmask(overrideInfo.getNetmask()); + amsg.setGateway(overrideInfo.getGateway()); + } else if (entry.getKey() == IPv6Constants.IPv6) { + amsg.setIpv6Gateway(overrideInfo.getIpv6Gateway()); + amsg.setIpv6Prefix(overrideInfo.getIpv6Prefix()); + } + } bus.makeTargetServiceIdByResourceUuid(amsg, L3NetworkConstant.SERVICE_ID, l3Uuid); bus.send(amsg, new CloudBusCallBack(trigger) { @Override @@ -3481,6 +3529,7 @@ private void handle(final APISetVmStaticIpMsg msg) { cmsg.setNetmask(msg.getNetmask()); cmsg.setIpv6Gateway(msg.getIpv6Gateway()); cmsg.setIpv6Prefix(msg.getIpv6Prefix()); + cmsg.setDnsAddresses(msg.getDnsAddresses()); bus.makeTargetServiceIdByResourceUuid(cmsg, VmInstanceConstant.SERVICE_ID, cmsg.getVmInstanceUuid()); bus.send(cmsg, new CloudBusCallBack(msg) { @Override @@ -3507,7 +3556,17 @@ public String getSyncSignature() { public void run(final SyncTaskChain chain) { L3NetworkVO l3NetworkVO = Q.New(L3NetworkVO.class).eq(L3NetworkVO_.uuid, msg.getL3NetworkUuid()).find(); - if (!l3NetworkVO.enableIpAddressAllocation()) { + + List staticIpList = new ArrayList<>(); + if (msg.getIp() != null) { + staticIpList.add(msg.getIp()); + } + if (msg.getIp6() != null) { + staticIpList.add(msg.getIp6()); + } + + if (!l3NetworkVO.enableIpAddressAllocation() + || allStaticIpsOutsideRange(msg.getL3NetworkUuid(), staticIpList)) { setNoIpamStaticIp(msg, new Completion(reply) { @Override public void success() { @@ -3652,6 +3711,11 @@ public void run(FlowTrigger trigger, Map data) { done(new FlowDoneHandler(completion) { @Override public void handle(Map data) { + // Set DNS addresses if provided + if (msg.getDnsAddresses() != null) { + new StaticIpOperator().setStaticDns(self.getUuid(), msg.getL3NetworkUuid(), msg.getDnsAddresses()); + } + completion.success(); } }); @@ -3680,7 +3744,13 @@ private void setIpamStaticIp(final SetVmStaticIpMsg msg, final Completion comple staticIpMap.put(IPv6Constants.IPv6, msg.getIp6()); } - changeVmIp(msg.getL3NetworkUuid(), staticIpMap, new Completion(msg, completion) { + IpOverrideInfo overrideInfo = new IpOverrideInfo(); + overrideInfo.setNetmask(msg.getNetmask()); + overrideInfo.setGateway(msg.getGateway()); + overrideInfo.setIpv6Gateway(msg.getIpv6Gateway()); + overrideInfo.setIpv6Prefix(msg.getIpv6Prefix()); + + changeVmIp(msg.getL3NetworkUuid(), staticIpMap, overrideInfo, new Completion(msg, completion) { @Override public void success() { if (msg.getIp() != null) { @@ -3690,6 +3760,10 @@ public void success() { new StaticIpOperator().setStaticIp(self.getUuid(), msg.getL3NetworkUuid(), msg.getIp6()); } new StaticIpOperator().setIpChange(self.getUuid(), msg.getL3NetworkUuid()); + // Set DNS addresses if provided + if (msg.getDnsAddresses() != null) { + new StaticIpOperator().setStaticDns(self.getUuid(), msg.getL3NetworkUuid(), msg.getDnsAddresses()); + } completion.success(); } @@ -5437,6 +5511,7 @@ public void handle(Map data) { private void removeStaticIp() { for (UsedIpInventory ip : nic.getUsedIps()) { new StaticIpOperator().deleteStaticIpByVmUuidAndL3Uuid(self.getUuid(), ip.getL3NetworkUuid()); + new StaticIpOperator().deleteStaticDnsByVmUuidAndL3Uuid(self.getUuid(), ip.getL3NetworkUuid()); } } @@ -6195,6 +6270,7 @@ private void handle(ChangeVmNicNetworkMsg msg) { public void success(VmNicInventory returnValue) { String originalL3Uuid = nic.getL3NetworkUuid(); new StaticIpOperator().deleteStaticIpByVmUuidAndL3Uuid(self.getUuid(), originalL3Uuid); + new StaticIpOperator().deleteStaticDnsByVmUuidAndL3Uuid(self.getUuid(), originalL3Uuid); reply.setInventory(returnValue); bus.reply(msg, reply); } @@ -6225,6 +6301,7 @@ private void handle(APIChangeVmNicNetworkMsg msg) { cmsg.setVmInstanceUuid(msg.getVmInstanceUuid()); cmsg.setRequiredIpMap(msg.getRequiredIpMap()); cmsg.setSystemTags(msg.getSystemTags()); + cmsg.setDnsAddresses(msg.getDnsAddresses()); bus.makeTargetServiceIdByResourceUuid(cmsg, VmInstanceConstant.SERVICE_ID, cmsg.getVmInstanceUuid()); bus.send(cmsg, new CloudBusCallBack(msg) { @Override @@ -6240,6 +6317,20 @@ public void run(MessageReply reply) { }); } + private boolean allStaticIpsOutsideRange(String l3Uuid, List ips) { + if (ips == null || ips.isEmpty()) { + return false; + } + + for (String ip : ips) { + if (new StaticIpOperator().getIpRangeUuid(l3Uuid, ip) != null) { + return false; + } + } + + return true; + } + private void changeVmNicNetwork(ChangeVmNicNetworkMsg msg, VmNicInventory nic, L3NetworkInventory destL3, final ReturnValueCompletion completion) { thdf.chainSubmit(new ChainTask(completion) { @Override @@ -6252,6 +6343,7 @@ public String getSyncSignature() { public void run(final SyncTaskChain chain) { class SetStaticIp { private boolean isSet = false; + private boolean isDnsSet = false; Map> staticIpMap = null; void set() { @@ -6272,17 +6364,28 @@ void set() { isSet = true; } + void setDns() { + if (msg.getDnsAddresses() != null) { + new StaticIpOperator().setStaticDns(self.getUuid(), msg.getDestL3NetworkUuid(), msg.getDnsAddresses()); + isDnsSet = true; + } + } + void rollback() { if (isSet) { for (Map.Entry> e : staticIpMap.entrySet()) { new StaticIpOperator().deleteStaticIpByVmUuidAndL3Uuid(self.getUuid(), e.getKey()); } } + if (isDnsSet) { + new StaticIpOperator().deleteStaticDnsByVmUuidAndL3Uuid(self.getUuid(), msg.getDestL3NetworkUuid()); + } } } final SetStaticIp setStaticIp = new SetStaticIp(); setStaticIp.set(); + setStaticIp.setDns(); Defer.guard(new Runnable() { @Override public void run() { @@ -6316,11 +6419,23 @@ public void run(FlowTrigger trigger, Map data) { @Override public void run(FlowTrigger trigger, Map data) { - if (!destL3.enableIpAddressAllocation()) { + if (!destL3.enableIpAddressAllocation() + || allStaticIpsOutsideRange(destL3.getUuid(), + msg.getRequiredIpMap() != null ? msg.getRequiredIpMap().get(destL3.getUuid()) : null)) { trigger.next(); return; } - allocateIp(destL3, nic, new ReturnValueCompletion>(chain) { + IpOverrideInfo nicOverrideInfo = null; + Map nicNetworkInfo = new StaticIpOperator().getNicNetworkInfoBySystemTag(msg.getSystemTags()); + NicIpAddressInfo nicIpInfo = nicNetworkInfo.get(msg.getDestL3NetworkUuid()); + if (nicIpInfo != null) { + nicOverrideInfo = new IpOverrideInfo(); + nicOverrideInfo.setNetmask(nicIpInfo.ipv4Netmask); + nicOverrideInfo.setGateway(nicIpInfo.ipv4Gateway); + nicOverrideInfo.setIpv6Gateway(nicIpInfo.ipv6Gateway); + nicOverrideInfo.setIpv6Prefix(nicIpInfo.ipv6Prefix); + } + allocateIp(destL3, nic, nicOverrideInfo, new ReturnValueCompletion>(chain) { @Override public void success(List returnValue) { data.put(VmInstanceConstant.Params.VmAllocateNicFlow_ips.toString(), returnValue); @@ -6370,7 +6485,9 @@ public void rollback(FlowRollback trigger, Map data) { @Override public void run(FlowTrigger trigger, Map data) { - if (destL3.enableIpAddressAllocation()) { + if (destL3.enableIpAddressAllocation() + && !allStaticIpsOutsideRange(destL3.getUuid(), + msg.getRequiredIpMap() != null ? msg.getRequiredIpMap().get(destL3.getUuid()) : null)) { trigger.next(); return; } @@ -6445,7 +6562,9 @@ public void run(FlowTrigger trigger, Map data) { @Override public void run(FlowTrigger trigger, Map data) { - if (!destL3.enableIpAddressAllocation()) { + if (!destL3.enableIpAddressAllocation() + || allStaticIpsOutsideRange(destL3.getUuid(), + msg.getRequiredIpMap() != null ? msg.getRequiredIpMap().get(destL3.getUuid()) : null)) { trigger.next(); return; } @@ -6617,7 +6736,7 @@ public String getName() { }); } - private void allocateIp(L3NetworkInventory l3, VmNicInventory nic,final ReturnValueCompletion> completion) { + private void allocateIp(L3NetworkInventory l3, VmNicInventory nic, final IpOverrideInfo overrideInfo, final ReturnValueCompletion> completion) { L3NetworkInventory nw = l3; Map> vmStaticIps = new StaticIpOperator().getStaticIpbyVmUuid(getSelf().getUuid()); List ipVersions = nw.getIpVersions(); @@ -6641,6 +6760,15 @@ private void allocateIp(L3NetworkInventory l3, VmNicInventory nic,final ReturnVa } } msg.setIpVersion(ipversion); + if (overrideInfo != null) { + if (ipversion == IPv6Constants.IPv4) { + msg.setNetmask(overrideInfo.getNetmask()); + msg.setGateway(overrideInfo.getGateway()); + } else if (ipversion == IPv6Constants.IPv6) { + msg.setIpv6Gateway(overrideInfo.getIpv6Gateway()); + msg.setIpv6Prefix(overrideInfo.getIpv6Prefix()); + } + } bus.makeTargetServiceIdByResourceUuid(msg, L3NetworkConstant.SERVICE_ID, nw.getUuid()); msgs.add(msg); } diff --git a/compute/src/main/java/org/zstack/compute/vm/VmSystemTags.java b/compute/src/main/java/org/zstack/compute/vm/VmSystemTags.java index 713c64890ee..df0c9fdd9e8 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmSystemTags.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmSystemTags.java @@ -311,6 +311,11 @@ public String desensitizeTag(SystemTag systemTag, String tag) { } } + // DNS servers for VM NIC, format: staticDns::{l3NetworkUuid}::{dns1,dns2,dns3} + public static String STATIC_DNS_L3_UUID_TOKEN = "l3NetworkUuid"; + public static String STATIC_DNS_TOKEN = "staticDns"; + public static PatternedSystemTag STATIC_DNS = new PatternedSystemTag(String.format("staticDns::{%s}::{%s}", STATIC_DNS_L3_UUID_TOKEN, STATIC_DNS_TOKEN), VmInstanceVO.class); + public static PatternedSystemTag VM_STATE_PAUSED_AFTER_MIGRATE = new PatternedSystemTag(("vmPausedAfterMigrate"), VmInstanceVO.class); public static PatternedSystemTag VM_MEMORY_ACCESS_MODE_SHARED = new PatternedSystemTag(("vmMemoryAccessModeShared"), VmInstanceVO.class); diff --git a/conf/db/upgrade/V5.5.12__schema.sql b/conf/db/upgrade/V5.5.12__schema.sql index 8d29c9f95ec..6c6168445f8 100644 --- a/conf/db/upgrade/V5.5.12__schema.sql +++ b/conf/db/upgrade/V5.5.12__schema.sql @@ -125,3 +125,56 @@ ALTER TABLE `zstack`.`BareMetal2InstanceVO` -- ZSTAC-68709: Add targetQueueKey for evaluation task queuing per service endpoint CALL ADD_COLUMN('ModelEvaluationTaskVO', 'targetQueueKey', 'TEXT', 1, NULL); + +-- Add prefixLen column to UsedIpVO for IPv6 addresses outside IP range +CALL ADD_COLUMN('UsedIpVO', 'prefixLen', 'INT', 1, NULL); + +-- Backfill prefixLen from IpRangeVO for existing IPv6 UsedIpVO records +UPDATE `zstack`.`UsedIpVO` `u` +INNER JOIN `zstack`.`IpRangeVO` `r` ON `u`.`ipRangeUuid` = `r`.`uuid` +SET `u`.`prefixLen` = `r`.`prefixLen` +WHERE `u`.`ipVersion` = 6 + AND `u`.`ipRangeUuid` IS NOT NULL + AND `u`.`prefixLen` IS NULL; + +-- Modify ipRangeUuid foreign key constraint to SET NULL on delete (instead of CASCADE) +DROP PROCEDURE IF EXISTS ModifyUsedIpVOForeignKey; +DELIMITER $$ + +CREATE PROCEDURE ModifyUsedIpVOForeignKey() +BEGIN + DECLARE constraint_exists INT; + + -- Check if the constraint exists before dropping it + SELECT COUNT(*) + INTO constraint_exists + FROM `INFORMATION_SCHEMA`.`TABLE_CONSTRAINTS` + WHERE `TABLE_SCHEMA` = 'zstack' + AND `TABLE_NAME` = 'UsedIpVO' + AND `CONSTRAINT_NAME` = 'fkUsedIpVOIpRangeEO' + AND `CONSTRAINT_TYPE` = 'FOREIGN KEY'; + + IF constraint_exists > 0 THEN + ALTER TABLE `zstack`.`UsedIpVO` DROP FOREIGN KEY `fkUsedIpVOIpRangeEO`; + END IF; + + -- Re-check before adding so the migration stays idempotent + SELECT COUNT(*) + INTO constraint_exists + FROM `INFORMATION_SCHEMA`.`TABLE_CONSTRAINTS` + WHERE `TABLE_SCHEMA` = 'zstack' + AND `TABLE_NAME` = 'UsedIpVO' + AND `CONSTRAINT_NAME` = 'fkUsedIpVOIpRangeEO' + AND `CONSTRAINT_TYPE` = 'FOREIGN KEY'; + + IF constraint_exists = 0 THEN + ALTER TABLE `zstack`.`UsedIpVO` + ADD CONSTRAINT `fkUsedIpVOIpRangeEO` + FOREIGN KEY (`ipRangeUuid`) REFERENCES `zstack`.`IpRangeEO`(`uuid`) ON DELETE SET NULL; + END IF; +END $$ + +DELIMITER ; + +CALL ModifyUsedIpVOForeignKey(); +DROP PROCEDURE IF EXISTS ModifyUsedIpVOForeignKey; \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/network/l3/AllocateIpMsg.java b/header/src/main/java/org/zstack/header/network/l3/AllocateIpMsg.java index 849ebe4c96c..78af601bea6 100755 --- a/header/src/main/java/org/zstack/header/network/l3/AllocateIpMsg.java +++ b/header/src/main/java/org/zstack/header/network/l3/AllocateIpMsg.java @@ -12,6 +12,10 @@ public class AllocateIpMsg extends NeedReplyMessage implements L3NetworkMessage, private String ipRangeUuid; private String ipRangeType; private int ipVersion = IPv6Constants.IPv4; + private String netmask; + private String gateway; + private String ipv6Gateway; + private String ipv6Prefix; public String getRequiredIp() { return requiredIp; @@ -74,4 +78,40 @@ public int getIpVersion() { public void setIpVersion(int ipVersion) { this.ipVersion = ipVersion; } + + @Override + public String getNetmask() { + return netmask; + } + + public void setNetmask(String netmask) { + this.netmask = netmask; + } + + @Override + public String getGateway() { + return gateway; + } + + public void setGateway(String gateway) { + this.gateway = gateway; + } + + @Override + public String getIpv6Gateway() { + return ipv6Gateway; + } + + public void setIpv6Gateway(String ipv6Gateway) { + this.ipv6Gateway = ipv6Gateway; + } + + @Override + public String getIpv6Prefix() { + return ipv6Prefix; + } + + public void setIpv6Prefix(String ipv6Prefix) { + this.ipv6Prefix = ipv6Prefix; + } } diff --git a/header/src/main/java/org/zstack/header/network/l3/IpAllocateMessage.java b/header/src/main/java/org/zstack/header/network/l3/IpAllocateMessage.java index 4bb49d9fc8f..0d886bab7b5 100755 --- a/header/src/main/java/org/zstack/header/network/l3/IpAllocateMessage.java +++ b/header/src/main/java/org/zstack/header/network/l3/IpAllocateMessage.java @@ -15,6 +15,22 @@ default String getExcludedIp() { default boolean isDuplicatedIpAllowed() {return false;} + default String getNetmask() { + return null; + } + + default String getGateway() { + return null; + } + + default String getIpv6Gateway() { + return null; + } + + default String getIpv6Prefix() { + return null; + } + void setIpRangeUuid(String ipRangeUuid); void setRequiredIp(String requiredIp); diff --git a/header/src/main/java/org/zstack/header/network/l3/UsedIpInventory.java b/header/src/main/java/org/zstack/header/network/l3/UsedIpInventory.java index 775dc66d9c5..c9f406ae985 100755 --- a/header/src/main/java/org/zstack/header/network/l3/UsedIpInventory.java +++ b/header/src/main/java/org/zstack/header/network/l3/UsedIpInventory.java @@ -30,6 +30,7 @@ public class UsedIpInventory implements Serializable { private Integer ipVersion; private String ip; private String netmask; + private Integer prefixLen; private String gateway; private String usedFor; @APINoSee @@ -52,6 +53,7 @@ public static UsedIpInventory valueOf(UsedIpVO vo) { inv.setL3NetworkUuid(vo.getL3NetworkUuid()); inv.setGateway(vo.getGateway()); inv.setNetmask(vo.getNetmask()); + inv.setPrefixLen(vo.getPrefixLen()); inv.setUsedFor(vo.getUsedFor()); inv.setVmNicUuid(vo.getVmNicUuid()); inv.setMetaData(vo.getMetaData()); @@ -139,6 +141,14 @@ public void setNetmask(String netmask) { this.netmask = netmask; } + public Integer getPrefixLen() { + return prefixLen; + } + + public void setPrefixLen(Integer prefixLen) { + this.prefixLen = prefixLen; + } + public String getGateway() { return gateway; } diff --git a/header/src/main/java/org/zstack/header/network/l3/UsedIpVO.java b/header/src/main/java/org/zstack/header/network/l3/UsedIpVO.java index c35346301ff..c4f4376fa3b 100755 --- a/header/src/main/java/org/zstack/header/network/l3/UsedIpVO.java +++ b/header/src/main/java/org/zstack/header/network/l3/UsedIpVO.java @@ -24,7 +24,7 @@ public class UsedIpVO { private String uuid; @Column - @ForeignKey(parentEntityClass = IpRangeEO.class, onDeleteAction = ReferenceOption.CASCADE) + @ForeignKey(parentEntityClass = IpRangeEO.class, onDeleteAction = ReferenceOption.SET_NULL) private String ipRangeUuid; @Column @@ -48,6 +48,9 @@ public class UsedIpVO { @Column private String netmask; + @Column + private Integer prefixLen; + @Column @Index private long ipInLong; @@ -147,6 +150,14 @@ public void setNetmask(String netmask) { this.netmask = netmask; } + public Integer getPrefixLen() { + return prefixLen; + } + + public void setPrefixLen(Integer prefixLen) { + this.prefixLen = prefixLen; + } + public String getUsedFor() { return usedFor; } diff --git a/header/src/main/java/org/zstack/header/network/l3/UsedIpVO_.java b/header/src/main/java/org/zstack/header/network/l3/UsedIpVO_.java index 4186a4a6d54..6625e62b12e 100755 --- a/header/src/main/java/org/zstack/header/network/l3/UsedIpVO_.java +++ b/header/src/main/java/org/zstack/header/network/l3/UsedIpVO_.java @@ -16,6 +16,8 @@ public class UsedIpVO_ { public static volatile SingularAttribute ipInLong; public static volatile SingularAttribute vmNicUuid; public static volatile SingularAttribute gateway; + public static volatile SingularAttribute netmask; + public static volatile SingularAttribute prefixLen; public static volatile SingularAttribute createDate; public static volatile SingularAttribute lastOpDate; } diff --git a/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsg.java b/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsg.java index c00ab47e904..6762f85f793 100644 --- a/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsg.java @@ -2,12 +2,16 @@ import org.springframework.http.HttpMethod; import org.zstack.header.identity.Action; +import org.zstack.header.message.APIEvent; import org.zstack.header.message.APIMessage; import org.zstack.header.message.APIParam; import org.zstack.header.network.l3.L3NetworkVO; +import org.zstack.header.other.APIAuditor; +import org.zstack.header.other.APIMultiAuditor; import org.zstack.header.rest.APINoSee; import org.zstack.header.rest.RestRequest; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -18,7 +22,7 @@ method = HttpMethod.POST, responseClass = APIChangeVmNicNetworkEvent.class ) -public class APIChangeVmNicNetworkMsg extends APIMessage implements VmInstanceMessage{ +public class APIChangeVmNicNetworkMsg extends APIMessage implements VmInstanceMessage, APIMultiAuditor { @APIParam(resourceType = VmNicVO.class, checkAccount = true, operationTarget = true) private String vmNicUuid; @@ -33,6 +37,10 @@ public class APIChangeVmNicNetworkMsg extends APIMessage implements VmInstanceMe private String staticIp; + + @APIParam(required = false) + private List dnsAddresses; + public String getVmNicUuid() { return vmNicUuid; } @@ -57,6 +65,7 @@ public void setRequiredIpMap(Map> requiredIpMap) { this.requiredIpMap = requiredIpMap; } + public static APIChangeVmNicNetworkMsg __example__() { APIChangeVmNicNetworkMsg msg = new APIChangeVmNicNetworkMsg(); msg.vmNicUuid = uuid(); @@ -80,4 +89,20 @@ public String getStaticIp() { public void setStaticIp(String staticIp) { this.staticIp = staticIp; } + + public List getDnsAddresses() { + return dnsAddresses; + } + + public void setDnsAddresses(List dnsAddresses) { + this.dnsAddresses = dnsAddresses; + } + + @Override + public List multiAudit(APIMessage msg, APIEvent rsp) { + APIChangeVmNicNetworkMsg amsg = (APIChangeVmNicNetworkMsg) msg; + List res = new ArrayList<>(); + res.add(new APIAuditor.Result(amsg.getVmInstanceUuid(), VmInstanceVO.class)); + return res; + } } diff --git a/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsgDoc_zh_cn.groovy index 893596734c7..c02221bd20e 100644 --- a/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsgDoc_zh_cn.groovy +++ b/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsgDoc_zh_cn.groovy @@ -66,6 +66,15 @@ doc { optional true since "0.6" } + column { + name "dnsAddresses" + enclosedIn "params" + desc "DNS服务器地址列表" + location "body" + type "List" + optional true + since "5.5.6" + } } } diff --git a/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsg.java b/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsg.java index a4e0d71b209..80a022b5cfb 100755 --- a/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsg.java @@ -7,6 +7,8 @@ import org.zstack.header.network.l3.L3NetworkVO; import org.zstack.header.rest.RestRequest; +import java.util.List; + /** * Created by frank on 2/26/2016. */ @@ -34,6 +36,8 @@ public class APISetVmStaticIpMsg extends APIMessage implements VmInstanceMessage private String ipv6Gateway; @APIParam(required = false) private String ipv6Prefix; + @APIParam(required = false) + private List dnsAddresses; public String getIp() { return ip; @@ -100,6 +104,14 @@ public void setIpv6Prefix(String ipv6Prefix) { this.ipv6Prefix = ipv6Prefix; } + public List getDnsAddresses() { + return dnsAddresses; + } + + public void setDnsAddresses(List dnsAddresses) { + this.dnsAddresses = dnsAddresses; + } + public static APISetVmStaticIpMsg __example__() { APISetVmStaticIpMsg msg = new APISetVmStaticIpMsg(); msg.vmInstanceUuid = uuid(); diff --git a/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsgDoc_zh_cn.groovy index 9415e501788..20513324725 100644 --- a/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsgDoc_zh_cn.groovy +++ b/header/src/main/java/org/zstack/header/vm/APISetVmStaticIpMsgDoc_zh_cn.groovy @@ -114,6 +114,15 @@ doc { optional true since "0.6" } + column { + name "dnsAddresses" + enclosedIn "setVmStaticIp" + desc "DNS服务器地址列表" + location "body" + type "List" + optional true + since "5.5.6" + } } } diff --git a/header/src/main/java/org/zstack/header/vm/ChangeVmNicNetworkMsg.java b/header/src/main/java/org/zstack/header/vm/ChangeVmNicNetworkMsg.java index 1e7f7b1772f..54816bdfcde 100644 --- a/header/src/main/java/org/zstack/header/vm/ChangeVmNicNetworkMsg.java +++ b/header/src/main/java/org/zstack/header/vm/ChangeVmNicNetworkMsg.java @@ -14,6 +14,7 @@ public class ChangeVmNicNetworkMsg extends NeedReplyMessage implements VmInstanc private String vmInstanceUuid; private Map> requiredIpMap; private String staticIp; + private List dnsAddresses; public String getVmNicUuid() { return vmNicUuid; @@ -55,4 +56,13 @@ public String getStaticIp() { public void setStaticIp(String staticIp) { this.staticIp = staticIp; } + + + public List getDnsAddresses() { + return dnsAddresses; + } + + public void setDnsAddresses(List dnsAddresses) { + this.dnsAddresses = dnsAddresses; + } } diff --git a/header/src/main/java/org/zstack/header/vm/SetVmStaticIpMsg.java b/header/src/main/java/org/zstack/header/vm/SetVmStaticIpMsg.java index dd27c3c0c48..a1a117da8a9 100644 --- a/header/src/main/java/org/zstack/header/vm/SetVmStaticIpMsg.java +++ b/header/src/main/java/org/zstack/header/vm/SetVmStaticIpMsg.java @@ -2,6 +2,8 @@ import org.zstack.header.message.NeedReplyMessage; +import java.util.List; + /** * Created by LiangHanYu on 2022/6/22 17:12 */ @@ -14,6 +16,7 @@ public class SetVmStaticIpMsg extends NeedReplyMessage implements VmInstanceMess private String gateway; private String ipv6Gateway; private String ipv6Prefix; + private List dnsAddresses; @Override public String getVmInstanceUuid() { @@ -79,4 +82,12 @@ public String getIpv6Prefix() { public void setIpv6Prefix(String ipv6Prefix) { this.ipv6Prefix = ipv6Prefix; } + + public List getDnsAddresses() { + return dnsAddresses; + } + + public void setDnsAddresses(List dnsAddresses) { + this.dnsAddresses = dnsAddresses; + } } diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceConstant.java b/header/src/main/java/org/zstack/header/vm/VmInstanceConstant.java index 896e60e414e..fec2e4f1b51 100755 --- a/header/src/main/java/org/zstack/header/vm/VmInstanceConstant.java +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceConstant.java @@ -13,6 +13,7 @@ public interface VmInstanceConstant { // System limit int MAXIMUM_CDROM_NUMBER = 3; + int MAXIMUM_NIC_DNS_NUMBER = 3; String KVM_HYPERVISOR_TYPE = "KVM"; diff --git a/network/src/main/java/org/zstack/network/l3/IpRangeHelper.java b/network/src/main/java/org/zstack/network/l3/IpRangeHelper.java index fffbff32665..64cd7854716 100644 --- a/network/src/main/java/org/zstack/network/l3/IpRangeHelper.java +++ b/network/src/main/java/org/zstack/network/l3/IpRangeHelper.java @@ -264,6 +264,95 @@ public static boolean isIpAddressAllocationEnableOnL3(String l3Uuid) { return l3NetworkVO.enableIpAddressAllocation(); } + /** + * Check if an IP address is within any L3 network's CIDR (from NormalIpRange). + */ + public static boolean isIpInL3NetworkCidr(String ip, String l3Uuid) { + if (ip == null || l3Uuid == null) { + return false; + } + + if (IPv6NetworkUtils.isIpv6Address(ip)) { + List ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, l3Uuid) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6).list(); + for (NormalIpRangeVO ipr : ranges) { + String cidr = ipr.getNetworkCidr(); + if (cidr != null && IPv6NetworkUtils.isIpv6InCidrRange(ip, cidr)) { + return true; + } + } + } else if (NetworkUtils.isIpv4Address(ip)) { + List ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, l3Uuid) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv4).list(); + for (NormalIpRangeVO ipr : ranges) { + String cidr = ipr.getNetworkCidr(); + if (cidr != null && NetworkUtils.isIpv4InCidr(ip, cidr)) { + return true; + } + } + } + + return false; + } + + /** + * Check if an IP address is outside all L3 network CIDRs. + */ + public static boolean isIpOutsideL3NetworkCidr(String ip, String l3Uuid) { + return !isIpInL3NetworkCidr(ip, l3Uuid); + } + + /** + * Find a NormalIpRangeVO whose CIDR contains the given IP. + * First tries exact range match (startIp-endIp), then falls back to CIDR match. + */ + public static NormalIpRangeVO findIpRangeByCidr(String ip, List ranges) { + if (ip == null || ranges == null || ranges.isEmpty()) { + return null; + } + + boolean isIpv4 = NetworkUtils.isIpv4Address(ip); + boolean isIpv6 = IPv6NetworkUtils.isIpv6Address(ip); + int targetVersion = isIpv4 ? IPv6Constants.IPv4 : (isIpv6 ? IPv6Constants.IPv6 : -1); + if (targetVersion == -1) { + return null; + } + + // First try exact range match + for (NormalIpRangeVO ipr : ranges) { + if (ipr.getIpVersion() != targetVersion) { + continue; + } + if (isIpv4 && NetworkUtils.isInRange(ip, ipr.getStartIp(), ipr.getEndIp())) { + return ipr; + } + if (isIpv6 && IPv6NetworkUtils.isIpv6InRange(ip, ipr.getStartIp(), ipr.getEndIp())) { + return ipr; + } + } + + // Fallback to CIDR match + for (NormalIpRangeVO ipr : ranges) { + if (ipr.getIpVersion() != targetVersion) { + continue; + } + String cidr = ipr.getNetworkCidr(); + if (cidr == null) { + continue; + } + if (isIpv4 && NetworkUtils.isIpv4InCidr(ip, cidr)) { + return ipr; + } + if (isIpv6 && IPv6NetworkUtils.isIpv6InCidrRange(ip, cidr)) { + return ipr; + } + } + + return null; + } + public static IpRangeVO fromIpRangeInventory(IpRangeInventory ipr, String accountUuid) { NormalIpRangeVO vo = new NormalIpRangeVO(); vo.setUuid(ipr.getUuid() == null ? Platform.getUuid() : ipr.getUuid()); diff --git a/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java b/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java index b1b0b92d497..d7c6c6798d9 100755 --- a/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java +++ b/network/src/main/java/org/zstack/network/l3/L3BasicNetwork.java @@ -319,6 +319,7 @@ public void fail(ErrorCode errorCode) { @Override public void run(FlowTrigger trigger, Map data) { + SQL.New(UsedIpVO.class).eq(UsedIpVO_.ipRangeUuid, iprvo.getUuid()).delete(); dbf.remove(iprvo); IpRangeHelper.updateL3NetworkIpversion(iprvo); @@ -555,6 +556,8 @@ public void run(SyncTaskChain chain) { return; } + ip = overrideUsedIpIfNeeded(msg, ip); + logger.debug(String.format("Ip allocator strategy[%s] successfully allocates an ip[%s]", strategyType, ip.getIp())); reply.setIpInventory(ip); bus.reply(msg, reply); @@ -568,6 +571,53 @@ public String getName() { }); } + private UsedIpInventory overrideUsedIpIfNeeded(AllocateIpMsg msg, UsedIpInventory ip) { + String overrideNetmask = null; + String overrideGateway = null; + Integer prefixLength = null; + + if (ip.getIpVersion() != null && ip.getIpVersion() == IPv6Constants.IPv4) { + if (msg.getNetmask() != null) { + overrideNetmask = msg.getNetmask(); + } + if (msg.getGateway() != null) { + overrideGateway = msg.getGateway(); + } + } else if (ip.getIpVersion() != null && ip.getIpVersion() == IPv6Constants.IPv6) { + if (msg.getIpv6Prefix() != null) { + try { + prefixLength = Integer.parseInt(msg.getIpv6Prefix()); + overrideNetmask = IPv6NetworkUtils.getFormalNetmaskOfNetworkCidr(ip.getIp() + "/" + msg.getIpv6Prefix()); + } catch (NumberFormatException e) { + logger.warn(String.format("failed to parse prefix length[%s], ignore it and use the default prefix length of the ip range", + msg.getIpv6Prefix())); + } + } + if (msg.getIpv6Gateway() != null) { + overrideGateway = msg.getIpv6Gateway().isEmpty() ? "" : IPv6NetworkUtils.getIpv6AddressCanonicalString(msg.getIpv6Gateway()); + } + } + + if (overrideNetmask != null || overrideGateway != null) { + UsedIpVO vo = dbf.findByUuid(ip.getUuid(), UsedIpVO.class); + if (vo != null) { + if (overrideNetmask != null) { + vo.setNetmask(overrideNetmask); + } + if (overrideGateway != null) { + vo.setGateway(overrideGateway); + } + if (prefixLength != null) { + vo.setPrefixLen(prefixLength); + } + vo = dbf.updateAndRefresh(vo); + ip = UsedIpInventory.valueOf(vo); + } + } + + return ip; + } + private void handleApiMessage(APIMessage msg) { if (msg instanceof APIDeleteL3NetworkMsg) { handle((APIDeleteL3NetworkMsg) msg); @@ -767,10 +817,7 @@ private void handle(APIDeleteIpAddressMsg msg) { @Override public CheckIpAvailabilityReply checkIpAvailability(CheckIpAvailabilityMsg msg) { CheckIpAvailabilityReply reply = new CheckIpAvailabilityReply(); - int ipversion = IPv6Constants.IPv4; - if (IPv6NetworkUtils.isIpv6Address(msg.getIp())) { - ipversion = IPv6Constants.IPv6; - } + final int ipversion = IPv6NetworkUtils.isIpv6Address(msg.getIp()) ? IPv6Constants.IPv6 : IPv6Constants.IPv4; SimpleQuery rq = dbf.createQuery(IpRangeVO.class); rq.select(IpRangeVO_.startIp, IpRangeVO_.endIp, IpRangeVO_.gateway); rq.add(IpRangeVO_.l3NetworkUuid, Op.EQ, self.getUuid()); diff --git a/network/src/main/java/org/zstack/network/l3/L3NetworkApiInterceptor.java b/network/src/main/java/org/zstack/network/l3/L3NetworkApiInterceptor.java index d4dfa4dc8d6..ac88e09dbf8 100755 --- a/network/src/main/java/org/zstack/network/l3/L3NetworkApiInterceptor.java +++ b/network/src/main/java/org/zstack/network/l3/L3NetworkApiInterceptor.java @@ -726,6 +726,48 @@ private void validate(IpRangeInventory ipr) { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_L3_10064, "new add ip range gateway %s is different from old gateway %s", ipr.getGateway(), r.getGateway())); } } + + // When adding the first IpRange, check if network address or gateway is already used + if (l3IpRanges.isEmpty()) { + String networkAddress = info.getNetworkAddress(); + String broadcastAddress = info.getBroadcastAddress(); + + // Check if gateway address is already used by VmNic with ipRangeUuid=null + boolean gatewayUsed = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, ipr.getL3NetworkUuid()) + .eq(UsedIpVO_.ip, ipr.getGateway()) + .isNull(UsedIpVO_.ipRangeUuid) + .isExists(); + if (gatewayUsed) { + throw new ApiMessageInterceptionException(argerr( + ORG_ZSTACK_NETWORK_L3_10079, "gateway address[%s] is already used by a VM NIC, cannot add IP range with this gateway", + ipr.getGateway())); + } + + // Check if network address is already used + boolean networkAddressUsed = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, ipr.getL3NetworkUuid()) + .eq(UsedIpVO_.ip, networkAddress) + .isNull(UsedIpVO_.ipRangeUuid) + .isExists(); + if (networkAddressUsed) { + throw new ApiMessageInterceptionException(argerr( + ORG_ZSTACK_NETWORK_L3_10080, "network address[%s] is already used by a VM NIC, cannot add IP range containing this address", + networkAddress)); + } + + // Check if broadcast address is already used + boolean broadcastAddressUsed = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, ipr.getL3NetworkUuid()) + .eq(UsedIpVO_.ip, broadcastAddress) + .isNull(UsedIpVO_.ipRangeUuid) + .isExists(); + if (broadcastAddressUsed) { + throw new ApiMessageInterceptionException(argerr( + ORG_ZSTACK_NETWORK_L3_10081, "broadcast address[%s] is already used by a VM NIC, cannot add IP range containing this address", + broadcastAddress)); + } + } } else if (ipr.getIpRangeType() == IpRangeType.AddressPool) { validateAddressPool(ipr); } diff --git a/network/src/main/java/org/zstack/network/l3/L3NetworkManagerImpl.java b/network/src/main/java/org/zstack/network/l3/L3NetworkManagerImpl.java index 384a5d2c1df..81fb7e94b3f 100755 --- a/network/src/main/java/org/zstack/network/l3/L3NetworkManagerImpl.java +++ b/network/src/main/java/org/zstack/network/l3/L3NetworkManagerImpl.java @@ -383,7 +383,7 @@ public IpCapacity call() { ts = IpRangeHelper.stripNetworkAndBroadcastAddress(ts); calcElementTotalIp(ts, ret); - sql = "select count(distinct uip.ip), uip.l3NetworkUuid, uip.ipVersion from UsedIpVO uip where uip.l3NetworkUuid in (:uuids) and (uip.metaData not in (:notAccountMetaData) or uip.metaData IS NULL) group by uip.l3NetworkUuid, uip.ipVersion"; + sql = "select count(distinct uip.ip), uip.l3NetworkUuid, uip.ipVersion from UsedIpVO uip where uip.l3NetworkUuid in (:uuids) and uip.ipRangeUuid is not null and (uip.metaData not in (:notAccountMetaData) or uip.metaData IS NULL) group by uip.l3NetworkUuid, uip.ipVersion"; TypedQuery cq = dbf.getEntityManager().createQuery(sql, Tuple.class); cq.setParameter("uuids", msg.getL3NetworkUuids()); cq.setParameter("notAccountMetaData", notAccountMetaDatas); @@ -399,7 +399,7 @@ public IpCapacity call() { ts = IpRangeHelper.stripNetworkAndBroadcastAddress(ts); calcElementTotalIp(ts, ret); - sql = "select count(distinct uip.ip), zone.uuid, uip.ipVersion from UsedIpVO uip, L3NetworkVO l3, ZoneVO zone where uip.l3NetworkUuid = l3.uuid and l3.zoneUuid = zone.uuid and zone.uuid in (:uuids) and (uip.metaData not in (:notAccountMetaData) or uip.metaData IS NULL) group by zone.uuid, uip.ipVersion"; + sql = "select count(distinct uip.ip), zone.uuid, uip.ipVersion from UsedIpVO uip, L3NetworkVO l3, ZoneVO zone where uip.l3NetworkUuid = l3.uuid and l3.zoneUuid = zone.uuid and zone.uuid in (:uuids) and uip.ipRangeUuid is not null and (uip.metaData not in (:notAccountMetaData) or uip.metaData IS NULL) group by zone.uuid, uip.ipVersion"; TypedQuery cq = dbf.getEntityManager().createQuery(sql, Tuple.class); cq.setParameter("uuids", msg.getZoneUuids()); cq.setParameter("notAccountMetaData", notAccountMetaDatas); @@ -723,6 +723,7 @@ private UsedIpInventory reserveIpv6(IpRangeVO ipRange, String ip, boolean allowD vo.setL3NetworkUuid(ipRange.getL3NetworkUuid()); vo.setNetmask(ipRange.getNetmask()); vo.setGateway(ipRange.getGateway()); + vo.setPrefixLen(ipRange.getPrefixLen()); vo.setIpVersion(IPv6Constants.IPv6); vo = dbf.persistAndRefresh(vo); return UsedIpInventory.valueOf(vo); diff --git a/network/src/main/java/org/zstack/network/l3/L3NetworkSystemTags.java b/network/src/main/java/org/zstack/network/l3/L3NetworkSystemTags.java index f6712c54053..c0403be58ed 100644 --- a/network/src/main/java/org/zstack/network/l3/L3NetworkSystemTags.java +++ b/network/src/main/java/org/zstack/network/l3/L3NetworkSystemTags.java @@ -1,6 +1,5 @@ package org.zstack.network.l3; -import org.zstack.header.network.l2.L2NetworkVO; import org.zstack.header.network.l3.L3NetworkVO; import org.zstack.header.tag.TagDefinition; import org.zstack.tag.PatternedSystemTag; diff --git a/network/src/main/java/org/zstack/network/l3/NormalIpRangeFactory.java b/network/src/main/java/org/zstack/network/l3/NormalIpRangeFactory.java index 6b7d70d45eb..c052d0a1d69 100644 --- a/network/src/main/java/org/zstack/network/l3/NormalIpRangeFactory.java +++ b/network/src/main/java/org/zstack/network/l3/NormalIpRangeFactory.java @@ -70,9 +70,11 @@ protected NormalIpRangeVO scripts() { IpRangeHelper.updateL3NetworkIpversion(vo); + // Update UsedIpVO records that have ipRangeUuid=null and IP is within the new range List usedIpVos = Q.New(UsedIpVO.class) .eq(UsedIpVO_.l3NetworkUuid, vo.getL3NetworkUuid()) - .eq(UsedIpVO_.ipVersion, vo.getIpVersion()).list(); + .eq(UsedIpVO_.ipVersion, vo.getIpVersion()) + .isNull(UsedIpVO_.ipRangeUuid).list(); List updateVos = new ArrayList<>(); for (UsedIpVO ipvo : usedIpVos) { if (ipvo.getIpVersion() == IPv6Constants.IPv4) { diff --git a/network/src/main/java/org/zstack/network/l3/zstack ipam.md b/network/src/main/java/org/zstack/network/l3/zstack ipam.md new file mode 100644 index 00000000000..63951964dbd --- /dev/null +++ b/network/src/main/java/org/zstack/network/l3/zstack ipam.md @@ -0,0 +1,139 @@ +# ZStack IPAM + +ZStack IPAM 负责管理 L3 网络的 IP 地址分配和回收。它提供三种方式: +1. **自动分配**: ZStack云平台根据L3配置的ip range自动分配。 +2. **手动分配**: 用户可以在创建虚拟机时指定IP地址。 +3. **qga获取**: 通过DHCP服务器动态分配IP地址。 + +## 自动分配 + +自动分配需要满足两个条件: +1. L3网络必须配置ip range。 +2. L3网络必须enable dhcp服务。这是个历史遗留问题: 扁平网络使用dhcp服务标识是否启用自动分配功能, 其它网络类型不受影响 +它根据用户输入的l3网络uuid和可选的ip地址, 按照地址分配算法分配一个可用地址,分配的IP地址包含: ip地址,掩码(或者前缀长度),网关 + +### 自动分配算法 +- 随机分配: 从可用ip地址池中随机选择一个ip地址分配给虚拟机 +- 顺序分配: 从可用ip地址池中按照顺序选择一个ip +- 循环分配: 从可用ip地址池中按照顺序选择一个ip, 分配完最后一个ip后, 从第一个ip重新开始分配 + +### 当前状况 +cloud 5.5版本情况: +1. 扁平网络可以有三种情况: no ip range, ip range without dhcp, ip range with dhcp. +2. 公有网络和VPC网络有两种情况: ip range without dhcp, ip range with dhcp. +3. 管理网和流量网络只有一种情况: ip range without dhcp. + +### 工作时机 +以下操作会触发自动分配: +1. 创建虚拟机(APICreateVmInstanceMsg) +2. 虚拟机添加网卡(APIAttachL3NetworkToVmMsg, APICreateVmNicMsg) +3. 修改虚拟机IP(APISetVmStaticIpMsg, APIChangeVmNicNetworkMsg) +4. 创建applianceVm(APICreateVpcVRouterMsg, APICreateSlbInstanceMsg, APICreateNfvInstMsg) +5. 创建Vip(APICreateVipMsg) + +## 手动分配 +手动指定仅仅对虚拟机生效,对于applianceVm不生效。 +在前述场景1,2,3的情况下,用户可以指定ip地址. 这又分两种情况: +1. 指定的ip在ip range之内,后端仍然执行的自动分配流量 +2. 指定的ip不在ip range之内, 按照手动指定流程分配 + 1. 如果指定的ip地址不在l3 cidr之内,必须指定掩码, 网关可选 + 2. 如果指定的ip地址在l3 cidr之内,可以不指定掩码, 网关, 如果指定必须和l3 cidr一致 + +### 工作时机 +1. 在5.5.12之前, 扁平网络在两种情况下: no ip range, ip range without dhcp, 允许指定地址不在ip range之内 +2. 在5.5.12版本及其以后, 任意网络,都可以通过修改虚拟机IP(APISetVmStaticIpMsg, APIChangeVmNicNetworkMsg) 设置不在ip range之内的地址, + +如果指定的ip地址在ip ranges之外, 但是在l3 cidr之内, 则掩码和网关可以不指定, 系统会自动从l3 cidr中获取掩码和网关 +如果指定的ip地址在ip cidr之外, 用户输入必须同时输入IP, 掩码或者前缀长度, 如果是默认网卡,必须指定网关, 网关必须在l3 cidr之内 + + +## qga获取 +这种方式需要打开全局配置: VmGlobalConfig.ENABLE_VM_INTERNAL_IP_OVERWRITE(默认值是false) +ZStack kvmagent会定期通过qga从云主机内部读取ip地址, 仅在扁平网络在no ip range的情况下会把读出来的ip地址分配给云主机, 其它网络类型不受影响。 +其它情况下,qga获取的ip地址如果和虚拟机的ip地址冲突, 则发送报警。 + +## 配置虚拟机guest OS的IP地址 +配置虚拟机guest OS的IP地址有3种方式: +1. **DHCP**: 通过DHCP服务器动态分配IP地址。 +2. **Cloud-init**: 在虚拟机创建时,使用Cloud-init工具预配置IP地址。 +3. **QGA**: 通过QEMU Guest Agent从虚拟 + +### DHCP +ZStack会在每个物理机启动分布式dhcp server, 虚拟机启动时候, 通过dhclient获取地址和dns等参数。 + +### Cloud-init +ZStack会在每个物理机启动分布式userdata server, 虚拟机启动时候, 通过cloud-init获取地址和dns等参数。 + +### QGA +当虚拟机安装ZStack Guest Agent后,在zstack检测guest agent第一次启动时候,通过qga配置虚拟机的ip地址,dns等参数 +当用户在UI手动修改IP(APISetVmStaticIpMsg, APIChangeVmNicNetworkMsg), UI调用后端api, 触发一次配置虚拟机ip地址的过程 +qga配置虚拟机的参数包含: +- IP地址 +- 掩码或者前缀长度 +- 网关 +- DNS服务器地址 +- mtu +- hostname +用户可以通过全局配置来限制配置的字段: GuestToolsGlobalProperty.GUESTTOOLS_VM_PORT_CONFIGFIELDS来限制 + +## 网络服务 +当网卡地址不在 l3 ip range之内的时候, 又可以分为在l3 cidr之内和在l3 cidr之外两种情况: + +### 在l3 cidr之内 +这种情况和在l3 ip range之内的情况一样, 网络服务没有影响 + +### 在l3 cidr之外 +#### 安全组 +1. 安全组的规则不关心网卡ip, 当这种网卡配置了安全组以后, 需要用户小心规则的配置,否则可能满足不了需求 + +#### DHCP +如果网卡没有ip range, 则没有dhcp服务 +如果网卡有ip range, zstack会启动dhcp服务, dnsmasq的配置文件要求指定一个ip cidr +如果网卡的ip地址不在dhcp服务的ip cidr之内, 因此dhcp模块下发配置的时候调多cidr之外的地址 + +#### Eip +对于扁平网络, eip功能不受影响,可以继续创建。 +对于vpc网络, eip的私网地址不在l3 cidr之内, vpc路由器无法路由,网络不通 +为了一致性,eip不能绑定ip地址不在l3 cidr之内的网卡, APIGetEipAttachableVmNicsMsg 也不返回ip地址不在l3 cidr之内的网卡 + +#### Port forwarding +只有vpc网络才有port forwarding功能, 和eip一样, vpc路由器无法路由,网络不通 +Port forwarding不能绑定ip地址不在l3 cidr之内的网卡, APIGetPortForwardingAttachableVmNicsMsg 也不返回ip地址不在l3 cidr之内的网卡 + +#### LoadBalancer +和eip一样,网络不通 +APIAddVmNicToLoadBalancerMsg, APIAddBackendServerToServerGroupMsg 不能绑定ip地址不在l3 cidr之内的网卡, +APIGetCandidateVmNicsForLoadBalancerServerGroupMsg, APIGetCandidateVmNicsForLoadBalancerMsg也不返回ip地址不在l3 cidr之内的网卡 + + +## 代码细节 + +### APISetVmStaticIpMsg +通过成员字段配置虚拟机的IP地址,掩码,网关等参数。需要完整性校验 +- 如果用户输入IP地址,不输入掩码和网关,优先使用网卡上使用的掩码和网关; +- 继续,如果网卡上没有使用的掩码和网关, 则从l3 cidr中获取掩码和网关 +- 继续,如果l3 cidr中也没有掩码和网关,报错 +- ipv6和ipv4的逻辑一样 +- +### APIChangeVmNicNetworkMsg +通过system tags来配置虚拟机的IP地址,掩码,网关等参数。需要完整性校验 +- 如果配置ip地址,且在l3 cidr之内,掩码和网关从l3 cidr中获取 +- 如果配置ip地址,且在l3 cidr之外,必须指定掩码, 网关可选; 如果是默认网卡,必须指定网关 +- 如果配置ipv6地址,且在l3 cidr之内,前缀长度和网关从l3 cidr中获取 +- 如果配置ipv6地址,且在l3 cidr之外,必须指定前缀长度, 网关可选; 如果是默认网卡,必须指定网关 + +### APICreateVmInstanceMsg +逻辑和APIChangeVmNicNetworkMsg相同 + +### APIGetL3NetworkIpStatisticMsg +不统计在ip range之外的ip地址 + +### APIAddIpRangeMsg +允许给云主机设置不在ip range之内的ip地址, 这样在添加ip range的时候, 可能包含已经分配的ip地址, 此时, 让已分配的地址属于新加入的ip range + +### APIAddReservedIpRangeMsg +这个api不仅添加了ReservedIpRangeVO, 还把ReservedIpRangeVO和ip range重叠的ip添加到UsedIpVO, +vo.setUsedFor(IpAllocatedReason.Reserved.toString()); + +### APICheckIpAvailabilityMsg +这个api在5.5.12版本之前, 在扁平网络no dhcp的情况下跳过ip range的检查, 这个功能不变 diff --git a/network/src/main/java/org/zstack/network/service/DhcpExtension.java b/network/src/main/java/org/zstack/network/service/DhcpExtension.java index 625c872dc8a..8e37da8b1e9 100755 --- a/network/src/main/java/org/zstack/network/service/DhcpExtension.java +++ b/network/src/main/java/org/zstack/network/service/DhcpExtension.java @@ -136,11 +136,16 @@ private void populateExtensions() { } public boolean isDualStackNicInSingleL3Network(VmNicInventory nic) { - if (nic.getUsedIps().size() < 2) { + // Filter out IPs outside L3 CIDR range + List validIps = nic.getUsedIps().stream() + .filter(ip -> ip.getIpRangeUuid() != null || IpRangeHelper.isIpInL3NetworkCidr(ip.getIp(), ip.getL3NetworkUuid())) + .collect(Collectors.toList()); + + if (validIps.size() < 2) { return false; } - return nic.getUsedIps().stream().map(UsedIpInventory::getL3NetworkUuid).distinct().count() == 1; + return validIps.stream().map(UsedIpInventory::getL3NetworkUuid).distinct().count() == 1; } private DhcpStruct getDhcpStruct(VmInstanceInventory vm, List hostNames, VmNicVO nic, UsedIpVO ip, boolean isDefaultNic) { @@ -194,7 +199,11 @@ private boolean isEnableRa(String l3Uuid) { private void setDualStackNicOfSingleL3Network(DhcpStruct struct, VmNicVO nic) { struct.setIpVersion(IPv6Constants.DUAL_STACK); - List sortedIps = nic.getUsedIps().stream().sorted(Comparator.comparingLong(UsedIpVO::getIpVersionl)).collect(Collectors.toList()); + // Filter out IPs outside L3 CIDR range + List sortedIps = nic.getUsedIps().stream() + .filter(ip -> ip.getIpRangeUuid() != null || IpRangeHelper.isIpInL3NetworkCidr(ip.getIp(), ip.getL3NetworkUuid())) + .sorted(Comparator.comparingLong(UsedIpVO::getIpVersionl)) + .collect(Collectors.toList()); for (UsedIpVO ip : sortedIps) { if (ip.getIpVersion() == IPv6Constants.IPv4) { struct.setGateway(ip.getGateway()); @@ -275,6 +284,11 @@ public List makeDhcpStruct(VmInstanceInventory vm, List> workoutDhcp(VmInstanceS for (VmNicInventory inv : spec.getDestNics()) { VmNicVO vmNicVO = dbf.findByUuid(inv.getUuid(), VmNicVO.class); for (UsedIpVO ip : vmNicVO.getUsedIps()) { + // Skip IPs outside L3 IP range (not managed by DHCP) + if (ip.getIpRangeUuid() == null) { + continue; + } + L3NetworkInventory l3 = l3Map.get(ip.getL3NetworkUuid()); if (l3 == null) { continue; diff --git a/network/src/main/java/org/zstack/network/service/NetworkServiceManager.java b/network/src/main/java/org/zstack/network/service/NetworkServiceManager.java index cd80c9b184f..7d421393afe 100755 --- a/network/src/main/java/org/zstack/network/service/NetworkServiceManager.java +++ b/network/src/main/java/org/zstack/network/service/NetworkServiceManager.java @@ -18,6 +18,16 @@ public interface NetworkServiceManager { void applyNetworkServiceOnChangeIP(VmInstanceSpec spec, NetworkServiceExtensionPoint.NetworkServiceExtensionPosition position, Completion completion); List getL3NetworkDns(String l3NetworkUuid); + /** + * Get DNS servers for a VM NIC. + * Priority: VM NIC system tag > L3 Network DNS + * + * @param vmUuid VM instance UUID + * @param l3NetworkUuid L3 network UUID + * @return List of DNS server addresses + */ + List getVmNicDns(String vmUuid, String l3NetworkUuid); + void enableNetworkService(L3NetworkVO l3VO, NetworkServiceProviderType providerType, NetworkServiceType nsType, List systemTags, Completion completion); diff --git a/network/src/main/java/org/zstack/network/service/NetworkServiceManagerImpl.java b/network/src/main/java/org/zstack/network/service/NetworkServiceManagerImpl.java index 5e353fa1ff2..24ab48a51de 100755 --- a/network/src/main/java/org/zstack/network/service/NetworkServiceManagerImpl.java +++ b/network/src/main/java/org/zstack/network/service/NetworkServiceManagerImpl.java @@ -25,9 +25,12 @@ import org.zstack.header.network.service.*; import org.zstack.header.network.service.NetworkServiceExtensionPoint.NetworkServiceExtensionPosition; import org.zstack.header.vm.*; +import org.zstack.header.tag.SystemTagVO; +import org.zstack.header.tag.SystemTagVO_; import org.zstack.query.QueryFacade; import org.zstack.utils.Utils; import org.zstack.utils.logging.CLogger; +import org.zstack.utils.network.IPv6NetworkUtils; import java.util.*; @@ -483,6 +486,36 @@ public List getL3NetworkDns(String l3NetworkUuid){ return dns; } + @Override + public List getVmNicDns(String vmUuid, String l3NetworkUuid) { + // First try to get DNS from system tag (VM NIC-level custom DNS) + // Tag format: staticDns::{l3NetworkUuid}::{dns1,dns2,dns3} + String tagLike = String.format("staticDns::%s::%%", l3NetworkUuid); + List tags = Q.New(SystemTagVO.class) + .select(SystemTagVO_.tag) + .eq(SystemTagVO_.resourceUuid, vmUuid) + .eq(SystemTagVO_.resourceType, VmInstanceVO.class.getSimpleName()) + .like(SystemTagVO_.tag, tagLike) + .listValues(); + if (tags != null && !tags.isEmpty()) { + String tag = tags.get(0); + // Parse DNS part: staticDns::{l3Uuid}::{dnsStr} + String prefix = String.format("staticDns::%s::", l3NetworkUuid); + if (tag.startsWith(prefix)) { + String dnsStr = tag.substring(prefix.length()); + if (!dnsStr.isEmpty()) { + List dnsList = new ArrayList<>(); + for (String dns : dnsStr.split(",")) { + dnsList.add(IPv6NetworkUtils.ipv6TagValueToAddress(dns)); + } + return dnsList; + } + } + } + // Fall back to L3 network DNS + return getL3NetworkDns(l3NetworkUuid); + } + @Override public void instantiateResourceOnAttachingNic(VmInstanceSpec spec, L3NetworkInventory l3, Completion completion) { preInstantiateVmResource(spec, completion); diff --git a/plugin/applianceVm/src/main/java/org/zstack/appliancevm/ApplianceVmNicTO.java b/plugin/applianceVm/src/main/java/org/zstack/appliancevm/ApplianceVmNicTO.java index c9905a78667..2c31637659d 100755 --- a/plugin/applianceVm/src/main/java/org/zstack/appliancevm/ApplianceVmNicTO.java +++ b/plugin/applianceVm/src/main/java/org/zstack/appliancevm/ApplianceVmNicTO.java @@ -35,9 +35,20 @@ public ApplianceVmNicTO(VmNicInventory inv) { } else { ip6 = uip.getIp(); gateway6 = uip.getGateway(); - NormalIpRangeVO ipRangeVO = Q.New(NormalIpRangeVO.class).eq(NormalIpRangeVO_.uuid, uip.getIpRangeUuid()).find(); - prefixLength = ipRangeVO.getPrefixLen(); - addressMode = ipRangeVO.getAddressMode(); + // First try to use prefixLen from UsedIpInventory (for IP outside range) + if (uip.getPrefixLen() != null) { + prefixLength = uip.getPrefixLen(); + addressMode = IPv6Constants.SLAAC; + } + if (uip.getIpRangeUuid() != null) { + NormalIpRangeVO ipRangeVO = Q.New(NormalIpRangeVO.class).eq(NormalIpRangeVO_.uuid, uip.getIpRangeUuid()).find(); + if (ipRangeVO != null) { + if (prefixLength == null) { + prefixLength = ipRangeVO.getPrefixLen(); + } + addressMode = ipRangeVO.getAddressMode(); + } + } } } /* for virtual router, gateway ip is in the usedIpVO */ diff --git a/plugin/eip/src/main/java/org/zstack/network/service/eip/EipApiInterceptor.java b/plugin/eip/src/main/java/org/zstack/network/service/eip/EipApiInterceptor.java index 85e9a357a06..c1ce073cdd8 100755 --- a/plugin/eip/src/main/java/org/zstack/network/service/eip/EipApiInterceptor.java +++ b/plugin/eip/src/main/java/org/zstack/network/service/eip/EipApiInterceptor.java @@ -23,6 +23,7 @@ import org.zstack.header.vm.VmNicHelper; import org.zstack.header.vm.VmNicVO; import org.zstack.header.vm.VmNicVO_; +import org.zstack.network.l3.IpRangeHelper; import org.zstack.network.service.vip.VipNetworkServicesRefVO; import org.zstack.network.service.vip.VipNetworkServicesRefVO_; import org.zstack.network.service.vip.VipState; @@ -202,6 +203,14 @@ public VipVO call() { } else { msg.setUsedIpUuid(nic.getUsedIpUuid()); } + + // Check if the IP is outside L3 CIDR range + UsedIpVO usedIpVO = dbf.findByUuid(msg.getUsedIpUuid(), UsedIpVO.class); + if (usedIpVO != null && IpRangeHelper.isIpOutsideL3NetworkCidr(usedIpVO.getIp(), usedIpVO.getL3NetworkUuid())) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_EIP_10024, + "cannot bind EIP to IP address[%s] which is outside L3 network CIDR range", + usedIpVO.getIp())); + } } private void validate(APIDetachEipMsg msg) { @@ -304,6 +313,14 @@ private void validate(APICreateEipMsg msg) { if (msg.getUsedIpUuid() != null) { isVipInVmNicSubnet(msg.getVipUuid(), msg.getUsedIpUuid()); + + // Check if the IP is outside L3 CIDR range + UsedIpVO usedIpVO = dbf.findByUuid(msg.getUsedIpUuid(), UsedIpVO.class); + if (usedIpVO != null && IpRangeHelper.isIpOutsideL3NetworkCidr(usedIpVO.getIp(), usedIpVO.getL3NetworkUuid())) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_EIP_10024, + "cannot bind EIP to IP address[%s] which is outside L3 network CIDR range", + usedIpVO.getIp())); + } } checkNicRule(msg.getVmNicUuid()); diff --git a/plugin/eip/src/main/java/org/zstack/network/service/eip/EipManagerImpl.java b/plugin/eip/src/main/java/org/zstack/network/service/eip/EipManagerImpl.java index 8e23e2a9175..dae4a3e10bf 100755 --- a/plugin/eip/src/main/java/org/zstack/network/service/eip/EipManagerImpl.java +++ b/plugin/eip/src/main/java/org/zstack/network/service/eip/EipManagerImpl.java @@ -34,6 +34,7 @@ import org.zstack.header.query.ExpandedQueryStruct; import org.zstack.header.vm.*; import org.zstack.identity.AccountManager; +import org.zstack.network.l3.IpRangeHelper; import org.zstack.network.l3.L3NetworkManager; import org.zstack.network.service.NetworkServiceManager; import org.zstack.network.service.vip.*; @@ -416,6 +417,15 @@ private List getAttachableVmNicForEip(VipInventory vip, APIGetEi } else { ret = l3Mgr.filterVmNicByIpVersion(VmNicInventory.valueOf(nics), IPv6Constants.IPv4); } + + // Filter out NICs whose primary IP is outside L3 CIDR + final int targetIpVersion = NetworkUtils.isIpv4Address(vip.getIp()) ? IPv6Constants.IPv4 : IPv6Constants.IPv6; + ret = ret.stream().filter(nic -> { + return nic.getUsedIps().stream() + .filter(ip -> ip.getIpVersion() == targetIpVersion) + .anyMatch(ip -> IpRangeHelper.isIpInL3NetworkCidr(ip.getIp(), ip.getL3NetworkUuid())); + }).collect(Collectors.toList()); + return ret; } diff --git a/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatDhcpBackend.java b/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatDhcpBackend.java index 962be8fe4ef..ecc0c14e2e3 100755 --- a/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatDhcpBackend.java +++ b/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatDhcpBackend.java @@ -1991,7 +1991,7 @@ public DhcpInfo call(DhcpStruct arg) { List dns = new ArrayList<>(); List dns6 = new ArrayList<>(); - for (String dnsIp : nwServiceMgr.getL3NetworkDns(arg.getL3Network().getUuid())) { + for (String dnsIp : nwServiceMgr.getVmNicDns(arg.getVmUuid(), arg.getL3Network().getUuid())) { if (NetworkUtils.isIpv4Address(dnsIp)) { dns.add(dnsIp); } else { diff --git a/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatEipBackend.java b/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatEipBackend.java index 5f0b1fb23e7..38a88a84950 100755 --- a/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatEipBackend.java +++ b/plugin/flatNetworkProvider/src/main/java/org/zstack/network/service/flat/FlatEipBackend.java @@ -439,8 +439,15 @@ public EipTO call(EipVO eip) { to.nicIp = ip.getIp(); to.nicGateway = ip.getGateway(); to.nicNetmask = ip.getNetmask(); - NormalIpRangeVO ipr = Q.New(NormalIpRangeVO.class).eq(NormalIpRangeVO_.uuid, ip.getIpRangeUuid()).find(); - to.nicPrefixLen = ipr.getPrefixLen(); + // First try to use prefixLen from UsedIpVO (for IP outside range) + if (ip.getPrefixLen() != null) { + to.nicPrefixLen = ip.getPrefixLen(); + } else if (ip.getIpRangeUuid() != null) { + NormalIpRangeVO ipr = Q.New(NormalIpRangeVO.class).eq(NormalIpRangeVO_.uuid, ip.getIpRangeUuid()).find(); + if (ipr != null) { + to.nicPrefixLen = ipr.getPrefixLen(); + } + } to.vmBridgeName = bridgeNames.get(ip.getL3NetworkUuid()); } } diff --git a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java index c8a1ba8c08c..f2f8271674b 100755 --- a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java +++ b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerApiInterceptor.java @@ -33,6 +33,7 @@ import org.zstack.header.tag.SystemTagVO_; import org.zstack.header.vm.VmNicVO; import org.zstack.header.vm.VmNicVO_; +import org.zstack.network.l3.IpRangeHelper; import org.zstack.network.service.vip.VipNetworkServicesRefVO; import org.zstack.network.service.vip.VipNetworkServicesRefVO_; import org.zstack.network.service.vip.VipVO; @@ -655,6 +656,30 @@ public void run(String arg) { q = dbf.getEntityManager().createQuery(sql, String.class); q.setParameter("uuid", msg.getListenerUuid()); msg.setLoadBalancerUuid(q.getSingleResult()); + + // When the load balancer has a VIP configured, the NIC's corresponding IP must be within L3 CIDR + LoadBalancerVO lbVO = dbf.findByUuid(msg.getLoadBalancerUuid(), LoadBalancerVO.class); + for (String nicUuid : msg.getVmNicUuids()) { + VmNicVO nicVO = dbf.findByUuid(nicUuid, VmNicVO.class); + if (nicVO == null) { + continue; + } + for (UsedIpVO usedIpVO : nicVO.getUsedIps()) { + if (!IpRangeHelper.isIpOutsideL3NetworkCidr(usedIpVO.getIp(), usedIpVO.getL3NetworkUuid())) { + continue; + } + if (lbVO.getVipUuid() != null && usedIpVO.getIpVersion() == IPv6Constants.IPv4) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_LB_10176, + "cannot add VM NIC[uuid:%s] with IPv4 address[%s] which is outside L3 network CIDR range to load balancer", + nicUuid, usedIpVO.getIp())); + } + if (lbVO.getIpv6VipUuid() != null && usedIpVO.getIpVersion() == IPv6Constants.IPv6) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_LB_10173, + "cannot add VM NIC[uuid:%s] with IPv6 address[%s] which is outside L3 network CIDR range to load balancer", + nicUuid, usedIpVO.getIp())); + } + } + } } private boolean hasTag(APIMessage msg, PatternedSystemTag tag) { @@ -1586,6 +1611,31 @@ private void validate(APIAddBackendServerToServerGroupMsg msg){ } } + // When server group has an IP version, the vmnic's corresponding IP must be within L3 CIDR + if (groupVO.getIpVersion() != null) { + for (String nicUuid : vmNicUuids) { + VmNicVO nicVO = dbf.findByUuid(nicUuid, VmNicVO.class); + if (nicVO == null) { + continue; + } + for (UsedIpVO usedIpVO : nicVO.getUsedIps()) { + if (!IpRangeHelper.isIpOutsideL3NetworkCidr(usedIpVO.getIp(), usedIpVO.getL3NetworkUuid())) { + continue; + } + if (groupVO.getIpVersion() == IPv6Constants.IPv4 && usedIpVO.getIpVersion() == IPv6Constants.IPv4) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_LB_10174, + "cannot add VM NIC[uuid:%s] with IPv4 address[%s] which is outside L3 network CIDR range to server group[uuid:%s]", + nicUuid, usedIpVO.getIp(), msg.getServerGroupUuid())); + } + if (groupVO.getIpVersion() == IPv6Constants.IPv6 && usedIpVO.getIpVersion() == IPv6Constants.IPv6) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_LB_10175, + "cannot add VM NIC[uuid:%s] with IPv6 address[%s] which is outside L3 network CIDR range to server group[uuid:%s]", + nicUuid, usedIpVO.getIp(), msg.getServerGroupUuid())); + } + } + } + } + canAddVmNic = true; } diff --git a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerBase.java b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerBase.java index f51ed2efb25..dadec74b6ec 100755 --- a/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerBase.java +++ b/plugin/loadBalancer/src/main/java/org/zstack/network/service/lb/LoadBalancerBase.java @@ -43,6 +43,7 @@ import org.zstack.header.vm.*; import org.zstack.header.vo.ResourceVO; import org.zstack.identity.Account; +import org.zstack.network.l3.IpRangeHelper; import org.zstack.network.l3.L3NetworkManager; import org.zstack.network.service.vip.*; import org.zstack.tag.PatternedSystemTag; @@ -635,6 +636,14 @@ private void handle(APIGetCandidateVmNicsForLoadBalancerServerGroupMsg msg) { ipVersion = groupVO.getIpVersion(); } List nicVOS = f.getAttachableVmNicsForServerGroup(self, groupVO, ipVersion); + + // Filter out NICs whose primary IP is outside L3 CIDR + nicVOS = nicVOS.stream().filter(nic -> { + String nicIp = nic.getIp(); + String nicL3 = nic.getL3NetworkUuid(); + return nicIp == null || IpRangeHelper.isIpInL3NetworkCidr(nicIp, nicL3); + }).collect(Collectors.toList()); + reply.setInventories(VmNicInventory.valueOf(nicVOS)); bus.reply(msg, reply); } @@ -1046,6 +1055,13 @@ protected void scripts() { .filter(nic -> !listenerVO.getAttachedVmNics().contains(nic.getUuid())) .collect(Collectors.toList()); + // Filter out NICs whose primary IP is outside L3 CIDR + nics = nics.stream().filter(nic -> { + String nicIp = nic.getIp(); + String nicL3 = nic.getL3NetworkUuid(); + return nicIp == null || IpRangeHelper.isIpInL3NetworkCidr(nicIp, nicL3); + }).collect(Collectors.toList()); + reply.setInventories(callGetCandidateVmNicsForLoadBalancerExtensionPoint(msg, VmNicInventory.valueOf(nics))); } }.execute(); diff --git a/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingApiInterceptor.java b/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingApiInterceptor.java index 43a2a4d10e7..5316be0629b 100755 --- a/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingApiInterceptor.java +++ b/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingApiInterceptor.java @@ -19,6 +19,8 @@ import org.zstack.header.vm.VmInstanceVO_; import org.zstack.header.vm.VmNicVO; import org.zstack.header.vm.VmNicVO_; +import org.zstack.header.network.l3.UsedIpVO; +import org.zstack.network.l3.IpRangeHelper; import org.zstack.network.service.vip.*; import org.zstack.utils.VipUseForList; import org.zstack.utils.network.IPv6Constants; @@ -147,6 +149,17 @@ public VipVO call() { } catch (CloudRuntimeException e) { throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10011, e.getMessage())); } + + // Check if the NIC's IP is outside L3 CIDR range + VmNicVO nicVO = dbf.findByUuid(msg.getVmNicUuid(), VmNicVO.class); + if (nicVO != null && nicVO.getUsedIpUuid() != null) { + UsedIpVO usedIpVO = dbf.findByUuid(nicVO.getUsedIpUuid(), UsedIpVO.class); + if (usedIpVO != null && IpRangeHelper.isIpOutsideL3NetworkCidr(usedIpVO.getIp(), usedIpVO.getL3NetworkUuid())) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10025, + "cannot bind port forwarding rule to IP address[%s] which is outside L3 network CIDR range", + usedIpVO.getIp())); + } + } } private boolean rangeOverlap(int s1, int e1, int s2, int e2) { @@ -243,6 +256,17 @@ private void validate(APICreatePortForwardingRuleMsg msg) { checkIfAnotherVip(msg.getVipUuid(), msg.getVmNicUuid()); checkForConflictsWithOtherRules(msg.getVmNicUuid(), msg.getPrivatePortStart(), msg.getPrivatePortEnd(), msg.getAllowedCidr(), PortForwardingProtocolType.valueOf(msg.getProtocolType())); + + // Check if the NIC's IP is outside L3 CIDR range + VmNicVO nicVO = dbf.findByUuid(msg.getVmNicUuid(), VmNicVO.class); + if (nicVO != null && nicVO.getUsedIpUuid() != null) { + UsedIpVO usedIpVO = dbf.findByUuid(nicVO.getUsedIpUuid(), UsedIpVO.class); + if (usedIpVO != null && IpRangeHelper.isIpOutsideL3NetworkCidr(usedIpVO.getIp(), usedIpVO.getL3NetworkUuid())) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10025, + "cannot bind port forwarding rule to IP address[%s] which is outside L3 network CIDR range", + usedIpVO.getIp())); + } + } } if(msg.getAllowedCidr() != null){ diff --git a/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingManagerImpl.java b/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingManagerImpl.java index ce34e098021..80aa7597ff1 100755 --- a/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingManagerImpl.java +++ b/plugin/portForwarding/src/main/java/org/zstack/network/service/portforwarding/PortForwardingManagerImpl.java @@ -37,6 +37,7 @@ import org.zstack.header.query.ExpandedQueryStruct; import org.zstack.header.vm.*; import org.zstack.identity.AccountManager; +import org.zstack.network.l3.IpRangeHelper; import org.zstack.network.l3.L3NetworkManager; import org.zstack.network.service.NetworkServiceManager; import org.zstack.network.service.vip.*; @@ -365,7 +366,16 @@ protected List scripts() { /* TODO: only ipv4 portforwarding is supported */ List nicInvs = VmNicInventory.valueOf(nics.stream().filter(nic -> !usedVm.contains(nic.getVmInstanceUuid())).collect(Collectors.toList())); - return l3Mgr.filterVmNicByIpVersion(nicInvs, IPv6Constants.IPv4); + List filtered = l3Mgr.filterVmNicByIpVersion(nicInvs, IPv6Constants.IPv4); + + // Filter out NICs whose primary IP is outside L3 CIDR + filtered = filtered.stream().filter(nic -> { + String nicIp = nic.getIp(); + String nicL3 = nic.getL3NetworkUuid(); + return nicIp == null || IpRangeHelper.isIpInL3NetworkCidr(nicIp, nicL3); + }).collect(Collectors.toList()); + + return filtered; } }.execute(); } diff --git a/plugin/virtualRouterProvider/src/main/java/org/zstack/network/service/virtualrouter/VirtualRouterManagerImpl.java b/plugin/virtualRouterProvider/src/main/java/org/zstack/network/service/virtualrouter/VirtualRouterManagerImpl.java index 610331e5f6c..a876ee24a34 100755 --- a/plugin/virtualRouterProvider/src/main/java/org/zstack/network/service/virtualrouter/VirtualRouterManagerImpl.java +++ b/plugin/virtualRouterProvider/src/main/java/org/zstack/network/service/virtualrouter/VirtualRouterManagerImpl.java @@ -2092,6 +2092,9 @@ void applianceVmsDeleteIpByIpRanges(List applianceVmVOS, vo = dbf.findByUuid(vo.getUuid(), ApplianceVmVO.class); for (VmNicVO nic : vo.getVmNics()) { for (UsedIpVO ip : nic.getUsedIps()) { + if (ip.getIpRangeUuid() == null) { + continue; + } if (ip.getIpVersion() == IPv6Constants.IPv4 && ipv4RangeUuids.contains(ip.getIpRangeUuid())) { ReturnIpMsg rmsg = new ReturnIpMsg(); rmsg.setL3NetworkUuid(ip.getL3NetworkUuid()); @@ -2139,7 +2142,7 @@ public List applianceVmsToDeleteNicByIpRanges(List appli for (ApplianceVmVO vo : applianceVmVOS) { for (VmNicVO nic : vo.getVmNics()) { for (UsedIpVO ip : nic.getUsedIps()) { - if (!iprUuids.contains(ip.getIpRangeUuid())) { + if (ip.getIpRangeUuid() == null || !iprUuids.contains(ip.getIpRangeUuid())) { continue; } @@ -2172,7 +2175,7 @@ public List applianceVmsToBeDeletedByIpRanges(List } /* if any ip of the nic is deleted, delete the appliance vm */ - if (nic.getUsedIps().stream().anyMatch(ip -> iprUuids.contains(ip.getIpRangeUuid()))) { + if (nic.getUsedIps().stream().anyMatch(ip -> ip.getIpRangeUuid() != null && iprUuids.contains(ip.getIpRangeUuid()))) { toDeleted.add(vos); break; } diff --git a/sdk/src/main/java/org/zstack/sdk/ChangeVmNicNetworkAction.java b/sdk/src/main/java/org/zstack/sdk/ChangeVmNicNetworkAction.java index ffec933f2cc..59beff6880a 100644 --- a/sdk/src/main/java/org/zstack/sdk/ChangeVmNicNetworkAction.java +++ b/sdk/src/main/java/org/zstack/sdk/ChangeVmNicNetworkAction.java @@ -34,6 +34,9 @@ public Result throwExceptionIfError() { @Param(required = false) public java.lang.String staticIp; + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List dnsAddresses; + @Param(required = false) public java.util.List systemTags; diff --git a/sdk/src/main/java/org/zstack/sdk/SetVmStaticIpAction.java b/sdk/src/main/java/org/zstack/sdk/SetVmStaticIpAction.java index 8240899d354..3deb766e3ce 100644 --- a/sdk/src/main/java/org/zstack/sdk/SetVmStaticIpAction.java +++ b/sdk/src/main/java/org/zstack/sdk/SetVmStaticIpAction.java @@ -49,6 +49,9 @@ public Result throwExceptionIfError() { @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) public java.lang.String ipv6Prefix; + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.util.List dnsAddresses; + @Param(required = false) public java.util.List systemTags; diff --git a/sdk/src/main/java/org/zstack/sdk/UsedIpInventory.java b/sdk/src/main/java/org/zstack/sdk/UsedIpInventory.java index 8befbe48ff7..5c47e90bb46 100644 --- a/sdk/src/main/java/org/zstack/sdk/UsedIpInventory.java +++ b/sdk/src/main/java/org/zstack/sdk/UsedIpInventory.java @@ -52,6 +52,14 @@ public java.lang.String getNetmask() { return this.netmask; } + public java.lang.Integer prefixLen; + public void setPrefixLen(java.lang.Integer prefixLen) { + this.prefixLen = prefixLen; + } + public java.lang.Integer getPrefixLen() { + return this.prefixLen; + } + public java.lang.String gateway; public void setGateway(java.lang.String gateway) { this.gateway = gateway; diff --git a/test/pom.xml b/test/pom.xml index 7dfbb0bbcac..6505ec9324c 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -9,7 +9,7 @@ zstack org.zstack - 5.5.0 + 5.5.0 .. test @@ -292,6 +292,12 @@ org.jasig.cas.client cas-client-core + + org.zstack + eip + 5.5.0 + test + diff --git a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy new file mode 100644 index 00000000000..62b71e75e04 --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy @@ -0,0 +1,923 @@ +package org.zstack.test.integration.networkservice.provider.flat + +import org.springframework.http.HttpEntity +import org.zstack.core.db.DatabaseFacade +import org.zstack.core.db.Q +import org.zstack.header.network.l3.UsedIpVO +import org.zstack.header.network.l3.UsedIpVO_ +import org.zstack.header.network.service.NetworkServiceType +import org.zstack.header.vm.VmNicVO +import org.zstack.network.securitygroup.SecurityGroupConstant +import org.zstack.network.service.eip.EipConstant +import org.zstack.network.service.flat.FlatDhcpBackend +import org.zstack.network.service.flat.FlatNetworkServiceConstant +import org.zstack.network.service.userdata.UserdataConstant +import org.zstack.sdk.* +import org.zstack.test.integration.networkservice.provider.NetworkServiceProviderTest +import org.zstack.testlib.EnvSpec +import org.zstack.testlib.SubCase +import org.zstack.utils.data.SizeUnit +import org.zstack.utils.gson.JSONObjectUtil +import org.zstack.utils.network.IPv6Constants + +/** + * Test IP outside CIDR behavior for flat networks. + * Outside-range IPs are always allowed (no global config needed). + * + * Flat network combinations (3 combos): + * 1. flatL3_noRange_noDhcp — no IP range, no DHCP (enableIPAM=false, enableIpAddressAllocation()=false) + * 2. flatL3_range_noDhcp — has IP range, no DHCP (enableIPAM=true, enableIpAddressAllocation()=false) + * 3. flatL3_range_dhcp — has IP range, has DHCP (enableIPAM=true, enableIpAddressAllocation()=true) + * + * pubL3_range_dhcp is included only as VIP network for EIP tests. + * + * Each scenario tests: setVmStaticIp, changeVmNicNetwork, DHCP skip, EIP rejection. + * Additional: orphan IP backfill when adding IP range. + */ +class FlatChangeVmIpOutsideCidrCase extends SubCase { + + EnvSpec env + DatabaseFacade dbf + + @Override + void setup() { + useSpring(NetworkServiceProviderTest.springSpec) + } + + @Override + void clean() { + env.delete() + } + + @Override + void environment() { + env = env { + instanceOffering { + name = "instanceOffering" + memory = SizeUnit.GIGABYTE.toByte(1) + cpu = 1 + } + + sftpBackupStorage { + name = "sftp" + url = "/sftp" + username = "root" + password = "password" + hostname = "localhost" + + image { + name = "image1" + url = "http://zstack.org/download/test.qcow2" + } + } + + zone { + name = "zone" + description = "test" + + cluster { + name = "cluster" + hypervisorType = "KVM" + + kvm { + name = "kvm" + managementIp = "127.0.0.1" + username = "root" + password = "password" + } + + attachPrimaryStorage("local") + attachL2Network("l2-flat-noRange-noDhcp") + attachL2Network("l2-flat-range-noDhcp") + attachL2Network("l2-flat-range-dhcp") + attachL2Network("l2-pub-range-dhcp") + attachL2Network("l2-backfill") + attachL2Network("l2-dest") + } + + localPrimaryStorage { + name = "local" + url = "/local_ps" + } + + // ========== Flat: no IP range, no DHCP ========== + l2NoVlanNetwork { + name = "l2-flat-noRange-noDhcp" + physicalInterface = "eth0" + + l3Network { + name = "flatL3_noRange_noDhcp" + enableIPAM = false + + service { + provider = FlatNetworkServiceConstant.FLAT_NETWORK_SERVICE_TYPE_STRING + types = [EipConstant.EIP_NETWORK_SERVICE_TYPE] + } + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + // No IP range, no DHCP + } + } + + // ========== Flat: has IP range, no DHCP ========== + l2NoVlanNetwork { + name = "l2-flat-range-noDhcp" + physicalInterface = "eth1" + + l3Network { + name = "flatL3_range_noDhcp" + + service { + provider = FlatNetworkServiceConstant.FLAT_NETWORK_SERVICE_TYPE_STRING + types = [EipConstant.EIP_NETWORK_SERVICE_TYPE] + } + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + + ip { + startIp = "192.168.100.10" + endIp = "192.168.100.200" + netmask = "255.255.255.0" + gateway = "192.168.100.1" + } + } + } + + // ========== Flat: has IP range, has DHCP ========== + l2NoVlanNetwork { + name = "l2-flat-range-dhcp" + physicalInterface = "eth2" + + l3Network { + name = "flatL3_range_dhcp" + + service { + provider = FlatNetworkServiceConstant.FLAT_NETWORK_SERVICE_TYPE_STRING + types = [NetworkServiceType.DHCP.toString(), + UserdataConstant.USERDATA_TYPE_STRING, + EipConstant.EIP_NETWORK_SERVICE_TYPE] + } + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + + ip { + startIp = "192.168.200.10" + endIp = "192.168.200.200" + netmask = "255.255.255.0" + gateway = "192.168.200.1" + } + } + } + + // ========== Public: has IP range, has DHCP (VIP network for EIP tests only) ========== + l2NoVlanNetwork { + name = "l2-pub-range-dhcp" + physicalInterface = "eth4" + + l3Network { + name = "pubL3_range_dhcp" + category = "Public" + + service { + provider = FlatNetworkServiceConstant.FLAT_NETWORK_SERVICE_TYPE_STRING + types = [NetworkServiceType.DHCP.toString(), + EipConstant.EIP_NETWORK_SERVICE_TYPE] + } + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + + ip { + startIp = "12.100.20.10" + endIp = "12.100.20.200" + netmask = "255.255.255.0" + gateway = "12.100.20.1" + } + } + } + + // ========== Dedicated L2/L3 for orphan IP backfill test ========== + l2NoVlanNetwork { + name = "l2-backfill" + physicalInterface = "eth5" + + l3Network { + name = "flatL3_backfill" + enableIPAM = false + + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + // No IP range initially, no DHCP + } + } + + // ========== Destination L3 for changeVmNicNetwork tests ========== + l2NoVlanNetwork { + name = "l2-dest" + physicalInterface = "eth6" + + l3Network { + name = "flatL3_dest" + enableIPAM = false + + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + // No IP range, no DHCP — used as changeVmNicNetwork source + } + } + + attachBackupStorage("sftp") + } + } + } + + @Override + void test() { + dbf = bean(DatabaseFacade.class) + env.create { + // ========================================== + // Outside-range IPs are always allowed (no global config needed) + // ========================================== + + // --- Flat: no IP range, no DHCP --- + testSetStaticIp_flatNoRangeNoDhcp() + testChangeNicNetwork_flatNoRangeNoDhcp() + testDhcpSkip_flatNoRangeNoDhcp() + testEipReject_flatNoRangeNoDhcp() + + // --- Flat: has IP range, no DHCP --- + testSetStaticIp_flatRangeNoDhcp() + testChangeNicNetwork_flatRangeNoDhcp() + testDhcpSkip_flatRangeNoDhcp() + testEipReject_flatRangeNoDhcp() + + // --- Flat: has IP range, has DHCP --- + testSetStaticIp_flatRangeDhcp() + testChangeNicNetwork_flatRangeDhcp() + testDhcpSkip_flatRangeDhcp() + testEipReject_flatRangeDhcp() + + // --- NIC DNS priority --- + testNicDnsPreservedWhenApiOmitsDns_flatRangeDhcp() + testNicDnsRemovedWhenEmptyDnsList_flatRangeDhcp() + + + // ========================================== + // Orphan IP backfill + // ========================================== + testOrphanIpBackfillOnAddIpRange() + } + } + + + // ================================================================ + // Helper: create a VM on a given L3 + // ================================================================ + + VmInstanceInventory createVmOnL3(String vmName, String l3Uuid) { + return createVmInstance { + name = vmName + imageUuid = env.inventoryByName("image1").uuid + instanceOfferingUuid = env.inventoryByName("instanceOffering").uuid + l3NetworkUuids = [l3Uuid] + } + } + + // ================================================================ + // Flat: no IP range, no DHCP + // ================================================================ + + /** + * Flat/no-range/no-DHCP: setVmStaticIp with outside-range IP should succeed. + * Must provide netmask/gateway explicitly (no IP range to default from). + */ + void testSetStaticIp_flatNoRangeNoDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_noRange_noDhcp") + + VmInstanceInventory vm = createVmOnL3("vm-flat-noRange-noDhcp-set", l3.uuid) + + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = "172.16.0.50" + netmask = "255.255.0.0" + gateway = "172.16.0.1" + } + + // Verify UsedIpVO + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm.vmNics[0].uuid) + .eq(UsedIpVO_.ip, "172.16.0.50") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + assert usedIp.netmask == "255.255.0.0" + assert usedIp.gateway == "172.16.0.1" + + // Verify VmNicVO + VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) + assert nicVO.ip == "172.16.0.50" + assert nicVO.netmask == "255.255.0.0" + assert nicVO.gateway == "172.16.0.1" + } + + /** + * Flat/no-range/no-DHCP: changeVmNicNetwork with outside-range IP should succeed. + */ + void testChangeNicNetwork_flatNoRangeNoDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_noRange_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-flat-noRange-noDhcp-change", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = l3.uuid + systemTags = [ + String.format("staticIp::%s::172.16.0.60", l3.uuid), + String.format("ipv4Netmask::%s::255.255.0.0", l3.uuid), + String.format("ipv4Gateway::%s::172.16.0.1", l3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.l3NetworkUuid == l3.uuid + assert nicVO.ip == "172.16.0.60" + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vmNic.uuid) + .eq(UsedIpVO_.ip, "172.16.0.60") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + } + + /** + * Flat/no-range/no-DHCP: outside-range IP should NOT appear in DHCP messages on reboot. + */ + void testDhcpSkip_flatNoRangeNoDhcp() { + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-flat-noRange-noDhcp-set"] }[0] + + stopVmInstance { uuid = vm.uuid } + + boolean dhcpApplied = false + env.afterSimulator(FlatDhcpBackend.APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.ApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.ApplyDhcpCmd.class) + for (def dhcp : cmd.dhcp) { + if (dhcp.ip == "172.16.0.50") { dhcpApplied = true } + } + return rsp + } + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.BatchApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + for (def dhcpInfo : cmd.dhcpInfos) { + for (def dhcp : dhcpInfo.dhcp) { + if (dhcp.ip == "172.16.0.50") { dhcpApplied = true } + } + } + return rsp + } + + startVmInstance { uuid = vm.uuid } + + assert !dhcpApplied : "DHCP should NOT include outside-range IP 172.16.0.50 on no-DHCP L3" + } + + + /** + * Flat/no-range/no-DHCP: EIP should reject binding to NIC with outside-range IP. + */ + void testEipReject_flatNoRangeNoDhcp() { + L3NetworkInventory pubL3 = env.inventoryByName("pubL3_range_dhcp") + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-flat-noRange-noDhcp-set"] }[0] + L3NetworkInventory l3 = env.inventoryByName("flatL3_noRange_noDhcp") + VmNicInventory nic = vm.vmNics.find { it.l3NetworkUuid == l3.uuid } + assert nic != null + + VipInventory vip = createVip { + name = "vip-flat-noRange-noDhcp" + l3NetworkUuid = pubL3.uuid + } + EipInventory eip = createEip { + name = "eip-flat-noRange-noDhcp" + vipUuid = vip.uuid + } + + expect(AssertionError.class) { + attachEip { + eipUuid = eip.uuid + vmNicUuid = nic.uuid + } + } + } + + // ================================================================ + // Part 2: Global config ON — Flat: has IP range, no DHCP + // ================================================================ + + /** + * Flat/range/no-DHCP: outside-range IP should succeed; in-range IP also works. + */ + void testSetStaticIp_flatRangeNoDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_noDhcp") + + VmInstanceInventory vm = createVmOnL3("vm-flat-range-noDhcp-set", l3.uuid) + + // Outside-range IP should succeed + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = "10.0.0.50" + netmask = "255.255.255.0" + gateway = "10.0.0.1" + } + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm.vmNics[0].uuid) + .eq(UsedIpVO_.ip, "10.0.0.50") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + assert usedIp.netmask == "255.255.255.0" + assert usedIp.gateway == "10.0.0.1" + + VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) + assert nicVO.ip == "10.0.0.50" + + // Also verify in-range IP works + VmInstanceInventory vm2 = createVmOnL3("vm-flat-range-noDhcp-inrange", l3.uuid) + List freeIps1 = getFreeIp { + l3NetworkUuid = l3.uuid + ipVersion = IPv6Constants.IPv4 + limit = 1 + } as List + String inRangeIp1 = freeIps1.get(0).getIp() + + setVmStaticIp { + vmInstanceUuid = vm2.uuid + l3NetworkUuid = l3.uuid + ip = inRangeIp1 + } + + UsedIpVO inRangeIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) + .eq(UsedIpVO_.ip, inRangeIp1) + .find() + assert inRangeIp != null + assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" + } + + /** + * Flat/range/no-DHCP: changeVmNicNetwork with outside-range IP should succeed. + */ + void testChangeNicNetwork_flatRangeNoDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-flat-range-noDhcp-change", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = l3.uuid + systemTags = [ + String.format("staticIp::%s::10.0.0.60", l3.uuid), + String.format("ipv4Netmask::%s::255.255.255.0", l3.uuid), + String.format("ipv4Gateway::%s::10.0.0.1", l3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.l3NetworkUuid == l3.uuid + assert nicVO.ip == "10.0.0.60" + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vmNic.uuid) + .eq(UsedIpVO_.ip, "10.0.0.60") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + } + + /** + * Flat/range/no-DHCP: outside-range IP should NOT appear in DHCP messages on reboot. + */ + void testDhcpSkip_flatRangeNoDhcp() { + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-flat-range-noDhcp-set"] }[0] + + stopVmInstance { uuid = vm.uuid } + + boolean dhcpApplied = false + env.afterSimulator(FlatDhcpBackend.APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.ApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.ApplyDhcpCmd.class) + for (def dhcp : cmd.dhcp) { + if (dhcp.ip == "10.0.0.50") { dhcpApplied = true } + } + return rsp + } + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.BatchApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + for (def dhcpInfo : cmd.dhcpInfos) { + for (def dhcp : dhcpInfo.dhcp) { + if (dhcp.ip == "10.0.0.50") { dhcpApplied = true } + } + } + return rsp + } + + startVmInstance { uuid = vm.uuid } + + assert !dhcpApplied : "DHCP should NOT include outside-range IP 10.0.0.50 on no-DHCP L3" + } + + + /** + * Flat/range/no-DHCP: EIP should reject binding to NIC with outside-range IP. + */ + void testEipReject_flatRangeNoDhcp() { + L3NetworkInventory pubL3 = env.inventoryByName("pubL3_range_dhcp") + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-flat-range-noDhcp-set"] }[0] + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_noDhcp") + VmNicInventory nic = vm.vmNics.find { it.l3NetworkUuid == l3.uuid } + assert nic != null + + VipInventory vip = createVip { + name = "vip-flat-range-noDhcp" + l3NetworkUuid = pubL3.uuid + } + EipInventory eip = createEip { + name = "eip-flat-range-noDhcp" + vipUuid = vip.uuid + } + + expect(AssertionError.class) { + attachEip { + eipUuid = eip.uuid + vmNicUuid = nic.uuid + } + } + } + + // ================================================================ + // Part 2: Global config ON — Flat: has IP range, has DHCP + // ================================================================ + + /** + * Flat/range/DHCP: outside-range IP should succeed with global config ON; + * in-range IP also works normally. + */ + void testSetStaticIp_flatRangeDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_dhcp") + + VmInstanceInventory vm = createVmOnL3("vm-flat-range-dhcp-set", l3.uuid) + + // Outside-range IP should succeed with global config ON + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = "10.0.1.50" + netmask = "255.255.255.0" + gateway = "10.0.1.1" + } + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm.vmNics[0].uuid) + .eq(UsedIpVO_.ip, "10.0.1.50") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + assert usedIp.netmask == "255.255.255.0" + assert usedIp.gateway == "10.0.1.1" + + VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) + assert nicVO.ip == "10.0.1.50" + + // Also verify in-range IP works + VmInstanceInventory vm2 = createVmOnL3("vm-flat-range-dhcp-inrange", l3.uuid) + List freeIps2 = getFreeIp { + l3NetworkUuid = l3.uuid + ipVersion = IPv6Constants.IPv4 + limit = 1 + } as List + String inRangeIp2 = freeIps2.get(0).getIp() + + setVmStaticIp { + vmInstanceUuid = vm2.uuid + l3NetworkUuid = l3.uuid + ip = inRangeIp2 + } + + UsedIpVO inRangeIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) + .eq(UsedIpVO_.ip, inRangeIp2) + .find() + assert inRangeIp != null + assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" + } + + /** + * Flat/range/DHCP: changeVmNicNetwork with outside-range IP should succeed. + */ + void testChangeNicNetwork_flatRangeDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_dhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-flat-range-dhcp-change", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = l3.uuid + systemTags = [ + String.format("staticIp::%s::10.0.1.60", l3.uuid), + String.format("ipv4Netmask::%s::255.255.255.0", l3.uuid), + String.format("ipv4Gateway::%s::10.0.1.1", l3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.l3NetworkUuid == l3.uuid + assert nicVO.ip == "10.0.1.60" + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vmNic.uuid) + .eq(UsedIpVO_.ip, "10.0.1.60") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + } + + /** + * Flat/range/DHCP: outside-range IP should NOT appear in DHCP messages on reboot, + * even though DHCP service is enabled on this L3. + */ + void testDhcpSkip_flatRangeDhcp() { + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-flat-range-dhcp-set"] }[0] + + stopVmInstance { uuid = vm.uuid } + + boolean dhcpAppliedForOutsideIp = false + env.afterSimulator(FlatDhcpBackend.APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.ApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.ApplyDhcpCmd.class) + for (def dhcp : cmd.dhcp) { + if (dhcp.ip == "10.0.1.50") { dhcpAppliedForOutsideIp = true } + } + return rsp + } + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.BatchApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + for (def dhcpInfo : cmd.dhcpInfos) { + for (def dhcp : dhcpInfo.dhcp) { + if (dhcp.ip == "10.0.1.50") { dhcpAppliedForOutsideIp = true } + } + } + return rsp + } + + startVmInstance { uuid = vm.uuid } + + assert !dhcpAppliedForOutsideIp : "DHCP should NOT include outside-range IP 10.0.1.50 even on DHCP-enabled L3" + } + + + /** + * Flat/range/DHCP: EIP should reject binding to NIC with outside-range IP. + */ + void testEipReject_flatRangeDhcp() { + L3NetworkInventory pubL3 = env.inventoryByName("pubL3_range_dhcp") + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-flat-range-dhcp-set"] }[0] + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_dhcp") + VmNicInventory nic = vm.vmNics.find { it.l3NetworkUuid == l3.uuid } + assert nic != null + + VipInventory vip = createVip { + name = "vip-flat-range-dhcp" + l3NetworkUuid = pubL3.uuid + } + EipInventory eip = createEip { + name = "eip-flat-range-dhcp" + vipUuid = vip.uuid + } + + expect(AssertionError.class) { + attachEip { + eipUuid = eip.uuid + vmNicUuid = nic.uuid + } + } + } + + // ================================================================ + // NIC DNS priority: NIC DNS > L3 DNS + // ================================================================ + + /** + * When setVmStaticIp is called with dnsAddresses, NIC-level DNS is used in DHCP. + * When setVmStaticIp is called again WITHOUT dnsAddresses (null), old NIC DNS is preserved. + */ + void testNicDnsPreservedWhenApiOmitsDns_flatRangeDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_dhcp") + + // Add L3-level DNS + addDnsToL3Network { + l3NetworkUuid = l3.uuid + dns = "8.8.8.8" + } + + // Create a VM on the DHCP-enabled L3 + VmInstanceInventory vm = createVmOnL3("vm-nic-dns-preserve", l3.uuid) + + // Set NIC-level DNS via setVmStaticIp + List freeIps = getFreeIp { + l3NetworkUuid = l3.uuid + ipVersion = IPv6Constants.IPv4 + limit = 1 + } as List + String ip1 = freeIps.get(0).getIp() + + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = ip1 + dnsAddresses = ["1.1.1.1", "1.0.0.1"] + } + + // Intercept DHCP apply and reboot to trigger DHCP re-apply + FlatDhcpBackend.BatchApplyDhcpCmd cmd1 = null + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + cmd1 = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + return rsp + } + + rebootVmInstance { uuid = vm.uuid } + + assert cmd1 != null + def dhcp1 = cmd1.dhcpInfos.collectMany { it.dhcp }.find { it.ip == ip1 } + assert dhcp1 != null : "DHCP entry for IP ${ip1} should exist" + assert dhcp1.dns == ["1.1.1.1", "1.0.0.1"] : "NIC DNS should override L3 DNS, got: ${dhcp1.dns}" + + // Now call setVmStaticIp again WITHOUT dnsAddresses (only change IP) + // dnsAddresses is null => old NIC DNS should be preserved + List freeIps2 = getFreeIp { + l3NetworkUuid = l3.uuid + ipVersion = IPv6Constants.IPv4 + limit = 1 + } as List + String ip2 = freeIps2.get(0).getIp() + + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = ip2 + // dnsAddresses is NOT set => null in the message + } + + FlatDhcpBackend.BatchApplyDhcpCmd cmd2 = null + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + cmd2 = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + return rsp + } + + rebootVmInstance { uuid = vm.uuid } + + assert cmd2 != null + def dhcp2 = cmd2.dhcpInfos.collectMany { it.dhcp }.find { it.ip == ip2 } + assert dhcp2 != null : "DHCP entry for IP ${ip2} should exist" + assert dhcp2.dns == ["1.1.1.1", "1.0.0.1"] : "NIC DNS should be preserved when API omits dnsAddresses, got: ${dhcp2.dns}" + } + + /** + * When setVmStaticIp is called with an empty dnsAddresses list ([]), + * NIC DNS is removed and DHCP falls back to L3 network DNS. + */ + void testNicDnsRemovedWhenEmptyDnsList_flatRangeDhcp() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_dhcp") + + // The previous test already added "8.8.8.8" as L3 DNS and created "vm-nic-dns-preserve" + // with NIC DNS ["1.1.1.1", "1.0.0.1"]. Reuse that VM. + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-nic-dns-preserve"] }[0] + + // Must use a different IP because setVmStaticIp rejects the same IP + List freeIps = getFreeIp { + l3NetworkUuid = l3.uuid + ipVersion = IPv6Constants.IPv4 + limit = 1 + } as List + String newIp = freeIps.get(0).getIp() + + // Call setVmStaticIp with a new IP and explicit empty dnsAddresses => removes NIC DNS + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = newIp + dnsAddresses = [] + } + + // Intercept DHCP apply and reboot + FlatDhcpBackend.BatchApplyDhcpCmd cmd = null + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + return rsp + } + + rebootVmInstance { uuid = vm.uuid } + + assert cmd != null + def dhcp = cmd.dhcpInfos.collectMany { it.dhcp }.find { it.ip == newIp } + assert dhcp != null : "DHCP entry for IP ${newIp} should exist" + assert dhcp.dns.contains("8.8.8.8") : "After removing NIC DNS, DHCP should fall back to L3 DNS (8.8.8.8), got: ${dhcp.dns}" + assert !dhcp.dns.contains("1.1.1.1") : "NIC DNS 1.1.1.1 should no longer be present after clearing, got: ${dhcp.dns}" + assert !dhcp.dns.contains("1.0.0.1") : "NIC DNS 1.0.0.1 should no longer be present after clearing, got: ${dhcp.dns}" + } + + + // ================================================================ + // Part 3: Orphan IP backfill (global config ON) + // ================================================================ + + /** + * Create orphan IPs on flatL3_backfill (no IP range), + * then add an IP range covering the orphan IPs. + * Verify ipRangeUuid is backfilled and capacity increases. + */ + void testOrphanIpBackfillOnAddIpRange() { + L3NetworkInventory backfillL3 = env.inventoryByName("flatL3_backfill") + + // Step 1: create VMs and assign outside-range IPs + VmInstanceInventory orphanVm1 = createVmOnL3("vm-backfill-orphan-1", backfillL3.uuid) + setVmStaticIp { + vmInstanceUuid = orphanVm1.uuid + l3NetworkUuid = backfillL3.uuid + ip = "172.16.0.80" + netmask = "255.255.0.0" + gateway = "172.16.0.1" + } + + VmInstanceInventory orphanVm2 = createVmOnL3("vm-backfill-orphan-2", backfillL3.uuid) + setVmStaticIp { + vmInstanceUuid = orphanVm2.uuid + l3NetworkUuid = backfillL3.uuid + ip = "172.16.0.90" + netmask = "255.255.0.0" + gateway = "172.16.0.1" + } + + // Confirm orphan IPs exist + long outsideCount = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, backfillL3.uuid) + .isNull(UsedIpVO_.ipRangeUuid) + .count() + assert outsideCount == 2 : "There should be 2 orphan IPs on flatL3_backfill" + + // Step 2: record capacity before backfill + GetIpAddressCapacityResult beforeBackfill = getIpAddressCapacity { + l3NetworkUuids = [backfillL3.uuid] + } + + // Step 3: add IP range covering the orphan IPs (172.16.0.80, 172.16.0.90) + IpRangeInventory ipRange = addIpRange { + delegate.name = "backfill-ip-range" + delegate.l3NetworkUuid = backfillL3.uuid + delegate.startIp = "172.16.0.2" + delegate.endIp = "172.16.0.253" + delegate.gateway = "172.16.0.1" + delegate.netmask = "255.255.0.0" + } + + // Step 4: verify orphan IPs now have ipRangeUuid backfilled + long backfilledCount = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, backfillL3.uuid) + .eq(UsedIpVO_.ipRangeUuid, ipRange.uuid) + .count() + assert backfilledCount == outsideCount : + "all ${outsideCount} orphan IPs should now be associated with the new IP range" + + // No more orphan IPs + long remainingOrphanCount = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, backfillL3.uuid) + .isNull(UsedIpVO_.ipRangeUuid) + .count() + assert remainingOrphanCount == 0 : "all orphan IPs should now have ipRangeUuid" + + // Step 5: capacity should now include the backfilled IPs + GetIpAddressCapacityResult afterBackfill = getIpAddressCapacity { + l3NetworkUuids = [backfillL3.uuid] + } + assert afterBackfill.totalCapacity > beforeBackfill.totalCapacity : + "totalCapacity should increase after adding new IP range" + assert afterBackfill.usedIpAddressNumber == beforeBackfill.usedIpAddressNumber + outsideCount : + "usedIpAddressNumber should increase by ${outsideCount} after backfill" + } +} diff --git a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy new file mode 100644 index 00000000000..cd226cac406 --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy @@ -0,0 +1,443 @@ +package org.zstack.test.integration.networkservice.provider.flat + +import org.springframework.http.HttpEntity +import org.zstack.core.db.DatabaseFacade +import org.zstack.core.db.Q +import org.zstack.header.network.l3.UsedIpVO +import org.zstack.header.network.l3.UsedIpVO_ +import org.zstack.header.network.service.NetworkServiceType +import org.zstack.header.vm.VmNicVO +import org.zstack.network.securitygroup.SecurityGroupConstant +import org.zstack.network.service.eip.EipConstant +import org.zstack.network.service.flat.FlatDhcpBackend +import org.zstack.network.service.flat.FlatNetworkServiceConstant +import org.zstack.sdk.* +import org.zstack.test.integration.networkservice.provider.NetworkServiceProviderTest +import org.zstack.testlib.EnvSpec +import org.zstack.testlib.SubCase +import org.zstack.utils.data.SizeUnit +import org.zstack.utils.gson.JSONObjectUtil +import org.zstack.utils.network.IPv6Constants + +/** + * Test IP outside CIDR behavior for public networks. + * Outside-range IPs are always allowed (no global config needed). + * + * Public network combinations (2 combos): + * 1. pubL3_range_noDhcp — has IP range, no DHCP (enableIPAM=true, enableIpAddressAllocation()=false) + * 2. pubL3_range_dhcp — has IP range, has DHCP (enableIPAM=true, enableIpAddressAllocation()=true) + * + * Each scenario tests: setVmStaticIp, changeVmNicNetwork, DHCP skip. + * No EIP tests — public networks do not need EIP testing. + */ +class PublicNetworkChangeVmIpOutsideCidrCase extends SubCase { + + EnvSpec env + DatabaseFacade dbf + + @Override + void setup() { + useSpring(NetworkServiceProviderTest.springSpec) + } + + @Override + void clean() { + env.delete() + } + + @Override + void environment() { + env = env { + instanceOffering { + name = "instanceOffering" + memory = SizeUnit.GIGABYTE.toByte(1) + cpu = 1 + } + + sftpBackupStorage { + name = "sftp" + url = "/sftp" + username = "root" + password = "password" + hostname = "localhost" + + image { + name = "image1" + url = "http://zstack.org/download/test.qcow2" + } + } + + zone { + name = "zone" + description = "test" + + cluster { + name = "cluster" + hypervisorType = "KVM" + + kvm { + name = "kvm" + managementIp = "127.0.0.1" + username = "root" + password = "password" + } + + attachPrimaryStorage("local") + attachL2Network("l2-pub-range-noDhcp") + attachL2Network("l2-pub-range-dhcp") + attachL2Network("l2-dest") + } + + localPrimaryStorage { + name = "local" + url = "/local_ps" + } + + // ========== Public: has IP range, no DHCP ========== + l2NoVlanNetwork { + name = "l2-pub-range-noDhcp" + physicalInterface = "eth0" + + l3Network { + name = "pubL3_range_noDhcp" + category = "Public" + + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + + ip { + startIp = "12.100.10.10" + endIp = "12.100.10.200" + netmask = "255.255.255.0" + gateway = "12.100.10.1" + } + } + } + + // ========== Public: has IP range, has DHCP ========== + l2NoVlanNetwork { + name = "l2-pub-range-dhcp" + physicalInterface = "eth1" + + l3Network { + name = "pubL3_range_dhcp" + category = "Public" + + service { + provider = FlatNetworkServiceConstant.FLAT_NETWORK_SERVICE_TYPE_STRING + types = [NetworkServiceType.DHCP.toString(), + EipConstant.EIP_NETWORK_SERVICE_TYPE] + } + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + + ip { + startIp = "12.100.20.10" + endIp = "12.100.20.200" + netmask = "255.255.255.0" + gateway = "12.100.20.1" + } + } + } + + // ========== Destination L3 for changeVmNicNetwork tests ========== + l2NoVlanNetwork { + name = "l2-dest" + physicalInterface = "eth2" + + l3Network { + name = "flatL3_dest" + enableIPAM = false + + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + // No IP range, no DHCP — used as changeVmNicNetwork source + } + } + + attachBackupStorage("sftp") + } + } + } + + @Override + void test() { + dbf = bean(DatabaseFacade.class) + env.create { + // ========================================== + // Outside-range IPs are always allowed (no global config needed) + // ========================================== + + // --- Public: has IP range, no DHCP --- + testSetStaticIp_pubRangeNoDhcp() + testChangeNicNetwork_pubRangeNoDhcp() + testDhcpSkip_pubRangeNoDhcp() + + // --- Public: has IP range, has DHCP --- + testSetStaticIp_pubRangeDhcp() + testChangeNicNetwork_pubRangeDhcp() + testDhcpSkip_pubRangeDhcp() + } + } + + // ================================================================ + // Helper: create a VM on a given L3 + // ================================================================ + + VmInstanceInventory createVmOnL3(String vmName, String l3Uuid) { + return createVmInstance { + name = vmName + imageUuid = env.inventoryByName("image1").uuid + instanceOfferingUuid = env.inventoryByName("instanceOffering").uuid + l3NetworkUuids = [l3Uuid] + } + } + + // ================================================================ + // Public: has IP range, no DHCP + // ================================================================ + + /** + * Public/range/no-DHCP: outside-range IP should succeed; in-range IP also works. + */ + void testSetStaticIp_pubRangeNoDhcp() { + L3NetworkInventory l3 = env.inventoryByName("pubL3_range_noDhcp") + + VmInstanceInventory vm = createVmOnL3("vm-pub-range-noDhcp-set", l3.uuid) + + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = "10.0.2.50" + netmask = "255.255.255.0" + gateway = "10.0.2.1" + } + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm.vmNics[0].uuid) + .eq(UsedIpVO_.ip, "10.0.2.50") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + assert usedIp.netmask == "255.255.255.0" + assert usedIp.gateway == "10.0.2.1" + + VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) + assert nicVO.ip == "10.0.2.50" + + // Also verify in-range IP works + VmInstanceInventory vm2 = createVmOnL3("vm-pub-range-noDhcp-inrange", l3.uuid) + List freeIps1 = getFreeIp { + l3NetworkUuid = l3.uuid + ipVersion = IPv6Constants.IPv4 + limit = 1 + } as List + String inRangeIp1 = freeIps1.get(0).getIp() + + setVmStaticIp { + vmInstanceUuid = vm2.uuid + l3NetworkUuid = l3.uuid + ip = inRangeIp1 + } + + UsedIpVO inRangeIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) + .eq(UsedIpVO_.ip, inRangeIp1) + .find() + assert inRangeIp != null + assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" + } + + /** + * Public/range/no-DHCP: changeVmNicNetwork with outside-range IP should succeed. + */ + void testChangeNicNetwork_pubRangeNoDhcp() { + L3NetworkInventory l3 = env.inventoryByName("pubL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-pub-range-noDhcp-change", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = l3.uuid + systemTags = [ + String.format("staticIp::%s::10.0.2.60", l3.uuid), + String.format("ipv4Netmask::%s::255.255.255.0", l3.uuid), + String.format("ipv4Gateway::%s::10.0.2.1", l3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.l3NetworkUuid == l3.uuid + assert nicVO.ip == "10.0.2.60" + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vmNic.uuid) + .eq(UsedIpVO_.ip, "10.0.2.60") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + } + + /** + * Public/range/no-DHCP: outside-range IP should NOT appear in DHCP messages on reboot. + */ + void testDhcpSkip_pubRangeNoDhcp() { + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-pub-range-noDhcp-set"] }[0] + + stopVmInstance { uuid = vm.uuid } + + boolean dhcpApplied = false + env.afterSimulator(FlatDhcpBackend.APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.ApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.ApplyDhcpCmd.class) + for (def dhcp : cmd.dhcp) { + if (dhcp.ip == "10.0.2.50") { dhcpApplied = true } + } + return rsp + } + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + FlatDhcpBackend.BatchApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + for (def dhcpInfo : cmd.dhcpInfos) { + for (def dhcp : dhcpInfo.dhcp) { + if (dhcp.ip == "10.0.2.50") { dhcpApplied = true } + } + } + return rsp + } + + startVmInstance { uuid = vm.uuid } + + assert !dhcpApplied : "DHCP should NOT include outside-range IP 10.0.2.50" + } + + // ================================================================ + // Part 2: Global config ON — Public: has IP range, has DHCP + // ================================================================ + + /** + * Public/range/DHCP: outside-range IP should succeed; in-range IP also works. + */ + void testSetStaticIp_pubRangeDhcp() { + L3NetworkInventory l3 = env.inventoryByName("pubL3_range_dhcp") + + VmInstanceInventory vm = createVmOnL3("vm-pub-range-dhcp-set", l3.uuid) + + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = "10.0.3.50" + netmask = "255.255.255.0" + gateway = "10.0.3.1" + } + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm.vmNics[0].uuid) + .eq(UsedIpVO_.ip, "10.0.3.50") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + assert usedIp.netmask == "255.255.255.0" + assert usedIp.gateway == "10.0.3.1" + + VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) + assert nicVO.ip == "10.0.3.50" + + // Also verify in-range IP works + VmInstanceInventory vm2 = createVmOnL3("vm-pub-range-dhcp-inrange", l3.uuid) + List freeIps2 = getFreeIp { + l3NetworkUuid = l3.uuid + ipVersion = IPv6Constants.IPv4 + limit = 1 + } as List + String inRangeIp2 = freeIps2.get(0).getIp() + + setVmStaticIp { + vmInstanceUuid = vm2.uuid + l3NetworkUuid = l3.uuid + ip = inRangeIp2 + } + + UsedIpVO inRangeIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) + .eq(UsedIpVO_.ip, inRangeIp2) + .find() + assert inRangeIp != null + assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" + } + + /** + * Public/range/DHCP: changeVmNicNetwork with outside-range IP should succeed. + */ + void testChangeNicNetwork_pubRangeDhcp() { + L3NetworkInventory l3 = env.inventoryByName("pubL3_range_dhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-pub-range-dhcp-change", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = l3.uuid + systemTags = [ + String.format("staticIp::%s::10.0.3.60", l3.uuid), + String.format("ipv4Netmask::%s::255.255.255.0", l3.uuid), + String.format("ipv4Gateway::%s::10.0.3.1", l3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.l3NetworkUuid == l3.uuid + assert nicVO.ip == "10.0.3.60" + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vmNic.uuid) + .eq(UsedIpVO_.ip, "10.0.3.60") + .find() + assert usedIp != null + assert usedIp.ipRangeUuid == null : "ipRangeUuid should be null for outside-range IP" + } + + /** + * Public/range/DHCP: outside-range IP should NOT appear in DHCP messages on reboot, + * even though DHCP service is enabled on this L3. + */ + void testDhcpSkip_pubRangeDhcp() { + VmInstanceInventory vm = queryVmInstance { conditions = ["name=vm-pub-range-dhcp-set"] }[0] + + stopVmInstance { uuid = vm.uuid } + + boolean dhcpAppliedForOutsideIp = false + boolean dhcpTriggered = false + env.afterSimulator(FlatDhcpBackend.APPLY_DHCP_PATH) { rsp, HttpEntity e -> + dhcpTriggered = true + FlatDhcpBackend.ApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.ApplyDhcpCmd.class) + for (def dhcp : cmd.dhcp) { + if (dhcp.ip == "10.0.3.50") { dhcpAppliedForOutsideIp = true } + } + return rsp + } + env.afterSimulator(FlatDhcpBackend.BATCH_APPLY_DHCP_PATH) { rsp, HttpEntity e -> + dhcpTriggered = true + FlatDhcpBackend.BatchApplyDhcpCmd cmd = JSONObjectUtil.toObject(e.body, FlatDhcpBackend.BatchApplyDhcpCmd.class) + for (def dhcpInfo : cmd.dhcpInfos) { + for (def dhcp : dhcpInfo.dhcp) { + if (dhcp.ip == "10.0.3.50") { dhcpAppliedForOutsideIp = true } + } + } + return rsp + } + + startVmInstance { uuid = vm.uuid } + + assert !dhcpTriggered : "expected DHCP backend to run on a DHCP-enabled public L3" + assert !dhcpAppliedForOutsideIp : "DHCP should NOT include outside-range IP 10.0.3.50 even on DHCP-enabled public L3" + } +} + diff --git a/testlib/src/main/java/org/zstack/testlib/Test.groovy b/testlib/src/main/java/org/zstack/testlib/Test.groovy index dc3b369f8b2..40f46defad1 100755 --- a/testlib/src/main/java/org/zstack/testlib/Test.groovy +++ b/testlib/src/main/java/org/zstack/testlib/Test.groovy @@ -1001,6 +1001,20 @@ mysqldump -u root zstack > ${failureLogDir.absolutePath}/dbdump.sql } } + /** + * Expect an API call to fail and verify the error details. + * The second closure's delegate is the parsed {@link ErrorCodeList}, so you can + * directly access {@code code}, {@code details}, {@code globalErrorCode}, etc. + * + * Example: + *
+     *   expectApiFailure {
+     *       someApiCall { ... }
+     *   } {
+     *       assert details.contains("expected error keyword")
+     *   }
+     * 
+ */ static void expectApiFailure(Closure c, @DelegatesTo(strategy = Closure.OWNER_FIRST, value = ErrorCodeList.class) Closure errorCodeChecker) { AssertionError error = null diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index 0deb26d677d..e84e33e7412 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -970,6 +970,12 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_L3_10078 = "ORG_ZSTACK_NETWORK_L3_10078"; + public static final String ORG_ZSTACK_NETWORK_L3_10079 = "ORG_ZSTACK_NETWORK_L3_10079"; + + public static final String ORG_ZSTACK_NETWORK_L3_10080 = "ORG_ZSTACK_NETWORK_L3_10080"; + + public static final String ORG_ZSTACK_NETWORK_L3_10081 = "ORG_ZSTACK_NETWORK_L3_10081"; + public static final String ORG_ZSTACK_SNS_PLATFORM_UNIVERSALSMS_SUPPLIER_EMAY_10000 = "ORG_ZSTACK_SNS_PLATFORM_UNIVERSALSMS_SUPPLIER_EMAY_10000"; public static final String ORG_ZSTACK_CORE_VALIDATION_10000 = "ORG_ZSTACK_CORE_VALIDATION_10000"; @@ -3234,6 +3240,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10024 = "ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10024"; + public static final String ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10025 = "ORG_ZSTACK_NETWORK_SERVICE_PORTFORWARDING_10025"; + public static final String ORG_ZSTACK_STORAGE_DEVICE_10000 = "ORG_ZSTACK_STORAGE_DEVICE_10000"; public static final String ORG_ZSTACK_STORAGE_DEVICE_10001 = "ORG_ZSTACK_STORAGE_DEVICE_10001"; @@ -9850,6 +9858,22 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_COMPUTE_VM_10320 = "ORG_ZSTACK_COMPUTE_VM_10320"; + public static final String ORG_ZSTACK_COMPUTE_VM_10321 = "ORG_ZSTACK_COMPUTE_VM_10321"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10322 = "ORG_ZSTACK_COMPUTE_VM_10322"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10323 = "ORG_ZSTACK_COMPUTE_VM_10323"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10324 = "ORG_ZSTACK_COMPUTE_VM_10324"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10325 = "ORG_ZSTACK_COMPUTE_VM_10325"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10326 = "ORG_ZSTACK_COMPUTE_VM_10326"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10327 = "ORG_ZSTACK_COMPUTE_VM_10327"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10328 = "ORG_ZSTACK_COMPUTE_VM_10328"; + public static final String ORG_ZSTACK_IDENTITY_LOGIN_10000 = "ORG_ZSTACK_IDENTITY_LOGIN_10000"; public static final String ORG_ZSTACK_STORAGE_VOLUME_BLOCK_EXPON_10000 = "ORG_ZSTACK_STORAGE_VOLUME_BLOCK_EXPON_10000"; @@ -10402,6 +10426,7 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_SECURITYGROUP_10129 = "ORG_ZSTACK_NETWORK_SECURITYGROUP_10129"; + public static final String ORG_ZSTACK_TEMPLATECONFIG_10000 = "ORG_ZSTACK_TEMPLATECONFIG_10000"; public static final String ORG_ZSTACK_TEMPLATECONFIG_10001 = "ORG_ZSTACK_TEMPLATECONFIG_10001"; @@ -13628,6 +13653,14 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_SERVICE_LB_10172 = "ORG_ZSTACK_NETWORK_SERVICE_LB_10172"; + public static final String ORG_ZSTACK_NETWORK_SERVICE_LB_10173 = "ORG_ZSTACK_NETWORK_SERVICE_LB_10173"; + + public static final String ORG_ZSTACK_NETWORK_SERVICE_LB_10174 = "ORG_ZSTACK_NETWORK_SERVICE_LB_10174"; + + public static final String ORG_ZSTACK_NETWORK_SERVICE_LB_10175 = "ORG_ZSTACK_NETWORK_SERVICE_LB_10175"; + + public static final String ORG_ZSTACK_NETWORK_SERVICE_LB_10176 = "ORG_ZSTACK_NETWORK_SERVICE_LB_10176"; + public static final String ORG_ZSTACK_IPSEC_10000 = "ORG_ZSTACK_IPSEC_10000"; public static final String ORG_ZSTACK_IPSEC_10001 = "ORG_ZSTACK_IPSEC_10001"; @@ -15150,6 +15183,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_SERVICE_EIP_10023 = "ORG_ZSTACK_NETWORK_SERVICE_EIP_10023"; + public static final String ORG_ZSTACK_NETWORK_SERVICE_EIP_10024 = "ORG_ZSTACK_NETWORK_SERVICE_EIP_10024"; + public static final String ORG_ZSTACK_TEST_INTEGRATION_GUESTTOOLS_10000 = "ORG_ZSTACK_TEST_INTEGRATION_GUESTTOOLS_10000"; public static final String ORG_ZSTACK_TEST_INTEGRATION_GUESTTOOLS_10001 = "ORG_ZSTACK_TEST_INTEGRATION_GUESTTOOLS_10001"; diff --git a/utils/src/main/java/org/zstack/utils/network/NicIpAddressInfo.java b/utils/src/main/java/org/zstack/utils/network/NicIpAddressInfo.java index 9803946ee25..f190a2f278b 100644 --- a/utils/src/main/java/org/zstack/utils/network/NicIpAddressInfo.java +++ b/utils/src/main/java/org/zstack/utils/network/NicIpAddressInfo.java @@ -1,5 +1,7 @@ package org.zstack.utils.network; +import java.util.List; + public class NicIpAddressInfo { public String ipv4Address; public String ipv4Gateway; @@ -7,6 +9,7 @@ public class NicIpAddressInfo { public String ipv6Address; public String ipv6Gateway; public String ipv6Prefix; + public List dnsAddresses; public NicIpAddressInfo(String ipv4Address, String ipv4Gateway, String ipv4Netmask, String ipv6Address, String ipv6Gateway, String ipv6Prefix) { this.ipv4Address = ipv4Address; @@ -15,5 +18,6 @@ public NicIpAddressInfo(String ipv4Address, String ipv4Gateway, String ipv4Netma this.ipv6Address = ipv6Address; this.ipv6Gateway = ipv6Gateway; this.ipv6Prefix = ipv6Prefix; + this.dnsAddresses = null; } } From 44aec1912887e0046e82f7a6d06ef76e25856abd Mon Sep 17 00:00:00 2001 From: "shixin.ruan" Date: Tue, 17 Mar 2026 12:45:34 +0800 Subject: [PATCH 78/85] [l2network]: validate physicalInterface for LinuxBridge When vSwitchType is LinuxBridge, physicalInterface must not be null or empty. Add interceptor check in L2NetworkApiInterceptor and unit test in AttachL2NetworkCase. ZSTAC-83300 Change-Id: I333a5bac866354c366051fece84e447f089e2306 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../network/l2/L2NetworkApiInterceptor.java | 6 ++++ .../l2network/AttachL2NetworkCase.groovy | 31 +++++++++++++++++++ .../CloudOperationsErrorCode.java | 2 ++ 3 files changed, 39 insertions(+) diff --git a/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java b/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java index 6f0b796e5b8..40786eb7b2e 100755 --- a/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java +++ b/network/src/main/java/org/zstack/network/l2/L2NetworkApiInterceptor.java @@ -123,6 +123,12 @@ private void validate(APICreateL2NetworkMsg msg) { } catch (Exception e) { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_L2_10012, "unsupported vSwitch type[%s]", msg.getvSwitchType())); } + + if (L2NetworkConstant.VSWITCH_TYPE_LINUX_BRIDGE.equals(msg.getvSwitchType()) + && (msg.getPhysicalInterface() == null || msg.getPhysicalInterface().trim().isEmpty())) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_NETWORK_L2_10021, + "physicalInterface is required when vSwitchType is [%s]", msg.getvSwitchType())); + } } private void validate(APIChangeL2NetworkVlanIdMsg msg) { diff --git a/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy b/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy index f31d19bae92..7d5e8bbfa1d 100644 --- a/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/network/l2network/AttachL2NetworkCase.groovy @@ -94,6 +94,7 @@ public class AttachL2NetworkCase extends SubCase{ @Override public void test() { env.create { + testCreateL2NetworkWithoutPhysicalInterface() testAttachL2NoVlanNetwork() testAttachL2ValnNetwork() testAttachL2NoVlanNetworkSynchronously() @@ -103,6 +104,36 @@ public class AttachL2NetworkCase extends SubCase{ } + void testCreateL2NetworkWithoutPhysicalInterface() { + ZoneInventory zone = env.inventoryByName("zone") + + // creating L2 NoVlan network without physicalInterface should fail when vSwitchType is LinuxBridge + expect(AssertionError.class) { + createL2NoVlanNetwork { + name = "test-no-physical-interface" + zoneUuid = zone.uuid + } + } + + // creating L2 NoVlan network with empty physicalInterface should also fail + expect(AssertionError.class) { + createL2NoVlanNetwork { + name = "test-empty-physical-interface" + zoneUuid = zone.uuid + physicalInterface = "" + } + } + + // creating L2 Vlan network without physicalInterface should fail when vSwitchType is LinuxBridge + expect(AssertionError.class) { + createL2VlanNetwork { + name = "test-vlan-no-physical-interface" + zoneUuid = zone.uuid + vlan = 100 + } + } + } + void testAttachL2NoVlanNetwork(){ L2NetworkInventory l21 = env.inventoryByName("l2-1") L2NetworkInventory l22 = env.inventoryByName("l2-2") diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index 0deb26d677d..f88b2100e08 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -1016,6 +1016,8 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_NETWORK_L2_10020 = "ORG_ZSTACK_NETWORK_L2_10020"; + public static final String ORG_ZSTACK_NETWORK_L2_10021 = "ORG_ZSTACK_NETWORK_L2_10021"; + public static final String ORG_ZSTACK_CONSOLE_10000 = "ORG_ZSTACK_CONSOLE_10000"; public static final String ORG_ZSTACK_CONSOLE_10001 = "ORG_ZSTACK_CONSOLE_10001"; From 76044ca40c742272129d8f8970aa23133006030d Mon Sep 17 00:00:00 2001 From: "shixin.ruan" Date: Tue, 17 Mar 2026 17:07:06 +0800 Subject: [PATCH 79/85] [docs]: move to zstack/docs Resolves: ZSTAC-81969 Change-Id: I7176617669636d7362616973787a68766c6d6579 --- docs/modules/network/nav.adoc | 1 + .../network/pages/networkResource/l3Ipam.adoc | 202 ++++++++++++++++++ .../networkResource/networkResource.adoc | 1 + .../java/org/zstack/network/l3/zstack ipam.md | 139 ------------ 4 files changed, 204 insertions(+), 139 deletions(-) create mode 100644 docs/modules/network/pages/networkResource/l3Ipam.adoc delete mode 100644 network/src/main/java/org/zstack/network/l3/zstack ipam.md diff --git a/docs/modules/network/nav.adoc b/docs/modules/network/nav.adoc index 53e42d44f22..19dbb6c06b1 100644 --- a/docs/modules/network/nav.adoc +++ b/docs/modules/network/nav.adoc @@ -2,5 +2,6 @@ ** xref:networkResource/networkResource.adoc[] *** xref:networkResource/L2Network.adoc[] *** xref:networkResource/L3Network.adoc[] + *** xref:networkResource/l3Ipam.adoc[] *** xref:networkResource/VpcRouter.adoc[] ** xref:networkService/networkService.adoc[] \ No newline at end of file diff --git a/docs/modules/network/pages/networkResource/l3Ipam.adoc b/docs/modules/network/pages/networkResource/l3Ipam.adoc new file mode 100644 index 00000000000..ce3fcda8335 --- /dev/null +++ b/docs/modules/network/pages/networkResource/l3Ipam.adoc @@ -0,0 +1,202 @@ += 三层网络IPAM + +ZStack IPAM 负责管理三层网络(L3)的 IP 地址分配与回收,支持以下三种方式: + +. 自动分配:ZStack 云平台根据 L3 网络配置的 IP range 自动分配。 +. 手动分配:用户在创建云主机时手动指定 IP 地址。 +. QGA 获取:通过 QEMU Guest Agent(QGA)从云主机内部读取并上报 IP 地址。 + +== 自动分配 + +自动分配需要满足以下两个条件: + +. L3 网络已配置 IP range。 +. L3 网络已启用 DHCP 服务。 ++ +这是历史遗留行为:扁平网络使用 DHCP 服务作为自动分配功能的开关,其他网络类型不受影响。 + +系统根据用户输入的 L3 网络 UUID 以及可选的 IP 地址,按照分配算法分配一个可用地址。返回的地址信息包括:IP 地址、掩码(或前缀长度)、网关。 + +=== 自动分配算法 + +* 随机分配:从可用地址池中随机选择一个 IP 地址。 +* 顺序分配:从可用地址池中按顺序选择一个 IP 地址。 +* 循环分配:从可用地址池中按顺序分配,分配到最后一个 IP 后回到第一个 IP 继续分配。 + +=== 当前状况 + +Cloud 5.5 版本中: + +. 扁平网络有三种情况:无 IP range、有 IP range 且未启用 DHCP、有 IP range 且已启用 DHCP。 +. 公有网络与 VPC 网络有两种情况:有 IP range 且未启用 DHCP、有 IP range 且已启用 DHCP。 +. 管理网络与流量网络仅有一种情况:有 IP range 且未启用 DHCP。 + +=== 工作时机 + +以下操作会触发自动分配: + +. 创建云主机(`APICreateVmInstanceMsg`) +. 云主机添加网卡(`APIAttachL3NetworkToVmMsg`, `APICreateVmNicMsg`) +. 修改云主机 IP(`APISetVmStaticIpMsg`, `APIChangeVmNicNetworkMsg`) +. 创建 appliance VM(`APICreateVpcVRouterMsg`, `APICreateSlbInstanceMsg`, `APICreateNfvInstMsg`) +. 创建 VIP(`APICreateVipMsg`) + +== 手动分配 + +手动指定仅对云主机生效,对 appliance VM 不生效。 + +在前述场景 1、2、3 中,用户可以指定 IP 地址,分为两种情况: + +. 指定 IP 在 IP range 之内:后端仍执行自动分配流程。 +. 指定 IP 不在 IP range 之内:按手动分配流程处理。 +.. 如果指定 IP 不在 L3 CIDR 内,必须指定掩码,网关可选。 +.. 如果指定 IP 在 L3 CIDR 内,可不指定掩码和网关;如指定,必须与 L3 CIDR 一致。 + +=== 工作时机 + +. 5.5.12 之前:扁平网络在“无 IP range”或“有 IP range 但未启用 DHCP”两种情况下,允许指定不在 IP range 内的地址。 +. 5.5.12 及以后:任意网络都可通过修改云主机 IP(`APISetVmStaticIpMsg`, `APIChangeVmNicNetworkMsg`)设置不在 IP range 内的地址。 + +如果指定 IP 在 IP range 之外、但在 L3 CIDR 之内,则掩码和网关可不指定,系统会自动从 L3 CIDR 推导。 + +如果指定 IP 在 L3 CIDR 之外,用户必须同时提供 IP 与掩码(或前缀长度);若为默认网卡,还必须指定网关,且网关必须位于 L3 CIDR 内。 + +== QGA 获取 + +该方式需要开启全局配置 `VmGlobalConfig.ENABLE_VM_INTERNAL_IP_OVERWRITE`(默认值为 `false`)。 + +ZStack KVM Agent 会定期通过 QGA 从云主机内部读取 IP 地址。仅在扁平网络且无 IP range 时,会将读取到的 IP 分配给云主机;其他网络类型不受影响。 + +在其他情况下,如果 QGA 读取到的 IP 与云主机当前 IP 冲突,系统会产生告警。 + +== 配置虚拟机 Guest OS 的 IP 地址 + +配置虚拟机 Guest OS 的 IP 地址有三种方式: + +. DHCP:通过 DHCP 服务器动态分配 IP 地址。 +. Cloud-init:在云主机创建时通过 Cloud-init 预配置 IP 地址。 +. QGA:通过 QEMU Guest Agent 配置云主机内部网络参数。 + +=== DHCP + +ZStack 会在每个物理机启动分布式 DHCP Server。云主机启动后通过 `dhclient` 获取 IP 地址、DNS 等参数。 + +=== Cloud-init + +ZStack 会在每个物理机启动分布式 Userdata Server。云主机启动后通过 Cloud-init 获取 IP 地址、DNS 等参数。 + +=== QGA + +云主机安装 ZStack Guest Agent 后,系统在检测到 Guest Agent 首次启动时,会通过 QGA 配置云主机的 IP、DNS 等参数。 + +当用户在 UI 手动修改 IP(`APISetVmStaticIpMsg`, `APIChangeVmNicNetworkMsg`)后,后端 API 会触发一次 Guest OS 网络参数下发流程。 + +QGA 下发的参数包括: + +* IP 地址 +* 掩码或前缀长度 +* 网关 +* DNS 服务器地址 +* MTU +* Hostname + +用户可通过全局配置 `GuestToolsGlobalProperty.GUESTTOOLS_VM_PORT_CONFIGFIELDS` 限制下发字段。 + +== 网络服务 + +当网卡地址不在 L3 IP range 内时,可分为“在 L3 CIDR 内”和“在 L3 CIDR 外”两种情况。 + +=== 在 L3 CIDR 内 + +该情况与“IP 在 L3 IP range 内”一致,网络服务不受影响。 + +=== 在 L3 CIDR 外 + +==== 安全组 + +安全组规则本身不依赖网卡 IP 是否位于 L3 CIDR 内,但需要用户谨慎设计规则,避免不符合预期。 + +==== DHCP + +如果网卡所属网络无 IP range,则不提供 DHCP 服务。 + +如果网卡所属网络有 IP range,ZStack 会启动 DHCP 服务,且 `dnsmasq` 配置要求指定一个 IP CIDR。 + +当网卡 IP 不在 DHCP 服务对应的 IP CIDR 内时,DHCP 模块下发配置时会过滤掉 CIDR 外地址。 + +==== EIP + +对于扁平网络,EIP 功能不受影响,可继续创建。 + +对于 VPC 网络,若 EIP 绑定的私网地址不在 L3 CIDR 内,VPC 路由器无法路由,网络不通。 + +为保证一致性,EIP 不能绑定 IP 不在 L3 CIDR 内的网卡;`APIGetEipAttachableVmNicsMsg` 也不会返回该类网卡。 + +==== Port Forwarding + +Port Forwarding 仅用于 VPC 网络。与 EIP 类似,若网卡 IP 不在 L3 CIDR 内,VPC 路由器无法路由,网络不通。 + +Port Forwarding 不能绑定 IP 不在 L3 CIDR 内的网卡;`APIGetPortForwardingAttachableVmNicsMsg` 也不会返回该类网卡。 + +==== Load Balancer + +与 EIP 类似,若网卡 IP 不在 L3 CIDR 内,网络不通。 + +`APIAddVmNicToLoadBalancerMsg`、`APIAddBackendServerToServerGroupMsg` 不能绑定 IP 不在 L3 CIDR 内的网卡。 + +`APIGetCandidateVmNicsForLoadBalancerServerGroupMsg`、`APIGetCandidateVmNicsForLoadBalancerMsg` 也不会返回该类网卡。 + +== 代码细节 + +=== APISetVmStaticIpMsg + +该 API 通过成员字段配置云主机 IP、掩码、网关等参数,并进行完整性校验: + +* 用户输入掩码与网关:以用户输入为准。 +* 用户仅输入网关:若 IP 与网关均在 L3 CIDR 内,使用 L3 CIDR 掩码;否则报错。 +* 用户仅输入掩码:若与 L3 CIDR 掩码一致,使用 L3 CIDR 网关;否则若网卡为默认网卡或唯一网卡,报错;否则使用输入掩码,网关置空。 +* 用户未输入掩码和网关,且 IP 在 L3 CIDR 内:使用 L3 CIDR 的掩码与网关。 +* 用户未输入掩码和网关,且 IP 在 L3 CIDR 外:报错。 +* IPv6 与 IPv4 的逻辑一致。 +* 对 DNS 参数:若 UI 传递 `NULL`,后端保持旧 DNS 参数不变。 +* 对 DNS 参数:若 UI 传递 `""` 或 `[]`,后端删除旧 DNS。 +* 其他情况仅允许传递合法 DNS 列表,后端会先删除旧 DNS,再配置新 DNS。 + +=== APIChangeVmNicNetworkMsg + +该 API 通过 System Tags 配置云主机 IP、掩码、网关等参数,并进行完整性校验: + +* 用户输入掩码与网关:以用户输入为准。 +* 用户仅输入网关:若 IP 与网关均在 L3 CIDR 内,使用 L3 CIDR 掩码;否则报错。 +* 用户仅输入掩码:若与 L3 CIDR 掩码一致,使用 L3 CIDR 网关;否则若网卡为默认网卡或唯一网卡,报错;否则使用输入掩码,网关置空。 +* 用户未输入掩码和网关:若网卡存在相同 IP 版本地址,且输入 IP 在旧 IP 的掩码与网关组成 CIDR 内,则复用旧掩码与网关;否则若 IP 在 L3 CIDR 内,使用 L3 CIDR 掩码与网关;否则报错。 +* IPv6 与 IPv4 的逻辑一致。 +* 对 DNS 参数:若 UI 传递 `NULL`,后端保持旧 DNS 参数不变。 +* 对 DNS 参数:若 UI 传递 `""` 或 `[]`,后端删除旧 DNS。 +* 其他情况仅允许传递合法 DNS 列表,后端会先删除旧 DNS,再配置新 DNS。 + +=== APICreateVmInstanceMsg + +逻辑与 `APIChangeVmNicNetworkMsg` 相同。 + +=== APIGetL3NetworkIpStatisticMsg + +不统计位于 IP range 之外的 IP 地址。 + +=== APIAddIpRangeMsg + +系统允许给云主机设置不在 IP range 内的 IP,因此添加 IP range 时可能覆盖已分配 IP。此时会将这些已分配地址归属到新加入的 IP range。 + +=== APIAddReservedIpRangeMsg + +该 API 不仅会添加 `ReservedIpRangeVO`,还会将 `ReservedIpRangeVO` 与 IP range 重叠的地址写入 `UsedIpVO`。 + +[source,java] +---- +vo.setUsedFor(IpAllocatedReason.Reserved.toString()); +---- + +=== APICheckIpAvailabilityMsg + +在 5.5.12 之前,该 API 在扁平网络未启用 DHCP 的情况下会跳过 IP range 检查;该行为保持不变。 + diff --git a/docs/modules/network/pages/networkResource/networkResource.adoc b/docs/modules/network/pages/networkResource/networkResource.adoc index 9aa66ce7341..3b567e4eb66 100644 --- a/docs/modules/network/pages/networkResource/networkResource.adoc +++ b/docs/modules/network/pages/networkResource/networkResource.adoc @@ -2,4 +2,5 @@ * xref:networkResource/L2Network.adoc[] * xref:networkResource/L3Network.adoc[] +* xref:networkResource/l3Ipam.adoc[] * xref:networkResource/VpcRouter.adoc[] \ No newline at end of file diff --git a/network/src/main/java/org/zstack/network/l3/zstack ipam.md b/network/src/main/java/org/zstack/network/l3/zstack ipam.md deleted file mode 100644 index 63951964dbd..00000000000 --- a/network/src/main/java/org/zstack/network/l3/zstack ipam.md +++ /dev/null @@ -1,139 +0,0 @@ -# ZStack IPAM - -ZStack IPAM 负责管理 L3 网络的 IP 地址分配和回收。它提供三种方式: -1. **自动分配**: ZStack云平台根据L3配置的ip range自动分配。 -2. **手动分配**: 用户可以在创建虚拟机时指定IP地址。 -3. **qga获取**: 通过DHCP服务器动态分配IP地址。 - -## 自动分配 - -自动分配需要满足两个条件: -1. L3网络必须配置ip range。 -2. L3网络必须enable dhcp服务。这是个历史遗留问题: 扁平网络使用dhcp服务标识是否启用自动分配功能, 其它网络类型不受影响 -它根据用户输入的l3网络uuid和可选的ip地址, 按照地址分配算法分配一个可用地址,分配的IP地址包含: ip地址,掩码(或者前缀长度),网关 - -### 自动分配算法 -- 随机分配: 从可用ip地址池中随机选择一个ip地址分配给虚拟机 -- 顺序分配: 从可用ip地址池中按照顺序选择一个ip -- 循环分配: 从可用ip地址池中按照顺序选择一个ip, 分配完最后一个ip后, 从第一个ip重新开始分配 - -### 当前状况 -cloud 5.5版本情况: -1. 扁平网络可以有三种情况: no ip range, ip range without dhcp, ip range with dhcp. -2. 公有网络和VPC网络有两种情况: ip range without dhcp, ip range with dhcp. -3. 管理网和流量网络只有一种情况: ip range without dhcp. - -### 工作时机 -以下操作会触发自动分配: -1. 创建虚拟机(APICreateVmInstanceMsg) -2. 虚拟机添加网卡(APIAttachL3NetworkToVmMsg, APICreateVmNicMsg) -3. 修改虚拟机IP(APISetVmStaticIpMsg, APIChangeVmNicNetworkMsg) -4. 创建applianceVm(APICreateVpcVRouterMsg, APICreateSlbInstanceMsg, APICreateNfvInstMsg) -5. 创建Vip(APICreateVipMsg) - -## 手动分配 -手动指定仅仅对虚拟机生效,对于applianceVm不生效。 -在前述场景1,2,3的情况下,用户可以指定ip地址. 这又分两种情况: -1. 指定的ip在ip range之内,后端仍然执行的自动分配流量 -2. 指定的ip不在ip range之内, 按照手动指定流程分配 - 1. 如果指定的ip地址不在l3 cidr之内,必须指定掩码, 网关可选 - 2. 如果指定的ip地址在l3 cidr之内,可以不指定掩码, 网关, 如果指定必须和l3 cidr一致 - -### 工作时机 -1. 在5.5.12之前, 扁平网络在两种情况下: no ip range, ip range without dhcp, 允许指定地址不在ip range之内 -2. 在5.5.12版本及其以后, 任意网络,都可以通过修改虚拟机IP(APISetVmStaticIpMsg, APIChangeVmNicNetworkMsg) 设置不在ip range之内的地址, - -如果指定的ip地址在ip ranges之外, 但是在l3 cidr之内, 则掩码和网关可以不指定, 系统会自动从l3 cidr中获取掩码和网关 -如果指定的ip地址在ip cidr之外, 用户输入必须同时输入IP, 掩码或者前缀长度, 如果是默认网卡,必须指定网关, 网关必须在l3 cidr之内 - - -## qga获取 -这种方式需要打开全局配置: VmGlobalConfig.ENABLE_VM_INTERNAL_IP_OVERWRITE(默认值是false) -ZStack kvmagent会定期通过qga从云主机内部读取ip地址, 仅在扁平网络在no ip range的情况下会把读出来的ip地址分配给云主机, 其它网络类型不受影响。 -其它情况下,qga获取的ip地址如果和虚拟机的ip地址冲突, 则发送报警。 - -## 配置虚拟机guest OS的IP地址 -配置虚拟机guest OS的IP地址有3种方式: -1. **DHCP**: 通过DHCP服务器动态分配IP地址。 -2. **Cloud-init**: 在虚拟机创建时,使用Cloud-init工具预配置IP地址。 -3. **QGA**: 通过QEMU Guest Agent从虚拟 - -### DHCP -ZStack会在每个物理机启动分布式dhcp server, 虚拟机启动时候, 通过dhclient获取地址和dns等参数。 - -### Cloud-init -ZStack会在每个物理机启动分布式userdata server, 虚拟机启动时候, 通过cloud-init获取地址和dns等参数。 - -### QGA -当虚拟机安装ZStack Guest Agent后,在zstack检测guest agent第一次启动时候,通过qga配置虚拟机的ip地址,dns等参数 -当用户在UI手动修改IP(APISetVmStaticIpMsg, APIChangeVmNicNetworkMsg), UI调用后端api, 触发一次配置虚拟机ip地址的过程 -qga配置虚拟机的参数包含: -- IP地址 -- 掩码或者前缀长度 -- 网关 -- DNS服务器地址 -- mtu -- hostname -用户可以通过全局配置来限制配置的字段: GuestToolsGlobalProperty.GUESTTOOLS_VM_PORT_CONFIGFIELDS来限制 - -## 网络服务 -当网卡地址不在 l3 ip range之内的时候, 又可以分为在l3 cidr之内和在l3 cidr之外两种情况: - -### 在l3 cidr之内 -这种情况和在l3 ip range之内的情况一样, 网络服务没有影响 - -### 在l3 cidr之外 -#### 安全组 -1. 安全组的规则不关心网卡ip, 当这种网卡配置了安全组以后, 需要用户小心规则的配置,否则可能满足不了需求 - -#### DHCP -如果网卡没有ip range, 则没有dhcp服务 -如果网卡有ip range, zstack会启动dhcp服务, dnsmasq的配置文件要求指定一个ip cidr -如果网卡的ip地址不在dhcp服务的ip cidr之内, 因此dhcp模块下发配置的时候调多cidr之外的地址 - -#### Eip -对于扁平网络, eip功能不受影响,可以继续创建。 -对于vpc网络, eip的私网地址不在l3 cidr之内, vpc路由器无法路由,网络不通 -为了一致性,eip不能绑定ip地址不在l3 cidr之内的网卡, APIGetEipAttachableVmNicsMsg 也不返回ip地址不在l3 cidr之内的网卡 - -#### Port forwarding -只有vpc网络才有port forwarding功能, 和eip一样, vpc路由器无法路由,网络不通 -Port forwarding不能绑定ip地址不在l3 cidr之内的网卡, APIGetPortForwardingAttachableVmNicsMsg 也不返回ip地址不在l3 cidr之内的网卡 - -#### LoadBalancer -和eip一样,网络不通 -APIAddVmNicToLoadBalancerMsg, APIAddBackendServerToServerGroupMsg 不能绑定ip地址不在l3 cidr之内的网卡, -APIGetCandidateVmNicsForLoadBalancerServerGroupMsg, APIGetCandidateVmNicsForLoadBalancerMsg也不返回ip地址不在l3 cidr之内的网卡 - - -## 代码细节 - -### APISetVmStaticIpMsg -通过成员字段配置虚拟机的IP地址,掩码,网关等参数。需要完整性校验 -- 如果用户输入IP地址,不输入掩码和网关,优先使用网卡上使用的掩码和网关; -- 继续,如果网卡上没有使用的掩码和网关, 则从l3 cidr中获取掩码和网关 -- 继续,如果l3 cidr中也没有掩码和网关,报错 -- ipv6和ipv4的逻辑一样 -- -### APIChangeVmNicNetworkMsg -通过system tags来配置虚拟机的IP地址,掩码,网关等参数。需要完整性校验 -- 如果配置ip地址,且在l3 cidr之内,掩码和网关从l3 cidr中获取 -- 如果配置ip地址,且在l3 cidr之外,必须指定掩码, 网关可选; 如果是默认网卡,必须指定网关 -- 如果配置ipv6地址,且在l3 cidr之内,前缀长度和网关从l3 cidr中获取 -- 如果配置ipv6地址,且在l3 cidr之外,必须指定前缀长度, 网关可选; 如果是默认网卡,必须指定网关 - -### APICreateVmInstanceMsg -逻辑和APIChangeVmNicNetworkMsg相同 - -### APIGetL3NetworkIpStatisticMsg -不统计在ip range之外的ip地址 - -### APIAddIpRangeMsg -允许给云主机设置不在ip range之内的ip地址, 这样在添加ip range的时候, 可能包含已经分配的ip地址, 此时, 让已分配的地址属于新加入的ip range - -### APIAddReservedIpRangeMsg -这个api不仅添加了ReservedIpRangeVO, 还把ReservedIpRangeVO和ip range重叠的ip添加到UsedIpVO, -vo.setUsedFor(IpAllocatedReason.Reserved.toString()); - -### APICheckIpAvailabilityMsg -这个api在5.5.12版本之前, 在扁平网络no dhcp的情况下跳过ip range的检查, 这个功能不变 From f32fb964eab9a349a52f76e1b0ae15e62f59d9f9 Mon Sep 17 00:00:00 2001 From: "ye.zou" Date: Tue, 17 Mar 2026 18:09:14 +0800 Subject: [PATCH 80/85] =?UTF-8?q?[errorcode]:=20address=20review=20?= =?UTF-8?q?=E2=80=94=20null-safe=20message=20fallback=20and=20avoid=20load?= =?UTF-8?q?er=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlobalErrorCodeI18nServiceImpl: add final fallback to error.code/empty string when both details and description are null, guaranteeing message is never null - Platform: use existing loader field instead of getComponentLoader() to avoid triggering ComponentLoader creation during early startup; move message fallback outside catch block so it always runs Co-Authored-By: ye.zou Change-Id: I8571f05657dc2173cc232b511f505eedc68d714e --- .../src/main/java/org/zstack/core/Platform.java | 17 ++++++++++------- .../GlobalErrorCodeI18nServiceImpl.java | 3 ++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/org/zstack/core/Platform.java b/core/src/main/java/org/zstack/core/Platform.java index dc7f6697150..78b184d12e7 100755 --- a/core/src/main/java/org/zstack/core/Platform.java +++ b/core/src/main/java/org/zstack/core/Platform.java @@ -992,15 +992,18 @@ public static ErrorCode err(String globalErrorCode, Enum errCode, ErrorCode caus // populate message at creation time with default locale; // RestServer will override with client's Accept-Language if different try { - GlobalErrorCodeI18nService i18nService = getComponentLoader().getComponent(GlobalErrorCodeI18nService.class); - if (i18nService != null) { - i18nService.localizeErrorCode(result, org.zstack.core.errorcode.LocaleUtils.DEFAULT_LOCALE); + ComponentLoader currentLoader = loader; + if (currentLoader != null) { + GlobalErrorCodeI18nService i18nService = currentLoader.getComponent(GlobalErrorCodeI18nService.class); + if (i18nService != null) { + i18nService.localizeErrorCode(result, org.zstack.core.errorcode.LocaleUtils.DEFAULT_LOCALE); + } } } catch (Exception e) { - // i18n service not initialized during early startup, use details as fallback - if (result.getMessage() == null) { - result.setMessage(details); - } + // i18n service not initialized during early startup + } + if (result.getMessage() == null) { + result.setMessage(details != null ? details : result.getDescription()); } return result; diff --git a/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java index 00b05e4330c..b6820fc2d3b 100644 --- a/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java +++ b/core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java @@ -140,7 +140,8 @@ public void localizeErrorCode(ErrorCode error, String locale) { // guarantee: message is never null if (error.getMessage() == null) { - error.setMessage(error.getDetails() != null ? error.getDetails() : error.getDescription()); + String fallback = error.getDetails() != null ? error.getDetails() : error.getDescription(); + error.setMessage(fallback != null ? fallback : (error.getCode() != null ? error.getCode() : "")); } if (error.getCause() != null) { From 3feb9e903e7a617aab0f30e3bbb2a9840a795646 Mon Sep 17 00:00:00 2001 From: "yingzhe.hu" Date: Wed, 11 Mar 2026 10:55:36 +0800 Subject: [PATCH 81/85] [kvm]: add libvirt TLS config Add libvirt.tls.enabled GlobalConfig and useTls field in MigrateVmCmd to support TLS-encrypted libvirt connections for migration and V2V. Resolves: ZSTAC-81343 Change-Id: I391fa36c0dd63c25c5d85d102bc3579c8eb3d685 --- .../java/org/zstack/kvm/KVMAgentCommands.java | 20 ++ .../java/org/zstack/kvm/KVMGlobalConfig.java | 4 + .../src/main/java/org/zstack/kvm/KVMHost.java | 13 + .../zstack/kvm/KVMHostDeployArguments.java | 10 + .../java/org/zstack/kvm/KVMHostFactory.java | 57 ++++ .../vm/migrate/LibvirtTlsMigrateCase.groovy | 267 ++++++++++++++++++ 6 files changed, 371 insertions(+) create mode 100644 test/src/test/groovy/org/zstack/test/integration/kvm/vm/migrate/LibvirtTlsMigrateCase.groovy diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java index a8a1378288b..2a36bb5aba3 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java @@ -3805,6 +3805,26 @@ public static class MigrateVmCmd extends AgentCommand implements HasThreadContex private boolean reload; @GrayVersion(value = "5.0.0") private long bandwidth; + @GrayVersion(value = "5.5.12") + private boolean useTls; + @GrayVersion(value = "5.5.12") + private String srcHostManagementIp; + + public String getSrcHostManagementIp() { + return srcHostManagementIp; + } + + public void setSrcHostManagementIp(String srcHostManagementIp) { + this.srcHostManagementIp = srcHostManagementIp; + } + + public boolean isUseTls() { + return useTls; + } + + public void setUseTls(boolean useTls) { + this.useTls = useTls; + } public Integer getDownTime() { return downTime; diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java index 8cdd2f54167..ee765aad40f 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMGlobalConfig.java @@ -139,6 +139,10 @@ public class KVMGlobalConfig { @BindResourceConfig({HostVO.class, ClusterVO.class}) public static GlobalConfig RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE = new GlobalConfig(CATEGORY, "reconnect.host.restart.libvirtd.service"); + @GlobalConfigValidation(validValues = {"true", "false"}) + @GlobalConfigDef(defaultValue = "true", type = Boolean.class, description = "enable TLS encryption for libvirt remote connections (migration)") + public static GlobalConfig LIBVIRT_TLS_ENABLED = new GlobalConfig(CATEGORY, "libvirt.tls.enabled"); + @GlobalConfigValidation public static GlobalConfig KVMAGENT_PHYSICAL_MEMORY_USAGE_ALARM_THRESHOLD = new GlobalConfig(CATEGORY, "kvmagent.physicalmemory.usage.alarm.threshold"); diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java index a245757517d..420f00aa2fd 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java @@ -3163,6 +3163,7 @@ public void run(final FlowTrigger trigger, Map data) { cmd.setDestHostIp(dstHostMigrateIp); cmd.setSrcHostIp(srcHostMigrateIp); cmd.setDestHostManagementIp(dstHostMnIp); + cmd.setSrcHostManagementIp(srcHostMnIp); cmd.setMigrateFromDestination(migrateFromDestination); cmd.setStorageMigrationPolicy(storageMigrationPolicy == null ? null : storageMigrationPolicy.toString()); cmd.setVmUuid(vmUuid); @@ -3174,6 +3175,8 @@ public void run(final FlowTrigger trigger, Map data) { cmd.setDownTime(s.downTime); cmd.setBandwidth(s.bandwidth); cmd.setNics(nicTos); + cmd.setUseTls(KVMGlobalConfig.LIBVIRT_TLS_ENABLED.value(Boolean.class) + && rcf.getResourceConfigValue(KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE, self.getUuid(), Boolean.class)); if (s.diskMigrationMap != null) { Map diskMigrationMap = new HashMap<>(); @@ -5815,6 +5818,16 @@ public void run(final FlowTrigger trigger, Map data) { deployArguments.setSkipPackages(info.getSkipPackages()); deployArguments.setUpdatePackages(String.valueOf(CoreGlobalProperty.UPDATE_PKG_WHEN_CONNECT)); + // Build TLS cert IP list: management IP + extra IPs (migration network etc.) + String managementIp = getSelf().getManagementIp(); + String extraIps = HostSystemTags.EXTRA_IPS.getTokenByResourceUuid( + self.getUuid(), HostSystemTags.EXTRA_IPS_TOKEN); + if (extraIps != null && !extraIps.isEmpty()) { + deployArguments.setTlsCertIps(managementIp + "," + extraIps); + } else { + deployArguments.setTlsCertIps(managementIp); + } + if (deployArguments.isForceRun()) { runner.setForceRun(true); } diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java index 71fb8a9769e..f2bb79c110b 100644 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostDeployArguments.java @@ -39,6 +39,8 @@ public class KVMHostDeployArguments extends SyncTimeRequestedDeployArguments { private String restartLibvirtd; @SerializedName("extra_packages") private String extraPackages; + @SerializedName("tls_cert_ips") + private String tlsCertIps; private transient boolean forceRun = false; @@ -135,6 +137,14 @@ public void setExtraPackages(String extraPackages) { this.extraPackages = extraPackages; } + public String getTlsCertIps() { + return tlsCertIps; + } + + public void setTlsCertIps(String tlsCertIps) { + this.tlsCertIps = tlsCertIps; + } + public String getEnableSpiceTls() { return enableSpiceTls; } diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java index acb129fe6dc..0188c920a83 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHostFactory.java @@ -1,5 +1,6 @@ package org.zstack.kvm; +import org.apache.commons.io.FileUtils; import org.apache.commons.lang.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.util.UriComponentsBuilder; @@ -9,6 +10,8 @@ import org.zstack.compute.vm.VmNicManager; import org.zstack.core.CoreGlobalProperty; import org.zstack.core.ansible.AnsibleFacade; +import org.zstack.core.jsonlabel.JsonLabel; +import org.zstack.core.jsonlabel.JsonLabelInventory; import org.zstack.core.cloudbus.CloudBus; import org.zstack.core.cloudbus.CloudBusListCallBack; import org.zstack.core.cloudbus.CloudBusSteppingCallback; @@ -75,6 +78,7 @@ import org.zstack.resourceconfig.ResourceConfigFacade; import org.zstack.utils.CollectionUtils; import org.zstack.utils.IpRangeSet; +import org.zstack.utils.ShellUtils; import org.zstack.utils.SizeUtils; import org.zstack.utils.Utils; import org.zstack.utils.data.SizeUnit; @@ -85,6 +89,7 @@ import org.zstack.utils.logging.CLogger; import javax.persistence.Tuple; +import java.io.File; import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; @@ -122,6 +127,10 @@ public class KVMHostFactory extends AbstractService implements HypervisorFactory HypervisorMessageFactory { private static final CLogger logger = Utils.getLogger(KVMHostFactory.class); + private static final String LIBVIRT_TLS_CA_KEY = "libvirtTLSCA"; + private static final String LIBVIRT_TLS_PRIVATE_KEY = "libvirtTLSPrivateKey"; + private static final String CA_DIR = "/var/lib/zstack/pki/CA"; + public static final HypervisorType hypervisorType = new HypervisorType(KVMConstant.KVM_HYPERVISOR_TYPE); public static final VolumeFormat QCOW2_FORMAT = new VolumeFormat(VolumeConstant.VOLUME_FORMAT_QCOW2, hypervisorType); public static final VolumeFormat RAW_FORMAT = new VolumeFormat(VolumeConstant.VOLUME_FORMAT_RAW, hypervisorType); @@ -458,8 +467,56 @@ private void processKvmagentPhysicalMemUsageAbnormal(HostProcessPhysicalMemoryUs bus.send(restartKvmAgentMsg); } + private void initLibvirtTlsCA() { + if (CoreGlobalProperty.UNIT_TEST_ON) { + return; + } + + try { + ShellUtils.run(String.format("mkdir -p %s", CA_DIR)); + ShellUtils.run("chown -R zstack:zstack /var/lib/zstack/pki"); + + File caFile = new File(CA_DIR + "/cacert.pem"); + File keyFile = new File(CA_DIR + "/cakey.pem"); + + // Local CA missing — generate with openssl + // NOTE: ShellUtils.run() prepends sudo only to the first command in &&-chains, + // so each command must be a separate call. + if (!caFile.exists() || !keyFile.exists()) { + ShellUtils.run(String.format( + "openssl genrsa -out %s/cakey.pem 4096", CA_DIR)); + ShellUtils.run(String.format( + "openssl req -new -x509 -days 3650 -key %s/cakey.pem " + + "-out %s/cacert.pem -subj '/O=ZStack/CN=ZStack Libvirt CA'", + CA_DIR, CA_DIR)); + ShellUtils.run(String.format("chown zstack:zstack %s/cakey.pem %s/cacert.pem", + CA_DIR, CA_DIR)); + ShellUtils.run(String.format("chmod 600 %s/cakey.pem", CA_DIR)); + ShellUtils.run(String.format("chmod 644 %s/cacert.pem", CA_DIR)); + } + + String ca = FileUtils.readFileToString(caFile).trim(); + String key = FileUtils.readFileToString(keyFile).trim(); + + // createIfAbsent: DB has no record → write; DB has record → return DB value + JsonLabelInventory caInv = new JsonLabel().createIfAbsent(LIBVIRT_TLS_CA_KEY, ca); + JsonLabelInventory keyInv = new JsonLabel().createIfAbsent(LIBVIRT_TLS_PRIVATE_KEY, key); + + // Use DB as source of truth — overwrite local files (HA: MN2 uses MN1's CA from DB) + FileUtils.writeStringToFile(caFile, caInv.getLabelValue()); + FileUtils.writeStringToFile(keyFile, keyInv.getLabelValue()); + ShellUtils.run(String.format("chmod 600 %s/cakey.pem", CA_DIR)); + ShellUtils.run(String.format("chmod 644 %s/cacert.pem", CA_DIR)); + + logger.info("Libvirt TLS CA initialized and persisted to database"); + } catch (Exception e) { + logger.warn("Failed to initialize libvirt TLS CA", e); + } + } + @Override public boolean start() { + initLibvirtTlsCA(); deployAnsibleModule(); populateExtensions(); configKVMDeviceType(); diff --git a/test/src/test/groovy/org/zstack/test/integration/kvm/vm/migrate/LibvirtTlsMigrateCase.groovy b/test/src/test/groovy/org/zstack/test/integration/kvm/vm/migrate/LibvirtTlsMigrateCase.groovy new file mode 100644 index 00000000000..d4a30cef78e --- /dev/null +++ b/test/src/test/groovy/org/zstack/test/integration/kvm/vm/migrate/LibvirtTlsMigrateCase.groovy @@ -0,0 +1,267 @@ +package org.zstack.test.integration.kvm.vm.migrate + +import org.springframework.http.HttpEntity +import org.zstack.kvm.KVMAgentCommands +import org.zstack.kvm.KVMConstant +import org.zstack.kvm.KVMGlobalConfig +import org.zstack.sdk.HostInventory +import org.zstack.sdk.UpdateGlobalConfigAction +import org.zstack.sdk.VmInstanceInventory +import org.zstack.test.integration.kvm.KvmTest +import org.zstack.testlib.EnvSpec +import org.zstack.testlib.SubCase +import org.zstack.testlib.Test +import org.zstack.utils.data.SizeUnit +import org.zstack.utils.gson.JSONObjectUtil + +/** + * Verify that the libvirt TLS configuration (ZSTAC-81343) is correctly + * propagated in the MigrateVmCmd sent to kvmagent. + * + * Key logic under test (KVMHost.java): + * cmd.setUseTls(LIBVIRT_TLS_ENABLED && RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE) + * cmd.setSrcHostManagementIp(srcHostMnIp) + */ +class LibvirtTlsMigrateCase extends SubCase { + EnvSpec env + + @Override + void clean() { + env.delete() + } + + @Override + void setup() { + useSpring(KvmTest.springSpec) + } + + @Override + void environment() { + env = env { + instanceOffering { + name = "instanceOffering" + memory = SizeUnit.GIGABYTE.toByte(8) + cpu = 4 + } + + zone { + name = "zone" + cluster { + name = "cluster" + hypervisorType = "KVM" + + kvm { + name = "kvm1" + managementIp = "127.0.0.1" + username = "root" + password = "password" + usedMem = 1000 + totalCpu = 10 + } + kvm { + name = "kvm2" + managementIp = "127.0.0.2" + username = "root" + password = "password" + usedMem = 1000 + totalCpu = 10 + } + + attachPrimaryStorage("ps") + attachL2Network("l2") + } + + l2NoVlanNetwork { + name = "l2" + physicalInterface = "eth0" + + l3Network { + name = "l3" + ip { + startIp = "192.168.100.10" + endIp = "192.168.100.100" + netmask = "255.255.255.0" + gateway = "192.168.100.1" + } + } + } + + cephPrimaryStorage { + name = "ps" + totalCapacity = SizeUnit.GIGABYTE.toByte(100) + availableCapacity = SizeUnit.GIGABYTE.toByte(100) + url = "ceph://pri" + fsid = "7ff218d9-f525-435f-8a40-3618d1772a64" + monUrls = ["root:password@localhost/?monPort=7777"] + } + + attachBackupStorage("bs") + } + + cephBackupStorage { + name = "bs" + totalCapacity = SizeUnit.GIGABYTE.toByte(100) + availableCapacity = SizeUnit.GIGABYTE.toByte(100) + url = "/bk" + fsid = "7ff218d9-f525-435f-8a40-3618d1772a64" + monUrls = ["root:password@localhost/?monPort=7777"] + + image { + name = "image" + url = "http://zstack.org/download/image.qcow2" + } + } + + vm { + name = "vm" + useCluster("cluster") + useHost("kvm1") + useL3Networks("l3") + useInstanceOffering("instanceOffering") + useImage("image") + } + } + } + + @Override + void test() { + env.create { + testMigrateWithTlsEnabled() + testMigrateWithTlsDisabled() + testMigrateWithRestartLibvirtdDisabled() + testGlobalConfigValidation() + } + } + + /** + * Case 1: Both LIBVIRT_TLS_ENABLED=true and RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE=true + * => useTls should be true, srcHostManagementIp should be set + */ + void testMigrateWithTlsEnabled() { + def vm = env.inventoryByName("vm") as VmInstanceInventory + def host1 = env.inventoryByName("kvm1") as HostInventory + def host2 = env.inventoryByName("kvm2") as HostInventory + + // Ensure TLS is enabled (default is true) + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("true") + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("true") + + KVMAgentCommands.MigrateVmCmd cmd = null + env.afterSimulator(KVMConstant.KVM_MIGRATE_VM_PATH) { rsp, HttpEntity e -> + cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.MigrateVmCmd.class) + return rsp + } + + // Migrate vm from kvm1 to kvm2 + migrateVm { + vmInstanceUuid = vm.uuid + hostUuid = host2.uuid + } + + assert cmd != null : "MigrateVmCmd should have been captured" + assert cmd.useTls : "useTls should be true when both TLS and restartLibvirtd are enabled" + assert cmd.srcHostManagementIp == host1.managementIp : + "srcHostManagementIp should be source host management IP" + assert cmd.destHostManagementIp == host2.managementIp : + "destHostManagementIp should be dest host management IP" + } + + /** + * Case 2: LIBVIRT_TLS_ENABLED=false => useTls should be false regardless of restartLibvirtd + */ + void testMigrateWithTlsDisabled() { + def vm = env.inventoryByName("vm") as VmInstanceInventory + def host1 = env.inventoryByName("kvm1") as HostInventory + + // Disable TLS + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("false") + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("true") + + KVMAgentCommands.MigrateVmCmd cmd = null + env.afterSimulator(KVMConstant.KVM_MIGRATE_VM_PATH) { rsp, HttpEntity e -> + cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.MigrateVmCmd.class) + return rsp + } + + // Migrate back to kvm1 + migrateVm { + vmInstanceUuid = vm.uuid + hostUuid = host1.uuid + } + + assert cmd != null : "MigrateVmCmd should have been captured" + assert !cmd.useTls : "useTls should be false when TLS config is disabled" + + // Restore default + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("true") + } + + /** + * Case 3: LIBVIRT_TLS_ENABLED=true but RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE=false + * => useTls should be false (AND logic: both must be true) + * + * This is a critical boundary: TLS config is on, but libvirtd was not restarted + * with TLS certs deployed, so we must NOT tell kvmagent to use TLS. + */ + void testMigrateWithRestartLibvirtdDisabled() { + def vm = env.inventoryByName("vm") as VmInstanceInventory + def host2 = env.inventoryByName("kvm2") as HostInventory + + KVMGlobalConfig.LIBVIRT_TLS_ENABLED.updateValue("true") + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("false") + + KVMAgentCommands.MigrateVmCmd cmd = null + env.afterSimulator(KVMConstant.KVM_MIGRATE_VM_PATH) { rsp, HttpEntity e -> + cmd = JSONObjectUtil.toObject(e.body, KVMAgentCommands.MigrateVmCmd.class) + return rsp + } + + migrateVm { + vmInstanceUuid = vm.uuid + hostUuid = host2.uuid + } + + assert cmd != null : "MigrateVmCmd should have been captured" + assert !cmd.useTls : + "useTls should be false when restartLibvirtd is disabled (TLS certs not deployed)" + + // Restore default + KVMGlobalConfig.RECONNECT_HOST_RESTART_LIBVIRTD_SERVICE.updateValue("true") + } + + /** + * Case 4: Validate that libvirt.tls.enabled GlobalConfig only accepts true/false + */ + void testGlobalConfigValidation() { + // Valid values via SDK action + updateGlobalConfig { + category = "kvm" + name = "libvirt.tls.enabled" + value = "true" + } + assert KVMGlobalConfig.LIBVIRT_TLS_ENABLED.value(Boolean.class) == true + + updateGlobalConfig { + category = "kvm" + name = "libvirt.tls.enabled" + value = "false" + } + assert KVMGlobalConfig.LIBVIRT_TLS_ENABLED.value(Boolean.class) == false + + // Invalid value should be rejected + def action = new UpdateGlobalConfigAction() + action.category = "kvm" + action.name = "libvirt.tls.enabled" + action.value = "invalid" + action.sessionId = Test.currentEnvSpec.session.uuid + UpdateGlobalConfigAction.Result res = action.call() + assert res.error != null : "Setting an invalid value for libvirt.tls.enabled should fail" + + // Restore default + updateGlobalConfig { + category = "kvm" + name = "libvirt.tls.enabled" + value = "true" + } + } +} From 7b6ceead7ac67eaba829989d20fe796d857d5f4a Mon Sep 17 00:00:00 2001 From: J M Date: Thu, 19 Mar 2026 13:23:52 +0800 Subject: [PATCH 82/85] [core,kvm]: fix SSH session leak in CallBackNetworkChecker and KVMHost 1. CallBackNetworkChecker.stopAnsible() creates Ssh object but never closes it, leaking JSch Session thread on every call. 2. KVMHost "check-host-is-taken-over" flow has the same pattern. When a host continuously fails reconnect (e.g. sharedblock VG error), each reconnect cycle leaks 1-2 SSH sessions. Over days this exhausts heap memory (18000+ threads observed) causing MN OOM and Unknown status. Fix: add finally { ssh.close() } to both code paths. Resolves: ZSTAC-83305 Resolves: ZSTAC-82731 Change-Id: I786e766e79776d6b7a75786d776278696c76666f --- .../org/zstack/core/ansible/CallBackNetworkChecker.java | 2 ++ plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java b/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java index d2661d5cab8..031f624218c 100644 --- a/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java +++ b/core/src/main/java/org/zstack/core/ansible/CallBackNetworkChecker.java @@ -72,6 +72,8 @@ public ErrorCode stopAnsible() { return useNcatAndNmapToTestConnection(ssh); } catch (SshException e) { return operr(ORG_ZSTACK_CORE_ANSIBLE_10004, e.getMessage()); + } finally { + ssh.close(); } } diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java index 420f00aa2fd..b8b38d1b802 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java @@ -5573,10 +5573,10 @@ public boolean skip(Map data) { @Override public void run(FlowTrigger trigger, Map data) { + Ssh ssh = new Ssh().setUsername(getSelf().getUsername()) + .setPassword(getSelf().getPassword()).setPort(getSelf().getPort()) + .setHostname(getSelf().getManagementIp()); try { - Ssh ssh = new Ssh().setUsername(getSelf().getUsername()) - .setPassword(getSelf().getPassword()).setPort(getSelf().getPort()) - .setHostname(getSelf().getManagementIp()); ssh.command(String.format("grep -i ^uuid %s | sed 's/uuid://g'", hostTakeOverFlagPath)); SshResult hostRet = ssh.run(); if (hostRet.isSshFailure() || hostRet.getReturnCode() != 0) { @@ -5625,6 +5625,8 @@ public void run(FlowTrigger trigger, Map data) { logger.warn(e.getMessage(), e); trigger.next(); return; + } finally { + ssh.close(); } } }); From 046d023e5fa376488ff7401b4f2ea93e01fe4f64 Mon Sep 17 00:00:00 2001 From: "shixin.ruan" Date: Wed, 18 Mar 2026 16:21:35 +0800 Subject: [PATCH 83/85] [compute]: fix user define param error when user define ip/netmask/gateway, then gateway must be in the cidr of ip/netamsl Resolves: ZSTAC-83321 Change-Id: I78616376707476666a7a72786a7062766272686b --- .../zstack/compute/vm/StaticIpOperator.java | 349 +++++++++++++--- .../compute/vm/VmInstanceApiInterceptor.java | 267 +++--------- .../network/pages/networkResource/l3Ipam.adoc | 5 +- .../flat/FlatChangeVmIpOutsideCidrCase.groovy | 384 ++++++++++++++++-- ...licNetworkChangeVmIpOutsideCidrCase.groovy | 45 -- .../CloudOperationsErrorCode.java | 4 + 6 files changed, 679 insertions(+), 375 deletions(-) diff --git a/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java b/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java index 3ebd89f5a57..92bb139450c 100755 --- a/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java +++ b/compute/src/main/java/org/zstack/compute/vm/StaticIpOperator.java @@ -1,6 +1,7 @@ package org.zstack.compute.vm; import org.apache.commons.lang.StringUtils; +import org.apache.commons.net.util.SubnetUtils; import org.springframework.beans.factory.annotation.Autowire; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Configurable; @@ -20,6 +21,7 @@ import org.zstack.header.tag.SystemTagValidator; import org.zstack.header.vm.VmInstanceVO; import org.zstack.header.vm.VmNicVO; +import org.zstack.network.l3.IpRangeHelper; import org.zstack.tag.SystemTagCreator; import org.zstack.tag.TagManager; import org.zstack.utils.TagUtils; @@ -380,8 +382,223 @@ public void validateSystemTagInCreateMessage(APICreateMessage msg) { validateSystemTagInApiMessage(msg); } - public List fillUpStaticIpInfoToVmNics(Map staticIps) { - List newSystags = new ArrayList<>(); + // ================================================================ + // Context classes for unified resolve logic + // ================================================================ + + /** + * Describes the role of the NIC being resolved, used by resolve methods + * to decide whether gateway is mandatory. + */ + public static class NicRoleContext { + public final boolean isDefaultNic; + public final boolean isOnlyNic; + + public NicRoleContext(boolean isDefaultNic, boolean isOnlyNic) { + this.isDefaultNic = isDefaultNic; + this.isOnlyNic = isOnlyNic; + } + } + + /** + * Holds existing UsedIpVO per (l3Uuid, ipVersion) for case(d) reuse logic. + * Only APISetVmStaticIpMsg populates this; APIChangeVmNicNetworkMsg passes empty. + */ + public static class ExistingIpContext { + private final Map ipv4Map = new HashMap<>(); + private final Map ipv6Map = new HashMap<>(); + + public void putIpv4(String l3Uuid, UsedIpVO vo) { + if (vo != null) { + ipv4Map.put(l3Uuid, vo); + } + } + + public void putIpv6(String l3Uuid, UsedIpVO vo) { + if (vo != null) { + ipv6Map.put(l3Uuid, vo); + } + } + + public UsedIpVO getIpv4(String l3Uuid) { + return ipv4Map.get(l3Uuid); + } + + public UsedIpVO getIpv6(String l3Uuid) { + return ipv6Map.get(l3Uuid); + } + } + + // ================================================================ + // Unified resolve methods (migrated from VmInstanceApiInterceptor) + // ================================================================ + + /** + * Determine whether to use the NIC's existing IPv4 parameters (netmask/gateway). + * Condition: existingIp is non-null with non-empty netmask and non-empty gateway, + * and the IP falls within the CIDR formed by existingIp's gateway + netmask. + */ + public boolean shouldUseExistingIpv4(String ip, UsedIpVO existingIp) { + if (existingIp == null || StringUtils.isEmpty(existingIp.getNetmask())) { + return false; + } + if (StringUtils.isEmpty(existingIp.getGateway())) { + return false; + } + try { + SubnetUtils.SubnetInfo info = NetworkUtils.getSubnetInfo( + new SubnetUtils(existingIp.getGateway(), existingIp.getNetmask())); + return NetworkUtils.isIpv4InRange(ip, info.getLowAddress(), info.getHighAddress()); + } catch (Exception e) { + return false; + } + } + + /** + * Determine whether to use the NIC's existing IPv6 parameters (prefix/gateway). + * Condition: existingIp is non-null with non-null prefixLen and non-empty gateway, + * and the IP falls within the CIDR formed by existingIp's gateway + prefixLen. + */ + public boolean shouldUseExistingIpv6(String ip6, UsedIpVO existingIp) { + if (existingIp == null || existingIp.getPrefixLen() == null) { + return false; + } + if (StringUtils.isEmpty(existingIp.getGateway())) { + return false; + } + try { + return IPv6NetworkUtils.isIpv6InCidrRange(ip6, + existingIp.getGateway() + "/" + existingIp.getPrefixLen()); + } catch (Exception e) { + return false; + } + } + + /** + * Resolve IPv4 netmask and gateway based on 4 cases: + * (a) Both netmask+gateway provided: validate gateway is in CIDR(ip/netmask), then use user input + * (b) Gateway provided, no netmask: if ip and gateway both in L3 CIDR, use CIDR netmask; else error + * (c) Netmask provided, no gateway: if netmask == CIDR netmask, use CIDR gateway; else gateway="" + * (d) Neither provided: if existingIp usable, use it; else if in L3 CIDR, use CIDR; else error + */ + public String[] resolveIpv4NetmaskAndGateway(String ip, String userNetmask, String userGateway, + List ipv4Ranges, NicRoleContext nicRole, UsedIpVO existingIp) { + boolean hasNetmask = StringUtils.isNotEmpty(userNetmask); + boolean hasGateway = StringUtils.isNotEmpty(userGateway); + + // case (a): both provided — validate gateway is in the CIDR formed by ip/netmask + if (hasNetmask && hasGateway) { + String cidr = NetworkUtils.getCidrFromIpMask(ip, userNetmask); + if (!NetworkUtils.isIpv4InCidr(userGateway, cidr)) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10329, + "gateway[%s] is not in the CIDR[%s] formed by IP[%s] and netmask[%s]", + userGateway, cidr, ip, userNetmask)); + } + return new String[]{userNetmask, userGateway}; + } + + NormalIpRangeVO matchedRange = IpRangeHelper.findIpRangeByCidr(ip, ipv4Ranges); + + // case (b): gateway provided, no netmask + if (hasGateway) { + if (matchedRange != null && matchedRange.getNetworkCidr() != null + && NetworkUtils.isIpv4InCidr(userGateway, matchedRange.getNetworkCidr())) { + return new String[]{matchedRange.getNetmask(), userGateway}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10323, + "gateway[%s] is provided but IP[%s] and gateway are not both in L3 network CIDR, netmask must be specified", + userGateway, ip)); + } + + // case (c): netmask provided, no gateway + if (hasNetmask) { + if (matchedRange != null && userNetmask.equals(matchedRange.getNetmask())) { + return new String[]{matchedRange.getNetmask(), matchedRange.getGateway()}; + } + return new String[]{userNetmask, ""}; + } + + // case (d): neither provided + if (existingIp != null && shouldUseExistingIpv4(ip, existingIp)) { + return new String[]{existingIp.getNetmask(), existingIp.getGateway()}; + } + if (matchedRange != null) { + return new String[]{matchedRange.getNetmask(), matchedRange.getGateway()}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10325, + "IP[%s] is outside all L3 network CIDRs and no existing IP parameters available, netmask and gateway must be specified", + ip)); + } + + /** + * Resolve IPv6 prefix and gateway based on 4 cases (mirrors IPv4 logic): + * (a) Both prefix+gateway provided: validate gateway is in CIDR(ip6/prefix), then use user input + * (b) Gateway provided, no prefix: if ip and gateway both in L3 CIDR, use CIDR prefix; else error + * (c) Prefix provided, no gateway: if prefix == CIDR prefix, use CIDR gateway; else if default/sole NIC, error; else gateway="" + * (d) Neither provided: if existingIp usable, use it; else if in L3 CIDR, use CIDR; else error + */ + public String[] resolveIpv6PrefixAndGateway(String ip6, String userPrefix, String userGateway, + List ipv6Ranges, NicRoleContext nicRole, UsedIpVO existingIp) { + boolean hasPrefix = StringUtils.isNotEmpty(userPrefix); + boolean hasGateway = StringUtils.isNotEmpty(userGateway); + + // case (a): both provided — validate gateway is in the CIDR formed by ip6/prefix + if (hasPrefix && hasGateway) { + String cidr = ip6 + "/" + userPrefix; + if (!IPv6NetworkUtils.isIpv6InCidrRange(userGateway, cidr)) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10330, + "gateway[%s] is not in the CIDR[%s] formed by IPv6[%s] and prefix[%s]", + userGateway, cidr, ip6, userPrefix)); + } + return new String[]{userPrefix, userGateway}; + } + + NormalIpRangeVO matchedRange = IpRangeHelper.findIpRangeByCidr(ip6, ipv6Ranges); + + // case (b): gateway provided, no prefix + if (hasGateway) { + if (matchedRange != null && matchedRange.getNetworkCidr() != null + && IPv6NetworkUtils.isIpv6InCidrRange(userGateway, matchedRange.getNetworkCidr())) { + return new String[]{matchedRange.getPrefixLen().toString(), userGateway}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10326, + "gateway[%s] is provided but IPv6[%s] and gateway are not both in L3 network CIDR, prefix must be specified", + userGateway, ip6)); + } + + // case (c): prefix provided, no gateway + if (hasPrefix) { + if (matchedRange != null && userPrefix.equals(matchedRange.getPrefixLen().toString())) { + return new String[]{matchedRange.getPrefixLen().toString(), matchedRange.getGateway()}; + } + if (nicRole.isDefaultNic || nicRole.isOnlyNic) { + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10327, + "prefix[%s] does not match L3 CIDR prefix and the NIC is the default or sole network, gateway must be specified", + userPrefix)); + } + return new String[]{userPrefix, ""}; + } + + // case (d): neither provided + if (existingIp != null && shouldUseExistingIpv6(ip6, existingIp)) { + return new String[]{existingIp.getPrefixLen().toString(), existingIp.getGateway()}; + } + if (matchedRange != null) { + return new String[]{matchedRange.getPrefixLen().toString(), matchedRange.getGateway()}; + } + throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10328, + "IPv6[%s] is outside all L3 network CIDRs and no existing IP parameters available, prefix and gateway must be specified", + ip6)); + } + + // ================================================================ + // IP availability validation (extracted from old fillUpStaticIpInfoToVmNics) + // ================================================================ + + /** + * Validate that all static IPs are available on their respective L3 networks. + */ + public void validateIpAvailability(Map staticIps) { for (Map.Entry e : staticIps.entrySet()) { String l3Uuid = e.getKey(); NicIpAddressInfo nicIp = e.getValue(); @@ -393,72 +610,63 @@ public List fillUpStaticIpInfoToVmNics(Map sta if (!StringUtils.isEmpty(nicIp.ipv6Address)) { checkIpAvailability(l3Uuid, nicIp.ipv6Address); } + } + } - if (!StringUtils.isEmpty(nicIp.ipv4Address)) { - NormalIpRangeVO ipRangeVO = findMatchedNormalIpRange(l3Uuid, nicIp.ipv4Address); - if (ipRangeVO == null) { - if (StringUtils.isEmpty(nicIp.ipv4Netmask)) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10310, "netmask must be set")); - } - } else { - if (StringUtils.isEmpty(nicIp.ipv4Netmask)) { - newSystags.add(VmSystemTags.IPV4_NETMASK.instantiateTag( - map(e(VmSystemTags.IPV4_NETMASK_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV4_NETMASK_TOKEN, ipRangeVO.getNetmask())) - )); - } else if (!nicIp.ipv4Netmask.equals(ipRangeVO.getNetmask())) { - newSystags.add(VmSystemTags.IPV4_NETMASK.instantiateTag( - map(e(VmSystemTags.IPV4_NETMASK_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV4_NETMASK_TOKEN, nicIp.ipv4Netmask)) - )); - } + // ================================================================ + // fillUpStaticIpInfoToVmNics — orchestration layer + // ================================================================ + + /** + * New signature: resolves netmask/gateway (or prefix/gateway) for each static IP entry, + * using the unified resolve methods with NicRoleContext and ExistingIpContext. + * Returns system tags to be added to the message. + */ + public List fillUpStaticIpInfoToVmNics(Map staticIps, + NicRoleContext nicRole, ExistingIpContext existingIpCtx) { + List newSystags = new ArrayList<>(); + for (Map.Entry entry : staticIps.entrySet()) { + String l3Uuid = entry.getKey(); + NicIpAddressInfo nicIp = entry.getValue(); - if (StringUtils.isEmpty(nicIp.ipv4Gateway)) { - newSystags.add(VmSystemTags.IPV4_GATEWAY.instantiateTag( - map(e(VmSystemTags.IPV4_GATEWAY_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV4_GATEWAY_TOKEN, ipRangeVO.getGateway())) - )); - } else if (!nicIp.ipv4Gateway.equals(ipRangeVO.getGateway())) { - newSystags.add(VmSystemTags.IPV4_GATEWAY.instantiateTag( - map(e(VmSystemTags.IPV4_GATEWAY_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV4_GATEWAY_TOKEN, nicIp.ipv4Gateway)) - )); - } + // Resolve IPv4 netmask/gateway + if (!StringUtils.isEmpty(nicIp.ipv4Address)) { + List ipv4Ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, l3Uuid) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv4).list(); + UsedIpVO existingIpv4 = existingIpCtx != null ? existingIpCtx.getIpv4(l3Uuid) : null; + + String[] ipv4Result = resolveIpv4NetmaskAndGateway(nicIp.ipv4Address, + nicIp.ipv4Netmask, nicIp.ipv4Gateway, ipv4Ranges, nicRole, existingIpv4); + + newSystags.add(VmSystemTags.IPV4_NETMASK.instantiateTag( + map(e(VmSystemTags.IPV4_NETMASK_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV4_NETMASK_TOKEN, ipv4Result[0])))); + if (!StringUtils.isEmpty(ipv4Result[1])) { + newSystags.add(VmSystemTags.IPV4_GATEWAY.instantiateTag( + map(e(VmSystemTags.IPV4_GATEWAY_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV4_GATEWAY_TOKEN, ipv4Result[1])))); } } + // Resolve IPv6 prefix/gateway if (!StringUtils.isEmpty(nicIp.ipv6Address)) { - NormalIpRangeVO ipRangeVO = findMatchedNormalIpRange(l3Uuid, nicIp.ipv6Address); - if (ipRangeVO == null) { - if (StringUtils.isEmpty(nicIp.ipv6Prefix)) { - throw new ApiMessageInterceptionException(operr(ORG_ZSTACK_COMPUTE_VM_10313, "ipv6 prefix length must be set")); - } - } else { - if (StringUtils.isEmpty(nicIp.ipv6Prefix)) { - newSystags.add(VmSystemTags.IPV6_PREFIX.instantiateTag( - map(e(VmSystemTags.IPV6_PREFIX_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV6_PREFIX_TOKEN, ipRangeVO.getPrefixLen())) - )); - } else if (!nicIp.ipv6Prefix.equals(ipRangeVO.getPrefixLen().toString())) { - newSystags.add(VmSystemTags.IPV6_PREFIX.instantiateTag( - map(e(VmSystemTags.IPV6_PREFIX_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV6_PREFIX_TOKEN, nicIp.ipv6Prefix)) - )); - } - - if (StringUtils.isEmpty(nicIp.ipv6Gateway)) { - newSystags.add(VmSystemTags.IPV6_GATEWAY.instantiateTag( - map(e(VmSystemTags.IPV6_GATEWAY_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV6_GATEWAY_TOKEN, - IPv6NetworkUtils.ipv6AddressToTagValue(ipRangeVO.getGateway()))) - )); - } else if (!nicIp.ipv6Gateway.equals(ipRangeVO.getGateway())) { - newSystags.add(VmSystemTags.IPV6_GATEWAY.instantiateTag( - map(e(VmSystemTags.IPV6_GATEWAY_L3_UUID_TOKEN, l3Uuid), - e(VmSystemTags.IPV6_GATEWAY_TOKEN, - IPv6NetworkUtils.ipv6AddressToTagValue(nicIp.ipv6Gateway))) - )); - } + List ipv6Ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, l3Uuid) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6).list(); + UsedIpVO existingIpv6 = existingIpCtx != null ? existingIpCtx.getIpv6(l3Uuid) : null; + + String[] ipv6Result = resolveIpv6PrefixAndGateway(nicIp.ipv6Address, + nicIp.ipv6Prefix, nicIp.ipv6Gateway, ipv6Ranges, nicRole, existingIpv6); + + newSystags.add(VmSystemTags.IPV6_PREFIX.instantiateTag( + map(e(VmSystemTags.IPV6_PREFIX_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV6_PREFIX_TOKEN, ipv6Result[0])))); + if (!StringUtils.isEmpty(ipv6Result[1])) { + newSystags.add(VmSystemTags.IPV6_GATEWAY.instantiateTag( + map(e(VmSystemTags.IPV6_GATEWAY_L3_UUID_TOKEN, l3Uuid), + e(VmSystemTags.IPV6_GATEWAY_TOKEN, + IPv6NetworkUtils.ipv6AddressToTagValue(ipv6Result[1]))))); } } } @@ -466,10 +674,27 @@ public List fillUpStaticIpInfoToVmNics(Map sta return newSystags; } + /** + * Legacy overload: preserves old behavior for existing callers + * (APICreateVmInstanceMsg, APIAttachL3NetworkToVmMsg). + * Uses default NicRoleContext(false, false) and empty ExistingIpContext. + */ + public List fillUpStaticIpInfoToVmNics(Map staticIps) { + return fillUpStaticIpInfoToVmNics(staticIps, + new NicRoleContext(false, false), new ExistingIpContext()); + } + public void validateSystemTagInApiMessage(APIMessage msg) { Map staticIps = getNicNetworkInfoBySystemTag(msg.getSystemTags()); + validateIpAvailability(staticIps); List newSystags = fillUpStaticIpInfoToVmNics(staticIps); if (!newSystags.isEmpty()) { + if (msg.getSystemTags() != null) { + // Remove any existing netmask/gateway/prefix tags before adding resolved ones + msg.getSystemTags().removeIf(tag -> + VmSystemTags.IPV4_NETMASK.isMatch(tag) || VmSystemTags.IPV4_GATEWAY.isMatch(tag) + || VmSystemTags.IPV6_PREFIX.isMatch(tag) || VmSystemTags.IPV6_GATEWAY.isMatch(tag)); + } msg.getSystemTags().addAll(newSystags); } } diff --git a/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java b/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java index 6f9c75963ef..06d175a8d13 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java @@ -3,7 +3,6 @@ import com.google.gson.JsonSyntaxException; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang.StringUtils; -import org.apache.commons.net.util.SubnetUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import org.zstack.core.Platform; @@ -58,9 +57,7 @@ import static org.zstack.core.Platform.argerr; import static org.zstack.core.Platform.operr; -import static org.zstack.utils.CollectionDSL.e; import static org.zstack.utils.CollectionDSL.list; -import static org.zstack.utils.CollectionDSL.map; import static org.zstack.utils.CollectionUtils.getDuplicateElementsOfList; import static org.zstack.utils.clouderrorcode.CloudOperationsErrorCode.*; @@ -115,161 +112,6 @@ private void validateStaticIpCommon(VmNicVO vmNicVO, L3NetworkVO l3NetworkVO, St } } - /** - * Determine whether to use the NIC's existing IPv4 parameters (netmask/gateway). - * Condition: existingIp is non-null with non-empty netmask and non-empty gateway, - * and the IP falls within the CIDR formed by existingIp's gateway + netmask. - */ - private boolean shouldUseExistingIpv4(String ip, UsedIpVO existingIp) { - if (existingIp == null || StringUtils.isEmpty(existingIp.getNetmask())) { - return false; - } - if (StringUtils.isEmpty(existingIp.getGateway())) { - return false; - } - try { - SubnetUtils.SubnetInfo info = NetworkUtils.getSubnetInfo( - new SubnetUtils(existingIp.getGateway(), existingIp.getNetmask())); - return NetworkUtils.isIpv4InRange(ip, info.getLowAddress(), info.getHighAddress()); - } catch (Exception e) { - return false; - } - } - - /** - * Determine whether to use the NIC's existing IPv6 parameters (prefix/gateway). - * Condition: existingIp is non-null with non-null prefixLen and non-empty gateway, - * and the IP falls within the CIDR formed by existingIp's gateway + prefixLen. - */ - private boolean shouldUseExistingIpv6(String ip6, UsedIpVO existingIp) { - if (existingIp == null || existingIp.getPrefixLen() == null) { - return false; - } - if (StringUtils.isEmpty(existingIp.getGateway())) { - return false; - } - try { - return IPv6NetworkUtils.isIpv6InCidrRange(ip6, - existingIp.getGateway() + "/" + existingIp.getPrefixLen()); - } catch (Exception e) { - return false; - } - } - - /** - * Resolve IPv4 netmask and gateway based on 4 cases: - * (a) Both netmask+gateway provided: use user input as-is - * (b) Gateway provided, no netmask: if ip and gateway both in L3 CIDR, use CIDR netmask; else error - * (c) Netmask provided, no gateway: if netmask == CIDR netmask, use CIDR gateway; else if default/sole NIC, error; else gateway="" - * (d) Neither provided: if existingIp usable (APISetVmStaticIpMsg), use it; else if in L3 CIDR, use CIDR; else error - * - * @param existingIp pass null for APIChangeVmNicNetworkMsg (no existing IP on dest L3) - */ - private String[] resolveIpv4NetmaskAndGateway(String ip, String userNetmask, String userGateway, - List ipv4Ranges, String l3Uuid, String defaultL3Uuid, int vmNicCount, UsedIpVO existingIp) { - boolean hasNetmask = StringUtils.isNotEmpty(userNetmask); - boolean hasGateway = StringUtils.isNotEmpty(userGateway); - - // case (a): both provided - if (hasNetmask && hasGateway) { - return new String[]{userNetmask, userGateway}; - } - - NormalIpRangeVO matchedRange = IpRangeHelper.findIpRangeByCidr(ip, ipv4Ranges); - - // case (b): gateway provided, no netmask - if (hasGateway) { - if (matchedRange != null && matchedRange.getNetworkCidr() != null - && NetworkUtils.isIpv4InCidr(userGateway, matchedRange.getNetworkCidr())) { - return new String[]{matchedRange.getNetmask(), userGateway}; - } - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10323, - "gateway[%s] is provided but IP[%s] and gateway are not both in L3 network CIDR, netmask must be specified", - userGateway, ip)); - } - - // case (c): netmask provided, no gateway - if (hasNetmask) { - if (matchedRange != null && userNetmask.equals(matchedRange.getNetmask())) { - return new String[]{matchedRange.getNetmask(), matchedRange.getGateway()}; - } - if (l3Uuid.equals(defaultL3Uuid) || vmNicCount == 1) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10324, - "netmask[%s] does not match L3 CIDR netmask and the NIC is the default or sole network, gateway must be specified", - userNetmask)); - } - return new String[]{userNetmask, ""}; - } - - // case (d): neither provided - if (existingIp != null && shouldUseExistingIpv4(ip, existingIp)) { - return new String[]{existingIp.getNetmask(), existingIp.getGateway()}; - } - if (matchedRange != null) { - return new String[]{matchedRange.getNetmask(), matchedRange.getGateway()}; - } - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10325, - "IP[%s] is outside all L3 network CIDRs and no existing IP parameters available, netmask and gateway must be specified", - ip)); - } - - /** - * Resolve IPv6 prefix and gateway based on 4 cases (mirrors IPv4 logic): - * (a) Both prefix+gateway provided: use user input as-is - * (b) Gateway provided, no prefix: if ip and gateway both in L3 CIDR, use CIDR prefix; else error - * (c) Prefix provided, no gateway: if prefix == CIDR prefix, use CIDR gateway; else if default/sole NIC, error; else gateway="" - * (d) Neither provided: if existingIp usable (APISetVmStaticIpMsg), use it; else if in L3 CIDR, use CIDR; else error - * - * @param existingIp pass null for APIChangeVmNicNetworkMsg (no existing IP on dest L3) - */ - private String[] resolveIpv6PrefixAndGateway(String ip6, String userPrefix, String userGateway, - List ipv6Ranges, String l3Uuid, String defaultL3Uuid, int vmNicCount, UsedIpVO existingIp) { - boolean hasPrefix = StringUtils.isNotEmpty(userPrefix); - boolean hasGateway = StringUtils.isNotEmpty(userGateway); - - // case (a): both provided - if (hasPrefix && hasGateway) { - return new String[]{userPrefix, userGateway}; - } - - NormalIpRangeVO matchedRange = IpRangeHelper.findIpRangeByCidr(ip6, ipv6Ranges); - - // case (b): gateway provided, no prefix - if (hasGateway) { - if (matchedRange != null && matchedRange.getNetworkCidr() != null - && IPv6NetworkUtils.isIpv6InCidrRange(userGateway, matchedRange.getNetworkCidr())) { - return new String[]{matchedRange.getPrefixLen().toString(), userGateway}; - } - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10326, - "gateway[%s] is provided but IPv6[%s] and gateway are not both in L3 network CIDR, prefix must be specified", - userGateway, ip6)); - } - - // case (c): prefix provided, no gateway - if (hasPrefix) { - if (matchedRange != null && userPrefix.equals(matchedRange.getPrefixLen().toString())) { - return new String[]{matchedRange.getPrefixLen().toString(), matchedRange.getGateway()}; - } - if (l3Uuid.equals(defaultL3Uuid) || vmNicCount == 1) { - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10327, - "prefix[%s] does not match L3 CIDR prefix and the NIC is the default or sole network, gateway must be specified", - userPrefix)); - } - return new String[]{userPrefix, ""}; - } - - // case (d): neither provided - if (existingIp != null && shouldUseExistingIpv6(ip6, existingIp)) { - return new String[]{existingIp.getPrefixLen().toString(), existingIp.getGateway()}; - } - if (matchedRange != null) { - return new String[]{matchedRange.getPrefixLen().toString(), matchedRange.getGateway()}; - } - throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10328, - "IPv6[%s] is outside all L3 network CIDRs and no existing IP parameters available, prefix and gateway must be specified", - ip6)); - } - /** * Check whether an IP is already in use (using error code ORG_ZSTACK_COMPUTE_VM_10105). */ @@ -504,60 +346,34 @@ private void validate(APIChangeVmNicNetworkMsg msg) { } } - new StaticIpOperator().validateSystemTagInApiMessage(msg); + // Build NicRoleContext for resolve logic + String defaultL3Uuid = Q.New(VmInstanceVO.class) + .select(VmInstanceVO_.defaultL3NetworkUuid) + .eq(VmInstanceVO_.uuid, vmUuid) + .findValue(); + int vmNicCount = Q.New(VmNicVO.class).eq(VmNicVO_.vmInstanceUuid, vmUuid).count().intValue(); + boolean isDefaultNic = srcL3Uuid.equals(defaultL3Uuid); + boolean isOnlyNic = vmNicCount == 1; - // Resolve netmask/gateway for static IPs in systemTags, overriding what validateSystemTagInApiMessage may have set - { - String destL3Uuid = msg.getDestL3NetworkUuid(); - Map nicNetworkInfo = new StaticIpOperator().getNicNetworkInfoBySystemTag(msg.getSystemTags()); - NicIpAddressInfo nicIpInfo = nicNetworkInfo.get(destL3Uuid); - if (nicIpInfo != null) { - List destIpv4Ranges = Q.New(NormalIpRangeVO.class) - .eq(NormalIpRangeVO_.l3NetworkUuid, destL3Uuid) - .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv4).list(); - List destIpv6Ranges = Q.New(NormalIpRangeVO.class) - .eq(NormalIpRangeVO_.l3NetworkUuid, destL3Uuid) - .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6).list(); - String defaultL3Uuid = Q.New(VmInstanceVO.class) - .select(VmInstanceVO_.defaultL3NetworkUuid) - .eq(VmInstanceVO_.uuid, vmUuid) - .findValue(); - int vmNicCount = Q.New(VmNicVO.class).eq(VmNicVO_.vmInstanceUuid, vmUuid).count().intValue(); - - // Remove existing netmask/gateway/prefix/ipv6Gateway tags for dest L3 from systemTags - if (msg.getSystemTags() != null) { - msg.getSystemTags().removeIf(tag -> - VmSystemTags.IPV4_NETMASK.isMatch(tag) || VmSystemTags.IPV4_GATEWAY.isMatch(tag) - || VmSystemTags.IPV6_PREFIX.isMatch(tag) || VmSystemTags.IPV6_GATEWAY.isMatch(tag)); - } + StaticIpOperator staticIpOp = new StaticIpOperator(); + Map nicNetworkInfo = staticIpOp.getNicNetworkInfoBySystemTag(msg.getSystemTags()); - // Resolve and add IPv4 netmask/gateway - if (StringUtils.isNotEmpty(nicIpInfo.ipv4Address)) { - String[] ipv4Result = resolveIpv4NetmaskAndGateway(nicIpInfo.ipv4Address, - nicIpInfo.ipv4Netmask, nicIpInfo.ipv4Gateway, - destIpv4Ranges, destL3Uuid, defaultL3Uuid, vmNicCount, null); - msg.getSystemTags().add(VmSystemTags.IPV4_NETMASK.instantiateTag( - map(e(VmSystemTags.IPV4_NETMASK_L3_UUID_TOKEN, destL3Uuid), - e(VmSystemTags.IPV4_NETMASK_TOKEN, ipv4Result[0])))); - msg.getSystemTags().add(VmSystemTags.IPV4_GATEWAY.instantiateTag( - map(e(VmSystemTags.IPV4_GATEWAY_L3_UUID_TOKEN, destL3Uuid), - e(VmSystemTags.IPV4_GATEWAY_TOKEN, ipv4Result[1])))); - } + // Validate IP availability + staticIpOp.validateIpAvailability(nicNetworkInfo); - // Resolve and add IPv6 prefix/gateway - if (StringUtils.isNotEmpty(nicIpInfo.ipv6Address)) { - String[] ipv6Result = resolveIpv6PrefixAndGateway(nicIpInfo.ipv6Address, - nicIpInfo.ipv6Prefix, nicIpInfo.ipv6Gateway, - destIpv6Ranges, destL3Uuid, defaultL3Uuid, vmNicCount, null); - msg.getSystemTags().add(VmSystemTags.IPV6_PREFIX.instantiateTag( - map(e(VmSystemTags.IPV6_PREFIX_L3_UUID_TOKEN, destL3Uuid), - e(VmSystemTags.IPV6_PREFIX_TOKEN, ipv6Result[0])))); - msg.getSystemTags().add(VmSystemTags.IPV6_GATEWAY.instantiateTag( - map(e(VmSystemTags.IPV6_GATEWAY_L3_UUID_TOKEN, destL3Uuid), - e(VmSystemTags.IPV6_GATEWAY_TOKEN, - IPv6NetworkUtils.ipv6AddressToTagValue(ipv6Result[1]))))); - } - } + // Resolve netmask/gateway using unified logic (no existingIp reuse for ChangeNicNetwork) + StaticIpOperator.NicRoleContext nicRole = new StaticIpOperator.NicRoleContext(isDefaultNic, isOnlyNic); + List resolvedTags = staticIpOp.fillUpStaticIpInfoToVmNics(nicNetworkInfo, + nicRole, new StaticIpOperator.ExistingIpContext()); + + // Remove any existing netmask/gateway/prefix tags, then add resolved ones + if (msg.getSystemTags() != null) { + msg.getSystemTags().removeIf(tag -> + VmSystemTags.IPV4_NETMASK.isMatch(tag) || VmSystemTags.IPV4_GATEWAY.isMatch(tag) + || VmSystemTags.IPV6_PREFIX.isMatch(tag) || VmSystemTags.IPV6_GATEWAY.isMatch(tag)); + } + if (!resolvedTags.isEmpty()) { + msg.getSystemTags().addAll(resolvedTags); } Map> staticIps = new StaticIpOperator().getStaticIpbySystemTag(msg.getSystemTags()); @@ -749,6 +565,7 @@ protected void scripts() { throw new ApiMessageInterceptionException(argerr( ORG_ZSTACK_COMPUTE_VM_10124, "the VM cannot do cpu hot plug because of disabling cpu hot plug. Please stop the VM then do the cpu hot plug again" )); + } if (memorySize != null && memorySize != vo.getMemorySize()) { @@ -811,12 +628,6 @@ private void validate(APISetVmStaticIpMsg msg) { throw new ApiMessageInterceptionException(argerr(ORG_ZSTACK_COMPUTE_VM_10135, "could not set ip address, due to no ip address is specified")); } } - List ipv4Ranges = Q.New(NormalIpRangeVO.class) - .eq(NormalIpRangeVO_.l3NetworkUuid, msg.getL3NetworkUuid()) - .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv4).list(); - List ipv6Ranges = Q.New(NormalIpRangeVO.class) - .eq(NormalIpRangeVO_.l3NetworkUuid, msg.getL3NetworkUuid()) - .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6).list(); List vmNics = Q.New(VmNicVO.class).eq(VmNicVO_.vmInstanceUuid, msg.getVmInstanceUuid()).list(); boolean l3Found = false; @@ -874,18 +685,32 @@ private void validate(APISetVmStaticIpMsg msg) { .select(VmInstanceVO_.defaultL3NetworkUuid) .eq(VmInstanceVO_.uuid, msg.getVmInstanceUuid()) .findValue(); + boolean isDefaultNic = msg.getL3NetworkUuid().equals(defaultL3NetworkUuid); + boolean isOnlyNic = vmNics.size() == 1; + + StaticIpOperator staticIpOp = new StaticIpOperator(); + StaticIpOperator.NicRoleContext nicRole = new StaticIpOperator.NicRoleContext(isDefaultNic, isOnlyNic); + StaticIpOperator.ExistingIpContext existingIpCtx = new StaticIpOperator.ExistingIpContext(); + existingIpCtx.putIpv4(msg.getL3NetworkUuid(), existingIpv4); + existingIpCtx.putIpv6(msg.getL3NetworkUuid(), existingIpv6); - // Fill parameters and check IP occupation + // Fill parameters and check IP occupation using unified resolve if (normalizedIp != null) { - String[] ipv4Result = resolveIpv4NetmaskAndGateway(normalizedIp, msg.getNetmask(), msg.getGateway(), - ipv4Ranges, msg.getL3NetworkUuid(), defaultL3NetworkUuid, vmNics.size(), existingIpv4); + List ipv4Ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, msg.getL3NetworkUuid()) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv4).list(); + String[] ipv4Result = staticIpOp.resolveIpv4NetmaskAndGateway(normalizedIp, msg.getNetmask(), msg.getGateway(), + ipv4Ranges, nicRole, existingIpv4); msg.setNetmask(ipv4Result[0]); msg.setGateway(ipv4Result[1]); checkIpOccupied(normalizedIp, msg.getL3NetworkUuid()); } if (normalizedIp6 != null) { - String[] ipv6Result = resolveIpv6PrefixAndGateway(normalizedIp6, msg.getIpv6Prefix(), msg.getIpv6Gateway(), - ipv6Ranges, msg.getL3NetworkUuid(), defaultL3NetworkUuid, vmNics.size(), existingIpv6); + List ipv6Ranges = Q.New(NormalIpRangeVO.class) + .eq(NormalIpRangeVO_.l3NetworkUuid, msg.getL3NetworkUuid()) + .eq(NormalIpRangeVO_.ipVersion, IPv6Constants.IPv6).list(); + String[] ipv6Result = staticIpOp.resolveIpv6PrefixAndGateway(normalizedIp6, msg.getIpv6Prefix(), msg.getIpv6Gateway(), + ipv6Ranges, nicRole, existingIpv6); msg.setIpv6Prefix(ipv6Result[0]); msg.setIpv6Gateway(ipv6Result[1]); checkIpOccupied(normalizedIp6, msg.getL3NetworkUuid()); @@ -1711,4 +1536,4 @@ private void validate(APIFstrimVmMsg msg) { } msg.setHostUuid(t.get(1, String.class)); } -} +} \ No newline at end of file diff --git a/docs/modules/network/pages/networkResource/l3Ipam.adoc b/docs/modules/network/pages/networkResource/l3Ipam.adoc index ce3fcda8335..8f0d00fb117 100644 --- a/docs/modules/network/pages/networkResource/l3Ipam.adoc +++ b/docs/modules/network/pages/networkResource/l3Ipam.adoc @@ -155,8 +155,7 @@ Port Forwarding 不能绑定 IP 不在 L3 CIDR 内的网卡;`APIGetPortForward * 用户输入掩码与网关:以用户输入为准。 * 用户仅输入网关:若 IP 与网关均在 L3 CIDR 内,使用 L3 CIDR 掩码;否则报错。 * 用户仅输入掩码:若与 L3 CIDR 掩码一致,使用 L3 CIDR 网关;否则若网卡为默认网卡或唯一网卡,报错;否则使用输入掩码,网关置空。 -* 用户未输入掩码和网关,且 IP 在 L3 CIDR 内:使用 L3 CIDR 的掩码与网关。 -* 用户未输入掩码和网关,且 IP 在 L3 CIDR 外:报错。 +* 用户未输入掩码和网关:若网卡存在相同 IP 版本地址,且输入 IP 在旧 IP 的掩码与网关组成 CIDR 内,则复用旧掩码与网关;否则若 IP 在 L3 CIDR 内,使用 L3 CIDR 的掩码与网关;否则报错。 * IPv6 与 IPv4 的逻辑一致。 * 对 DNS 参数:若 UI 传递 `NULL`,后端保持旧 DNS 参数不变。 * 对 DNS 参数:若 UI 传递 `""` 或 `[]`,后端删除旧 DNS。 @@ -169,7 +168,7 @@ Port Forwarding 不能绑定 IP 不在 L3 CIDR 内的网卡;`APIGetPortForward * 用户输入掩码与网关:以用户输入为准。 * 用户仅输入网关:若 IP 与网关均在 L3 CIDR 内,使用 L3 CIDR 掩码;否则报错。 * 用户仅输入掩码:若与 L3 CIDR 掩码一致,使用 L3 CIDR 网关;否则若网卡为默认网卡或唯一网卡,报错;否则使用输入掩码,网关置空。 -* 用户未输入掩码和网关:若网卡存在相同 IP 版本地址,且输入 IP 在旧 IP 的掩码与网关组成 CIDR 内,则复用旧掩码与网关;否则若 IP 在 L3 CIDR 内,使用 L3 CIDR 掩码与网关;否则报错。 +* 用户未输入掩码和网关:若 IP 在 L3 CIDR 内,使用 L3 CIDR 掩码与网关;否则报错。 * IPv6 与 IPv4 的逻辑一致。 * 对 DNS 参数:若 UI 传递 `NULL`,后端保持旧 DNS 参数不变。 * 对 DNS 参数:若 UI 传递 `""` 或 `[]`,后端删除旧 DNS。 diff --git a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy index 62b71e75e04..90875ddbf6a 100644 --- a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/FlatChangeVmIpOutsideCidrCase.groovy @@ -6,7 +6,10 @@ import org.zstack.core.db.Q import org.zstack.header.network.l3.UsedIpVO import org.zstack.header.network.l3.UsedIpVO_ import org.zstack.header.network.service.NetworkServiceType +import org.zstack.header.vm.VmInstanceVO +import org.zstack.header.vm.VmInstanceVO_ import org.zstack.header.vm.VmNicVO +import org.zstack.header.vm.VmNicVO_ import org.zstack.network.securitygroup.SecurityGroupConstant import org.zstack.network.service.eip.EipConstant import org.zstack.network.service.flat.FlatDhcpBackend @@ -33,6 +36,12 @@ import org.zstack.utils.network.IPv6Constants * * Each scenario tests: setVmStaticIp, changeVmNicNetwork, DHCP skip, EIP rejection. * Additional: orphan IP backfill when adding IP range. + * + * Netmask/gateway auto-resolve tests (Case A–D): + * Uses flatL3_range_noDhcp (CIDR: 192.168.100.0/24) as destination. + * Tests the unified resolveIpv4NetmaskAndGateway logic via changeVmNicNetwork and setVmStaticIp. + * Case C (netmask mismatch) requires multi-NIC VMs via flatL3_second (no IPAM). + * Case D-3 (existing IP reuse) is unique to setVmStaticIp. */ class FlatChangeVmIpOutsideCidrCase extends SubCase { @@ -93,6 +102,7 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { attachL2Network("l2-pub-range-dhcp") attachL2Network("l2-backfill") attachL2Network("l2-dest") + attachL2Network("l2-second") } localPrimaryStorage { @@ -237,6 +247,22 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { } } + // ========== Second L3: no IPAM, used as extra NIC for multi-NIC resolve tests ========== + l2NoVlanNetwork { + name = "l2-second" + physicalInterface = "eth7" + + l3Network { + name = "flatL3_second" + enableIPAM = false + + service { + provider = SecurityGroupConstant.SECURITY_GROUP_PROVIDER_TYPE + types = [SecurityGroupConstant.SECURITY_GROUP_NETWORK_SERVICE_TYPE] + } + } + } + attachBackupStorage("sftp") } } @@ -277,6 +303,21 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { // Orphan IP backfill // ========================================== testOrphanIpBackfillOnAddIpRange() + + // ========================================== + // Netmask/gateway auto-resolve (Case A–D) + // Uses flatL3_range_noDhcp (CIDR 192.168.100.0/24) + // ========================================== + testResolve_A1_bothProvided_gatewayInCidr_success() + testResolve_A2_bothProvided_gatewayNotInCidr_error() + testResolve_B1_gatewayAndIpBothInCidr_success() + testResolve_B2_ipInCidrButGatewayNotInCidr_error() + testResolve_B3_ipNotInAnyCidr_error() + testResolve_C1_netmaskMatchesCidr_success() + testResolve_C4_netmaskMismatch_nonDefaultNonSole_success() + testResolve_D1_ipInCidr_success() + testResolve_D2_ipOutsideCidr_error() + testResolve_D3_setStaticIp_existingIpReuse_success() } } @@ -294,6 +335,18 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { } } + VmInstanceInventory createVmOnL3(String vmName, List l3Uuids, String defaultL3Uuid = null) { + return createVmInstance { + name = vmName + imageUuid = env.inventoryByName("image1").uuid + instanceOfferingUuid = env.inventoryByName("instanceOffering").uuid + l3NetworkUuids = l3Uuids + if (defaultL3Uuid != null) { + delegate.defaultL3NetworkUuid = defaultL3Uuid + } + } + } + // ================================================================ // Flat: no IP range, no DHCP // ================================================================ @@ -455,28 +508,6 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) assert nicVO.ip == "10.0.0.50" - - // Also verify in-range IP works - VmInstanceInventory vm2 = createVmOnL3("vm-flat-range-noDhcp-inrange", l3.uuid) - List freeIps1 = getFreeIp { - l3NetworkUuid = l3.uuid - ipVersion = IPv6Constants.IPv4 - limit = 1 - } as List - String inRangeIp1 = freeIps1.get(0).getIp() - - setVmStaticIp { - vmInstanceUuid = vm2.uuid - l3NetworkUuid = l3.uuid - ip = inRangeIp1 - } - - UsedIpVO inRangeIp = Q.New(UsedIpVO.class) - .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) - .eq(UsedIpVO_.ip, inRangeIp1) - .find() - assert inRangeIp != null - assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" } /** @@ -603,28 +634,6 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) assert nicVO.ip == "10.0.1.50" - - // Also verify in-range IP works - VmInstanceInventory vm2 = createVmOnL3("vm-flat-range-dhcp-inrange", l3.uuid) - List freeIps2 = getFreeIp { - l3NetworkUuid = l3.uuid - ipVersion = IPv6Constants.IPv4 - limit = 1 - } as List - String inRangeIp2 = freeIps2.get(0).getIp() - - setVmStaticIp { - vmInstanceUuid = vm2.uuid - l3NetworkUuid = l3.uuid - ip = inRangeIp2 - } - - UsedIpVO inRangeIp = Q.New(UsedIpVO.class) - .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) - .eq(UsedIpVO_.ip, inRangeIp2) - .find() - assert inRangeIp != null - assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" } /** @@ -920,4 +929,291 @@ class FlatChangeVmIpOutsideCidrCase extends SubCase { assert afterBackfill.usedIpAddressNumber == beforeBackfill.usedIpAddressNumber + outsideCount : "usedIpAddressNumber should increase by ${outsideCount} after backfill" } + + // ================================================================ + // Netmask/gateway auto-resolve tests (Case A–D) + // Uses flatL3_range_noDhcp (CIDR: 192.168.100.0/24, gw: 192.168.100.1) + // Source L3: flatL3_dest (no IPAM) + // Second L3: flatL3_second (no IPAM, for multi-NIC tests) + // ================================================================ + + /** + * a-1: both netmask+gateway provided, gateway in CIDR(ip/netmask). + * Expected: success, use user input as-is. + */ + void testResolve_A1_bothProvided_gatewayInCidr_success() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-a1", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.40", destL3.uuid), + String.format("ipv4Netmask::%s::255.255.255.0", destL3.uuid), + String.format("ipv4Gateway::%s::192.168.100.1", destL3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.l3NetworkUuid == destL3.uuid + assert nicVO.ip == "192.168.100.40" + assert nicVO.netmask == "255.255.255.0" + assert nicVO.gateway == "192.168.100.1" + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.vmNicUuid, vmNic.uuid) + .eq(UsedIpVO_.ip, "192.168.100.40") + .find() + assert usedIp != null + assert usedIp.netmask == "255.255.255.0" + assert usedIp.gateway == "192.168.100.1" + } + + /** + * a-2: both netmask+gateway provided, gateway NOT in CIDR(ip/netmask). + * Expected: error. + */ + void testResolve_A2_bothProvided_gatewayNotInCidr_error() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-a2", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + expect(AssertionError.class) { + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.41", destL3.uuid), + String.format("ipv4Netmask::%s::255.255.255.0", destL3.uuid), + String.format("ipv4Gateway::%s::10.0.0.1", destL3.uuid) + ] + } + } + } + + /** + * b-1: gateway provided (no netmask), IP and gateway both in L3 CIDR. + * Expected: success, netmask from CIDR. + */ + void testResolve_B1_gatewayAndIpBothInCidr_success() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-b1", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.50", destL3.uuid), + String.format("ipv4Gateway::%s::192.168.100.1", destL3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.ip == "192.168.100.50" + assert nicVO.netmask == "255.255.255.0" : "netmask should be inferred from L3 CIDR" + assert nicVO.gateway == "192.168.100.1" + } + + /** + * b-2: gateway provided (no netmask), IP in CIDR but gateway NOT in CIDR. + * Expected: error. + */ + void testResolve_B2_ipInCidrButGatewayNotInCidr_error() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-b2", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + expect(AssertionError.class) { + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.51", destL3.uuid), + String.format("ipv4Gateway::%s::10.0.0.1", destL3.uuid) + ] + } + } + } + + /** + * b-3: gateway provided (no netmask), IP NOT in any CIDR. + * Expected: error. + */ + void testResolve_B3_ipNotInAnyCidr_error() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-b3", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + expect(AssertionError.class) { + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::10.0.0.50", destL3.uuid), + String.format("ipv4Gateway::%s::10.0.0.1", destL3.uuid) + ] + } + } + } + + /** + * c-1: netmask provided (no gateway), netmask == CIDR netmask. + * Expected: success, gateway from CIDR. + */ + void testResolve_C1_netmaskMatchesCidr_success() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-c1", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.60", destL3.uuid), + String.format("ipv4Netmask::%s::255.255.255.0", destL3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.ip == "192.168.100.60" + assert nicVO.netmask == "255.255.255.0" + assert nicVO.gateway == "192.168.100.1" : "gateway should be inferred from L3 CIDR" + } + + /** + * c-4: netmask != CIDR, non-default & non-sole. + * VM has 2 NICs [flatL3_dest(default), flatL3_second]. + * Change flatL3_second NIC → flatL3_range_noDhcp (not default, vmNicCount=2). + * Expected: success, netmask=user input, gateway="". + */ + void testResolve_C4_netmaskMismatch_nonDefaultNonSole_success() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + L3NetworkInventory secondL3 = env.inventoryByName("flatL3_second") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-c4", [srcL3.uuid, secondL3.uuid], srcL3.uuid) + + int nicCount = Q.New(VmNicVO.class).eq(VmNicVO_.vmInstanceUuid, vm.uuid).count().intValue() + assert nicCount == 2 + + String defaultL3 = Q.New(VmInstanceVO.class) + .select(VmInstanceVO_.defaultL3NetworkUuid) + .eq(VmInstanceVO_.uuid, vm.uuid) + .findValue() + assert defaultL3 == srcL3.uuid + assert defaultL3 != destL3.uuid + + VmNicInventory nicOnSecond = vm.vmNics.find { it.l3NetworkUuid == secondL3.uuid } + assert nicOnSecond != null + + changeVmNicNetwork { + vmNicUuid = nicOnSecond.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.90", destL3.uuid), + String.format("ipv4Netmask::%s::255.255.0.0", destL3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(nicOnSecond.uuid, VmNicVO.class) + assert nicVO.ip == "192.168.100.90" + assert nicVO.netmask == "255.255.0.0" : "netmask should be user input" + assert nicVO.gateway == "" || nicVO.gateway == null : "gateway should be empty" + } + + /** + * d-1: neither netmask nor gateway, IP in L3 CIDR. + * Expected: success, netmask+gateway from CIDR. + */ + void testResolve_D1_ipInCidr_success() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-d1", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::192.168.100.100", destL3.uuid) + ] + } + + VmNicVO nicVO = dbFindByUuid(vmNic.uuid, VmNicVO.class) + assert nicVO.ip == "192.168.100.100" + assert nicVO.netmask == "255.255.255.0" : "netmask should be inferred from L3 CIDR" + assert nicVO.gateway == "192.168.100.1" : "gateway should be inferred from L3 CIDR" + } + + /** + * d-2: neither netmask nor gateway, IP NOT in any CIDR. + * Expected: error. + */ + void testResolve_D2_ipOutsideCidr_error() { + L3NetworkInventory destL3 = env.inventoryByName("flatL3_range_noDhcp") + L3NetworkInventory srcL3 = env.inventoryByName("flatL3_dest") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-d2", srcL3.uuid) + VmNicInventory vmNic = vm.vmNics[0] + + expect(AssertionError.class) { + changeVmNicNetwork { + vmNicUuid = vmNic.uuid + destL3NetworkUuid = destL3.uuid + systemTags = [ + String.format("staticIp::%s::10.0.0.100", destL3.uuid) + ] + } + } + } + + /** + * d-3 (setVmStaticIp only): existing IP has params, new IP in old CIDR → reuse. + * This scenario is unique to APISetVmStaticIpMsg (existing IP reuse via ExistingIpContext). + */ + void testResolve_D3_setStaticIp_existingIpReuse_success() { + L3NetworkInventory l3 = env.inventoryByName("flatL3_range_noDhcp") + + VmInstanceInventory vm = createVmOnL3("vm-resolve-d3", l3.uuid) + + // First set a known IP with explicit netmask/gateway + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = "192.168.100.110" + netmask = "255.255.255.0" + gateway = "192.168.100.1" + } + + // Now change to a new IP in the same CIDR, without specifying netmask/gateway + setVmStaticIp { + vmInstanceUuid = vm.uuid + l3NetworkUuid = l3.uuid + ip = "192.168.100.111" + } + + UsedIpVO usedIp = Q.New(UsedIpVO.class) + .eq(UsedIpVO_.l3NetworkUuid, l3.uuid) + .eq(UsedIpVO_.ip, "192.168.100.111") + .find() + assert usedIp != null + assert usedIp.netmask == "255.255.255.0" : "should reuse old netmask" + assert usedIp.gateway == "192.168.100.1" : "should reuse old gateway" + } } diff --git a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy index cd226cac406..3b1ae524137 100644 --- a/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy +++ b/test/src/test/groovy/org/zstack/test/integration/networkservice/provider/flat/PublicNetworkChangeVmIpOutsideCidrCase.groovy @@ -17,7 +17,6 @@ import org.zstack.testlib.EnvSpec import org.zstack.testlib.SubCase import org.zstack.utils.data.SizeUnit import org.zstack.utils.gson.JSONObjectUtil -import org.zstack.utils.network.IPv6Constants /** * Test IP outside CIDR behavior for public networks. @@ -230,28 +229,6 @@ class PublicNetworkChangeVmIpOutsideCidrCase extends SubCase { VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) assert nicVO.ip == "10.0.2.50" - - // Also verify in-range IP works - VmInstanceInventory vm2 = createVmOnL3("vm-pub-range-noDhcp-inrange", l3.uuid) - List freeIps1 = getFreeIp { - l3NetworkUuid = l3.uuid - ipVersion = IPv6Constants.IPv4 - limit = 1 - } as List - String inRangeIp1 = freeIps1.get(0).getIp() - - setVmStaticIp { - vmInstanceUuid = vm2.uuid - l3NetworkUuid = l3.uuid - ip = inRangeIp1 - } - - UsedIpVO inRangeIp = Q.New(UsedIpVO.class) - .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) - .eq(UsedIpVO_.ip, inRangeIp1) - .find() - assert inRangeIp != null - assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" } /** @@ -348,28 +325,6 @@ class PublicNetworkChangeVmIpOutsideCidrCase extends SubCase { VmNicVO nicVO = dbFindByUuid(vm.vmNics[0].uuid, VmNicVO.class) assert nicVO.ip == "10.0.3.50" - - // Also verify in-range IP works - VmInstanceInventory vm2 = createVmOnL3("vm-pub-range-dhcp-inrange", l3.uuid) - List freeIps2 = getFreeIp { - l3NetworkUuid = l3.uuid - ipVersion = IPv6Constants.IPv4 - limit = 1 - } as List - String inRangeIp2 = freeIps2.get(0).getIp() - - setVmStaticIp { - vmInstanceUuid = vm2.uuid - l3NetworkUuid = l3.uuid - ip = inRangeIp2 - } - - UsedIpVO inRangeIp = Q.New(UsedIpVO.class) - .eq(UsedIpVO_.vmNicUuid, vm2.vmNics[0].uuid) - .eq(UsedIpVO_.ip, inRangeIp2) - .find() - assert inRangeIp != null - assert inRangeIp.ipRangeUuid != null : "ipRangeUuid should not be null for in-range IP" } /** diff --git a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java index e84e33e7412..80d13d1a949 100644 --- a/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java +++ b/utils/src/main/java/org/zstack/utils/clouderrorcode/CloudOperationsErrorCode.java @@ -9874,6 +9874,10 @@ public class CloudOperationsErrorCode { public static final String ORG_ZSTACK_COMPUTE_VM_10328 = "ORG_ZSTACK_COMPUTE_VM_10328"; + public static final String ORG_ZSTACK_COMPUTE_VM_10329 = "ORG_ZSTACK_COMPUTE_VM_10329"; + + public static final String ORG_ZSTACK_COMPUTE_VM_10330 = "ORG_ZSTACK_COMPUTE_VM_10330"; + public static final String ORG_ZSTACK_IDENTITY_LOGIN_10000 = "ORG_ZSTACK_IDENTITY_LOGIN_10000"; public static final String ORG_ZSTACK_STORAGE_VOLUME_BLOCK_EXPON_10000 = "ORG_ZSTACK_STORAGE_VOLUME_BLOCK_EXPON_10000"; From e2956c7e2aebbad691edbab26f706780f8fc06b4 Mon Sep 17 00:00:00 2001 From: "chao.he" Date: Wed, 17 Dec 2025 16:13:06 +0800 Subject: [PATCH 84/85] [plugin-premium]: GPU/VM page keeps loading when shutting down or encountering errors in Zaku cluster. Resolves: ZSTAC-80202 Change-Id: I7778676171646874706164777869707279776172 --- .../zql/BeforeCallZWatchExtensionPoint.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java diff --git a/header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java b/header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java new file mode 100644 index 00000000000..ae634553d8e --- /dev/null +++ b/header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java @@ -0,0 +1,23 @@ +package org.zstack.header.zql; + +import java.util.List; + +/** + * BeforeZQLReturnWithExtensionPoint is an extension point that allows plugins + * to perform custom operations before calling zwatch. + */ +public interface BeforeCallZWatchExtensionPoint { + /** + * Check if this extension supports the given VO class + * @param voClass the VO class to check + * @return true if this extension supports the VO class, false otherwise + */ + boolean supports(Class voClass); + + /** + * Perform custom operations before calling ZWatch, for example: health-check + * @param voClass the VO class type + * @param uuids the list of resource UUIDs to process + */ + void beforeCallZWatch(Class voClass, List uuids); +} From 107fb32d48ebdfb724c88388b6249ba5307b1704 Mon Sep 17 00:00:00 2001 From: "chao.he" Date: Mon, 12 Jan 2026 13:34:14 +0800 Subject: [PATCH 85/85] [plugin-premium]: GPU/VM page keeps loading when shutting down or encountering errors in Zaku cluster. Resolves: ZSTAC-80202 Change-Id: I7778676171646874706164777869707279776172 --- .../org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java b/header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java index ae634553d8e..d0ec90621d6 100644 --- a/header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java +++ b/header/src/main/java/org/zstack/header/zql/BeforeCallZWatchExtensionPoint.java @@ -3,7 +3,7 @@ import java.util.List; /** - * BeforeZQLReturnWithExtensionPoint is an extension point that allows plugins + * BeforeCallZWatchExtensionPoint is an extension point that allows plugins * to perform custom operations before calling zwatch. */ public interface BeforeCallZWatchExtensionPoint {