diff --git a/compute/src/main/java/org/zstack/compute/vm/AbstractVmInstance.java b/compute/src/main/java/org/zstack/compute/vm/AbstractVmInstance.java index 6972470c964..9af3af26ee7 100755 --- a/compute/src/main/java/org/zstack/compute/vm/AbstractVmInstance.java +++ b/compute/src/main/java/org/zstack/compute/vm/AbstractVmInstance.java @@ -205,6 +205,12 @@ public abstract class AbstractVmInstance implements VmInstance { APIDestroyVmInstanceMsg.class.getName(), DestroyVmInstanceMsg.class.getName()); + // Registering state: only metadata-related reads, destroy (for cleanup/rollback), + // and ChangeVmMetaDataMsg (for state transitions during registration) are allowed. + allowedOperations.addState(VmInstanceState.Registering, + ChangeVmMetaDataMsg.class.getName(), + APIDestroyVmInstanceMsg.class.getName(), + DestroyVmInstanceMsg.class.getName()); stateChangeChecker.addState(VmInstanceStateEvent.unknown.toString(), VmInstanceState.Created.toString(), diff --git a/compute/src/main/java/org/zstack/compute/vm/VmExpungeMetadataFlow.java b/compute/src/main/java/org/zstack/compute/vm/VmExpungeMetadataFlow.java new file mode 100644 index 00000000000..946eeb3dce0 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/VmExpungeMetadataFlow.java @@ -0,0 +1,91 @@ +package org.zstack.compute.vm; + +import org.springframework.beans.factory.annotation.Autowire; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Configurable; +import org.zstack.core.db.Q; +import org.zstack.header.core.Completion; +import org.zstack.header.core.workflow.FlowTrigger; +import org.zstack.header.core.workflow.NoRollbackFlow; +import org.zstack.header.errorcode.ErrorCode; +import org.zstack.header.vm.MetadataStorageHandler; +import org.zstack.header.vm.VmInstanceConstant; +import org.zstack.header.vm.VmInstanceSpec; +import org.zstack.header.volume.VolumeVO; +import org.zstack.header.volume.VolumeVO_; +import org.zstack.header.volume.VolumeType; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.Map; + +/** + * VM 彻底删除(Expunge)时清理主存储上的元数据文件。 + * + *

设计要点(Part 02b §8.3):

+ * + * + *

删除时机说明(Δ-5):元数据在 Expunge(物理删除)而非 Destroy(软删除) + * 阶段清理。Destroy 时 VM 可通过 Recover 恢复,过早删除会导致恢复后元数据丢失。

+ */ +@Configurable(preConstruction = true, autowire = Autowire.BY_TYPE) +public class VmExpungeMetadataFlow extends NoRollbackFlow { + private static final CLogger logger = Utils.getLogger(VmExpungeMetadataFlow.class); + + @Autowired + private MetadataStorageHandler metadataStorageHandler; + + @Override + public void run(FlowTrigger trigger, Map data) { + final VmInstanceSpec spec = (VmInstanceSpec) data.get(VmInstanceConstant.Params.VmInstanceSpec.toString()); + final String vmUuid = spec.getVmInventory().getUuid(); + + // 功能开关检查:即使功能关闭,也尝试清理已有的元数据文件(best-effort) + // 不检查 VM_METADATA 开关——Expunge 是不可逆操作,应始终尝试清理残留 + + // 通过根卷查找 PS UUID + String rootVolumeUuid = spec.getVmInventory().getRootVolumeUuid(); + if (rootVolumeUuid == null) { + // VM 处于中间状态,无根卷,跳过 + logger.debug(String.format("[MetadataExpunge] vm[uuid:%s] has no root volume, skipping metadata cleanup", vmUuid)); + trigger.next(); + return; + } + + String psUuid = Q.New(VolumeVO.class) + .eq(VolumeVO_.uuid, rootVolumeUuid) + .select(VolumeVO_.primaryStorageUuid) + .findValue(); + + if (psUuid == null) { + // 根卷已被删除或无 PS 信息,跳过 + logger.debug(String.format("[MetadataExpunge] vm[uuid:%s] root volume[uuid:%s] has no primaryStorageUuid, " + + "skipping metadata cleanup", vmUuid, rootVolumeUuid)); + trigger.next(); + return; + } + + logger.info(String.format("[MetadataExpunge] deleting metadata for vm[uuid:%s] on ps[uuid:%s]", vmUuid, psUuid)); + + metadataStorageHandler.deleteMetadata(psUuid, vmUuid, new Completion(trigger) { + @Override + public void success() { + logger.info(String.format("[MetadataExpunge] metadata deleted for vm[uuid:%s] on ps[uuid:%s]", vmUuid, psUuid)); + trigger.next(); + } + + @Override + public void fail(ErrorCode errorCode) { + // best-effort:失败不阻塞 VM 物理清除 + logger.warn(String.format("[MetadataExpunge] failed to delete metadata for vm[uuid:%s] on ps[uuid:%s], " + + "continuing expunge. Error: %s", vmUuid, psUuid, errorCode)); + trigger.next(); + } + }); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/VmGlobalConfig.java b/compute/src/main/java/org/zstack/compute/vm/VmGlobalConfig.java index bd79900c13c..c0442dcf4c2 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmGlobalConfig.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmGlobalConfig.java @@ -133,4 +133,139 @@ public class VmGlobalConfig { @GlobalConfigValidation(validValues = {"None", "AuthenticAMD"}) @BindResourceConfig(value = {VmInstanceVO.class}) public static GlobalConfig VM_CPUID_VENDOR = new GlobalConfig(CATEGORY, "vm.cpuid.vendor"); + + @GlobalConfigValidation(numberGreaterThan = 1) + public static GlobalConfig GC_INTERVAL = new GlobalConfig(CATEGORY, "deletion.gcInterval"); + + @GlobalConfigValidation(validValues = {"true", "false"}) + public static GlobalConfig VM_METADATA = new GlobalConfig(CATEGORY, "vm.metadata"); + + @GlobalConfigDef(defaultValue = "5", type = Integer.class, + description = "Max concurrent metadata writes per primary storage per MN") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_PS_MAX_CONCURRENT = new GlobalConfig(CATEGORY, "vm.metadata.ps.maxConcurrent"); + + @GlobalConfigDef(defaultValue = "10", type = Integer.class, + description = "Max concurrent VM metadata updates globally per MN") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_GLOBAL_MAX_CONCURRENT = new GlobalConfig(CATEGORY, "vm.metadata.global.maxConcurrent"); + + @GlobalConfigDef(defaultValue = "10", type = Integer.class, + description = "Initial GC delay in seconds after API success") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_GC_INITIAL_DELAY_SEC = new GlobalConfig(CATEGORY, "vm.metadata.gc.initialDelaySec"); + + @GlobalConfigDef(defaultValue = "5", type = Integer.class, + description = "Max retry count before giving up metadata flush") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_MAX_RETRY = new GlobalConfig(CATEGORY, "vm.metadata.maxRetry"); + + @GlobalConfigDef(defaultValue = "5", type = Long.class, + description = "Dirty poller interval in seconds") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_DIRTY_POLL_INTERVAL = new GlobalConfig(CATEGORY, "vm.metadata.dirty.pollIntervalSec"); + + @GlobalConfigDef(defaultValue = "20", type = Integer.class, + description = "Max dirty rows to claim per poller cycle") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_DIRTY_BATCH_SIZE = new GlobalConfig(CATEGORY, "vm.metadata.dirty.batchSize"); + + @GlobalConfigDef(defaultValue = "300", type = Long.class, + description = "Path fingerprint check interval in seconds") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_PATH_CHECK_INTERVAL = new GlobalConfig(CATEGORY, "vm.metadata.pathCheck.intervalSec"); + + @GlobalConfigDef(defaultValue = "500", type = Integer.class, + description = "Path fingerprint check keyset pagination batch size") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_PATH_CHECK_BATCH_SIZE = new GlobalConfig(CATEGORY, "vm.metadata.pathCheck.batchSize"); + + @GlobalConfigDef(defaultValue = "600", type = Long.class, + description = "Delay in seconds before full refresh after upgrade, waiting for rolling upgrade to complete") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_UPGRADE_REFRESH_DELAY = new GlobalConfig(CATEGORY, "vm.metadata.upgrade.refreshDelaySec"); + + @GlobalConfigDef(defaultValue = "1000", type = Integer.class, + description = "Upgrade full refresh SQL batch size") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_UPGRADE_REFRESH_BATCH_SIZE = new GlobalConfig(CATEGORY, "vm.metadata.upgrade.refreshBatchSize"); + + @GlobalConfigDef(defaultValue = "5", type = Long.class, + description = "Delay in seconds after nodeLeft before takeover, reduces zombie MN race condition") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_NODE_LEFT_DELAY = new GlobalConfig(CATEGORY, "vm.metadata.nodeLeft.delaySec"); + + @GlobalConfigDef(defaultValue = "1800", type = Long.class, + description = "MetadataStaleRecoveryTask scan interval in seconds") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_STALE_RECOVERY_INTERVAL = new GlobalConfig(CATEGORY, "vm.metadata.staleRecovery.intervalSec"); + + @GlobalConfigDef(defaultValue = "100", type = Integer.class, + description = "MetadataStaleRecoveryTask rows per scan batch") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_STALE_RECOVERY_BATCH_SIZE = new GlobalConfig(CATEGORY, "vm.metadata.staleRecovery.batchSize"); + + @GlobalConfigDef(defaultValue = "10", type = Integer.class, + description = "Max consecutive stale recovery cycles per VM before circuit-break") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_STALE_RECOVERY_MAX_CYCLES = new GlobalConfig(CATEGORY, "vm.metadata.staleRecovery.maxCycles"); + + @GlobalConfigDef(defaultValue = "45", type = Long.class, + description = "Pending API timeout cleanup threshold in minutes") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_PENDING_API_TIMEOUT = new GlobalConfig(CATEGORY, "vm.metadata.pendingApi.timeoutMinutes"); + + @GlobalConfigDef(defaultValue = "10", type = Integer.class, + description = "Exponential backoff base delay in seconds") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_RETRY_BASE_DELAY = new GlobalConfig(CATEGORY, "vm.metadata.retry.baseDelaySeconds"); + + @GlobalConfigDef(defaultValue = "10", type = Integer.class, + description = "Exponential backoff max exponent") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_RETRY_MAX_EXPONENT = new GlobalConfig(CATEGORY, "vm.metadata.retry.maxExponent"); + + @GlobalConfigDef(defaultValue = "200", type = Integer.class, + description = "Batch size per round when enabling metadata (false to true init)") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_INIT_BATCH_SIZE = new GlobalConfig(CATEGORY, "vm.metadata.init.batchSize"); + + @GlobalConfigDef(defaultValue = "5", type = Long.class, + description = "Delay in seconds between init batches to prevent IO storm") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_INIT_BATCH_DELAY = new GlobalConfig(CATEGORY, "vm.metadata.init.batchDelaySec"); + + @GlobalConfigDef(defaultValue = "3600", type = Long.class, + description = "Orphan metadata detection interval in seconds") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_ORPHAN_CHECK_INTERVAL = new GlobalConfig(CATEGORY, "vm.metadata.orphanCheck.intervalSec"); + + @GlobalConfigDef(defaultValue = "15", type = Long.class, + description = "Zombie claim threshold in minutes: claimed dirty rows older than this are released") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_ZOMBIE_CLAIM_THRESHOLD = new GlobalConfig(CATEGORY, "vm.metadata.zombieClaim.thresholdMinutes"); + + @GlobalConfigDef(defaultValue = "30", type = Long.class, + description = "Stale claim threshold in minutes for background recovery task") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_STALE_CLAIM_THRESHOLD = new GlobalConfig(CATEGORY, "vm.metadata.staleClaim.thresholdMinutes"); + + @GlobalConfigDef(defaultValue = "10", type = Long.class, + description = "Inline stale claim takeover threshold in minutes for triggerFlushForVm hot path") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_TRIGGER_FLUSH_STALE = new GlobalConfig(CATEGORY, "vm.metadata.triggerFlush.staleMinutes"); + + @GlobalConfigDef(defaultValue = "3", type = Integer.class, + description = "Max retry count for deleteMetadata in ExpungeVmInstanceFlow") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_DELETE_MAX_RETRY = new GlobalConfig(CATEGORY, "vm.metadata.delete.maxRetry"); + + @GlobalConfigDef(defaultValue = "30", type = Long.class, + description = "Base delay in seconds for deleteMetadata retry backoff") + @GlobalConfigValidation(numberGreaterThan = 0) + public static GlobalConfig VM_METADATA_DELETE_BASE_DELAY = new GlobalConfig(CATEGORY, "vm.metadata.delete.baseDelaySec"); + + @GlobalConfigDef(defaultValue = "", type = String.class, + description = "Last completed upgrade refresh version, prevents duplicate triggers across MNs. Internal use only") + public static GlobalConfig VM_METADATA_LAST_REFRESH_VERSION = new GlobalConfig(CATEGORY, "vm.metadata.lastRefreshVersion"); } 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 c17cf5d5179..0ce04419dcb 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceApiInterceptor.java @@ -21,6 +21,7 @@ import org.zstack.header.message.APIMessage; import org.zstack.header.network.l2.*; import org.zstack.header.network.l3.*; +import org.zstack.header.storage.primary.APIRegisterVmInstanceMsg; import org.zstack.header.storage.primary.PrimaryStorageClusterRefVO; import org.zstack.header.storage.primary.PrimaryStorageClusterRefVO_; import org.zstack.header.storage.snapshot.VolumeSnapshotVO; 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 e31bc001218..7dcb5e6899a 100755 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceBase.java @@ -45,6 +45,15 @@ import org.zstack.header.message.*; import org.zstack.header.network.l3.*; import org.zstack.header.storage.primary.*; +import org.zstack.header.storage.snapshot.*; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefVO; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefVO_; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO_; +import org.zstack.header.tag.SystemTagVO; +import org.zstack.header.tag.SystemTagVO_; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import org.zstack.header.vm.*; import org.zstack.header.vm.ChangeVmMetaDataMsg.AtomicHostUuid; import org.zstack.header.vm.ChangeVmMetaDataMsg.AtomicVmState; @@ -66,23 +75,19 @@ import org.zstack.network.l3.L3NetworkManager; import org.zstack.network.service.DnsUtils; import org.zstack.network.service.NetworkServiceManager; -import org.zstack.resourceconfig.ResourceConfig; -import org.zstack.resourceconfig.ResourceConfigFacade; +import org.zstack.resourceconfig.*; import org.zstack.tag.SystemTagCreator; import org.zstack.tag.SystemTagUtils; import org.zstack.tag.TagManager; -import org.zstack.utils.CollectionUtils; -import org.zstack.utils.ExceptionDSL; -import org.zstack.utils.ObjectUtils; -import org.zstack.utils.Utils; +import org.zstack.utils.*; import org.zstack.utils.function.ForEachFunction; import org.zstack.utils.function.Function; import org.zstack.utils.gson.JSONObjectUtil; import org.zstack.utils.logging.CLogger; -import org.zstack.utils.network.NicIpAddressInfo; import org.zstack.utils.network.IPv6Constants; import org.zstack.utils.network.IPv6NetworkUtils; import org.zstack.utils.network.NetworkUtils; +import org.zstack.utils.network.NicIpAddressInfo; import javax.persistence.PersistenceException; import javax.persistence.Tuple; @@ -90,6 +95,7 @@ import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static java.util.Arrays.asList; @@ -140,6 +146,10 @@ public class VmInstanceBase extends AbstractVmInstance { private VmInstanceResourceMetadataManager vidm; @Autowired private NetworkServiceManager nwServiceMgr; + @Autowired + private ResourceDestinationMaker destMaker; + @Autowired + private org.zstack.compute.vm.metadata.VmMetadataBuilder vmMetadataBuilder; protected VmInstanceVO self; protected VmInstanceVO originalCopy; @@ -533,6 +543,8 @@ protected void handleLocalMessage(Message msg) { handle((CancelFlattenVmInstanceMsg) msg); } else if (msg instanceof KvmReportVmShutdownEventMsg) { handle((KvmReportVmShutdownEventMsg) msg); + } else if (msg instanceof UpdateVmInstanceMetadataMsg) { + handle((UpdateVmInstanceMetadataMsg) msg); } else { VmInstanceBaseExtensionFactory ext = vmMgr.getVmInstanceBaseExtensionFactory(msg); if (ext != null) { @@ -3184,6 +3196,17 @@ protected List getImageCandidatesForVm(ImageMediaType type) { } protected void handleApiMessage(APIMessage msg) { + // Guard: reject most API operations while VM is in Registering state (§3.5) + if (self != null && self.getState() == VmInstanceState.Registering) { + // Only allow Destroy and query/read operations during registration + if (!(msg instanceof APIDestroyVmInstanceMsg)) { + bus.replyErrorByMessageType((Message) msg, + operr("vm[uuid:%s] is in Registering state, operation[%s] is not allowed", + self.getUuid(), msg.getMessageName())); + return; + } + } + if (msg instanceof APIStopVmInstanceMsg) { handle((APIStopVmInstanceMsg) msg); } else if (msg instanceof APIRebootVmInstanceMsg) { @@ -9369,5 +9392,80 @@ public void run(MessageReply reply) { } }); } -} + /** + * 处理元数据更新消息。 + * + *

通过 ChainTask 确保同一 VM 的元数据更新串行执行。 + * 该消息由 VmMetadataDirtyMarker 发送到本地 VM 服务, + * 内部从 DB 全量构建 metadata payload 后写入主存储。

+ * + *

失败路径直接返回错误 reply,由 VmMetadataDirtyMarker 的 + * onFlushFailure() 统一处理重试和指数退避。

+ */ + private void handle(UpdateVmInstanceMetadataMsg msg) { + thdf.chainSubmit(new ChainTask(msg) { + @Override + public String getSyncSignature() { + return String.format("handle-update-vm-%s-metadata", msg.getUuid()); + } + + @Override + public void run(SyncTaskChain chain) { + doHandleUpdateVmInstanceMetadata(msg); + chain.next(); + } + + @Override + public String getName() { + return String.format("handle-update-vm-%s-metadata-task", msg.getUuid()); + } + }); + } + + private void doHandleUpdateVmInstanceMetadata(UpdateVmInstanceMetadataMsg msg) { + // 1. 构建 payload(通过 VmMetadataBuilder 在 @Transactional(readOnly=true) 事务内完成) + String metadata = vmMetadataBuilder.buildVmInstanceMetadata(msg.getUuid()); + + // 2. Payload 大小保护 + int payloadSize = metadata.getBytes(java.nio.charset.StandardCharsets.UTF_8).length; + if (payloadSize > org.zstack.compute.vm.metadata.VmMetadataBuilder.REJECT_THRESHOLD) { + logger.error(String.format("metadata payload too large: %d bytes for vm[uuid:%s], rejecting", + payloadSize, msg.getUuid())); + MessageReply reply = new MessageReply(); + reply.setError(Platform.operr("metadata payload too large (%d bytes, limit %d) for vm[uuid=%s]", + payloadSize, org.zstack.compute.vm.metadata.VmMetadataBuilder.REJECT_THRESHOLD, msg.getUuid())); + bus.reply(msg, reply); + return; + } + if (payloadSize > org.zstack.compute.vm.metadata.VmMetadataBuilder.WARN_THRESHOLD) { + logger.warn(String.format("metadata payload large: %d bytes for vm[uuid:%s]", + payloadSize, msg.getUuid())); + } + + // 3. 发送到主存储 + Tuple tuple = Q.New(VolumeVO.class).select(VolumeVO_.primaryStorageUuid, VolumeVO_.uuid) + .eq(VolumeVO_.vmInstanceUuid, msg.getUuid()).eq(VolumeVO_.type, VolumeType.Root).findTuple(); + String primaryStorageUuid = tuple.get(0, String.class); + String rootVolumeUuid = tuple.get(1, String.class); + + UpdateVmInstanceMetadataOnPrimaryStorageMsg umsg = new UpdateVmInstanceMetadataOnPrimaryStorageMsg(); + umsg.setMetadata(metadata); + umsg.setPrimaryStorageUuid(primaryStorageUuid); + umsg.setRootVolumeUuid(rootVolumeUuid); + umsg.setStorageStructureChange(msg.isStorageStructureChange()); + bus.makeLocalServiceId(umsg, PrimaryStorageConstant.SERVICE_ID); + bus.send(umsg, new CloudBusCallBack(msg) { + @Override + public void run(MessageReply r) { + UpdateVmInstanceMetadataOnPrimaryStorageReply reply = new UpdateVmInstanceMetadataOnPrimaryStorageReply(); + + if (!r.isSuccess()) { + reply.setError(Platform.operr("failed to update vm[uuid=%s] metadata on primary storage", + msg.getUuid()).withCause(r.getError())); + } + bus.reply(msg, reply); + } + }); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/VmInstanceMetadataFieldProcessor.java b/compute/src/main/java/org/zstack/compute/vm/VmInstanceMetadataFieldProcessor.java new file mode 100644 index 00000000000..e2b74d42f02 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceMetadataFieldProcessor.java @@ -0,0 +1,233 @@ +package org.zstack.compute.vm; + +import org.zstack.header.vm.VmInstanceMetadataDTO; +import org.zstack.header.vm.VmInstanceMetadataRegistrationSpec; +import org.zstack.utils.gson.JSONObjectUtil; + +import java.util.*; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * 虚拟机元数据注册时的字段处理器。 + * + *

根据"注册字段处理矩阵"的规则,对反序列化后的 VO JSON 字段执行: + * 保留 / 替换 / 设 null / 重新生成 / 硬编码 等操作。

+ * + *

处理采用 Map 操作方式(而非反序列化为具体 VO 类), + * 避免字段类型变更导致的兼容性问题。

+ * + * @see VmInstanceMetadataRegistrationSpec + */ +public class VmInstanceMetadataFieldProcessor { + + private VmInstanceMetadataFieldProcessor() { + } + + // ================================================================ + // VmInstanceVO + // ================================================================ + + /** + * VmInstanceVO 中注册时需要设为 null 的字段。 + */ + private static final Set VM_NULL_FIELDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "clusterUuid", + "hostUuid", + "lastHostUuid", + "instanceOfferingUuid", + "defaultL3NetworkUuid", + "managementNetworkUuid" + ))); + + /** + * 处理 VmInstanceVO JSON。 + * + *

处理规则: + *

+ * + * @param vmVoJson 原始 VmInstanceVO JSON + * @param spec 注册参数 + * @return 处理后的 VmInstanceVO JSON + */ + @SuppressWarnings("unchecked") + public static String processVmInstanceVO(String vmVoJson, VmInstanceMetadataRegistrationSpec spec) { + Map voMap = JSONObjectUtil.toObject(vmVoJson, LinkedHashMap.class); + + for (String field : VM_NULL_FIELDS) { + voMap.put(field, null); + } + + voMap.put("zoneUuid", spec.getZoneUuid()); + voMap.put("accountUuid", spec.getAccountUuid()); + voMap.put("state", "Stopped"); + + return JSONObjectUtil.toJsonString(voMap); + } + + // ================================================================ + // VolumeVO + // ================================================================ + + /** + * VolumeVO 中注册时需要设为 null 的字段。 + */ + private static final Set VOLUME_NULL_FIELDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "diskOfferingUuid" + ))); + + /** + * 处理 VolumeVO JSON。 + * + *

处理规则: + *

    + *
  • uuid/vmInstanceUuid/name/size/type/format → 保留
  • + *
  • primaryStorageUuid → 替换为 spec 中的新主存储 UUID
  • + *
  • installPath → 路径标识符替换
  • + *
  • diskOfferingUuid → 设 null
  • + *
  • accountUuid → 替换为 spec 中的调用者
  • + *
+ * + * @param volumeVoJson 原始 VolumeVO JSON + * @param spec 注册参数 + * @return 处理后的 VolumeVO JSON + */ + @SuppressWarnings("unchecked") + public static String processVolumeVO(String volumeVoJson, VmInstanceMetadataRegistrationSpec spec) { + Map voMap = JSONObjectUtil.toObject(volumeVoJson, LinkedHashMap.class); + + for (String field : VOLUME_NULL_FIELDS) { + voMap.put(field, null); + } + + voMap.put("primaryStorageUuid", spec.getPrimaryStorageUuid()); + voMap.put("accountUuid", spec.getAccountUuid()); + + replaceInstallPath(voMap, "installPath", spec); + + return JSONObjectUtil.toJsonString(voMap); + } + + // ================================================================ + // VolumeSnapshotVO + // ================================================================ + + /** + * 处理 VolumeSnapshotVO JSON。 + * + *

处理规则: + *

    + *
  • uuid/volumeUuid/parentUuid/treeUuid/latest → 保留
  • + *
  • primaryStorageUuid → 替换为 spec 中的新主存储 UUID
  • + *
  • primaryStorageInstallPath → 路径标识符替换
  • + *
+ * + * @param snapshotVoJson 原始 VolumeSnapshotVO JSON + * @param spec 注册参数 + * @return 处理后的 VolumeSnapshotVO JSON + */ + @SuppressWarnings("unchecked") + public static String processVolumeSnapshotVO(String snapshotVoJson, VmInstanceMetadataRegistrationSpec spec) { + Map voMap = JSONObjectUtil.toObject(snapshotVoJson, LinkedHashMap.class); + + voMap.put("primaryStorageUuid", spec.getPrimaryStorageUuid()); + + replaceInstallPath(voMap, "primaryStorageInstallPath", spec); + + return JSONObjectUtil.toJsonString(voMap); + } + + // ================================================================ + // SystemTagVO / ResourceConfigVO + // ================================================================ + + /** + * 处理 SystemTagVO JSON:为 uuid 生成新值,移除自增 id。 + * + * @param tagJson 原始 SystemTagVO JSON + * @param uuidSupplier UUID 生成器(通常为 Platform::getUuid) + * @return 处理后的 SystemTagVO JSON + */ + @SuppressWarnings("unchecked") + public static String processSystemTagVO(String tagJson, Supplier uuidSupplier) { + Map tagMap = JSONObjectUtil.toObject(tagJson, LinkedHashMap.class); + + tagMap.put("uuid", uuidSupplier.get()); + tagMap.remove("id"); + + return JSONObjectUtil.toJsonString(tagMap); + } + + /** + * 处理 ResourceConfigVO JSON:为 uuid 生成新值,移除自增 id。 + * + * @param configJson 原始 ResourceConfigVO JSON + * @param uuidSupplier UUID 生成器(通常为 Platform::getUuid) + * @return 处理后的 ResourceConfigVO JSON + */ + @SuppressWarnings("unchecked") + public static String processResourceConfigVO(String configJson, Supplier uuidSupplier) { + Map configMap = JSONObjectUtil.toObject(configJson, LinkedHashMap.class); + + configMap.put("uuid", uuidSupplier.get()); + configMap.remove("id"); + + return JSONObjectUtil.toJsonString(configMap); + } + + // ================================================================ + // 跨存储过滤 + // ================================================================ + + /** + * 判断 volume 的 installPath 是否属于指定主存储。 + * + * @param volumeVoJson VolumeVO JSON + * @param pathIdentifier 存储路径标识符(如 vg uuid 或挂载路径前缀) + * @return true 表示属于该主存储 + */ + @SuppressWarnings("unchecked") + public static boolean belongsToPrimaryStorage(String volumeVoJson, String pathIdentifier) { + Map voMap = JSONObjectUtil.toObject(volumeVoJson, LinkedHashMap.class); + String installPath = (String) voMap.get("installPath"); + return installPath != null && installPath.contains(pathIdentifier); + } + + /** + * 过滤出属于指定主存储的 volume UUID 集合。 + * + *

注册时,仅处理属于当前存储的 volume 及其关联快照。 + * 不属于当前存储的 volume 跳过。

+ * + * @param dto 完整元数据 DTO + * @param pathIdentifier 旧存储路径标识符 + * @return 属于该存储的 volume resourceUuid 集合 + */ + public static Set filterVolumesByStorage(VmInstanceMetadataDTO dto, String pathIdentifier) { + if (dto.volumes == null) { + return Collections.emptySet(); + } + return dto.volumes.stream() + .filter(rm -> belongsToPrimaryStorage(rm.vo, pathIdentifier)) + .map(rm -> rm.resourceUuid) + .collect(Collectors.toSet()); + } + + // ================================================================ + // 内部工具 + // ================================================================ + + private static void replaceInstallPath(Map voMap, String fieldName, + VmInstanceMetadataRegistrationSpec spec) { + String path = (String) voMap.get(fieldName); + if (path != null && spec.getOldPathIdentifier() != null && spec.getNewPathIdentifier() != null) { + voMap.put(fieldName, path.replace(spec.getOldPathIdentifier(), spec.getNewPathIdentifier())); + } + } +} \ No newline at end of file diff --git a/compute/src/main/java/org/zstack/compute/vm/VmInstanceUtils.java b/compute/src/main/java/org/zstack/compute/vm/VmInstanceUtils.java index 33afa043278..1f916560f58 100644 --- a/compute/src/main/java/org/zstack/compute/vm/VmInstanceUtils.java +++ b/compute/src/main/java/org/zstack/compute/vm/VmInstanceUtils.java @@ -2,22 +2,29 @@ import org.apache.commons.collections.CollectionUtils; import org.zstack.core.Platform; +import org.zstack.core.db.Q; import org.zstack.header.configuration.InstanceOfferingInventory; import org.zstack.header.errorcode.OperationFailureException; -import org.zstack.header.vm.APIChangeInstanceOfferingMsg; -import org.zstack.header.vm.APICreateVmInstanceMsg; -import org.zstack.header.vm.CreateVmInstanceMsg; -import org.zstack.header.vm.DiskAO; -import org.zstack.header.vm.UpdateVmInstanceMsg; -import org.zstack.header.vm.UpdateVmInstanceSpec; -import org.zstack.header.vm.VmInstanceVO; +import org.zstack.header.storage.snapshot.VolumeSnapshotInventory; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO_; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefVO; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefVO_; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO_; +import org.zstack.header.tag.SystemTagVO; +import org.zstack.header.tag.SystemTagVO_; +import org.zstack.header.vm.*; +import org.zstack.header.volume.VolumeVO; +import org.zstack.header.volume.VolumeVO_; +import org.zstack.resourceconfig.ResourceConfigVO; +import org.zstack.resourceconfig.ResourceConfigVO_; import org.zstack.tag.SystemTagUtils; +import org.zstack.utils.function.ForEachFunction; +import org.zstack.utils.gson.JSONObjectUtil; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; +import java.util.stream.Collectors; import static java.util.Objects.requireNonNull; import static org.zstack.compute.vm.VmSystemTags.PRIMARY_STORAGE_UUID_FOR_DATA_VOLUME; diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/DefaultVmUuidFromApiResolver.java b/compute/src/main/java/org/zstack/compute/vm/metadata/DefaultVmUuidFromApiResolver.java new file mode 100644 index 00000000000..75e3122184a --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/DefaultVmUuidFromApiResolver.java @@ -0,0 +1,27 @@ +package org.zstack.compute.vm.metadata; + +import org.zstack.header.message.APIMessage; +import org.zstack.header.vm.VmInstanceMessage; +import org.zstack.header.vm.VmUuidFromApiResolver; + +import java.util.Collections; +import java.util.List; + +/** + * 默认 VM UUID 解析器:从实现 {@link VmInstanceMessage} 接口的 API 消息中直接获取 vmInstanceUuid。 + * + *

覆盖绝大多数 VM 直接 API(如 APIUpdateVmInstanceMsg、APIStartVmInstanceMsg 等)。

+ */ +public class DefaultVmUuidFromApiResolver implements VmUuidFromApiResolver { + + @Override + public boolean supports(APIMessage msg) { + return msg instanceof VmInstanceMessage; + } + + @Override + public List resolveVmUuids(APIMessage msg) { + String vmUuid = ((VmInstanceMessage) msg).getVmInstanceUuid(); + return vmUuid != null ? Collections.singletonList(vmUuid) : Collections.emptyList(); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataCascadeExtension.java b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataCascadeExtension.java new file mode 100644 index 00000000000..29c3c82054a --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataCascadeExtension.java @@ -0,0 +1,127 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.compute.vm.VmGlobalConfig; +import org.zstack.core.cascade.AbstractAsyncCascadeExtension; +import org.zstack.core.cascade.CascadeAction; +import org.zstack.core.cascade.CascadeConstant; +import org.zstack.core.db.DatabaseFacade; +import org.zstack.header.core.Completion; +import org.zstack.header.volume.VolumeDeletionStruct; +import org.zstack.header.volume.VolumeVO; +import org.zstack.header.vm.VmInstanceVO; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 监听 Volume 级联删除事件,为受影响的 VM 触发元数据标脏。 + * + *

设计背景

+ *

{@code @MetadataImpact} 注解仅标注在 {@code APIMessage} 子类上, + * 通过 {@link VmMetadataUpdateInterceptor} 自动触发标脏。 + * 但系统中存在不经过 API 拦截器的级联删除操作也会修改 VM 存储拓扑, + * 例如:删除 PrimaryStorage → 级联删除 Volume → VM 失去数据卷。 + * 本扩展在级联清理阶段({@code DELETION_CLEANUP_CODE})捕获这些事件, + * 为受影响的 VM 调用 markDirty。

+ * + *

Cascade 图位置

+ *
+ *   ... → PrimaryStorageVO → VolumeVO → VmInstanceMetadata (本扩展)
+ *                                     → VolumeSnapshotVO → ...
+ * 
+ * + *

两道防线

+ *
    + *
  1. 本扩展 + {@code @MetadataImpact} 拦截器覆盖大部分场景
  2. + *
  3. 健康巡检兜底:周期全量比对 DB vs 存储元数据
  4. + *
+ */ +public class MetadataCascadeExtension extends AbstractAsyncCascadeExtension { + private static final CLogger logger = Utils.getLogger(MetadataCascadeExtension.class); + + private static final String NAME = "VmInstanceMetadata"; + + @Autowired + private VmMetadataDirtyMarker dirtyMarker; + + @Autowired + private DatabaseFacade dbf; + + @Override + public void asyncCascade(CascadeAction action, Completion completion) { + if (!action.isActionCode(CascadeConstant.DELETION_CLEANUP_CODE)) { + completion.success(); + return; + } + + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + completion.success(); + return; + } + + List vmUuids = extractAffectedVmUuids(action); + if (vmUuids.isEmpty()) { + completion.success(); + return; + } + + for (String vmUuid : vmUuids) { + // 检查 VM 是否仍然存在(级联删除 VM 时不需要更新元数据) + if (dbf.isExist(vmUuid, VmInstanceVO.class)) { + logger.debug(String.format("[MetadataCascade] volume cascade cleanup affected " + + "vm[uuid:%s], marking dirty for metadata update", vmUuid)); + // 级联删除 Volume 属于存储结构变更(STORAGE → OP type 2) + dirtyMarker.markDirty(vmUuid, true); + } + } + + completion.success(); + } + + /** + * 从 CascadeAction 上下文中提取受影响的 VM UUID 列表。 + * + *

当前支持的 parentIssuer:

+ *
    + *
  • {@code VolumeVO} → 从 {@link VolumeDeletionStruct} 中获取 vmInstanceUuid
  • + *
+ */ + private List extractAffectedVmUuids(CascadeAction action) { + if (VolumeVO.class.getSimpleName().equals(action.getParentIssuer())) { + List structs = action.getParentIssuerContext(); + if (structs == null || structs.isEmpty()) { + return Collections.emptyList(); + } + + return structs.stream() + .map(s -> s.getInventory().getVmInstanceUuid()) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + } + + return Collections.emptyList(); + } + + @Override + public List getEdgeNames() { + return Arrays.asList(VolumeVO.class.getSimpleName()); + } + + @Override + public String getCascadeResourceName() { + return NAME; + } + + @Override + public CascadeAction createActionForChildResource(CascadeAction action) { + // 叶子节点,不向下传播级联 + return null; + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataOrphanDetector.java b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataOrphanDetector.java new file mode 100644 index 00000000000..2f57f0c9f83 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataOrphanDetector.java @@ -0,0 +1,287 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.compute.vm.VmGlobalConfig; +import org.zstack.core.db.Q; +import org.zstack.core.thread.PeriodicTask; +import org.zstack.core.thread.ThreadFacade; +import org.zstack.header.Component; +import org.zstack.header.managementnode.ManagementNodeReadyExtensionPoint; +import org.zstack.header.storage.primary.PrimaryStorageState; +import org.zstack.header.storage.primary.PrimaryStorageVO; +import org.zstack.header.storage.primary.PrimaryStorageVO_; +import org.zstack.header.vm.VmInstanceVO; +import org.zstack.header.vm.VmInstanceVO_; +import org.zstack.header.volume.VolumeType; +import org.zstack.header.volume.VolumeVO; +import org.zstack.header.volume.VolumeVO_; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 孤儿元数据检测器:周期性扫描各 PS 上残留的元数据条目,检测并报告孤儿。 + * + *

设计背景(Part 01c §1.3, Part 02b C-02B-14)

+ *

VM 删除时元数据同步清理可能因 IO 错误失败(3 次重试后放弃), + * 或 VM 创建失败导致残留。本检测器作为安全网,周期性地扫描每个 + * 支持元数据的 PS,比对存储侧 vmUuid 列表与 DB 中实际存在的 VM, + * 发现孤儿后仅记录日志告警,不执行自动删除

+ * + *

孤儿判定条件

+ *
    + *
  • 存储侧有元数据但 DB 中 VM 不存在(已彻底 Expunge)
  • + *
  • 存储侧有元数据但该 VM 的 Root Volume 不在此 PS 上(迁移残留)
  • + *
+ * + *

约束

+ *
    + *
  • C-02B-14: 仅报告不自动删除,避免与进行中的存储迁移竞态导致误删
  • + *
  • 仅扫描 {@code PrimaryStorageState.Enabled} 的 PS
  • + *
  • 依赖 {@code MetadataStorageHandler.scanMetadataVmUuids()} — 当前为骨架实现
  • + *
+ * + *

TODO

+ *

{@code MetadataStorageHandler} 接口及其实现(SblkMetadataStorageHandler, + * LocalNfsMetadataStorageHandler)尚未创建。本类在 scanMetadataVmUuids 可用后 + * 需取消 TODO 标记并完成 Agent 调用接入。

+ */ +public class MetadataOrphanDetector implements Component, ManagementNodeReadyExtensionPoint { + private static final CLogger logger = Utils.getLogger(MetadataOrphanDetector.class); + + // TODO: 待 MetadataStorageHandler 接口创建后注入 + // @Autowired + // private List metadataStorageHandlers; + + @Autowired + private ThreadFacade thdf; + + private Future taskFuture; + + // ===================================================================== + // 支持元数据的 PS 类型(与 MetadataStorageHandler.isMetadataSupported 对齐) + // ===================================================================== + + /** + * 当前支持元数据的存储类型。 + * 待 MetadataStorageHandler 接口就绪后,应通过 handler.isMetadataSupported() 动态判断。 + */ + private static final List SUPPORTED_PS_TYPES = List.of( + "SharedBlock", + "LocalStorage", + "NFS" + ); + + // ===================================================================== + // Component 生命周期 + // ===================================================================== + + @Override + public boolean start() { + return true; + } + + @Override + public boolean stop() { + stopTask(); + return true; + } + + // ===================================================================== + // ManagementNodeReadyExtensionPoint + // ===================================================================== + + @Override + public void managementNodeReady() { + startTask(); + } + + // ===================================================================== + // 任务管理 + // ===================================================================== + + private synchronized void startTask() { + if (taskFuture != null) { + taskFuture.cancel(false); + } + taskFuture = thdf.submitPeriodicTask(new PeriodicTask() { + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.SECONDS; + } + + @Override + public long getInterval() { + return VmGlobalConfig.VM_METADATA_ORPHAN_CHECK_INTERVAL.value(Long.class); + } + + @Override + public String getName() { + return "vm-metadata-orphan-detector"; + } + + @Override + public void run() { + detectOrphans(); + } + }); + logger.info("[MetadataOrphanDetector] task started (interval={}s)", + VmGlobalConfig.VM_METADATA_ORPHAN_CHECK_INTERVAL.value(Long.class)); + } + + private synchronized void stopTask() { + if (taskFuture != null) { + taskFuture.cancel(false); + taskFuture = null; + logger.info("[MetadataOrphanDetector] task stopped"); + } + } + + // ===================================================================== + // 核心检测逻辑 + // ===================================================================== + + /** + * 扫描所有支持元数据的已启用 PS,对比存储侧 vmUuid 列表与 DB 状态, + * 检测并报告孤儿元数据。 + * + *

C-02B-14: 仅报告(WARN 日志),不执行 deleteMetadata。

+ */ + private void detectOrphans() { + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return; + } + + // 查询所有已启用且支持元数据的 PS + List psList = Q.New(PrimaryStorageVO.class) + .eq(PrimaryStorageVO_.state, PrimaryStorageState.Enabled) + .in(PrimaryStorageVO_.type, SUPPORTED_PS_TYPES) + .list(); + + if (psList.isEmpty()) { + return; + } + + int totalOrphans = 0; + + for (PrimaryStorageVO ps : psList) { + try { + int orphanCount = scanPsForOrphans(ps); + totalOrphans += orphanCount; + } catch (Exception e) { + logger.warn("[MetadataOrphanDetector] failed to scan PS [{}] (type={}): {}", + ps.getUuid(), ps.getType(), e.getMessage()); + } + } + + if (totalOrphans > 0) { + logger.warn("[MetadataOrphanDetector] scan complete: {} orphan(s) detected across {} PS(es). " + + "Use APICleanupVmInstanceMetadataMsg to clean up manually.", totalOrphans, psList.size()); + } + } + + /** + * 扫描单个 PS 上的元数据条目,识别孤儿。 + * + *

TODO: 当前为骨架实现。待 MetadataStorageHandler.scanMetadataVmUuids() 接口 + * 就绪后,替换下方 TODO 块为实际 agent 调用。

+ * + * @param ps 目标 PrimaryStorageVO + * @return 检测到的孤儿数量 + */ + private int scanPsForOrphans(PrimaryStorageVO ps) { + String psUuid = ps.getUuid(); + String psType = ps.getType(); + + // =================================================================== + // TODO: 替换为 MetadataStorageHandler.scanMetadataVmUuids(psUuid) 调用 + // + // 预期调用模式: + // MetadataStorageHandler handler = findHandler(psType); + // handler.scanMetadataVmUuids(psUuid, new ReturnValueCompletion>(null) { + // @Override + // public void success(List entries) { + // int orphans = checkOrphanEntries(psUuid, entries); + // // ... + // } + // @Override + // public void fail(ErrorCode errorCode) { + // logger.warn("scan failed for PS [{}]: {}", psUuid, errorCode); + // } + // }); + // + // VmMetadataEntry 结构(Part 01c §1.3): + // - vmUuid: String + // - hostUuid: String (nullable, 仅 LocalStorage 场景有值) + // =================================================================== + + logger.debug("[MetadataOrphanDetector] scanning PS [{}] (type={}) — skipped: " + + "MetadataStorageHandler not yet implemented", psUuid, psType); + return 0; + } + + /** + * 检查从 agent 扫描返回的 vmUuid 列表,识别孤儿。 + * + *

孤儿条件:

+ *
    + *
  1. vmUuid 在 VmInstanceVO 中不存在(已彻底 Expunge)
  2. + *
  3. vmUuid 存在但其 Root Volume 的 primaryStorageUuid 不等于当前 PS(迁移残留)
  4. + *
+ * + *

C-02B-14: 仅报告(WARN 日志),不执行自动删除。

+ * + * @param psUuid 当前扫描的 PS UUID + * @param metadataVmUuids agent 扫描返回的 vmUuid 列表 + * @return 检测到的孤儿数量 + */ + int checkOrphanEntries(String psUuid, List metadataVmUuids) { + if (metadataVmUuids == null || metadataVmUuids.isEmpty()) { + return 0; + } + + // 批量查询 DB 中存在的 VM UUIDs + List existingVmUuids = Q.New(VmInstanceVO.class) + .select(VmInstanceVO_.uuid) + .in(VmInstanceVO_.uuid, metadataVmUuids) + .listValues(); + + int orphanCount = 0; + + // 类型 1: VM 不存在(已 Expunge) + List expungedVmUuids = metadataVmUuids.stream() + .filter(uuid -> !existingVmUuids.contains(uuid)) + .collect(Collectors.toList()); + + for (String vmUuid : expungedVmUuids) { + logger.warn("[MetadataOrphanDetector] orphan detected: VM [{}] on PS [{}] — " + + "VM no longer exists in DB (expunged)", vmUuid, psUuid); + orphanCount++; + } + + // 类型 2: VM 存在但 Root Volume 不在此 PS 上(迁移残留) + if (!existingVmUuids.isEmpty()) { + // 查询这些 VM 的 Root Volume 所在 PS + List rootVolumes = Q.New(VolumeVO.class) + .eq(VolumeVO_.type, VolumeType.Root) + .in(VolumeVO_.vmInstanceUuid, existingVmUuids) + .list(); + + for (VolumeVO rootVol : rootVolumes) { + if (rootVol.getPrimaryStorageUuid() != null + && !rootVol.getPrimaryStorageUuid().equals(psUuid)) { + logger.warn("[MetadataOrphanDetector] orphan detected: VM [{}] on PS [{}] — " + + "root volume is on PS [{}] (migration residue)", + rootVol.getVmInstanceUuid(), psUuid, rootVol.getPrimaryStorageUuid()); + orphanCount++; + } + } + } + + return orphanCount; + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataPathDriftDetector.java b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataPathDriftDetector.java new file mode 100644 index 00000000000..629f096b464 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataPathDriftDetector.java @@ -0,0 +1,214 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.compute.vm.VmGlobalConfig; +import org.zstack.core.db.Q; +import org.zstack.core.db.SQL; +import org.zstack.core.db.SimpleQuery; +import org.zstack.core.thread.PeriodicTask; +import org.zstack.core.thread.ThreadFacade; +import org.zstack.header.Component; +import org.zstack.header.managementnode.ManagementNodeReadyExtensionPoint; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO_; +import org.zstack.header.vm.VmMetadataPathFingerprintVO; +import org.zstack.header.volume.VolumeVO; +import org.zstack.header.volume.VolumeVO_; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 路径指纹巡检任务:周期性检测 VM 存储拓扑是否发生漂移。 + * + *

设计背景(Part 02b §8.2)

+ *

当存储拓扑变更绕过了 {@code @MetadataImpact} 拦截器(例如底层存储迁移、 + * 手动数据库修改等),dirty mark 不会被触发,导致元数据与实际拓扑不一致。 + * 本巡检任务作为安全网,定期比对每个 VM 的当前路径快照与上次刷写时记录的 + * 路径指纹,发现漂移则调用 markDirty 触发重新刷写。

+ * + *

巡检策略

+ *
    + *
  • C-02B-3: 禁止 listAll,必须使用 keyset 分页(vmInstanceUuid > lastUuid)
  • + *
  • 零存储 I/O:纯 DB 查询比对,不涉及 agent 调用
  • + *
  • pathSnapshot JSON 格式与 {@link MetadataPathSnapshotBuilder#buildPathJson} 保持一致
  • + *
  • 仅在上次成功刷写过的 VM(有指纹记录)上进行巡检,从未刷写过的 VM 自动跳过
  • + *
+ */ +public class MetadataPathDriftDetector implements Component, ManagementNodeReadyExtensionPoint { + private static final CLogger logger = Utils.getLogger(MetadataPathDriftDetector.class); + + @Autowired + private VmMetadataDirtyMarker dirtyMarker; + + @Autowired + private ThreadFacade thdf; + + private Future taskFuture; + + // ===================================================================== + // Component 生命周期 + // ===================================================================== + + @Override + public boolean start() { + return true; + } + + @Override + public boolean stop() { + stopTask(); + return true; + } + + // ===================================================================== + // ManagementNodeReadyExtensionPoint + // ===================================================================== + + @Override + public void managementNodeReady() { + startTask(); + } + + // ===================================================================== + // 任务管理 + // ===================================================================== + + private synchronized void startTask() { + if (taskFuture != null) { + taskFuture.cancel(false); + } + taskFuture = thdf.submitPeriodicTask(new PeriodicTask() { + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.SECONDS; + } + + @Override + public long getInterval() { + return VmGlobalConfig.VM_METADATA_PATH_CHECK_INTERVAL.value(Long.class); + } + + @Override + public String getName() { + return "vm-metadata-path-drift-detector"; + } + + @Override + public void run() { + detectPathDrift(); + } + }); + logger.info("[MetadataPathDrift] task started (interval={}s)", + VmGlobalConfig.VM_METADATA_PATH_CHECK_INTERVAL.value(Long.class)); + } + + private synchronized void stopTask() { + if (taskFuture != null) { + taskFuture.cancel(false); + taskFuture = null; + logger.info("[MetadataPathDrift] task stopped"); + } + } + + // ===================================================================== + // 核心巡检逻辑 + // ===================================================================== + + /** + * 使用 keyset 分页遍历所有指纹记录,比对当前路径快照。 + * + *

C-02B-3: 禁止 listAll,使用 {@code vmInstanceUuid > lastUuid} 分页。 + * 因 PK 为 vmInstanceUuid(非自增 id),keyset 分页天然适用。

+ */ + private void detectPathDrift() { + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return; + } + + int batchSize = VmGlobalConfig.VM_METADATA_PATH_CHECK_BATCH_SIZE.value(Integer.class); + String lastUuid = ""; + int driftCount = 0; + int totalChecked = 0; + + while (true) { + List batch = SQL.New( + "SELECT fp FROM VmMetadataPathFingerprintVO fp " + + "WHERE fp.vmInstanceUuid > :lastUuid " + + "ORDER BY fp.vmInstanceUuid ASC", + VmMetadataPathFingerprintVO.class) + .param("lastUuid", lastUuid) + .limit(batchSize) + .list(); + + if (batch.isEmpty()) { + break; + } + + for (VmMetadataPathFingerprintVO fp : batch) { + String vmUuid = fp.getVmInstanceUuid(); + String currentSnapshot = buildCurrentPathSnapshot(vmUuid); + + // pathSnapshot 可能为 null(简化实现阶段的历史记录) + String recordedSnapshot = fp.getPathSnapshot(); + if (recordedSnapshot == null) { + // 无历史指纹,跳过(等待下次刷写补充) + continue; + } + + if (!recordedSnapshot.equals(currentSnapshot)) { + logger.warn("[MetadataPathDrift] drift detected for VM [{}], " + + "recorded: {}, current: {}", vmUuid, recordedSnapshot, currentSnapshot); + dirtyMarker.markDirty(vmUuid); + driftCount++; + } + totalChecked++; + } + + lastUuid = batch.get(batch.size() - 1).getVmInstanceUuid(); + } + + if (driftCount > 0) { + logger.info("[MetadataPathDrift] scan complete: checked={}, driftDetected={}", + totalChecked, driftCount); + } + } + + /** + * 构建 VM 的当前路径快照 JSON。 + * + *

与 {@link MetadataPathSnapshotBuilder#buildPathJson} 使用完全相同的逻辑, + * 确保比对结果一致:

+ *
    + *
  • volumes: 按 uuid ASC 排序
  • + *
  • snapshots: 按 uuid ASC 排序,仅包含 volumes 关联的快照
  • + *
  • JSON 字段声明顺序固定(uuid, installPath),Gson 按声明顺序输出
  • + *
+ */ + private String buildCurrentPathSnapshot(String vmUuid) { + List volumes = Q.New(VolumeVO.class) + .eq(VolumeVO_.vmInstanceUuid, vmUuid) + .orderBy(VolumeVO_.uuid, SimpleQuery.Od.ASC) + .list(); + + List snapshots; + if (volumes.isEmpty()) { + snapshots = new ArrayList<>(); + } else { + List volumeUuids = volumes.stream() + .map(VolumeVO::getUuid) + .collect(Collectors.toList()); + snapshots = Q.New(VolumeSnapshotVO.class) + .in(VolumeSnapshotVO_.volumeUuid, volumeUuids) + .orderBy(VolumeSnapshotVO_.uuid, SimpleQuery.Od.ASC) + .list(); + } + + return MetadataPathSnapshotBuilder.buildPathJson(volumes, snapshots); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataPathSnapshotBuilder.java b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataPathSnapshotBuilder.java new file mode 100644 index 00000000000..03569c79d02 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataPathSnapshotBuilder.java @@ -0,0 +1,88 @@ +package org.zstack.compute.vm.metadata; + +import org.zstack.header.storage.snapshot.VolumeSnapshotVO; +import org.zstack.header.volume.VolumeVO; +import org.zstack.utils.gson.JSONObjectUtil; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 构建 VM 存储路径指纹 JSON 的共享工具类。 + * + *

同时被 {@link VmMetadataDirtyMarker#savePathFingerprint} 和 + * {@link MetadataPathDriftDetector#buildCurrentPathSnapshot} 使用, + * 确保写入时记录的指纹与巡检时构建的指纹使用完全相同的逻辑。

+ * + *

JSON 格式

+ *
+ * {
+ *   "volumes": [
+ *     {"uuid": "vol-aaa", "installPath": "/dev/vg/vol-aaa"},
+ *     {"uuid": "vol-bbb", "installPath": "/dev/vg/vol-bbb"}
+ *   ],
+ *   "snapshots": [
+ *     {"uuid": "sp-001", "installPath": "/dev/vg/sp-001"},
+ *     {"uuid": "sp-002", "installPath": "/dev/vg/sp-002"}
+ *   ]
+ * }
+ * 
+ * + *

确定性保证

+ *
    + *
  • 列表层面:按 uuid ASC 排序(调用方负责传入已排序列表)
  • + *
  • 字段层面:Gson 按 Java 字段声明顺序输出(uuid 在前, installPath 在后)
  • + *
+ */ +public class MetadataPathSnapshotBuilder { + + /** + * 构建路径指纹 JSON。 + * + * @param volumes 已按 uuid ASC 排序的 VolumeVO 列表 + * @param snapshots 已按 uuid ASC 排序的 VolumeSnapshotVO 列表 + * @return 确定性 JSON 字符串 + */ + public static String buildPathJson(List volumes, List snapshots) { + List volumeEntries = volumes.stream() + .map(v -> new PathEntry(v.getUuid(), v.getInstallPath())) + .collect(Collectors.toList()); + + List snapshotEntries = snapshots.stream() + .map(s -> new PathEntry(s.getUuid(), s.getPrimaryStorageInstallPath())) + .collect(Collectors.toList()); + + PathSnapshot snapshot = new PathSnapshot(volumeEntries, snapshotEntries); + return JSONObjectUtil.toJsonString(snapshot); + } + + /** + * 路径快照顶层结构。 + * + *

Gson 按字段声明顺序序列化:volumes → snapshots。

+ */ + private static class PathSnapshot { + final List volumes; + final List snapshots; + + PathSnapshot(List volumes, List snapshots) { + this.volumes = volumes; + this.snapshots = snapshots; + } + } + + /** + * 单条路径条目。 + * + *

Gson 按字段声明顺序序列化:uuid → installPath。

+ */ + private static class PathEntry { + final String uuid; + final String installPath; + + PathEntry(String uuid, String installPath) { + this.uuid = uuid; + this.installPath = installPath; + } + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataStaleRecoveryTask.java b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataStaleRecoveryTask.java new file mode 100644 index 00000000000..af4a89ca023 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/MetadataStaleRecoveryTask.java @@ -0,0 +1,186 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.compute.vm.VmGlobalConfig; +import org.zstack.core.db.SQL; +import org.zstack.core.thread.PeriodicTask; +import org.zstack.core.thread.ThreadFacade; +import org.zstack.header.Component; +import org.zstack.header.managementnode.ManagementNodeReadyExtensionPoint; +import org.zstack.header.vm.VmMetadataPathFingerprintVO; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * Stale 恢复任务:为重试耗尽(lastFlushFailed=true)的 VM 重新入队 markDirty。 + * + *

设计背景(Part 02 §4.8)

+ *

当 dirty 行因重试耗尽被删除后,低频 VM(长期无 {@code @MetadataImpact} API)将失去 + * 自愈机会。本任务作为独立低频扫描器,周期性地将这些 VM 重新标脏,给予全新重试机会。

+ * + *

慢速重试闭环

+ *
+ *   lastFlushFailed=true
+ *     → StaleRecoveryTask markDirty(retryCount=0)
+ *       → Poller 5 次重试
+ *         → 若仍失败 → lastFlushFailed=true → 30min 后再来
+ *         → 若成功 → 正常完成
+ * 
+ * + *

熔断机制(Q27)

+ *

当 PS 长期不可达时,staleRecoveryCount 累加。达到上限(默认 10 ≈ 5 小时)后 + * 停止自动恢复,记 WARN 日志提示管理员手动触发。

+ * + *

约束

+ *
    + *
  • C-SR-06: markDirty 使用 retryCount=0(全新起点),不继承历史退避
  • + *
  • C-02B-8: lastFlushFailed 仅在 markDirty 成功时重置为 false
  • + *
  • DP-03: 先验证 markDirty 返回值,仅在成功时清除 lastFlushFailed
  • + *
+ */ +public class MetadataStaleRecoveryTask implements Component, ManagementNodeReadyExtensionPoint { + private static final CLogger logger = Utils.getLogger(MetadataStaleRecoveryTask.class); + + @Autowired + private VmMetadataDirtyMarker dirtyMarker; + + @Autowired + private ThreadFacade thdf; + + private Future taskFuture; + + // ===================================================================== + // Component 生命周期 + // ===================================================================== + + @Override + public boolean start() { + return true; + } + + @Override + public boolean stop() { + stopTask(); + return true; + } + + // ===================================================================== + // ManagementNodeReadyExtensionPoint + // ===================================================================== + + @Override + public void managementNodeReady() { + startTask(); + } + + // ===================================================================== + // 任务管理 + // ===================================================================== + + private synchronized void startTask() { + if (taskFuture != null) { + taskFuture.cancel(false); + } + taskFuture = thdf.submitPeriodicTask(new PeriodicTask() { + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.SECONDS; + } + + @Override + public long getInterval() { + return VmGlobalConfig.VM_METADATA_STALE_RECOVERY_INTERVAL.value(Long.class); + } + + @Override + public String getName() { + return "vm-metadata-stale-recovery"; + } + + @Override + public void run() { + recoverStaleVms(); + } + }); + logger.info("[MetadataStaleRecovery] task started (interval={}s)", + VmGlobalConfig.VM_METADATA_STALE_RECOVERY_INTERVAL.value(Long.class)); + } + + private synchronized void stopTask() { + if (taskFuture != null) { + taskFuture.cancel(false); + taskFuture = null; + logger.info("[MetadataStaleRecovery] task stopped"); + } + } + + // ===================================================================== + // 核心逻辑 + // ===================================================================== + + private void recoverStaleVms() { + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return; + } + + int batchSize = VmGlobalConfig.VM_METADATA_STALE_RECOVERY_BATCH_SIZE.value(Integer.class); + int maxCycles = VmGlobalConfig.VM_METADATA_STALE_RECOVERY_MAX_CYCLES.value(Integer.class); + + // 查找所有 lastFlushFailed=true 的指纹记录 + List staleVms = SQL.New( + "SELECT fp FROM VmMetadataPathFingerprintVO fp WHERE fp.lastFlushFailed = 1", + VmMetadataPathFingerprintVO.class) + .limit(batchSize) + .list(); + + if (staleVms.isEmpty()) { + return; + } + + int requeued = 0; + int circuitBroken = 0; + + for (VmMetadataPathFingerprintVO fp : staleVms) { + String vmUuid = fp.getVmInstanceUuid(); + + // Q27 熔断检查:staleRecoveryCount 达到上限 → 停止自动恢复 + if (fp.getStaleRecoveryCount() >= maxCycles) { + // 置 lastFlushFailed=false 停止后续扫描 + SQL.New("UPDATE VmMetadataPathFingerprintVO " + + "SET lastFlushFailed = 0 WHERE vmInstanceUuid = :vmUuid") + .param("vmUuid", vmUuid) + .execute(); + + logger.warn("VM [{}] metadata stale recovery exceeded {} cycles, entering permanent-stale. " + + "Use APIUpdateVmMetadataMsg to manually trigger.", vmUuid, maxCycles); + circuitBroken++; + continue; + } + + // C-SR-06: markDirty 使用 retryCount=0(全新起点,由 markDirty 内部 INSERT IGNORE 保证) + // DP-03: 先验证 markDirty 返回值 + boolean markSuccess = dirtyMarker.markDirty(vmUuid); + + if (markSuccess) { + // markDirty 成功 → 安全清除 stale 标记 + 递增 staleRecoveryCount + SQL.New("UPDATE VmMetadataPathFingerprintVO " + + "SET lastFlushFailed = 0, staleRecoveryCount = staleRecoveryCount + 1 " + + "WHERE vmInstanceUuid = :vmUuid") + .param("vmUuid", vmUuid) + .execute(); + requeued++; + } else { + // markDirty 失败 → 保留 lastFlushFailed=true,下轮重试 + logger.warn("[MetadataStaleRecovery] markDirty failed for vm={}, " + + "keeping lastFlushFailed=true for next retry cycle", vmUuid); + } + } + + logger.info("[MetadataStaleRecovery] processed {} stale VMs: requeued={}, circuitBroken={}", + staleVms.size(), requeued, circuitBroken); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/NicBasedVmUuidFromApiResolver.java b/compute/src/main/java/org/zstack/compute/vm/metadata/NicBasedVmUuidFromApiResolver.java new file mode 100644 index 00000000000..ff0a8a1017a --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/NicBasedVmUuidFromApiResolver.java @@ -0,0 +1,127 @@ +package org.zstack.compute.vm.metadata; + +import org.zstack.core.db.SQL; +import org.zstack.header.message.APIMessage; +import org.zstack.header.vm.VmUuidFromApiResolver; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; + +/** + * NIC 关联 VM UUID 解析器:从携带 vmNicUuid 的 API 消息中查询 VmNicVO 得到 vmInstanceUuid。 + * + *

覆盖的 API:

+ *
    + *
  • {@code APIChangeVmNicNetworkMsg} — vmNicUuid 字段,实现了 VmInstanceMessage 但 vmInstanceUuid 为 @APINoSee
  • + *
  • {@code APIChangeVmNicStateMsg} — vmNicUuid 字段,同上
  • + *
  • {@code APIDeleteVmNicMsg} — uuid 字段(即 nicUuid),继承 APIDeleteMessage,不实现 VmInstanceMessage
  • + *
+ * + *

解析链

+ *
+ *   vmNicUuid → VmNicVO.vmInstanceUuid
+ * 
+ * + *

设计说明

+ *

由于不存在统一的 VmNicMessage 接口,本解析器通过反射检测消息上的 {@code getVmNicUuid()} 方法获取 nicUuid。 + * 对于 {@code APIDeleteVmNicMsg},其 uuid 字段即为 nicUuid,通过 {@code getUuid()} 获取。

+ * + *

本解析器不处理 BondingMessage 类消息({@code APIAttachNicToBondingMsg}、{@code APIDetachNicFromBondingMsg}), + * 因为 bonding → VM 的解析链涉及 PCI 设备关联,复杂度过高且为边缘场景。 + * 这些消息由 {@link ReflectionBasedVmUuidFromApiResolver} 兜底处理。

+ * + *

解析时机

+ *

在 API 执行前(BeforeDeliveryMessageInterceptor)调用。此时 VmNicVO 仍在数据库中, + * 即使是 delete 场景也能查到关联的 vmInstanceUuid。

+ */ +public class NicBasedVmUuidFromApiResolver implements VmUuidFromApiResolver { + private static final CLogger logger = Utils.getLogger(NicBasedVmUuidFromApiResolver.class); + + @Override + public boolean supports(APIMessage msg) { + // 检测消息是否携带 vmNicUuid(通过反射,因无统一接口) + return getVmNicUuid(msg) != null; + } + + @Override + public List resolveVmUuids(APIMessage msg) { + String nicUuid = getVmNicUuid(msg); + if (nicUuid == null) { + return Collections.emptyList(); + } + + return SQL.New( + "SELECT n.vmInstanceUuid FROM VmNicVO n " + + "WHERE n.uuid = :nicUuid AND n.vmInstanceUuid IS NOT NULL", + String.class + ).param("nicUuid", nicUuid).list(); + } + + /** + * 尝试从消息中提取 vmNicUuid。 + * + *

按优先级尝试:

+ *
    + *
  1. getVmNicUuid() — APIChangeVmNicNetworkMsg, APIChangeVmNicStateMsg 等
  2. + *
  3. getUuid() 且 resourceType 为 VmNicVO — APIDeleteVmNicMsg
  4. + *
+ */ + private String getVmNicUuid(APIMessage msg) { + // 1. 尝试 getVmNicUuid() + try { + Method method = msg.getClass().getMethod("getVmNicUuid"); + Object result = method.invoke(msg); + if (result instanceof String) { + return (String) result; + } + } catch (NoSuchMethodException ignored) { + // 无此方法,继续尝试 + } catch (Exception e) { + logger.trace(String.format("failed to invoke getVmNicUuid() on %s: %s", + msg.getClass().getSimpleName(), e.getMessage())); + } + + // 2. 尝试 getUuid():针对 APIDeleteVmNicMsg(@APIParam(resourceType = VmNicVO.class)) + // 通过检查 APIParam 注解的 resourceType 确认 uuid 确实是 VmNicVO 的主键 + try { + Method method = msg.getClass().getMethod("getUuid"); + // 检查 uuid 字段上的 @APIParam(resourceType = VmNicVO.class) 注解 + java.lang.reflect.Field field = findField(msg.getClass(), "uuid"); + if (field != null) { + org.zstack.header.message.APIParam apiParam = field.getAnnotation( + org.zstack.header.message.APIParam.class); + if (apiParam != null && "VmNicVO".equals(apiParam.resourceType().getSimpleName())) { + Object result = method.invoke(msg); + if (result instanceof String) { + return (String) result; + } + } + } + } catch (NoSuchMethodException ignored) { + // 无此方法 + } catch (Exception e) { + logger.trace(String.format("failed to check uuid field on %s: %s", + msg.getClass().getSimpleName(), e.getMessage())); + } + + return null; + } + + /** + * 在类层次结构中查找声明的字段(包括父类)。 + */ + private java.lang.reflect.Field findField(Class clazz, String fieldName) { + Class current = clazz; + while (current != null && current != Object.class) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + return null; + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/ReflectionBasedVmUuidFromApiResolver.java b/compute/src/main/java/org/zstack/compute/vm/metadata/ReflectionBasedVmUuidFromApiResolver.java new file mode 100644 index 00000000000..edf07e49a1c --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/ReflectionBasedVmUuidFromApiResolver.java @@ -0,0 +1,59 @@ +package org.zstack.compute.vm.metadata; + +import org.zstack.header.message.APIMessage; +import org.zstack.header.vm.VmUuidFromApiResolver; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; + +/** + * 反射兜底解析器:通过反射调用 API 消息的 getVmInstanceUuid() / getResourceUuid() 方法。 + * + *

此解析器作为所有其他 Resolver 的兜底,处理未被显式 Resolver 覆盖但仍然携带 + * vmInstanceUuid 或 resourceUuid 的 API 消息。

+ * + *

注册顺序必须排在所有显式 Resolver 之后(在 XML 中排最后)。

+ */ +public class ReflectionBasedVmUuidFromApiResolver implements VmUuidFromApiResolver { + private static final CLogger logger = Utils.getLogger(ReflectionBasedVmUuidFromApiResolver.class); + + @Override + public boolean supports(APIMessage msg) { + // 兜底:对所有消息返回 true,但 resolveVmUuids 可能返回空 + return true; + } + + @Override + public List resolveVmUuids(APIMessage msg) { + // 优先尝试 getVmInstanceUuid() + String vmUuid = invokeGetter(msg, "getVmInstanceUuid"); + if (vmUuid != null) { + return Collections.singletonList(vmUuid); + } + + // fallback: getResourceUuid() + String resourceUuid = invokeGetter(msg, "getResourceUuid"); + if (resourceUuid != null) { + return Collections.singletonList(resourceUuid); + } + + logger.debug(String.format("cannot extract vmInstanceUuid from %s via reflection", msg.getClass().getName())); + return Collections.emptyList(); + } + + private String invokeGetter(Object obj, String methodName) { + try { + Method method = obj.getClass().getMethod(methodName); + return (String) method.invoke(obj); + } catch (NoSuchMethodException e) { + return null; + } catch (Exception e) { + logger.warn(String.format("failed to invoke %s on %s: %s", + methodName, obj.getClass().getName(), e.getMessage())); + return null; + } + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/ResourceBasedVmUuidFromApiResolver.java b/compute/src/main/java/org/zstack/compute/vm/metadata/ResourceBasedVmUuidFromApiResolver.java new file mode 100644 index 00000000000..b6da68a0172 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/ResourceBasedVmUuidFromApiResolver.java @@ -0,0 +1,158 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.core.db.DatabaseFacade; +import org.zstack.core.db.SQL; +import org.zstack.header.message.APIMessage; +import org.zstack.header.tag.APIAbstractCreateTagMsg; +import org.zstack.header.tag.APIDeleteTagMsg; +import org.zstack.header.tag.SystemTagVO; +import org.zstack.header.vm.VmInstanceVO; +import org.zstack.header.vm.VmUuidFromApiResolver; +import org.zstack.resourceconfig.APIDeleteResourceConfigMsg; +import org.zstack.resourceconfig.APIUpdateResourceConfigMsg; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.util.Collections; +import java.util.List; + +/** + * 资源关联 VM UUID 解析器:从 SystemTag / ResourceConfig 类 API 消息中解析出关联的 vmInstanceUuid。 + * + *

通过 resourceType + resourceUuid 判断资源所属 VM:

+ *
    + *
  • resourceType=VmInstanceVO → 直接返回 resourceUuid
  • + *
  • resourceType=VolumeVO → 查询 VolumeVO.vmInstanceUuid
  • + *
  • resourceType=VmNicVO → 查询 VmNicVO.vmInstanceUuid
  • + *
  • resourceType=VolumeSnapshotVO → VolumeSnapshotVO.volumeUuid → VolumeVO.vmInstanceUuid
  • + *
  • 其他类型 → 不影响 VM 元数据,返回空
  • + *
+ * + *

注意

+ *

APIDeleteTagMsg 需要先查询 Tag 获取 resourceType/resourceUuid, + * 因此必须在 API 执行前(beforeDeliveryMessage)调用。

+ */ +public class ResourceBasedVmUuidFromApiResolver implements VmUuidFromApiResolver { + private static final CLogger logger = Utils.getLogger(ResourceBasedVmUuidFromApiResolver.class); + + @Autowired + private DatabaseFacade dbf; + + @Override + public boolean supports(APIMessage msg) { + return msg instanceof APIAbstractCreateTagMsg + || msg instanceof APIDeleteTagMsg + || msg instanceof APIUpdateResourceConfigMsg + || msg instanceof APIDeleteResourceConfigMsg; + } + + @Override + public List resolveVmUuids(APIMessage msg) { + String resourceType = null; + String resourceUuid = null; + + if (msg instanceof APIAbstractCreateTagMsg) { + resourceType = ((APIAbstractCreateTagMsg) msg).getResourceType(); + resourceUuid = ((APIAbstractCreateTagMsg) msg).getResourceUuid(); + } else if (msg instanceof APIDeleteTagMsg) { + // 查询 Tag 获取 resourceType 和 resourceUuid + SystemTagVO tag = dbf.findByUuid(((APIDeleteTagMsg) msg).getUuid(), SystemTagVO.class); + if (tag != null) { + resourceType = tag.getResourceType(); + resourceUuid = tag.getResourceUuid(); + } + } else if (msg instanceof APIUpdateResourceConfigMsg) { + // ResourceConfig API: resourceType 为 ResourceVO(通用基类),需通过 resourceUuid 逐一探测 + return resolveByResourceUuid(((APIUpdateResourceConfigMsg) msg).getResourceUuid()); + } else if (msg instanceof APIDeleteResourceConfigMsg) { + return resolveByResourceUuid(((APIDeleteResourceConfigMsg) msg).getResourceUuid()); + } + + if (resourceType == null || resourceUuid == null) { + return Collections.emptyList(); + } + + return resolveByResourceType(resourceType, resourceUuid); + } + + private List resolveByResourceType(String resourceType, String resourceUuid) { + // 直接关联 VM + if ("VmInstanceVO".equals(resourceType)) { + return Collections.singletonList(resourceUuid); + } + + // Volume → VM + if ("VolumeVO".equals(resourceType)) { + return SQL.New( + "SELECT v.vmInstanceUuid FROM VolumeVO v " + + "WHERE v.uuid = :uuid AND v.vmInstanceUuid IS NOT NULL", + String.class + ).param("uuid", resourceUuid).list(); + } + + // VmNic → VM + if ("VmNicVO".equals(resourceType)) { + return SQL.New( + "SELECT n.vmInstanceUuid FROM VmNicVO n " + + "WHERE n.uuid = :uuid AND n.vmInstanceUuid IS NOT NULL", + String.class + ).param("uuid", resourceUuid).list(); + } + + // VolumeSnapshot → Volume → VM + if ("VolumeSnapshotVO".equals(resourceType)) { + return SQL.New( + "SELECT v.vmInstanceUuid FROM VolumeVO v " + + "WHERE v.uuid = (SELECT s.volumeUuid FROM VolumeSnapshotVO s WHERE s.uuid = :uuid) " + + "AND v.vmInstanceUuid IS NOT NULL", + String.class + ).param("uuid", resourceUuid).list(); + } + + // 其他资源类型不影响 VM 元数据 + logger.trace(String.format("resourceType[%s] does not map to VM metadata, skipping", resourceType)); + return Collections.emptyList(); + } + + /** + * ResourceConfig API 的 resourceUuid 类型为 ResourceVO(通用基类),无法从消息中获取具体资源类型。 + * 按优先级逐一探测:VmInstanceVO → VolumeVO → VmNicVO → 空(非 VM 关联资源)。 + * 每步查询命中主键索引,开销 < 1ms。 + */ + private List resolveByResourceUuid(String resourceUuid) { + if (resourceUuid == null) { + return Collections.emptyList(); + } + + // 1. 直接关联 VM + VmInstanceVO vm = dbf.findByUuid(resourceUuid, VmInstanceVO.class); + if (vm != null) { + return Collections.singletonList(resourceUuid); + } + + // 2. Volume → VM + List vmUuids = SQL.New( + "SELECT v.vmInstanceUuid FROM VolumeVO v " + + "WHERE v.uuid = :uuid AND v.vmInstanceUuid IS NOT NULL", + String.class + ).param("uuid", resourceUuid).list(); + if (!vmUuids.isEmpty()) { + return vmUuids; + } + + // 3. VmNic → VM + vmUuids = SQL.New( + "SELECT n.vmInstanceUuid FROM VmNicVO n " + + "WHERE n.uuid = :uuid AND n.vmInstanceUuid IS NOT NULL", + String.class + ).param("uuid", resourceUuid).list(); + if (!vmUuids.isEmpty()) { + return vmUuids; + } + + // 非 VM 关联资源(如 Host、Cluster 等),不触发元数据操作 + logger.trace(String.format("resourceUuid[%s] does not map to any VM, skipping", resourceUuid)); + return Collections.emptyList(); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/SnapshotBasedVmUuidFromApiResolver.java b/compute/src/main/java/org/zstack/compute/vm/metadata/SnapshotBasedVmUuidFromApiResolver.java new file mode 100644 index 00000000000..fba6b337862 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/SnapshotBasedVmUuidFromApiResolver.java @@ -0,0 +1,54 @@ +package org.zstack.compute.vm.metadata; + +import org.zstack.core.db.SQL; +import org.zstack.header.message.APIMessage; +import org.zstack.header.storage.snapshot.VolumeSnapshotMessage; +import org.zstack.header.vm.VmUuidFromApiResolver; + +import java.util.Collections; +import java.util.List; + +/** + * 快照关联 VM UUID 解析器:从实现 {@link VolumeSnapshotMessage} 接口的 API 消息中获取 snapshotUuid, + * 查询 VolumeSnapshotVO → VolumeVO 得到关联的 vmInstanceUuid。 + * + *

覆盖的 API:

+ *
    + *
  • {@code APIDeleteVolumeSnapshotMsg}(implements DeleteVolumeSnapshotMessage → VolumeSnapshotMessage)
  • + *
  • {@code APIRevertVolumeFromSnapshotMsg}(implements RevertVolumeSnapshotMessage → VolumeSnapshotMessage)
  • + *
  • {@code APIFlattenVolumeMsg} 等直接携带 volumeUuid 的不经过本解析器
  • + *
+ * + *

解析链

+ *
+ *   snapshotUuid → VolumeSnapshotVO.volumeUuid → VolumeVO.vmInstanceUuid
+ * 
+ * + *

解析时机

+ *

在 API 执行前(BeforeDeliveryMessageInterceptor)调用。此时快照和关联的 Volume 仍存在于数据库中, + * 因此无需 pre-capture 机制。对于 delete 场景,快照本身被删除但 Volume 仍在;对于 revert 场景, + * Volume 关联关系不变。

+ */ +public class SnapshotBasedVmUuidFromApiResolver implements VmUuidFromApiResolver { + + @Override + public boolean supports(APIMessage msg) { + return msg instanceof VolumeSnapshotMessage; + } + + @Override + public List resolveVmUuids(APIMessage msg) { + String snapshotUuid = ((VolumeSnapshotMessage) msg).getSnapshotUuid(); + if (snapshotUuid == null) { + return Collections.emptyList(); + } + + // snapshotUuid → VolumeSnapshotVO.volumeUuid → VolumeVO.vmInstanceUuid + return SQL.New( + "SELECT v.vmInstanceUuid FROM VolumeVO v " + + "WHERE v.uuid = (SELECT s.volumeUuid FROM VolumeSnapshotVO s WHERE s.uuid = :snapshotUuid) " + + "AND v.vmInstanceUuid IS NOT NULL", + String.class + ).param("snapshotUuid", snapshotUuid).list(); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/SnapshotGroupBasedVmUuidFromApiResolver.java b/compute/src/main/java/org/zstack/compute/vm/metadata/SnapshotGroupBasedVmUuidFromApiResolver.java new file mode 100644 index 00000000000..3245afcfebe --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/SnapshotGroupBasedVmUuidFromApiResolver.java @@ -0,0 +1,62 @@ +package org.zstack.compute.vm.metadata; + +import org.zstack.core.db.SQL; +import org.zstack.header.message.APIMessage; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupMessage; +import org.zstack.header.vm.VmUuidFromApiResolver; + +import java.util.Collections; +import java.util.List; + +/** + * 快照组关联 VM UUID 解析器:从实现 {@link VolumeSnapshotGroupMessage} 接口的 API 消息中获取 groupUuid, + * 通过 VolumeSnapshotGroupRefVO 查询关联的所有 volumeUuid,再查询 VolumeVO 得到 vmInstanceUuid。 + * + *

覆盖的 API:

+ *
    + *
  • {@code APIDeleteVolumeSnapshotGroupMsg}(STORAGE, updateOnFailure=true)
  • + *
  • {@code APICreateVolumeSnapshotGroupMsg}(STORAGE, updateOnFailure=true)
  • + *
+ * + *

解析链

+ *
+ *   groupUuid → VolumeSnapshotGroupRefVO.volumeUuid(多个) → VolumeVO.vmInstanceUuid(去重)
+ * 
+ * + *

注意

+ *

一个快照组可能关联多个 Volume(根盘 + 数据盘),这些 Volume 可能分属不同 VM(虽然通常同属一个 VM)。 + * 本解析器返回所有关联 VM 的 uuid 列表。

+ */ +public class SnapshotGroupBasedVmUuidFromApiResolver implements VmUuidFromApiResolver { + + @Override + public boolean supports(APIMessage msg) { + return msg instanceof VolumeSnapshotGroupMessage; + } + + @Override + public List resolveVmUuids(APIMessage msg) { + String groupUuid = ((VolumeSnapshotGroupMessage) msg).getGroupUuid(); + if (groupUuid == null) { + return Collections.emptyList(); + } + + // groupUuid → VolumeSnapshotGroupRefVO → volumeUuid 列表 + List volumeUuids = SQL.New( + "SELECT DISTINCT ref.volumeUuid FROM VolumeSnapshotGroupRefVO ref " + + "WHERE ref.volumeSnapshotGroupUuid = :groupUuid", + String.class + ).param("groupUuid", groupUuid).list(); + + if (volumeUuids.isEmpty()) { + return Collections.emptyList(); + } + + // volumeUuid 列表 → VolumeVO.vmInstanceUuid(去重,排除 NULL) + return SQL.New( + "SELECT DISTINCT v.vmInstanceUuid FROM VolumeVO v " + + "WHERE v.uuid IN (:volumeUuids) AND v.vmInstanceUuid IS NOT NULL", + String.class + ).param("volumeUuids", volumeUuids).list(); + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataBuilder.java b/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataBuilder.java new file mode 100644 index 00000000000..85d7527a445 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataBuilder.java @@ -0,0 +1,345 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import org.zstack.core.db.DatabaseFacade; +import org.zstack.core.db.Q; +import org.zstack.header.storage.snapshot.VolumeSnapshotInventory; +import org.zstack.header.storage.snapshot.VolumeSnapshotTree; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO_; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefVO; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefVO_; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO_; +import org.zstack.header.storage.snapshot.reference.VolumeSnapshotReferenceTreeVO; +import org.zstack.header.storage.snapshot.reference.VolumeSnapshotReferenceTreeVO_; +import org.zstack.header.storage.snapshot.reference.VolumeSnapshotReferenceVO; +import org.zstack.header.storage.snapshot.reference.VolumeSnapshotReferenceVO_; +import org.zstack.header.tag.SystemTagVO; +import org.zstack.header.tag.SystemTagVO_; +import org.zstack.header.vm.*; +import org.zstack.header.volume.VolumeType; +import org.zstack.header.volume.VolumeVO; +import org.zstack.header.volume.VolumeVO_; +import org.zstack.resourceconfig.ResourceConfigVO; +import org.zstack.resourceconfig.ResourceConfigVO_; +import org.zstack.utils.Utils; +import org.zstack.utils.gson.JSONObjectUtil; +import org.zstack.utils.logging.CLogger; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 构建虚拟机元数据 payload 的 Spring Component。 + * + *

从 VmInstanceBase 中提取出来,以获得 Spring AOP 代理的 {@code @Transactional} 支持。 + * VmInstanceBase 实例不是 Spring 单例 Bean,其内部方法调用不经过 AOP 代理, + * 因此 {@code @Transactional} 注解在 VmInstanceBase 自身方法上不生效。

+ * + *

{@link #buildVmInstanceMetadata(String)} 执行 6+ 条 SELECT 查询, + * 必须在同一个 REPEATABLE READ 事务快照内完成,以保证读一致性。

+ * + * @see VmInstanceMetadataDTO + */ +public class VmMetadataBuilder { + private static final CLogger logger = Utils.getLogger(VmMetadataBuilder.class); + + /** Payload 大小预警阈值(8 MB) */ + public static final int WARN_THRESHOLD = 8 * 1024 * 1024; + + /** Payload 大小拒绝阈值(30 MB) */ + public static final int REJECT_THRESHOLD = 30 * 1024 * 1024; + + @Autowired + private DatabaseFacade dbf; + + /** + * 从 DB 全量构建指定 VM 的元数据 JSON 字符串。 + * + *

使用 {@code @Transactional(readOnly = true)} 确保所有 SELECT 查询 + * 在同一个 InnoDB REPEATABLE READ 事务快照内执行,保证读一致性。

+ * + * @param vmInstanceUuid 目标虚拟机 UUID + * @return 元数据 JSON 字符串;若 VM 不符合构建条件则返回 null + */ + @Transactional(readOnly = true) + public String buildVmInstanceMetadata(String vmInstanceUuid) { + // ── 查询 VM 本体 ── + VmInstanceVO vm = Q.New(VmInstanceVO.class).eq(VmInstanceVO_.uuid, vmInstanceUuid).find(); + if (vm == null) { + logger.warn(String.format("VM[uuid:%s] not found, skip metadata build", vmInstanceUuid)); + return null; + } + + // ── UserVm 类型检查 ── + if (!VmInstanceConstant.USER_VM_TYPE.equals(vm.getType())) { + logger.debug(String.format("VM[uuid:%s] type is [%s], not UserVm, skip metadata build", + vmInstanceUuid, vm.getType())); + return null; + } + + // ── 云盘(挂载的 + 已卸载但 lastVmInstanceUuid 指向本 VM 的) ── + List allVolumes = new ArrayList<>(); + allVolumes.addAll(Q.New(VolumeVO.class).eq(VolumeVO_.vmInstanceUuid, vmInstanceUuid).list()); + allVolumes.addAll(Q.New(VolumeVO.class).isNull(VolumeVO_.vmInstanceUuid) + .eq(VolumeVO_.lastVmInstanceUuid, vmInstanceUuid).list()); + + // ── 共享盘排除 ── + List volumes = allVolumes.stream() + .filter(v -> !v.isShareable()) + .collect(Collectors.toList()); + + // ── Root Volume 检查 ── + boolean hasRootVolume = volumes.stream() + .anyMatch(v -> VolumeType.Root.toString().equals(v.getType())); + if (!hasRootVolume) { + logger.warn(String.format("VM[uuid:%s] has no root volume, skip metadata build", vmInstanceUuid)); + return null; + } + + // ── 确定性排序:volumes by uuid ── + volumes.sort(Comparator.comparing(VolumeVO::getUuid)); + + VmInstanceMetadataDTO dto = new VmInstanceMetadataDTO(); + + // ── schemaVersion ── + dto.schemaVersion = dbf.getDbVersion(); + + // ── vmCategory(先判缓存再判模板) ── + if (Q.New(TemplatedVmInstanceCacheVO.class) + .eq(TemplatedVmInstanceCacheVO_.cacheVmInstanceUuid, vmInstanceUuid) + .isExists()) { + dto.vmCategory = VmMetadataCategory.TEMPLATE_CACHE; + } else if (Q.New(TemplatedVmInstanceVO.class) + .eq(TemplatedVmInstanceVO_.uuid, vmInstanceUuid) + .isExists()) { + dto.vmCategory = VmMetadataCategory.TEMPLATE; + } else { + dto.vmCategory = VmMetadataCategory.REGULAR; + } + + // ── VM 本体 ── + dto.vm = buildResourceMetadata(vm.getUuid(), vm); + + // ── 云盘(VolumeResourceMetadata,含引用数据) ── + List volumeUuids = volumes.stream().map(VolumeVO::getUuid).collect(Collectors.toList()); + dto.volumes = new ArrayList<>(); + for (VolumeVO vol : volumes) { + dto.volumes.add(buildVolumeResourceMetadata(vol)); + } + + // ── 网卡(排序 by uuid) ── + List nics = Q.New(VmNicVO.class).eq(VmNicVO_.vmInstanceUuid, vmInstanceUuid).list(); + nics.sort(Comparator.comparing(VmNicVO::getUuid)); + dto.nics = new ArrayList<>(); + nics.forEach(n -> dto.nics.add(buildResourceMetadata(n.getUuid(), n))); + + // ── 快照(BFS 拓扑排序,扁平列表) ── + if (!volumeUuids.isEmpty()) { + List allSnapshots = Q.New(VolumeSnapshotVO.class) + .in(VolumeSnapshotVO_.volumeUuid, volumeUuids).list(); + + if (allSnapshots.isEmpty()) { + dto.snapshots = Collections.emptyList(); + } else { + List sorted = topoSortSnapshots(allSnapshots, vmInstanceUuid); + dto.snapshots = sorted.stream() + .map(JSONObjectUtil::toJsonString) + .collect(Collectors.toList()); + } + } else { + dto.snapshots = Collections.emptyList(); + } + + // ── 快照组(排序 by uuid) ── + List groups = Q.New(VolumeSnapshotGroupVO.class) + .eq(VolumeSnapshotGroupVO_.vmInstanceUuid, vmInstanceUuid).list(); + groups.sort(Comparator.comparing(VolumeSnapshotGroupVO::getUuid)); + dto.snapshotGroups = groups.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + + // ── 快照组关联引用(复合键排序:volumeSnapshotGroupUuid + volumeUuid) ── + List groupUuids = groups.stream() + .map(VolumeSnapshotGroupVO::getUuid).collect(Collectors.toList()); + if (!groupUuids.isEmpty()) { + List refs = Q.New(VolumeSnapshotGroupRefVO.class) + .in(VolumeSnapshotGroupRefVO_.volumeSnapshotGroupUuid, groupUuids).list(); + refs.sort(Comparator.comparing(VolumeSnapshotGroupRefVO::getVolumeSnapshotGroupUuid) + .thenComparing(VolumeSnapshotGroupRefVO::getVolumeUuid)); + dto.snapshotGroupRefs = refs.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + } else { + dto.snapshotGroupRefs = Collections.emptyList(); + } + + // ── 序列化 & payload 大小检查 ── + String json = JSONObjectUtil.toJsonString(dto); + int payloadSize = json.getBytes(StandardCharsets.UTF_8).length; + if (payloadSize > REJECT_THRESHOLD) { + logger.error(String.format("VM[uuid:%s] metadata payload size %d bytes exceeds reject threshold %d bytes, " + + "skip metadata build", vmInstanceUuid, payloadSize, REJECT_THRESHOLD)); + return null; + } + if (payloadSize > WARN_THRESHOLD) { + logger.warn(String.format("VM[uuid:%s] metadata payload size %d bytes exceeds warn threshold %d bytes", + vmInstanceUuid, payloadSize, WARN_THRESHOLD)); + } + + return json; + } + + /** + * 对所有快照进行 BFS 拓扑排序。 + * + *

按 volumeUuid 分组,再按 treeUuid 分组(双层 TreeMap 保证 ASC 排序), + * 同一 tree 内使用 {@link VolumeSnapshotTree#fromVOs(List)} + + * {@link VolumeSnapshotTree#levelOrderTraversal()} 进行 BFS 层序遍历。

+ * + * @param allSnapshots 待排序的全部快照 VO + * @param vmUuid VM UUID(仅用于日志) + * @return 拓扑排序后的快照 VO 列表 + */ + private List topoSortSnapshots(List allSnapshots, String vmUuid) { + // 双层 TreeMap 分组:volumeUuid → treeUuid → List + Map>> byVolumeThenTree = + allSnapshots.stream().collect(Collectors.groupingBy( + VolumeSnapshotVO::getVolumeUuid, TreeMap::new, + Collectors.groupingBy(VolumeSnapshotVO::getTreeUuid, + TreeMap::new, Collectors.toList()))); + + List result = new ArrayList<>(); + // 按 volumeUuid ASC → treeUuid ASC 遍历 + for (Map> treesInVolume : byVolumeThenTree.values()) { + for (List treeSnapshots : treesInVolume.values()) { + VolumeSnapshotTree tree = VolumeSnapshotTree.fromVOs(treeSnapshots); + List ordered = tree.levelOrderTraversal(); + for (VolumeSnapshotInventory inv : ordered) { + VolumeSnapshotVO found = findSnapshotByUuid(treeSnapshots, inv.getUuid()); + if (found != null) { + result.add(found); + } + } + } + } + + // 循环引用防护:若 BFS 遗漏了快照,追加到结尾 + if (result.size() < allSnapshots.size()) { + Set resultUuids = result.stream() + .map(VolumeSnapshotVO::getUuid).collect(Collectors.toSet()); + List missing = allSnapshots.stream() + .filter(s -> !resultUuids.contains(s.getUuid())) + .sorted(Comparator.comparing(VolumeSnapshotVO::getUuid)) + .collect(Collectors.toList()); + logger.warn(String.format("Unreachable snapshots detected for VM[uuid:%s]: %d out of %d, " + + "possible circular reference. Appending missing snapshots by uuid ASC.", + vmUuid, missing.size(), allSnapshots.size())); + result.addAll(missing); + } + + return result; + } + + /** + * 从快照列表中按 UUID 查找。 + */ + private VolumeSnapshotVO findSnapshotByUuid(List snapshots, String uuid) { + for (VolumeSnapshotVO s : snapshots) { + if (s.getUuid().equals(uuid)) { + return s; + } + } + return null; + } + + /** + * 构建单个 Volume 的 {@link VolumeResourceMetadata}。 + * + *

包含 VO JSON、SystemTag、ResourceConfig 以及 + * 该 Volume 关联的快照引用(VolumeSnapshotReferenceVO)和引用树(VolumeSnapshotReferenceTreeVO)。

+ * + * @param vol VolumeVO 对象 + * @return 填充完毕的 VolumeResourceMetadata + */ + private VolumeResourceMetadata buildVolumeResourceMetadata(VolumeVO vol) { + VolumeResourceMetadata meta = new VolumeResourceMetadata(); + meta.resourceUuid = vol.getUuid(); + meta.vo = JSONObjectUtil.toJsonString(vol); + + // SystemTag: 排序 by uuid → JSON 数组 → Base64 + List tagVOs = Q.New(SystemTagVO.class) + .eq(SystemTagVO_.resourceUuid, vol.getUuid()).list(); + tagVOs.sort(Comparator.comparing(SystemTagVO::getUuid)); + // TODO: 白名单过滤(CoreMemorySnapshotConfigs 当前不存在,待后续实现) + List tagJsons = tagVOs.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + meta.systemTags = Base64.getEncoder().encodeToString( + JSONObjectUtil.toJsonString(tagJsons).getBytes(StandardCharsets.UTF_8)); + + // ResourceConfig: 排序 by uuid → JSON 数组 → Base64 + List cfgVOs = Q.New(ResourceConfigVO.class) + .eq(ResourceConfigVO_.resourceUuid, vol.getUuid()).list(); + cfgVOs.sort(Comparator.comparing(ResourceConfigVO::getUuid)); + // TODO: 白名单过滤(CoreMemorySnapshotConfigs 当前不存在,待后续实现) + List cfgJsons = cfgVOs.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + meta.resourceConfigs = Base64.getEncoder().encodeToString( + JSONObjectUtil.toJsonString(cfgJsons).getBytes(StandardCharsets.UTF_8)); + + // 快照引用:按 id 排序 + List refs = Q.New(VolumeSnapshotReferenceVO.class) + .eq(VolumeSnapshotReferenceVO_.referenceVolumeUuid, vol.getUuid()).list(); + refs.sort(Comparator.comparing(VolumeSnapshotReferenceVO::getId)); + meta.snapshotReferences = refs.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + + // 快照引用树:按 uuid 排序 + List trees = Q.New(VolumeSnapshotReferenceTreeVO.class) + .eq(VolumeSnapshotReferenceTreeVO_.rootVolumeUuid, vol.getUuid()).list(); + trees.sort(Comparator.comparing(VolumeSnapshotReferenceTreeVO::getUuid)); + meta.snapshotReferenceTrees = trees.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + + return meta; + } + + /** + * 构建单个资源的 {@link VmInstanceMetadataDTO.ResourceMetadata}。 + * + *

VO 全量 JSON 明文存储;SystemTagVO 和 ResourceConfigVO 整体列表序列化为 JSON 数组后 + * 一次性 Base64 编码,以保护可能包含的密码、密钥等敏感信息。

+ * + * @param resourceUuid 资源 UUID + * @param vo 资源 VO 对象(VmInstanceVO / VmNicVO) + * @return 填充完毕的 ResourceMetadata + */ + private VmInstanceMetadataDTO.ResourceMetadata buildResourceMetadata(String resourceUuid, Object vo) { + VmInstanceMetadataDTO.ResourceMetadata meta = new VmInstanceMetadataDTO.ResourceMetadata(); + meta.resourceUuid = resourceUuid; + meta.vo = JSONObjectUtil.toJsonString(vo); + + // SystemTagVO: 排序 by uuid → JSON 数组 → Base64 + List tagVOs = Q.New(SystemTagVO.class) + .eq(SystemTagVO_.resourceUuid, resourceUuid).list(); + tagVOs.sort(Comparator.comparing(SystemTagVO::getUuid)); + // TODO: 白名单过滤(CoreMemorySnapshotConfigs 当前不存在,待后续实现) + List tagJsons = tagVOs.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + meta.systemTags = Base64.getEncoder().encodeToString( + JSONObjectUtil.toJsonString(tagJsons).getBytes(StandardCharsets.UTF_8)); + + // ResourceConfigVO: 排序 by uuid → JSON 数组 → Base64 + List cfgVOs = Q.New(ResourceConfigVO.class) + .eq(ResourceConfigVO_.resourceUuid, resourceUuid).list(); + cfgVOs.sort(Comparator.comparing(ResourceConfigVO::getUuid)); + // TODO: 白名单过滤(CoreMemorySnapshotConfigs 当前不存在,待后续实现) + List cfgJsons = cfgVOs.stream() + .map(JSONObjectUtil::toJsonString).collect(Collectors.toList()); + meta.resourceConfigs = Base64.getEncoder().encodeToString( + JSONObjectUtil.toJsonString(cfgJsons).getBytes(StandardCharsets.UTF_8)); + + return meta; + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataDirtyMarker.java b/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataDirtyMarker.java new file mode 100644 index 00000000000..b08b8c5cc65 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataDirtyMarker.java @@ -0,0 +1,1201 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.compute.vm.VmGlobalConfig; +import org.zstack.core.Platform; +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.db.SQL; +import org.zstack.core.db.SQLBatch; +import org.zstack.core.db.SimpleQuery; +import org.zstack.core.thread.ChainTask; +import org.zstack.core.thread.PeriodicTask; +import org.zstack.core.thread.SyncTaskChain; +import org.zstack.core.thread.ThreadFacade; +import org.zstack.header.Component; +import org.zstack.header.errorcode.ErrorCode; +import org.zstack.header.managementnode.ManagementNodeChangeListener; +import org.zstack.header.managementnode.ManagementNodeInventory; +import org.zstack.header.managementnode.ManagementNodeReadyExtensionPoint; +import org.zstack.header.message.MessageReply; +import org.zstack.header.vm.UpdateVmInstanceMetadataMsg; +import org.zstack.header.vm.VmInstanceConstant; +import org.zstack.header.vm.VmInstanceState; +import org.zstack.header.vm.VmInstanceVO; +import org.zstack.header.vm.VmInstanceVO_; +import org.zstack.header.vm.VmMetadataDirtyVO; +import org.zstack.header.vm.VmMetadataDirtyVO_; +import org.zstack.header.vm.VmMetadataPathFingerprintVO; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO; +import org.zstack.header.storage.snapshot.VolumeSnapshotVO_; +import org.zstack.header.volume.VolumeVO; +import org.zstack.header.volume.VolumeVO_; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * VM 元数据 Dirty Mark + Poller 机制的核心实现。 + * + *

职责

+ *
    + *
  • {@link #markDirty(String)} — 标脏入口,INSERT ON DUPLICATE KEY UPDATE + 立即唤醒
  • + *
  • {@link MetadataDirtyPoller} — 周期轮询安全网,处理退避到期行、MN 宕机释放行等
  • + *
  • {@link #claimAndFlush()} — CAS 认领 + 提交刷写
  • + *
  • {@link #doFlush} — 构建 payload → 发送 UpdateVmInstanceMetadataMsg → 成功/失败处理
  • + *
  • {@link ManagementNodeChangeListener#nodeLeft} — MN 宕机后立即接管
  • + *
+ * + *

串行化保证(四层)

+ *
+ *   Layer 1 — DB CAS 认领:UPDATE WHERE managementNodeUuid IS NULL → 同一行只被一个 MN 处理
+ *   Layer 2 — AtomicInteger 全局限流:globalFlushInFlight(默认上限 10)
+ *   Layer 3 — ChainTask 队列 "update-vm-{vmUuid}-metadata":syncLevel=1, maxPending=1
+ *   Layer 4 — 主存储级队列 "update-metadata-on-ps-{psUuid}"(在 PS handler 内部实现)
+ * 
+ * + * @see VmMetadataDirtyVO + * @see VmMetadataUpdateInterceptor + */ +public class VmMetadataDirtyMarker implements Component, ManagementNodeChangeListener, ManagementNodeReadyExtensionPoint { + private static final CLogger logger = Utils.getLogger(VmMetadataDirtyMarker.class); + + // ===================================================================== + // 常量 + // ===================================================================== + + // 指数退避参数改为 GlobalConfig(C-RB-04),详见 onFlushFailure()。 + + // ===================================================================== + // 注入 + // ===================================================================== + + @Autowired + private CloudBus bus; + + @Autowired + private DatabaseFacade dbf; + + @Autowired + private ThreadFacade thdf; + + // ===================================================================== + // Poller 状态 + // ===================================================================== + + private Future pollerFuture; + private Future zombieCleanupFuture; + + // ===================================================================== + // 全局并发限流(Δ-1:替代原嵌套 ChainTask 外层) + // ===================================================================== + + /** 当前正在 flight 的 flush 任务数。per-MN JVM 本地计数器。 */ + private final AtomicInteger globalFlushInFlight = new AtomicInteger(0); + + // ===================================================================== + // Component 生命周期 + // ===================================================================== + + @Override + public boolean start() { + return true; + } + + @Override + public boolean stop() { + stopPoller(); + stopZombieCleanupTask(); + return true; + } + + // ===================================================================== + // ManagementNodeReadyExtensionPoint:MN 就绪后启动 Poller + // ===================================================================== + + @Override + public void managementNodeReady() { + recoverStalledMigrationPauses(); // C-01C-8: must run before Poller starts + startPoller(); + startZombieCleanupTask(); + + VmGlobalConfig.VM_METADATA_DIRTY_POLL_INTERVAL.installUpdateExtension((oldValue, newValue) -> { + restartPoller(); + }); + + // §9a: 监听 vm.metadata.enabled 开关切换 + VmGlobalConfig.VM_METADATA.installUpdateExtension((oldValue, newValue) -> { + boolean wasEnabled = Boolean.parseBoolean(oldValue.toString()); + boolean nowEnabled = Boolean.parseBoolean(newValue.toString()); + if (!wasEnabled && nowEnabled) { + // false → true:分批全量初始化(§9a.1) + logger.info("[MetadataDirty] vm.metadata.enabled toggled from false to true, starting batch initialization"); + submitBatchInitialization(); + } else if (wasEnabled && !nowEnabled) { + // true → false:清理 PathFingerprint(§9a.2 讨论 Δ-10) + logger.info("[MetadataDirty] vm.metadata.enabled toggled from true to false, cleaning up PathFingerprints"); + cleanupPathFingerprints(); + } + }); + + // §9.1: 升级后全量刷新 — 检查 DB 版本与 lastRefreshVersion 是否一致 + scheduleUpgradeRefreshIfNeeded(); + } + + /** + * 恢复因存储迁移中断而"永久暂停"的脏标记行。 + * + *

存储迁移期间 Poller 会将相关 dirty 行的 nextRetryTime 设为 2099-12-31 23:59:59 + * 以防止 flush 竞争。如果迁移流程崩溃(MN 宕机),这些行会卡在该时间点永远不被处理。

+ * + *

本方法在 MN 重启后、Poller 启动前执行,将所有"远未来"暂停行重置为可处理状态。

+ * + * @see Part 01c §1.6 迁移暂停恢复 + */ + private void recoverStalledMigrationPauses() { + int recovered = SQL.New( + "UPDATE VmMetadataDirtyVO " + + "SET nextRetryTime = NULL, retryCount = 0 " + + "WHERE nextRetryTime = '2099-12-31 23:59:59'") + .execute(); + if (recovered > 0) { + logger.warn(String.format("[MetadataDirty] Recovered %d dirty rows with stalled migration pause (nextRetryTime far in future)", recovered)); + } + } + + // ===================================================================== + // ManagementNodeChangeListener:MN 拓扑变化处理 + // ===================================================================== + + /** Timestamp of the most recent nodeLeft event, used by §9.1 M3 recent-nodeLeft check. */ + private volatile long lastNodeLeftTimestamp = 0; + + @Override + public void nodeLeft(ManagementNodeInventory inv) { + // MN 宕机 → FK SET_NULL 已释放其认领的 dirty 行 + // C-02B-1 §7.2: 延迟 N 秒后再触发 claimAndFlush(),降低 zombie MN 并发写入概率 + long delaySec = VmGlobalConfig.VM_METADATA_NODE_LEFT_DELAY.value(Long.class); + logger.info(String.format("[MetadataDirty] node[%s] left, scheduling claim and flush after %ds delay", + inv.getUuid(), delaySec)); + + // M3 修复:记录 nodeLeft 时间戳,供 §9.1 升级刷新 recent-nodeLeft 检查使用 + lastNodeLeftTimestamp = System.currentTimeMillis(); + + thdf.submit(new org.zstack.core.thread.Task() { + @Override + public String getName() { + return "metadata-dirty-node-left-claim"; + } + + @Override + public Void call() { + try { + TimeUnit.SECONDS.sleep(delaySec); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("[MetadataDirty] nodeLeft delay interrupted"); + return null; + } + claimAndFlush(); + return null; + } + }); + } + + @Override + public void nodeJoin(ManagementNodeInventory inv) { + // 无需特殊处理,新 MN 的 Poller 正常启动即可 + } + + @Override + public void iAmDead(ManagementNodeInventory inv) { + // 本 MN 即将死亡,不做处理 + // FK SET_NULL 会自动释放本 MN 认领的行 + } + + @Override + public void iJoin(ManagementNodeInventory inv) { + // 由 managementNodeReady 启动 Poller + } + + // ===================================================================== + // markDirty — 标脏入口(公开方法) + // ===================================================================== + + /** + * 将指定 VM 标记为"元数据脏",需要重新写入主存储。 + * + *

使用 INSERT IGNORE + UPDATE 两步(C-DM-01: Galera 集群兼容),保证:

+ *
    + *
  • 行不存在 → INSERT IGNORE 新建(dirtyVersion=1)
  • + *
  • 行已存在 → UPDATE dirtyVersion +1(标记"有新变更")
  • + *
  • 竞态 inserted==0 && updated==0 → 重新 INSERT IGNORE(Q19 修复)
  • + *
  • storageStructureChange 使用 OR 升级策略
  • + *
  • 不重置 retryCount / managementNodeUuid / nextRetryTime
  • + *
+ * + *

markDirty 后立即调用 {@link #triggerFlushForVm(String)}, + * 尝试认领并提交刷写,消除最长 N 秒的 Poller 等待延迟。

+ * + * @param vmInstanceUuid 目标虚拟机 UUID + * @param storageStructureChange 是否涉及存储结构变更 + * @return true 如果标脏成功(供 MetadataStaleRecoveryTask DP-03 使用) + */ + public boolean markDirty(String vmInstanceUuid, boolean storageStructureChange) { + // 前置检查:功能开关 + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return false; + } + + // 前置检查:仅处理 KVM 虚拟化 + UserVm 类型的 VM + // 非 KVM(如 Simulator)或非 UserVm(如 ApplianceVm)不产生元数据 + boolean isTargetVm = Q.New(VmInstanceVO.class) + .eq(VmInstanceVO_.uuid, vmInstanceUuid) + .eq(VmInstanceVO_.type, VmInstanceConstant.USER_VM_TYPE) + .eq(VmInstanceVO_.hypervisorType, VmInstanceConstant.KVM_HYPERVISOR_TYPE) + .isExists(); + if (!isTargetVm) { + logger.trace(String.format("[MetadataDirty] vm[uuid:%s] is not KVM UserVm, skipping markDirty", + vmInstanceUuid)); + return false; + } + + try { + // C-DM-01: Galera 集群兼容写法,避免 INSERT ON DUPLICATE KEY 在高并发下死锁 + // Step 1: INSERT IGNORE(新行) + int inserted = SQL.New("INSERT IGNORE INTO VmMetadataDirtyVO " + + "(vmInstanceUuid, dirtyVersion, storageStructureChange) " + + "VALUES (:vmUuid, 1, :ssc)") + .param("vmUuid", vmInstanceUuid) + .param("ssc", storageStructureChange) + .execute(); + + // Step 2: 仅在行已存在时执行 UPDATE(dirtyVersion +1, storageStructureChange OR 升级) + if (inserted == 0) { + int updated = SQL.New("UPDATE VmMetadataDirtyVO " + + "SET dirtyVersion = dirtyVersion + 1, " + + " storageStructureChange = storageStructureChange OR :ssc " + + "WHERE vmInstanceUuid = :vmUuid") + .param("vmUuid", vmInstanceUuid) + .param("ssc", storageStructureChange) + .execute(); + + // Q19 修复:INSERT IGNORE 返回 0(行已存在)但 UPDATE 也返回 0(行被并发删除) + // 竞态窗口:INSERT IGNORE → onFlushSuccess DELETE → UPDATE(行已不存在) + // 此时必须重新 INSERT,否则本次 markDirty 对应的 DB 变更将丢失 + if (updated == 0) { + SQL.New("INSERT IGNORE INTO VmMetadataDirtyVO " + + "(vmInstanceUuid, dirtyVersion, storageStructureChange) " + + "VALUES (:vmUuid, 1, :ssc)") + .param("vmUuid", vmInstanceUuid) + .param("ssc", storageStructureChange) + .execute(); + } + } + + logger.debug(String.format("[MetadataDirty] marked dirty for vm[uuid:%s], storageStructureChange=%s", + vmInstanceUuid, storageStructureChange)); + + // 立即唤醒:尝试认领并提交刷写,不等待 Poller 轮询 + triggerFlushForVm(vmInstanceUuid); + return true; + } catch (Exception e) { + logger.warn(String.format("[MetadataDirty] markDirty failed for vm[uuid:%s]: %s", + vmInstanceUuid, e.getMessage())); + return false; + } + } + + /** + * 标脏入口(便捷重载,默认 storageStructureChange=false,即 CONFIG 级别)。 + * + * @param vmInstanceUuid 目标虚拟机 UUID + * @return true 如果标脏成功 + */ + public boolean markDirty(String vmInstanceUuid) { + return markDirty(vmInstanceUuid, false); + } + + // ===================================================================== + // triggerFlushForVm — 立即唤醒(单 VM) + // ===================================================================== + + /** + * 立即尝试认领并刷写指定 VM 的 dirty 行。 + * 若行已被认领或处于退避期,跳过(Poller 安全网会处理)。 + * + *

Q20 修复:findStaleClaimOwner 可能返回 null(无 stale claim)。 + * SQL 的 OR 分支使用 :staleId 参数,当 staleId=null 时 + * MySQL 会将 {@code managementNodeUuid = NULL} 解析为 FALSE(SQL 三值逻辑), + * 不会误匹配任何行。但为避免依赖此隐式行为,显式处理: + * staleId=null 时仅使用 IS NULL 分支,不包含 stale 接管条件。

+ */ + private void triggerFlushForVm(String vmUuid) { + String myId = Platform.getManagementServerId(); + long staleMinutes = VmGlobalConfig.VM_METADATA_TRIGGER_FLUSH_STALE.value(Long.class); + String staleId = findStaleClaimOwner(vmUuid, Duration.ofMinutes(staleMinutes)); + + String sql; + if (staleId != null) { + sql = "UPDATE VmMetadataDirtyVO " + + "SET managementNodeUuid = :myId, lastClaimTime = CURRENT_TIMESTAMP " + + "WHERE vmInstanceUuid = :vmUuid " + + "AND (managementNodeUuid IS NULL " + + " OR (managementNodeUuid = :staleId AND lastClaimTime < CURRENT_TIMESTAMP - INTERVAL " + staleMinutes + " MINUTE)) " + + "AND (nextRetryTime IS NULL OR nextRetryTime <= CURRENT_TIMESTAMP)"; + } else { + sql = "UPDATE VmMetadataDirtyVO " + + "SET managementNodeUuid = :myId, lastClaimTime = CURRENT_TIMESTAMP " + + "WHERE vmInstanceUuid = :vmUuid " + + "AND managementNodeUuid IS NULL " + + "AND (nextRetryTime IS NULL OR nextRetryTime <= CURRENT_TIMESTAMP)"; + } + + int claimed = SQL.New(sql) + .param("myId", myId) + .param("staleId", staleId) + .param("vmUuid", vmUuid) + .execute(); + + if (claimed == 0) { + logger.debug(String.format("[MetadataDirty] triggerFlushForVm skip claim, vmUuid=%s, " + + "reason=already-claimed-or-backoff", vmUuid)); + return; + } + + VmMetadataDirtyVO dirty = dbf.findByUuid(vmUuid, VmMetadataDirtyVO.class); + // DP-07 说明:dirty == null 是合法场景。CAS UPDATE 成功后、findByUuid 前, + // 若同 MN 上一个 running flush 的 onFlushSuccess() 恰好执行了条件 DELETE, + // 则该行已被删除。此时直接 return 即可——数据已经是最新的。 + if (dirty == null) { + return; + } + + submitFlushTask(dirty); + } + + // ===================================================================== + // Poller — 轮询安全网 + // ===================================================================== + + /** + * 内部 PeriodicTask 实现。 + * + *

Poller 角色定位:markDirty 后的 triggerFlushForVm 已覆盖常规场景。 + * Poller 降级为安全网,负责处理: + *

    + *
  • 退避中的行(nextRetryTime 到期后才能认领)
  • + *
  • MN 宕机后 FK SET_NULL 释放的孤儿行
  • + *
  • triggerFlushForVm 认领失败的行(已被其他 MN Poller 认领)
  • + *
+ */ + private class MetadataDirtyPoller implements PeriodicTask { + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.SECONDS; + } + + @Override + public long getInterval() { + return VmGlobalConfig.VM_METADATA_DIRTY_POLL_INTERVAL.value(Long.class); + } + + @Override + public String getName() { + return "vm-metadata-dirty-poller"; + } + + @Override + public void run() { + claimAndFlush(); + } + } + + private synchronized void startPoller() { + if (pollerFuture != null) { + pollerFuture.cancel(false); + } + pollerFuture = thdf.submitPeriodicTask(new MetadataDirtyPoller()); + logger.info("[MetadataDirty] poller started"); + } + + private synchronized void stopPoller() { + if (pollerFuture != null) { + pollerFuture.cancel(false); + pollerFuture = null; + logger.info("[MetadataDirty] poller stopped"); + } + } + + private void restartPoller() { + logger.info("[MetadataDirty] restarting poller due to config change"); + startPoller(); + } + + // ===================================================================== + // claimAndFlush — 认领 + 提交刷写(Poller 和 nodeLeft 共用) + // ===================================================================== + + /** + * CAS 认领一批 dirty 行并提交刷写。 + */ + private void claimAndFlush() { + // 功能关闭时跳过,避免 Poller 空转(P2-2.2 修复) + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return; + } + + List claimed = claimDirtyRows(); + for (VmMetadataDirtyVO dirty : claimed) { + submitFlushTask(dirty); + } + } + + /** + * CAS 原子认领一批 dirty 行。 + * + *

单条 UPDATE 天然原子,无锁等待,无死锁风险。

+ *

DP-05 修复:僵尸 claim 清理已提取为独立低频任务 {@link #cleanupZombieClaims()}。

+ * + * @return 认领到的 dirty 行列表 + */ + private List claimDirtyRows() { + // Step 1: CAS 原子认领 + // Q17 修复:ORDER BY lastOpDate ASC + vmInstanceUuid ASC(稳定 tiebreaker) + // C-CL-02: lastClaimTime = CURRENT_TIMESTAMP + int claimed = SQL.New("UPDATE VmMetadataDirtyVO " + + "SET managementNodeUuid = :myId, lastClaimTime = CURRENT_TIMESTAMP " + + "WHERE managementNodeUuid IS NULL " + + "AND (nextRetryTime IS NULL OR nextRetryTime <= CURRENT_TIMESTAMP) " + + "ORDER BY lastOpDate ASC, vmInstanceUuid ASC " + + "LIMIT :batchSize") + .param("myId", Platform.getManagementServerId()) + .param("batchSize", VmGlobalConfig.VM_METADATA_DIRTY_BATCH_SIZE.value(Integer.class)) + .execute(); + + if (claimed == 0) { + return Collections.emptyList(); + } + + // Step 2: 查询刚认领到的行(DP-01 修复:增加 lastClaimTime 过滤, + // 仅返回本轮 CAS 认领的行,避免与 triggerFlushForVm 并发认领的行混入) + Timestamp thisCycleCutoff = Timestamp.from(Instant.now().minus(Duration.ofSeconds(5))); + return Q.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.managementNodeUuid, Platform.getManagementServerId()) + .gte(VmMetadataDirtyVO_.lastClaimTime, thisCycleCutoff) + .list(); + } + + // ===================================================================== + // submitFlushTask — AtomicInteger 全局限流 + per-VM 串行去重(Δ-1 重构) + // ===================================================================== + + /** + * 将 dirty 行的刷写任务提交到 ChainTask 队列。 + * + *

Δ-1 重构:原嵌套 ChainTask(外层全局限流 + 内层 per-VM 串行) + * 改为 AtomicInteger 全局限流 + 单层 per-VM ChainTask。原因:

+ *
    + *
  1. 嵌套 ChainTask 的 outerChain.next() 在 exceedMaxPendingCallback 中直接调用 + * 导致 outer slot 提前释放,全局限流语义被破坏
  2. + *
  3. 嵌套结构难以推断 Chain 生命周期
  4. + *
  5. AtomicInteger 语义简单明确:flush 开始 increment、完成 decrement、超限 skip
  6. + *
+ */ + private void submitFlushTask(VmMetadataDirtyVO dirty) { + final String vmUuid = dirty.getVmInstanceUuid(); + + // 全局并发检查 + int maxConcurrent = VmGlobalConfig.VM_METADATA_GLOBAL_MAX_CONCURRENT.value(Integer.class); + if (globalFlushInFlight.get() >= maxConcurrent) { + // 全局并发已满,释放 claim,Poller 下轮重试 + releaseClaim(vmUuid); + return; + } + globalFlushInFlight.incrementAndGet(); + + // 单层 per-VM 串行 + 去重 + thdf.chainSubmit(new ChainTask(null) { + @Override + public String getSyncSignature() { + return String.format("update-vm-%s-metadata", vmUuid); + } + + @Override + public int getSyncLevel() { + return 1; + } + + @Override + protected int getMaxPendingTasks() { + return 1; + } + + @Override + protected String getDeduplicateString() { + return getSyncSignature(); + } + + @Override + protected void exceedMaxPendingCallback() { + // Δ-1 改进:在单层结构中,exceed 时直接 decrement 并释放 claim + globalFlushInFlight.decrementAndGet(); + releaseClaim(vmUuid); + } + + @Override + public void run(final SyncTaskChain chain) { + doFlush(dirty, () -> { + globalFlushInFlight.decrementAndGet(); + chain.next(); + }); + } + + @Override + public String getName() { + return String.format("update-vm-%s-metadata-task", vmUuid); + } + }); + } + + // ===================================================================== + // doFlush — 核心刷写逻辑 + // ===================================================================== + + /** + * 执行元数据刷写。 + * + *

流程:

+ *
    + *
  1. P2 修复:重新从 DB 读取 dirty 行(获取最新 storageStructureChange/dirtyVersion)
  2. + *
  3. 前置检查(VM 是否存在、C-FL-08 Destroyed 状态过滤)
  4. + *
  5. 发送 UpdateVmInstanceMetadataMsg(由 VmInstanceBase 构建 payload 并写入主存储)
  6. + *
  7. 成功 → onFlushSuccess(条件删除 dirty 行)
  8. + *
  9. 失败 → onFlushFailure(指数退避或放弃)
  10. + *
+ */ + private void doFlush(VmMetadataDirtyVO dirty, Runnable chainNext) { + String vmUuid = dirty.getVmInstanceUuid(); + + // P2 修复:重新从 DB 读取 dirty 行,获取最新的 storageStructureChange 和 dirtyVersion。 + // 原因:submitFlushTask 传入的 dirty 对象是 CAS 认领时的缓存快照,排队等待期间 + // 可能有新的 markDirty(storageStructureChange=true) 通过 OR 升级了该字段。 + VmMetadataDirtyVO latestDirty = dbf.findByUuid(vmUuid, VmMetadataDirtyVO.class); + if (latestDirty == null) { + // VM 已删除(FK CASCADE)或 onFlushSuccess 已删除该行 + chainNext.run(); + return; + } + + // 0. 记录刷写开始时的 dirtyVersion 快照(使用最新值) + long snapshotVersion = latestDirty.getDirtyVersion(); + + // C-02B-2 §7.6: Fence Check — 防止 zombie MN(GC pause 恢复后)并发写入 + // 验证 dirty 行仍被本 MN 认领。若认领已被 nodeLeft → FK SET_NULL → 其他 MN 接管, + // 则本 MN 的旧 flush 任务必须立即中止。 + if (!Platform.getManagementServerId().equals(latestDirty.getManagementNodeUuid())) { + logger.warn(String.format("[MetadataDirty] Lost claim on vm[uuid:%s], " + + "expected mnUuid=%s but got %s, abort flush write", + vmUuid, Platform.getManagementServerId(), latestDirty.getManagementNodeUuid())); + chainNext.run(); + return; + } + + // 1. 前置检查:VM 是否存在 + if (!dbf.isExist(vmUuid, VmInstanceVO.class)) { + // VM 已删除,FK CASCADE 应已删除 dirty 行,兜底删除 + SQL.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .delete(); + chainNext.run(); + return; + } + + // 1b. C-FL-08:过滤 Destroyed 状态的 VM + // VM 正在销毁过程中(state=Destroyed),EO 尚未物理删除,FK CASCADE 未触发。 + // 此时刷写元数据无意义——销毁完成后 EO 删除时 dirty 行会被级联清理。 + VmInstanceState vmState = Q.New(VmInstanceVO.class) + .eq(VmInstanceVO_.uuid, vmUuid) + .select(VmInstanceVO_.state) + .findValue(); + if (vmState == VmInstanceState.Destroyed) { + SQL.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .delete(); + chainNext.run(); + return; + } + + // 2. 发送到 VmInstanceBase 处理(由 VmInstanceBase 内部构建 payload 并写入主存储) + // C-TM-03:超时 ≥ 5 分钟 + UpdateVmInstanceMetadataMsg msg = new UpdateVmInstanceMetadataMsg(); + msg.setUuid(vmUuid); + msg.setStorageStructureChange(latestDirty.isStorageStructureChange()); + msg.setTimeout(TimeUnit.MINUTES.toMillis(5)); + bus.makeLocalServiceId(msg, VmInstanceConstant.SERVICE_ID); + + bus.send(msg, new CloudBusCallBack(null) { + @Override + public void run(MessageReply reply) { + if (reply.isSuccess()) { + onFlushSuccess(vmUuid, snapshotVersion); + } else { + onFlushFailure(vmUuid, reply.getError()); + } + chainNext.run(); + } + }); + } + + // ===================================================================== + // onFlushSuccess — 刷写成功处理(dirtyVersion 条件删除) + // ===================================================================== + + /** + * 刷写成功后的处理。 + * + *

Δ-2 修复:使用 SQLBatch 替代 @Transactional,避免 self-invocation 陷阱。

+ * + *

条件删除:仅当 dirtyVersion == snapshotVersion 时删除, + * 即"刷写期间没有新的 markDirty 到来"。

+ * + *

如果 dirtyVersion > snapshotVersion,说明刷写期间有新变更, + * 释放认领让 triggerFlush / Poller 重新处理。

+ */ + private void onFlushSuccess(String vmUuid, long snapshotVersion) { + new SQLBatch() { + @Override + protected void scripts() { + // 条件删除:仅当 dirtyVersion == snapshotVersion 时删除 + int deleted = SQL.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .eq(VmMetadataDirtyVO_.dirtyVersion, snapshotVersion) + .delete(); + + if (deleted == 0) { + // dirtyVersion > snapshotVersion → 刷写期间有新变更 + // 释放认领,让 triggerFlush / Poller 重新处理 + // 同时重置 retryCount(本次成功说明通路正常) + SQL.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .set(VmMetadataDirtyVO_.managementNodeUuid, null) + .set(VmMetadataDirtyVO_.retryCount, 0) + .set(VmMetadataDirtyVO_.nextRetryTime, null) + .update(); + + logger.debug(String.format("[MetadataDirty] vm[uuid:%s] has new changes during flush " + + "(snapshotVersion=%d), released for re-processing", vmUuid, snapshotVersion)); + } else { + logger.debug(String.format("[MetadataDirty] vm[uuid:%s] flush completed and dirty row removed", + vmUuid)); + } + } + }.execute(); + + // Δ-9:记录路径指纹(用于 PathDriftDetector 巡检) + savePathFingerprint(vmUuid); + } + + // ===================================================================== + // onFlushFailure — 刷写失败处理(指数退避 / 放弃) + // ===================================================================== + + /** + * 刷写失败后的处理。 + * + *

retryCount++ → 达到上限则标记 stale + 删除行(MetadataStaleRecoveryTask 接管); + * 未达上限则释放认领 + 指数退避。

+ * + *

C-RB-04: 退避参数来自 GlobalConfig,禁止硬编码。

+ *

C-SR-05: 重试耗尽时在 PathFingerprintVO 标记 lastFlushFailed=true。

+ */ + private void onFlushFailure(String vmUuid, ErrorCode error) { + VmMetadataDirtyVO dirty = dbf.findByUuid(vmUuid, VmMetadataDirtyVO.class); + if (dirty == null) { + return; // VM 已销毁,FK CASCADE 已清理 + } + + int newRetryCount = dirty.getRetryCount() + 1; + int maxRetry = VmGlobalConfig.VM_METADATA_MAX_RETRY.value(Integer.class); + int baseDelay = VmGlobalConfig.VM_METADATA_RETRY_BASE_DELAY.value(Integer.class); + int maxExponent = VmGlobalConfig.VM_METADATA_RETRY_MAX_EXPONENT.value(Integer.class); + + if (newRetryCount >= maxRetry) { + // 达到上限 → 告警 + 标记 stale(C-SR-05:不再直接删除后静默放弃) + logger.error(String.format("[MetadataDirty] metadata update for vm[uuid:%s] failed " + + "after %d retries, marking as stale. MetadataStaleRecoveryTask will retry " + + "independently. Error: %s", vmUuid, newRetryCount, error)); + + // C-SR-05: 在 PathFingerprintVO 上标记 lastFlushFailed=true + SQL.New("UPDATE VmMetadataPathFingerprintVO " + + "SET lastFlushFailed = 1 WHERE vmInstanceUuid = :vmUuid") + .param("vmUuid", vmUuid) + .execute(); + + // 删除 dirty 行(释放 Poller 资源),stale 恢复由独立任务接管 + SQL.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .delete(); + return; + } + + // 未达上限 → 释放认领 + 指数退避(C-RB-04: 参数来自 GlobalConfig) + long delaySec = baseDelay * (1L << Math.min(newRetryCount, maxExponent)); + Timestamp nextRetry = Timestamp.from(Instant.now().plusSeconds(delaySec)); + + SQL.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .set(VmMetadataDirtyVO_.managementNodeUuid, null) + .set(VmMetadataDirtyVO_.retryCount, newRetryCount) + .set(VmMetadataDirtyVO_.nextRetryTime, nextRetry) + .update(); + + logger.warn(String.format("[MetadataDirty] metadata update for vm[uuid:%s] failed " + + "(retry %d/%d), next retry at %s. Error: %s", + vmUuid, newRetryCount, maxRetry, nextRetry, error)); + } + + // ===================================================================== + // 辅助方法 + // ===================================================================== + + /** + * 释放 dirty 行的认领(managementNodeUuid 置 NULL)。 + */ + private void releaseClaim(String vmUuid) { + SQL.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .set(VmMetadataDirtyVO_.managementNodeUuid, null) + .update(); + } + + /** + * 查找指定 VM dirty 行的 stale claim owner。 + * + *

若该 VM 的 dirty 行被某个 MN 认领,且 lastClaimTime 超过 staleThreshold, + * 则返回该 MN 的 UUID;否则返回 null。

+ * + * @param vmUuid 目标 VM UUID + * @param staleThreshold 认领超时阈值 + * @return stale claim owner 的 MN UUID,或 null + */ + private String findStaleClaimOwner(String vmUuid, Duration staleThreshold) { + Timestamp cutoff = Timestamp.from(Instant.now().minus(staleThreshold)); + return Q.New(VmMetadataDirtyVO.class) + .eq(VmMetadataDirtyVO_.vmInstanceUuid, vmUuid) + .notNull(VmMetadataDirtyVO_.managementNodeUuid) + .lt(VmMetadataDirtyVO_.lastClaimTime, cutoff) + .select(VmMetadataDirtyVO_.managementNodeUuid) + .findValue(); + } + + /** + * 记录 VM 的路径指纹(用于 MetadataPathDriftDetector 巡检)。 + * + *

每次 flush 成功后调用,INSERT or UPDATE VmMetadataPathFingerprintVO。 + * pathSnapshot 为当前 VM 所有 Volume + Snapshot 的 installPath 列表的 JSON。

+ * + *

pathSnapshot 构建使用 {@link MetadataPathSnapshotBuilder#buildPathJson}, + * 与 {@link MetadataPathDriftDetector} 巡检时使用完全相同的逻辑,确保一致性。

+ */ + private void savePathFingerprint(String vmUuid) { + // 构建当前路径快照 JSON + List volumes = Q.New(VolumeVO.class) + .eq(VolumeVO_.vmInstanceUuid, vmUuid) + .orderBy(VolumeVO_.uuid, SimpleQuery.Od.ASC) + .list(); + + List snapshots; + if (volumes.isEmpty()) { + snapshots = new java.util.ArrayList<>(); + } else { + List volumeUuids = volumes.stream() + .map(VolumeVO::getUuid) + .collect(java.util.stream.Collectors.toList()); + snapshots = Q.New(VolumeSnapshotVO.class) + .in(VolumeSnapshotVO_.volumeUuid, volumeUuids) + .orderBy(VolumeSnapshotVO_.uuid, SimpleQuery.Od.ASC) + .list(); + } + + String pathJson = MetadataPathSnapshotBuilder.buildPathJson(volumes, snapshots); + + VmMetadataPathFingerprintVO fp = dbf.findByUuid(vmUuid, VmMetadataPathFingerprintVO.class); + if (fp == null) { + fp = new VmMetadataPathFingerprintVO(); + fp.setVmInstanceUuid(vmUuid); + fp.setPathSnapshot(pathJson); + fp.setLastFlushTime(new Timestamp(System.currentTimeMillis())); + fp.setLastFlushFailed(false); + fp.setStaleRecoveryCount(0); + dbf.persist(fp); + } else { + fp.setPathSnapshot(pathJson); + fp.setLastFlushTime(new Timestamp(System.currentTimeMillis())); + dbf.update(fp); + } + } + + /** + * 独立的僵尸 claim 清理任务(防御性措施,DP-05)。 + * + *

从 claimDirtyRows() 提取为独立低频任务,避免每 5s Poller 周期执行不必要的 + * write-intent 扫描。覆盖的场景:

+ *
    + *
  • MN 进程 hang 住(JVM 死锁 / 长 GC),心跳未失效但 flush 永久阻塞
  • + *
  • 网络分区导致目标 Agent 无响应,ChainTask 在 timeout 前持续持有 claim
  • + *
  • 极端:MN 已离线但 ManagementNodeVO 记录因 heartbeat 延迟尚未被清理
  • + *
+ * + *

C-CL-02: 阈值 15 分钟 > flush 最大超时(5min),安全余量充足。

+ */ + private void cleanupZombieClaims() { + long thresholdMinutes = VmGlobalConfig.VM_METADATA_ZOMBIE_CLAIM_THRESHOLD.value(Long.class); + int cleaned = SQL.New("UPDATE VmMetadataDirtyVO " + + "SET managementNodeUuid = NULL, lastClaimTime = NULL " + + "WHERE managementNodeUuid IS NOT NULL " + + "AND lastClaimTime < CURRENT_TIMESTAMP - INTERVAL " + thresholdMinutes + " MINUTE") + .execute(); + + if (cleaned > 0) { + logger.info(String.format("[MetadataDirty] cleanupZombieClaims released %d zombie claim(s) " + + "(threshold=%d minutes)", cleaned, thresholdMinutes)); + } + } + + // ===================================================================== + // 升级全量刷新(§9.2) + // ===================================================================== + + /** + * 升级后全量刷新:为所有 UserVm 标脏,Poller 自动处理。 + * + *

§9.2: 使用 C-DM-01 兼容的 INSERT IGNORE + UPDATE 两步,keyset 分页。 + * storageStructureChange=1(C-SC-07:升级后无法判断存储拓扑是否变化)。

+ * + *

lastRefreshVersion 在全量刷新完成后写入(讨论 Δ-8): + * 若刷新过程中 MN 崩溃,重启后 lastRefreshVersion 仍为旧值 → 重新触发 → 幂等安全。

+ */ + private void submitFullRefresh(String currentVersion) { + logger.info(String.format("[MetadataDirty] metadata full refresh: starting for version %s", currentVersion)); + + int batchSize = VmGlobalConfig.VM_METADATA_UPGRADE_REFRESH_BATCH_SIZE.value(Integer.class); + String lastUuid = ""; + int totalProcessed = 0; + + while (true) { + // Step 1: INSERT IGNORE — 为尚无 dirty 行的 VM 创建新行 + SQL.New( + "INSERT IGNORE INTO VmMetadataDirtyVO (vmInstanceUuid, dirtyVersion, storageStructureChange) " + + "SELECT v.uuid, 1, 1 FROM VmInstanceVO v " + + "WHERE v.type = 'UserVm' AND v.uuid > :lastUuid " + + "ORDER BY v.uuid ASC LIMIT :batchSize") + .param("lastUuid", lastUuid) + .param("batchSize", batchSize) + .execute(); + + // Step 2: UPDATE — 已有 dirty 行的 VM 递增 dirtyVersion + 升级 storageStructureChange + SQL.New( + "UPDATE VmMetadataDirtyVO d " + + "INNER JOIN VmInstanceVO v ON d.vmInstanceUuid = v.uuid " + + "SET d.dirtyVersion = d.dirtyVersion + 1, " + + " d.storageStructureChange = 1 " + + "WHERE v.type = 'UserVm' AND v.uuid > :lastUuid " + + "ORDER BY v.uuid ASC LIMIT :batchSize") + .param("lastUuid", lastUuid) + .param("batchSize", batchSize) + .execute(); + + // 更新 lastUuid 用于 keyset 分页 + List batch = SQL.New("SELECT v.uuid FROM VmInstanceVO v " + + "WHERE v.type = 'UserVm' AND v.uuid > :lastUuid " + + "ORDER BY v.uuid ASC LIMIT :batchSize", String.class) + .param("lastUuid", lastUuid) + .param("batchSize", batchSize) + .list(); + + if (batch.isEmpty()) { + break; + } + + totalProcessed += batch.size(); + lastUuid = batch.get(batch.size() - 1); + } + + logger.info(String.format("[MetadataDirty] metadata full refresh: %d VMs processed for version %s", + totalProcessed, currentVersion)); + + // 更新 lastRefreshVersion — 必须在全量刷新完成后写入(讨论 Δ-8) + VmGlobalConfig.VM_METADATA_LAST_REFRESH_VERSION.updateValue(currentVersion); + } + + // ===================================================================== + // 功能开关 false→true:分批全量初始化(§9a.1) + // ===================================================================== + + /** + * 功能开关从 false 切换到 true 时,分批为尚无 dirty 行的 UserVm 创建 dirty 行。 + * + *

与 §9.2 升级全量刷新的区别:

+ *
    + *
  • 仅处理尚无 dirty 行的 VM(LEFT JOIN 排除已有行)
  • + *
  • storageStructureChange=0(首次初始化不涉及存储拓扑变更)
  • + *
  • 每批之间有延迟(防止 IO 风暴)
  • + *
  • 每轮重新检查开关状态(防御快速 toggle)
  • + *
+ */ + private void submitBatchInitialization() { + thdf.submit(new org.zstack.core.thread.Task() { + @Override + public String getName() { + return "metadata-batch-initialization"; + } + + @Override + public Void call() { + // 延迟 30s 启动,等待 Poller、ChainTask 线程池初始化完成 + try { + TimeUnit.SECONDS.sleep(30); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("[MetadataDirty] batch initialization startup delay interrupted"); + return null; + } + + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + // 延迟执行前再次检查,防止快速 toggle 后仍执行初始化 + logger.info("[MetadataDirty] vm.metadata.enabled toggled back to false before initialization, skip"); + return null; + } + + int batchSize = VmGlobalConfig.VM_METADATA_INIT_BATCH_SIZE.value(Integer.class); + long batchDelaySec = VmGlobalConfig.VM_METADATA_INIT_BATCH_DELAY.value(Long.class); + String lastUuid = ""; + int totalInitialized = 0; + + while (true) { + // 每轮检查开关状态,若已关闭则中止 + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + logger.info(String.format("[MetadataDirty] vm.metadata.enabled disabled during initialization, " + + "abort. initialized=%d", totalInitialized)); + break; + } + + // Keyset 分页查询尚无 dirty 行的 UserVm,INSERT IGNORE + int initialized = SQL.New( + "INSERT IGNORE INTO VmMetadataDirtyVO (vmInstanceUuid, dirtyVersion, storageStructureChange) " + + "SELECT v.uuid, 1, 0 FROM VmInstanceVO v " + + "LEFT JOIN VmMetadataDirtyVO d ON v.uuid = d.vmInstanceUuid " + + "WHERE v.type = 'UserVm' AND v.uuid > :lastUuid AND d.vmInstanceUuid IS NULL " + + "ORDER BY v.uuid ASC LIMIT :batchSize") + .param("lastUuid", lastUuid) + .param("batchSize", batchSize) + .execute(); + + totalInitialized += initialized; + + // Q29 修复:lastUuid 基于 VmInstanceVO 全量 UUID 推进, + // 而非 INSERT 结果。当本批所有 VM 都已有 dirty 行时 INSERT IGNORE + // affected_rows=0,但后续批次可能还有未初始化的 VM。 + List batchUuids = SQL.New("SELECT v.uuid FROM VmInstanceVO v " + + "WHERE v.type = 'UserVm' AND v.uuid > :lastUuid " + + "ORDER BY v.uuid ASC", String.class) + .param("lastUuid", lastUuid) + .limit(batchSize) + .list(); + + if (batchUuids.isEmpty()) { + break; // 真正遍历完所有 VM + } + lastUuid = batchUuids.get(batchUuids.size() - 1); + + logger.info(String.format("[MetadataDirty] metadata initialization batch completed: " + + "%d VMs in this batch, %d total", initialized, totalInitialized)); + + // 批间延迟:等待 Poller 消化已有 dirty 行,避免瞬间堆积 + if (batchDelaySec > 0) { + try { + TimeUnit.SECONDS.sleep(batchDelaySec); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("[MetadataDirty] metadata initialization interrupted"); + break; + } + } + } + + logger.info(String.format("[MetadataDirty] metadata initialization complete: %d VMs total", + totalInitialized)); + return null; + } + }); + } + + // ===================================================================== + // 功能开关 true→false:清理 PathFingerprint(§9a.2 讨论 Δ-10) + // ===================================================================== + + /** + * 功能关闭时异步批量删除所有 VmMetadataPathFingerprintVO 行。 + * + *

§9a.2 讨论 Δ-10:功能关闭期间存储拓扑可能发生变更, + * 重新启用时旧指纹与实际拓扑不一致,会导致路径巡检产生大量误报。 + * 清理采用 keyset 分页异步删除(每批 1000 行),不阻塞 GlobalConfig 变更回调。

+ */ + private void cleanupPathFingerprints() { + thdf.submit(new org.zstack.core.thread.Task() { + @Override + public String getName() { + return "metadata-cleanup-path-fingerprints"; + } + + @Override + public Void call() { + String lastUuid = ""; + int totalDeleted = 0; + int batchSize = 1000; + + while (true) { + List batch = SQL.New( + "SELECT vmInstanceUuid FROM VmMetadataPathFingerprintVO " + + "WHERE vmInstanceUuid > :lastUuid " + + "ORDER BY vmInstanceUuid ASC LIMIT :batchSize", String.class) + .param("lastUuid", lastUuid) + .param("batchSize", batchSize) + .list(); + + if (batch.isEmpty()) { + break; + } + + int deleted = SQL.New("DELETE FROM VmMetadataPathFingerprintVO " + + "WHERE vmInstanceUuid IN (:uuids)") + .param("uuids", batch) + .execute(); + totalDeleted += deleted; + lastUuid = batch.get(batch.size() - 1); + } + + if (totalDeleted > 0) { + logger.info(String.format("[MetadataDirty] cleaned up %d PathFingerprint rows " + + "after metadata feature disabled", totalDeleted)); + } + return null; + } + }); + } + + // ===================================================================== + // 升级后全量刷新调度(§9.1) + // ===================================================================== + + /** + * 升级后自动检测是否需要全量刷新。 + * + *

§9.1: 比较 {@code dbf.getDbVersion()} 与 {@code VM_METADATA_LAST_REFRESH_VERSION}, + * 若不一致则在延迟后执行 {@link #submitFullRefresh(String)}。

+ * + *

延迟原因:升级后多个 MN 同时启动,仅需一个 MN 执行全量刷新。 + * 通过 {@code VM_METADATA_UPGRADE_REFRESH_DELAY}(默认 600s)延迟 + 执行前 re-check + * 实现"最终只有一个 MN 执行"的效果(best-effort, 非 leader election)。

+ * + *

M3 recent-nodeLeft 防护:延迟到期后若近 15 分钟内发生过 nodeLeft, + * 说明集群可能不稳定,递归 reschedule 以避免在 MN 重新平衡期间执行全量刷新。

+ */ + private void scheduleUpgradeRefreshIfNeeded() { + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return; + } + + String currentVersion = dbf.getDbVersion(); + String lastRefreshVersion = VmGlobalConfig.VM_METADATA_LAST_REFRESH_VERSION.value(String.class); + + if (currentVersion.equals(lastRefreshVersion)) { + logger.debug("[MetadataDirty] DB version matches lastRefreshVersion, no upgrade refresh needed"); + return; + } + + long delaySec = VmGlobalConfig.VM_METADATA_UPGRADE_REFRESH_DELAY.value(Long.class); + logger.info(String.format("[MetadataDirty] DB version %s != lastRefreshVersion %s, " + + "scheduling upgrade refresh after %ds delay", currentVersion, lastRefreshVersion, delaySec)); + + thdf.submitTimeoutTask(() -> { + // Re-check: version may have changed, or feature may be disabled + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return; + } + String recheckVersion = dbf.getDbVersion(); + if (!recheckVersion.equals(currentVersion)) { + logger.warn("[MetadataDirty] DB version changed during upgrade refresh delay, skip"); + return; + } + String recheckLastRefresh = VmGlobalConfig.VM_METADATA_LAST_REFRESH_VERSION.value(String.class); + if (recheckVersion.equals(recheckLastRefresh)) { + logger.info("[MetadataDirty] another MN already completed upgrade refresh, skip"); + return; + } + + // M3 recent-nodeLeft check: if nodeLeft within last 15 min, reschedule + long recentNodeLeftWindowMs = 15L * 60 * 1000; + if (System.currentTimeMillis() - lastNodeLeftTimestamp < recentNodeLeftWindowMs) { + logger.info("[MetadataDirty] recent nodeLeft detected, rescheduling upgrade refresh"); + scheduleUpgradeRefreshIfNeeded(); // re-enter with fresh delay + return; + } + + submitFullRefresh(recheckVersion); + }, TimeUnit.SECONDS, delaySec); + } + + /** + * 启动僵尸 claim 清理定时任务(60s 间隔)。 + */ + private synchronized void startZombieCleanupTask() { + if (zombieCleanupFuture != null) { + zombieCleanupFuture.cancel(false); + } + zombieCleanupFuture = thdf.submitPeriodicTask(new PeriodicTask() { + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.SECONDS; + } + + @Override + public long getInterval() { + return 60; + } + + @Override + public String getName() { + return "vm-metadata-zombie-claim-cleanup"; + } + + @Override + public void run() { + cleanupZombieClaims(); + } + }); + logger.info("[MetadataDirty] zombie claim cleanup task started (interval=60s)"); + } + + /** + * 停止僵尸 claim 清理定时任务。 + */ + private synchronized void stopZombieCleanupTask() { + if (zombieCleanupFuture != null) { + zombieCleanupFuture.cancel(false); + zombieCleanupFuture = null; + logger.info("[MetadataDirty] zombie claim cleanup task stopped"); + } + } +} diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataUpdateInterceptor.java b/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataUpdateInterceptor.java new file mode 100644 index 00000000000..efb4d67d044 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/VmMetadataUpdateInterceptor.java @@ -0,0 +1,358 @@ +package org.zstack.compute.vm.metadata; + + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.compute.vm.VmGlobalConfig; +import org.zstack.core.cloudbus.CloudBus; +import org.zstack.header.Component; +import org.zstack.header.message.*; +import org.zstack.header.vm.MetadataImpact; +import org.zstack.header.vm.VmUuidFromApiResolver; +import org.zstack.utils.BeanUtils; +import org.zstack.utils.Utils; +import org.zstack.utils.logging.CLogger; + +import org.zstack.core.thread.PeriodicTask; +import org.zstack.core.thread.ThreadFacade; +import org.zstack.header.managementnode.ManagementNodeReadyExtensionPoint; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * 拦截标注了 {@link MetadataImpact} 的 API 消息,在 API 成功后调用 markDirty 触发元数据更新。 + * + *

工作流程

+ *
+ * API Message 投递                          Event 发布
+ *       │                                      │
+ *       ▼                                      ▼
+ * BeforeDeliveryMessageInterceptor    BeforePublishEventInterceptor
+ *       │                                      │
+ *       │  检测 @MetadataImpact               │  通过 apiId 匹配
+ *       │  通过 VmUuidFromApiResolver          │  检查 API 是否成功
+ *       │  解析 vmUuid 列表                    │  调用 markDirty()
+ *       │  缓存到 pendingApis                  │  清理 pendingApis
+ *       │  (key = apiId)                       │
+ *       ▼                                      ▼
+ * 
+ * + *

VM UUID 解析链

+ *

通过注入的 {@link VmUuidFromApiResolver} 列表按顺序解析 vmUuid:

+ *
    + *
  1. {@link DefaultVmUuidFromApiResolver} — VmInstanceMessage 接口
  2. + *
  3. {@link VolumeBasedVmUuidFromApiResolver} — VolumeMessage → 查库
  4. + *
  5. {@link ResourceBasedVmUuidFromApiResolver} — Tag/ResourceConfig API → resourceType/resourceUuid 查库
  6. + *
  7. {@link SnapshotBasedVmUuidFromApiResolver} — VolumeSnapshotMessage → snapshotUuid → volumeUuid → vmUuid
  8. + *
  9. {@link SnapshotGroupBasedVmUuidFromApiResolver} — VolumeSnapshotGroupMessage → groupUuid → vmUuids
  10. + *
  11. {@link NicBasedVmUuidFromApiResolver} — vmNicUuid(反射) → VmNicVO → vmUuid
  12. + *
  13. {@link ReflectionBasedVmUuidFromApiResolver} — 反射兜底
  14. + *
+ * + *

标脏策略

+ *

使用 {@link VmMetadataDirtyMarker#markDirty(String, boolean)} 将 VM 标记为脏, + * INSERT ON DUPLICATE KEY UPDATE 天然去重,100 个 API 只产生 1 行 dirty 行。 + * markDirty 后立即尝试认领并刷写,Poller 作为安全网处理退避和异常场景。

+ */ +public class VmMetadataUpdateInterceptor implements Component, ManagementNodeReadyExtensionPoint { + private static final CLogger logger = Utils.getLogger(VmMetadataUpdateInterceptor.class); + + @Autowired + private CloudBus bus; + + @Autowired + private VmMetadataDirtyMarker dirtyMarker; + + @Autowired + private ThreadFacade thdf; + + /** + * VM UUID 解析器链,按注册顺序尝试(在 VmInstanceManager.xml 中注册)。 + */ + @Autowired(required = false) + private List resolvers = Collections.emptyList(); + + // apiId -> MetadataImpactInfo 映射,在 API 投递时写入,在 Event 发布时消费 + private final Map pendingApis = new ConcurrentHashMap<>(); + + // 标注了 @MetadataImpact 的 API 消息类集合 + private final Set> impactApiClasses = ConcurrentHashMap.newKeySet(); + + /** + * 内部消息注册表:声明"已知会影响 VM 元数据但不经过 API 拦截器"的消息类型。 + * + *

用途:

+ *
    + *
  • 代码审计与 CI 检查:确保所有影响元数据的内部消息已被识别
  • + *
  • 对应 handler 在事务提交后直接调用 {@code markDirty()},不走本拦截器
  • + *
+ * + *

注册方式:各模块在 Component.start() 中调用 + * {@link #registerInternalMetadataMessage(Class)} 注册。

+ * + * @see VmMetadataDirtyMarker#markDirty(String, boolean) + */ + private static final Set> INTERNAL_METADATA_MESSAGES = + Collections.synchronizedSet(new HashSet<>()); + + /** + * 注册一个内部消息类型到 INTERNAL_METADATA_MESSAGES 注册表。 + * 各模块在 Component.start() 中调用此方法。 + * + * @param msgClass 内部消息类型 + */ + public static void registerInternalMetadataMessage(Class msgClass) { + INTERNAL_METADATA_MESSAGES.add(msgClass); + logger.debug(String.format("registered internal metadata message: %s", msgClass.getName())); + } + + /** + * 获取已注册的内部元数据消息类型(只读视图,用于 CI 检查)。 + */ + public static Set> getInternalMetadataMessages() { + return Collections.unmodifiableSet(INTERNAL_METADATA_MESSAGES); + } + + // pendingApis 超时清理任务 + private Future cleanupFuture; + + @Override + public boolean start() { + // 1. 扫描所有标注了 @MetadataImpact 的 API 消息类 + scanMetadataImpactApis(); + + // 2. 注册 BeforeDeliveryMessageInterceptor,在 API 消息被投递处理前, + // 通过 Resolver 链解析 vmInstanceUuid 并缓存 + bus.installBeforeDeliveryMessageInterceptor(new AbstractBeforeDeliveryMessageInterceptor() { + @Override + public void beforeDeliveryMessage(Message msg) { + if (!(msg instanceof APIMessage)) { + return; + } + if (!impactApiClasses.contains(msg.getClass())) { + return; + } + + // vm.metadata 功能总开关,关闭时跳过所有元数据更新 + if (!VmGlobalConfig.VM_METADATA.value(Boolean.class)) { + return; + } + + APIMessage apiMsg = (APIMessage) msg; + List vmUuids = resolveVmUuids(apiMsg); + if (vmUuids.isEmpty()) { + return; + } + + MetadataImpact impact = msg.getClass().getAnnotation(MetadataImpact.class); + pendingApis.put(apiMsg.getId(), new MetadataImpactInfo( + vmUuids, impact.value(), impact.updateOnFailure())); + } + }, impactApiClasses.toArray(new Class[0])); + + // 3. 注册 BeforePublishEventInterceptor,在 Event 发布前检查并标脏 + bus.installBeforePublishEventInterceptor(new AbstractBeforePublishEventInterceptor() { + @Override + public void beforePublishEvent(Event evt) { + if (!(evt instanceof APIEvent)) { + return; + } + + APIEvent apiEvent = (APIEvent) evt; + MetadataImpactInfo info = pendingApis.remove(apiEvent.getApiId()); + if (info == null) { + return; + } + + // API 失败则跳过(除非 @MetadataImpact(updateOnFailure=true)) + if (apiEvent.getError() != null && !info.updateOnFailure) { + return; + } + + for (String vmUuid : info.vmUuids) { + submitMarkDirty(vmUuid, info.impact); + } + } + }); + + return true; + } + + private void scanMetadataImpactApis() { + // 利用 ZStack 的反射工具扫描所有带 @MetadataImpact 注解的 APIMessage 子类 + BeanUtils.reflections.getTypesAnnotatedWith(MetadataImpact.class).forEach(clz -> { + if (APIMessage.class.isAssignableFrom(clz)) { + impactApiClasses.add((Class) clz); + logger.debug(String.format("detected @MetadataImpact API: %s", clz.getName())); + } + }); + } + + /** + * 通过 Resolver 链解析 API 消息关联的 vmInstanceUuid 列表。 + * + *

按注册顺序遍历 resolvers,使用第一个 supports() 返回 true 的 Resolver 进行解析。 + * 如果所有 Resolver 都不支持或返回空列表,则返回空。

+ */ + private List resolveVmUuids(APIMessage msg) { + for (VmUuidFromApiResolver resolver : resolvers) { + if (resolver.supports(msg)) { + List vmUuids = resolver.resolveVmUuids(msg); + if (vmUuids != null && !vmUuids.isEmpty()) { + return vmUuids; + } + } + } + logger.debug(String.format("no resolver could extract vmUuids from %s", msg.getClass().getName())); + return Collections.emptyList(); + } + + /** + * 提交元数据标脏。 + * + *

根据 {@link MetadataImpact.Impact} 决定 OP type:

+ *
    + *
  • {@code CONFIG} → storageStructureChange=false(OP type 1)
  • + *
  • {@code STORAGE} → storageStructureChange=true(OP type 2)
  • + *
+ * + * @param vmInstanceUuid 目标虚拟机 UUID + * @param impact 影响级别(来自 {@code @MetadataImpact} 注解) + */ + void submitMarkDirty(String vmInstanceUuid, MetadataImpact.Impact impact) { + boolean storageStructureChange = (impact == MetadataImpact.Impact.STORAGE); + logger.debug(String.format("[MetadataDirty] API succeeded, marking dirty " + + "for vm[uuid:%s], impact=%s, storageStructureChange=%s", + vmInstanceUuid, impact, storageStructureChange)); + dirtyMarker.markDirty(vmInstanceUuid, storageStructureChange); + } + + @Override + public boolean stop() { + stopCleanupTask(); + pendingApis.clear(); + return true; + } + + // ===================================================================== + // ManagementNodeReadyExtensionPoint + // ===================================================================== + + @Override + public void managementNodeReady() { + startCleanupTask(); + } + + // ===================================================================== + // pendingApis 超时清理 + // ===================================================================== + + /** + * 启动 pendingApis 超时清理周期任务。 + * + *

pendingApis 在 BeforeDeliveryMessageInterceptor 中写入,在 BeforePublishEventInterceptor + * 中消费。若 APIEvent 因 MN 崩溃、消息丢失等原因永远不发布,pendingApis 中的条目会泄漏。 + * 本任务定期清理超过 {@code vm.metadata.pendingApi.timeoutMinutes}(默认 45 分钟)的陈旧条目, + * 并为其调用 markDirty(保守策略:超时意味着 API 结果未知,保守标脏确保最终一致)。

+ */ + private synchronized void startCleanupTask() { + if (cleanupFuture != null) { + cleanupFuture.cancel(false); + } + // 每 5 分钟检查一次 + cleanupFuture = thdf.submitPeriodicTask(new PeriodicTask() { + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.MINUTES; + } + + @Override + public long getInterval() { + return 5; + } + + @Override + public String getName() { + return "vm-metadata-pending-api-cleanup"; + } + + @Override + public void run() { + cleanupStalePendingApis(); + } + }); + logger.info("[MetadataInterceptor] pendingApis cleanup task started (check every 5min, " + + "timeout={}min)", VmGlobalConfig.VM_METADATA_PENDING_API_TIMEOUT.value(Long.class)); + } + + private synchronized void stopCleanupTask() { + if (cleanupFuture != null) { + cleanupFuture.cancel(false); + cleanupFuture = null; + } + } + + /** + * 清理超时的 pendingApis 条目。 + * + *

超时条目执行保守 markDirty:API 结果未知,保守标脏确保元数据最终一致。 + * 使用 STORAGE 级别(storageStructureChange=true)以覆盖最坏情况。

+ */ + private void cleanupStalePendingApis() { + if (pendingApis.isEmpty()) { + return; + } + + long timeoutMs = VmGlobalConfig.VM_METADATA_PENDING_API_TIMEOUT.value(Long.class) + * 60 * 1000; + long now = System.currentTimeMillis(); + int cleaned = 0; + + Iterator> it = pendingApis.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + MetadataImpactInfo info = entry.getValue(); + + if (now - info.timestamp > timeoutMs) { + it.remove(); + // 保守标脏:API 结果未知,假设成功,标脏确保最终一致 + for (String vmUuid : info.vmUuids) { + logger.warn("[MetadataInterceptor] pendingApi timeout: apiId={}, vm={}, " + + "age={}min. Conservative markDirty applied.", + entry.getKey(), vmUuid, (now - info.timestamp) / 60000); + dirtyMarker.markDirty(vmUuid, true); + } + cleaned++; + } + } + + if (cleaned > 0) { + logger.info("[MetadataInterceptor] cleaned {} stale pendingApi entries", cleaned); + } + } + + /** + * 缓存 API 投递时提取的元数据影响信息,供 Event 发布时匹配使用。 + */ + private static class MetadataImpactInfo { + final List vmUuids; + final MetadataImpact.Impact impact; + final boolean updateOnFailure; + final long timestamp; + + MetadataImpactInfo(List vmUuids, MetadataImpact.Impact impact, boolean updateOnFailure) { + this.vmUuids = vmUuids; + this.impact = impact; + this.updateOnFailure = updateOnFailure; + this.timestamp = System.currentTimeMillis(); + } + } +} \ No newline at end of file diff --git a/compute/src/main/java/org/zstack/compute/vm/metadata/VolumeBasedVmUuidFromApiResolver.java b/compute/src/main/java/org/zstack/compute/vm/metadata/VolumeBasedVmUuidFromApiResolver.java new file mode 100644 index 00000000000..b3f27a6eea9 --- /dev/null +++ b/compute/src/main/java/org/zstack/compute/vm/metadata/VolumeBasedVmUuidFromApiResolver.java @@ -0,0 +1,66 @@ +package org.zstack.compute.vm.metadata; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.core.db.SQL; +import org.zstack.header.message.APIMessage; +import org.zstack.header.vm.VmInstanceMessage; +import org.zstack.header.vm.VmUuidFromApiResolver; +import org.zstack.header.volume.VolumeMessage; + +import java.util.Collections; +import java.util.List; + +/** + * Volume 关联 VM UUID 解析器:从实现 {@link VolumeMessage} 接口的 API 消息中获取 volumeUuid, + * 查询 VolumeVO 得到关联的 vmInstanceUuid。 + * + *

覆盖快照、云盘挂载/卸载等涉及 Volume 但不直接携带 vmInstanceUuid 的 API。

+ * + *

排除条件

+ *

如果消息同时实现了 {@link VmInstanceMessage},则由 {@link DefaultVmUuidFromApiResolver} 处理, + * 本解析器不参与。

+ * + *

解析时机

+ *

在 API 执行前调用。对于 attach 场景,VolumeVO.vmInstanceUuid 可能尚未设置, + * 此时通过反射 fallback 到 msg.getVmInstanceUuid()(如果存在)。

+ */ +public class VolumeBasedVmUuidFromApiResolver implements VmUuidFromApiResolver { + + @Override + public boolean supports(APIMessage msg) { + // 同时实现 VmInstanceMessage 的由 DefaultResolver 处理 + return msg instanceof VolumeMessage && !(msg instanceof VmInstanceMessage); + } + + @Override + public List resolveVmUuids(APIMessage msg) { + String volumeUuid = ((VolumeMessage) msg).getVolumeUuid(); + if (volumeUuid == null) { + return Collections.emptyList(); + } + + // 查询 Volume → vmInstanceUuid + List vmUuids = SQL.New( + "SELECT v.vmInstanceUuid FROM VolumeVO v " + + "WHERE v.uuid = :uuid AND v.vmInstanceUuid IS NOT NULL", + String.class + ).param("uuid", volumeUuid).list(); + + if (!vmUuids.isEmpty()) { + return vmUuids; + } + + // Fallback:尝试通过反射获取 msg 上的 getVmInstanceUuid() + // 适用于 APIAttachDataVolumeToVmMsg 等同时携带 vmInstanceUuid 的消息 + try { + String vmUuid = (String) msg.getClass().getMethod("getVmInstanceUuid").invoke(msg); + if (vmUuid != null) { + return Collections.singletonList(vmUuid); + } + } catch (Exception ignored) { + // 无此方法,忽略 + } + + return Collections.emptyList(); + } +} diff --git a/conf/db/upgrade/V4.10.29__schema.sql b/conf/db/upgrade/V4.10.29__schema.sql new file mode 100644 index 00000000000..10aa08fc073 --- /dev/null +++ b/conf/db/upgrade/V4.10.29__schema.sql @@ -0,0 +1,36 @@ +-- Feature: VM Metadata Dirty Mark + Poller (replaces GC-based approach) + +CREATE TABLE IF NOT EXISTS `zstack`.`VmMetadataDirtyVO` ( + `vmInstanceUuid` VARCHAR(32) NOT NULL, + `managementNodeUuid` VARCHAR(32) DEFAULT NULL, + `dirtyVersion` BIGINT NOT NULL DEFAULT 1, + `lastClaimTime` TIMESTAMP NULL DEFAULT NULL, + `storageStructureChange` TINYINT(1) NOT NULL DEFAULT 0, + `retryCount` INT NOT NULL DEFAULT 0, + `nextRetryTime` TIMESTAMP NULL DEFAULT NULL, + `createDate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `lastOpDate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`vmInstanceUuid`), + CONSTRAINT `fkVmMetadataDirtyVOVmInstanceEO` FOREIGN KEY (`vmInstanceUuid`) + REFERENCES `VmInstanceEO` (`uuid`) ON DELETE CASCADE, + CONSTRAINT `fkVmMetadataDirtyVOManagementNodeVO` FOREIGN KEY (`managementNodeUuid`) + REFERENCES `ManagementNodeVO` (`uuid`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Poller CAS claim query optimization: WHERE managementNodeUuid IS NULL AND lastClaimTime ... AND nextRetryTime <= NOW() +CREATE INDEX `idxVmMetadataDirtyUnclaimed` ON `VmMetadataDirtyVO` (`managementNodeUuid`, `lastClaimTime`, `nextRetryTime`); + +-- Path fingerprint for lightweight drift detection (§8.2.3) +CREATE TABLE IF NOT EXISTS `zstack`.`VmMetadataPathFingerprintVO` ( + `vmInstanceUuid` VARCHAR(32) NOT NULL, + `pathSnapshot` LONGTEXT, + `lastFlushTime` TIMESTAMP NULL DEFAULT NULL, + `lastFlushFailed` TINYINT(1) NOT NULL DEFAULT 0, + `staleRecoveryCount` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`vmInstanceUuid`), + CONSTRAINT `fkVmMetadataPathFingerprintVOVmInstanceEO` FOREIGN KEY (`vmInstanceUuid`) + REFERENCES `VmInstanceEO` (`uuid`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Clean up any old GC rows for vm metadata (from previous GC-based implementation) +DELETE FROM `GarbageCollectorVO` WHERE `name` LIKE 'update-vm-%-metadata-gc'; diff --git a/conf/globalConfig/vm.xml b/conf/globalConfig/vm.xml index 8563169b335..bae6e369e5f 100755 --- a/conf/globalConfig/vm.xml +++ b/conf/globalConfig/vm.xml @@ -317,4 +317,68 @@ java.lang.Boolean false + + + vm + deletion.gcInterval + update vm metadata interval + java.lang.Long + 30 + + + + vm + vm.metadata + save vm metadata + java.lang.Boolean + false + + + + vm + vm.metadata.ps.maxConcurrent + Max concurrent metadata writes per primary storage per MN. In dual-MN the actual global concurrency per PS = 2 × this value. + java.lang.Integer + 5 + + + + vm + vm.metadata.global.maxConcurrent + Max concurrent VM metadata updates globally per MN. In dual-MN the actual global concurrency = 2 × this value. + java.lang.Integer + 10 + + + + vm + vm.metadata.gc.initialDelaySec + Initial GC delay in seconds after API success. The first metadata update attempt happens after this delay. + java.lang.Integer + 10 + + + + vm + vm.metadata.maxRetry + Max retry count before giving up metadata flush. After this many failed attempts with exponential backoff, the dirty row is deleted and will auto-retry on the next API that modifies this VM. + java.lang.Integer + 5 + + + + vm + vm.metadata.dirty.pollIntervalSec + Dirty poller interval in seconds. The poller acts as a safety net to process dirty rows that were not handled by the immediate triggerFlush path (e.g., rows in backoff, orphaned rows after MN crash). + java.lang.Long + 5 + + + + vm + vm.metadata.dirty.batchSize + Max dirty rows to claim per poller cycle. Controls the batch size of CAS claim in each poller round. + java.lang.Integer + 20 + diff --git a/conf/persistence.xml b/conf/persistence.xml index aa295bcb365..dd97b857617 100755 --- a/conf/persistence.xml +++ b/conf/persistence.xml @@ -83,6 +83,7 @@ org.zstack.header.vm.VmInstanceEO org.zstack.header.vm.VmInstanceSequenceNumberVO org.zstack.header.vm.VmCrashHistoryVO + org.zstack.header.vm.VmMetadataDirtyVO org.zstack.appliancevm.ApplianceVmVO org.zstack.appliancevm.ApplianceVmFirewallRuleVO org.zstack.header.vm.VmDnsVO diff --git a/conf/serviceConfig/primaryStorage.xml b/conf/serviceConfig/primaryStorage.xml index 337ce4eaac3..06f4d94bc07 100755 --- a/conf/serviceConfig/primaryStorage.xml +++ b/conf/serviceConfig/primaryStorage.xml @@ -84,4 +84,7 @@ org.zstack.header.storage.primary.APIAddStorageProtocolMsg + + org.zstack.header.storage.primary.APIRegisterVmInstanceMsg + diff --git a/conf/springConfigXml/VmInstanceManager.xml b/conf/springConfigXml/VmInstanceManager.xml index 20e094378aa..0d3e7138af7 100755 --- a/conf/springConfigXml/VmInstanceManager.xml +++ b/conf/springConfigXml/VmInstanceManager.xml @@ -118,6 +118,7 @@ org.zstack.compute.vm.VmExpungeRootVolumeFlow org.zstack.compute.vm.VmExpungeMemoryVolumeFlow org.zstack.compute.vm.VmExpungeCacheVolumeFlow + org.zstack.compute.vm.VmExpungeMetadataFlow @@ -267,4 +268,58 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/header/src/main/java/org/zstack/header/storage/backup/APIExportImageFromBackupStorageMsg.java b/header/src/main/java/org/zstack/header/storage/backup/APIExportImageFromBackupStorageMsg.java index 35edcd109ed..89b0a3edb52 100755 --- a/header/src/main/java/org/zstack/header/storage/backup/APIExportImageFromBackupStorageMsg.java +++ b/header/src/main/java/org/zstack/header/storage/backup/APIExportImageFromBackupStorageMsg.java @@ -8,6 +8,7 @@ import org.zstack.header.message.DefaultTimeout; import org.zstack.header.other.APIAuditor; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; import java.util.concurrent.TimeUnit; @@ -18,6 +19,7 @@ responseClass = APIExportImageFromBackupStorageEvent.class ) @DefaultTimeout(timeunit = TimeUnit.HOURS, value = 3) +@MetadataImpact(MetadataImpact.Impact.NONE) public class APIExportImageFromBackupStorageMsg extends APIMessage implements BackupStorageMessage, APIAuditor { @APIParam(resourceType = BackupStorageVO.class) private String backupStorageUuid; diff --git a/header/src/main/java/org/zstack/header/storage/primary/APIGetVmInstanceMetadataFromPrimaryStorageMsg.java b/header/src/main/java/org/zstack/header/storage/primary/APIGetVmInstanceMetadataFromPrimaryStorageMsg.java new file mode 100644 index 00000000000..aab3976a182 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/APIGetVmInstanceMetadataFromPrimaryStorageMsg.java @@ -0,0 +1,36 @@ +package org.zstack.header.storage.primary; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIParam; +import org.zstack.header.message.APISyncCallMessage; +import org.zstack.header.rest.RestRequest; + + +@RestRequest( + path = "/primary-storage/vm-instances/metadata", + method = HttpMethod.GET, + responseClass = APIGetVmInstanceMetadataFromPrimaryStorageReply.class +) +public class APIGetVmInstanceMetadataFromPrimaryStorageMsg extends APISyncCallMessage implements PrimaryStorageMessage { + @APIParam(resourceType = PrimaryStorageVO.class) + private String uuid; + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + @Override + public String getPrimaryStorageUuid() { + return uuid; + } + + public static APIGetVmInstanceMetadataFromPrimaryStorageMsg __example__() { + APIGetVmInstanceMetadataFromPrimaryStorageMsg msg = new APIGetVmInstanceMetadataFromPrimaryStorageMsg(); + msg.setUuid(uuid()); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/APIGetVmInstanceMetadataFromPrimaryStorageReply.java b/header/src/main/java/org/zstack/header/storage/primary/APIGetVmInstanceMetadataFromPrimaryStorageReply.java new file mode 100644 index 00000000000..5e2dfa123fe --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/APIGetVmInstanceMetadataFromPrimaryStorageReply.java @@ -0,0 +1,26 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.APIReply; +import org.zstack.header.rest.RestResponse; + +import java.util.ArrayList; +import java.util.List; + + +@RestResponse(allTo = "all") +public class APIGetVmInstanceMetadataFromPrimaryStorageReply extends APIReply { + private List vmInstanceMetadata = new ArrayList<>(); + + public List getVmInstanceMetadata() { + return vmInstanceMetadata; + } + + public void setVmInstanceMetadata(List vmInstanceMetadata) { + this.vmInstanceMetadata = vmInstanceMetadata; + } + + public static APIGetVmInstanceMetadataFromPrimaryStorageReply __example__() { + APIGetVmInstanceMetadataFromPrimaryStorageReply reply = new APIGetVmInstanceMetadataFromPrimaryStorageReply(); + return reply; + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceEventDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceEventDoc_zh_cn.groovy new file mode 100644 index 00000000000..972cbf23154 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceEventDoc_zh_cn.groovy @@ -0,0 +1,32 @@ +package org.zstack.header.storage.primary + +import org.zstack.header.vm.VmInstanceInventory +import org.zstack.header.errorcode.ErrorCode + +doc { + + title "注册虚拟机返回" + + ref { + name "inventory" + path "org.zstack.header.storage.primary.APIRegisterVmInstanceEvent.inventory" + desc "null" + type "VmInstanceInventory" + since "4.10.0" + clz VmInstanceInventory.class + } + field { + name "success" + desc "" + type "boolean" + since "4.10.0" + } + ref { + name "error" + path "org.zstack.header.storage.primary.APIRegisterVmInstanceEvent.error" + desc "错误码,若不为null,则表示操作失败, 操作成功时该字段为null" + type "ErrorCode" + since "4.10.0" + clz ErrorCode.class + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceMsg.java b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceMsg.java new file mode 100644 index 00000000000..4b2c4e80778 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceMsg.java @@ -0,0 +1,63 @@ +package org.zstack.header.storage.primary; + +import org.springframework.http.HttpMethod; +import org.zstack.header.cluster.ClusterVO; +import org.zstack.header.host.HostVO; +import org.zstack.header.message.APIMessage; +import org.zstack.header.message.APIParam; +import org.zstack.header.rest.RestRequest; + +@RestRequest( + path = "/vm-instances/register", + method = HttpMethod.POST, + responseClass = APIRegisterVmInstanceReply.class, + parameterName = "params" +) +public class APIRegisterVmInstanceMsg extends APIMessage implements PrimaryStorageMessage { + @APIParam() + private String metadataPath; + @APIParam(resourceType = PrimaryStorageVO.class) + private String primaryStorageUuid; + @APIParam(resourceType = ClusterVO.class) + private String clusterUuid; + @APIParam(required = false, resourceType = HostVO.class) + private String hostUuid; + + @Override + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public String getClusterUuid() { + return clusterUuid; + } + + public void setClusterUuid(String clusterUuid) { + this.clusterUuid = clusterUuid; + } + + public String getHostUuid() { + return hostUuid; + } + + public void setHostUuid(String hostUuid) { + this.hostUuid = hostUuid; + } + + public String getMetadataPath() { + return metadataPath; + } + + public void setMetadataPath(String metadataPath) { + this.metadataPath = metadataPath; + } + + public static APIRegisterVmInstanceMsg __example__() { + APIRegisterVmInstanceMsg msg = new APIRegisterVmInstanceMsg(); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceMsgDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceMsgDoc_zh_cn.groovy new file mode 100644 index 00000000000..9772948e4dd --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceMsgDoc_zh_cn.groovy @@ -0,0 +1,83 @@ +package org.zstack.header.storage.primary + +doc { + title "RegisterVmInstance" + + category "storage.primary" + + desc """注册虚拟机""" + + rest { + request { + url "POST /v1/vm-instances/register" + + header (Authorization: 'OAuth the-session-uuid') + + clz APIRegisterVmInstanceMsg.class + + desc """""" + + params { + + column { + name "primaryStorageUuid" + enclosedIn "params" + desc "主存储UUID" + location "body" + type "String" + optional false + since "4.10.0" + } + column { + name "clusterUuid" + enclosedIn "params" + desc "集群UUID" + location "body" + type "String" + optional false + since "4.10.0" + } + column { + name "hostUuid" + enclosedIn "params" + desc "物理机UUID" + location "body" + type "String" + optional true + since "4.10.0" + } + column { + name "metadataPath" + enclosedIn "params" + desc "" + location "body" + type "String" + optional false + since "4.10.0" + } + column { + name "systemTags" + enclosedIn "" + desc "系统标签" + location "body" + type "List" + optional true + since "4.10.0" + } + column { + name "userTags" + enclosedIn "" + desc "用户标签" + location "body" + type "List" + optional true + since "4.10.0" + } + } + } + + response { + clz APIRegisterVmInstanceReply.class + } + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceReply.java b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceReply.java new file mode 100644 index 00000000000..eb5c1fdda69 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/APIRegisterVmInstanceReply.java @@ -0,0 +1,103 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.allocator.HostAllocatorConstant; +import org.zstack.header.message.APIEvent; +import org.zstack.header.rest.RestResponse; +import org.zstack.header.vm.VmInstanceConstant; +import org.zstack.header.vm.VmInstanceInventory; +import org.zstack.header.vm.VmInstanceState; +import org.zstack.header.vm.VmNicInventory; +import org.zstack.header.volume.VolumeInventory; +import org.zstack.header.volume.VolumeState; +import org.zstack.header.volume.VolumeStatus; +import org.zstack.header.volume.VolumeType; +import org.zstack.utils.data.SizeUnit; + +import java.sql.Timestamp; + +import static java.util.Arrays.asList; + +@RestResponse(allTo = "inventory") +public class APIRegisterVmInstanceReply extends APIEvent { + private VmInstanceInventory inventory; + + public APIRegisterVmInstanceReply() { + } + + public APIRegisterVmInstanceReply(String apiId) { + super(apiId); + } + + public VmInstanceInventory getInventory() { + return inventory; + } + + public void setInventory(VmInstanceInventory inventory) { + this.inventory = inventory; + } + + public static APIRegisterVmInstanceReply __example__() { + APIRegisterVmInstanceReply event = new APIRegisterVmInstanceReply(); + + + String defaultL3Uuid = uuid(); + String rootVolumeUuid = uuid(); + + VmInstanceInventory vm = new VmInstanceInventory(); + vm.setName("Test-VM"); + vm.setUuid(uuid()); + vm.setAllocatorStrategy(HostAllocatorConstant.LAST_HOST_PREFERRED_ALLOCATOR_STRATEGY_TYPE); + vm.setClusterUuid(uuid()); + vm.setCpuNum(1); + vm.setCreateDate(new Timestamp(org.zstack.header.message.DocUtils.date)); + vm.setDefaultL3NetworkUuid(defaultL3Uuid); + vm.setDescription("web server VM"); + vm.setHostUuid(uuid()); + vm.setHypervisorType("KVM"); + vm.setImageUuid(uuid()); + vm.setInstanceOfferingUuid(uuid()); + vm.setLastHostUuid(uuid()); + vm.setMemorySize(SizeUnit.GIGABYTE.toByte(8)); + vm.setPlatform("Linux"); + vm.setRootVolumeUuid(rootVolumeUuid); + vm.setState(VmInstanceState.Stopped.toString()); + vm.setType(VmInstanceConstant.USER_VM_TYPE); + vm.setLastOpDate(new Timestamp(org.zstack.header.message.DocUtils.date)); + vm.setZoneUuid(uuid()); + + VolumeInventory vol = new VolumeInventory(); + vol.setName(String.format("Root-Volume-For-VM-%s", vm.getUuid())); + vol.setCreateDate(new Timestamp(org.zstack.header.message.DocUtils.date)); + vol.setLastOpDate(new Timestamp(org.zstack.header.message.DocUtils.date)); + vol.setType(VolumeType.Root.toString()); + vol.setUuid(rootVolumeUuid); + vol.setSize(SizeUnit.GIGABYTE.toByte(100)); + vol.setActualSize(SizeUnit.GIGABYTE.toByte(20)); + vol.setDeviceId(0); + vol.setState(VolumeState.Enabled.toString()); + vol.setFormat("qcow2"); + vol.setDiskOfferingUuid(uuid()); + vol.setInstallPath(String.format("/zstack_ps/rootVolumes/acct-36c27e8ff05c4780bf6d2fa65700f22e/vol-%s/%s.qcow2", rootVolumeUuid, rootVolumeUuid)); + vol.setStatus(VolumeStatus.Ready.toString()); + vol.setPrimaryStorageUuid(uuid()); + vol.setVmInstanceUuid(vm.getUuid()); + vol.setRootImageUuid(vm.getImageUuid()); + vm.setAllVolumes(asList(vol)); + + VmNicInventory nic = new VmNicInventory(); + nic.setVmInstanceUuid(vm.getUuid()); + nic.setCreateDate(vm.getCreateDate()); + nic.setLastOpDate(vm.getLastOpDate()); + nic.setDeviceId(0); + nic.setL3NetworkUuid(defaultL3Uuid); + nic.setMac("00:0c:29:bd:99:fc"); + nic.setHypervisorType("KVM"); + nic.setUuid(uuid()); + vm.setVmNics(asList(nic)); + + event.setInventory(vm); + + return event; + } + +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/CleanupVmInstanceMetadataOnPrimaryStorageMsg.java b/header/src/main/java/org/zstack/header/storage/primary/CleanupVmInstanceMetadataOnPrimaryStorageMsg.java new file mode 100644 index 00000000000..9b208366107 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/CleanupVmInstanceMetadataOnPrimaryStorageMsg.java @@ -0,0 +1,27 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.NeedReplyMessage; + +import java.util.List; + +public class CleanupVmInstanceMetadataOnPrimaryStorageMsg extends NeedReplyMessage implements PrimaryStorageMessage { + private String primaryStorageUuid; + private List vmUuids; + + @Override + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public List getVmUuids() { + return vmUuids; + } + + public void setVmUuids(List vmUuids) { + this.vmUuids = vmUuids; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/CleanupVmInstanceMetadataOnPrimaryStorageReply.java b/header/src/main/java/org/zstack/header/storage/primary/CleanupVmInstanceMetadataOnPrimaryStorageReply.java new file mode 100644 index 00000000000..9146991af1b --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/CleanupVmInstanceMetadataOnPrimaryStorageReply.java @@ -0,0 +1,36 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.MessageReply; + +import java.util.ArrayList; +import java.util.List; + +public class CleanupVmInstanceMetadataOnPrimaryStorageReply extends MessageReply { + private int totalCleaned; + private int totalFailed; + private List failedVmUuids = new ArrayList<>(); + + public int getTotalCleaned() { + return totalCleaned; + } + + public void setTotalCleaned(int totalCleaned) { + this.totalCleaned = totalCleaned; + } + + public int getTotalFailed() { + return totalFailed; + } + + public void setTotalFailed(int totalFailed) { + this.totalFailed = totalFailed; + } + + public List getFailedVmUuids() { + return failedVmUuids; + } + + public void setFailedVmUuids(List failedVmUuids) { + this.failedVmUuids = failedVmUuids; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/GetVmInstanceMetadataFromPrimaryStorageMsg.java b/header/src/main/java/org/zstack/header/storage/primary/GetVmInstanceMetadataFromPrimaryStorageMsg.java new file mode 100644 index 00000000000..d08098380d9 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/GetVmInstanceMetadataFromPrimaryStorageMsg.java @@ -0,0 +1,17 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.NeedReplyMessage; + + +public class GetVmInstanceMetadataFromPrimaryStorageMsg extends NeedReplyMessage implements PrimaryStorageMessage { + private String primaryStorageUuid; + + @Override + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/GetVmInstanceMetadataFromPrimaryStorageReply.java b/header/src/main/java/org/zstack/header/storage/primary/GetVmInstanceMetadataFromPrimaryStorageReply.java new file mode 100644 index 00000000000..cfa378a28e9 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/GetVmInstanceMetadataFromPrimaryStorageReply.java @@ -0,0 +1,20 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.MessageReply; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class GetVmInstanceMetadataFromPrimaryStorageReply extends MessageReply { + private List vmInstanceMetadata = new ArrayList<>(); + + public List getVmInstanceMetadata() { + return vmInstanceMetadata; + } + + public void setVmInstanceMetadata(List vmInstanceMetadata) { + this.vmInstanceMetadata = vmInstanceMetadata; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataMsg.java b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataMsg.java new file mode 100644 index 00000000000..ed09f15eb4d --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataMsg.java @@ -0,0 +1,21 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.NeedReplyMessage; +import org.zstack.header.vm.VmInstanceMessage; + +public class ReadVmInstanceMetadataMsg extends NeedReplyMessage implements VmInstanceMessage { + private String uuid; + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + @Override + public String getVmInstanceUuid() { + return uuid; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataOnHypervisorMsg.java b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataOnHypervisorMsg.java new file mode 100644 index 00000000000..d5d43cb36ea --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataOnHypervisorMsg.java @@ -0,0 +1,26 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.host.HostMessage; +import org.zstack.header.message.NeedReplyMessage; + +public class ReadVmInstanceMetadataOnHypervisorMsg extends NeedReplyMessage implements HostMessage { + private String hostUuid; + private String metadataPath; + + public void setHostUuid(String hostUuid) { + this.hostUuid = hostUuid; + } + + public String getMetadataPath() { + return metadataPath; + } + + public void setMetadataPath(String metadataPath) { + this.metadataPath = metadataPath; + } + + @Override + public String getHostUuid() { + return hostUuid; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataOnHypervisorReply.java b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataOnHypervisorReply.java new file mode 100644 index 00000000000..25044b944a6 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataOnHypervisorReply.java @@ -0,0 +1,15 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.MessageReply; + +public class ReadVmInstanceMetadataOnHypervisorReply extends MessageReply { + private String metadata; + + public String getMetadata() { + return metadata; + } + + public void setMetadata(String metadata) { + this.metadata = metadata; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataReply.java b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataReply.java new file mode 100644 index 00000000000..04462f849ad --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/ReadVmInstanceMetadataReply.java @@ -0,0 +1,15 @@ +package org.zstack.header.storage.primary; + +import org.zstack.header.message.MessageReply; + +public class ReadVmInstanceMetadataReply extends MessageReply { + private String vmMetadata; + + public String getVmMetadata() { + return vmMetadata; + } + + public void setVmMetadata(String vmMetadata) { + this.vmMetadata = vmMetadata; + } +} diff --git a/header/src/main/java/org/zstack/header/storage/primary/RegisterVmInstanceException.java b/header/src/main/java/org/zstack/header/storage/primary/RegisterVmInstanceException.java new file mode 100644 index 00000000000..cef54c1c4e5 --- /dev/null +++ b/header/src/main/java/org/zstack/header/storage/primary/RegisterVmInstanceException.java @@ -0,0 +1,9 @@ +package org.zstack.header.storage.primary; + +public interface RegisterVmInstanceException { + String updateVolumeInstallPath(String installPath); + + String updateVolumeSnapshotInstallPath(String installPath); + + PrimaryStorageType getPrimaryStorageType(); +} diff --git a/header/src/main/java/org/zstack/header/storage/snapshot/APIDeleteVolumeSnapshotMsg.java b/header/src/main/java/org/zstack/header/storage/snapshot/APIDeleteVolumeSnapshotMsg.java index 966a7d1030c..9a0b9664816 100755 --- a/header/src/main/java/org/zstack/header/storage/snapshot/APIDeleteVolumeSnapshotMsg.java +++ b/header/src/main/java/org/zstack/header/storage/snapshot/APIDeleteVolumeSnapshotMsg.java @@ -4,6 +4,7 @@ import org.zstack.header.message.*; import org.zstack.header.rest.APINoSee; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; import java.util.List; import java.util.concurrent.TimeUnit; @@ -44,6 +45,7 @@ responseClass = APIDeleteVolumeSnapshotEvent.class ) @DefaultTimeout(timeunit = TimeUnit.HOURS, value = 6) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIDeleteVolumeSnapshotMsg extends APIDeleteMessage implements DeleteVolumeSnapshotMessage { /** * @desc volume snapshot uuid diff --git a/header/src/main/java/org/zstack/header/storage/snapshot/APIRevertVolumeFromSnapshotMsg.java b/header/src/main/java/org/zstack/header/storage/snapshot/APIRevertVolumeFromSnapshotMsg.java index 744f13038b6..e3827f38bbe 100755 --- a/header/src/main/java/org/zstack/header/storage/snapshot/APIRevertVolumeFromSnapshotMsg.java +++ b/header/src/main/java/org/zstack/header/storage/snapshot/APIRevertVolumeFromSnapshotMsg.java @@ -8,6 +8,7 @@ import org.zstack.header.other.APIAuditor; import org.zstack.header.rest.APINoSee; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; import org.zstack.header.volume.VolumeVO; import java.util.concurrent.TimeUnit; @@ -46,6 +47,7 @@ responseClass = APIRevertVolumeFromSnapshotEvent.class ) @DefaultTimeout(timeunit = TimeUnit.HOURS, value = 24) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIRevertVolumeFromSnapshotMsg extends APIMessage implements RevertVolumeSnapshotMessage, APIAuditor { /** * @desc volume snapshot uuid diff --git a/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotTree.java b/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotTree.java index 57b4fab4099..cd6a33b4cd3 100755 --- a/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotTree.java +++ b/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotTree.java @@ -442,4 +442,24 @@ public SnapshotLeaf findSnapshot(String snapshotUuid) { return findSnapshot(arg -> arg.getUuid().equals(snapshotUuid)); } + + public List levelOrderTraversal() { + List result = new ArrayList<>(); + if (this.root == null) { + return result; + } + + Queue queue = new LinkedList<>(); + queue.offer(this.root); + + while (!queue.isEmpty()) { + SnapshotLeaf currentLeaf = queue.poll(); + result.add(currentLeaf.getInventory()); + for (SnapshotLeaf child : currentLeaf.getChildren()) { + queue.offer(child); + } + } + + return result; + } } diff --git a/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotVO_.java b/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotVO_.java index 2d5abaf4f7f..8aeb5873f6b 100755 --- a/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotVO_.java +++ b/header/src/main/java/org/zstack/header/storage/snapshot/VolumeSnapshotVO_.java @@ -1,9 +1,7 @@ package org.zstack.header.storage.snapshot; -/** - */ - import javax.persistence.metamodel.StaticMetamodel; +import java.sql.Timestamp; @StaticMetamodel(VolumeSnapshotVO.class) public class VolumeSnapshotVO_ extends VolumeSnapshotAO_ { diff --git a/header/src/main/java/org/zstack/header/storage/snapshot/group/APIDeleteVolumeSnapshotGroupMsg.java b/header/src/main/java/org/zstack/header/storage/snapshot/group/APIDeleteVolumeSnapshotGroupMsg.java index 4afc5170734..eec7f5cb291 100644 --- a/header/src/main/java/org/zstack/header/storage/snapshot/group/APIDeleteVolumeSnapshotGroupMsg.java +++ b/header/src/main/java/org/zstack/header/storage/snapshot/group/APIDeleteVolumeSnapshotGroupMsg.java @@ -7,6 +7,7 @@ import org.zstack.header.rest.APINoSee; import org.zstack.header.rest.RestRequest; import org.zstack.header.storage.snapshot.SnapshotBackendOperation; +import org.zstack.header.vm.MetadataImpact; import java.util.concurrent.TimeUnit; @@ -19,6 +20,7 @@ responseClass = APIDeleteVolumeSnapshotGroupEvent.class ) @DefaultTimeout(timeunit = TimeUnit.HOURS, value = 3) +@MetadataImpact(value = MetadataImpact.Impact.STORAGE, updateOnFailure = true) public class APIDeleteVolumeSnapshotGroupMsg extends APIDeleteMessage implements VolumeSnapshotGroupMessage { @APIParam(resourceType = VolumeSnapshotGroupVO.class, successIfResourceNotExisting = true) private String uuid; diff --git a/header/src/main/java/org/zstack/header/tag/APICreateSystemTagMsg.java b/header/src/main/java/org/zstack/header/tag/APICreateSystemTagMsg.java index a02a6001cf1..91744da003f 100755 --- a/header/src/main/java/org/zstack/header/tag/APICreateSystemTagMsg.java +++ b/header/src/main/java/org/zstack/header/tag/APICreateSystemTagMsg.java @@ -2,6 +2,7 @@ import org.springframework.http.HttpMethod; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; /** */ @@ -11,6 +12,7 @@ responseClass = APICreateSystemTagEvent.class, parameterName = "params" ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APICreateSystemTagMsg extends APIAbstractCreateTagMsg { public static APICreateSystemTagMsg __example__() { diff --git a/header/src/main/java/org/zstack/header/tag/APIDeleteTagMsg.java b/header/src/main/java/org/zstack/header/tag/APIDeleteTagMsg.java index b1159b20945..c06abceb3aa 100755 --- a/header/src/main/java/org/zstack/header/tag/APIDeleteTagMsg.java +++ b/header/src/main/java/org/zstack/header/tag/APIDeleteTagMsg.java @@ -4,6 +4,7 @@ import org.zstack.header.message.APIDeleteMessage; import org.zstack.header.message.APIParam; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; /** */ @@ -12,6 +13,7 @@ method = HttpMethod.DELETE, responseClass = APIDeleteTagEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIDeleteTagMsg extends APIDeleteMessage { @APIParam private String uuid; diff --git a/header/src/main/java/org/zstack/header/tag/APIUpdateSystemTagMsg.java b/header/src/main/java/org/zstack/header/tag/APIUpdateSystemTagMsg.java index 2962ce07061..51c17f3b9aa 100755 --- a/header/src/main/java/org/zstack/header/tag/APIUpdateSystemTagMsg.java +++ b/header/src/main/java/org/zstack/header/tag/APIUpdateSystemTagMsg.java @@ -4,6 +4,7 @@ import org.zstack.header.message.APIMessage; import org.zstack.header.message.APIParam; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; /** * Created by frank on 8/17/2015. @@ -14,6 +15,7 @@ isAction = true, method = HttpMethod.PUT ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIUpdateSystemTagMsg extends APIMessage { @APIParam(resourceType = SystemTagVO.class) private String uuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIAttachVmNicToVmMsg.java b/header/src/main/java/org/zstack/header/vm/APIAttachVmNicToVmMsg.java index 05b86a2bedf..fafffe79f34 100644 --- a/header/src/main/java/org/zstack/header/vm/APIAttachVmNicToVmMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIAttachVmNicToVmMsg.java @@ -11,6 +11,7 @@ responseClass = APIAttachVmNicToVmEvent.class, parameterName = "params" ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIAttachVmNicToVmMsg extends APIMessage implements VmInstanceMessage { @APIParam(resourceType = VmNicVO.class) 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 60ded8149d2..ae9c67d5d6e 100644 --- a/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIChangeVmNicNetworkMsg.java @@ -16,6 +16,7 @@ method = HttpMethod.POST, responseClass = APIChangeVmNicNetworkEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIChangeVmNicNetworkMsg extends APIMessage implements VmInstanceMessage{ @APIParam(resourceType = VmNicVO.class) private String vmNicUuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIChangeVmNicStateMsg.java b/header/src/main/java/org/zstack/header/vm/APIChangeVmNicStateMsg.java index 6c1594fed23..320b34050e9 100644 --- a/header/src/main/java/org/zstack/header/vm/APIChangeVmNicStateMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIChangeVmNicStateMsg.java @@ -22,6 +22,7 @@ responseClass = APIChangeVmNicStateEvent.class, isAction = true ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIChangeVmNicStateMsg extends APIMessage implements VmInstanceMessage, APIMultiAuditor { @APIParam(resourceType = VmNicVO.class) private String vmNicUuid; diff --git a/header/src/main/java/org/zstack/header/vm/APICheckVmInstanceMetadataConsistencyMsg.java b/header/src/main/java/org/zstack/header/vm/APICheckVmInstanceMetadataConsistencyMsg.java new file mode 100644 index 00000000000..df4da4eee67 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APICheckVmInstanceMetadataConsistencyMsg.java @@ -0,0 +1,56 @@ +package org.zstack.header.vm; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIParam; +import org.zstack.header.message.APISyncCallMessage; +import org.zstack.header.rest.RestRequest; +import org.zstack.header.storage.primary.PrimaryStorageVO; + +import java.util.List; + +@RestRequest( + path = "/vm-instances/metadata/consistency-check", + method = HttpMethod.PUT, + responseClass = APICheckVmInstanceMetadataConsistencyReply.class, + isAction = true +) +public class APICheckVmInstanceMetadataConsistencyMsg extends APISyncCallMessage { + @APIParam(required = false, resourceType = VmInstanceVO.class) + private List vmUuids; + + @APIParam(required = false, resourceType = PrimaryStorageVO.class) + private String primaryStorageUuid; + + @APIParam(required = false) + private Boolean autoRepair; + + public List getVmUuids() { + return vmUuids; + } + + public void setVmUuids(List vmUuids) { + this.vmUuids = vmUuids; + } + + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public Boolean getAutoRepair() { + return autoRepair; + } + + public void setAutoRepair(Boolean autoRepair) { + this.autoRepair = autoRepair; + } + + public static APICheckVmInstanceMetadataConsistencyMsg __example__() { + APICheckVmInstanceMetadataConsistencyMsg msg = new APICheckVmInstanceMetadataConsistencyMsg(); + msg.autoRepair = false; + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APICheckVmInstanceMetadataConsistencyReply.java b/header/src/main/java/org/zstack/header/vm/APICheckVmInstanceMetadataConsistencyReply.java new file mode 100644 index 00000000000..61ac6cf55bf --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APICheckVmInstanceMetadataConsistencyReply.java @@ -0,0 +1,31 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIReply; +import org.zstack.header.rest.RestResponse; + +import java.util.Collections; +import java.util.List; + +@RestResponse(fieldsTo = {"all"}) +public class APICheckVmInstanceMetadataConsistencyReply extends APIReply { + private List results; + + public List getResults() { + return results; + } + + public void setResults(List results) { + this.results = results; + } + + public static APICheckVmInstanceMetadataConsistencyReply __example__() { + APICheckVmInstanceMetadataConsistencyReply reply = new APICheckVmInstanceMetadataConsistencyReply(); + ConsistencyCheckResult result = new ConsistencyCheckResult(); + result.setVmUuid(uuid()); + result.setConsistent(true); + result.setDiffs(Collections.emptyList()); + result.setAction("NONE"); + reply.results = Collections.singletonList(result); + return reply; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APICleanupVmInstanceMetadataEvent.java b/header/src/main/java/org/zstack/header/vm/APICleanupVmInstanceMetadataEvent.java new file mode 100644 index 00000000000..60a441a626d --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APICleanupVmInstanceMetadataEvent.java @@ -0,0 +1,53 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIEvent; +import org.zstack.header.rest.RestResponse; + +import java.util.List; + +@RestResponse(fieldsTo = {"all"}) +public class APICleanupVmInstanceMetadataEvent extends APIEvent { + private Integer totalCleaned; + private Integer totalFailed; + private List failedVmUuids; + + public APICleanupVmInstanceMetadataEvent() { + super(null); + } + + public APICleanupVmInstanceMetadataEvent(String apiId) { + super(apiId); + } + + public Integer getTotalCleaned() { + return totalCleaned; + } + + public void setTotalCleaned(Integer totalCleaned) { + this.totalCleaned = totalCleaned; + } + + public Integer getTotalFailed() { + return totalFailed; + } + + public void setTotalFailed(Integer totalFailed) { + this.totalFailed = totalFailed; + } + + public List getFailedVmUuids() { + return failedVmUuids; + } + + public void setFailedVmUuids(List failedVmUuids) { + this.failedVmUuids = failedVmUuids; + } + + public static APICleanupVmInstanceMetadataEvent __example__() { + APICleanupVmInstanceMetadataEvent evt = new APICleanupVmInstanceMetadataEvent(); + evt.totalCleaned = 5; + evt.totalFailed = 0; + evt.failedVmUuids = java.util.Collections.emptyList(); + return evt; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APICleanupVmInstanceMetadataMsg.java b/header/src/main/java/org/zstack/header/vm/APICleanupVmInstanceMetadataMsg.java new file mode 100644 index 00000000000..a8003a15428 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APICleanupVmInstanceMetadataMsg.java @@ -0,0 +1,44 @@ +package org.zstack.header.vm; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIMessage; +import org.zstack.header.message.APIParam; +import org.zstack.header.rest.RestRequest; +import org.zstack.header.storage.primary.PrimaryStorageVO; + +import java.util.List; + +@RestRequest( + path = "/vm-instances/metadata/cleanup", + method = HttpMethod.PUT, + responseClass = APICleanupVmInstanceMetadataEvent.class, + isAction = true +) +public class APICleanupVmInstanceMetadataMsg extends APIMessage { + @APIParam(required = false, resourceType = PrimaryStorageVO.class) + private List primaryStorageUuids; + + @APIParam(required = false, resourceType = VmInstanceVO.class) + private List vmUuids; + + public List getPrimaryStorageUuids() { + return primaryStorageUuids; + } + + public void setPrimaryStorageUuids(List primaryStorageUuids) { + this.primaryStorageUuids = primaryStorageUuids; + } + + public List getVmUuids() { + return vmUuids; + } + + public void setVmUuids(List vmUuids) { + this.vmUuids = vmUuids; + } + + public static APICleanupVmInstanceMetadataMsg __example__() { + APICleanupVmInstanceMetadataMsg msg = new APICleanupVmInstanceMetadataMsg(); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIConvertTemplatedVmInstanceToVmInstanceMsg.java b/header/src/main/java/org/zstack/header/vm/APIConvertTemplatedVmInstanceToVmInstanceMsg.java index 1a128bfaf84..d091c517a45 100644 --- a/header/src/main/java/org/zstack/header/vm/APIConvertTemplatedVmInstanceToVmInstanceMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIConvertTemplatedVmInstanceToVmInstanceMsg.java @@ -14,6 +14,7 @@ responseClass = APIConvertTemplatedVmInstanceToVmInstanceEvent.class, parameterName = "params" ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIConvertTemplatedVmInstanceToVmInstanceMsg extends APIMessage implements VmInstanceMessage, APIAuditor { @APIParam(resourceType = TemplatedVmInstanceVO.class) private String templatedVmInstanceUuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIConvertVmInstanceToTemplatedVmInstanceMsg.java b/header/src/main/java/org/zstack/header/vm/APIConvertVmInstanceToTemplatedVmInstanceMsg.java index 2b9191824b7..62fba128eb1 100644 --- a/header/src/main/java/org/zstack/header/vm/APIConvertVmInstanceToTemplatedVmInstanceMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIConvertVmInstanceToTemplatedVmInstanceMsg.java @@ -13,6 +13,7 @@ responseClass = APIConvertVmInstanceToTemplatedVmInstanceEvent.class, parameterName = "params" ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIConvertVmInstanceToTemplatedVmInstanceMsg extends APIMessage implements VmInstanceMessage, APIAuditor { @APIParam(resourceType = VmInstanceVO.class) private String vmInstanceUuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIDeleteVmBootModeMsg.java b/header/src/main/java/org/zstack/header/vm/APIDeleteVmBootModeMsg.java index a2b73a69527..2edf2e39a84 100644 --- a/header/src/main/java/org/zstack/header/vm/APIDeleteVmBootModeMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIDeleteVmBootModeMsg.java @@ -10,6 +10,7 @@ method = HttpMethod.DELETE, responseClass = APIDeleteVmBootModeEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIDeleteVmBootModeMsg extends APIDeleteMessage implements VmInstanceMessage { @APIParam(resourceType = VmInstanceVO.class) private String uuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIDeleteVmHostnameMsg.java b/header/src/main/java/org/zstack/header/vm/APIDeleteVmHostnameMsg.java index 611c1c13da4..9cc7d73425d 100755 --- a/header/src/main/java/org/zstack/header/vm/APIDeleteVmHostnameMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIDeleteVmHostnameMsg.java @@ -13,6 +13,7 @@ method = HttpMethod.DELETE, responseClass = APIDeleteVmHostnameEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIDeleteVmHostnameMsg extends APIDeleteMessage implements VmInstanceMessage { @APIParam(resourceType = VmInstanceVO.class) private String uuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIDeleteVmNicMsg.java b/header/src/main/java/org/zstack/header/vm/APIDeleteVmNicMsg.java index 19fc5f5e8bc..9c2af9e25c6 100644 --- a/header/src/main/java/org/zstack/header/vm/APIDeleteVmNicMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIDeleteVmNicMsg.java @@ -10,6 +10,7 @@ method = HttpMethod.DELETE, responseClass = APIDeleteVmNicEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIDeleteVmNicMsg extends APIDeleteMessage { @APIParam(resourceType = VmNicVO.class, successIfResourceNotExisting = true) diff --git a/header/src/main/java/org/zstack/header/vm/APIDeleteVmSshKeyMsg.java b/header/src/main/java/org/zstack/header/vm/APIDeleteVmSshKeyMsg.java index 0372c7526ab..b71ab8e3d7a 100755 --- a/header/src/main/java/org/zstack/header/vm/APIDeleteVmSshKeyMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIDeleteVmSshKeyMsg.java @@ -12,6 +12,7 @@ method = HttpMethod.DELETE, responseClass = APIDeleteVmSshKeyEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIDeleteVmSshKeyMsg extends APIMessage implements VmInstanceMessage { private String uuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIPreCheckVmMetadataRegistrationMsg.java b/header/src/main/java/org/zstack/header/vm/APIPreCheckVmMetadataRegistrationMsg.java new file mode 100644 index 00000000000..68912504843 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIPreCheckVmMetadataRegistrationMsg.java @@ -0,0 +1,79 @@ +package org.zstack.header.vm; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIParam; +import org.zstack.header.message.APISyncCallMessage; +import org.zstack.header.rest.RestRequest; +import org.zstack.header.cluster.ClusterVO; +import org.zstack.header.storage.primary.PrimaryStorageVO; +import org.zstack.header.zone.ZoneVO; + +@RestRequest( + path = "/vm-instances/metadata/pre-check", + method = HttpMethod.PUT, + responseClass = APIPreCheckVmMetadataRegistrationReply.class, + isAction = true +) +public class APIPreCheckVmMetadataRegistrationMsg extends APISyncCallMessage { + @APIParam + private String metadataContent; + + @APIParam(resourceType = PrimaryStorageVO.class) + private String targetPrimaryStorageUuid; + + @APIParam(required = false, resourceType = ZoneVO.class) + private String zoneUuid; + + @APIParam(required = false, resourceType = ClusterVO.class) + private String clusterUuid; + + @APIParam(required = false) + private Boolean forceVersionMismatch; + + public String getMetadataContent() { + return metadataContent; + } + + public void setMetadataContent(String metadataContent) { + this.metadataContent = metadataContent; + } + + public String getTargetPrimaryStorageUuid() { + return targetPrimaryStorageUuid; + } + + public void setTargetPrimaryStorageUuid(String targetPrimaryStorageUuid) { + this.targetPrimaryStorageUuid = targetPrimaryStorageUuid; + } + + public String getZoneUuid() { + return zoneUuid; + } + + public void setZoneUuid(String zoneUuid) { + this.zoneUuid = zoneUuid; + } + + public String getClusterUuid() { + return clusterUuid; + } + + public void setClusterUuid(String clusterUuid) { + this.clusterUuid = clusterUuid; + } + + public Boolean getForceVersionMismatch() { + return forceVersionMismatch; + } + + public void setForceVersionMismatch(Boolean forceVersionMismatch) { + this.forceVersionMismatch = forceVersionMismatch; + } + + public static APIPreCheckVmMetadataRegistrationMsg __example__() { + APIPreCheckVmMetadataRegistrationMsg msg = new APIPreCheckVmMetadataRegistrationMsg(); + msg.metadataContent = "{\"schemaVersion\":\"1.0\",\"vmUuid\":\"...\"}"; + msg.targetPrimaryStorageUuid = uuid(); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIPreCheckVmMetadataRegistrationReply.java b/header/src/main/java/org/zstack/header/vm/APIPreCheckVmMetadataRegistrationReply.java new file mode 100644 index 00000000000..6a0e7efdb3d --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIPreCheckVmMetadataRegistrationReply.java @@ -0,0 +1,30 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIReply; +import org.zstack.header.rest.RestResponse; + +import java.util.Collections; +import java.util.List; + +@RestResponse(fieldsTo = {"all"}) +public class APIPreCheckVmMetadataRegistrationReply extends APIReply { + private List checkResults; + + public List getCheckResults() { + return checkResults; + } + + public void setCheckResults(List checkResults) { + this.checkResults = checkResults; + } + + public static APIPreCheckVmMetadataRegistrationReply __example__() { + APIPreCheckVmMetadataRegistrationReply reply = new APIPreCheckVmMetadataRegistrationReply(); + PreCheckItem item = new PreCheckItem(); + item.setName("schema_version_check"); + item.setPassed(true); + item.setMessage("Schema version 1.0 is supported"); + reply.checkResults = Collections.singletonList(item); + return reply; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIReadVmInstanceMetadataMsg.java b/header/src/main/java/org/zstack/header/vm/APIReadVmInstanceMetadataMsg.java new file mode 100644 index 00000000000..db0ba2966cd --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIReadVmInstanceMetadataMsg.java @@ -0,0 +1,48 @@ +package org.zstack.header.vm; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIParam; +import org.zstack.header.message.APISyncCallMessage; +import org.zstack.header.rest.RestRequest; +import org.zstack.header.storage.primary.PrimaryStorageVO; + +@RestRequest( + path = "/vm-instances/{vmUuid}/metadata", + method = HttpMethod.GET, + responseClass = APIReadVmInstanceMetadataReply.class +) +public class APIReadVmInstanceMetadataMsg extends APISyncCallMessage implements VmInstanceMessage { + @APIParam(resourceType = VmInstanceVO.class) + private String vmUuid; + + @APIParam(resourceType = PrimaryStorageVO.class) + private String primaryStorageUuid; + + public String getVmUuid() { + return vmUuid; + } + + public void setVmUuid(String vmUuid) { + this.vmUuid = vmUuid; + } + + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + @Override + public String getVmInstanceUuid() { + return vmUuid; + } + + public static APIReadVmInstanceMetadataMsg __example__() { + APIReadVmInstanceMetadataMsg msg = new APIReadVmInstanceMetadataMsg(); + msg.vmUuid = uuid(); + msg.primaryStorageUuid = uuid(); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIReadVmInstanceMetadataReply.java b/header/src/main/java/org/zstack/header/vm/APIReadVmInstanceMetadataReply.java new file mode 100644 index 00000000000..64726c7a8e6 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIReadVmInstanceMetadataReply.java @@ -0,0 +1,65 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIReply; +import org.zstack.header.rest.RestResponse; + +import java.util.List; + +@RestResponse(fieldsTo = {"all"}) +public class APIReadVmInstanceMetadataReply extends APIReply { + private String metadataContent; + private String schemaVersion; + private String readStatus; + private String repairAction; + private List warnings; + + public String getMetadataContent() { + return metadataContent; + } + + public void setMetadataContent(String metadataContent) { + this.metadataContent = metadataContent; + } + + public String getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + } + + public String getReadStatus() { + return readStatus; + } + + public void setReadStatus(String readStatus) { + this.readStatus = readStatus; + } + + public String getRepairAction() { + return repairAction; + } + + public void setRepairAction(String repairAction) { + this.repairAction = repairAction; + } + + public List getWarnings() { + return warnings; + } + + public void setWarnings(List warnings) { + this.warnings = warnings; + } + + public static APIReadVmInstanceMetadataReply __example__() { + APIReadVmInstanceMetadataReply reply = new APIReadVmInstanceMetadataReply(); + reply.metadataContent = "{\"schemaVersion\":\"1.0\",\"vmUuid\":\"...\"}"; + reply.schemaVersion = "1.0"; + reply.readStatus = "OK"; + reply.repairAction = "NONE"; + reply.warnings = java.util.Collections.emptyList(); + return reply; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIRecoverVmInstanceMsg.java b/header/src/main/java/org/zstack/header/vm/APIRecoverVmInstanceMsg.java index 7de84b5dccd..58bb578a0f6 100755 --- a/header/src/main/java/org/zstack/header/vm/APIRecoverVmInstanceMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIRecoverVmInstanceMsg.java @@ -14,6 +14,7 @@ method = HttpMethod.PUT, responseClass = APIRecoverVmInstanceEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIRecoverVmInstanceMsg extends APIMessage implements VmInstanceMessage { @APIParam(resourceType = VmInstanceVO.class) private String uuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIRegisterVmInstanceFromMetadataEvent.java b/header/src/main/java/org/zstack/header/vm/APIRegisterVmInstanceFromMetadataEvent.java new file mode 100644 index 00000000000..db685ce85f2 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIRegisterVmInstanceFromMetadataEvent.java @@ -0,0 +1,45 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIEvent; +import org.zstack.header.rest.RestResponse; + +import java.util.List; + +@RestResponse(allTo = "inventory") +public class APIRegisterVmInstanceFromMetadataEvent extends APIEvent { + private VmInstanceInventory inventory; + private List warnings; + + public APIRegisterVmInstanceFromMetadataEvent() { + super(null); + } + + public APIRegisterVmInstanceFromMetadataEvent(String apiId) { + super(apiId); + } + + public VmInstanceInventory getInventory() { + return inventory; + } + + public void setInventory(VmInstanceInventory inventory) { + this.inventory = inventory; + } + + public List getWarnings() { + return warnings; + } + + public void setWarnings(List warnings) { + this.warnings = warnings; + } + + public static APIRegisterVmInstanceFromMetadataEvent __example__() { + APIRegisterVmInstanceFromMetadataEvent evt = new APIRegisterVmInstanceFromMetadataEvent(); + VmInstanceInventory vm = new VmInstanceInventory(); + vm.setUuid(uuid()); + vm.setName("recovered-vm"); + evt.setInventory(vm); + return evt; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIRegisterVmInstanceFromMetadataMsg.java b/header/src/main/java/org/zstack/header/vm/APIRegisterVmInstanceFromMetadataMsg.java new file mode 100644 index 00000000000..11e3a8a0db5 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIRegisterVmInstanceFromMetadataMsg.java @@ -0,0 +1,88 @@ +package org.zstack.header.vm; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APICreateMessage; +import org.zstack.header.message.APIParam; +import org.zstack.header.message.DefaultTimeout; +import org.zstack.header.rest.RestRequest; +import org.zstack.header.storage.primary.PrimaryStorageVO; +import org.zstack.header.zone.ZoneVO; +import org.zstack.header.cluster.ClusterVO; +import org.zstack.header.tag.TagResourceType; + +import java.util.concurrent.TimeUnit; + +@TagResourceType(VmInstanceVO.class) +@RestRequest( + path = "/vm-instances/metadata/register", + method = HttpMethod.POST, + responseClass = APIRegisterVmInstanceFromMetadataEvent.class, + parameterName = "params" +) +@DefaultTimeout(timeunit = TimeUnit.HOURS, value = 3) +public class APIRegisterVmInstanceFromMetadataMsg extends APICreateMessage { + @APIParam + private String metadataContent; + + @APIParam(resourceType = PrimaryStorageVO.class) + private String targetPrimaryStorageUuid; + + @APIParam(resourceType = ZoneVO.class) + private String zoneUuid; + + @APIParam(resourceType = ClusterVO.class) + private String clusterUuid; + + @APIParam(required = false) + private Boolean forceVersionMismatch; + + public String getMetadataContent() { + return metadataContent; + } + + public void setMetadataContent(String metadataContent) { + this.metadataContent = metadataContent; + } + + public String getTargetPrimaryStorageUuid() { + return targetPrimaryStorageUuid; + } + + public void setTargetPrimaryStorageUuid(String targetPrimaryStorageUuid) { + this.targetPrimaryStorageUuid = targetPrimaryStorageUuid; + } + + public String getZoneUuid() { + return zoneUuid; + } + + public void setZoneUuid(String zoneUuid) { + this.zoneUuid = zoneUuid; + } + + public String getClusterUuid() { + return clusterUuid; + } + + public void setClusterUuid(String clusterUuid) { + this.clusterUuid = clusterUuid; + } + + public Boolean getForceVersionMismatch() { + return forceVersionMismatch; + } + + public void setForceVersionMismatch(Boolean forceVersionMismatch) { + this.forceVersionMismatch = forceVersionMismatch; + } + + public static APIRegisterVmInstanceFromMetadataMsg __example__() { + APIRegisterVmInstanceFromMetadataMsg msg = new APIRegisterVmInstanceFromMetadataMsg(); + msg.metadataContent = "{\"schemaVersion\":\"1.0\",\"vmUuid\":\"...\"}"; + msg.targetPrimaryStorageUuid = uuid(); + msg.zoneUuid = uuid(); + msg.clusterUuid = uuid(); + msg.forceVersionMismatch = false; + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceEvent.java b/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceEvent.java old mode 100755 new mode 100644 diff --git a/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceEventDoc_zh_cn.groovy b/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceEventDoc_zh_cn.groovy old mode 100755 new mode 100644 diff --git a/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceMsg.java b/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceMsg.java index 53ad2c26f4f..e570c3e4914 100755 --- a/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIReimageVmInstanceMsg.java @@ -15,6 +15,7 @@ responseClass = APIReimageVmInstanceEvent.class, category = "vmInstance" ) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIReimageVmInstanceMsg extends APIMessage implements VmInstanceMessage { public String getVmInstanceUuid() { return vmInstanceUuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIScanVmInstanceMetadataMsg.java b/header/src/main/java/org/zstack/header/vm/APIScanVmInstanceMetadataMsg.java new file mode 100644 index 00000000000..e9e3cb26c33 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIScanVmInstanceMetadataMsg.java @@ -0,0 +1,45 @@ +package org.zstack.header.vm; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIParam; +import org.zstack.header.message.APISyncCallMessage; +import org.zstack.header.rest.RestRequest; +import org.zstack.header.storage.primary.PrimaryStorageVO; + +import java.util.List; + +@RestRequest( + path = "/vm-instances/metadata/scan", + method = HttpMethod.GET, + responseClass = APIScanVmInstanceMetadataReply.class +) +public class APIScanVmInstanceMetadataMsg extends APISyncCallMessage { + @APIParam(required = false, resourceType = PrimaryStorageVO.class) + private List primaryStorageUuids; + + @APIParam(required = false, resourceType = VmInstanceVO.class) + private List vmUuids; + + public List getPrimaryStorageUuids() { + return primaryStorageUuids; + } + + public void setPrimaryStorageUuids(List primaryStorageUuids) { + this.primaryStorageUuids = primaryStorageUuids; + } + + public List getVmUuids() { + return vmUuids; + } + + public void setVmUuids(List vmUuids) { + this.vmUuids = vmUuids; + } + + public static APIScanVmInstanceMetadataMsg __example__() { + APIScanVmInstanceMetadataMsg msg = new APIScanVmInstanceMetadataMsg(); + msg.primaryStorageUuids = null; + msg.vmUuids = null; + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIScanVmInstanceMetadataReply.java b/header/src/main/java/org/zstack/header/vm/APIScanVmInstanceMetadataReply.java new file mode 100644 index 00000000000..060d706c6c3 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIScanVmInstanceMetadataReply.java @@ -0,0 +1,35 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIReply; +import org.zstack.header.rest.RestResponse; + +import java.util.List; + +@RestResponse(fieldsTo = {"all"}) +public class APIScanVmInstanceMetadataReply extends APIReply { + private List metadataList; + + public List getMetadataList() { + return metadataList; + } + + public void setMetadataList(List metadataList) { + this.metadataList = metadataList; + } + + public static APIScanVmInstanceMetadataReply __example__() { + APIScanVmInstanceMetadataReply reply = new APIScanVmInstanceMetadataReply(); + VmMetadataScanResult result = new VmMetadataScanResult(); + result.setVmUuid(uuid()); + result.setVmName("test-vm"); + result.setVmCategory("UserVm"); + result.setPrimaryStorageUuid(uuid()); + result.setPrimaryStorageType("SharedBlock"); + result.setSchemaVersion("1.0"); + result.setLastUpdateTime(System.currentTimeMillis()); + result.setMetadataPath("/dev/zvmdata-xxxxx/vm-metadata-xxxxx"); + result.setSizeBytes(4096L); + reply.metadataList = java.util.Collections.singletonList(result); + return reply; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APISetVmBootOrderMsg.java b/header/src/main/java/org/zstack/header/vm/APISetVmBootOrderMsg.java index fb3d980e807..0adf42f96d3 100755 --- a/header/src/main/java/org/zstack/header/vm/APISetVmBootOrderMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APISetVmBootOrderMsg.java @@ -18,6 +18,7 @@ method = HttpMethod.PUT, responseClass = APISetVmBootOrderEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APISetVmBootOrderMsg extends APIMessage implements VmInstanceMessage { @APIParam(resourceType = VmInstanceVO.class) private String uuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIUpdateVmInstanceMsg.java b/header/src/main/java/org/zstack/header/vm/APIUpdateVmInstanceMsg.java index 60e9343ff38..d4b79951246 100755 --- a/header/src/main/java/org/zstack/header/vm/APIUpdateVmInstanceMsg.java +++ b/header/src/main/java/org/zstack/header/vm/APIUpdateVmInstanceMsg.java @@ -15,6 +15,7 @@ isAction = true, responseClass = APIUpdateVmInstanceEvent.class ) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIUpdateVmInstanceMsg extends APIMessage implements VmInstanceMessage { @APIParam(resourceType = VmInstanceVO.class) private String uuid; diff --git a/header/src/main/java/org/zstack/header/vm/APIUpdateVmMetadataEvent.java b/header/src/main/java/org/zstack/header/vm/APIUpdateVmMetadataEvent.java new file mode 100644 index 00000000000..9eb4e932014 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIUpdateVmMetadataEvent.java @@ -0,0 +1,19 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIEvent; +import org.zstack.header.rest.RestResponse; + +@RestResponse(fieldsTo = {"all"}) +public class APIUpdateVmMetadataEvent extends APIEvent { + public APIUpdateVmMetadataEvent() { + super(null); + } + + public APIUpdateVmMetadataEvent(String apiId) { + super(apiId); + } + + public static APIUpdateVmMetadataEvent __example__() { + return new APIUpdateVmMetadataEvent(); + } +} diff --git a/header/src/main/java/org/zstack/header/vm/APIUpdateVmMetadataMsg.java b/header/src/main/java/org/zstack/header/vm/APIUpdateVmMetadataMsg.java new file mode 100644 index 00000000000..c04a6a59d23 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/APIUpdateVmMetadataMsg.java @@ -0,0 +1,36 @@ +package org.zstack.header.vm; + +import org.springframework.http.HttpMethod; +import org.zstack.header.message.APIMessage; +import org.zstack.header.message.APIParam; +import org.zstack.header.rest.RestRequest; + +@RestRequest( + path = "/vm-instances/{vmUuid}/metadata/actions", + method = HttpMethod.PUT, + responseClass = APIUpdateVmMetadataEvent.class, + isAction = true +) +public class APIUpdateVmMetadataMsg extends APIMessage implements VmInstanceMessage { + @APIParam(resourceType = VmInstanceVO.class) + private String vmUuid; + + public String getVmUuid() { + return vmUuid; + } + + public void setVmUuid(String vmUuid) { + this.vmUuid = vmUuid; + } + + @Override + public String getVmInstanceUuid() { + return vmUuid; + } + + public static APIUpdateVmMetadataMsg __example__() { + APIUpdateVmMetadataMsg msg = new APIUpdateVmMetadataMsg(); + msg.vmUuid = uuid(); + return msg; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/BatchCheckMetadataStatusMsg.java b/header/src/main/java/org/zstack/header/vm/BatchCheckMetadataStatusMsg.java new file mode 100644 index 00000000000..66c88fb97e4 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/BatchCheckMetadataStatusMsg.java @@ -0,0 +1,39 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.NeedReplyMessage; + +import java.util.List; + +/** + * 批量检查多个 VM 元数据 Header 状态的内部消息(健康巡检)。 + * + *

由管理平面发送给主存储 handler,Agent 端仅读取 Header(不读 Slot), + * 返回每个 VM 的 readStatus 和 PendingOp 信息。

+ * + *

路由:{@code makeLocalServiceId} → 主存储 handler → Agent HTTP 调用

+ * + * @see BatchCheckMetadataStatusReply + * @see MetadataStatusResult + */ +public class BatchCheckMetadataStatusMsg extends NeedReplyMessage { + + private String primaryStorageUuid; + + private List vmUuids; + + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public List getVmUuids() { + return vmUuids; + } + + public void setVmUuids(List vmUuids) { + this.vmUuids = vmUuids; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/BatchCheckMetadataStatusReply.java b/header/src/main/java/org/zstack/header/vm/BatchCheckMetadataStatusReply.java new file mode 100644 index 00000000000..24fea84c40d --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/BatchCheckMetadataStatusReply.java @@ -0,0 +1,26 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.MessageReply; + +import java.util.Map; + +/** + * {@link BatchCheckMetadataStatusMsg} 的回复。 + * + * @see MetadataStatusResult + */ +public class BatchCheckMetadataStatusReply extends MessageReply { + + /** + * key = vmUuid, value = 该 VM 的元数据状态结果。 + */ + private Map results; + + public Map getResults() { + return results; + } + + public void setResults(Map results) { + this.results = results; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/ConsistencyCheckResult.java b/header/src/main/java/org/zstack/header/vm/ConsistencyCheckResult.java new file mode 100644 index 00000000000..fb00307bbe5 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/ConsistencyCheckResult.java @@ -0,0 +1,43 @@ +package org.zstack.header.vm; + +import java.io.Serializable; +import java.util.List; + +public class ConsistencyCheckResult implements Serializable { + private String vmUuid; + private boolean consistent; + private List diffs; + private String action; + + public String getVmUuid() { + return vmUuid; + } + + public void setVmUuid(String vmUuid) { + this.vmUuid = vmUuid; + } + + public boolean isConsistent() { + return consistent; + } + + public void setConsistent(boolean consistent) { + this.consistent = consistent; + } + + public List getDiffs() { + return diffs; + } + + public void setDiffs(List diffs) { + this.diffs = diffs; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/DiffEntry.java b/header/src/main/java/org/zstack/header/vm/DiffEntry.java new file mode 100644 index 00000000000..c1b5e3cbdcd --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/DiffEntry.java @@ -0,0 +1,33 @@ +package org.zstack.header.vm; + +import java.io.Serializable; + +public class DiffEntry implements Serializable { + private String field; + private String expected; + private String actual; + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + + public String getExpected() { + return expected; + } + + public void setExpected(String expected) { + this.expected = expected; + } + + public String getActual() { + return actual; + } + + public void setActual(String actual) { + this.actual = actual; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/MetadataImpact.java b/header/src/main/java/org/zstack/header/vm/MetadataImpact.java new file mode 100644 index 00000000000..8c982b7687f --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/MetadataImpact.java @@ -0,0 +1,71 @@ +package org.zstack.header.vm; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标注 API 消息对虚拟机元数据的影响类型。 + * + *

opt-out 策略

+ *

不标注时默认行为等同于 {@link Impact#CONFIG}。 + * 明确不影响元数据的 API 应标注 {@link Impact#NONE}。

+ * + *

vmUuid 解析

+ *

不涉及 VM 的 API(如 APICreateZoneMsg)即使默认 CONFIG, + * 也不会触发元数据更新——因为 {@link VmUuidFromApiResolver} 无法解析出 vmUuid, + * 不会产生 {@link UpdateVmInstanceMetadataMsg}。

+ * + * @see VmUuidFromApiResolver + * @see UpdateVmInstanceMetadataMsg + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MetadataImpact { + + /** + * 影响类型。 + */ + Impact value(); + + /** + * API 失败时是否也需要更新元数据。 + * + *

默认 false:仅在 API 成功后触发元数据更新。 + * 设为 true 时,API 执行失败也会触发 markDirty。 + * 适用于 API 可能部分成功、需要同步最新状态的场景。

+ */ + boolean updateOnFailure() default false; + + /** + * API 对虚拟机元数据的影响类型枚举。 + */ + enum Impact { + /** + * 不影响虚拟机元数据,明确跳过。 + * + *

用于标注与 VM 无关或虽关联 VM 但不影响元数据内容的 API, + * 如 APIQueryVmInstanceMsg、APIGetVmConsoleAddressMsg 等。

+ */ + NONE, + + /** + * 影响虚拟机配置,触发元数据更新。 + * + *

如修改 CPU/内存、增删 SystemTag/ResourceConfig 等。 + * 这是未标注 {@link MetadataImpact} 注解时的默认行为。

+ */ + CONFIG, + + /** + * 影响存储结构,触发元数据更新。 + * + *

如存储迁移、快照操作、删除云盘等涉及存储结构变更的 API。 + * 在 sblk 场景下会设置 pending_op=2 以标记存储结构变更。

+ */ + STORAGE + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/vm/MetadataStatusResult.java b/header/src/main/java/org/zstack/header/vm/MetadataStatusResult.java new file mode 100644 index 00000000000..c26c684ac5b --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/MetadataStatusResult.java @@ -0,0 +1,65 @@ +package org.zstack.header.vm; + +import java.io.Serializable; + +/** + * 单个 VM 的元数据 Header 状态结果(用于健康巡检)。 + * + * @see BatchCheckMetadataStatusReply + */ +public class MetadataStatusResult implements Serializable { + + /** + * 读取状态:OK / NEED_REPAIR / RECOVERED / DEGRADED / + * STORAGE_CHANGE_INCOMPLETE / CORRUPTED + */ + private String readStatus; + + /** + * 可为 null。NEED_REPAIR/RECOVERED 时提示的修复动作 + * (如 "complete_phase3" / "rebuild_header" / "full_refresh")。 + */ + private String repairAction; + + /** + * 最后更新时间戳(epoch ms)。 + */ + private Long lastUpdateTime; + + /** + * 当前 PendingOp 值(0/1/2)。 + */ + private Integer pendingOp; + + public String getReadStatus() { + return readStatus; + } + + public void setReadStatus(String readStatus) { + this.readStatus = readStatus; + } + + public String getRepairAction() { + return repairAction; + } + + public void setRepairAction(String repairAction) { + this.repairAction = repairAction; + } + + public Long getLastUpdateTime() { + return lastUpdateTime; + } + + public void setLastUpdateTime(Long lastUpdateTime) { + this.lastUpdateTime = lastUpdateTime; + } + + public Integer getPendingOp() { + return pendingOp; + } + + public void setPendingOp(Integer pendingOp) { + this.pendingOp = pendingOp; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/MetadataStorageHandler.java b/header/src/main/java/org/zstack/header/vm/MetadataStorageHandler.java new file mode 100644 index 00000000000..4f59e965716 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/MetadataStorageHandler.java @@ -0,0 +1,177 @@ +package org.zstack.header.vm; + +import org.zstack.header.core.Completion; +import org.zstack.header.core.ReturnValueCompletion; + +import java.util.List; + +/** + * 元数据存储处理器接口 — 抽象不同存储类型的元数据读写操作。 + * + *

设计背景(Part 01c §1.3)

+ *

VM 元数据需要持久化到 VM 根盘所在的 Primary Storage 上。不同存储类型 + * (SharedBlock、Local/NFS)使用不同的存储格式和协议,本接口统一抽象 + * 这些差异,使上层逻辑(Poller、迁移流程、注册 API)无需关心底层实现。

+ * + *

Handler 动态路由(SM-07)

+ *

所有方法通过 {@code psUuid} 参数动态路由 — 每次调用时根据 + * {@code PrimaryStorageVO.type} 查找对应 Handler 实现。支持同一迁移流程中 + * 源/目标使用不同 Handler。例如 VM 从 SharedBlock 迁移到 NFS 时, + * {@code initializeMetadata(targetPsUuid)} 路由到 {@code LocalNfsMetadataStorageHandler}, + * {@code deleteMetadata(sourcePsUuid)} 路由到 {@code SblkMetadataStorageHandler}。

+ * + *

实现类

+ * + * + * + * + *
实现类存储类型
{@code SblkMetadataStorageHandler}SharedBlock
{@code LocalNfsMetadataStorageHandler}Local/NFS
+ * + *

设计约束

+ *
    + *
  • Agent 端不解析 DTO 内容;控制面负责 DTO 构建、序列化和反序列化
  • + *
  • C-01C-9: {@code deleteMetadata} 必须幂等 — 删除不存在的元数据必须返回成功
  • + *
  • C-01C-11: 必须包含 {@code scanMetadataVmUuids()} 方法
  • + *
+ * + * @see VmMetadataDirtyVO + * @see MetadataImpact + */ +public interface MetadataStorageHandler { + + /** + * 初始化 VM 元数据容器并写入完整 payload。 + * + *

VM 创建或存储迁移 Step 4 时调用。对于 sblk,创建 LV 并写入 Header + Slot A; + * 对于 local/NFS,创建 {@code .zstack-vm-metadata/} 目录(若不存在)并 + * 通过 tmp+fsync+rename 原子写入 JSON 文件。

+ * + *

若容器已存在则覆盖写入(幂等)。

+ * + * @param psUuid Primary Storage UUID — 用于路由到正确的存储后端 + * @param vmUuid VM UUID — 确定元数据文件/LV 名称 + * @param payloadJson 完整的元数据 JSON payload(由 {@code VmMetadataBuilder} 构建) + * @param completion 异步回调 + */ + void initializeMetadata(String psUuid, String vmUuid, String payloadJson, Completion completion); + + /** + * 删除 VM 元数据。 + * + *

ExpungeVm 或存储迁移 Step 7 源端清理时调用。

+ * + *

C-01C-9 幂等约束:删除不存在的元数据(LV 已删除或 JSON 文件不存在) + * 必须返回成功(不抛异常)。同时清理同名的 {@code .tmp} 和 {@code .sc.tmp} + * 残留文件(如存在),删除 tmp 失败不影响主操作成功。

+ * + * @param psUuid Primary Storage UUID + * @param vmUuid VM UUID + * @param completion 异步回调 + */ + void deleteMetadata(String psUuid, String vmUuid, Completion completion); + + /** + * 写入/更新 VM 元数据(原子操作)。 + * + *

Poller flush 时调用。sblk 使用三阶段原子写入;local/NFS 使用 + * tmp+fsync+rename 原子写入。

+ * + *

{@code storageStructureChange} 参数用于区分 tmp 文件后缀: + * {@code true} 使用 {@code .sc.tmp}(存储迁移写入), + * {@code false} 使用 {@code .tmp}(普通写入)。 + * 注册时若检测到 {@code .sc.tmp} 残留,说明存储迁移写入未完成, + * 该元数据文件标记为不可靠。

+ * + * @param psUuid Primary Storage UUID + * @param vmUuid VM UUID + * @param payloadJson 完整的元数据 JSON payload + * @param storageStructureChange 是否为存储结构变更写入(影响 tmp 文件后缀和 sblk OP type) + * @param completion 异步回调 + */ + void writeMetadata(String psUuid, String vmUuid, String payloadJson, + boolean storageStructureChange, Completion completion); + + /** + * 读取 VM 元数据。 + * + *

存储迁移 Step 5 read-back 校验、Scan/Read API 时调用。

+ * + *

返回值说明:

+ *
    + *
  • 成功读取 → {@code success(payloadJson)}
  • + *
  • 文件不存在 → {@code success(null)}
  • + *
  • 读取失败或内容损坏 → {@code fail(errorCode)}
  • + *
+ * + * @param psUuid Primary Storage UUID + * @param vmUuid VM UUID + * @param completion 异步回调,返回 JSON payload 字符串(可为 null 表示不存在) + */ + void readMetadata(String psUuid, String vmUuid, ReturnValueCompletion completion); + + /** + * 判断给定的 Primary Storage 类型是否支持元数据。 + * + *

当前支持:SharedBlock、LocalStorage、NFS。 + * 不支持的类型(ceph、zbs、vhost 等)返回 false,上层静默跳过。

+ * + * @param psType Primary Storage 类型字符串(如 "SharedBlock"、"LocalStorage"、"NFS") + * @return true 如果该存储类型支持元数据 + */ + boolean isMetadataSupported(String psType); + + /** + * 扫描指定 PS 上所有元数据条目,返回 {@link VmMetadataEntry} 列表(轻量级,不读取 payload)。 + * + *

扫描方式因存储类型而异:

+ *
    + *
  • sblk: 扫描 VG 中所有 {@code *_vmmeta} LV,提取 vmUuid 前缀
  • + *
  • local/NFS: 列举 {@code .zstack-vm-metadata/} 目录下 {@code *.json} 文件名
  • + *
+ * + *

用途:{@code MetadataOrphanDetector}(Part 2b §8.4.2)、Scan API(Part 5 §2)

+ * + *

返回类型说明(讨论 Δ-7):原方案返回 {@code List}(纯 vmUuid), + * 改为返回 {@code List}。Local Storage 场景下扫描需要逐 Host 执行, + * 调用方需要知道元数据位于哪台 Host 上以便后续操作(如孤儿清理、注册时路由)。

+ * + * @param psUuid Primary Storage UUID + * @param completion 异步回调,返回元数据条目列表 + */ + void scanMetadataVmUuids(String psUuid, ReturnValueCompletion> completion); + + /** + * 元数据扫描结果条目。 + * + *

包含 vmUuid 和可选的 hostUuid 信息。对于共享存储(SharedBlock/NFS), + * hostUuid 为 null;对于 Local Storage,hostUuid 标识元数据文件所在 Host。

+ */ + class VmMetadataEntry { + private String vmUuid; + private String hostUuid; // nullable: SharedBlock/NFS 场景为 null + + public VmMetadataEntry() { + } + + public VmMetadataEntry(String vmUuid, String hostUuid) { + this.vmUuid = vmUuid; + this.hostUuid = hostUuid; + } + + public String getVmUuid() { + return vmUuid; + } + + public void setVmUuid(String vmUuid) { + this.vmUuid = vmUuid; + } + + public String getHostUuid() { + return hostUuid; + } + + public void setHostUuid(String hostUuid) { + this.hostUuid = hostUuid; + } + } +} diff --git a/header/src/main/java/org/zstack/header/vm/PreCheckItem.java b/header/src/main/java/org/zstack/header/vm/PreCheckItem.java new file mode 100644 index 00000000000..49dc2239b2d --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/PreCheckItem.java @@ -0,0 +1,33 @@ +package org.zstack.header.vm; + +import java.io.Serializable; + +public class PreCheckItem implements Serializable { + private String name; + private boolean passed; + private String message; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isPassed() { + return passed; + } + + public void setPassed(boolean passed) { + this.passed = passed; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/RegisterVmFromMetadataInnerMsg.java b/header/src/main/java/org/zstack/header/vm/RegisterVmFromMetadataInnerMsg.java new file mode 100644 index 00000000000..9b7bfabe21a --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/RegisterVmFromMetadataInnerMsg.java @@ -0,0 +1,64 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.NeedReplyMessage; + +/** + * Internal message for LongJob integration of VM registration from metadata. + * Mirrors fields from APIRegisterVmInstanceFromMetadataMsg. + */ +public class RegisterVmFromMetadataInnerMsg extends NeedReplyMessage { + private String metadataContent; + private String targetPrimaryStorageUuid; + private String zoneUuid; + private String clusterUuid; + private Boolean forceVersionMismatch; + private String accountUuid; + + public String getMetadataContent() { + return metadataContent; + } + + public void setMetadataContent(String metadataContent) { + this.metadataContent = metadataContent; + } + + public String getTargetPrimaryStorageUuid() { + return targetPrimaryStorageUuid; + } + + public void setTargetPrimaryStorageUuid(String targetPrimaryStorageUuid) { + this.targetPrimaryStorageUuid = targetPrimaryStorageUuid; + } + + public String getZoneUuid() { + return zoneUuid; + } + + public void setZoneUuid(String zoneUuid) { + this.zoneUuid = zoneUuid; + } + + public String getClusterUuid() { + return clusterUuid; + } + + public void setClusterUuid(String clusterUuid) { + this.clusterUuid = clusterUuid; + } + + public Boolean getForceVersionMismatch() { + return forceVersionMismatch; + } + + public void setForceVersionMismatch(Boolean forceVersionMismatch) { + this.forceVersionMismatch = forceVersionMismatch; + } + + public String getAccountUuid() { + return accountUuid; + } + + public void setAccountUuid(String accountUuid) { + this.accountUuid = accountUuid; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/RegisterVmFromMetadataInnerReply.java b/header/src/main/java/org/zstack/header/vm/RegisterVmFromMetadataInnerReply.java new file mode 100644 index 00000000000..8353aa880e2 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/RegisterVmFromMetadataInnerReply.java @@ -0,0 +1,26 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.MessageReply; + +import java.util.List; + +public class RegisterVmFromMetadataInnerReply extends MessageReply { + private VmInstanceInventory inventory; + private List warnings; + + public VmInstanceInventory getInventory() { + return inventory; + } + + public void setInventory(VmInstanceInventory inventory) { + this.inventory = inventory; + } + + public List getWarnings() { + return warnings; + } + + public void setWarnings(List warnings) { + this.warnings = warnings; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/RepairMetadataMsg.java b/header/src/main/java/org/zstack/header/vm/RepairMetadataMsg.java new file mode 100644 index 00000000000..212aa76c088 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/RepairMetadataMsg.java @@ -0,0 +1,52 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.NeedReplyMessage; + +/** + * 修复 sblk 元数据 Header 的内部消息。 + * + *

由管理平面发送给主存储 handler,用于完成未完成的 Phase 3、 + * 清除 PendingOp、重建 Header 或触发全量刷写。

+ * + *

路由:{@code makeLocalServiceId} → 主存储 handler → Agent HTTP 调用

+ * + * @see BatchCheckMetadataStatusMsg + */ +public class RepairMetadataMsg extends NeedReplyMessage { + + private String vmUuid; + + private String primaryStorageUuid; + + /** + * 修复动作。 + * + *

可选值:{@code complete_phase3} / {@code clear_pending_op} / + * {@code rebuild_header} / {@code full_refresh}

+ */ + private String repairAction; + + public String getVmUuid() { + return vmUuid; + } + + public void setVmUuid(String vmUuid) { + this.vmUuid = vmUuid; + } + + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public String getRepairAction() { + return repairAction; + } + + public void setRepairAction(String repairAction) { + this.repairAction = repairAction; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/RepairMetadataReply.java b/header/src/main/java/org/zstack/header/vm/RepairMetadataReply.java new file mode 100644 index 00000000000..16445a19fe6 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/RepairMetadataReply.java @@ -0,0 +1,11 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.MessageReply; + +/** + * {@link RepairMetadataMsg} 的回复。 + * + *

成功/失败通过 {@link MessageReply} 基类的 ErrorCode 传递。

+ */ +public class RepairMetadataReply extends MessageReply { +} diff --git a/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataMsg.java b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataMsg.java new file mode 100644 index 00000000000..e918ad6b075 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataMsg.java @@ -0,0 +1,43 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.NeedReplyMessage; + +/** + * 更新虚拟机元数据消息(MN 内部)。 + * + *

调用链第 1 步:由 API 完成后的拦截器发出,路由到 VM 所在的 MN 节点。 + * 接收方从 DB 构建 {@link VmInstanceMetadataDTO},编码后发送 + * {@link UpdateVmInstanceMetadataOnPrimaryStorageMsg}。

+ * + * @see UpdateVmInstanceMetadataOnPrimaryStorageMsg + * @see UpdateVmInstanceMetadataOnHypervisorMsg + */ +public class UpdateVmInstanceMetadataMsg extends NeedReplyMessage implements VmInstanceMessage { + + private String vmInstanceUuid; + + /** + * 是否涉及存储结构变更。 + * + *

对应 {@link MetadataImpact.Impact#STORAGE} 类型的操作。 + * sblk 场景下会设置 pending_op=2。

+ */ + private boolean storageStructureChange; + + @Override + public String getVmInstanceUuid() { + return vmInstanceUuid; + } + + public void setVmInstanceUuid(String vmInstanceUuid) { + this.vmInstanceUuid = vmInstanceUuid; + } + + public boolean isStorageStructureChange() { + return storageStructureChange; + } + + public void setStorageStructureChange(boolean storageStructureChange) { + this.storageStructureChange = storageStructureChange; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnHypervisorMsg.java b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnHypervisorMsg.java new file mode 100644 index 00000000000..61e0ebde900 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnHypervisorMsg.java @@ -0,0 +1,86 @@ +package org.zstack.header.vm; + +import org.zstack.header.host.HostMessage; +import org.zstack.header.message.NeedReplyMessage; + +/** + * 在 Hypervisor 上更新虚拟机元数据消息。 + * + *

调用链第 3 步(可选):发送到 Host Agent 执行实际的存储写入。

+ * + *

使用场景

+ *
    + *
  • sblk:需要通过 Host Agent 操作 LV(activate → write → deactivate)
  • + *
  • local:数据在本地磁盘,需要通过 Host Agent 写入
  • + *
  • NFS:通常通过 PS Agent 直接操作,不使用此消息
  • + *
+ * + * @see UpdateVmInstanceMetadataMsg + * @see UpdateVmInstanceMetadataOnPrimaryStorageMsg + */ +public class UpdateVmInstanceMetadataOnHypervisorMsg extends NeedReplyMessage implements HostMessage { + + private String hostUuid; + private String vmInstanceUuid; + + /** + * 元数据文件在存储上的路径。 + * + *
    + *
  • sblk:LV 设备路径,如 /dev/{vg_uuid}/{vm_uuid}_vmmeta
  • + *
  • local:本地文件路径,如 /path/to/vm/vm_metadata.json
  • + *
+ */ + private String metadataPath; + + /** + * 元数据 JSON 字符串。 + */ + private String metadata; + + /** + * 是否涉及存储结构变更(sblk 场景设置 pending_op=2)。 + */ + private boolean storageStructureChange; + + @Override + public String getHostUuid() { + return hostUuid; + } + + public void setHostUuid(String hostUuid) { + this.hostUuid = hostUuid; + } + + public String getVmInstanceUuid() { + return vmInstanceUuid; + } + + public void setVmInstanceUuid(String vmInstanceUuid) { + this.vmInstanceUuid = vmInstanceUuid; + } + + public String getMetadataPath() { + return metadataPath; + } + + public void setMetadataPath(String metadataPath) { + this.metadataPath = metadataPath; + } + + public String getMetadata() { + return metadata; + } + + public void setMetadata(String metadata) { + this.metadata = metadata; + } + + public boolean isStorageStructureChange() { + return storageStructureChange; + } + + public void setStorageStructureChange(boolean storageStructureChange) { + this.storageStructureChange = storageStructureChange; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnHypervisorReply.java b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnHypervisorReply.java new file mode 100644 index 00000000000..036403b01b2 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnHypervisorReply.java @@ -0,0 +1,9 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.MessageReply; + +/** + * {@link UpdateVmInstanceMetadataOnHypervisorMsg} 的回复。 + */ +public class UpdateVmInstanceMetadataOnHypervisorReply extends MessageReply { +} diff --git a/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnPrimaryStorageMsg.java b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnPrimaryStorageMsg.java new file mode 100644 index 00000000000..41ba6d9b254 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnPrimaryStorageMsg.java @@ -0,0 +1,84 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.NeedReplyMessage; +import org.zstack.header.storage.primary.PrimaryStorageMessage; + +/** + * 在主存储上更新虚拟机元数据消息。 + * + *

调用链第 2 步:发送到主存储服务,由主存储根据自身类型决定写入方式: + *

    + *
  • sblk/local:进一步发送 {@link UpdateVmInstanceMetadataOnHypervisorMsg} 到 Host Agent
  • + *
  • NFS:直接通过 PS Agent 写入
  • + *
+ * + * @see UpdateVmInstanceMetadataMsg + * @see UpdateVmInstanceMetadataOnHypervisorMsg + */ +public class UpdateVmInstanceMetadataOnPrimaryStorageMsg extends NeedReplyMessage implements PrimaryStorageMessage { + + private String primaryStorageUuid; + private String vmInstanceUuid; + + /** + * 根盘 UUID,用于 PS handler 定位元数据写入路径。 + * + *

LocalStorage 通过根盘 installPath 推导元数据文件路径; + * NFS 通过根盘关联的 Host 确定转发目标。

+ */ + private String rootVolumeUuid; + + /** + * 元数据 JSON 字符串。 + * + *

由 {@code VmInstanceBase.buildVmInstanceMetadata()} 从 DB 全量构建, + * 为 {@link VmInstanceMetadataDTO} 的 JSON 序列化结果。

+ */ + private String metadata; + + /** + * 是否涉及存储结构变更(sblk 场景设置 pending_op=2)。 + */ + private boolean storageStructureChange; + + @Override + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public String getVmInstanceUuid() { + return vmInstanceUuid; + } + + public void setVmInstanceUuid(String vmInstanceUuid) { + this.vmInstanceUuid = vmInstanceUuid; + } + + public String getRootVolumeUuid() { + return rootVolumeUuid; + } + + public void setRootVolumeUuid(String rootVolumeUuid) { + this.rootVolumeUuid = rootVolumeUuid; + } + + public String getMetadata() { + return metadata; + } + + public void setMetadata(String metadata) { + this.metadata = metadata; + } + + public boolean isStorageStructureChange() { + return storageStructureChange; + } + + public void setStorageStructureChange(boolean storageStructureChange) { + this.storageStructureChange = storageStructureChange; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnPrimaryStorageReply.java b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnPrimaryStorageReply.java new file mode 100644 index 00000000000..475855f2b67 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataOnPrimaryStorageReply.java @@ -0,0 +1,9 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.MessageReply; + +/** + * {@link UpdateVmInstanceMetadataOnPrimaryStorageMsg} 的回复。 + */ +public class UpdateVmInstanceMetadataOnPrimaryStorageReply extends MessageReply { +} diff --git a/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataReply.java b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataReply.java new file mode 100644 index 00000000000..61a2d4dbd1b --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/UpdateVmInstanceMetadataReply.java @@ -0,0 +1,9 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.MessageReply; + +/** + * {@link UpdateVmInstanceMetadataMsg} 的回复。 + */ +public class UpdateVmInstanceMetadataReply extends MessageReply { +} 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 9d0efdd77f1..a2716386957 100755 --- a/header/src/main/java/org/zstack/header/vm/VmInstanceConstant.java +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceConstant.java @@ -96,4 +96,6 @@ enum Capability { String VM_CDROM_OCCUPANT_ISO = "ISO"; String VM_CDROM_OCCUPANT_GUEST_TOOLS = "GuestTools"; + + String VM_META_SUFFIX = "_meta"; } diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataCodec.java b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataCodec.java new file mode 100644 index 00000000000..2153f9247cb --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataCodec.java @@ -0,0 +1,96 @@ +package org.zstack.header.vm; + +import org.zstack.utils.gson.JSONObjectUtil; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * 虚拟机元数据编解码器。 + * + *

负责 {@link VmInstanceMetadataDTO} 与存储介质之间的编解码: + *

+ *   序列化流程:DTO → JSON String → Base64 String → byte[](写入存储)
+ *   反序列化流程:byte[](读取存储) → Base64 String → JSON String → DTO
+ * 
+ * + *

单层 Base64 编码策略:DTO 内部所有字段为明文 JSON, + * 仅在写入存储时做一次 Base64 编码。

+ */ +public class VmInstanceMetadataCodec { + + private VmInstanceMetadataCodec() { + } + + /** + * 将 DTO 编码为可写入存储的字节数组。 + * + * @param dto 元数据 DTO + * @return Base64 编码后的字节数组 + */ + public static byte[] encode(VmInstanceMetadataDTO dto) { + String json = JSONObjectUtil.toJsonString(dto); + return Base64.getEncoder().encode(json.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 将 DTO 编码为 Base64 字符串。 + * + * @param dto 元数据 DTO + * @return Base64 编码后的字符串 + */ + public static String encodeToString(VmInstanceMetadataDTO dto) { + String json = JSONObjectUtil.toJsonString(dto); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 从存储读取的字节数组解码为 DTO。 + * + * @param data Base64 编码的字节数组 + * @return 元数据 DTO + * @throws IllegalArgumentException 如果 Base64 解码失败或 JSON 格式错误 + */ + public static VmInstanceMetadataDTO decode(byte[] data) { + byte[] jsonBytes = Base64.getDecoder().decode(data); + String json = new String(jsonBytes, StandardCharsets.UTF_8); + return JSONObjectUtil.toObject(json, VmInstanceMetadataDTO.class); + } + + /** + * 从 Base64 字符串解码为 DTO。 + * + * @param base64 Base64 编码的字符串 + * @return 元数据 DTO + * @throws IllegalArgumentException 如果 Base64 解码失败或 JSON 格式错误 + */ + public static VmInstanceMetadataDTO decodeFromString(String base64) { + byte[] jsonBytes = Base64.getDecoder().decode(base64); + String json = new String(jsonBytes, StandardCharsets.UTF_8); + return JSONObjectUtil.toObject(json, VmInstanceMetadataDTO.class); + } + + /** + * 将 DTO 序列化为 JSON 字符串(不做 Base64 编码)。 + * + *

用于调试、日志、一致性检查等场景。

+ * + * @param dto 元数据 DTO + * @return JSON 字符串 + */ + public static String toJson(VmInstanceMetadataDTO dto) { + return JSONObjectUtil.toJsonString(dto); + } + + /** + * 从 JSON 字符串反序列化为 DTO(不做 Base64 解码)。 + * + *

用于调试、测试等场景。

+ * + * @param json JSON 字符串 + * @return 元数据 DTO + */ + public static VmInstanceMetadataDTO fromJson(String json) { + return JSONObjectUtil.toObject(json, VmInstanceMetadataDTO.class); + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataConstants.java b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataConstants.java new file mode 100644 index 00000000000..ec9b5a231ee --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataConstants.java @@ -0,0 +1,82 @@ +package org.zstack.header.vm; + +/** + * 虚拟机元数据相关常量。 + */ +public class VmInstanceMetadataConstants { + + private VmInstanceMetadataConstants() { + } + + /** + * 元数据 LV 后缀(sblk 场景)。 + * + *

LV 命名规则:{vm_uuid}_vmmeta

+ */ + public static final String SBLK_LV_SUFFIX = "_vmmeta"; + + /** + * 元数据文件名(local/NFS 场景)。 + * + *

文件位于与根盘同目录下。

+ */ + public static final String METADATA_FILE_NAME = "vm_metadata.json"; + + /** + * sblk 元数据 LV 默认初始大小(字节):4MB。 + */ + public static final long SBLK_LV_INITIAL_SIZE = 4L * 1024 * 1024; + + /** + * sblk 元数据 LV 最大大小(字节):64MB。 + */ + public static final long SBLK_LV_MAX_SIZE = 64L * 1024 * 1024; + + /** + * sblk 写入序列号最大值。溢出后回绕到 1。 + */ + public static final long MAX_WRITE_SEQUENCE = 0xFFFFFFFFFFFFFFFFL; + + /** + * 全局配置:是否启用虚拟机元数据记录。 + * + *

默认关闭。开启后,API 操作成功时自动触发元数据更新。

+ */ + public static final String GLOBAL_CONFIG_METADATA_ENABLED = "vm.metadata.enabled"; + + /** + * GC 初始延迟秒数。 + * + *

API 成功后延迟该秒数再触发元数据更新, + * 避免短时间内多次 API 操作产生过多无用更新。

+ */ + public static final int INITIAL_GC_DELAY_SECONDS = 5; + + /** + * 注册虚拟机 MN 标识 System Tag 前缀。 + * + *

注册过程中在 VM 上打标记,记录执行注册的 MN UUID, + * 用于 MN 崩溃后的事务回滚判断。

+ */ + public static final String REGISTERING_MN_TAG_PREFIX = "vmMetadata::registeringMnUuid::"; + + /** + * VM 状态:注册中。 + * + *

注册开始时 VM 进入此中间状态,注册完成后转为 Stopped。

+ */ + public static final String VM_STATE_REGISTERING = "Registering"; + + /** + * ChainTask 最大排队任务数。 + * + *

同一 VM 的元数据更新 ChainTask 最多排队 1 个, + * 超出的通过 exceedMaxPendingCallback 立即 Done。

+ */ + public static final int MAX_PENDING_METADATA_TASKS = 1; + + /** + * ChainTask syncSignature 前缀。 + */ + public static final String CHAIN_TASK_SIGNATURE_PREFIX = "vm-metadata-update-"; +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataDTO.java b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataDTO.java new file mode 100644 index 00000000000..d0a3ee2db8f --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataDTO.java @@ -0,0 +1,141 @@ +package org.zstack.header.vm; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +/** + * 虚拟机元数据 DTO。 + * + *

存储在主存储上的元数据文件内容就是该 DTO 的 JSON 字符串经 Base64 编码后的结果。

+ * + *

编码策略

+ *

DTO 内部所有字段均为明文 JSON。由存储写入层对整个 DTO 的 JSON 字符串做一次统一 + * Base64 编码后写入存储介质(sblk Slot Payload / local NFS 文件内容)。

+ * + *

Checksum

+ *

Checksum 不作为 DTO 字段,由存储层保证: + *

    + *
  • sblk: Slot 结构自带 Checksum 字段
  • + *
  • local/NFS: tmp + rename 原子写入保证完整性
  • + *
+ */ +public class VmInstanceMetadataDTO { + + /** + * 资源元数据子结构。 + * + *

对于每种资源(VM、Volume、Nic),记录其 VO 全量 JSON 及关联的 SystemTag/ResourceConfig。

+ */ + public static class ResourceMetadata { + /** + * 资源 UUID。 + * + *

冗余字段,反序列化时必须校验与 {@link #vo} 内部的 uuid 字段一致。

+ */ + @SerializedName("resourceUuid") + public String resourceUuid; + + /** + * VO 全量 JSON 明文。 + * + *
    + *
  • {@link VmInstanceMetadataDTO#vm} → VmInstanceVO JSON
  • + *
  • {@link VmInstanceMetadataDTO#volumes} 元素 → VolumeVO JSON
  • + *
  • {@link VmInstanceMetadataDTO#nics} 元素 → VmNicVO JSON
  • + *
+ * + *

序列化时由 Gson 自动处理嵌套 JSON 的转义;反序列化时需要二次反序列化为具体 VO 类。

+ */ + @SerializedName("vo") + public String vo; + + /** + * SystemTag 列表的 Base64 编码。 + * + *

构建过程:SystemTagVO 列表 → 逐个 JSON 序列化 → 组成 JSON Array 字符串 → Base64 编码。 + * Base64 编码是为了保护可能包含的密码、密钥等敏感信息。

+ */ + @SerializedName("systemTags") + public String systemTags; + + /** + * ResourceConfig 列表的 Base64 编码。 + * + *

构建过程与 systemTags 一致。

+ */ + @SerializedName("resourceConfigs") + public String resourceConfigs; + } + + /** + * 元数据 schema 版本,与 ZStack 数据库版本(zsv)一致,如 "5.0.0"。 + * + *

序列化时自动填充当前平台版本。注册时若版本不匹配则拒绝注册。 + * 升级后通过全量更新 GC 将所有 VM 的元数据刷新到新版本。

+ */ + @SerializedName("schemaVersion") + public String schemaVersion; + + /** + * 虚拟机分类。 + * + *

标识本元数据所属 VM 的分类(普通 / 模板 / 模板缓存), + * 注册恢复时按不同分类执行不同的恢复逻辑。

+ */ + @SerializedName("vmCategory") + public VmMetadataCategory vmCategory; + + /** + * 虚拟机自身的元数据。 + * + *

{@link ResourceMetadata#vo} 为 VmInstanceVO 的 JSON。

+ */ + @SerializedName("vm") + public ResourceMetadata vm; + + /** + * 云盘元数据列表。 + * + *

包含根盘与数据盘(挂载的 + 已卸载但 lastVmInstanceUuid 指向本 VM 的)。 + * 不包含共享盘(isShareable=true 的 Volume 被排除)。 + * {@link VolumeResourceMetadata#vo} 为 VolumeVO 的 JSON, + * 每个 Volume 的快照引用数据内嵌在 {@link VolumeResourceMetadata} 中。

+ */ + @SerializedName("volumes") + public List volumes; + + /** + * 网卡元数据列表。 + * + *

仅记录,注册时不恢复。{@link ResourceMetadata#vo} 为 VmNicVO 的 JSON。

+ */ + @SerializedName("nics") + public List nics; + + /** + * 快照数据(扁平列表)。 + * + *

所有 Volume 下的 VolumeSnapshotVO JSON 明文的扁平列表, + * 按 BFS 拓扑序排列(父快照在子快照之前)。

+ */ + @SerializedName("snapshots") + public List snapshots; + + /** + * 快照组列表。 + * + *

每个元素是 VolumeSnapshotGroupVO 的 JSON 明文。

+ */ + @SerializedName("snapshotGroups") + public List snapshotGroups; + + /** + * 快照组关联引用列表。 + * + *

每个元素是 VolumeSnapshotGroupRefVO 的 JSON 明文。 + * 通过 {@code volumeSnapshotGroupUuid} 字段与 {@link #snapshotGroups} 关联。

+ */ + @SerializedName("snapshotGroupRefs") + public List snapshotGroupRefs; +} diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataRegistrationSpec.java b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataRegistrationSpec.java new file mode 100644 index 00000000000..a3ea3981759 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataRegistrationSpec.java @@ -0,0 +1,92 @@ +package org.zstack.header.vm; + +/** + * 虚拟机元数据注册参数。 + * + *

封装从元数据注册虚拟机时需要的新环境上下文信息。

+ * + *

字段处理矩阵中标记为"API 参数"或"替换"的字段,其新值来源于此对象。

+ */ +public class VmInstanceMetadataRegistrationSpec { + + /** + * 注册目标 Zone UUID(必填)。 + * + *

替换 VmInstanceVO.zoneUuid。

+ */ + private String zoneUuid; + + /** + * 注册目标主存储 UUID(必填)。 + * + *

替换 VolumeVO.primaryStorageUuid、VolumeSnapshotVO.primaryStorageUuid。

+ */ + private String primaryStorageUuid; + + /** + * 注册操作的账户 UUID。 + * + *

替换所有 VO 的 accountUuid 字段。通常为 admin。

+ */ + private String accountUuid; + + /** + * 旧存储路径标识符。 + * + *
    + *
  • sblk 场景:旧 VG UUID
  • + *
  • local/NFS 场景:旧路径前缀(如 /vms_ds)
  • + *
+ */ + private String oldPathIdentifier; + + /** + * 新存储路径标识符。 + * + *
    + *
  • sblk 场景:新 VG UUID
  • + *
  • local/NFS 场景:新路径前缀(如 /vms_ds2)
  • + *
+ */ + private String newPathIdentifier; + + public String getZoneUuid() { + return zoneUuid; + } + + public void setZoneUuid(String zoneUuid) { + this.zoneUuid = zoneUuid; + } + + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public String getAccountUuid() { + return accountUuid; + } + + public void setAccountUuid(String accountUuid) { + this.accountUuid = accountUuid; + } + + public String getOldPathIdentifier() { + return oldPathIdentifier; + } + + public void setOldPathIdentifier(String oldPathIdentifier) { + this.oldPathIdentifier = oldPathIdentifier; + } + + public String getNewPathIdentifier() { + return newPathIdentifier; + } + + public void setNewPathIdentifier(String newPathIdentifier) { + this.newPathIdentifier = newPathIdentifier; + } +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataValidator.java b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataValidator.java new file mode 100644 index 00000000000..2a9722511ad --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceMetadataValidator.java @@ -0,0 +1,143 @@ +package org.zstack.header.vm; + +import org.zstack.header.exception.CloudRuntimeException; +import org.zstack.utils.gson.JSONObjectUtil; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 虚拟机元数据校验器。 + * + *

在反序列化后、注册前执行校验,确保元数据完整性和一致性。

+ * + *

校验项: + *

    + *
  • schemaVersion 与当前平台版本匹配
  • + *
  • ResourceMetadata.resourceUuid 与 vo 内部 uuid 一致
  • + *
  • snapshotGroupRefs 引用的 groupUuid 必须存在于 snapshotGroups 中
  • + *
+ */ +public class VmInstanceMetadataValidator { + + private VmInstanceMetadataValidator() { + } + + /** + * 执行全量校验。 + * + * @param dto 待校验的元数据 DTO + * @param currentVersion 当前平台 schema 版本 + * @throws CloudRuntimeException 校验失败时抛出 + */ + public static void validate(VmInstanceMetadataDTO dto, String currentVersion) { + validateSchemaVersion(dto, currentVersion); + validateResourceUuidConsistency(dto); + validateSnapshotGroupIntegrity(dto); + } + + /** + * 校验 schema 版本是否匹配当前平台版本。 + * + * @param dto 待校验的元数据 DTO + * @param currentVersion 当前平台 schema 版本 + * @throws CloudRuntimeException 版本缺失或不匹配时抛出 + */ + public static void validateSchemaVersion(VmInstanceMetadataDTO dto, String currentVersion) { + if (dto.schemaVersion == null || dto.schemaVersion.isEmpty()) { + throw new CloudRuntimeException("metadata schemaVersion is missing"); + } + if (!dto.schemaVersion.equals(currentVersion)) { + throw new CloudRuntimeException(String.format( + "metadata schemaVersion[%s] does not match current platform version[%s]," + + " please upgrade metadata first", + dto.schemaVersion, currentVersion)); + } + } + + /** + * 校验所有 ResourceMetadata 的 resourceUuid 与 vo 内部 uuid 一致。 + * + * @param dto 待校验的元数据 DTO + * @throws CloudRuntimeException resourceUuid 缺失或与 vo.uuid 不一致时抛出 + */ + public static void validateResourceUuidConsistency(VmInstanceMetadataDTO dto) { + if (dto.vm != null) { + validateSingleResourceUuid(dto.vm, "vm"); + } + if (dto.volumes != null) { + for (int i = 0; i < dto.volumes.size(); i++) { + validateSingleResourceUuid(dto.volumes.get(i), "volumes[" + i + "]"); + } + } + if (dto.nics != null) { + for (int i = 0; i < dto.nics.size(); i++) { + validateSingleResourceUuid(dto.nics.get(i), "nics[" + i + "]"); + } + } + } + + @SuppressWarnings("unchecked") + private static void validateSingleResourceUuid(VmInstanceMetadataDTO.ResourceMetadata rm, String path) { + if (rm.resourceUuid == null) { + throw new CloudRuntimeException(String.format( + "metadata %s.resourceUuid is null", path)); + } + if (rm.vo == null) { + throw new CloudRuntimeException(String.format( + "metadata %s.vo is null", path)); + } + + Map voMap = JSONObjectUtil.toObject(rm.vo, Map.class); + Object voUuid = voMap.get("uuid"); + if (voUuid == null) { + throw new CloudRuntimeException(String.format( + "metadata %s.vo does not contain uuid field", path)); + } + if (!rm.resourceUuid.equals(voUuid.toString())) { + throw new CloudRuntimeException(String.format( + "metadata %s.resourceUuid[%s] does not match vo.uuid[%s]", + path, rm.resourceUuid, voUuid)); + } + } + + /** + * 校验快照组引用的完整性。 + * + *

snapshotGroupRefs 中引用的 volumeSnapshotGroupUuid + * 必须存在于 snapshotGroups 中。

+ * + * @param dto 待校验的元数据 DTO + * @throws CloudRuntimeException 引用了不存在的 group 时抛出 + */ + @SuppressWarnings("unchecked") + public static void validateSnapshotGroupIntegrity(VmInstanceMetadataDTO dto) { + if (dto.snapshotGroupRefs == null || dto.snapshotGroupRefs.isEmpty()) { + return; + } + if (dto.snapshotGroups == null || dto.snapshotGroups.isEmpty()) { + throw new CloudRuntimeException( + "metadata has snapshotGroupRefs but no snapshotGroups"); + } + + Set groupUuids = new HashSet<>(); + for (String groupJson : dto.snapshotGroups) { + Map groupMap = JSONObjectUtil.toObject(groupJson, Map.class); + Object uuid = groupMap.get("uuid"); + if (uuid != null) { + groupUuids.add(uuid.toString()); + } + } + + for (String refJson : dto.snapshotGroupRefs) { + Map refMap = JSONObjectUtil.toObject(refJson, Map.class); + Object groupUuid = refMap.get("volumeSnapshotGroupUuid"); + if (groupUuid != null && !groupUuids.contains(groupUuid.toString())) { + throw new CloudRuntimeException(String.format( + "metadata snapshotGroupRef references non-existent group[uuid:%s]", + groupUuid)); + } + } + } +} \ No newline at end of file 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 b71d8f3be45..1e5a9947ec9 100755 --- a/header/src/main/java/org/zstack/header/vm/VmInstanceState.java +++ b/header/src/main/java/org/zstack/header/vm/VmInstanceState.java @@ -30,7 +30,8 @@ public enum VmInstanceState { Error(null), NoState(VmInstanceStateEvent.noState), Unknown(VmInstanceStateEvent.unknown), - Crashed(VmInstanceStateEvent.crashed); + Crashed(VmInstanceStateEvent.crashed), + Registering(null); public static List intermediateStates = new ArrayList<>(); @@ -52,6 +53,7 @@ public enum VmInstanceState { offlineStates.add(Destroyed); offlineStates.add(VolumeMigrating); offlineStates.add(Crashed); + offlineStates.add(Registering); Created.transactions( new Transaction(VmInstanceStateEvent.starting, VmInstanceState.Starting), diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadata.java b/header/src/main/java/org/zstack/header/vm/VmMetadata.java new file mode 100644 index 00000000000..92452753804 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadata.java @@ -0,0 +1,44 @@ +package org.zstack.header.vm; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class VmMetadata { + public String vmInstanceVO; + public List vmSystemTags = new ArrayList<>(); + public List vmResourceConfigs = new ArrayList<>(); + + public List volumeVOs = new ArrayList<>(); + // key = volumeUuid + // value = SystemTag + public Map> volumeSystemTags = new HashMap<>(); + // key = volumeUuid + // value = ResourceConfig + public Map> volumeResourceConfigs = new HashMap<>(); + + public List vmNicVOs = new ArrayList<>(); + // key = nicUuid + // value = SystemTag + public Map> vmNicSystemTags = new HashMap<>(); + // key = nicUuid + // value = ResourceConfig + public Map> vmNicResourceConfigs = new HashMap<>(); + + // key = volumeUuid + // value = List + public Map> volumeSnapshots = new HashMap<>(); + + // VolumeSnapshotGroupVO.toString + public List volumeSnapshotGroupVO = new ArrayList<>(); + // VolumeSnapshotGroupRefVO.toString + public List volumeSnapshotGroupRefVO = new ArrayList<>(); + + // key = volumeUuid + // value = VolumeSnapshotReferenceVO.toString + public Map volumeSnapshotReferenceVO = new HashMap<>(); + // key = volumeUuid + // value = VolumeSnapshotReferenceTreeVO.toString + public Map volumeSnapshotReferenceTreeVO = new HashMap<>(); +} diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadataCanonicalEvents.java b/header/src/main/java/org/zstack/header/vm/VmMetadataCanonicalEvents.java new file mode 100644 index 00000000000..dd5323fea7e --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataCanonicalEvents.java @@ -0,0 +1,31 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.NeedJsonSchema; + +/** + * 虚拟机元数据相关 CanonicalEvent 定义。 + * + *

通过 {@code EventFacade.fire()} 发布,供监控系统和巡检机制消费。

+ */ +public class VmMetadataCanonicalEvents { + + /** + * GC 放弃后的 stale 事件路径。 + * + *

当 {@code UpdateVmInstanceMetadataGC} 超过最大重试次数后发布此事件, + * {@code MetadataHealthCheckJob} 监听此事件将 VM 加入优先刷新队列。

+ */ + public static final String VM_METADATA_STALE_PATH = "/vm/metadata/stale"; + + @NeedJsonSchema + public static class MetadataStaleData { + public String vmInstanceUuid; + + public MetadataStaleData() { + } + + public MetadataStaleData(String vmInstanceUuid) { + this.vmInstanceUuid = vmInstanceUuid; + } + } +} diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadataCategory.java b/header/src/main/java/org/zstack/header/vm/VmMetadataCategory.java new file mode 100644 index 00000000000..8945760d3e5 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataCategory.java @@ -0,0 +1,18 @@ +package org.zstack.header.vm; + +/** + * 虚拟机元数据分类。 + * + *

用于区分元数据所属的 VM 类型,注册恢复时按不同分类执行不同的恢复逻辑。

+ * + *
    + *
  • {@link #REGULAR} — 普通云主机
  • + *
  • {@link #TEMPLATE} — 模板虚拟机
  • + *
  • {@link #TEMPLATE_CACHE} — 模板虚拟机缓存
  • + *
+ */ +public enum VmMetadataCategory { + REGULAR, + TEMPLATE, + TEMPLATE_CACHE +} diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadataConstants.java b/header/src/main/java/org/zstack/header/vm/VmMetadataConstants.java new file mode 100644 index 00000000000..d5f26e11427 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataConstants.java @@ -0,0 +1,54 @@ +package org.zstack.header.vm; + +/** + * SharedBlock 元数据存储的容量常量与 Payload 大小保护阈值。 + * + *

SharedBlock(sblk)使用固定大小的 LV 存储 VM 元数据,采用双 Slot 布局: + *

+ *   [ LV Header (4096B) ][ Slot-A ][ Slot-B ]
+ *   Slot 大小 = (lvSize - headerSize) / 2,向下对齐到 4096
+ *   Slot Header = 36B(Magic 4B + SeqNum 8B + SlotOffset 8B + SlotCapacity 8B + PayloadLen 8B)
+ *   可用 Payload = SlotCapacity - SlotHeaderSize
+ * 
+ * + * @see Part 02b §10.0 容量公式与常量 + */ +public final class VmMetadataConstants { + + private VmMetadataConstants() { + // utility class + } + + /** LV 头部大小(字节) */ + public static final long SBLK_HEADER_SIZE = 4096L; + + /** Slot 头部大小(字节):Magic(4) + SeqNum(8) + SlotOffset(8) + SlotCapacity(8) + PayloadLen(8) */ + public static final long SBLK_SLOT_HEADER_SIZE = 36L; + + /** SharedBlock 元数据 LV 最大大小(64MB) */ + public static final long SBLK_MAX_LV_SIZE = 64L * 1024 * 1024; + + /** + * 计算给定 LV 大小下单个 Slot 的容量(字节)。 + * + *

公式:((lvSize - headerSize) / 2 / 4096) * 4096(向下对齐到 4096)

+ * + * @param lvSize LV 总大小(字节) + * @return 单个 Slot 的容量(字节) + */ + public static long slotCapacity(long lvSize) { + return ((lvSize - SBLK_HEADER_SIZE) / 2 / 4096) * 4096; + } + + /** 64MB LV 下单个 Slot 的最大容量(约 33,550,336 字节) */ + public static final long SBLK_MAX_SLOT_CAPACITY = slotCapacity(SBLK_MAX_LV_SIZE); + + /** 64MB LV 下单个 Slot 的最大可用 Payload(约 33,550,300 字节) */ + public static final long SBLK_MAX_PAYLOAD_SIZE = SBLK_MAX_SLOT_CAPACITY - SBLK_SLOT_HEADER_SIZE; + + /** Payload 大小预警阈值(8MB):超过时输出 WARN 日志 */ + public static final long PAYLOAD_WARN_THRESHOLD = 8L * 1024 * 1024; + + /** Payload 大小拒绝阈值(30MB):超过时 ERROR + 拒绝写入 */ + public static final long PAYLOAD_REJECT_THRESHOLD = 30L * 1024 * 1024; +} diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadataDirtyVO.java b/header/src/main/java/org/zstack/header/vm/VmMetadataDirtyVO.java new file mode 100644 index 00000000000..60bf5231263 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataDirtyVO.java @@ -0,0 +1,143 @@ +package org.zstack.header.vm; + +import org.zstack.header.managementnode.ManagementNodeVO; +import org.zstack.header.vo.ForeignKey; +import org.zstack.header.vo.ForeignKey.ReferenceOption; + +import javax.persistence.*; +import java.sql.Timestamp; + +/** + * 记录 VM 元数据的"脏标记",表示该 VM 的元数据需要写入主存储。 + * + *

设计要点

+ *
    + *
  • vmInstanceUuid 做主键:一个 VM 最多一行,天然去重。 + * 100 个 API 只产生 1 行,不是 100 行。
  • + *
  • managementNodeUuid FK SET_NULL:MN 宕机后 DB 约束自动释放认领, + * 无需额外孤儿扫描。
  • + *
  • vmInstanceUuid FK CASCADE:VM 销毁时自动删除脏标记,无残留。
  • + *
  • dirtyVersion:每次 markDirty +1,刷写前快照 version, + * 成功后比较——检测刷写期间是否有新变更。语义比时间戳比较更明确,无精度问题。
  • + *
  • nextRetryTime:退避控制,失败后不立刻重试,等到下次重试时间。
  • + *
+ * + *

行语义

+ *
    + *
  • 行存在 = VM 元数据是脏的(需要刷写)
  • + *
  • 行不存在 = VM 元数据已是最新(或 VM 不存在)
  • + *
  • managementNodeUuid != null = 该行已被某个 MN 认领,正在处理
  • + *
  • managementNodeUuid == null = 该行未被认领,可被 Poller 或 triggerFlush 认领
  • + *
+ */ +@Entity +@Table(name = "VmMetadataDirtyVO") +public class VmMetadataDirtyVO { + + @Id + @Column + @ForeignKey(parentEntityClass = VmInstanceEO.class, onDeleteAction = ReferenceOption.CASCADE) + private String vmInstanceUuid; + + @Column + @ForeignKey(parentEntityClass = ManagementNodeVO.class, onDeleteAction = ReferenceOption.SET_NULL) + private String managementNodeUuid; + + @Column + private long dirtyVersion; + + @Column + private Timestamp lastClaimTime; + + @Column + private boolean storageStructureChange; + + @Column + private int retryCount; + + @Column + private Timestamp nextRetryTime; + + @Column + private Timestamp createDate; + + @Column + private Timestamp lastOpDate; + + @PreUpdate + private void preUpdate() { + lastOpDate = null; + } + + public String getVmInstanceUuid() { + return vmInstanceUuid; + } + + public void setVmInstanceUuid(String vmInstanceUuid) { + this.vmInstanceUuid = vmInstanceUuid; + } + + public String getManagementNodeUuid() { + return managementNodeUuid; + } + + public void setManagementNodeUuid(String managementNodeUuid) { + this.managementNodeUuid = managementNodeUuid; + } + + public long getDirtyVersion() { + return dirtyVersion; + } + + public void setDirtyVersion(long dirtyVersion) { + this.dirtyVersion = dirtyVersion; + } + + public Timestamp getLastClaimTime() { + return lastClaimTime; + } + + public void setLastClaimTime(Timestamp lastClaimTime) { + this.lastClaimTime = lastClaimTime; + } + + public boolean isStorageStructureChange() { + return storageStructureChange; + } + + public void setStorageStructureChange(boolean storageStructureChange) { + this.storageStructureChange = storageStructureChange; + } + + public int getRetryCount() { + return retryCount; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public Timestamp getNextRetryTime() { + return nextRetryTime; + } + + public void setNextRetryTime(Timestamp nextRetryTime) { + this.nextRetryTime = nextRetryTime; + } + + 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/vm/VmMetadataDirtyVO_.java b/header/src/main/java/org/zstack/header/vm/VmMetadataDirtyVO_.java new file mode 100644 index 00000000000..8ed099d9d8e --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataDirtyVO_.java @@ -0,0 +1,17 @@ +package org.zstack.header.vm; + +import javax.persistence.metamodel.SingularAttribute; +import javax.persistence.metamodel.StaticMetamodel; +import java.sql.Timestamp; + +@StaticMetamodel(VmMetadataDirtyVO.class) +public class VmMetadataDirtyVO_ { + public static volatile SingularAttribute vmInstanceUuid; + public static volatile SingularAttribute managementNodeUuid; + public static volatile SingularAttribute dirtyVersion; + public static volatile SingularAttribute storageStructureChange; + public static volatile SingularAttribute retryCount; + public static volatile SingularAttribute nextRetryTime; + public static volatile SingularAttribute createDate; + public static volatile SingularAttribute lastOpDate; +} diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadataErrors.java b/header/src/main/java/org/zstack/header/vm/VmMetadataErrors.java new file mode 100644 index 00000000000..8e37a63dc67 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataErrors.java @@ -0,0 +1,28 @@ +package org.zstack.header.vm; + +public enum VmMetadataErrors { + METADATA_INVALID_FORMAT(1300), + METADATA_SCHEMA_VERSION_MISMATCH(1301), + METADATA_UUID_CONFLICT(1302), + METADATA_STORAGE_NOT_SUPPORTED(1303), + METADATA_CROSS_STORAGE_FORBIDDEN(1304), + METADATA_INSTALL_PATH_NOT_FOUND(1305), + METADATA_CACHE_VM_NOT_REGISTERABLE(1306), + METADATA_VM_REGISTERING(1307), + METADATA_READ_CORRUPTED(1308), + METADATA_PAYLOAD_TOO_LARGE(1309), + METADATA_PS_UNREACHABLE(1310), + METADATA_FEATURE_DISABLED(1311), + ; + + private String code; + + private VmMetadataErrors(int id) { + code = String.format("VM_METADATA.%s", id); + } + + @Override + public String toString() { + return code; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadataPathFingerprintVO.java b/header/src/main/java/org/zstack/header/vm/VmMetadataPathFingerprintVO.java new file mode 100644 index 00000000000..62268413d79 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataPathFingerprintVO.java @@ -0,0 +1,86 @@ +package org.zstack.header.vm; + +import org.zstack.header.vo.ForeignKey; +import org.zstack.header.vo.ForeignKey.ReferenceOption; + +import javax.persistence.*; +import java.sql.Timestamp; + +/** + * 路径指纹:记录每个 VM 上次成功刷写元数据时的存储拓扑路径快照。 + * + *

设计要点(Part 02b §8.2.3)

+ *
    + *
  • pathSnapshot:JSON 格式的 volumes/snapshots installPath 列表, + * 按 uuid ASC 排序保证确定性,用于纯 DB 侧路径漂移检测(零存储 I/O)。
  • + *
  • lastFlushFailed:Poller 重试耗尽时置 true(C-SR-05), + * 仅由 {@code MetadataStaleRecoveryTask} 重置为 false(C-02B-8)。
  • + *
  • staleRecoveryCount:熔断计数器,{@code MetadataStaleRecoveryTask} 每次 + * 重入队递增,达到上限(默认 10 ≈ 5 小时)后停止自动恢复。 + * 管理员可通过 {@code APIUpdateVmMetadataMsg} 手动重置为 0。
  • + *
  • vmInstanceUuid 做 PK:一个 VM 最多一行。 + * FK CASCADE 保证 VM 物理删除时自动清理。
  • + *
+ */ +@Entity +@Table(name = "VmMetadataPathFingerprintVO") +public class VmMetadataPathFingerprintVO { + + @Id + @Column + @ForeignKey(parentEntityClass = VmInstanceEO.class, onDeleteAction = ReferenceOption.CASCADE) + private String vmInstanceUuid; + + @Column + @Lob + private String pathSnapshot; + + @Column + private Timestamp lastFlushTime; + + @Column + private boolean lastFlushFailed; + + @Column + private int staleRecoveryCount; + + public String getVmInstanceUuid() { + return vmInstanceUuid; + } + + public void setVmInstanceUuid(String vmInstanceUuid) { + this.vmInstanceUuid = vmInstanceUuid; + } + + public String getPathSnapshot() { + return pathSnapshot; + } + + public void setPathSnapshot(String pathSnapshot) { + this.pathSnapshot = pathSnapshot; + } + + public Timestamp getLastFlushTime() { + return lastFlushTime; + } + + public void setLastFlushTime(Timestamp lastFlushTime) { + this.lastFlushTime = lastFlushTime; + } + + public boolean isLastFlushFailed() { + return lastFlushFailed; + } + + public void setLastFlushFailed(boolean lastFlushFailed) { + this.lastFlushFailed = lastFlushFailed; + } + + public int getStaleRecoveryCount() { + return staleRecoveryCount; + } + + public void setStaleRecoveryCount(int staleRecoveryCount) { + this.staleRecoveryCount = staleRecoveryCount; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/VmMetadataScanResult.java b/header/src/main/java/org/zstack/header/vm/VmMetadataScanResult.java new file mode 100644 index 00000000000..8a007b3df34 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmMetadataScanResult.java @@ -0,0 +1,87 @@ +package org.zstack.header.vm; + +import java.io.Serializable; + +public class VmMetadataScanResult implements Serializable { + private String vmUuid; + private String vmName; + private String vmCategory; + private String primaryStorageUuid; + private String primaryStorageType; + private String schemaVersion; + private Long lastUpdateTime; + private String metadataPath; + private Long sizeBytes; + + public String getVmUuid() { + return vmUuid; + } + + public void setVmUuid(String vmUuid) { + this.vmUuid = vmUuid; + } + + public String getVmName() { + return vmName; + } + + public void setVmName(String vmName) { + this.vmName = vmName; + } + + public String getVmCategory() { + return vmCategory; + } + + public void setVmCategory(String vmCategory) { + this.vmCategory = vmCategory; + } + + public String getPrimaryStorageUuid() { + return primaryStorageUuid; + } + + public void setPrimaryStorageUuid(String primaryStorageUuid) { + this.primaryStorageUuid = primaryStorageUuid; + } + + public String getPrimaryStorageType() { + return primaryStorageType; + } + + public void setPrimaryStorageType(String primaryStorageType) { + this.primaryStorageType = primaryStorageType; + } + + public String getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + } + + public Long getLastUpdateTime() { + return lastUpdateTime; + } + + public void setLastUpdateTime(Long lastUpdateTime) { + this.lastUpdateTime = lastUpdateTime; + } + + public String getMetadataPath() { + return metadataPath; + } + + public void setMetadataPath(String metadataPath) { + this.metadataPath = metadataPath; + } + + public Long getSizeBytes() { + return sizeBytes; + } + + public void setSizeBytes(Long sizeBytes) { + this.sizeBytes = sizeBytes; + } +} diff --git a/header/src/main/java/org/zstack/header/vm/VmUuidFromApiResolver.java b/header/src/main/java/org/zstack/header/vm/VmUuidFromApiResolver.java new file mode 100644 index 00000000000..1852f716827 --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VmUuidFromApiResolver.java @@ -0,0 +1,49 @@ +package org.zstack.header.vm; + +import org.zstack.header.message.APIMessage; + +import java.util.List; + +/** + * 从 API 消息中解析关联的 vmInstanceUuid。 + * + *

用于非 VM 直接 API(如 Volume/Nic/快照 API)中提取关联的 VM UUID, + * 以便在 API 成功后触发对应 VM 的元数据更新。

+ * + *

实现类示例

+ *
    + *
  • VolumeToVmResolver:volumeUuid → vmInstanceUuid
  • + *
  • NicToVmResolver:vmNicUuid → vmInstanceUuid
  • + *
  • SnapshotToVmResolver:snapshotUuid → volumeUuid → vmInstanceUuid
  • + *
+ * + *

解析时机

+ *

Resolver 应在 API 执行前 预解析 vmUuid 并缓存在上下文中, + * 因为 API 执行后相关资源可能已被删除(如 APIDeleteVolumeMsg 执行后 VolumeVO 不存在)。

+ * + * @see MetadataImpact + * @see UpdateVmInstanceMetadataMsg + */ +public interface VmUuidFromApiResolver { + + /** + * 判断此 Resolver 是否能处理指定的 API 消息类型。 + * + * @param msg API 消息 + * @return true 表示此 Resolver 可以从该消息中解析 vmUuid + */ + boolean supports(APIMessage msg); + + /** + * 从 API 消息中解析出关联的 vmInstanceUuid 列表。 + * + *

可能返回空列表(如 volume 未挂载到任何 VM)。 + * 可能返回多个 UUID(如批量操作涉及多台 VM)。

+ * + *

此方法应在 API 执行前调用。

+ * + * @param msg API 消息 + * @return 关联的 vmInstanceUuid 列表,不为 null + */ + List resolveVmUuids(APIMessage msg); +} \ No newline at end of file diff --git a/header/src/main/java/org/zstack/header/vm/VolumeResourceMetadata.java b/header/src/main/java/org/zstack/header/vm/VolumeResourceMetadata.java new file mode 100644 index 00000000000..cff9943392a --- /dev/null +++ b/header/src/main/java/org/zstack/header/vm/VolumeResourceMetadata.java @@ -0,0 +1,31 @@ +package org.zstack.header.vm; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +/** + * 云盘资源元数据,扩展 {@link VmInstanceMetadataDTO.ResourceMetadata} 以包含 + * 快照引用(VolumeSnapshotReferenceVO)和快照引用树(VolumeSnapshotReferenceTreeVO)数据。 + * + *

每个 Volume 的快照引用数据直接关联到对应的 VolumeResourceMetadata 中, + * 而非放在 DTO 顶层的 Map 结构里,便于按卷维度整体操作。

+ */ +public class VolumeResourceMetadata extends VmInstanceMetadataDTO.ResourceMetadata { + /** + * 该 Volume 关联的快照引用列表。 + * + *

每个元素是 VolumeSnapshotReferenceVO 的 JSON 明文。 + * 通过 {@code referenceVolumeUuid} 查询关联到本 Volume。

+ */ + @SerializedName("snapshotReferences") + public List snapshotReferences; + + /** + * 该 Volume 关联的快照引用树列表。 + * + *

每个元素是 VolumeSnapshotReferenceTreeVO 的 JSON 明文。

+ */ + @SerializedName("snapshotReferenceTrees") + public List snapshotReferenceTrees; +} diff --git a/header/src/main/java/org/zstack/header/vm/cdrom/APIDeleteVmCdRomMsg.java b/header/src/main/java/org/zstack/header/vm/cdrom/APIDeleteVmCdRomMsg.java index a708c8fb2b3..3274219ca1d 100644 --- a/header/src/main/java/org/zstack/header/vm/cdrom/APIDeleteVmCdRomMsg.java +++ b/header/src/main/java/org/zstack/header/vm/cdrom/APIDeleteVmCdRomMsg.java @@ -8,6 +8,7 @@ import org.zstack.header.other.APIAuditor; import org.zstack.header.rest.APINoSee; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; import org.zstack.header.vm.VmInstanceMessage; import org.zstack.header.vm.VmInstanceVO; @@ -19,6 +20,7 @@ method = HttpMethod.DELETE, responseClass = APIDeleteVmCdRomEvent.class ) +@MetadataImpact(MetadataImpact.Impact.NONE) public class APIDeleteVmCdRomMsg extends APIDeleteMessage implements VmInstanceMessage, APIAuditor { @APIParam(resourceType = VmCdRomVO.class, successIfResourceNotExisting = true) private String uuid; diff --git a/header/src/main/java/org/zstack/header/volume/APIAttachDataVolumeToVmMsg.java b/header/src/main/java/org/zstack/header/volume/APIAttachDataVolumeToVmMsg.java index 6ae817723f4..a2d3e9a4076 100755 --- a/header/src/main/java/org/zstack/header/volume/APIAttachDataVolumeToVmMsg.java +++ b/header/src/main/java/org/zstack/header/volume/APIAttachDataVolumeToVmMsg.java @@ -4,6 +4,7 @@ import org.zstack.header.message.APIMessage; import org.zstack.header.message.APIParam; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; import org.zstack.header.vm.VmInstanceVO; /** @@ -39,6 +40,7 @@ parameterName = "params", responseClass = APIAttachDataVolumeToVmEvent.class ) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIAttachDataVolumeToVmMsg extends APIMessage implements VolumeMessage { /** * @desc vm uuid. see :ref:`VmInstanceInventory` diff --git a/header/src/main/java/org/zstack/header/volume/APICreateVolumeSnapshotGroupMsg.java b/header/src/main/java/org/zstack/header/volume/APICreateVolumeSnapshotGroupMsg.java index 9b497a73fe4..b211cd862c4 100644 --- a/header/src/main/java/org/zstack/header/volume/APICreateVolumeSnapshotGroupMsg.java +++ b/header/src/main/java/org/zstack/header/volume/APICreateVolumeSnapshotGroupMsg.java @@ -11,6 +11,7 @@ import org.zstack.header.storage.snapshot.VolumeSnapshotVO; import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefInventory; import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO; +import org.zstack.header.vm.MetadataImpact; import org.zstack.header.vm.VmInstanceInventory; import org.zstack.header.vm.VmInstanceVO; @@ -28,6 +29,7 @@ parameterName = "params" ) @DefaultTimeout(timeunit = TimeUnit.HOURS, value = 3) +@MetadataImpact(value = MetadataImpact.Impact.STORAGE, updateOnFailure = true) public class APICreateVolumeSnapshotGroupMsg extends APICreateMessage implements VolumeMessage, CreateVolumeSnapshotGroupMessage, APIMultiAuditor { /** * @desc root volume uuid. See :ref:`VolumeInventory` diff --git a/header/src/main/java/org/zstack/header/volume/APIDeleteDataVolumeMsg.java b/header/src/main/java/org/zstack/header/volume/APIDeleteDataVolumeMsg.java index fdbb1be9847..1b90420fb65 100755 --- a/header/src/main/java/org/zstack/header/volume/APIDeleteDataVolumeMsg.java +++ b/header/src/main/java/org/zstack/header/volume/APIDeleteDataVolumeMsg.java @@ -4,6 +4,7 @@ import org.zstack.header.message.APIDeleteMessage; import org.zstack.header.message.APIParam; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; import java.util.List; @@ -42,6 +43,7 @@ method = HttpMethod.DELETE, responseClass = APIDeleteDataVolumeEvent.class ) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIDeleteDataVolumeMsg extends APIDeleteMessage implements VolumeMessage { /** * @desc data volume uuid diff --git a/header/src/main/java/org/zstack/header/volume/APIDetachDataVolumeFromVmMsg.java b/header/src/main/java/org/zstack/header/volume/APIDetachDataVolumeFromVmMsg.java index 5164ffca076..4b93990dff3 100755 --- a/header/src/main/java/org/zstack/header/volume/APIDetachDataVolumeFromVmMsg.java +++ b/header/src/main/java/org/zstack/header/volume/APIDetachDataVolumeFromVmMsg.java @@ -3,6 +3,7 @@ import org.springframework.http.HttpMethod; import org.zstack.header.message.APIMessage; import org.zstack.header.message.APIParam; +import org.zstack.header.vm.MetadataImpact; import org.zstack.header.vm.VmInstanceVO; import org.zstack.header.rest.RestRequest; @@ -36,6 +37,7 @@ method = HttpMethod.DELETE, responseClass = APIDetachDataVolumeFromVmEvent.class ) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIDetachDataVolumeFromVmMsg extends APIMessage implements VolumeMessage { /** * @desc data volume uuid. See :ref:`VolumeInventory` diff --git a/header/src/main/java/org/zstack/header/volume/APIFlattenVolumeMsg.java b/header/src/main/java/org/zstack/header/volume/APIFlattenVolumeMsg.java index daeb56b44eb..817be91596b 100644 --- a/header/src/main/java/org/zstack/header/volume/APIFlattenVolumeMsg.java +++ b/header/src/main/java/org/zstack/header/volume/APIFlattenVolumeMsg.java @@ -7,6 +7,7 @@ import org.zstack.header.message.DefaultTimeout; import org.zstack.header.other.APIAuditor; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; import java.util.concurrent.TimeUnit; @@ -17,6 +18,7 @@ method = HttpMethod.PUT, responseClass = APIFlattenVolumeEvent.class ) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIFlattenVolumeMsg extends APIMessage implements VolumeMessage, APIAuditor { @APIParam(resourceType = VolumeVO.class) private String uuid; diff --git a/header/src/main/java/org/zstack/header/volume/APIRecoverDataVolumeMsg.java b/header/src/main/java/org/zstack/header/volume/APIRecoverDataVolumeMsg.java index eec77334a37..ea62fcff23e 100755 --- a/header/src/main/java/org/zstack/header/volume/APIRecoverDataVolumeMsg.java +++ b/header/src/main/java/org/zstack/header/volume/APIRecoverDataVolumeMsg.java @@ -4,6 +4,7 @@ import org.zstack.header.message.APIMessage; import org.zstack.header.message.APIParam; import org.zstack.header.rest.RestRequest; +import org.zstack.header.vm.MetadataImpact; /** * Created by frank on 11/12/2015. @@ -14,6 +15,7 @@ method = HttpMethod.PUT, responseClass = APIRecoverDataVolumeEvent.class ) +@MetadataImpact(MetadataImpact.Impact.STORAGE) public class APIRecoverDataVolumeMsg extends APIMessage implements VolumeMessage { @APIParam(resourceType = VolumeVO.class) private String uuid; 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 92e76ede2c5..51e3c4f1dee 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMAgentCommands.java @@ -4716,4 +4716,20 @@ public void setMemoryUsage(long memoryUsage) { this.memoryUsage = memoryUsage; } } + + public static class WriteVmInstanceMetadataCmd extends AgentCommand { + public String metadata; + public String metadataPath; + } + + public static class WriteVmInstanceMetadataRsp extends AgentResponse { + } + + public static class ReadVmInstanceMetadataCmd extends AgentCommand { + public String metadataPath; + } + + public static class ReadVmInstanceMetadataRsp extends AgentResponse { + public String metadata; + } } diff --git a/plugin/kvm/src/main/java/org/zstack/kvm/KVMConstant.java b/plugin/kvm/src/main/java/org/zstack/kvm/KVMConstant.java index 7cd78c36c93..6c845676ca8 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMConstant.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMConstant.java @@ -86,6 +86,9 @@ public interface KVMConstant { String CLEAN_FIRMWARE_FLASH = "/clean/firmware/flash"; String FSTRIM_VM_PATH = "/vm/fstrim"; + String WRITE_VM_INSTANCE_METADATA_PATH = "/vm/metadata/write"; + String READ_VM_INSTANCE_METADATA_PATH = "/vm/metadata/read"; + String ISO_TO = "kvm.isoto"; String ANSIBLE_PLAYBOOK_NAME = "kvm.py"; String ANSIBLE_MODULE_PATH = "ansible/kvm"; 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 23d7b1cfe47..86a19c60b2f 100755 --- a/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java +++ b/plugin/kvm/src/main/java/org/zstack/kvm/KVMHost.java @@ -230,6 +230,8 @@ public class KVMHost extends HostBase implements Host { private String fileDownloadPath; private String fileUploadPath; private String fileDownloadProgressPath; + private String writeVmInstanceMetadataPath; + private String readVmInstanceMetadataPath; public KVMHost(KVMHostVO self, KVMHostContext context) { super(self); @@ -480,6 +482,14 @@ public KVMHost(KVMHostVO self, KVMHostContext context) { ub = UriComponentsBuilder.fromHttpUrl(baseUrl); ub.path(KVMConstant.KVM_HOST_FILE_DOWNLOAD_PROGRESS_PATH); fileDownloadProgressPath = ub.build().toString(); + + ub = UriComponentsBuilder.fromHttpUrl(baseUrl); + ub.path(KVMConstant.WRITE_VM_INSTANCE_METADATA_PATH); + writeVmInstanceMetadataPath = ub.build().toString(); + + ub = UriComponentsBuilder.fromHttpUrl(baseUrl); + ub.path(KVMConstant.READ_VM_INSTANCE_METADATA_PATH); + readVmInstanceMetadataPath = ub.build().toString(); } static { @@ -738,6 +748,10 @@ protected void handleLocalMessage(Message msg) { handle((GetFileDownloadProgressMsg) msg); } else if (msg instanceof RestartKvmAgentMsg) { handle((RestartKvmAgentMsg) msg); + } else if (msg instanceof UpdateVmInstanceMetadataOnHypervisorMsg) { + handle((UpdateVmInstanceMetadataOnHypervisorMsg) msg); + } else if (msg instanceof ReadVmInstanceMetadataOnHypervisorMsg) { + handle((ReadVmInstanceMetadataOnHypervisorMsg) msg); } else { super.handleLocalMessage(msg); } @@ -7309,4 +7323,77 @@ public void fail(ErrorCode errorCode) { } }); } + + private void handle(UpdateVmInstanceMetadataOnHypervisorMsg msg) { + inQueue().name(String.format("update-vmInstance-metadata-on-host-%s", self.getUuid())) + .asyncBackup(msg) + .run(chain -> updateVmInstanceMetadata(msg, new NoErrorCompletion(chain) { + @Override + public void done() { + chain.next(); + } + })); + } + + private void updateVmInstanceMetadata(final UpdateVmInstanceMetadataOnHypervisorMsg msg, final NoErrorCompletion completion) { + UpdateVmInstanceMetadataOnHypervisorReply reply = new UpdateVmInstanceMetadataOnHypervisorReply(); + + checkStatus(); + WriteVmInstanceMetadataCmd cmd = new WriteVmInstanceMetadataCmd(); + cmd.metadata = msg.getMetadata(); + cmd.metadataPath = msg.getMetadataPath(); + new Http<>(writeVmInstanceMetadataPath, cmd, WriteVmInstanceMetadataRsp.class).call(new ReturnValueCompletion(msg) { + @Override + public void success(WriteVmInstanceMetadataRsp ret) { + if (!ret.isSuccess()) { + reply.setError(operr("operation error, because:%s", ret.getError())); + } + bus.reply(msg, reply); + completion.done(); + } + + @Override + public void fail(ErrorCode errorCode) { + reply.setError(errorCode); + bus.reply(msg, reply); + completion.done(); + } + }); + } + + private void handle(ReadVmInstanceMetadataOnHypervisorMsg msg) { + inQueue().name(String.format("readVmInstanceMetadata-on-host-%s", self.getUuid())) + .asyncBackup(msg) + .run(chain -> readVmInstanceMetadata(msg, new NoErrorCompletion(chain) { + @Override + public void done() { + chain.next(); + } + })); + } + + private void readVmInstanceMetadata(final ReadVmInstanceMetadataOnHypervisorMsg msg, final NoErrorCompletion completion) { + checkStatus(); + ReadVmInstanceMetadataOnHypervisorReply reply = new ReadVmInstanceMetadataOnHypervisorReply(); + ReadVmInstanceMetadataCmd cmd = new ReadVmInstanceMetadataCmd(); + cmd.metadataPath = msg.getMetadataPath(); + new Http<>(readVmInstanceMetadataPath, cmd, ReadVmInstanceMetadataRsp.class).call(new ReturnValueCompletion(msg) { + @Override + public void success(ReadVmInstanceMetadataRsp rsp) { + if (!rsp.isSuccess()) { + reply.setError(operr("operation error, because:%s", rsp.getError())); + } + reply.setMetadata(rsp.metadata); + bus.reply(msg, reply); + completion.done(); + } + + @Override + public void fail(ErrorCode errorCode) { + reply.setError(errorCode); + bus.reply(msg, reply); + completion.done(); + } + }); + } } diff --git a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/APILocalStorageMigrateVolumeMsg.java b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/APILocalStorageMigrateVolumeMsg.java index e1864931171..186519a2484 100755 --- a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/APILocalStorageMigrateVolumeMsg.java +++ b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/APILocalStorageMigrateVolumeMsg.java @@ -11,6 +11,7 @@ import org.zstack.header.rest.RestRequest; import org.zstack.header.storage.primary.PrimaryStorageMessage; import org.zstack.header.storage.primary.PrimaryStorageVO; +import org.zstack.header.vm.MetadataImpact; import org.zstack.header.volume.VolumeVO; import java.util.concurrent.TimeUnit; @@ -25,6 +26,7 @@ isAction = true ) @DefaultTimeout(timeunit = TimeUnit.HOURS, value = 24) +@MetadataImpact(value = MetadataImpact.Impact.STORAGE, updateOnFailure = true) public class APILocalStorageMigrateVolumeMsg extends APIMessage implements PrimaryStorageMessage, APIAuditor { @APIParam(resourceType = VolumeVO.class) private String volumeUuid; diff --git a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageBase.java b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageBase.java index d4665a86a06..792cd13017e 100755 --- a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageBase.java +++ b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageBase.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import org.zstack.compute.host.VolumeMigrationTargetHostFilter; +import org.zstack.compute.vm.VmGlobalConfig; import org.zstack.core.asyncbatch.While; import org.zstack.core.cloudbus.CloudBusCallBack; import org.zstack.core.cloudbus.EventFacade; @@ -902,6 +903,8 @@ public void handleLocalMessage(Message msg) { handle((CommitVolumeSnapshotOnPrimaryStorageMsg) msg); } else if (msg instanceof PullVolumeSnapshotOnPrimaryStorageMsg) { handle((PullVolumeSnapshotOnPrimaryStorageMsg) msg); + } else if (msg instanceof UpdateVmInstanceMetadataOnPrimaryStorageMsg) { + handle((UpdateVmInstanceMetadataOnPrimaryStorageMsg) msg); } else { super.handleLocalMessage(msg); } @@ -3329,4 +3332,49 @@ public void fail(ErrorCode errorCode) { public static class LocalStoragePhysicalCapacityUsage extends PrimaryStorageBase.PhysicalCapacityUsage { public long localStorageUsedSize; } + + private void handle(final UpdateVmInstanceMetadataOnPrimaryStorageMsg msg) { + // Layer 3: PS-level concurrency control (§4) + thdf.chainSubmit(new ChainTask(msg) { + @Override + public String getSyncSignature() { + return "update-metadata-on-ps-" + self.getUuid(); + } + + @Override + public int getSyncLevel() { + return VmGlobalConfig.VM_METADATA_PS_MAX_CONCURRENT.value(Integer.class); + } + + @Override + public void run(SyncTaskChain chain) { + doHandleUpdateMetadata(msg); + chain.next(); + } + + @Override + public String getName() { + return "update-metadata-on-ps-" + self.getUuid(); + } + }); + } + + private void doHandleUpdateMetadata(final UpdateVmInstanceMetadataOnPrimaryStorageMsg msg) { + final String hostUuid = getHostUuidByResourceUuid(msg.getRootVolumeUuid()); + LocalStorageHypervisorFactory f = getHypervisorBackendFactoryByHostUuid(hostUuid); + LocalStorageHypervisorBackend bkd = f.getHypervisorBackend(self); + bkd.handle(msg, hostUuid, new ReturnValueCompletion(msg) { + @Override + public void success(UpdateVmInstanceMetadataOnPrimaryStorageReply returnValue) { + bus.reply(msg, returnValue); + } + + @Override + public void fail(ErrorCode errorCode) { + UpdateVmInstanceMetadataOnPrimaryStorageReply reply = new UpdateVmInstanceMetadataOnPrimaryStorageReply(); + reply.setError(errorCode); + bus.reply(msg, reply); + } + }); + } } diff --git a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageHypervisorBackend.java b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageHypervisorBackend.java index 7760e28de93..7e85d562d8c 100755 --- a/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageHypervisorBackend.java +++ b/plugin/localstorage/src/main/java/org/zstack/storage/primary/local/LocalStorageHypervisorBackend.java @@ -7,6 +7,8 @@ import org.zstack.header.image.ImageInventory; import org.zstack.header.storage.primary.*; import org.zstack.header.storage.snapshot.VolumeSnapshotInventory; +import org.zstack.header.vm.UpdateVmInstanceMetadataOnPrimaryStorageMsg; +import org.zstack.header.vm.UpdateVmInstanceMetadataOnPrimaryStorageReply; import org.zstack.header.volume.*; import org.zstack.storage.primary.EstimateVolumeTemplateSizeOnPrimaryStorageMsg; import org.zstack.storage.primary.EstimateVolumeTemplateSizeOnPrimaryStorageReply; @@ -121,4 +123,6 @@ public LocalStorageHypervisorBackend(PrimaryStorageVO self) { abstract void handle(CommitVolumeSnapshotOnPrimaryStorageMsg msg, String hostUuid, ReturnValueCompletion completion); abstract void handle(PullVolumeSnapshotOnPrimaryStorageMsg msg, String hostUuid, ReturnValueCompletion completion); + + abstract void handle(UpdateVmInstanceMetadataOnPrimaryStorageMsg msg, String hostUuid, ReturnValueCompletion completion); } 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 e8d268e518a..7da947744b7 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 @@ -43,10 +43,8 @@ import org.zstack.header.storage.backup.*; import org.zstack.header.storage.primary.*; import org.zstack.header.storage.snapshot.*; +import org.zstack.header.vm.*; import org.zstack.header.vm.VmInstanceSpec.ImageSpec; -import org.zstack.header.vm.VmInstanceState; -import org.zstack.header.vm.VmInstanceVO; -import org.zstack.header.vm.VmInstanceVO_; import org.zstack.header.volume.*; import org.zstack.identity.AccountManager; import org.zstack.kvm.*; @@ -70,6 +68,7 @@ import static org.zstack.core.Platform.inerr; import static org.zstack.core.Platform.multiErr; import static org.zstack.core.Platform.operr; +import static org.zstack.header.vm.VmInstanceConstant.VM_META_SUFFIX; import static org.zstack.utils.CollectionDSL.list; import static org.zstack.utils.CollectionUtils.transformAndRemoveNull; @@ -3797,4 +3796,31 @@ public void fail(ErrorCode errorCode) { } }); } + + @Override + void handle(UpdateVmInstanceMetadataOnPrimaryStorageMsg msg, String hostUuid, ReturnValueCompletion completion) { + String installPath = Q.New(VolumeVO.class).eq(VolumeVO_.uuid, msg.getRootVolumeUuid()).select(VolumeVO_.installPath).findValue(); + // /vms_ds/rootVolumes/acct-36c27e8ff05c4780bf6d2fa65700f22e/vol-829a91b68e794a03865eab8a5918600a/snapshots/f2c31aeede604917aa8cee24848d8bfa.qcow2 + // /vms_ds/rootVolumes/acct-36c27e8ff05c4780bf6d2fa65700f22e/vol-829a91b68e794a03865eab8a5918600a/829a91b68e794a03865eab8a5918600a.qcow2 + + String path = installPath.replaceFirst("^(.+/vol-[^/]+/).*$", "$1"); + String metadataPath = String.format("%s%s", path, VM_META_SUFFIX); + + UpdateVmInstanceMetadataOnHypervisorMsg umsg = new UpdateVmInstanceMetadataOnHypervisorMsg(); + umsg.setMetadata(msg.getMetadata()); + umsg.setMetadataPath(metadataPath); + umsg.setHostUuid(hostUuid); + bus.makeTargetServiceIdByResourceUuid(umsg, HostConstant.SERVICE_ID, hostUuid); + bus.send(umsg, new CloudBusCallBack(msg) { + @Override + public void run(MessageReply r) { + UpdateVmInstanceMetadataOnPrimaryStorageReply reply = new UpdateVmInstanceMetadataOnPrimaryStorageReply(); + if (!r.isSuccess()) { + reply.setError(Platform.operr("failed to update vm[uuid=%s] on hypervisor.", self.getUuid()) + .withCause(r.getError())); + } + bus.reply(msg, reply); + } + }); + } } diff --git a/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorage.java b/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorage.java index abe9ac152b6..f179dacfeb9 100755 --- a/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorage.java +++ b/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorage.java @@ -40,10 +40,9 @@ import org.zstack.header.storage.snapshot.ShrinkVolumeSnapshotOnPrimaryStorageMsg; import org.zstack.header.storage.snapshot.VolumeSnapshotConstant; import org.zstack.header.storage.snapshot.VolumeSnapshotInventory; +import org.zstack.compute.vm.VmGlobalConfig; +import org.zstack.header.vm.*; import org.zstack.header.vm.VmInstanceSpec.ImageSpec; -import org.zstack.header.vm.VmInstanceState; -import org.zstack.header.vm.VmInstanceVO; -import org.zstack.header.vm.VmInstanceVO_; import org.zstack.header.volume.*; import org.zstack.kvm.*; import org.zstack.storage.primary.*; @@ -131,6 +130,8 @@ protected void handleLocalMessage(Message msg) { handle((CommitVolumeSnapshotOnPrimaryStorageMsg) msg); } else if (msg instanceof PullVolumeSnapshotOnPrimaryStorageMsg) { handle((PullVolumeSnapshotOnPrimaryStorageMsg) msg); + } else if (msg instanceof UpdateVmInstanceMetadataOnPrimaryStorageMsg) { + handle((UpdateVmInstanceMetadataOnPrimaryStorageMsg) msg); } else { super.handleLocalMessage(msg); } @@ -1924,4 +1925,57 @@ private String getHostUuidFromVolume(String volumeUuid) { return hostUuid; } + + protected void handle(UpdateVmInstanceMetadataOnPrimaryStorageMsg msg) { + // Layer 3: PS-level concurrency control (§4) + // 同一 MN 上同一 PS 最多 N 个并发元数据写入 + thdf.chainSubmit(new ChainTask(msg) { + @Override + public String getSyncSignature() { + return "update-metadata-on-ps-" + self.getUuid(); + } + + @Override + public int getSyncLevel() { + return VmGlobalConfig.VM_METADATA_PS_MAX_CONCURRENT.value(Integer.class); + } + + @Override + public void run(SyncTaskChain chain) { + doHandleUpdateMetadata(msg); + chain.next(); + } + + @Override + public String getName() { + return "update-metadata-on-ps-" + self.getUuid(); + } + }); + } + + private void doHandleUpdateMetadata(UpdateVmInstanceMetadataOnPrimaryStorageMsg msg) { + UpdateVmInstanceMetadataOnPrimaryStorageReply reply = new UpdateVmInstanceMetadataOnPrimaryStorageReply(); + + String hostUuid = getHostUuidFromVolume(msg.getRootVolumeUuid()); + if (hostUuid == null || hostUuid.isEmpty()) { + reply.setError(operr("no host found for volume[uuid:%s]", msg.getRootVolumeUuid())); + bus.reply(msg, reply); + return; + } + + final NfsPrimaryStorageBackend backend = getUsableBackend(); + + backend.handle(msg, hostUuid, new ReturnValueCompletion(msg) { + @Override + public void success(UpdateVmInstanceMetadataOnPrimaryStorageReply r) { + bus.reply(msg, r); + } + + @Override + public void fail(ErrorCode errorCode) { + reply.setError(errorCode); + bus.reply(msg, reply); + } + }); + } } diff --git a/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageBackend.java b/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageBackend.java index 459023d7c17..a19f2d1d38e 100755 --- a/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageBackend.java +++ b/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageBackend.java @@ -7,6 +7,8 @@ import org.zstack.header.image.ImageInventory; import org.zstack.header.storage.primary.*; import org.zstack.header.storage.snapshot.VolumeSnapshotInventory; +import org.zstack.header.vm.UpdateVmInstanceMetadataOnPrimaryStorageMsg; +import org.zstack.header.vm.UpdateVmInstanceMetadataOnPrimaryStorageReply; import org.zstack.header.volume.VolumeStats; import org.zstack.header.volume.BatchSyncVolumeSizeOnPrimaryStorageMsg; import org.zstack.header.volume.BatchSyncVolumeSizeOnPrimaryStorageReply; @@ -91,6 +93,8 @@ public interface NfsPrimaryStorageBackend { void updateMountPoint(PrimaryStorageInventory pinv, String clusterUuid, String oldMountPoint, String newMountPoint, Completion completion); + void handle(UpdateVmInstanceMetadataOnPrimaryStorageMsg msg, String hostUuid, ReturnValueCompletion completion); + class BitsInfo { private String installPath; private long size; diff --git a/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageKVMBackend.java b/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageKVMBackend.java index 93d3d7aab99..e3ec8ac8c35 100755 --- a/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageKVMBackend.java +++ b/plugin/nfsPrimaryStorage/src/main/java/org/zstack/storage/primary/nfs/NfsPrimaryStorageKVMBackend.java @@ -35,10 +35,7 @@ import org.zstack.header.storage.primary.*; import org.zstack.header.storage.snapshot.VolumeSnapshotInventory; import org.zstack.header.storage.snapshot.VolumeSnapshotVO; -import org.zstack.header.vm.VmInstanceSpec; -import org.zstack.header.vm.VmInstanceState; -import org.zstack.header.vm.VmInstanceVO; -import org.zstack.header.vm.VmInstanceVO_; +import org.zstack.header.vm.*; import org.zstack.header.volume.*; import org.zstack.identity.AccountManager; import org.zstack.kvm.*; @@ -67,6 +64,7 @@ import static java.lang.Integer.min; import static org.zstack.core.Platform.operr; import static org.zstack.core.Platform.touterr; +import static org.zstack.header.vm.VmInstanceConstant.VM_META_SUFFIX; import static org.zstack.utils.CollectionUtils.transformAndRemoveNull; public class NfsPrimaryStorageKVMBackend implements NfsPrimaryStorageBackend, @@ -2051,4 +2049,32 @@ public void run(MessageReply r) { } }); } + + public void handle(UpdateVmInstanceMetadataOnPrimaryStorageMsg msg, String hostUuid, ReturnValueCompletion completion) { + UpdateVmInstanceMetadataOnHypervisorMsg umsg = new UpdateVmInstanceMetadataOnHypervisorMsg(); + umsg.setMetadata(msg.getMetadata()); + umsg.setHostUuid(hostUuid); + + String installPath = Q.New(VolumeVO.class) + .eq(VolumeVO_.uuid, msg.getRootVolumeUuid()) + .select(VolumeVO_.installPath) + .findValue(); + String path = installPath.replaceFirst("^(.+/vol-[^/]+/).*$", "$1"); + String metadataPath = String.format("%s%s", path, VM_META_SUFFIX); + umsg.setMetadataPath(metadataPath); + + bus.makeTargetServiceIdByResourceUuid(umsg, HostConstant.SERVICE_ID, hostUuid); + bus.send(umsg, new CloudBusCallBack(msg) { + @Override + public void run(MessageReply r) { + UpdateVmInstanceMetadataOnPrimaryStorageReply reply = new UpdateVmInstanceMetadataOnPrimaryStorageReply(); + if (!r.isSuccess()) { + reply.setError(Platform.operr("failed to update vm[uuid=%s] metadata on hypervisor via host[uuid:%s]", + msg.getVmInstanceUuid(), hostUuid) + .withCause(r.getError())); + } + completion.success(reply); + } + }); + } } diff --git a/resourceconfig/src/main/java/org/zstack/resourceconfig/APIDeleteResourceConfigMsg.java b/resourceconfig/src/main/java/org/zstack/resourceconfig/APIDeleteResourceConfigMsg.java index fd43d66dac3..6aaa88a6f69 100644 --- a/resourceconfig/src/main/java/org/zstack/resourceconfig/APIDeleteResourceConfigMsg.java +++ b/resourceconfig/src/main/java/org/zstack/resourceconfig/APIDeleteResourceConfigMsg.java @@ -6,10 +6,12 @@ import org.zstack.header.message.APIParam; import org.zstack.header.rest.RestRequest; import org.zstack.header.vo.ResourceVO; +import org.zstack.header.vm.MetadataImpact; @RestRequest(path = "/resource-configurations/{category}/{name}/{resourceUuid}", method = HttpMethod.DELETE, responseClass = APIDeleteResourceConfigEvent.class) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIDeleteResourceConfigMsg extends APIDeleteMessage implements ResourceConfigMessage { @APIParam private String category; diff --git a/resourceconfig/src/main/java/org/zstack/resourceconfig/APIUpdateResourceConfigMsg.java b/resourceconfig/src/main/java/org/zstack/resourceconfig/APIUpdateResourceConfigMsg.java index 1b8437d2404..4a4cf153b12 100644 --- a/resourceconfig/src/main/java/org/zstack/resourceconfig/APIUpdateResourceConfigMsg.java +++ b/resourceconfig/src/main/java/org/zstack/resourceconfig/APIUpdateResourceConfigMsg.java @@ -6,11 +6,13 @@ import org.zstack.header.message.APIParam; import org.zstack.header.rest.RestRequest; import org.zstack.header.vo.ResourceVO; +import org.zstack.header.vm.MetadataImpact; @RestRequest(path = "/resource-configurations/{category}/{name}/{resourceUuid}/actions", method = HttpMethod.PUT, isAction = true, responseClass = APIUpdateResourceConfigEvent.class) +@MetadataImpact(MetadataImpact.Impact.CONFIG) public class APIUpdateResourceConfigMsg extends APIMessage implements ResourceConfigMessage { @APIParam private String category; diff --git a/sdk/src/main/java/org/zstack/sdk/RegisterVmInstanceAction.java b/sdk/src/main/java/org/zstack/sdk/RegisterVmInstanceAction.java new file mode 100644 index 00000000000..1fb295bf5ae --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/RegisterVmInstanceAction.java @@ -0,0 +1,113 @@ +package org.zstack.sdk; + +import java.util.HashMap; +import java.util.Map; +import org.zstack.sdk.*; + +public class RegisterVmInstanceAction 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.RegisterVmInstanceResult value; + + public Result throwExceptionIfError() { + if (error != null) { + throw new ApiException( + String.format("error[code: %s, description: %s, details: %s]", error.code, error.description, error.details) + ); + } + + return this; + } + } + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String clusterUuid; + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String zoneUuid; + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String primaryStorageUuid; + + @Param(required = false, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String hostUuid; + + @Param(required = true, nonempty = false, nullElements = false, emptyString = true, noTrim = false) + public java.lang.String metadataPath; + + @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.RegisterVmInstanceResult value = res.getResult(org.zstack.sdk.RegisterVmInstanceResult.class); + ret.value = value == null ? new org.zstack.sdk.RegisterVmInstanceResult() : 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 = "/vm-instances/register"; + info.needSession = true; + info.needPoll = true; + info.parameterName = "params"; + return info; + } + +} diff --git a/sdk/src/main/java/org/zstack/sdk/RegisterVmInstanceResult.java b/sdk/src/main/java/org/zstack/sdk/RegisterVmInstanceResult.java new file mode 100644 index 00000000000..49510a84cb9 --- /dev/null +++ b/sdk/src/main/java/org/zstack/sdk/RegisterVmInstanceResult.java @@ -0,0 +1,14 @@ +package org.zstack.sdk; + +import org.zstack.sdk.VmInstanceInventory; + +public class RegisterVmInstanceResult { + public VmInstanceInventory inventory; + public void setInventory(VmInstanceInventory inventory) { + this.inventory = inventory; + } + public VmInstanceInventory getInventory() { + return this.inventory; + } + +} diff --git a/storage/src/main/java/org/zstack/storage/primary/PrimaryStorageBase.java b/storage/src/main/java/org/zstack/storage/primary/PrimaryStorageBase.java index b7f8cfbc24d..869e7133c26 100755 --- a/storage/src/main/java/org/zstack/storage/primary/PrimaryStorageBase.java +++ b/storage/src/main/java/org/zstack/storage/primary/PrimaryStorageBase.java @@ -14,6 +14,8 @@ import org.zstack.core.cloudbus.CloudBusListCallBack; import org.zstack.core.cloudbus.EventFacade; import org.zstack.core.componentloader.PluginRegistry; +import org.zstack.core.config.GlobalConfig; +import org.zstack.core.config.GlobalConfigDefinition; import org.zstack.core.db.*; import org.zstack.core.db.SimpleQuery.Op; import org.zstack.core.errorcode.ErrorFacade; @@ -27,6 +29,7 @@ import org.zstack.core.trash.TrashType; import org.zstack.core.workflow.FlowChainBuilder; import org.zstack.core.workflow.ShareFlow; +import org.zstack.core.workflow.ShareFlowChain; import org.zstack.header.apimediator.ApiMessageInterceptionException; import org.zstack.header.core.*; import org.zstack.header.core.trash.CleanTrashResult; @@ -49,16 +52,24 @@ import org.zstack.header.storage.primary.PrimaryStorageCanonicalEvent.PrimaryStorageDeletedData; import org.zstack.header.storage.primary.PrimaryStorageCanonicalEvent.PrimaryStorageStatusChangedData; import org.zstack.header.storage.snapshot.*; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupRefVO; +import org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO; +import org.zstack.header.tag.TagDefinition; import org.zstack.header.vm.*; import org.zstack.header.volume.*; +import org.zstack.resourceconfig.BindResourceConfig; import org.zstack.storage.volume.VolumeUtils; +import org.zstack.tag.SystemTag; +import org.zstack.utils.BeanUtils; import org.zstack.utils.CollectionDSL; import org.zstack.utils.DebugUtils; import org.zstack.utils.Utils; +import org.zstack.utils.gson.JSONObjectUtil; import org.zstack.utils.logging.CLogger; import javax.persistence.LockModeType; import javax.persistence.TypedQuery; +import java.lang.reflect.Field; import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -177,6 +188,8 @@ public void setNewAdded(boolean newAdded) { protected abstract void handle(GetVolumeSnapshotEncryptedOnPrimaryStorageMsg msg); + protected abstract void handle(GetVmInstanceMetadataFromPrimaryStorageMsg msg); + public PrimaryStorageBase(PrimaryStorageVO self) { this.self = self; } @@ -935,6 +948,10 @@ protected void handleApiMessage(APIMessage msg) { handle((APICleanUpStorageTrashOnPrimaryStorageMsg) msg); } else if (msg instanceof APIAddStorageProtocolMsg) { handle((APIAddStorageProtocolMsg) msg); + } else if (msg instanceof APIRegisterVmInstanceMsg) { + handle((APIRegisterVmInstanceMsg) msg); + } else if (msg instanceof APIGetVmInstanceMetadataFromPrimaryStorageMsg) { + handle((APIGetVmInstanceMetadataFromPrimaryStorageMsg) msg); } else { bus.dealWithUnknownMessage(msg); } @@ -1812,4 +1829,375 @@ protected ImageCacheVO createTemporaryImageCacheFromVolumeSnapshot(ImageInventor private static String getDeduplicateError(String operationName) { return String.format("an other %s task is running, cancel this operation", operationName); } + + private void handle(APIRegisterVmInstanceMsg msg) { + APIRegisterVmInstanceReply event = new APIRegisterVmInstanceReply(msg.getId()); + thdf.chainSubmit(new ChainTask(msg) { + @Override + public String getSyncSignature() { + return String.format("register-vm-from-%s", msg.getMetadataPath()); + } + + @Override + public void run(SyncTaskChain chain) { + registerVmInstance(msg, new ReturnValueCompletion(chain, msg) { + @Override + public void success(VmInstanceInventory vmInstanceInventory) { + event.setInventory(vmInstanceInventory); + bus.publish(event); + chain.next(); + } + + @Override + public void fail(ErrorCode errorCode) { + bus.publish(event); + chain.next(); + } + }); + } + + @Override + public String getName() { + return String.format("register-vm-from-%s", msg.getMetadataPath()); + } + }); + } + + private void registerVmInstance(APIRegisterVmInstanceMsg msg, ReturnValueCompletion completion) { + FlowChain chain = new ShareFlowChain(); + chain.setName("register-vm-from-metadata"); + chain.then(new ShareFlow() { + VmMetadata vmMetadata; + VmInstanceInventory vmInstanceInventory; + + @Override + public void setup() { + flow(new NoRollbackFlow() { + String __name__ = "read-metadata"; + + @Override + public void run(FlowTrigger trigger, Map data) { + ReadVmInstanceMetadataOnHypervisorMsg umsg = new ReadVmInstanceMetadataOnHypervisorMsg(); + umsg.setHostUuid(msg.getHostUuid()); + umsg.setMetadataPath(msg.getMetadataPath()); + bus.makeTargetServiceIdByResourceUuid(umsg, HostConstant.SERVICE_ID, msg.getHostUuid()); + bus.send(umsg, new CloudBusCallBack(msg) { + @Override + public void run(MessageReply r) { + if (!r.isSuccess()) { + trigger.fail(operr("failed to update vm[uuid=%s] on hypervisor.", + self.getUuid()).withCause(r.getError())); + return; + } + ReadVmInstanceMetadataOnHypervisorReply reply = r.castReply(); + vmMetadata = JSONObjectUtil.toObject(reply.getMetadata(), VmMetadata.class); + trigger.next(); + } + }); + } + }); + + flow(new NoRollbackFlow() { + String __name__ = "register-volume"; + + @Override + public void run(FlowTrigger trigger, Map data) { + List volumesString = vmMetadata.volumeVOs; + + List volumes = new ArrayList<>(); + volumesString.forEach(v -> volumes.add(JSONObjectUtil.toObject(v, VolumeVO.class))); + + List newVolumes = new ArrayList<>(); + volumes.forEach(v -> { + VolumeVO vo = new VolumeVO(); +// vo.setRootImageUuid(vo.getRootImageUuid()); + vo.setAccountUuid(msg.getSession().getAccountUuid()); + vo.setPrimaryStorageUuid(msg.getPrimaryStorageUuid()); + vo.setInstallPath(v.getInstallPath()); + + vo.setCreateDate(v.getCreateDate()); + vo.setDescription(v.getDescription()); + vo.setName(v.getName()); + vo.setSize(v.getSize()); + vo.setActualSize(v.getActualSize()); + vo.setState(v.getState()); + vo.setUuid(v.getUuid()); + vo.setVmInstanceUuid(v.getVmInstanceUuid()); + vo.setType(v.getType()); + vo.setCreateDate(v.getCreateDate()); + vo.setLastOpDate(v.getLastOpDate()); + vo.setDeviceId(v.getDeviceId()); + vo.setStatus(v.getStatus()); + vo.setFormat(v.getFormat()); + vo.setShareable(v.isShareable()); + vo.setVolumeQos(v.getVolumeQos()); + vo.setLastDetachDate(v.getLastDetachDate()); + vo.setLastVmInstanceUuid(v.getLastVmInstanceUuid()); + vo.setLastAttachDate(v.getLastAttachDate()); + vo.setProtocol(v.getProtocol()); + newVolumes.add(vo); + }); + dbf.persistCollection(newVolumes); + trigger.next(); + } + }); + + flow(new NoRollbackFlow() { + String __name__ = "register-snapshot"; + + @Override + public void run(FlowTrigger trigger, Map data) { + // 快照 + vmMetadata.volumeSnapshots.forEach((volumeUuid, snapshotList) -> { + // 一个 volume 有多个快照树 + // key = treeuuid + // value = snapshosts + Map> snapshotsByTreeUuid = new HashMap<>(); + snapshotList.forEach(snapshot -> { + VolumeSnapshotInventory inv = JSONObjectUtil.toObject(snapshot, VolumeSnapshotInventory.class); + if (snapshotsByTreeUuid.containsKey(inv.getTreeUuid())) { + snapshotsByTreeUuid.get(inv.getTreeUuid()).add(inv); + } else { + snapshotsByTreeUuid.put(inv.getTreeUuid(), new ArrayList<>()); + snapshotsByTreeUuid.get(inv.getTreeUuid()).add(inv); + } + }); + + // 遍历每一颗树 + snapshotsByTreeUuid.forEach((treeUuid, snapshots) -> { + //构建快照树 + VolumeSnapshotTree tree = VolumeSnapshotTree.fromInventories(snapshots); + // 层级遍历 快照 + List levelOrderTraversals = tree.levelOrderTraversal(); + // 判断当前树有没有 latest 节点 + boolean treeIsCurrent = levelOrderTraversals.stream().anyMatch(VolumeSnapshotInventory::isLatest); + + // 先创建快照树,VolumeSnapshotVO 外键依赖 VolumeSnapshotTreeVO + VolumeSnapshotTreeVO newTree = new VolumeSnapshotTreeVO(); + newTree.setCurrent(treeIsCurrent); + newTree.setVolumeUuid(volumeUuid); + newTree.setUuid(treeUuid); + newTree.setStatus(VolumeSnapshotTreeStatus.Completed); + dbf.persist(newTree); + + // 按照层级遍历的快照构建VolumeSnapshotTreeVO + levelOrderTraversals.forEach(snapshot -> { + VolumeSnapshotVO vo = new VolumeSnapshotVO(); + vo.setPrimaryStorageUuid(msg.getPrimaryStorageUuid()); + vo.setPrimaryStorageInstallPath(snapshot.getPrimaryStorageInstallPath()); + + vo.setName(snapshot.getName()); + vo.setCreateDate(snapshot.getCreateDate()); + vo.setDescription(snapshot.getDescription()); + vo.setLastOpDate(snapshot.getLastOpDate()); + vo.setParentUuid(snapshot.getParentUuid()); + vo.setState(VolumeSnapshotState.valueOf(snapshot.getState())); + vo.setType(snapshot.getType()); + vo.setVolumeUuid(snapshot.getVolumeUuid()); + vo.setFormat(snapshot.getFormat()); + vo.setUuid(snapshot.getUuid()); + vo.setStatus(VolumeSnapshotStatus.valueOf(snapshot.getStatus())); + vo.setLatest(snapshot.isLatest()); + vo.setSize(snapshot.getSize()); + vo.setVolumeType(snapshot.getVolumeType()); + vo.setTreeUuid(snapshot.getTreeUuid()); + vo.setDistance(snapshot.getDistance()); + dbf.persist(vo); + }); + }); + }); + + // 快照组 + List newGroups = new ArrayList<>(); + vmMetadata.volumeSnapshotGroupVO.forEach(group -> { + VolumeSnapshotGroupVO vo = JSONObjectUtil.toObject(group, VolumeSnapshotGroupVO.class); + vo.setAccountUuid(msg.getSession().getAccountUuid()); + newGroups.add(vo); + }); + dbf.persistCollection(newGroups); + + // 快照组ref + List newGroupRefs = new ArrayList<>(); + vmMetadata.volumeSnapshotGroupRefVO.forEach(group -> { + VolumeSnapshotGroupRefVO vo = JSONObjectUtil.toObject(group, VolumeSnapshotGroupRefVO.class); + newGroupRefs.add(vo); + }); + dbf.persistCollection(newGroupRefs); + + trigger.next(); + } + }); + + flow(new NoRollbackFlow() { + String __name__ = "register-vmInstance"; + + @Override + public void run(FlowTrigger trigger, Map data) { + VmInstanceVO metaVm = JSONObjectUtil.toObject(vmMetadata.vmInstanceVO, VmInstanceVO.class); + VmInstanceVO newVm = new VmInstanceVO(); + + newVm.setClusterUuid(msg.getClusterUuid()); + newVm.setHostUuid(msg.getHostUuid()); + // 寻找有没有cache的tag lv 构建imageCache +// newVm.setImageUuid(); + + newVm.setUuid(metaVm.getUuid()); + newVm.setName(metaVm.getName()); + newVm.setDescription(metaVm.getDescription()); + newVm.setType(metaVm.getType()); + newVm.setHypervisorType(metaVm.getHypervisorType()); + newVm.setCreateDate(metaVm.getCreateDate()); + newVm.setLastOpDate(metaVm.getLastOpDate()); + newVm.setState(metaVm.getState()); + newVm.setRootVolumeUuid(metaVm.getRootVolumeUuid()); + newVm.setInternalId(metaVm.getInternalId()); + newVm.setCpuNum(metaVm.getCpuNum()); + newVm.setCpuSpeed(metaVm.getCpuSpeed()); + newVm.setMemorySize(metaVm.getMemorySize()); + newVm.setReservedMemorySize(metaVm.getReservedMemorySize()); + newVm.setAllocatorStrategy(metaVm.getAllocatorStrategy()); + newVm.setPlatform(metaVm.getPlatform()); + newVm.setArchitecture(metaVm.getArchitecture()); + newVm.setGuestOsType(metaVm.getGuestOsType()); + dbf.persist(newVm); + vmInstanceInventory = VmInstanceInventory.valueOf(newVm); + trigger.next(); +// List vmSystemTags = vmMetadata.vmSystemTags; +// List vmResourceConfigs = vmMetadata.vmResourceConfigs; +// +// try { +// List systemTags = getResourceSystemTagFromSystem(VmInstanceVO.class.getSimpleName()); +// List resourceConfigs = getResourceConfigFromSystem(VmInstanceVO.class.getSimpleName()); +// +// List tagVOS = new ArrayList<>(); +// vmSystemTags.forEach(tag -> { +// List info = asList(tag.split("_")); +// String t = info.get(0); +// Boolean inherent = Boolean.valueOf(info.get(1)); +// String type = info.get(2); +// systemTags.forEach(it -> { +// if (!it.isMatch(t)) { +// return; +// } +// SystemTagVO vo = new SystemTagVO(); +// vo.setTag(t); +// vo.setType(TagType.valueOf(type)); +// vo.setInherent(inherent); +// vo.setResourceType(VmInstanceVO.class.getSimpleName()); +// vo.setResourceUuid(newVm.getUuid()); +// tagVOS.add(vo); +// }); +// }); +// +// List configVOS = new ArrayList<>(); +// vmResourceConfigs.forEach(tag -> { +// List info = asList(tag.split("_")); +// String identity = info.get(0); +// String value = info.get(1); +// resourceConfigs.forEach(it -> { +// if (it.getIdentity() == identity) { +// return; +// } +// ResourceConfigVO vo = new ResourceConfigVO(); +// vo.setCategory(identity); +// vo.setName(identity); +// vo.setValue(value); +// vo.setResourceType(VmInstanceVO.class.getSimpleName()); +// vo.setResourceUuid(newVm.getUuid()); +// configVOS.add(vo); +// }); +// }); +// } catch (IllegalAccessException | InstantiationException e) { +// throw new RuntimeException(e); +// } + } + }); + + done(new FlowDoneHandler(completion) { + @Override + public void handle(Map data) { + completion.success(vmInstanceInventory); + } + }); + + error(new FlowErrorHandler(msg) { + @Override + public void handle(ErrorCode errCode, Map data) { + completion.fail(errCode); + } + }); + } + }).start(); + } + + private List getResourceSystemTagFromSystem(String resourceType) throws IllegalAccessException, InstantiationException { + List systemTags = new ArrayList<>(); + + Set> classes = BeanUtils.reflections.getTypesAnnotatedWith(TagDefinition.class); + for (Class clazz : classes) { + Field[] fields = clazz.getDeclaredFields(); + for (Field field : fields) { + if (!SystemTag.class.isAssignableFrom(field.getType())) { + continue; + } + + SystemTag systemTag = (SystemTag) field.get(clazz.newInstance()); + + if (resourceType.equals(systemTag.getResourceClass().getName())) { + systemTags.add(systemTag); + } + } + } + return systemTags; + } + + private List getResourceConfigFromSystem(String resourceType) throws IllegalAccessException, InstantiationException { + List globalConfigs = new ArrayList<>(); + + Set> classes = BeanUtils.reflections.getTypesAnnotatedWith(GlobalConfigDefinition.class); + for (Class clazz : classes) { + Field[] fields = clazz.getDeclaredFields(); + for (Field field : fields) { + if (!GlobalConfig.class.isAssignableFrom(field.getType())) { + continue; + } + GlobalConfig globalConfig = (GlobalConfig) field.get(clazz.newInstance()); + + BindResourceConfig bindResourceConfig = field.getAnnotation(BindResourceConfig.class); + if (bindResourceConfig == null) { + continue; + } + + List bindResourceConfigs = Arrays.stream(bindResourceConfig.value()).map(Class::getName).collect(Collectors.toList()); + + if (bindResourceConfigs.contains(resourceType)) { + globalConfigs.add(globalConfig); + } + } + } + + return globalConfigs; + } + + private void handle(APIGetVmInstanceMetadataFromPrimaryStorageMsg msg) { + APIGetVmInstanceMetadataFromPrimaryStorageReply reply = new APIGetVmInstanceMetadataFromPrimaryStorageReply(); + + GetVmInstanceMetadataFromPrimaryStorageMsg gmsg = new GetVmInstanceMetadataFromPrimaryStorageMsg(); + gmsg.setPrimaryStorageUuid(msg.getPrimaryStorageUuid()); + bus.makeTargetServiceIdByResourceUuid(gmsg, PrimaryStorageConstant.SERVICE_ID, msg.getPrimaryStorageUuid()); + + bus.send(gmsg, new CloudBusCallBack(msg) { + @Override + public void run(MessageReply r) { + if (!r.isSuccess()) { + reply.setError(r.getError()); + bus.reply(msg, reply); + return; + } + GetVmInstanceMetadataFromPrimaryStorageReply re = r.castReply(); + reply.setVmInstanceMetadata(re.getVmInstanceMetadata()); + bus.reply(msg, reply); + } + }); + } } diff --git a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy index 07c05b73b9e..5ddb04eac93 100644 --- a/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy +++ b/testlib/src/main/java/org/zstack/testlib/ApiHelper.groovy @@ -27406,6 +27406,33 @@ abstract class ApiHelper { } + def registerVmInstance(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.RegisterVmInstanceAction.class) Closure c) { + def a = new org.zstack.sdk.RegisterVmInstanceAction() + 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 reimageVmInstance(@DelegatesTo(strategy = Closure.OWNER_FIRST, value = org.zstack.sdk.ReimageVmInstanceAction.class) Closure c) { def a = new org.zstack.sdk.ReimageVmInstanceAction() a.sessionId = Test.currentEnvSpec?.session?.uuid diff --git a/testlib/src/main/java/org/zstack/testlib/KVMSimulator.groovy b/testlib/src/main/java/org/zstack/testlib/KVMSimulator.groovy index 94fc178245d..fcfb5a8ff78 100755 --- a/testlib/src/main/java/org/zstack/testlib/KVMSimulator.groovy +++ b/testlib/src/main/java/org/zstack/testlib/KVMSimulator.groovy @@ -680,5 +680,15 @@ class KVMSimulator implements Simulator { spec.simulator(KVMConstant.KVM_UPDATE_HOSTNAME_PATH) { return new UpdateHostnameRsp() } + + spec.simulator(KVMConstant.WRITE_VM_INSTANCE_METADATA_PATH) { HttpEntity e -> + return new WriteVmInstanceMetadataRsp() + } + + spec.simulator(KVMConstant.READ_VM_INSTANCE_METADATA_PATH) { HttpEntity e -> + def rsp = new ReadVmInstanceMetadataRsp() + rsp.metadata = "{\"vmInstanceVO\":\"{\\\"vmNics\\\":[{\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"l3NetworkUuid\\\":\\\"28d3a9c8e54c48f290ab4f9e52bbb006\\\",\\\"mac\\\":\\\"fa:81:16:b2:32:00\\\",\\\"hypervisorType\\\":\\\"KVM\\\",\\\"deviceId\\\":0,\\\"internalName\\\":\\\"vnic1.0\\\",\\\"driverType\\\":\\\"virtio\\\",\\\"type\\\":\\\"VNIC\\\",\\\"state\\\":\\\"enable\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"usedIps\\\":[],\\\"uuid\\\":\\\"a77234a5a45a4a7caca46d01d746f41f\\\",\\\"resourceType\\\":\\\"VmNicVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.vm.VmNicVO\\\"}],\\\"allVolumes\\\":[{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/7832daf63d9b41d68bd1460c20ed0e0a\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":2,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/7832daf63d9b41d68bd1460c20ed0e0a\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":2,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"resourceName\\\":\\\"volumeName1null\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"},{\\\"name\\\":\\\"ROOT-for-vmName\\\",\\\"description\\\":\\\"Root volume for VM[uuid:77bc3074f5f4438c836ce6c56bc5a4aa]\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"rootImageUuid\\\":\\\"575591e021b446e4b465e981da3a8d1b\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/bc5ab54cb3d04635923a2a9d0b5fc73f\\\",\\\"type\\\":\\\"Root\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":0,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"ROOT-for-vmName\\\",\\\"description\\\":\\\"Root volume for VM[uuid:77bc3074f5f4438c836ce6c56bc5a4aa]\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"rootImageUuid\\\":\\\"575591e021b446e4b465e981da3a8d1b\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/bc5ab54cb3d04635923a2a9d0b5fc73f\\\",\\\"type\\\":\\\"Root\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":0,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"resourceName\\\":\\\"ROOT-for-vmName\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"},{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/43436624dc714282913e0a141246629e\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":1,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/43436624dc714282913e0a141246629e\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":1,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"resourceName\\\":\\\"volumeName1null\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"},{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b711f22ad5c045b6ad1d770d4f301d05\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":3,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b711f22ad5c045b6ad1d770d4f301d05\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":3,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"resourceName\\\":\\\"volumeName1null\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"}],\\\"vmCdRoms\\\":[{\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"deviceId\\\":0,\\\"name\\\":\\\"vm-77bc3074f5f4438c836ce6c56bc5a4aa-cdRom\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"uuid\\\":\\\"e8a57f5b8c834573b4da822b672740e4\\\",\\\"resourceName\\\":\\\"vm-77bc3074f5f4438c836ce6c56bc5a4aa-cdRom\\\",\\\"resourceType\\\":\\\"VmCdRomVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.vm.cdrom.VmCdRomVO\\\"}],\\\"name\\\":\\\"vmName\\\",\\\"zoneUuid\\\":\\\"d71de3f6981d46c9a2be43e5fcf31021\\\",\\\"clusterUuid\\\":\\\"29f13acb820d4f7f8cd3593b79b742e5\\\",\\\"imageUuid\\\":\\\"575591e021b446e4b465e981da3a8d1b\\\",\\\"hostUuid\\\":\\\"e99debc09c5845fb8ed682320117f4ce\\\",\\\"internalId\\\":1,\\\"lastHostUuid\\\":\\\"e99debc09c5845fb8ed682320117f4ce\\\",\\\"rootVolumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"defaultL3NetworkUuid\\\":\\\"28d3a9c8e54c48f290ab4f9e52bbb006\\\",\\\"type\\\":\\\"UserVm\\\",\\\"hypervisorType\\\":\\\"KVM\\\",\\\"cpuNum\\\":1,\\\"cpuSpeed\\\":0,\\\"memorySize\\\":1073741824,\\\"reservedMemorySize\\\":0,\\\"platform\\\":\\\"Linux\\\",\\\"architecture\\\":\\\"x86_64\\\",\\\"guestOsType\\\":\\\"CentOS\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:45 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\",\\\"state\\\":\\\"Running\\\",\\\"uuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"resourceName\\\":\\\"vmName\\\",\\\"resourceType\\\":\\\"VmInstanceVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.vm.VmInstanceVO\\\"}\",\"vmSystemTags\":[\"{\\\"inherent\\\":false,\\\"uuid\\\":\\\"38a9b4bd1b8b3dfa829d582aafb2ec25\\\",\\\"resourceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"resourceType\\\":\\\"VmInstanceVO\\\",\\\"tag\\\":\\\"syncPorts::77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:45 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:45 AM\\\"}\",\"{\\\"inherent\\\":true,\\\"uuid\\\":\\\"3e984cdb5edb47559a3f907e1d49bfcc\\\",\\\"resourceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"resourceType\\\":\\\"VmInstanceVO\\\",\\\"tag\\\":\\\"additionalQmp\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\"}\",\"{\\\"inherent\\\":false,\\\"uuid\\\":\\\"85237d3a06133523bd84669349040ec5\\\",\\\"resourceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"resourceType\\\":\\\"VmInstanceVO\\\",\\\"tag\\\":\\\"vmPriority::Normal\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\"}\",\"{\\\"inherent\\\":true,\\\"uuid\\\":\\\"b7c5d5e94ba13159ab2c8c65c1d7bc29\\\",\\\"resourceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"resourceType\\\":\\\"VmInstanceVO\\\",\\\"tag\\\":\\\"vmSystemSerialNumber::8ed14f00-50bb-4e9e-9448-e92c0f67e1e1\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\"}\",\"{\\\"inherent\\\":false,\\\"uuid\\\":\\\"d5019730aeba3e57b2f1a3e8d74d0cbc\\\",\\\"resourceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"resourceType\\\":\\\"VmInstanceVO\\\",\\\"tag\\\":\\\"ha::None\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\"}\"],\"vmResourceConfigs\":[\"{\\\"uuid\\\":\\\"8d2f9937a28846aba03fded826c10c73\\\",\\\"resourceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"resourceType\\\":\\\"VmInstanceVO\\\",\\\"name\\\":\\\"nicMultiQueueNum\\\",\\\"description\\\":\\\"default num of queues on virtio nic\\\",\\\"category\\\":\\\"vm\\\",\\\"value\\\":\\\"1\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\"}\"],\"volumeVOs\":[\"{\\\"name\\\":\\\"ROOT-for-vmName\\\",\\\"description\\\":\\\"Root volume for VM[uuid:77bc3074f5f4438c836ce6c56bc5a4aa]\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"rootImageUuid\\\":\\\"575591e021b446e4b465e981da3a8d1b\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/bc5ab54cb3d04635923a2a9d0b5fc73f\\\",\\\"type\\\":\\\"Root\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":0,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"ROOT-for-vmName\\\",\\\"description\\\":\\\"Root volume for VM[uuid:77bc3074f5f4438c836ce6c56bc5a4aa]\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"rootImageUuid\\\":\\\"575591e021b446e4b465e981da3a8d1b\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/bc5ab54cb3d04635923a2a9d0b5fc73f\\\",\\\"type\\\":\\\"Root\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":0,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"resourceName\\\":\\\"ROOT-for-vmName\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"}\",\"{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b711f22ad5c045b6ad1d770d4f301d05\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":3,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b711f22ad5c045b6ad1d770d4f301d05\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":3,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"resourceName\\\":\\\"volumeName1null\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"}\",\"{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/7832daf63d9b41d68bd1460c20ed0e0a\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":2,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/7832daf63d9b41d68bd1460c20ed0e0a\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":2,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"resourceName\\\":\\\"volumeName1null\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"}\",\"{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/43436624dc714282913e0a141246629e\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":1,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"isShareable\\\":false,\\\"shadow\\\":{\\\"name\\\":\\\"volumeName1null\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"installPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/43436624dc714282913e0a141246629e\\\",\\\"type\\\":\\\"Data\\\",\\\"status\\\":\\\"Ready\\\",\\\"size\\\":1073741824,\\\"actualSize\\\":0,\\\"deviceId\\\":1,\\\"format\\\":\\\"qcow2\\\",\\\"state\\\":\\\"Enabled\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"isShareable\\\":false},\\\"uuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"resourceName\\\":\\\"volumeName1null\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.volume.VolumeVO\\\"}\"],\"volumeSystemTags\":{\"b7290c15276b4700af2c1b108b2b62e1\":[\"{\\\"inherent\\\":true,\\\"uuid\\\":\\\"b9874ec02b583538a5603e7eec8c5b69\\\",\\\"resourceUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"tag\\\":\\\"kvm::volume::0x000f59f934d14a68\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\"],\"8d1e76eca52647f5a4544b9ff2d370de\":[\"{\\\"inherent\\\":true,\\\"uuid\\\":\\\"96cb4b006708387b8318f0fd6ae6ab8b\\\",\\\"resourceUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"tag\\\":\\\"kvm::volume::0x000faad0c9ca4231\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:48 AM\\\"}\"],\"ae9f28cb5055498e8661793d204208ba\":[\"{\\\"inherent\\\":true,\\\"uuid\\\":\\\"5ceacd06bf753b0c8abe5bcef9b5a894\\\",\\\"resourceUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"tag\\\":\\\"kvm::volume::0x000fc4ffeaab6e71\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\"],\"db8251e870b14d60ace863a7598cce8b\":[\"{\\\"inherent\\\":true,\\\"uuid\\\":\\\"d53865baa675373a9bf07a6f501eab41\\\",\\\"resourceUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"resourceType\\\":\\\"VolumeVO\\\",\\\"tag\\\":\\\"kvm::volume::0x000fad154165d205\\\",\\\"type\\\":\\\"System\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"}\"]},\"volumeResourceConfigs\":{\"b7290c15276b4700af2c1b108b2b62e1\":[],\"8d1e76eca52647f5a4544b9ff2d370de\":[],\"ae9f28cb5055498e8661793d204208ba\":[],\"db8251e870b14d60ace863a7598cce8b\":[]},\"vmNicVOs\":[\"{\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"l3NetworkUuid\\\":\\\"28d3a9c8e54c48f290ab4f9e52bbb006\\\",\\\"mac\\\":\\\"fa:81:16:b2:32:00\\\",\\\"hypervisorType\\\":\\\"KVM\\\",\\\"deviceId\\\":0,\\\"internalName\\\":\\\"vnic1.0\\\",\\\"driverType\\\":\\\"virtio\\\",\\\"type\\\":\\\"VNIC\\\",\\\"state\\\":\\\"enable\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:46 AM\\\",\\\"usedIps\\\":[],\\\"uuid\\\":\\\"a77234a5a45a4a7caca46d01d746f41f\\\",\\\"resourceType\\\":\\\"VmNicVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.vm.VmNicVO\\\"}\"],\"vmNicSystemTags\":{\"a77234a5a45a4a7caca46d01d746f41f\":[]},\"vmNicResourceConfigs\":{\"a77234a5a45a4a7caca46d01d746f41f\":[]},\"volumeSnapshots\":{\"b7290c15276b4700af2c1b108b2b62e1\":[\"{\\\"uuid\\\":\\\"7832daf63d9b41d68bd1460c20ed0e0a\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"treeUuid\\\":\\\"f8042fb57bb04ebcb0f01bab2abeb5dd\\\",\\\"parentUuid\\\":\\\"a31c3de68ce246538e982e0e5c7d2d73\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a31c3de68ce246538e982e0e5c7d2d73\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":true,\\\"size\\\":1,\\\"distance\\\":4,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\"}\",\"{\\\"uuid\\\":\\\"a31c3de68ce246538e982e0e5c7d2d73\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"treeUuid\\\":\\\"f8042fb57bb04ebcb0f01bab2abeb5dd\\\",\\\"parentUuid\\\":\\\"b5d771aa83584c9c88d9b84147dfc9ad\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b5d771aa83584c9c88d9b84147dfc9ad\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":3,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\"}\",\"{\\\"uuid\\\":\\\"b5d771aa83584c9c88d9b84147dfc9ad\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"treeUuid\\\":\\\"f8042fb57bb04ebcb0f01bab2abeb5dd\\\",\\\"parentUuid\\\":\\\"bcc3ed8070984cd691c62f421aeaa44d\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/bcc3ed8070984cd691c62f421aeaa44d\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":2,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\"}\",\"{\\\"uuid\\\":\\\"bcc3ed8070984cd691c62f421aeaa44d\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"treeUuid\\\":\\\"f8042fb57bb04ebcb0f01bab2abeb5dd\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":1,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:51 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\"}\"],\"8d1e76eca52647f5a4544b9ff2d370de\":[\"{\\\"uuid\\\":\\\"04ef6d31675f4ba5816b104920dc3e2c\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"treeUuid\\\":\\\"d4a030087ed3407894c393ee81f0bc3b\\\",\\\"parentUuid\\\":\\\"79caace79a1048d58ea7c0b38815bbd0\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/79caace79a1048d58ea7c0b38815bbd0\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":0,\\\"distance\\\":3,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\"}\",\"{\\\"uuid\\\":\\\"61e2ada0170142bb8b303910a27690aa\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"treeUuid\\\":\\\"d4a030087ed3407894c393ee81f0bc3b\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":0,\\\"distance\\\":1,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:51 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\"}\",\"{\\\"uuid\\\":\\\"79caace79a1048d58ea7c0b38815bbd0\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"treeUuid\\\":\\\"d4a030087ed3407894c393ee81f0bc3b\\\",\\\"parentUuid\\\":\\\"61e2ada0170142bb8b303910a27690aa\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/61e2ada0170142bb8b303910a27690aa\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":0,\\\"distance\\\":2,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\"}\",\"{\\\"uuid\\\":\\\"bc5ab54cb3d04635923a2a9d0b5fc73f\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"treeUuid\\\":\\\"d4a030087ed3407894c393ee81f0bc3b\\\",\\\"parentUuid\\\":\\\"04ef6d31675f4ba5816b104920dc3e2c\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/04ef6d31675f4ba5816b104920dc3e2c\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":true,\\\"size\\\":0,\\\"distance\\\":4,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\"}\"],\"ae9f28cb5055498e8661793d204208ba\":[\"{\\\"uuid\\\":\\\"1aaa92b2d1eb4c36bb8951b8e1521b34\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"treeUuid\\\":\\\"055b80b0727e4117b246b1b29f2d58b6\\\",\\\"parentUuid\\\":\\\"aefbe47465c047d1b118321c34425869\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/aefbe47465c047d1b118321c34425869\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":3,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\"}\",\"{\\\"uuid\\\":\\\"69e85ac72fea4263a55cbcd21785006e\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"treeUuid\\\":\\\"055b80b0727e4117b246b1b29f2d58b6\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":1,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:51 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\"}\",\"{\\\"uuid\\\":\\\"aefbe47465c047d1b118321c34425869\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"treeUuid\\\":\\\"055b80b0727e4117b246b1b29f2d58b6\\\",\\\"parentUuid\\\":\\\"69e85ac72fea4263a55cbcd21785006e\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/69e85ac72fea4263a55cbcd21785006e\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":2,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\"}\",\"{\\\"uuid\\\":\\\"b711f22ad5c045b6ad1d770d4f301d05\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"treeUuid\\\":\\\"055b80b0727e4117b246b1b29f2d58b6\\\",\\\"parentUuid\\\":\\\"1aaa92b2d1eb4c36bb8951b8e1521b34\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/1aaa92b2d1eb4c36bb8951b8e1521b34\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":true,\\\"size\\\":1,\\\"distance\\\":4,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\"}\"],\"db8251e870b14d60ace863a7598cce8b\":[\"{\\\"uuid\\\":\\\"43436624dc714282913e0a141246629e\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"treeUuid\\\":\\\"1c0773fa98f4465b8e535ba3c00dc039\\\",\\\"parentUuid\\\":\\\"a70bb2be871644b6ad12ac8d6e9524d0\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a70bb2be871644b6ad12ac8d6e9524d0\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":true,\\\"size\\\":1,\\\"distance\\\":4,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\"}\",\"{\\\"uuid\\\":\\\"791f523bc99a4f08bd70b5a59d8ed5c8\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"treeUuid\\\":\\\"1c0773fa98f4465b8e535ba3c00dc039\\\",\\\"parentUuid\\\":\\\"a35b7ae1616a4974b2f80654c5527fbb\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a35b7ae1616a4974b2f80654c5527fbb\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":2,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\"}\",\"{\\\"uuid\\\":\\\"a35b7ae1616a4974b2f80654c5527fbb\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"treeUuid\\\":\\\"1c0773fa98f4465b8e535ba3c00dc039\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":1,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:51 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:53 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\"}\",\"{\\\"uuid\\\":\\\"a70bb2be871644b6ad12ac8d6e9524d0\\\",\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"type\\\":\\\"Hypervisor\\\",\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"treeUuid\\\":\\\"1c0773fa98f4465b8e535ba3c00dc039\\\",\\\"parentUuid\\\":\\\"791f523bc99a4f08bd70b5a59d8ed5c8\\\",\\\"primaryStorageUuid\\\":\\\"e121a11157bb4746ad3c8d56c3760a3e\\\",\\\"primaryStorageInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/791f523bc99a4f08bd70b5a59d8ed5c8\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"format\\\":\\\"qcow2\\\",\\\"latest\\\":false,\\\"size\\\":1,\\\"distance\\\":3,\\\"state\\\":\\\"Enabled\\\",\\\"status\\\":\\\"Ready\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"backupStorageRefs\\\":[],\\\"groupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\"}\"]},\"volumeSnapshotGroupVO\":[\"{\\\"snapshotCount\\\":4,\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeSnapshotRefs\\\":[{\\\"volumeSnapshotUuid\\\":\\\"a35b7ae1616a4974b2f80654c5527fbb\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"69e85ac72fea4263a55cbcd21785006e\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"61e2ada0170142bb8b303910a27690aa\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"bcc3ed8070984cd691c62f421aeaa44d\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}],\\\"uuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"resourceName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"resourceType\\\":\\\"VolumeSnapshotGroupVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO\\\"}\",\"{\\\"snapshotCount\\\":4,\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeSnapshotRefs\\\":[{\\\"volumeSnapshotUuid\\\":\\\"b5d771aa83584c9c88d9b84147dfc9ad\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/bcc3ed8070984cd691c62f421aeaa44d\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"79caace79a1048d58ea7c0b38815bbd0\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/61e2ada0170142bb8b303910a27690aa\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"aefbe47465c047d1b118321c34425869\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/69e85ac72fea4263a55cbcd21785006e\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"791f523bc99a4f08bd70b5a59d8ed5c8\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a35b7ae1616a4974b2f80654c5527fbb\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"}],\\\"uuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"resourceName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"resourceType\\\":\\\"VolumeSnapshotGroupVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO\\\"}\",\"{\\\"snapshotCount\\\":4,\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeSnapshotRefs\\\":[{\\\"volumeSnapshotUuid\\\":\\\"1aaa92b2d1eb4c36bb8951b8e1521b34\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/aefbe47465c047d1b118321c34425869\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"a70bb2be871644b6ad12ac8d6e9524d0\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/791f523bc99a4f08bd70b5a59d8ed5c8\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"04ef6d31675f4ba5816b104920dc3e2c\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/79caace79a1048d58ea7c0b38815bbd0\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"a31c3de68ce246538e982e0e5c7d2d73\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b5d771aa83584c9c88d9b84147dfc9ad\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}],\\\"uuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"resourceName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"resourceType\\\":\\\"VolumeSnapshotGroupVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO\\\"}\",\"{\\\"snapshotCount\\\":4,\\\"name\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"vmInstanceUuid\\\":\\\"77bc3074f5f4438c836ce6c56bc5a4aa\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeSnapshotRefs\\\":[{\\\"volumeSnapshotUuid\\\":\\\"43436624dc714282913e0a141246629e\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a70bb2be871644b6ad12ac8d6e9524d0\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"bc5ab54cb3d04635923a2a9d0b5fc73f\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/04ef6d31675f4ba5816b104920dc3e2c\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"b711f22ad5c045b6ad1d770d4f301d05\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/1aaa92b2d1eb4c36bb8951b8e1521b34\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"},{\\\"volumeSnapshotUuid\\\":\\\"7832daf63d9b41d68bd1460c20ed0e0a\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a31c3de68ce246538e982e0e5c7d2d73\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}],\\\"uuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"resourceName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"resourceType\\\":\\\"VolumeSnapshotGroupVO\\\",\\\"concreteResourceType\\\":\\\"org.zstack.header.storage.snapshot.group.VolumeSnapshotGroupVO\\\"}\"],\"volumeSnapshotGroupRefVO\":[\"{\\\"volumeSnapshotUuid\\\":\\\"04ef6d31675f4ba5816b104920dc3e2c\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/79caace79a1048d58ea7c0b38815bbd0\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"1aaa92b2d1eb4c36bb8951b8e1521b34\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/aefbe47465c047d1b118321c34425869\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"43436624dc714282913e0a141246629e\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a70bb2be871644b6ad12ac8d6e9524d0\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"61e2ada0170142bb8b303910a27690aa\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"69e85ac72fea4263a55cbcd21785006e\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"7832daf63d9b41d68bd1460c20ed0e0a\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a31c3de68ce246538e982e0e5c7d2d73\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"791f523bc99a4f08bd70b5a59d8ed5c8\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/a35b7ae1616a4974b2f80654c5527fbb\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"79caace79a1048d58ea7c0b38815bbd0\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/61e2ada0170142bb8b303910a27690aa\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"a31c3de68ce246538e982e0e5c7d2d73\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b5d771aa83584c9c88d9b84147dfc9ad\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"a35b7ae1616a4974b2f80654c5527fbb\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"a70bb2be871644b6ad12ac8d6e9524d0\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"7648a93930db473785b0abc0e0716c1a\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":1,\\\"volumeUuid\\\":\\\"db8251e870b14d60ace863a7598cce8b\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/791f523bc99a4f08bd70b5a59d8ed5c8\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:56 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:49 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"aefbe47465c047d1b118321c34425869\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/69e85ac72fea4263a55cbcd21785006e\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"b5d771aa83584c9c88d9b84147dfc9ad\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"6db066c890d141008e8ff18bd5940d77\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/bcc3ed8070984cd691c62f421aeaa44d\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:54 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"b711f22ad5c045b6ad1d770d4f301d05\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":3,\\\"volumeUuid\\\":\\\"ae9f28cb5055498e8661793d204208ba\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/1aaa92b2d1eb4c36bb8951b8e1521b34\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"bc5ab54cb3d04635923a2a9d0b5fc73f\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"d2d486455d6c472cbb7391958edebea5\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":0,\\\"volumeUuid\\\":\\\"8d1e76eca52647f5a4544b9ff2d370de\\\",\\\"volumeName\\\":\\\"ROOT-for-vmName\\\",\\\"volumeType\\\":\\\"Root\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/04ef6d31675f4ba5816b104920dc3e2c\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-ROOT-for-vmName\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:57 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:47 AM\\\"}\",\"{\\\"volumeSnapshotUuid\\\":\\\"bcc3ed8070984cd691c62f421aeaa44d\\\",\\\"volumeSnapshotGroupUuid\\\":\\\"01171364e6704dbe83c36167de52d719\\\",\\\"snapshotDeleted\\\":false,\\\"deviceId\\\":2,\\\"volumeUuid\\\":\\\"b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeName\\\":\\\"volumeName1null\\\",\\\"volumeType\\\":\\\"Data\\\",\\\"volumeSnapshotInstallPath\\\":\\\"sharedblock://e121a11157bb4746ad3c8d56c3760a3e/b7290c15276b4700af2c1b108b2b62e1\\\",\\\"volumeSnapshotName\\\":\\\"group-8d1e76eca52647f5a4544b9ff2d370de-volumeName1null\\\",\\\"createDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"lastOpDate\\\":\\\"Jan 28, 2026 4:15:52 AM\\\",\\\"volumeLastAttachDate\\\":\\\"Jan 28, 2026 4:15:50 AM\\\"}\"],\"volumeSnapshotReferenceVO\":{},\"volumeSnapshotReferenceTreeVO\":{},\"EncryptedResourceKeyRefVO\":{}}" + return rsp + } } }