From 8b608f5f5d6d6ec73fa7b4bdeadd08c74b313b9c Mon Sep 17 00:00:00 2001 From: dhslove <44049806+dhslove@users.noreply.github.com> Date: Mon, 25 May 2026 06:00:56 +0900 Subject: [PATCH 1/4] Integrate v2k n2k import workflows --- .../vm/ExecuteImportVMTaskActionCmd.java | 115 ++ ...rtUnmanagedInstanceForAblestackN2KCmd.java | 113 ++ ...rtUnmanagedInstanceForAblestackV2KCmd.java | 13 +- .../api/command/admin/vm/ImportVmCmd.java | 2 +- .../admin/vm/ListImportVMTaskEventsCmd.java | 77 + .../admin/vm/ListImportVMTasksCmd.java | 54 + .../command/admin/vm/ListVmsForImportCmd.java | 79 +- .../vm/PreflightAblestackVmImportCmd.java | 197 ++ .../AblestackVmImportPreflightResponse.java | 156 ++ .../response/ImportVMTaskEventResponse.java | 135 ++ .../api/response/ImportVMTaskResponse.java | 193 ++ .../apache/cloudstack/vm/ImportVmTask.java | 135 +- .../vm/ImportVmTaskSourceCredential.java | 54 + .../cloudstack/vm/ImportVmTaskStatus.java | 108 ++ .../cloudstack/vm/ImportVmTasksManager.java | 29 +- .../apache/cloudstack/vm/VmImportService.java | 5 + .../agent/api/AblestackN2KCleanupCommand.java | 50 + .../AblestackN2KConvertInstanceCommand.java | 297 +++ .../agent/api/AblestackN2KStatusAnswer.java | 158 ++ .../agent/api/AblestackN2KStatusCommand.java | 44 + .../agent/api/AblestackV2KCleanupCommand.java | 63 + .../AblestackV2KConvertInstanceCommand.java | 180 ++ .../api/AblestackV2KListVmwareVmsAnswer.java | 51 + .../api/AblestackV2KListVmwareVmsCommand.java | 86 + .../agent/api/AblestackV2KStatusAnswer.java | 90 + ...ack-v2k-n2k-cloud-import-integration.ko.md | 996 ++++++++++ .../design/import-vm-task-actions-design.md | 120 ++ .../import-vm-task-sync-progress-design.md | 96 + .../design/n2k-cloud-background-execution.md | 74 + .../cloud/vm/ImportVMTaskCredentialVO.java | 184 ++ .../com/cloud/vm/ImportVMTaskEventVO.java | 159 ++ .../java/com/cloud/vm/ImportVMTaskVO.java | 252 +++ .../vm/dao/ImportVMTaskCredentialDao.java | 29 + .../vm/dao/ImportVMTaskCredentialDaoImpl.java | 58 + .../com/cloud/vm/dao/ImportVMTaskDao.java | 4 +- .../com/cloud/vm/dao/ImportVMTaskDaoImpl.java | 29 +- .../cloud/vm/dao/ImportVMTaskEventDao.java | 30 + .../vm/dao/ImportVMTaskEventDaoImpl.java | 57 + ...spring-engine-schema-core-daos-context.xml | 2 + .../META-INF/db/schema-42100to42200.sql | 74 +- .../META-INF/db/schema-Europa-After.sql | 70 + ...virtAblestackN2KCleanupCommandWrapper.java | 57 + ...stackN2KConvertInstanceCommandWrapper.java | 285 +++ ...bvirtAblestackN2KStatusCommandWrapper.java | 190 ++ ...virtAblestackV2KCleanupCommandWrapper.java | 141 ++ ...stackV2KConvertInstanceCommandWrapper.java | 157 +- ...lestackV2KListVmwareVmsCommandWrapper.java | 290 +++ ...bvirtAblestackV2KStatusCommandWrapper.java | 257 ++- ...kN2KConvertInstanceCommandWrapperTest.java | 143 ++ ...tAblestackN2KStatusCommandWrapperTest.java | 107 ++ .../com/cloud/api/ApiAsyncJobDispatcher.java | 1 + .../com/cloud/api/ApiSensitiveParamUtils.java | 99 + .../main/java/com/cloud/api/ApiServer.java | 17 +- .../cloudstack/vm/AblestackN2KAdapter.java | 48 + .../cloudstack/vm/AblestackV2KAdapter.java | 48 + .../vm/AblestackV2KTargetStoragePlan.java | 90 + .../vm/AblestackV2KTargetStorageResolver.java | 194 ++ .../vm/AblestackVmMigrationManager.java | 24 + .../vm/AblestackVmMigrationManagerImpl.java | 103 ++ .../vm/AblestackVmMigrationRequest.java | 90 + .../vm/ImportVmTasksManagerImpl.java | 885 ++++++++- .../cloudstack/vm/MigrationSourceAdapter.java | 24 + .../cloudstack/vm/MigrationTargetAdapter.java | 24 + .../cloudstack/vm/MigrationToolAdapter.java | 28 + .../cloudstack/vm/NutanixSourceAdapter.java | 574 ++++++ .../vm/UnmanagedVMsManagerImpl.java | 1611 ++++++++++++++++- .../cloud/api/ApiSensitiveParamUtilsTest.java | 54 + ...AblestackV2KTargetStorageResolverTest.java | 146 ++ .../AblestackVmMigrationManagerImplTest.java | 101 ++ ...ImportVmTasksManagerImplAblestackTest.java | 100 + .../vm/UnmanagedVMsManagerImplTest.java | 10 + ui/public/locales/en.json | 59 +- ui/public/locales/ko_KR.json | 57 +- ui/src/style/dark-mode.less | 112 +- ui/src/style/index.less | 20 + .../compute/wizard/MultiDiskSelection.vue | 3 +- .../views/tools/ImportUnmanagedInstance.vue | 751 +++++++- ui/src/views/tools/ImportVmTasks.vue | 1035 ++++++++++- ui/src/views/tools/ManageInstances.vue | 391 +++- ui/src/views/tools/SelectVmwareVcenter.vue | 41 +- .../unit/components/view/ActionButton.spec.js | 26 +- 81 files changed, 12822 insertions(+), 289 deletions(-) create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ExecuteImportVMTaskActionCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackN2KCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTaskEventsCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/PreflightAblestackVmImportCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/AblestackVmImportPreflightResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskEventResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskSourceCredential.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskStatus.java create mode 100644 core/src/main/java/com/cloud/agent/api/AblestackN2KCleanupCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/AblestackN2KConvertInstanceCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/AblestackN2KStatusAnswer.java create mode 100644 core/src/main/java/com/cloud/agent/api/AblestackN2KStatusCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/AblestackV2KCleanupCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsAnswer.java create mode 100644 core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsCommand.java create mode 100644 developer/design/ablestack-v2k-n2k-cloud-import-integration.ko.md create mode 100644 developer/design/import-vm-task-actions-design.md create mode 100644 developer/design/import-vm-task-sync-progress-design.md create mode 100644 developer/design/n2k-cloud-background-execution.md create mode 100644 engine/schema/src/main/java/com/cloud/vm/ImportVMTaskCredentialVO.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/ImportVMTaskEventVO.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDao.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDaoImpl.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDao.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDaoImpl.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KCleanupCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KStatusCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KCleanupCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KListVmwareVmsCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KStatusCommandWrapperTest.java create mode 100644 server/src/main/java/com/cloud/api/ApiSensitiveParamUtils.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/AblestackN2KAdapter.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/AblestackV2KAdapter.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/AblestackV2KTargetStoragePlan.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/AblestackV2KTargetStorageResolver.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/AblestackVmMigrationManager.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/AblestackVmMigrationManagerImpl.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/AblestackVmMigrationRequest.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/MigrationSourceAdapter.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/MigrationTargetAdapter.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/MigrationToolAdapter.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/NutanixSourceAdapter.java create mode 100644 server/src/test/java/com/cloud/api/ApiSensitiveParamUtilsTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/AblestackV2KTargetStorageResolverTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/AblestackVmMigrationManagerImplTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/ImportVmTasksManagerImplAblestackTest.java diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ExecuteImportVMTaskActionCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ExecuteImportVMTaskActionCmd.java new file mode 100644 index 000000000000..6b7fcf393624 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ExecuteImportVMTaskActionCmd.java @@ -0,0 +1,115 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ImportVMTaskResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.ImportVmTasksManager; +import org.apache.commons.lang3.BooleanUtils; + +import javax.inject.Inject; + +@APICommand(name = "executeImportVmTaskAction", + description = "Execute a generic action on an import virtual machine task", + responseObject = ImportVMTaskResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22") +public class ExecuteImportVMTaskActionCmd extends BaseCmd { + + @Inject + public ImportVmTasksManager importVmTasksManager; + + @Parameter(name = ApiConstants.IMPORT_VM_TASK_ID, + type = CommandType.STRING, + required = true, + description = "the import VM task ID") + private String importVmTaskId; + + @Parameter(name = ApiConstants.ACTION, + type = CommandType.STRING, + required = true, + description = "the task action to execute. Supported values are: refresh, phase2, resume, retryfromstart, cancel, delete, clearcredentials") + private String action; + + @Parameter(name = "cleanup", + type = CommandType.BOOLEAN, + description = "whether to cleanup runtime artifacts such as workdir when the selected action supports cleanup") + private Boolean cleanup; + + @Parameter(name = "removecredentials", + type = CommandType.BOOLEAN, + description = "whether to remove stored encrypted source credentials when the selected action supports credential cleanup") + private Boolean removeCredentials; + + @Parameter(name = "force", + type = CommandType.BOOLEAN, + description = "force the selected action when it is otherwise restricted") + private Boolean force; + + public String getImportVmTaskId() { + return importVmTaskId; + } + + public String getAction() { + return action; + } + + public boolean isCleanup() { + return BooleanUtils.toBooleanDefaultIfNull(cleanup, false); + } + + public boolean isRemoveCredentials() { + return BooleanUtils.toBooleanDefaultIfNull(removeCredentials, false); + } + + public boolean isForced() { + return BooleanUtils.toBooleanDefaultIfNull(force, false); + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + ImportVMTaskResponse response = importVmTasksManager.executeImportVMTaskAction(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + if (account != null) { + return account.getId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackN2KCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackN2KCmd.java new file mode 100644 index 000000000000..fd8ba5e8fd5d --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackN2KCmd.java @@ -0,0 +1,113 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.UserVmResponse; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +@APICommand(name = "importUnmanagedInstanceForAblestackN2K", + description = "Import virtual machine from Nutanix into CloudStack using ablestack-n2k workflow", + responseObject = UserVmResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = true, + authorized = {RoleType.Admin}, + since = "4.22") +public class ImportUnmanagedInstanceForAblestackN2KCmd extends ImportVmCmd { + + private static final String DEFAULT_SPLIT_MODE = "phase1"; + private static final String DEFAULT_SOURCE_API = "v3"; + private static final Long DEFAULT_RETENTION_SECONDS = 1209600L; + + @Parameter(name = "split", + type = CommandType.STRING, + description = "(only for importing VMs from Nutanix to KVM with ablestack-n2k) split-run mode: phase1, phase2 or full") + private String splitMode; + + @Parameter(name = ApiConstants.IMPORT_VM_TASK_ID, + type = CommandType.STRING, + description = "(only for task continuation) existing import VM task ID to continue on the original conversion host") + private String importVmTaskId; + + @Parameter(name = "taskaction", + type = CommandType.STRING, + description = "(only with importvmtaskid) task action to execute: phase2, resume, or retryfromstart") + private String taskAction; + + @Parameter(name = "sourceapi", + type = CommandType.STRING, + description = "source API for ablestack-n2k run. Current Cloud-managed execution uses v3 snapshot/NFS data path") + private String sourceApi; + + @Parameter(name = "insecure", + type = CommandType.BOOLEAN, + description = "skip TLS verification for Nutanix Prism when true") + private Boolean insecure; + + @Parameter(name = "retentionseconds", + type = CommandType.LONG, + description = "source snapshot/recovery point retention time in seconds for ablestack-n2k. Default is 1209600 seconds (14 days)") + private Long retentionSeconds; + + public String getSplitMode() { + return StringUtils.defaultIfBlank(splitMode, DEFAULT_SPLIT_MODE); + } + + public String getImportVmTaskId() { + return importVmTaskId; + } + + public String getTaskAction() { + return taskAction; + } + + public String getSourceApi() { + return StringUtils.defaultIfBlank(sourceApi, DEFAULT_SOURCE_API); + } + + public boolean isInsecure() { + return BooleanUtils.toBooleanDefaultIfNull(insecure, true); + } + + public Long getRequestedRetentionSeconds() { + return retentionSeconds; + } + + public long getRetentionSeconds() { + return retentionSeconds != null ? retentionSeconds : DEFAULT_RETENTION_SECONDS; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + UserVmResponse response = vmImportService.importVmForAblestackN2K(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackV2KCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackV2KCmd.java index 4fb7deb9bf74..3a0259253500 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackV2KCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceForAblestackV2KCmd.java @@ -34,7 +34,7 @@ description = "Import virtual machine from VMware into CloudStack using ablestack-v2k workflow", responseObject = UserVmResponse.class, responseView = ResponseObject.ResponseView.Full, - requestHasSensitiveInfo = false, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, authorized = {RoleType.Admin}, since = "4.19.0") @@ -49,9 +49,14 @@ public class ImportUnmanagedInstanceForAblestackV2KCmd extends ImportVmCmd { @Parameter(name = ApiConstants.IMPORT_VM_TASK_ID, type = CommandType.STRING, - description = "(only for phase2 execution) existing import VM task ID to continue on the original conversion host") + description = "(only for task continuation) existing import VM task ID to continue on the original conversion host") private String importVmTaskId; + @Parameter(name = "taskaction", + type = CommandType.STRING, + description = "(only with importvmtaskid) task action to execute: phase2, resume, or retryfromstart") + private String taskAction; + public String getSplitMode() { return StringUtils.defaultIfBlank(splitMode, DEFAULT_SPLIT_MODE); } @@ -60,6 +65,10 @@ public String getImportVmTaskId() { return importVmTaskId; } + public String getTaskAction() { + return taskAction; + } + @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { UserVmResponse response = vmImportService.importVmForAblestackV2K(this); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java index db7dcc3fb44f..b69ac71643ef 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java @@ -48,7 +48,7 @@ description = "Import virtual machine from a unmanaged host into CloudStack", responseObject = UserVmResponse.class, responseView = ResponseObject.ResponseView.Full, - requestHasSensitiveInfo = false, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, authorized = {RoleType.Admin}, since = "4.19.0") diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTaskEventsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTaskEventsCmd.java new file mode 100644 index 000000000000..e698eea6b146 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTaskEventsCmd.java @@ -0,0 +1,77 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ImportVMTaskEventResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.ImportVmTasksManager; + +import javax.inject.Inject; + +@APICommand(name = "listImportVmTaskEvents", + description = "List events for an import virtual machine task", + responseObject = ImportVMTaskEventResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22") +public class ListImportVMTaskEventsCmd extends BaseListCmd { + + @Inject + public ImportVmTasksManager importVmTasksManager; + + @Parameter(name = ApiConstants.IMPORT_VM_TASK_ID, + type = CommandType.STRING, + required = true, + description = "the import VM task ID") + private String importVmTaskId; + + public String getImportVmTaskId() { + return importVmTaskId; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + ListResponse response = importVmTasksManager.listImportVMTaskEvents(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + if (account != null) { + return account.getId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTasksCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTasksCmd.java index 94b547ff4267..6dd1d6483cea 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTasksCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListImportVMTasksCmd.java @@ -78,6 +78,36 @@ public class ListImportVMTasksCmd extends BaseListCmd { @Parameter(name = ApiConstants.TASKS_FILTER, type = CommandType.STRING, description = "Filter tasks by state, valid options are: All, Running, Completed, Failed") private String tasksFilter; + @Parameter(name = "migrationtool", + type = CommandType.STRING, + description = "Filter tasks by migration tool, for example legacy, ablestack_v2k, or ablestack_n2k") + private String migrationTool; + + @Parameter(name = "sourceprovider", + type = CommandType.STRING, + description = "Filter tasks by source provider, for example vmware or nutanix") + private String sourceProvider; + + @Parameter(name = "targetprovider", + type = CommandType.STRING, + description = "Filter tasks by target provider, for example cloud or kvm") + private String targetProvider; + + @Parameter(name = "targetprofile", + type = CommandType.STRING, + description = "Filter tasks by target profile") + private String targetProfile; + + @Parameter(name = "currentphase", + type = CommandType.STRING, + description = "Filter tasks by normalized current migration phase") + private String currentPhase; + + @Parameter(name = "migrationstate", + type = CommandType.STRING, + description = "Filter tasks by normalized migration state") + private String migrationState; + public Long getZoneId() { return zoneId; } @@ -98,6 +128,30 @@ public String getTasksFilter() { return tasksFilter; } + public String getMigrationTool() { + return migrationTool; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public String getTargetProvider() { + return targetProvider; + } + + public String getTargetProfile() { + return targetProfile; + } + + public String getCurrentPhase() { + return currentPhase; + } + + public String getMigrationState() { + return migrationState; + } + @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { ListResponse response = importVmTasksManager.listImportVMTasks(this); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmsForImportCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmsForImportCmd.java index f40f1c0cb4a9..163a15fe8e6a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmsForImportCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmsForImportCmd.java @@ -31,7 +31,9 @@ import org.apache.cloudstack.api.ResponseObject; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ClusterResponse; import org.apache.cloudstack.api.response.UnmanagedInstanceResponse; +import org.apache.cloudstack.api.response.VmwareDatacenterResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.vm.UnmanagedInstanceTO; @@ -44,7 +46,7 @@ responseObject = UnmanagedInstanceResponse.class, responseView = ResponseObject.ResponseView.Full, entityType = {UnmanagedInstanceTO.class}, - requestHasSensitiveInfo = false, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, authorized = {RoleType.Admin}, since = "4.19.0") @@ -76,7 +78,6 @@ public class ListVmsForImportCmd extends BaseListCmd { @Parameter(name = ApiConstants.HOST, type = CommandType.STRING, - required = true, description = "the host name or IP address") private String host; @@ -86,6 +87,48 @@ public class ListVmsForImportCmd extends BaseListCmd { description = "hypervisor type of the host") private String hypervisor; + @Parameter(name = ApiConstants.CLUSTER_ID, + type = CommandType.UUID, + entityType = ClusterResponse.class, + description = "the destination KVM cluster ID used to select an ablestack-v2k inventory host") + private Long clusterId; + + @Parameter(name = ApiConstants.EXISTING_VCENTER_ID, + type = CommandType.UUID, + entityType = VmwareDatacenterResponse.class, + description = "UUID of a linked existing vCenter") + private Long existingVcenterId; + + @Parameter(name = ApiConstants.VCENTER, + type = CommandType.STRING, + description = "the name/IP of the vCenter. If omitted, host is used for VMware source inventory.") + private String vcenter; + + @Parameter(name = ApiConstants.DATACENTER_NAME, + type = CommandType.STRING, + description = "name of VMware datacenter") + private String datacenterName; + + @Parameter(name = ApiConstants.INSTANCE_NAME, + type = CommandType.STRING, + description = "name of one source VM to retrieve with detailed inventory") + private String instanceName; + + @Parameter(name = "sourceprovider", + type = CommandType.STRING, + description = "source provider for ablestack import. Supported values: kvm, vmware, nutanix") + private String sourceProvider; + + @Parameter(name = "sourceapi", + type = CommandType.STRING, + description = "source API selection for Nutanix inventory. Supported values: auto, v4, v3, v2") + private String sourceApi; + + @Parameter(name = "insecure", + type = CommandType.BOOLEAN, + description = "skip TLS verification for Nutanix Prism when true") + private Boolean insecure; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -110,6 +153,38 @@ public String getHypervisor() { return hypervisor; } + public Long getClusterId() { + return clusterId; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenterName() { + return datacenterName; + } + + public String getInstanceName() { + return instanceName; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public String getSourceApi() { + return sourceApi; + } + + public boolean isInsecure() { + return Boolean.TRUE.equals(insecure); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/PreflightAblestackVmImportCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/PreflightAblestackVmImportCmd.java new file mode 100644 index 000000000000..b1f9f072e98c --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/PreflightAblestackVmImportCmd.java @@ -0,0 +1,197 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AblestackVmImportPreflightResponse; +import org.apache.cloudstack.api.response.ClusterResponse; +import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.ServiceOfferingResponse; +import org.apache.cloudstack.api.response.StoragePoolResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmImportService; + +import javax.inject.Inject; + +@APICommand(name = "preflightAblestackVmImport", + description = "Preflight source and target checks for ABLESTACK v2k/n2k VM import", + responseObject = AblestackVmImportPreflightResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22") +public class PreflightAblestackVmImportCmd extends BaseCmd { + + @Inject + public VmImportService vmImportService; + + @Parameter(name = ApiConstants.ZONE_ID, + type = CommandType.UUID, + entityType = ZoneResponse.class, + required = true, + description = "the zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.CLUSTER_ID, + type = CommandType.UUID, + entityType = ClusterResponse.class, + description = "the target KVM cluster ID") + private Long clusterId; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_HOST_ID, + type = CommandType.UUID, + entityType = HostResponse.class, + description = "the KVM host that will run the import tool") + private Long convertInstanceHostId; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, + type = CommandType.UUID, + entityType = StoragePoolResponse.class, + description = "the target primary storage pool ID") + private Long targetStoragePoolId; + + @Parameter(name = ApiConstants.SERVICE_OFFERING_ID, + type = CommandType.UUID, + entityType = ServiceOfferingResponse.class, + description = "the target service offering ID") + private Long serviceOfferingId; + + @Parameter(name = "migrationtool", + type = CommandType.STRING, + description = "migration tool. Supported values: ablestack_n2k, ablestack_v2k") + private String migrationTool; + + @Parameter(name = "sourceprovider", + type = CommandType.STRING, + required = true, + description = "source provider. Supported values: nutanix, vmware") + private String sourceProvider; + + @Parameter(name = ApiConstants.HOST, + type = CommandType.STRING, + required = true, + description = "source endpoint, for example Nutanix Prism Central endpoint") + private String host; + + @Parameter(name = ApiConstants.USERNAME, + type = CommandType.STRING, + description = "the source username") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, + type = CommandType.STRING, + description = "the source password") + private String password; + + @Parameter(name = "sourceapi", + type = CommandType.STRING, + description = "source API selection. Supported values for Nutanix: auto, v4, v3, v2") + private String sourceApi; + + @Parameter(name = "sourcevmname", + type = CommandType.STRING, + description = "optional source VM name or UUID to validate") + private String sourceVmName; + + @Parameter(name = "insecure", + type = CommandType.BOOLEAN, + description = "skip TLS verification for source endpoint when true") + private Boolean insecure; + + public Long getZoneId() { + return zoneId; + } + + public Long getClusterId() { + return clusterId; + } + + public Long getConvertInstanceHostId() { + return convertInstanceHostId; + } + + public Long getTargetStoragePoolId() { + return targetStoragePoolId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + public String getMigrationTool() { + return migrationTool; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public String getHost() { + return host; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getSourceApi() { + return sourceApi; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public boolean isInsecure() { + return Boolean.TRUE.equals(insecure); + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + AblestackVmImportPreflightResponse response = vmImportService.preflightAblestackVmImport(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + if (account != null) { + return account.getId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/AblestackVmImportPreflightResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/AblestackVmImportPreflightResponse.java new file mode 100644 index 000000000000..145c3de3f74e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/AblestackVmImportPreflightResponse.java @@ -0,0 +1,156 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.BaseResponse; + +public class AblestackVmImportPreflightResponse extends BaseResponse { + + @SerializedName("success") + @Param(description = "true when the preflight checks passed") + private Boolean success; + + @SerializedName("migrationtool") + @Param(description = "the migration tool selected for the import") + private String migrationTool; + + @SerializedName("sourceprovider") + @Param(description = "the source provider selected for the import") + private String sourceProvider; + + @SerializedName("targetprovider") + @Param(description = "the target provider selected for the import") + private String targetProvider; + + @SerializedName("sourceapi") + @Param(description = "the source API path selected after probing") + private String sourceApi; + + @SerializedName("sourcevmcount") + @Param(description = "the number of source VMs visible through the selected source API") + private Integer sourceVmCount; + + @SerializedName("sourcevmname") + @Param(description = "the matched source VM name when a VM filter is supplied") + private String sourceVmName; + + @SerializedName("targetstorage") + @Param(description = "the resolved target storage type") + private String targetStorage; + + @SerializedName("targetformat") + @Param(description = "the resolved target disk format") + private String targetFormat; + + @SerializedName("message") + @Param(description = "human-readable preflight result") + private String message; + + @SerializedName("details") + @Param(description = "non-secret preflight details") + private String details; + + public Boolean getSuccess() { + return success; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public String getMigrationTool() { + return migrationTool; + } + + public void setMigrationTool(String migrationTool) { + this.migrationTool = migrationTool; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public void setSourceProvider(String sourceProvider) { + this.sourceProvider = sourceProvider; + } + + public String getTargetProvider() { + return targetProvider; + } + + public void setTargetProvider(String targetProvider) { + this.targetProvider = targetProvider; + } + + public String getSourceApi() { + return sourceApi; + } + + public void setSourceApi(String sourceApi) { + this.sourceApi = sourceApi; + } + + public Integer getSourceVmCount() { + return sourceVmCount; + } + + public void setSourceVmCount(Integer sourceVmCount) { + this.sourceVmCount = sourceVmCount; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public void setSourceVmName(String sourceVmName) { + this.sourceVmName = sourceVmName; + } + + public String getTargetStorage() { + return targetStorage; + } + + public void setTargetStorage(String targetStorage) { + this.targetStorage = targetStorage; + } + + public String getTargetFormat() { + return targetFormat; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskEventResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskEventResponse.java new file mode 100644 index 000000000000..8bb53d790d4a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskEventResponse.java @@ -0,0 +1,135 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import java.util.Date; + +public class ImportVMTaskEventResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the import VM task event") + private String id; + + @SerializedName(ApiConstants.IMPORT_VM_TASK_ID) + @Param(description = "the import VM task ID") + private String importVmTaskId; + + @SerializedName("eventtype") + @Param(description = "the import VM task event type") + private String eventType; + + @SerializedName("phase") + @Param(description = "the migration phase at event time") + private String phase; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the migration state at event time") + private String state; + + @SerializedName("step") + @Param(description = "the migration step at event time") + private String step; + + @SerializedName("message") + @Param(description = "the event message") + private String message; + + @SerializedName("payload") + @Param(description = "the event payload without secrets") + private String payload; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the event create date") + private Date created; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getImportVmTaskId() { + return importVmTaskId; + } + + public void setImportVmTaskId(String importVmTaskId) { + this.importVmTaskId = importVmTaskId; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getPhase() { + return phase; + } + + public void setPhase(String phase) { + this.phase = phase; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getStep() { + return step; + } + + public void setStep(String step) { + this.step = step; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskResponse.java index b9462426e0bf..1d5a6093f39a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/ImportVMTaskResponse.java @@ -24,6 +24,7 @@ import org.apache.cloudstack.api.BaseResponse; import java.util.Date; +import java.util.List; public class ImportVMTaskResponse extends BaseResponse { @@ -79,6 +80,26 @@ public class ImportVMTaskResponse extends BaseResponse { @Param(description = "the source VM name") private String sourceVMName; + @SerializedName("migrationtool") + @Param(description = "the migration tool used by the import task") + private String migrationTool; + + @SerializedName("sourceprovider") + @Param(description = "the source provider used by the import task") + private String sourceProvider; + + @SerializedName("targetprovider") + @Param(description = "the target provider used by the import task") + private String targetProvider; + + @SerializedName("targetprofile") + @Param(description = "the target profile selected for the import task") + private String targetProfile; + + @SerializedName("targetvmname") + @Param(description = "the target VM name selected for the import task") + private String targetVMName; + @SerializedName("step") @Param(description = "the current step on the importing VM task") private String step; @@ -119,6 +140,10 @@ public class ImportVMTaskResponse extends BaseResponse { @Param(description = "the current PHASE from ablestack-v2k status") private String phase; + @SerializedName("currentphase") + @Param(description = "the normalized current migration phase") + private String currentPhase; + @SerializedName("migrationstate") @Param(description = "the current STATE from ablestack-v2k status") private String migrationState; @@ -131,10 +156,50 @@ public class ImportVMTaskResponse extends BaseResponse { @Param(description = "the current SYNC(Physical) from ablestack-v2k status") private String syncPhysical; + @SerializedName("displaystep") + @Param(description = "the normalized display step for the import task") + private String displayStep; + + @SerializedName("syncprogresslabel") + @Param(description = "the current sync progress label") + private String syncProgressLabel; + + @SerializedName("syncdonebytes") + @Param(description = "the bytes transferred in the current sync step") + private Long syncDoneBytes; + + @SerializedName("synctotalbytes") + @Param(description = "the total bytes for the current sync step") + private Long syncTotalBytes; + + @SerializedName("syncpercent") + @Param(description = "the percent completed in the current sync step") + private Integer syncPercent; + + @SerializedName("synccumulativedonebytes") + @Param(description = "the cumulative transferred bytes from base sync through the current sync step") + private Long syncCumulativeDoneBytes; + + @SerializedName("synccumulativeknownbytes") + @Param(description = "the cumulative known bytes from base sync through the current sync step") + private Long syncCumulativeKnownBytes; + + @SerializedName("synccumulativepercent") + @Param(description = "the cumulative sync percent from base sync through the current sync step") + private Integer syncCumulativePercent; + @SerializedName("workdir") @Param(description = "the current WORKDIR from ablestack-v2k status") private String workdir; + @SerializedName("credentialstate") + @Param(description = "the source credential state for the import task") + private String credentialState; + + @SerializedName("availableactions") + @Param(description = "the currently available task actions") + private List availableActions; + public String getId() { return id; } @@ -231,6 +296,46 @@ public void setSourceVMName(String sourceVMName) { this.sourceVMName = sourceVMName; } + public String getMigrationTool() { + return migrationTool; + } + + public void setMigrationTool(String migrationTool) { + this.migrationTool = migrationTool; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public void setSourceProvider(String sourceProvider) { + this.sourceProvider = sourceProvider; + } + + public String getTargetProvider() { + return targetProvider; + } + + public void setTargetProvider(String targetProvider) { + this.targetProvider = targetProvider; + } + + public String getTargetProfile() { + return targetProfile; + } + + public void setTargetProfile(String targetProfile) { + this.targetProfile = targetProfile; + } + + public String getTargetVMName() { + return targetVMName; + } + + public void setTargetVMName(String targetVMName) { + this.targetVMName = targetVMName; + } + public String getStep() { return step; } @@ -319,6 +424,14 @@ public void setPhase(String phase) { this.phase = phase; } + public String getCurrentPhase() { + return currentPhase; + } + + public void setCurrentPhase(String currentPhase) { + this.currentPhase = currentPhase; + } + public String getMigrationState() { return migrationState; } @@ -343,6 +456,70 @@ public void setSyncPhysical(String syncPhysical) { this.syncPhysical = syncPhysical; } + public String getDisplayStep() { + return displayStep; + } + + public void setDisplayStep(String displayStep) { + this.displayStep = displayStep; + } + + public String getSyncProgressLabel() { + return syncProgressLabel; + } + + public void setSyncProgressLabel(String syncProgressLabel) { + this.syncProgressLabel = syncProgressLabel; + } + + public Long getSyncDoneBytes() { + return syncDoneBytes; + } + + public void setSyncDoneBytes(Long syncDoneBytes) { + this.syncDoneBytes = syncDoneBytes; + } + + public Long getSyncTotalBytes() { + return syncTotalBytes; + } + + public void setSyncTotalBytes(Long syncTotalBytes) { + this.syncTotalBytes = syncTotalBytes; + } + + public Integer getSyncPercent() { + return syncPercent; + } + + public void setSyncPercent(Integer syncPercent) { + this.syncPercent = syncPercent; + } + + public Long getSyncCumulativeDoneBytes() { + return syncCumulativeDoneBytes; + } + + public void setSyncCumulativeDoneBytes(Long syncCumulativeDoneBytes) { + this.syncCumulativeDoneBytes = syncCumulativeDoneBytes; + } + + public Long getSyncCumulativeKnownBytes() { + return syncCumulativeKnownBytes; + } + + public void setSyncCumulativeKnownBytes(Long syncCumulativeKnownBytes) { + this.syncCumulativeKnownBytes = syncCumulativeKnownBytes; + } + + public Integer getSyncCumulativePercent() { + return syncCumulativePercent; + } + + public void setSyncCumulativePercent(Integer syncCumulativePercent) { + this.syncCumulativePercent = syncCumulativePercent; + } + public String getWorkdir() { return workdir; } @@ -350,4 +527,20 @@ public String getWorkdir() { public void setWorkdir(String workdir) { this.workdir = workdir; } + + public String getCredentialState() { + return credentialState; + } + + public void setCredentialState(String credentialState) { + this.credentialState = credentialState; + } + + public List getAvailableActions() { + return availableActions; + } + + public void setAvailableActions(List availableActions) { + this.availableActions = availableActions; + } } diff --git a/api/src/main/java/org/apache/cloudstack/vm/ImportVmTask.java b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTask.java index ed881cea63c4..af54d99046ca 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/ImportVmTask.java +++ b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTask.java @@ -24,6 +24,139 @@ public interface ImportVmTask extends Identity, InternalIdentity { String V2K_STEP_NONE = "None"; + enum MigrationTool { + Legacy("legacy"), + AblestackV2K("ablestack_v2k"), + AblestackN2K("ablestack_n2k"); + + private final String value; + + MigrationTool(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + enum SourceProvider { + VMware("vmware"), + Nutanix("nutanix"), + KVM("kvm"), + Local("local"), + Shared("shared"); + + private final String value; + + SourceProvider(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + enum TargetProvider { + Cloud("cloud"), + KVM("kvm"); + + private final String value; + + TargetProvider(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + enum MigrationPhase { + Prepare("prepare"), + Phase1("phase1"), + Phase2("phase2"), + Finalize("finalize"), + Completed("completed"); + + private final String value; + + MigrationPhase(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + enum MigrationState { + Pending("pending"), + Running("running"), + Completed("completed"), + Failed("failed"); + + private final String value; + + MigrationState(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + enum CredentialState { + NotRequired("notrequired"), + Managed("managed"), + Stored("stored"), + Legacy("legacy"), + Missing("missing"); + + private final String value; + + CredentialState(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + enum Action { + Refresh("refresh"), + ClearCredentials("clearcredentials"), + Phase2("phase2"), + Finalize("finalize"), + Retry("retry"), + Resume("resume"), + RetryFromStart("retryfromstart"), + Cancel("cancel"), + Delete("delete"); + + private final String value; + + Action(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static Action fromValue(String value) { + for (Action action : Action.values()) { + if (action.value.equalsIgnoreCase(value)) { + return action; + } + } + throw new IllegalArgumentException("Invalid import VM task action: " + value); + } + } + enum Step { Prepare, CloningInstance, ConvertingInstance, Importing, Completed } @@ -33,7 +166,7 @@ enum V2KStep { } enum TaskState { - Running, Completed, Failed; + Running, Completed, Failed, Cancelling, Cancelled; public static TaskState getValue(String state) { for (TaskState s : TaskState.values()) { diff --git a/api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskSourceCredential.java b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskSourceCredential.java new file mode 100644 index 000000000000..77cebf656165 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskSourceCredential.java @@ -0,0 +1,54 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class ImportVmTaskSourceCredential { + + private final String provider; + private final String credentialType; + private final String endpoint; + private final String username; + private final String password; + + public ImportVmTaskSourceCredential(String provider, String credentialType, String endpoint, String username, String password) { + this.provider = provider; + this.credentialType = credentialType; + this.endpoint = endpoint; + this.username = username; + this.password = password; + } + + public String getProvider() { + return provider; + } + + public String getCredentialType() { + return credentialType; + } + + public String getEndpoint() { + return endpoint; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskStatus.java b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskStatus.java new file mode 100644 index 000000000000..6b16077d4842 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTaskStatus.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class ImportVmTaskStatus { + + private final String currentPhase; + private final String migrationState; + private final String migrationStep; + private final String workdir; + private final String syncPhysical; + private final String displayStep; + private final String syncProgressLabel; + private final Long syncDoneBytes; + private final Long syncTotalBytes; + private final Integer syncPercent; + private final Long syncCumulativeDoneBytes; + private final Long syncCumulativeKnownBytes; + private final Integer syncCumulativePercent; + + public ImportVmTaskStatus(String currentPhase, String migrationState, String migrationStep, String workdir, String syncPhysical) { + this(currentPhase, migrationState, migrationStep, workdir, syncPhysical, null, null, null, null, null, null, null, null); + } + + public ImportVmTaskStatus(String currentPhase, String migrationState, String migrationStep, String workdir, String syncPhysical, + String displayStep, String syncProgressLabel, Long syncDoneBytes, Long syncTotalBytes, Integer syncPercent, + Long syncCumulativeDoneBytes, Long syncCumulativeKnownBytes, Integer syncCumulativePercent) { + this.currentPhase = currentPhase; + this.migrationState = migrationState; + this.migrationStep = migrationStep; + this.workdir = workdir; + this.syncPhysical = syncPhysical; + this.displayStep = displayStep; + this.syncProgressLabel = syncProgressLabel; + this.syncDoneBytes = syncDoneBytes; + this.syncTotalBytes = syncTotalBytes; + this.syncPercent = syncPercent; + this.syncCumulativeDoneBytes = syncCumulativeDoneBytes; + this.syncCumulativeKnownBytes = syncCumulativeKnownBytes; + this.syncCumulativePercent = syncCumulativePercent; + } + + public String getCurrentPhase() { + return currentPhase; + } + + public String getMigrationState() { + return migrationState; + } + + public String getMigrationStep() { + return migrationStep; + } + + public String getWorkdir() { + return workdir; + } + + public String getSyncPhysical() { + return syncPhysical; + } + + public String getDisplayStep() { + return displayStep; + } + + public String getSyncProgressLabel() { + return syncProgressLabel; + } + + public Long getSyncDoneBytes() { + return syncDoneBytes; + } + + public Long getSyncTotalBytes() { + return syncTotalBytes; + } + + public Integer getSyncPercent() { + return syncPercent; + } + + public Long getSyncCumulativeDoneBytes() { + return syncCumulativeDoneBytes; + } + + public Long getSyncCumulativeKnownBytes() { + return syncCumulativeKnownBytes; + } + + public Integer getSyncCumulativePercent() { + return syncCumulativePercent; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/ImportVmTasksManager.java b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTasksManager.java index c6c0dedd0d6e..f4e4fd2d0766 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/ImportVmTasksManager.java +++ b/api/src/main/java/org/apache/cloudstack/vm/ImportVmTasksManager.java @@ -19,7 +19,10 @@ import com.cloud.dc.DataCenter; import com.cloud.host.Host; import com.cloud.user.Account; +import org.apache.cloudstack.api.command.admin.vm.ExecuteImportVMTaskActionCmd; +import org.apache.cloudstack.api.command.admin.vm.ListImportVMTaskEventsCmd; import org.apache.cloudstack.api.command.admin.vm.ListImportVMTasksCmd; +import org.apache.cloudstack.api.response.ImportVMTaskEventResponse; import org.apache.cloudstack.api.response.ImportVMTaskResponse; import org.apache.cloudstack.api.response.ListResponse; @@ -29,6 +32,10 @@ public interface ImportVmTasksManager { ListResponse listImportVMTasks(ListImportVMTasksCmd cmd); + ListResponse listImportVMTaskEvents(ListImportVMTaskEventsCmd cmd); + + ImportVMTaskResponse executeImportVMTaskAction(ExecuteImportVMTaskActionCmd cmd); + ImportVmTask createImportVMTaskRecord(DataCenter zone, Account owner, long userId, String displayName, String vcenter, String datacenterName, String sourceVMName, Host convertHost, Host importHost); @@ -38,11 +45,31 @@ void updateImportVMTaskStep(ImportVmTask importVMTaskVO, DataCenter zone, Accoun void updateImportVMTaskV2KStep(ImportVmTask importVMTaskVO, ImportVmTask.V2KStep step); + void updateImportVMTaskRuntimeStatus(ImportVmTask importVMTaskVO, ImportVmTaskStatus status, + String rawStatusJson, String description); + void updateImportVMTaskV2KContext(ImportVmTask importVMTaskVO, Long clusterId, Long serviceOfferingId, Long targetStoragePoolId, String sourceClusterName, String sourceHostName, Long vcenterId, String vcenterUsername, String vcenterPassword, Map serviceOfferingDetails, - Map> nicSelectionMap); + Map> nicSelectionMap, + String targetProfile, String targetFormat, String targetStorageType, + String targetVMName, String workdir, String targetContextJson); + + void updateImportVMTaskN2KContext(ImportVmTask importVMTaskVO, Long clusterId, Long serviceOfferingId, + Long targetStoragePoolId, String prismEndpoint, String sourceApi, + String sourceInventoryJson, Map serviceOfferingDetails, + Map> nicSelectionMap, + String targetProfile, String targetFormat, String targetStorageType, + String targetVMName, String workdir, String splitMode, + String sourceContextJson, String targetContextJson); + + ImportVmTaskSourceCredential storeImportVMTaskSourceCredential(ImportVmTask importVMTask, String provider, String credentialType, + String endpoint, String username, String password); + + ImportVmTaskSourceCredential getImportVMTaskSourceCredential(ImportVmTask importVMTask); + + boolean removeImportVMTaskSourceCredentials(ImportVmTask importVMTask); void updateImportVMTaskErrorState(ImportVmTask importVMTaskVO, ImportVmTask.TaskState state, String errorMsg); } diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmImportService.java b/api/src/main/java/org/apache/cloudstack/vm/VmImportService.java index 6545da6c4907..e03944e4e7ba 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/VmImportService.java +++ b/api/src/main/java/org/apache/cloudstack/vm/VmImportService.java @@ -18,10 +18,13 @@ package org.apache.cloudstack.vm; import org.apache.cloudstack.api.command.admin.vm.ImportUnmanagedInstanceCmd; +import org.apache.cloudstack.api.command.admin.vm.ImportUnmanagedInstanceForAblestackN2KCmd; import org.apache.cloudstack.api.command.admin.vm.ImportUnmanagedInstanceForAblestackV2KCmd; import org.apache.cloudstack.api.command.admin.vm.ImportVmCmd; import org.apache.cloudstack.api.command.admin.vm.ListUnmanagedInstancesCmd; import org.apache.cloudstack.api.command.admin.vm.ListVmsForImportCmd; +import org.apache.cloudstack.api.command.admin.vm.PreflightAblestackVmImportCmd; +import org.apache.cloudstack.api.response.AblestackVmImportPreflightResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.UnmanagedInstanceResponse; import org.apache.cloudstack.api.response.UserVmResponse; @@ -42,6 +45,8 @@ public String toString() { UserVmResponse importVm(ImportVmCmd cmd); UserVmResponse importVmForAblestackV2K(ImportUnmanagedInstanceForAblestackV2KCmd cmd); + UserVmResponse importVmForAblestackN2K(ImportUnmanagedInstanceForAblestackN2KCmd cmd); ListResponse listVmsForImport(ListVmsForImportCmd cmd); + AblestackVmImportPreflightResponse preflightAblestackVmImport(PreflightAblestackVmImportCmd cmd); } diff --git a/core/src/main/java/com/cloud/agent/api/AblestackN2KCleanupCommand.java b/core/src/main/java/com/cloud/agent/api/AblestackN2KCleanupCommand.java new file mode 100644 index 000000000000..05c99ef6fa2a --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/AblestackN2KCleanupCommand.java @@ -0,0 +1,50 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +public class AblestackN2KCleanupCommand extends Command { + + private String workdir; + private boolean keepSourcePoints; + private boolean removeWorkdir; + + public AblestackN2KCleanupCommand() { + } + + public AblestackN2KCleanupCommand(String workdir, boolean keepSourcePoints, boolean removeWorkdir) { + this.workdir = workdir; + this.keepSourcePoints = keepSourcePoints; + this.removeWorkdir = removeWorkdir; + } + + public String getWorkdir() { + return workdir; + } + + public boolean isKeepSourcePoints() { + return keepSourcePoints; + } + + public boolean isRemoveWorkdir() { + return removeWorkdir; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/AblestackN2KConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/AblestackN2KConvertInstanceCommand.java new file mode 100644 index 000000000000..a311eac94ac5 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/AblestackN2KConvertInstanceCommand.java @@ -0,0 +1,297 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import com.cloud.agent.api.to.DataStoreTO; + +public class AblestackN2KConvertInstanceCommand extends Command { + + private String vmName; + private String prismEndpoint; + private String username; + private String password; + private DataStoreTO targetStorageLocation; + private String splitMode; + private String sourceApi; + private String nfsHost; + private boolean insecure; + private String workdir; + private String targetFormat; + private String targetStorage; + private String targetMapJson; + private String targetDestinationPath; + private String targetProvider; + private String cloudEndpoint; + private String cloudApiKey; + private String cloudSecretKey; + private String cloudZoneId; + private String cloudServiceOfferingId; + private String cloudNetworkIds; + private String cloudStorageId; + private String cloudDiskOfferingId; + private String cloudHostId; + private String cloudAccount; + private String cloudDomainId; + private String cloudProjectId; + private String cloudName; + private String cloudDisplayName; + private String cloudCpuSpeed; + private Long retentionSeconds; + private boolean resume; + + public AblestackN2KConvertInstanceCommand() { + } + + public AblestackN2KConvertInstanceCommand(String vmName, String prismEndpoint, String username, String password, + DataStoreTO targetStorageLocation, String splitMode, String sourceApi, + boolean insecure, String workdir) { + this.vmName = vmName; + this.prismEndpoint = prismEndpoint; + this.username = username; + this.password = password; + this.targetStorageLocation = targetStorageLocation; + this.splitMode = splitMode; + this.sourceApi = sourceApi; + this.insecure = insecure; + this.workdir = workdir; + } + + public String getVmName() { + return vmName; + } + + public String getPrismEndpoint() { + return prismEndpoint; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public DataStoreTO getTargetStorageLocation() { + return targetStorageLocation; + } + + public String getSplitMode() { + return splitMode; + } + + public String getSourceApi() { + return sourceApi; + } + + public String getNfsHost() { + return nfsHost; + } + + public void setNfsHost(String nfsHost) { + this.nfsHost = nfsHost; + } + + public boolean isInsecure() { + return insecure; + } + + public String getWorkdir() { + return workdir; + } + + public String getTargetFormat() { + return targetFormat; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public String getTargetStorage() { + return targetStorage; + } + + public void setTargetStorage(String targetStorage) { + this.targetStorage = targetStorage; + } + + public String getTargetMapJson() { + return targetMapJson; + } + + public void setTargetMapJson(String targetMapJson) { + this.targetMapJson = targetMapJson; + } + + public String getTargetDestinationPath() { + return targetDestinationPath; + } + + public void setTargetDestinationPath(String targetDestinationPath) { + this.targetDestinationPath = targetDestinationPath; + } + + public String getTargetProvider() { + return targetProvider; + } + + public void setTargetProvider(String targetProvider) { + this.targetProvider = targetProvider; + } + + public String getCloudEndpoint() { + return cloudEndpoint; + } + + public void setCloudEndpoint(String cloudEndpoint) { + this.cloudEndpoint = cloudEndpoint; + } + + public String getCloudApiKey() { + return cloudApiKey; + } + + public void setCloudApiKey(String cloudApiKey) { + this.cloudApiKey = cloudApiKey; + } + + public String getCloudSecretKey() { + return cloudSecretKey; + } + + public void setCloudSecretKey(String cloudSecretKey) { + this.cloudSecretKey = cloudSecretKey; + } + + public String getCloudZoneId() { + return cloudZoneId; + } + + public void setCloudZoneId(String cloudZoneId) { + this.cloudZoneId = cloudZoneId; + } + + public String getCloudServiceOfferingId() { + return cloudServiceOfferingId; + } + + public void setCloudServiceOfferingId(String cloudServiceOfferingId) { + this.cloudServiceOfferingId = cloudServiceOfferingId; + } + + public String getCloudNetworkIds() { + return cloudNetworkIds; + } + + public void setCloudNetworkIds(String cloudNetworkIds) { + this.cloudNetworkIds = cloudNetworkIds; + } + + public String getCloudStorageId() { + return cloudStorageId; + } + + public void setCloudStorageId(String cloudStorageId) { + this.cloudStorageId = cloudStorageId; + } + + public String getCloudDiskOfferingId() { + return cloudDiskOfferingId; + } + + public void setCloudDiskOfferingId(String cloudDiskOfferingId) { + this.cloudDiskOfferingId = cloudDiskOfferingId; + } + + public String getCloudHostId() { + return cloudHostId; + } + + public void setCloudHostId(String cloudHostId) { + this.cloudHostId = cloudHostId; + } + + public String getCloudAccount() { + return cloudAccount; + } + + public void setCloudAccount(String cloudAccount) { + this.cloudAccount = cloudAccount; + } + + public String getCloudDomainId() { + return cloudDomainId; + } + + public void setCloudDomainId(String cloudDomainId) { + this.cloudDomainId = cloudDomainId; + } + + public String getCloudProjectId() { + return cloudProjectId; + } + + public void setCloudProjectId(String cloudProjectId) { + this.cloudProjectId = cloudProjectId; + } + + public String getCloudName() { + return cloudName; + } + + public void setCloudName(String cloudName) { + this.cloudName = cloudName; + } + + public String getCloudDisplayName() { + return cloudDisplayName; + } + + public void setCloudDisplayName(String cloudDisplayName) { + this.cloudDisplayName = cloudDisplayName; + } + + public String getCloudCpuSpeed() { + return cloudCpuSpeed; + } + + public void setCloudCpuSpeed(String cloudCpuSpeed) { + this.cloudCpuSpeed = cloudCpuSpeed; + } + + public Long getRetentionSeconds() { + return retentionSeconds; + } + + public void setRetentionSeconds(Long retentionSeconds) { + this.retentionSeconds = retentionSeconds; + } + + public boolean isResume() { + return resume; + } + + public void setResume(boolean resume) { + this.resume = resume; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/AblestackN2KStatusAnswer.java b/core/src/main/java/com/cloud/agent/api/AblestackN2KStatusAnswer.java new file mode 100644 index 000000000000..c374c5b8f996 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/AblestackN2KStatusAnswer.java @@ -0,0 +1,158 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +public class AblestackN2KStatusAnswer extends Answer { + + private String phase; + private String migrationState; + private String migrationStep; + private String syncPhysical; + private String workdir; + private String statusJson; + private String targetProvider; + private String cloudVmId; + private String displayStep; + private String syncProgressLabel; + private Long syncDoneBytes; + private Long syncTotalBytes; + private Integer syncPercent; + private Long syncCumulativeDoneBytes; + private Long syncCumulativeKnownBytes; + private Integer syncCumulativePercent; + + public AblestackN2KStatusAnswer(Command command, boolean success, String details) { + super(command, success, details); + } + + public AblestackN2KStatusAnswer(Command command, boolean success, String details, + String phase, String migrationState, String migrationStep, + String syncPhysical, String workdir, String statusJson) { + this(command, success, details, phase, migrationState, migrationStep, syncPhysical, workdir, statusJson, null, null); + } + + public AblestackN2KStatusAnswer(Command command, boolean success, String details, + String phase, String migrationState, String migrationStep, + String syncPhysical, String workdir, String statusJson, + String targetProvider, String cloudVmId) { + super(command, success, details); + this.phase = phase; + this.migrationState = migrationState; + this.migrationStep = migrationStep; + this.syncPhysical = syncPhysical; + this.workdir = workdir; + this.statusJson = statusJson; + this.targetProvider = targetProvider; + this.cloudVmId = cloudVmId; + } + + public String getPhase() { + return phase; + } + + public String getMigrationState() { + return migrationState; + } + + public String getMigrationStep() { + return migrationStep; + } + + public String getSyncPhysical() { + return syncPhysical; + } + + public String getWorkdir() { + return workdir; + } + + public String getStatusJson() { + return statusJson; + } + + public String getTargetProvider() { + return targetProvider; + } + + public String getCloudVmId() { + return cloudVmId; + } + + public String getDisplayStep() { + return displayStep; + } + + public void setDisplayStep(String displayStep) { + this.displayStep = displayStep; + } + + public String getSyncProgressLabel() { + return syncProgressLabel; + } + + public void setSyncProgressLabel(String syncProgressLabel) { + this.syncProgressLabel = syncProgressLabel; + } + + public Long getSyncDoneBytes() { + return syncDoneBytes; + } + + public void setSyncDoneBytes(Long syncDoneBytes) { + this.syncDoneBytes = syncDoneBytes; + } + + public Long getSyncTotalBytes() { + return syncTotalBytes; + } + + public void setSyncTotalBytes(Long syncTotalBytes) { + this.syncTotalBytes = syncTotalBytes; + } + + public Integer getSyncPercent() { + return syncPercent; + } + + public void setSyncPercent(Integer syncPercent) { + this.syncPercent = syncPercent; + } + + public Long getSyncCumulativeDoneBytes() { + return syncCumulativeDoneBytes; + } + + public void setSyncCumulativeDoneBytes(Long syncCumulativeDoneBytes) { + this.syncCumulativeDoneBytes = syncCumulativeDoneBytes; + } + + public Long getSyncCumulativeKnownBytes() { + return syncCumulativeKnownBytes; + } + + public void setSyncCumulativeKnownBytes(Long syncCumulativeKnownBytes) { + this.syncCumulativeKnownBytes = syncCumulativeKnownBytes; + } + + public Integer getSyncCumulativePercent() { + return syncCumulativePercent; + } + + public void setSyncCumulativePercent(Integer syncCumulativePercent) { + this.syncCumulativePercent = syncCumulativePercent; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/AblestackN2KStatusCommand.java b/core/src/main/java/com/cloud/agent/api/AblestackN2KStatusCommand.java new file mode 100644 index 000000000000..23c9dc9395c4 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/AblestackN2KStatusCommand.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +public class AblestackN2KStatusCommand extends Command { + + private String vmName; + private String workdir; + + public AblestackN2KStatusCommand() { + } + + public AblestackN2KStatusCommand(String vmName, String workdir) { + this.vmName = vmName; + this.workdir = workdir; + } + + public String getVmName() { + return vmName; + } + + public String getWorkdir() { + return workdir; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/AblestackV2KCleanupCommand.java b/core/src/main/java/com/cloud/agent/api/AblestackV2KCleanupCommand.java new file mode 100644 index 000000000000..bab42bee8f67 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/AblestackV2KCleanupCommand.java @@ -0,0 +1,63 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +public class AblestackV2KCleanupCommand extends Command { + + private String workdir; + private String domainName; + private boolean keepSourceSnapshots; + private boolean removeWorkdir; + private boolean undefineDomain; + + public AblestackV2KCleanupCommand() { + } + + public AblestackV2KCleanupCommand(String workdir, String domainName, boolean keepSourceSnapshots, + boolean removeWorkdir, boolean undefineDomain) { + this.workdir = workdir; + this.domainName = domainName; + this.keepSourceSnapshots = keepSourceSnapshots; + this.removeWorkdir = removeWorkdir; + this.undefineDomain = undefineDomain; + } + + public String getWorkdir() { + return workdir; + } + + public String getDomainName() { + return domainName; + } + + public boolean isKeepSourceSnapshots() { + return keepSourceSnapshots; + } + + public boolean isRemoveWorkdir() { + return removeWorkdir; + } + + public boolean isUndefineDomain() { + return undefineDomain; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/AblestackV2KConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/AblestackV2KConvertInstanceCommand.java index 1e64137625f4..58ff779a0aa1 100644 --- a/core/src/main/java/com/cloud/agent/api/AblestackV2KConvertInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/AblestackV2KConvertInstanceCommand.java @@ -26,9 +26,29 @@ public class AblestackV2KConvertInstanceCommand extends Command { private String password; private DataStoreTO targetStorageLocation; private String splitMode; + private String workdir; private String targetFormat; private String targetStorage; private String targetMapJson; + private String targetDestinationPath; + private String targetProfile; + private String targetProvider; + private String cloudEndpoint; + private String cloudApiKey; + private String cloudSecretKey; + private String cloudZoneId; + private String cloudServiceOfferingId; + private String cloudNetworkIds; + private String cloudStorageId; + private String cloudDiskOfferingId; + private String cloudHostId; + private String cloudAccount; + private String cloudDomainId; + private String cloudProjectId; + private String cloudName; + private String cloudDisplayName; + private String cloudCpuSpeed; + private boolean resume; public AblestackV2KConvertInstanceCommand() { } @@ -67,6 +87,14 @@ public String getSplitMode() { return splitMode; } + public String getWorkdir() { + return workdir; + } + + public void setWorkdir(String workdir) { + this.workdir = workdir; + } + public String getTargetFormat() { return targetFormat; } @@ -91,6 +119,158 @@ public void setTargetMapJson(String targetMapJson) { this.targetMapJson = targetMapJson; } + public String getTargetDestinationPath() { + return targetDestinationPath; + } + + public void setTargetDestinationPath(String targetDestinationPath) { + this.targetDestinationPath = targetDestinationPath; + } + + public String getTargetProfile() { + return targetProfile; + } + + public void setTargetProfile(String targetProfile) { + this.targetProfile = targetProfile; + } + + public String getTargetProvider() { + return targetProvider; + } + + public void setTargetProvider(String targetProvider) { + this.targetProvider = targetProvider; + } + + public String getCloudEndpoint() { + return cloudEndpoint; + } + + public void setCloudEndpoint(String cloudEndpoint) { + this.cloudEndpoint = cloudEndpoint; + } + + public String getCloudApiKey() { + return cloudApiKey; + } + + public void setCloudApiKey(String cloudApiKey) { + this.cloudApiKey = cloudApiKey; + } + + public String getCloudSecretKey() { + return cloudSecretKey; + } + + public void setCloudSecretKey(String cloudSecretKey) { + this.cloudSecretKey = cloudSecretKey; + } + + public String getCloudZoneId() { + return cloudZoneId; + } + + public void setCloudZoneId(String cloudZoneId) { + this.cloudZoneId = cloudZoneId; + } + + public String getCloudServiceOfferingId() { + return cloudServiceOfferingId; + } + + public void setCloudServiceOfferingId(String cloudServiceOfferingId) { + this.cloudServiceOfferingId = cloudServiceOfferingId; + } + + public String getCloudNetworkIds() { + return cloudNetworkIds; + } + + public void setCloudNetworkIds(String cloudNetworkIds) { + this.cloudNetworkIds = cloudNetworkIds; + } + + public String getCloudStorageId() { + return cloudStorageId; + } + + public void setCloudStorageId(String cloudStorageId) { + this.cloudStorageId = cloudStorageId; + } + + public String getCloudDiskOfferingId() { + return cloudDiskOfferingId; + } + + public void setCloudDiskOfferingId(String cloudDiskOfferingId) { + this.cloudDiskOfferingId = cloudDiskOfferingId; + } + + public String getCloudHostId() { + return cloudHostId; + } + + public void setCloudHostId(String cloudHostId) { + this.cloudHostId = cloudHostId; + } + + public String getCloudAccount() { + return cloudAccount; + } + + public void setCloudAccount(String cloudAccount) { + this.cloudAccount = cloudAccount; + } + + public String getCloudDomainId() { + return cloudDomainId; + } + + public void setCloudDomainId(String cloudDomainId) { + this.cloudDomainId = cloudDomainId; + } + + public String getCloudProjectId() { + return cloudProjectId; + } + + public void setCloudProjectId(String cloudProjectId) { + this.cloudProjectId = cloudProjectId; + } + + public String getCloudName() { + return cloudName; + } + + public void setCloudName(String cloudName) { + this.cloudName = cloudName; + } + + public String getCloudDisplayName() { + return cloudDisplayName; + } + + public void setCloudDisplayName(String cloudDisplayName) { + this.cloudDisplayName = cloudDisplayName; + } + + public String getCloudCpuSpeed() { + return cloudCpuSpeed; + } + + public void setCloudCpuSpeed(String cloudCpuSpeed) { + this.cloudCpuSpeed = cloudCpuSpeed; + } + + public boolean isResume() { + return resume; + } + + public void setResume(boolean resume) { + this.resume = resume; + } + @Override public boolean executeInSequence() { return false; diff --git a/core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsAnswer.java b/core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsAnswer.java new file mode 100644 index 000000000000..99a74423d305 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsAnswer.java @@ -0,0 +1,51 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import org.apache.cloudstack.vm.UnmanagedInstanceTO; + +import java.util.List; + +public class AblestackV2KListVmwareVmsAnswer extends Answer { + + @LogLevel(LogLevel.Log4jLevel.Trace) + private List unmanagedInstances; + private Integer count; + + public AblestackV2KListVmwareVmsAnswer() { + } + + public AblestackV2KListVmwareVmsAnswer(AblestackV2KListVmwareVmsCommand command, String details, + List unmanagedInstances) { + this(command, details, unmanagedInstances, unmanagedInstances != null ? unmanagedInstances.size() : 0); + } + + public AblestackV2KListVmwareVmsAnswer(AblestackV2KListVmwareVmsCommand command, String details, + List unmanagedInstances, Integer count) { + super(command, true, details); + this.unmanagedInstances = unmanagedInstances; + this.count = count; + } + + public List getUnmanagedInstances() { + return unmanagedInstances; + } + + public Integer getCount() { + return count; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsCommand.java b/core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsCommand.java new file mode 100644 index 000000000000..c0f05fd98a0e --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/AblestackV2KListVmwareVmsCommand.java @@ -0,0 +1,86 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +public class AblestackV2KListVmwareVmsCommand extends Command { + + private String vcenter; + private String datacenterName; + private String username; + @LogLevel(LogLevel.Log4jLevel.Off) + private String password; + private String instanceName; + private String keyword; + private Long startIndex; + private Long pageSize; + + public AblestackV2KListVmwareVmsCommand() { + } + + public AblestackV2KListVmwareVmsCommand(String vcenter, String datacenterName, String username, String password, String instanceName) { + this(vcenter, datacenterName, username, password, instanceName, null, null, null); + } + + public AblestackV2KListVmwareVmsCommand(String vcenter, String datacenterName, String username, String password, String instanceName, + String keyword, Long startIndex, Long pageSize) { + this.vcenter = vcenter; + this.datacenterName = datacenterName; + this.username = username; + this.password = password; + this.instanceName = instanceName; + this.keyword = keyword; + this.startIndex = startIndex; + this.pageSize = pageSize; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenterName() { + return datacenterName; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getInstanceName() { + return instanceName; + } + + public String getKeyword() { + return keyword; + } + + public Long getStartIndex() { + return startIndex; + } + + public Long getPageSize() { + return pageSize; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/AblestackV2KStatusAnswer.java b/core/src/main/java/com/cloud/agent/api/AblestackV2KStatusAnswer.java index 9ae59519f804..00c00325afc8 100644 --- a/core/src/main/java/com/cloud/agent/api/AblestackV2KStatusAnswer.java +++ b/core/src/main/java/com/cloud/agent/api/AblestackV2KStatusAnswer.java @@ -23,6 +23,16 @@ public class AblestackV2KStatusAnswer extends Answer { private String migrationStep; private String syncPhysical; private String workdir; + private String targetProvider; + private String cloudVmId; + private String displayStep; + private String syncProgressLabel; + private Long syncDoneBytes; + private Long syncTotalBytes; + private Integer syncPercent; + private Long syncCumulativeDoneBytes; + private Long syncCumulativeKnownBytes; + private Integer syncCumulativePercent; public AblestackV2KStatusAnswer(Command command, boolean success, String details) { super(command, success, details); @@ -31,12 +41,20 @@ public AblestackV2KStatusAnswer(Command command, boolean success, String details public AblestackV2KStatusAnswer(Command command, boolean success, String details, String phase, String migrationState, String migrationStep, String syncPhysical, String workdir) { + this(command, success, details, phase, migrationState, migrationStep, syncPhysical, workdir, null, null); + } + + public AblestackV2KStatusAnswer(Command command, boolean success, String details, + String phase, String migrationState, String migrationStep, + String syncPhysical, String workdir, String targetProvider, String cloudVmId) { super(command, success, details); this.phase = phase; this.migrationState = migrationState; this.migrationStep = migrationStep; this.syncPhysical = syncPhysical; this.workdir = workdir; + this.targetProvider = targetProvider; + this.cloudVmId = cloudVmId; } public String getPhase() { @@ -58,4 +76,76 @@ public String getSyncPhysical() { public String getWorkdir() { return workdir; } + + public String getTargetProvider() { + return targetProvider; + } + + public String getCloudVmId() { + return cloudVmId; + } + + public String getDisplayStep() { + return displayStep; + } + + public void setDisplayStep(String displayStep) { + this.displayStep = displayStep; + } + + public String getSyncProgressLabel() { + return syncProgressLabel; + } + + public void setSyncProgressLabel(String syncProgressLabel) { + this.syncProgressLabel = syncProgressLabel; + } + + public Long getSyncDoneBytes() { + return syncDoneBytes; + } + + public void setSyncDoneBytes(Long syncDoneBytes) { + this.syncDoneBytes = syncDoneBytes; + } + + public Long getSyncTotalBytes() { + return syncTotalBytes; + } + + public void setSyncTotalBytes(Long syncTotalBytes) { + this.syncTotalBytes = syncTotalBytes; + } + + public Integer getSyncPercent() { + return syncPercent; + } + + public void setSyncPercent(Integer syncPercent) { + this.syncPercent = syncPercent; + } + + public Long getSyncCumulativeDoneBytes() { + return syncCumulativeDoneBytes; + } + + public void setSyncCumulativeDoneBytes(Long syncCumulativeDoneBytes) { + this.syncCumulativeDoneBytes = syncCumulativeDoneBytes; + } + + public Long getSyncCumulativeKnownBytes() { + return syncCumulativeKnownBytes; + } + + public void setSyncCumulativeKnownBytes(Long syncCumulativeKnownBytes) { + this.syncCumulativeKnownBytes = syncCumulativeKnownBytes; + } + + public Integer getSyncCumulativePercent() { + return syncCumulativePercent; + } + + public void setSyncCumulativePercent(Integer syncCumulativePercent) { + this.syncCumulativePercent = syncCumulativePercent; + } } diff --git a/developer/design/ablestack-v2k-n2k-cloud-import-integration.ko.md b/developer/design/ablestack-v2k-n2k-cloud-import-integration.ko.md new file mode 100644 index 000000000000..282c131b090a --- /dev/null +++ b/developer/design/ablestack-v2k-n2k-cloud-import-integration.ko.md @@ -0,0 +1,996 @@ +# ABLESTACK v2k/n2k Cloud Import Integration Design + +## 1. 목표 + +이 작업의 목표는 가상머신 import의 시작, 진행 상태 확인, phase2 cutover, 최종 Cloud VM 생성까지를 Cloud UI/API 관점에서 통합하는 것이다. + +현재 `ablestack-europa` 기준 Cloud에는 VMware -> KVM import와 `ablestack_v2k` phase1/phase2 흐름이 이미 들어와 있다. 그러나 이 구현은 v2k 전용 필드와 API에 강하게 묶여 있고, Nutanix -> KVM/Cloud 흐름인 `ablestack_n2k`는 Cloud UI/API에 아직 없다. + +따라서 설계 원칙은 다음과 같다. + +- 기존 `import_vm_task`와 `listImportVmTasks` 흐름을 유지해 UI/API 호환성을 보존한다. +- v2k 전용 모델을 generic Cloud migration task 모델로 확장한다. +- VMware/v2k와 Nutanix/n2k를 같은 task/status/finalize UI에서 다룬다. +- Cloud target 생성은 가능하면 Management Server 내부 서비스로 처리하고, Cloud API key/secret을 KVM host나 작업 디렉터리에 전달하지 않는다. +- Prism, vCenter, SSH, Cloud API secret은 DB/파일/manifest에 평문으로 저장하지 않는다. phase2 재사용이 필요한 source credential은 Cloud DB에 암호화해서 보관하고, 복호화는 Management Server의 task 실행 경로에서만 수행한다. + +## 2. 현재 상태 요약 + +### UI + +- 진입점은 `ui/src/views/tools/ManageInstances.vue`의 Tools > Manage Instances이다. +- VMware import는 `listVmwareDcVms`, `listVmwareDcs` API가 노출될 때만 보인다. +- `ui/src/views/tools/ImportUnmanagedInstance.vue`는 KVM cluster + VMware source일 때 `useablestackv2k` switch를 노출하고, 기본값을 true로 둔다. +- v2k mode에서는 `importUnmanagedInstanceForAblestackV2K`를 호출한다. +- `ui/src/views/tools/ImportVmTasks.vue`는 `listImportVmTasks` 결과를 보여주며, `v2kstep=Phase1_Completed` 또는 status상 phase1 완료이면 Phase2 버튼을 표시한다. + +### API/Backend + +- `ImportVmCmd`는 기존 `importVm` API이며 source는 `UNMANAGED`, `VMWARE`, `EXTERNAL`, `SHARED`, `LOCAL`이다. +- `ImportUnmanagedInstanceForAblestackV2KCmd`는 `split=phase1|phase2`, `importvmtaskid`를 추가한 v2k 전용 API이다. +- `UnmanagedVMsManagerImpl`은 v2k phase1에서 `AblestackV2KConvertInstanceCommand`를 KVM host에 보내고, phase2 완료 후 KVM unmanaged import 로직으로 최종 Cloud VM을 등록한다. +- `ImportVmTasksManagerImpl`은 `AblestackV2KStatusCommand`로 CLI 상태를 polling하고 `ImportVMTaskResponse`에 phase/state/step/workdir를 채운다. +- 현재 v2k Cloud storage 연동은 RBD 중심이다. `getAblestackV2KTargetFormat()`/`getAblestackV2KTargetStorage()`는 RBD와 SharedMountPoint만 분기하지만, `buildAblestackV2KTargetMapJson()`은 RBD에서만 target map을 만들고 다른 storage는 `null`을 반환한다. KVM wrapper도 target map을 RBD에서만 필수로 검사한다. 따라서 file 계열은 느슨한 best-effort이고, block/LVM/기타 primary storage는 현재 v2k Cloud 흐름에서 unsupported로 봐야 한다. + +### DB + +- `import_vm_task`는 legacy VMware import task에 v2k 필드가 덧붙은 형태이다. +- 현재 주요 v2k 확장 필드는 `v2k_step`, `cluster_id`, `service_offering_id`, `v2k_target_storage_pool_id`, `source_cluster_name`, `source_host_name`, `vcenter_id`, `vcenter_username`, `vcenter_password`, `service_offering_details`, `nic_network_map`이다. +- `vcenter_username`, `vcenter_password` 저장은 phase2 편의성은 있지만 secret hygiene 관점에서 개선 대상이다. + +## 3. 목표 아키텍처 + +```mermaid +flowchart LR + UI["Cloud UI: Import VM"] --> API["Cloud API: ablestack VM import"] + API --> Task["import_vm_task"] + API --> Orch["AblestackVmMigrationManager"] + Orch --> Source["Source connector: VMware or Nutanix"] + Orch --> Agent["KVM Agent command"] + Agent --> Tool["ablestack_v2k / ablestack_n2k"] + Tool --> Storage["Cloud primary storage image"] + Orch --> Finalize["Cloud internal finalize"] + Finalize --> VM["Cloud VM + volumes"] + UI --> Tasks["Unified task dashboard"] + Tasks --> API +``` + +핵심은 Cloud가 migration lifecycle의 주체가 되는 것이다. 도구 CLI는 source disk sync와 상태 보고를 맡고, Cloud의 task/finalize/resource ownership은 Management Server가 책임진다. + +## 4. DB 설계 + +### 4.1 기존 테이블 확장 + +기존 `import_vm_task`를 유지하고 다음 column을 추가한다. 기존 v2k column은 바로 제거하지 않고 하위 호환용으로 유지한다. + +| Column | Type | 설명 | +| --- | --- | --- | +| `migration_tool` | `varchar(32)` | `legacy`, `ablestack_v2k`, `ablestack_n2k` | +| `source_provider` | `varchar(32)` | `vmware`, `nutanix`, `kvm`, `local`, `shared` | +| `target_provider` | `varchar(32)` | `cloud`, `kvm` | +| `target_profile` | `varchar(64)` | resolver가 결정한 `cloud-rbd`, `cloud-file`, `cloud-block`, `libvirt-*`, plugin profile | +| `target_storage_pool_id` | `bigint unsigned` | v2k/n2k 공통 target primary storage | +| `target_format` | `varchar(16)` | `raw`, `qcow2` | +| `target_storage_type` | `varchar(32)` | `rbd`, `file`, `block` | +| `target_vm_name` | `varchar(255)` | Cloud에 생성될 VM 이름 | +| `source_endpoint` | `varchar(255)` | vCenter 또는 Prism endpoint. secret 제외 | +| `source_ref` | `varchar(255)` | source VM uuid/name 등 provider별 안정 식별자 | +| `source_inventory_json` | `text` | source VM disk/NIC/OS 요약 snapshot | +| `source_context_json` | `text` | cluster/host/datacenter/container 등 비밀이 아닌 source context | +| `source_credential_id` | `bigint unsigned` | phase2/retry에서 재사용할 encrypted credential row | +| `target_context_json` | `text` | zone/network/storage/disk offering/target map 등 | +| `workdir` | `varchar(1024)` | tool workdir | +| `split_mode` | `varchar(16)` | requested split: `phase1`, `phase2`, `full` | +| `current_phase` | `varchar(32)` | `prepare`, `phase1`, `phase2`, `finalize`, `completed` | +| `migration_state` | `varchar(32)` | tool-level state: `pending`, `running`, `completed`, `failed` | +| `migration_step` | `varchar(255)` | tool-level current step | +| `cutover_policy` | `varchar(32)` | `guest`, `poweroff`, `manual`, `none` | +| `status_json` | `mediumtext` | latest normalized status payload | +| `error_code` | `varchar(64)` | failure classification | + +권장 index: + +- `(zone_id, migration_tool, state, created)` +- `(zone_id, source_provider, state, created)` +- `(uuid)` +- `(target_provider, current_phase, migration_state)` + +### 4.2 Credential 저장 정책 + +phase2에서는 phase1 시작 시 입력한 credential을 재사용하는 것이 원칙이다. 따라서 external vCenter, Prism, source API credential은 DB에 저장하되 평문으로 저장하지 않고 task 전용 encrypted credential row로 분리한다. + +`import_vm_task`에는 password나 secret 값을 직접 저장하지 않는다. 대신 `source_credential_id`만 저장한다. + +```sql +CREATE TABLE `cloud`.`import_vm_task_credential` ( + `id` bigint unsigned NOT NULL auto_increment, + `uuid` varchar(40) NOT NULL, + `task_id` bigint unsigned NOT NULL, + `provider` varchar(32) NOT NULL, + `credential_type` varchar(32) NOT NULL, + `username_hint` varchar(255), + `encrypted_payload` mediumtext NOT NULL, + `encryption_version` varchar(32) NOT NULL, + `key_id` varchar(128), + `created` datetime NOT NULL, + `updated` datetime, + `removed` datetime, + PRIMARY KEY (`id`), + INDEX `i_import_vm_task_credential__task_id` (`task_id`), + CONSTRAINT `fk_import_vm_task_credential__task_id` + FOREIGN KEY (`task_id`) REFERENCES `import_vm_task`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +``` + +`encrypted_payload`는 provider별 credential JSON을 Cloud management-server의 encryption service로 암호화한 값이다. + +예시 payload: + +```json +{ + "endpoint": "https://10.10.132.100:9440", + "username": "admin", + "password": "...", + "sourceApi": "auto" +} +``` + +보안 규칙: + +- `source_context_json`, `target_context_json`, `status_json`, task event payload, management log에는 secret 값을 쓰지 않는다. +- API command의 credential parameter는 sensitive parameter로 표시하고 request logging/redaction 대상에 포함한다. +- phase1 시작 시 credential을 검증한 뒤 encrypted credential row를 만들고, phase2/retry/finalize는 `source_credential_id`로 복호화해 사용한다. +- 복호화는 Management Server의 task execution path에서만 수행한다. UI/API response에는 credential 존재 여부와 `username_hint` 정도만 노출한다. +- KVM agent command에는 credential을 CLI argument로 직접 넣지 않는다. Wrapper가 `0600` 권한의 root-owned protected temp credential file을 만들고 `--cred-file`/`--cloud-cred-file` 류 옵션으로 전달한 뒤, 프로세스 종료/cleanup 시 제거한다. command log에는 temp path와 redacted value만 남긴다. +- task cleanup 또는 보존 기간 만료 시 encrypted credential row를 soft-delete/purge할 수 있어야 한다. + +기존 v2k `vcenter_username`, `vcenter_password` column은 compatibility 때문에 즉시 삭제하지 않되 신규 generic path에서는 쓰지 않는다. 기존 registered vCenter처럼 Cloud가 이미 관리하는 credential 모델이 있는 경우에는 `source_context_json`에 `existingvcenterid`만 저장하고, 실제 credential 조회는 기존 DAO/credential owner 정책을 따른다. + +### 4.3 Task event table + +status polling 결과와 주요 전환을 추적하기 위해 별도 append-only table을 추가한다. + +```sql +CREATE TABLE `cloud`.`import_vm_task_event` ( + `id` bigint unsigned NOT NULL auto_increment, + `task_id` bigint unsigned NOT NULL, + `event_type` varchar(64) NOT NULL, + `phase` varchar(32), + `state` varchar(32), + `step` varchar(255), + `message` text, + `payload_json` mediumtext, + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + INDEX `i_import_vm_task_event__task_id_created` (`task_id`, `created`), + CONSTRAINT `fk_import_vm_task_event__task_id` + FOREIGN KEY (`task_id`) REFERENCES `import_vm_task`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +``` + +이 테이블은 UI detail drawer의 event timeline과 장애 분석에 사용한다. + +## 5. API 설계 + +### 5.1 Compatibility API + +기존 API는 유지한다. + +- `importVm` +- `importUnmanagedInstanceForAblestackV2K` +- `listImportVmTasks` +- `listVmwareDcs` +- `listVmwareDcVms` + +단, `importUnmanagedInstanceForAblestackV2K` 내부 구현은 새 generic manager로 위임한다. + +### 5.2 Generic ablestack migration API + +새 UI는 generic API를 우선 사용한다. + +#### `listVmImportSources` + +Cloud가 지원하는 source provider와 capability를 반환한다. + +주요 response: + +- `provider`: `vmware`, `nutanix` +- `tool`: `ablestack_v2k`, `ablestack_n2k` +- `supportsPhaseSplit` +- `supportsCloudTarget` +- `storesEncryptedCredentialForPhase2` +- `supportedStorageTypes`: installed tool와 Cloud storage resolver가 함께 지원하는 `rbd|file|block` 목록 +- `available`: API/plugin/agent capability 기준 가능 여부 + +#### `listVmsForAblestackImport` + +source VM inventory 조회용 API이다. + +주요 request: + +- `sourceprovider`: `VMWARE|NUTANIX` +- `zoneid` +- `existingvcenterid` 또는 `vcenter/datacentername/username/password` +- `prismendpoint`, `prismusername`, `prismpassword`, `sourceapi=auto|v4|v3|v2` +- `name`, `page`, `pagesize` + +주요 response: + +- `sourcevmid` +- `name` +- `powerstate` +- `osdisplayname` +- `clustername` +- `hostname` +- `disk[]` +- `nic[]` +- `sourcecapabilities` + +VMware는 기존 `listVmwareDcVms` adapter를 재사용한다. Nutanix는 management server에서 직접 Prism API를 호출하거나, 선택된 KVM conversion host에 inventory command를 보내는 두 방법을 제공한다. 초기 구현은 agent command 방식이 CLI/runtime dependency와 맞다. + +#### `preflightAblestackVmImport` + +source VM과 Cloud target 계획을 검증한다. + +검증 항목: + +- source credential 접근 가능 여부 +- selected VM disk/NIC inventory +- target cluster/host/tool 설치 여부 +- target storage type과 format 매핑 +- service offering custom details +- network mapping +- writeback disk offering 자동 선택 가능 여부 +- phase1/full 가능 여부 + +#### `importVmForAblestackMigration` + +v2k/n2k 공통 start/continue API이다. + +주요 request: + +- `tool`: `ABLESTACK_V2K|ABLESTACK_N2K` +- `sourceprovider`: `VMWARE|NUTANIX` +- `targetprovider`: `CLOUD` +- `split`: `phase1|phase2|full` +- `importvmtaskid`: phase2/retry/finalize continuation +- `zoneid`, `clusterid` +- `convertinstancehostid` +- `serviceofferingid`, custom `details` +- `targetstoragepoolid` +- `targetprofile` +- `name` 또는 `targetvmname` +- `nicnetworklist`, `nicipaddresslist` +- `datadiskofferinglist` +- source credential fields. phase1/full에서는 encrypted credential row를 생성하고 phase2/retry에서 재사용한다. +- `cutoverpolicy`: phase2/full에서 `guest|poweroff|manual|none` + +response는 `ImportVMTaskResponse` 또는 async job의 job result로 task id를 반환한다. 기존 `UserVmResponse` 빈 응답보다 task 중심 응답이 Cloud UI에 더 자연스럽다. + +#### `listImportVmTasks` 확장 + +추가 filter: + +- `migrationtool` +- `sourceprovider` +- `targetprovider` +- `phase` +- `migrationstate` +- `sourcevmname` +- `includelegacy` + +추가 response: + +- `migrationtool` +- `sourceprovider` +- `targetprovider` +- `targetprofile` +- `targetvmname` +- `workdir` +- `currentphase` +- `migrationstate` +- `migrationstep` +- `cutoverpolicy` +- `credentialstate`: `available`, `missing`, `expired`, `purged` +- `credentialusernamehint` +- `availableactions[]` + +#### `listImportVmTaskEvents` + +Task detail timeline용 API이다. + +Request: + +- `importvmtaskid` +- `page`, `pagesize` + +Response: + +- event list from `import_vm_task_event` + +#### Task action API + +단일 action API 또는 action별 API를 둔다. + +- `executeImportVmTaskAction` + - `action=START_PHASE2|REFRESH_STATUS|FINALIZE|CLEANUP|CANCEL|RETRY` + - `importvmtaskid` + - `cutoverpolicy` + - optional credential override fields only when the encrypted credential is missing, expired, or explicitly rotated + +초기 구현은 `START_PHASE2`, `REFRESH_STATUS`, `CLEANUP`부터 넣는다. `CANCEL`은 tool-level cancel semantics가 확정된 뒤 구현한다. + +## 6. Backend 설계 + +### 6.1 Manager 구조 + +새 manager를 추가한다. + +```text +AblestackVmMigrationManager + - preflight(...) + - listSourceVms(...) + - start(...) + - continuePhase2(...) + - refreshStatus(...) + - finalizeTask(...) + - cleanupTask(...) +``` + +Provider/adapter 분리: + +```text +MigrationSourceAdapter + - VMwareSourceAdapter + - NutanixSourceAdapter + +MigrationToolAdapter + - AblestackV2KAdapter + - AblestackN2KAdapter + +MigrationTargetAdapter + - CloudTargetAdapter + - KvmTargetAdapter(optional compatibility) +``` + +기존 `UnmanagedVMsManagerImpl` 안에 있는 v2k 메서드는 새 manager로 점진 이동한다. 첫 구현에서는 기존 class 안에서 helper를 늘리되, 파일이 더 커지지 않도록 adapter class를 분리하는 것이 좋다. + +### 6.2 Agent command + +기존 v2k command와 wrapper는 유지하면서 generic command를 추가한다. + +```text +AblestackVmMigrationCommand + tool: ABLESTACK_V2K | ABLESTACK_N2K + taskUuid + vmName + splitMode + sourceProvider + sourceEndpoint + sourceCredential + targetProvider + targetStorageType + targetFormat + targetMapJson + workdir + optionsJson + +AblestackVmMigrationStatusCommand + tool + vmName + workdir + +AblestackVmMigrationCleanupCommand + tool + vmName + workdir +``` + +`sourceCredential`은 DB에 저장된 encrypted credential을 Management Server가 복호화한 runtime-only 값이다. 이 객체는 API response, task event, agent command `toString()`, management log에 직접 출력하지 않는다. Wrapper는 CLI 실행 직전에 protected temp credential file과 redacted command builder를 사용하고, 실행 후 temp file을 제거한다. + +KVM wrapper는 tool별 CLI를 호출한다. + +v2k: + +```bash +ablestack_v2k run \ + --vm \ + --vcenter \ + --split \ + --target-format \ + --target-storage \ + --target-map-json +``` + +n2k: + +```bash +ablestack_n2k \ + --workdir \ + run \ + --pc \ + --vm \ + --split \ + --target-provider cloud-managed \ + --target-format \ + --target-storage \ + --target-map-json +``` + +`cloud-managed`는 Cloud가 최종 VM 생성을 직접 수행한다는 의미의 신규/정렬 대상이다. qemu-exec-tools가 당장 이 옵션을 갖지 않는다면 초기에는 `--apply` 없는 disk-sync mode 또는 libvirt/file target mode를 사용하고, Cloud backend가 importVolume/deploy step을 수행한다. + +### 6.3 Status normalization + +v2k와 n2k의 status 출력은 서로 다르므로 backend는 다음 normalized model로 통일한다. + +```json +{ + "phase": "phase1", + "state": "running", + "step": "sync", + "progress": { + "percent": 71, + "currentDisk": "scsi0:0", + "bytesCopied": 1234, + "bytesTotal": 5678 + }, + "workdir": "/var/lib/ablestack-n2k/rhel/20260520-120944-writeback", + "resumePlan": "run --split phase2", + "finalizeReady": false, + "message": "..." +} +``` + +이 model을 `status_json`에 저장하고, 기존 response 필드인 `phase`, `migrationstate`, `migrationstep`, `workdir`에도 projection한다. + +### 6.4 Cloud target finalize + +v2k 현행 방식: + +1. v2k가 KVM domain/disk를 준비한다. +2. Cloud가 unmanaged KVM VM import로 최종 VM을 등록한다. + +개선 설계: + +- v2k도 target storage map을 Cloud primary storage 기준으로 만들고, finalize는 `importUnmanagedInstance` 재사용 또는 volume import path를 선택한다. +- RBD/file/block storage의 target path naming과 volume reservation은 Cloud가 task 생성 시 확정해 tool에 전달한다. + +n2k 권장 방식: + +1. Cloud가 선택된 primary storage type을 해석해 target disk plan을 만든다. +2. block/LVM 계열이면 phase1 전에 Cloud volume 또는 block target을 예약하고, file/RBD 계열이면 target image name/path를 예약한다. +3. phase1/phase2 동안 n2k는 예약된 Cloud primary storage target에 root/data disk를 동기화한다. +4. phase2 완료 후 Management Server가 내부 service로 volume import 또는 reserved volume association을 수행한다. +5. root volume 기반 VM 생성 API/service를 호출한다. +6. data disk를 attach한다. +7. VM start policy에 따라 start한다. + +Cloud API key/secret을 KVM host에 전달하지 않는 것이 핵심이다. + +### 6.5 Target storage resolver + +UI는 제한된 target profile을 수동으로 고르게 하지 않고, 사용자가 선택한 Cloud primary storage를 기준으로 target plan을 자동 생성한다. 이 resolver는 installed `ablestack_v2k`/`ablestack_n2k` capability와 Cloud primary storage type을 함께 보고 `target_storage_type`, `target_format`, `target_map_json`, finalize strategy를 결정한다. + +지원 원칙: + +- v2k/n2k가 지원하는 모든 storage mode를 Cloud storage resolver에 등록한다. +- 특정 tool version 또는 Cloud storage plugin이 아직 어떤 mode를 지원하지 않으면 UI를 숨기지 않고 preflight에서 명확한 unsupported reason을 반환한다. +- 사용자가 기본 스토리지만 선택하면 resolver가 맞는 format/path/map/finalize strategy를 자동으로 채운다. Advanced override는 디버깅/검증 목적에만 둔다. + +Cloud target plan: + +| Cloud primary storage | Tool storage | Format | Tool target | Finalize strategy | +| --- | --- | --- | --- | --- | +| RBD | `rbd` | `raw` | `rbd:/` | `importVolume` 또는 RBD volume association | +| SharedMountPoint / Filesystem / NetworkFilesystem | `file` | `qcow2` | `/.qcow2` | file-backed volume import | +| LVM / CLVM / block primary storage | `block` | `raw` | reserved block device path or device mapper path | pre-created/reserved volume association | +| Host-local file storage | `file` | `qcow2` 또는 `raw` | selected host local pool path | host-pinned unmanaged import/finalize | +| Plugin-provided storage | resolver capability | resolver capability | resolver-generated map | plugin-specific finalize adapter | + +Resolver output: + +```json +{ + "targetProfile": "cloud-rbd", + "targetStorageType": "rbd", + "targetFormat": "raw", + "targetMapJson": { + "scsi0:0": "rbd:rbd/ablestack-n2k--root", + "scsi0:1": "rbd:rbd/ablestack-n2k--data1" + }, + "finalizeStrategy": "IMPORT_VOLUME", + "requiresPrecreatedVolume": false, + "hostAffinityRequired": false +} +``` + +Block/LVM output는 `requiresPrecreatedVolume=true`가 되며, phase1 전에 Cloud가 volume/device를 준비한다. file/RBD output는 image/path reservation만 필요하다. + +Naming: + +```text +---root +---data +``` + +이름은 task 생성 시 DB에 저장하고 phase2/finalize까지 변경하지 않는다. + +### 6.6 v2k Cloud storage 개선 + +현행 v2k 구현은 RBD Cloud target에 맞춰진 부분이 많으므로, generic resolver 적용 전에 v2k adapter를 다음처럼 개선한다. + +현재 코드상의 제한: + +- RBD만 `targetMapJson`을 생성한다. +- SharedMountPoint는 `target-storage=file`, `target-format=qcow2`까지는 내려가지만 disk별 target path/map이 Cloud task에 안정적으로 기록되지 않는다. +- `getAblestackV2KTargetFormat()`과 `getAblestackV2KTargetStorage()`는 RBD/SharedMountPoint 외 primary storage를 unsupported 처리한다. +- wrapper의 `--dst`는 RBD일 때 `/var/lib/libvirt/images/`으로 고정되고, file일 때 storage pool local path만 사용한다. +- block/LVM target reservation, pre-created volume association, Cloud finalize strategy가 없다. + +개선 방향: + +1. v2k도 n2k와 같은 `TargetStorageResolver` output을 사용한다. +2. RBD/file/block 모두 disk별 `targetMapJson` 또는 equivalent target manifest를 생성한다. +3. SharedMountPoint/Filesystem/NetworkFilesystem은 Cloud storage pool `path`를 기준으로 root-level qcow2 target path를 확정한다. +4. Host-local file storage는 selected conversion/import host affinity를 강제하고, task context에 host affinity를 저장한다. +5. LVM/block storage는 phase1 전에 Cloud가 target volume/block device를 예약하고, v2k에는 device path map을 넘긴다. +6. v2k wrapper는 `rbd`만 특별취급하지 않고 resolver가 준 `targetStorageType`, `targetFormat`, `targetMapJson`, `dst`를 그대로 사용한다. +7. phase2 완료 후 finalize는 storage별 strategy를 사용한다. + - RBD/file: volume import 또는 unmanaged import + - block/LVM: pre-created/reserved volume association + - host-local: host-pinned unmanaged import +8. UI preflight는 현재 installed v2k version이 특정 storage mode를 지원하지 않으면 `unsupported by ablestack_v2k runtime` reason을 보여준다. + +v2k storage 개선은 n2k 추가보다 먼저 또는 동시에 진행해야 한다. 그렇지 않으면 UI가 "기본 스토리지를 선택하면 자동 대응"한다고 보여주면서 v2k는 사실상 RBD에서만 안정 동작하는 상태가 된다. + +### 6.7 Disk offering/writeback + +n2k/v2k Cloud target은 writeback disk offering을 자동 선택한다. + +- shared storage: `N2K Migration Writeback` 또는 `V2K Migration Writeback` +- local storage: `N2K Migration Writeback Local` 또는 `V2K Migration Writeback Local` +- 필수 조건: `customized=true`, no tags, `cachemode=writeback` + +공통 helper를 둔다. + +```text +resolveMigrationWritebackDiskOffering(tool, storageScope, account, zone) +``` + +동일 이름의 incompatible offering이 있으면 preflight에서 실패시킨다. + +## 7. UI 설계 + +### 7.0 UI 공통 원칙 + +새 v2k/n2k import UI는 기존 Cloud UI의 룩앤필을 최대한 준용한다. 별도 디자인 시스템이나 독립적인 visual language를 만들지 않고, 현재 `ManageInstances.vue`, `ImportUnmanagedInstance.vue`, `ImportVmTasks.vue`가 사용하는 Ant Design Vue component, form layout, table, card, modal, drawer, notification, pagination, status 표현 방식을 재사용한다. + +구현 원칙: + +- 기존 Tools > Manage Instances 화면의 정보 밀도와 흐름을 유지한다. +- 새 source 선택, target resolver preview, task detail drawer는 기존 form/table/card 스타일 위에 얹는다. +- 색상, border, spacing, font size는 전역 theme token과 기존 class를 우선 사용하고, component-local CSS는 필요한 경우에만 최소화한다. +- inline hard-coded color를 피하고, light/dark mode에서 모두 읽히는 token 또는 CSS variable을 사용한다. +- 상태 badge, progress, warning, error, disabled reason은 기존 `Status`, Ant Design status color, notification 패턴을 따른다. +- 다국어 지원을 위해 UI 문구는 모두 locale key로 분리한다. template/script에 영문/한글 문구를 직접 박지 않는다. +- API에서 내려오는 machine state는 UI에서 locale key로 mapping한다. 예: `Phase1_Completed` -> `label.migration.phase1.completed`. +- 긴 한글/영문/식별자/경로가 table cell, button, drawer 안에서 깨지지 않도록 responsive wrapping, ellipsis, tooltip을 적용한다. +- light/dark mode 모두에서 wizard, table, drawer, modal, preflight warning, credential status, storage resolver preview를 확인한다. + +Locale 파일: + +- `ui/public/locales/en.json` +- `ui/public/locales/ko_KR.json` + +신규 key는 `label.migration.*`, `message.migration.*`, `error.migration.*` prefix를 우선 사용한다. 기존 `label.import.vm.tasks`, `label.phase2.execute`처럼 이미 있는 key는 재사용한다. + +### 7.1 진입점 + +기존 Manage Instances 화면을 유지하되 source action을 재구성한다. + +- Existing unmanaged VM +- VMware via ABLESTACK V2K +- Nutanix via ABLESTACK N2K +- External KVM +- Local disk +- Shared disk + +VMware/Nutanix는 일반 "VM Import" 안에서 source provider tab 또는 segmented control로 고른다. + +### 7.2 Wizard 단계 + +1. Source + - VMware: existing vCenter 또는 external vCenter + - Nutanix: Prism Central/Element endpoint, source API `auto|v4|v3`, credential input + - Source VM list + +2. Target Cloud + - Zone, cluster, conversion host + - Primary storage + - Auto-resolved target plan: `cloud-rbd`, `cloud-file`, `cloud-block`, plugin-specific profile + - Tool storage/format preview: `rbd/raw`, `file/qcow2`, `block/raw` 등 + - Service offering/custom details + - Target VM name + +3. Network and Disk + - NIC -> Cloud network mapping + - Optional static IP + - Disk offering/writeback result preview + - Disk target path preview + +4. Run mode + - `phase1`, `full` + - For phase2 only task dashboard action에서 실행 + - Cutover policy is disabled in phase1, required in phase2/full + +5. Review and Start + - Preflight result + - Submit + +### 7.3 Task dashboard + +`ImportVmTasks.vue`를 generic task dashboard로 확장하거나 새 `VmMigrationTasks.vue`로 분리한다. + +Columns: + +- Created +- Tool +- Source provider +- Source VM +- Target VM +- Phase +- State +- Step +- Progress +- Conversion host +- Workdir +- Actions + +Actions: + +- Refresh +- Start Phase2 +- Finalize +- Retry status +- Cleanup +- Open VM + +Phase2 action은 다음 조건에서만 노출한다. + +- `migration_tool in (ablestack_v2k, ablestack_n2k)` +- `current_phase=phase1` +- `migration_state=completed` +- `vm_id is null` +- target context가 유효함 +- encrypted credential이 `available` 상태임 + +Phase2 modal은 기본적으로 credential을 다시 받지 않는다. 대신 phase1에서 암호화 저장된 credential을 재사용한다. UI는 `credentialstate=available`과 `credentialusernamehint`를 표시하고, credential이 `missing`, `expired`, `purged`이거나 운영자가 명시적으로 교체할 때만 credential override 입력을 연다. + +### 7.4 Task detail drawer + +Task row 클릭 시 drawer를 연다. + +Sections: + +- Summary +- Source +- Target plan +- Disk map +- Network map +- Latest status +- Event timeline +- Error detail + +secret 값은 표시하지 않는다. + +### 7.5 Permission/API gating + +UI는 `listVmImportSources` 또는 `listApis` 결과로 기능을 켠다. + +- VMware/v2k: `listVmwareDcVms`, `importVmForAblestackMigration` 또는 compatibility v2k API +- Nutanix/n2k: `listVmsForAblestackImport`, `importVmForAblestackMigration`, KVM agent capability + +n2k CLI 미설치 또는 agent wrapper 미지원이면 Nutanix source action을 disabled 상태로 표시하고 preflight에서 원인을 보여준다. + +## 8. 소스코드 작성 단계 + +총 9단계로 나누어 진행한다. 각 단계는 가능하면 독립 commit 또는 작은 PR 단위로 끝낼 수 있게 만든다. 앞 단계의 schema/API contract가 뒤 단계의 UI와 runtime 구현을 받쳐야 하므로, DB/API 기반을 먼저 고정하고 v2k 개선, n2k 추가, UI 통합 순서로 진행한다. + +### 1단계: DB schema와 domain model 기반 + +목표: + +- `import_vm_task`를 generic migration task로 확장한다. +- task event와 encrypted credential 저장소의 DB 기반을 만든다. + +주요 작업: + +- `import_vm_task` generic column 추가 +- `import_vm_task_event` table 추가 +- `import_vm_task_credential` table 추가 +- `ImportVMTaskVO`, `ImportVmTask`, DAO, schema migration 정리 +- fresh install과 Europa upgrade 모두 idempotent하게 통과하도록 schema 파일 정리 + +완료 기준: + +- DB migration SQL이 중복 실행 가능해야 한다. +- 기존 legacy/v2k task row가 깨지지 않아야 한다. +- DAO 단위 테스트 또는 최소 schema/VO compile 검증을 통과해야 한다. + +### 2단계: encrypted credential과 redaction + +목표: + +- phase1에서 입력한 credential을 암호화 저장하고 phase2/retry/finalize에서 재사용한다. +- request/log/response/task event에 secret이 남지 않도록 한다. + +주요 작업: + +- `ImportVmTaskCredentialVO`와 DAO 추가 +- Management Server encryption service 기반 encrypt/decrypt helper 추가 +- vCenter/Prism credential payload 저장/조회 API 내부 helper 추가 +- API parameter sensitive marking과 logging redaction 정리 +- agent command `toString()`/log redaction 점검 +- protected temp credential file 생성/삭제 helper 설계 반영 + +완료 기준: + +- phase2 API 호출 시 credential 재입력 없이 task credential을 복호화해 사용할 수 있어야 한다. +- UI/API response와 management log에 password/secret 원문이 없어야 한다. +- credential row cleanup/soft-delete 경로가 있어야 한다. + +### 3단계: task API, response, event/status 공통화 + +목표: + +- v2k/n2k가 공통 task API와 response model 위에서 동작할 수 있게 한다. + +주요 작업: + +- `ImportVMTaskResponse`에 `migrationtool`, `sourceprovider`, `targetprovider`, `targetprofile`, `targetvmname`, `workdir`, `currentphase`, `migrationstate`, `migrationstep`, `credentialstate`, `availableactions` 추가 +- `listImportVmTasks` filter 확장 +- `listImportVmTaskEvents` 추가 +- `executeImportVmTaskAction` 또는 action별 API skeleton 추가 +- status normalization model과 event append helper 추가 + +완료 기준: + +- 기존 `listImportVmTasks` UI/API 호출은 하위 호환되어야 한다. +- 신규 filter와 response field가 API discovery에 노출되어야 한다. +- task event timeline을 API로 조회할 수 있어야 한다. + +### 4단계: TargetStorageResolver와 v2k storage 개선 + +목표: + +- 현재 RBD 중심인 v2k Cloud storage coupling을 제거한다. +- 사용자가 기본 primary storage를 선택하면 RBD/file/block에 맞는 target plan이 자동 생성되도록 한다. + +주요 작업: + +- `AblestackV2KTargetStorageResolver`와 `AblestackV2KTargetStoragePlan` 추가 +- RBD/raw, SharedMountPoint/Filesystem/NetworkFilesystem file/qcow2 target plan 생성 +- v2k `getAblestackV2KTargetFormat()`, `getAblestackV2KTargetStorage()`, `buildAblestackV2KTargetMapJson()`을 resolver 기반으로 재정리 +- v2k wrapper가 resolver의 `dst`, `targetStorageType`, `targetFormat`, `targetMapJson`을 그대로 사용하도록 개선 +- NetworkFilesystem처럼 Management Server의 storage path와 KVM host local mount path가 다를 수 있는 file storage는 agent-side storage pool local path를 fallback으로 사용 +- block/LVM/Iscsi/PowerFlex/Linstor/StorPool/FiberChannel 계열은 v2k의 `block/raw` target으로 분류하되, Cloud phase1에서 안전한 per-disk block device reservation map을 만들 수 없으면 명확한 preflight 실패 사유를 반환 +- block 계열의 실제 Cloud volume/device reservation 흐름은 별도 reservation contract가 준비된 뒤 활성화 +- unsupported storage는 preflight reason으로 반환 + +완료 기준: + +- v2k RBD/raw 기존 동작이 유지되어야 한다. +- v2k SharedMountPoint/Filesystem/NetworkFilesystem qcow2 target path가 task context에 안정적으로 남아야 한다. +- block/raw는 잘못된 device path를 추정하지 않고 resolver/preflight 단계에서 안전하게 중단되어야 한다. + +진행 상태: + +- 구현됨: RBD/raw target map, file/qcow2 target plan, agent-side destination fallback, task context 저장, v2k command parameter 전달 +- 구현됨: block 계열 storage type 감지와 안전한 preflight 실패 처리 +- 보류: block 계열 실제 Cloud volume/device reservation map 생성과 활성화 + +### 5단계: generic migration manager와 v2k 호환 API 이관 + +목표: + +- 기존 v2k 전용 구현을 generic manager 위로 올려 이후 n2k와 UI가 같은 흐름을 쓰게 한다. + +주요 작업: + +- `AblestackVmMigrationManager` 추가 +- `MigrationSourceAdapter`, `MigrationToolAdapter`, `MigrationTargetAdapter` interface 추가 +- `AblestackV2KAdapter` 추가 +- 기존 `importUnmanagedInstanceForAblestackV2K`가 generic manager를 호출하도록 변경 +- v2k phase1/phase2/status/finalize가 신규 task/status/event/credential/storage resolver를 사용하도록 이관 + +완료 기준: + +- 기존 v2k UI에서 phase1/phase2가 계속 동작해야 한다. +- 기존 compatibility API response와 async job behavior가 깨지지 않아야 한다. +- 신규 task response field에도 v2k 상태가 채워져야 한다. + +진행 상태: + +- 구현됨: `AblestackVmMigrationManager`, generic migration request, source/tool/target adapter interface skeleton +- 구현됨: `AblestackV2KAdapter` 추가 및 기존 `importUnmanagedInstanceForAblestackV2K` compatibility API의 generic manager 위임 +- 유지됨: v2k phase1/phase2 실행, credential 재사용, task event/status, target storage resolver, finalize 경로 +- 보류: source/target adapter의 실제 preflight 구현과 n2k adapter 등록은 6-7단계에서 진행 + +### 6단계: n2k source inventory와 preflight + +목표: + +- Cloud UI/API에서 Nutanix source VM을 조회하고 target 계획을 사전 검증할 수 있게 한다. + +주요 작업: + +- `NutanixSourceAdapter` 추가 +- Prism endpoint/API version/credential handling 추가 +- `listVmsForAblestackImport`의 Nutanix path 추가 +- `preflightAblestackVmImport` Nutanix/n2k 검증 추가 +- source VM disk/NIC inventory를 `source_inventory_json`에 저장 가능한 normalized model로 변환 + +완료 기준: + +- Prism source VM 목록과 VM detail이 Cloud API response로 조회되어야 한다. +- credential은 encrypted credential flow와 연결되어야 한다. +- n2k CLI/agent 미설치, API fallback 불가, target storage 미지원 같은 실패가 preflight reason으로 표현되어야 한다. + +진행 상태: + +- 구현됨: `NutanixSourceAdapter`가 Prism v4 -> v3 -> v2 순서로 VM inventory를 조회하고 `UnmanagedInstanceTO`로 정규화 +- 구현됨: `listVmsForImport`에 `sourceprovider=nutanix`, `sourceapi=auto|v4|v3|v2`, `insecure` 파라미터 추가 +- 구현됨: `preflightAblestackVmImport` API와 `AblestackVmImportPreflightResponse` 추가 +- 구현됨: n2k Cloud target preflight에서 RBD/raw, file/qcow2 storage plan 검증 및 unsupported storage reason 반환 +- 구현됨: phase1 시작 시 Nutanix credential을 encrypted credential flow에 저장하고 `source_inventory_json` snapshot을 task context에 저장 + +### 7단계: n2k runtime, status, finalize + +목표: + +- n2k phase1/full/phase2를 Cloud task lifecycle에 연결하고 최종 Cloud VM 생성까지 마무리한다. + +주요 작업: + +- `AblestackN2KConvertInstanceCommand`, status, cleanup command 추가 +- KVM wrapper에서 `ablestack_n2k` 실행, status parsing, cleanup 구현 +- n2k target plan을 RBD/file/block resolver와 연결 +- n2k phase1/full start +- n2k phase2 action +- phase2 완료 후 Cloud internal finalize: volume import/reserved volume association, VM 생성, data disk attach, start policy + +완료 기준: + +- n2k task가 `phase1 -> Phase2 가능 -> phase2 -> finalize -> Completed`로 전환되어야 한다. +- status polling 결과가 normalized response와 event timeline에 반영되어야 한다. +- 22.x RBD와 1.x SharedMountPoint target 모두 설계상 같은 API 흐름을 사용해야 한다. + +진행 상태: + +- 구현됨: `importUnmanagedInstanceForAblestackN2K` API command 추가 +- 구현됨: generic migration manager에 `AblestackN2KAdapter` 등록 +- 구현됨: `AblestackN2KConvertInstanceCommand`, `AblestackN2KStatusCommand`, `AblestackN2KCleanupCommand`와 answer model 추가 +- 구현됨: KVM wrapper에서 `ablestack_n2k --workdir ... run/status/cleanup` 실행 +- 구현됨: KVM wrapper가 Nutanix credential을 0600 임시 env 파일로 생성하고 실행 후 삭제 +- 구현됨: n2k status JSON의 `resume`, `runtime.split`, `phases`를 `phase/state/step/sync/workdir/status_json`으로 정규화 +- 구현됨: phase1 시작 시 Cloud task 생성, credential 암호화 저장, source inventory snapshot 저장, target storage plan 저장 +- 구현됨: phase2 재개 시 DB에 저장된 credential을 재사용하고, 완료 후 기존 Cloud unmanaged KVM import finalization 경로를 재사용 +- 구현됨: n2k cleanup command는 Cloud finalize 후 `--keep-source-points --remove-workdir --apply`로 호출 +- 제약: 현재 Cloud-managed n2k run은 `ablestack_n2k` CLI 제약에 맞춰 `sourceapi=v3` snapshot/NFS data path로 실행한다. v4/v3/v2 inventory fallback은 6단계 API/preflight에서 지원하지만, 실제 data-plane run은 n2k upstream이 v4 native run을 제공하기 전까지 v3로 고정한다. +- 제약: 7단계 구현은 backend/agent runtime path이다. UI에서 phase2 action 버튼과 status refresh를 연결하는 작업은 8단계에서 수행한다. + +### 8단계: UI 통합 + +목표: + +- 기존 Cloud UI 룩앤필을 유지하면서 VMware/v2k와 Nutanix/n2k를 같은 import wizard와 task dashboard로 통합한다. + +주요 작업: + +- Manage Instances source action에 VMware via V2K, Nutanix via N2K 정리 +- v2k switch를 generic source/tool 선택 흐름으로 정리 +- Nutanix source credential/source VM list UI 추가 +- primary storage 선택 기반 target resolver preview 추가 +- unified task dashboard/detail drawer 추가 +- phase2 credential status/rotation UI 추가 +- light/dark mode 대응 style 정리 +- `en.json`, `ko_KR.json` locale key 추가와 hard-coded text 제거 + +완료 기준: + +- 기존 VM import 화면과 이질감이 없어야 한다. +- 기본 모드와 다크 모드에서 table, drawer, modal, warning이 읽혀야 한다. +- English/Korean locale에서 버튼/table/status 문구가 locale key로 표시되어야 한다. + +진행 상태: + +- 구현됨: `ManageInstances.vue`의 source action에 Nutanix/N2K 선택지를 추가하고, Prism endpoint, source API, TLS 검증 옵션을 기존 VMware import wizard 흐름 안에 배치 +- 구현됨: Nutanix source VM 조회를 `listVmsForImport`의 `sourceprovider=nutanix`, `hypervisor=Nutanix`, `sourceapi`, `insecure` 파라미터로 연결 +- 구현됨: task tab을 VMware/v2k 전용에서 ABLESTACK Cloud migration 공통 tab으로 확장하고, tool/source별 task filter를 적용 +- 구현됨: `ImportUnmanagedInstance.vue`에 N2K target host/primary storage 선택, target storage preview, `preflightAblestackVmImport` 실행 버튼을 추가 +- 구현됨: N2K phase1 submit을 `importUnmanagedInstanceForAblestackN2K` API로 연결하고, source credential과 target storage 선택값을 backend contract에 맞춰 전달 +- 구현됨: `ImportVmTasks.vue`에 migration tool/source provider/credential state column, task detail drawer, event timeline, credential clear action을 추가 +- 구현됨: phase2 실행 modal에서 저장 credential 재사용 원칙을 UI에 표시하고, 필요한 경우에만 endpoint/user/password/source API/TLS 값을 갱신해 phase2 API로 전달 +- 구현됨: 신규 UI 문구를 `en.json`, `ko_KR.json`에 추가해 English/Korean locale에서 hard-coded text 없이 표시되도록 정리 +- 검증됨: 변경 Vue 파일 3개 targeted eslint 통과 +- 검증됨: `NODE_OPTIONS=--openssl-legacy-provider npm run build`로 UI production build 통과 +- 제약: 전체 `npm run lint -- --no-fix`는 기존 `tests/unit/components/view/ActionButton.spec.js` 들여쓰기 오류에서 실패한다. 이번 8단계 변경 파일의 targeted lint와 production build는 통과했다. +- 제약: 실제 light/dark 화면 육안 검증과 VMware/Nutanix E2E click smoke는 9단계 통합 검증에서 수행한다. + +### 9단계: 통합 검증과 안정화 + +목표: + +- 코드 경계별 단위 검증과 실제 환경 E2E를 통해 릴리즈 가능한 상태로 안정화한다. + +주요 작업: + +- DB/DAO/filter/status normalization unit test +- credential encryption/redaction test +- API command/action test +- agent wrapper command construction smoke +- v2k storage smoke: RBD/raw, SharedMountPoint/qcow2, block/raw resolver/finalize +- n2k storage smoke: RBD/raw, SharedMountPoint/qcow2, block/raw resolver/finalize +- UI smoke: VMware v2k phase1/phase2, Nutanix n2k phase1/phase2 +- UI theme/i18n smoke: 기본 mode, dark mode, English, Korean +- Real env validation: + - 22.x Cloud RBD target + - 1.x Cloud SharedMountPoint target + +완료 기준: + +- v2k 기존 RBD flow regression이 없어야 한다. +- v2k file/block 개선 flow가 preflight와 task 상태에서 명확하게 동작해야 한다. +- n2k phase1/phase2/finalize E2E가 최소 RBD와 SharedMountPoint에서 확인되어야 한다. +- secret 원문이 DB 일반 column, response, log, task event에 남지 않아야 한다. + +진행 상태: + +- 구현됨: `AblestackV2KTargetStorageResolverTest` 추가 + - RBD/raw target map 생성, VM 이름 sanitize, SharedMountPoint/qcow2 file plan, NetworkFilesystem mount resolution, block/raw unsupported guard 검증 +- 구현됨: `AblestackVmMigrationManagerImplTest` 추가 + - source/target adapter validate 후 tool adapter execute 순서와 unsupported tool 조합 거부 검증 +- 구현됨: `ImportVmTasksManagerImplAblestackTest` 추가 + - normalized runtime status 저장, raw status JSON 반영, event timeline 생성, event payload secret redaction 검증 +- 구현됨: `ApiSensitiveParamUtilsTest` 추가 + - API parameter redaction이 password/secret/credential 계열만 masking하고 endpoint 같은 운영 context는 유지하는지 검증 +- 구현됨: `LibvirtAblestackN2KConvertInstanceCommandWrapperTest` 추가 + - RBD/block target map 필수값, Cloud-managed n2k `sourceApi=v3` 제한, credential file 기반 command 구성과 plain secret 미전달 검증 +- 구현됨: `LibvirtAblestackN2KStatusCommandWrapperTest` 추가 + - workdir 필수값과 n2k JSON status의 phase/state/step/sync/workdir 정규화 검증 +- 수정됨: 기존 UI 전체 lint를 막던 `ui/tests/unit/components/view/ActionButton.spec.js` 들여쓰기 오류를 최소 수정 +- 검증됨: server 신규 테스트 11개 통과 + - `mvn -pl server -am -DfailIfNoTests=false -Dtest=AblestackV2KTargetStorageResolverTest,AblestackVmMigrationManagerImplTest,ImportVmTasksManagerImplAblestackTest,ApiSensitiveParamUtilsTest test` +- 검증됨: KVM 신규 테스트 5개 통과 + - `mvn -pl plugins/hypervisors/kvm -am -DfailIfNoTests=false -Dtest=LibvirtAblestackN2KConvertInstanceCommandWrapperTest,LibvirtAblestackN2KStatusCommandWrapperTest test` +- 검증됨: 변경 backend/API/agent/schema 모듈 compile 통과 + - `mvn -pl api,engine/schema,server,plugins/hypervisors/kvm -am -DskipTests compile` +- 검증됨: UI 전체 lint 통과 + - `cd ui && npm run lint -- --no-fix` +- 검증됨: UI 변경 파일 targeted eslint 통과 + - `cd ui && ./node_modules/.bin/eslint --no-fix src/views/tools/ManageInstances.vue src/views/tools/ImportUnmanagedInstance.vue src/views/tools/ImportVmTasks.vue` +- 검증됨: English/Korean locale JSON parse 통과 +- 검증됨: UI production build 통과 + - `cd ui && NODE_OPTIONS=--openssl-legacy-provider npm run build` +- 검증됨: `git diff --check` 통과 +- 제약: 로컬 자동화 검증은 외부 vCenter/Nutanix/Cloud credential 없이 수행 가능한 smoke/unit/build 범위다. 22.x RBD target과 1.x SharedMountPoint target 실제 phase1/phase2/finalize E2E는 운영 credential을 런타임에 주입한 환경 검증으로 남는다. +- 제약: UI production build는 현재 Rocky 9.7의 Node.js 16/OpenSSL 3 조합에서 Webpack 4 호환을 위해 `NODE_OPTIONS=--openssl-legacy-provider`가 필요하다. + +## 9. 주요 리스크와 대응 + +| 리스크 | 대응 | +| --- | --- | +| v2k 기존 API 호환성 깨짐 | compatibility command 유지, 내부만 generic manager로 위임 | +| phase2 credential 재사용 중 secret 노출 | task credential table에 암호화 저장, response/log/event redaction, runtime-only 복호화 | +| Cloud API key가 host로 전달됨 | Cloud finalize는 Management Server 내부 service 사용 | +| n2k CLI status format 변경 | backend normalized parser와 raw `status_json` 저장 | +| target disk path mismatch | primary storage resolver가 Cloud storage별 target map/format/finalize strategy를 확정 후 tool에 전달 | +| 특정 storage mode가 tool 또는 Cloud plugin에서 누락됨 | resolver capability와 preflight unsupported reason을 명확히 반환하고 UI에서 원인을 표시 | +| old schema와 Europa-After schema 불일치 | 새 migration에서 fresh install/upgrade 모두 idempotent하게 column 보강 | +| Import task 화면에 legacy/v2k/n2k가 섞임 | `migrationtool`, `sourceprovider`, `includelegacy` filter 추가 | + +## 10. 설계 결론 + +구현은 기존 v2k 코드를 별도 기능으로 더 키우는 방식보다, `import_vm_task`를 Cloud migration task로 일반화하는 방식이 맞다. v2k는 첫 번째 adapter가 되고, n2k는 같은 task/API/UI 위에 두 번째 adapter로 들어온다. + +이렇게 하면 사용자는 Cloud UI에서 source와 기본 스토리지를 고르고 phase1을 시작한 뒤, 같은 Cloud task 화면에서 phase2와 최종 VM 생성까지 마무리할 수 있다. phase2는 phase1 때 입력해 암호화 저장한 credential을 재사용한다. 동시에 Cloud API secret을 KVM host에 넘기지 않고, source credential은 DB에 평문으로 남기지 않는 구조를 유지할 수 있다. diff --git a/developer/design/import-vm-task-actions-design.md b/developer/design/import-vm-task-actions-design.md new file mode 100644 index 000000000000..4374f04a72a3 --- /dev/null +++ b/developer/design/import-vm-task-actions-design.md @@ -0,0 +1,120 @@ +# Import VM Task Action Design + +## Goal + +ABLESTACK Cloud must operate v2k/n2k migration tasks from the VM import task list. Operators need to continue a failed task, restart from the beginning, cancel an in-progress task, delete task history, clear stored credentials, and execute Phase2 from a single UI action menu. + +## Scope + +- Source engines: `ablestack_v2k`, `ablestack_n2k` +- Target provider: ABLESTACK Cloud +- UI entry point: `ManageInstances` import task list +- API entry point: `executeImportVmTaskAction` +- Task storage: `import_vm_task`, `import_vm_task_event`, `import_vm_task_credential` + +## Implementation Status + +The implementation exposes only actions that can be executed safely with the current Cloud task context. + +| Action | Status | +| --- | --- | +| `refresh` | Implemented and exposed. | +| `phase2` | Implemented by the existing Phase2 path and exposed from the action dropdown. | +| `cancel` | Implemented and exposed for running v2k/n2k tasks. | +| `delete` | Implemented and exposed for failed, cancelled, and completed tasks. | +| `clearcredentials` | Implemented and exposed for non-running tasks with stored credentials. | +| `resume` | Implemented through the existing v2k/n2k import API with `taskaction=resume`; the KVM wrapper passes the engine global `--resume` before `run`. | +| `retryfromstart` | Implemented through the existing v2k/n2k import API with `taskaction=retryfromstart`; the previous runtime is cleaned up and the same task context is re-entered with a fresh workdir/target map. | + +## State Model + +Existing task states are extended as follows. + +| State | Meaning | +| --- | --- | +| Running | Engine or Cloud-side import workflow is active or waiting for the next phase. | +| Completed | Cloud import workflow completed and the target VM is recorded. | +| Failed | Engine or Cloud-side workflow failed. | +| Cancelling | Operator requested cancellation and cleanup is being attempted. | +| Cancelled | Cleanup/cancel completed or was made safe enough to stop tracking as running. | + +Deletion uses the existing `removed` column and is treated as a soft delete. List APIs hide removed tasks by default. + +## Actions + +| Action | Eligible task state | Behavior | +| --- | --- | --- | +| `refresh` | Any visible task | Re-read status and return the updated task response. | +| `phase2` | Running + `Phase1_Completed` | UI opens confirmation/credential modal and uses the existing Phase2 import path. | +| `resume` | Failed, Cancelled | Reuses the same task context, workdir, target selection, Cloud API context, and encrypted source credential. The engine receives `--resume` before `run`. | +| `retryfromstart` | Failed, Cancelled, Phase1 completed/waiting | Reuses the same task and source/target selections, cleans runtime leftovers, generates a fresh workdir/target map, and restarts from Phase1. | +| `cancel` | Running | Marks task as cancelling, sends engine cleanup/cancel command to the original conversion host, then marks cancelled. | +| `delete` | Failed, Cancelled, Completed | Soft deletes the task, optionally removes stored credentials and workdir. Running delete is rejected unless forced. | +| `clearcredentials` | Non-running with stored credential | Removes encrypted source credentials for the task. | + +## API Contract + +`executeImportVmTaskAction` accepts cleanup-oriented actions: + +- `importvmtaskid`: task UUID +- `action`: `refresh`, `cancel`, `delete`, `clearcredentials` +- `cleanup`: optional boolean; when true, remove workdir/runtime leftovers when the action supports it +- `removecredentials`: optional boolean; when true, remove encrypted credentials with delete/cancel +- `force`: optional boolean; allows risky variants such as deleting a running task + +Engine re-entry actions use the existing import APIs so they can keep the async job and target-context behavior of the original v2k/n2k flow: + +- `importUnmanagedInstanceForAblestackV2K&importvmtaskid=&taskaction=resume` +- `importUnmanagedInstanceForAblestackV2K&importvmtaskid=&taskaction=retryfromstart` +- `importUnmanagedInstanceForAblestackN2K&importvmtaskid=&taskaction=resume` +- `importUnmanagedInstanceForAblestackN2K&importvmtaskid=&taskaction=retryfromstart` + +Long-running operations should not keep the UI notification open until conversion ends. The UI registers the async action, closes the confirmation modal, and refreshes the task list; detailed progress remains visible in the task table/detail drawer. + +## Engine Cleanup + +### v2k + +`AblestackV2KCleanupCommand` is introduced for Cloud task actions. It should: + +- cleanup by workdir when available +- fallback to domain name for old tasks +- call `ablestack_v2k cleanup --force --keep-workdir|...` where safe +- undefine temporary libvirt domains if requested +- keep cleanup idempotent + +### n2k + +`AblestackN2KCleanupCommand` is reused and must remain idempotent. Cancel/delete use: + +- `keepSourcePoints=true` for cancel or failed cleanup by default +- `removeWorkdir=true` when deleting or explicitly cleaning task artifacts + +## UI Contract + +All state-changing actions are displayed in the `actions` column only. + +- The dropdown button label is `작업 선택`. +- Phase2 is removed from the step column and appears in the dropdown. +- Selecting a menu item never executes immediately. +- A confirmation modal is shown with the action name, target VM, current state, and action impact. +- The API is called only after the operator confirms. +- Destructive actions use danger styling. +- `Details` remains a direct read-only button. + +## Implementation Notes + +- `availableactions` remains server-calculated and is the UI source of truth. +- Every accepted action writes an `import_vm_task_event`. +- Event payloads must be sanitized and must never include credentials or API secrets. +- Credential reuse relies on `import_vm_task_credential.encrypted_payload`. +- Retry-from-start must create a new workdir and new target disk names. It keeps the task identity so the operator can continue from the same row and event history. + +## Verification + +1. Failed v2k task exposes resume, retry-from-start, delete, and credential cleanup when eligible. +2. Running v2k/n2k task exposes cancel only as a state-changing action. +3. Phase1-completed v2k/n2k task exposes Phase2 in the dropdown and no button in the step column. +4. Deleted tasks disappear from the default list but remain in DB with `removed`. +5. Cancel/delete cleanup is idempotent. +6. UI confirmation modal is shown before every state-changing action. diff --git a/developer/design/import-vm-task-sync-progress-design.md b/developer/design/import-vm-task-sync-progress-design.md new file mode 100644 index 000000000000..a14ba5fb930f --- /dev/null +++ b/developer/design/import-vm-task-sync-progress-design.md @@ -0,0 +1,96 @@ +# Import VM Task Sync Progress Design + +## Goal + +The VM import task list should show migration progress as an operator-oriented sync status instead of a raw engine status sentence. Phase, state, and workdir are already available in other columns and in the detail drawer, so the list column must focus on current sync work and transferred bytes. + +## UI Changes + +- Rename the `Description` column to `Sync Progress Status`. +- Swap the `PHASE` and `Current Step` columns so `PHASE` appears first. +- The sync progress column shows only: + - current sync step label: `Base Sync`, `Incr Sync`, or `Final Sync` + - current sync transferred bytes: `done / total (percent)` + - cumulative transferred bytes from base through final when available +- The `Current Step` column shows normalized step labels: + - `Init` + - `Base Snap` + - `Base Sync` + - `Incr Snap` + - `Incr Sync` + - `Guest Shutdown` + - `Final Snap` + - `Final Sync` + - `Initramfs/WinPE` + - `Migration Completed` + +## Engine Status Contract + +Both `ablestack_v2k` and `ablestack_n2k` expose structured sync progress in their JSON status output. + +```json +{ + "step": "BASE_SYNC", + "display_step": "Base Sync", + "sync_progress": { + "mode": "base", + "kind": "physical", + "done_bytes": 10737418240, + "total_bytes": 21474836480, + "percent": 50 + }, + "sync_total": { + "done_bytes": 11811160064, + "known_total_bytes": 22548578304, + "percent": 52 + } +} +``` + +Rules: + +- `sync` is scoped to the current sync phase only. +- `sync_total` is cumulative from Base Sync through the current sync phase. +- Incremental/final totals are the changed-region bytes for the active window. +- When a delta total is not known yet, `total_bytes` is `0` and the UI displays `-`. +- Existing string fields such as `SYNC(Physical)` are retained as fallback compatibility only. + +## Cloud API Contract + +`ImportVMTaskResponse` is extended with: + +- `displaystep` +- `syncprogresslabel` +- `syncdonebytes` +- `synctotalbytes` +- `syncpercent` +- `synccumulativedonebytes` +- `synccumulativeknownbytes` +- `synccumulativepercent` + +The Cloud agent wrappers prefer engine JSON fields. If they are absent, the server falls back to existing `syncphysical` string parsing. + +## Backend Normalization + +The backend maps engine-specific steps to a shared display vocabulary: + +| Engine step | Display step | +| --- | --- | +| `init`, `preflight`, `inventory`, `prepare-target-storage`, `prepare_target_storage` | `Init` | +| `snapshot.base`, `v3-snapshot-base`, `recovery-point-base`, `BASE_SNAP` | `Base Snap` | +| `sync.base`, `sync-base`, `base_sync`, `BASE_SYNC` | `Base Sync` | +| `snapshot.incr`, `v3-snapshot-incr`, `recovery-point-incr`, `INCR_SNAP` | `Incr Snap` | +| `sync.incr`, `sync-incr`, `incr_sync`, `INCR_SYNC` | `Incr Sync` | +| `shutdown-source`, `shutdown_guest_start` | `Guest Shutdown` | +| `snapshot.final`, `v3-snapshot-final`, `recovery-point-final`, `FINAL_SNAP` | `Final Snap` | +| `sync.final`, `sync-final`, `final_sync`, `FINAL_SYNC` | `Final Sync` | +| `linux_bootstrap`, `initramfs`, `winpe`, `WINPE` | `Initramfs/WinPE` | +| completed import task | `Migration Completed` | + +## Verification + +1. Running Base Sync shows current Base Sync bytes and cumulative bytes. +2. Running Incr Sync shows changed-region bytes, not whole VM bytes. +3. Running Final Sync shows final changed-region bytes, not whole VM bytes. +4. Completed tasks show `Migration Completed` and preserve the last sync progress in details/events. +5. Legacy tasks without structured sync fields still render a best-effort progress string from `syncphysical`. diff --git a/developer/design/n2k-cloud-background-execution.md b/developer/design/n2k-cloud-background-execution.md new file mode 100644 index 000000000000..12fd5ab78ab0 --- /dev/null +++ b/developer/design/n2k-cloud-background-execution.md @@ -0,0 +1,74 @@ +# N2K Cloud Background Execution Alignment + +## Goal + +ABLESTACK Cloud must run ablestack-n2k with the same user-facing execution model as ablestack-v2k: + +- The Cloud API job confirms that the migration task has started. +- Long-running phase progress is reported through the Import VM task list and detail view. +- The top UI job alert disappears after task registration/start, not after phase completion. + +Existing CLI and wizard behavior must remain compatible. Operators running the tool manually should still see the migration progress in the terminal unless they explicitly ask for background execution. + +## Current Difference + +V2K uses a background/fleet handoff for split phase runs. CloudStack receives a successful answer once the runner is started, so the async API job completes quickly. + +N2K currently executes `ablestack_n2k run` in the foreground from the KVM agent wrapper. The agent command returns only after phase1 or phase2 finishes, so the CloudStack async job remains running and the UI header alert stays visible for the entire phase. + +## Execution Model + +### CLI and Wizard + +- `ablestack_n2k run` keeps foreground behavior by default. +- `ablestack_n2k wizard`, `migrate`, and `interactive` keep foreground behavior by default. +- `--foreground` explicitly forces foreground execution. +- `--background` explicitly starts a detached worker and returns after the worker has been launched. + +This preserves existing automation and operator expectations. + +### Cloud + +The Cloud KVM wrapper passes `--background` when invoking `ablestack_n2k run`. + +The background parent process performs argument validation, resolves the workdir, creates a lightweight runner state file, starts a detached foreground worker, and returns success once the worker is alive. + +The worker calls the same `run --foreground` path, so the actual migration pipeline remains shared with CLI/wizard execution. + +## State Model + +The migration truth source remains: + +- `manifest.json` +- `events.log` +- Cloud Import VM task records + +The background runner adds only lightweight helper files inside the workdir: + +- `runner.json`: state, pid, split, log path, timestamps, and exit code. +- `run-.log`: detached worker stdout/stderr. + +Status commands may use runner state only as a fallback while manifest/events are still being initialized. + +## Cloud Backend Rules + +- Initial N2K phase1 API stores task context and sets `Phase1_In_Progress`, then starts the background worker. +- It must not mark `Phase1_Completed` immediately after worker start. +- N2K phase2 API sets `Phase2_In_Progress`, starts the background worker, and starts the same style of background monitoring used by V2K. +- Phase completion and failure are derived later from status refresh/monitoring, not from the initial command return. + +## UI Impact + +No custom long-running UI poller is required for N2K. The existing Cloud async job poller can remain because the async job now represents "task started", not "phase completed". + +The Import VM task list and detail view remain the source for long-running progress. + +## Verification + +1. CLI `ablestack_n2k wizard ...` runs foreground and shows progress until completion. +2. CLI `ablestack_n2k run --foreground ...` runs foreground. +3. CLI `ablestack_n2k run --background --split phase1 ...` returns quickly and leaves a running worker state. +4. Cloud N2K phase1 registration returns quickly, the header alert disappears, and the task list shows `Phase1_In_Progress`. +5. Cloud N2K phase1 completion later updates to `Phase1_Completed`. +6. Cloud N2K phase2 registration returns quickly, phase2 action disappears, and the task list shows `Phase2_In_Progress`. +7. Cloud N2K phase2 completion/failure is reflected through the existing task monitor. diff --git a/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskCredentialVO.java b/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskCredentialVO.java new file mode 100644 index 000000000000..1b98f400cfe9 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskCredentialVO.java @@ -0,0 +1,184 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "import_vm_task_credential") +public class ImportVMTaskCredentialVO implements Identity, InternalIdentity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "task_id") + private long taskId; + + @Column(name = "provider") + private String provider; + + @Column(name = "credential_type") + private String credentialType; + + @Column(name = "username_hint") + private String usernameHint; + + @Column(name = "encrypted_payload") + private String encryptedPayload; + + @Column(name = "encryption_version") + private String encryptionVersion; + + @Column(name = "key_id") + private String keyId; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + public ImportVMTaskCredentialVO() { + this.uuid = UUID.randomUUID().toString(); + } + + public ImportVMTaskCredentialVO(long taskId, String provider, String credentialType, String usernameHint, + String encryptedPayload, String encryptionVersion, String keyId) { + this(); + this.taskId = taskId; + this.provider = provider; + this.credentialType = credentialType; + this.usernameHint = usernameHint; + this.encryptedPayload = encryptedPayload; + this.encryptionVersion = encryptionVersion; + this.keyId = keyId; + this.created = new Date(); + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getTaskId() { + return taskId; + } + + public void setTaskId(long taskId) { + this.taskId = taskId; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getCredentialType() { + return credentialType; + } + + public void setCredentialType(String credentialType) { + this.credentialType = credentialType; + } + + public String getUsernameHint() { + return usernameHint; + } + + public void setUsernameHint(String usernameHint) { + this.usernameHint = usernameHint; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } + + public String getEncryptionVersion() { + return encryptionVersion; + } + + public void setEncryptionVersion(String encryptionVersion) { + this.encryptionVersion = encryptionVersion; + } + + public String getKeyId() { + return keyId; + } + + public void setKeyId(String keyId) { + this.keyId = keyId; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskEventVO.java b/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskEventVO.java new file mode 100644 index 000000000000..32c1026680dd --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskEventVO.java @@ -0,0 +1,159 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "import_vm_task_event") +public class ImportVMTaskEventVO implements Identity, InternalIdentity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "task_id") + private long taskId; + + @Column(name = "event_type") + private String eventType; + + @Column(name = "phase") + private String phase; + + @Column(name = "state") + private String state; + + @Column(name = "step") + private String step; + + @Column(name = "message") + private String message; + + @Column(name = "payload_json") + private String payloadJson; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + public ImportVMTaskEventVO() { + this.uuid = UUID.randomUUID().toString(); + } + + public ImportVMTaskEventVO(long taskId, String eventType, String phase, String state, String step, String message, String payloadJson) { + this(); + this.taskId = taskId; + this.eventType = eventType; + this.phase = phase; + this.state = state; + this.step = step; + this.message = message; + this.payloadJson = payloadJson; + this.created = new Date(); + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getTaskId() { + return taskId; + } + + public void setTaskId(long taskId) { + this.taskId = taskId; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getPhase() { + return phase; + } + + public void setPhase(String phase) { + this.phase = phase; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getStep() { + return step; + } + + public void setStep(String step) { + this.step = step; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getPayloadJson() { + return payloadJson; + } + + public void setPayloadJson(String payloadJson) { + this.payloadJson = payloadJson; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskVO.java b/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskVO.java index 3e56d9da2e96..8408798affe3 100644 --- a/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskVO.java +++ b/engine/schema/src/main/java/com/cloud/vm/ImportVMTaskVO.java @@ -25,6 +25,7 @@ import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; +import javax.persistence.Lob; import javax.persistence.Table; import javax.persistence.Temporal; import javax.persistence.TemporalType; @@ -48,6 +49,12 @@ public ImportVMTaskVO(long zoneId, long accountId, long userId, String displayNa this.uuid = UUID.randomUUID().toString(); this.convertHostId = convertHostId; this.importHostId = importHostId; + this.migrationTool = ImportVmTask.MigrationTool.Legacy.getValue(); + this.sourceProvider = ImportVmTask.SourceProvider.VMware.getValue(); + this.targetProvider = ImportVmTask.TargetProvider.KVM.getValue(); + this.sourceEndpoint = vcenter; + this.sourceRef = sourceVMName; + this.targetVMName = displayName; } public ImportVMTaskVO() { @@ -126,6 +133,75 @@ public ImportVMTaskVO() { @Column(name = "nic_network_map") private String nicNetworkMap; + @Column(name = "migration_tool") + private String migrationTool; + + @Column(name = "source_provider") + private String sourceProvider; + + @Column(name = "target_provider") + private String targetProvider; + + @Column(name = "target_profile") + private String targetProfile; + + @Column(name = "target_storage_pool_id") + private Long targetStoragePoolId; + + @Column(name = "target_format") + private String targetFormat; + + @Column(name = "target_storage_type") + private String targetStorageType; + + @Column(name = "target_vm_name") + private String targetVMName; + + @Column(name = "source_endpoint") + private String sourceEndpoint; + + @Column(name = "source_ref") + private String sourceRef; + + @Lob + @Column(name = "source_inventory_json") + private String sourceInventoryJson; + + @Lob + @Column(name = "source_context_json") + private String sourceContextJson; + + @Column(name = "source_credential_id") + private Long sourceCredentialId; + + @Lob + @Column(name = "target_context_json") + private String targetContextJson; + + @Column(name = "workdir") + private String workdir; + + @Column(name = "split_mode") + private String splitMode; + + @Column(name = "current_phase") + private String currentPhase; + + @Column(name = "migration_state") + private String migrationState; + + @Column(name = "migration_step") + private String migrationStep; + + @Column(name = "cutover_policy") + private String cutoverPolicy; + + @Column(name = "status_json") + private String statusJson; + + @Column(name = "error_code") + private String errorCode; + @Column(name = "state") private TaskState state; @@ -341,6 +417,182 @@ public void setNicNetworkMap(String nicNetworkMap) { this.nicNetworkMap = nicNetworkMap; } + public String getMigrationTool() { + return migrationTool; + } + + public void setMigrationTool(String migrationTool) { + this.migrationTool = migrationTool; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public void setSourceProvider(String sourceProvider) { + this.sourceProvider = sourceProvider; + } + + public String getTargetProvider() { + return targetProvider; + } + + public void setTargetProvider(String targetProvider) { + this.targetProvider = targetProvider; + } + + public String getTargetProfile() { + return targetProfile; + } + + public void setTargetProfile(String targetProfile) { + this.targetProfile = targetProfile; + } + + public Long getTargetStoragePoolId() { + return targetStoragePoolId; + } + + public void setTargetStoragePoolId(Long targetStoragePoolId) { + this.targetStoragePoolId = targetStoragePoolId; + } + + public String getTargetFormat() { + return targetFormat; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public String getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(String targetStorageType) { + this.targetStorageType = targetStorageType; + } + + public String getTargetVMName() { + return targetVMName; + } + + public void setTargetVMName(String targetVMName) { + this.targetVMName = targetVMName; + } + + public String getSourceEndpoint() { + return sourceEndpoint; + } + + public void setSourceEndpoint(String sourceEndpoint) { + this.sourceEndpoint = sourceEndpoint; + } + + public String getSourceRef() { + return sourceRef; + } + + public void setSourceRef(String sourceRef) { + this.sourceRef = sourceRef; + } + + public String getSourceInventoryJson() { + return sourceInventoryJson; + } + + public void setSourceInventoryJson(String sourceInventoryJson) { + this.sourceInventoryJson = sourceInventoryJson; + } + + public String getSourceContextJson() { + return sourceContextJson; + } + + public void setSourceContextJson(String sourceContextJson) { + this.sourceContextJson = sourceContextJson; + } + + public Long getSourceCredentialId() { + return sourceCredentialId; + } + + public void setSourceCredentialId(Long sourceCredentialId) { + this.sourceCredentialId = sourceCredentialId; + } + + public String getTargetContextJson() { + return targetContextJson; + } + + public void setTargetContextJson(String targetContextJson) { + this.targetContextJson = targetContextJson; + } + + public String getWorkdir() { + return workdir; + } + + public void setWorkdir(String workdir) { + this.workdir = workdir; + } + + public String getSplitMode() { + return splitMode; + } + + public void setSplitMode(String splitMode) { + this.splitMode = splitMode; + } + + public String getCurrentPhase() { + return currentPhase; + } + + public void setCurrentPhase(String currentPhase) { + this.currentPhase = currentPhase; + } + + public String getMigrationState() { + return migrationState; + } + + public void setMigrationState(String migrationState) { + this.migrationState = migrationState; + } + + public String getMigrationStep() { + return migrationStep; + } + + public void setMigrationStep(String migrationStep) { + this.migrationStep = migrationStep; + } + + public String getCutoverPolicy() { + return cutoverPolicy; + } + + public void setCutoverPolicy(String cutoverPolicy) { + this.cutoverPolicy = cutoverPolicy; + } + + public String getStatusJson() { + return statusJson; + } + + public void setStatusJson(String statusJson) { + this.statusJson = statusJson; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + public TaskState getState() { return state; } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDao.java new file mode 100644 index 000000000000..3646823e7ae2 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDao.java @@ -0,0 +1,29 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.ImportVMTaskCredentialVO; + +import java.util.List; + +public interface ImportVMTaskCredentialDao extends GenericDao { + + ImportVMTaskCredentialVO findLatestByTaskId(long taskId); + + List listByTaskId(long taskId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDaoImpl.java new file mode 100644 index 000000000000..9fb5dd7f1076 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskCredentialDaoImpl.java @@ -0,0 +1,58 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.ImportVMTaskCredentialVO; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.util.List; + +@Component +public class ImportVMTaskCredentialDaoImpl extends GenericDaoBase implements ImportVMTaskCredentialDao { + + private SearchBuilder ActiveTaskSearch; + + @PostConstruct + void init() { + ActiveTaskSearch = createSearchBuilder(); + ActiveTaskSearch.and("taskId", ActiveTaskSearch.entity().getTaskId(), SearchCriteria.Op.EQ); + ActiveTaskSearch.and("removed", ActiveTaskSearch.entity().getRemoved(), SearchCriteria.Op.NULL); + ActiveTaskSearch.done(); + } + + @Override + public ImportVMTaskCredentialVO findLatestByTaskId(long taskId) { + SearchCriteria sc = ActiveTaskSearch.create(); + sc.setParameters("taskId", taskId); + Filter filter = new Filter(ImportVMTaskCredentialVO.class, "created", false, 0L, 1L); + List credentials = listBy(sc, filter); + return credentials.isEmpty() ? null : credentials.get(0); + } + + @Override + public List listByTaskId(long taskId) { + SearchCriteria sc = ActiveTaskSearch.create(); + sc.setParameters("taskId", taskId); + Filter filter = new Filter(ImportVMTaskCredentialVO.class, "created", false, null, null); + return listBy(sc, filter); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDao.java index a4f0a155da41..95c2f2974db7 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDao.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDao.java @@ -27,5 +27,7 @@ public interface ImportVMTaskDao extends GenericDao { Pair, Integer> listImportVMTasks(Long zoneId, Long accountId, String vcenter, Long convertHostId, - ImportVmTask.TaskState state, Long startIndex, Long pageSizeVal); + ImportVmTask.TaskState state, String migrationTool, String sourceProvider, + String targetProvider, String targetProfile, String currentPhase, + String migrationState, Long startIndex, Long pageSizeVal); } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDaoImpl.java index da9c391af9db..a88b6f5bda3a 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskDaoImpl.java @@ -45,13 +45,22 @@ void init() { AllFieldsSearch.and("vcenter", AllFieldsSearch.entity().getVcenter(), SearchCriteria.Op.EQ); AllFieldsSearch.and("convertHostId", AllFieldsSearch.entity().getConvertHostId(), SearchCriteria.Op.EQ); AllFieldsSearch.and("state", AllFieldsSearch.entity().getState(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("migrationTool", AllFieldsSearch.entity().getMigrationTool(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("sourceProvider", AllFieldsSearch.entity().getSourceProvider(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("targetProvider", AllFieldsSearch.entity().getTargetProvider(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("targetProfile", AllFieldsSearch.entity().getTargetProfile(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("currentPhase", AllFieldsSearch.entity().getCurrentPhase(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("migrationState", AllFieldsSearch.entity().getMigrationState(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("removed", AllFieldsSearch.entity().getRemoved(), SearchCriteria.Op.NULL); AllFieldsSearch.done(); } @Override public Pair, Integer> listImportVMTasks(Long zoneId, Long accountId, String vcenter, Long convertHostId, - ImportVmTask.TaskState state, Long startIndex, Long pageSizeVal) { + ImportVmTask.TaskState state, String migrationTool, String sourceProvider, + String targetProvider, String targetProfile, String currentPhase, + String migrationState, Long startIndex, Long pageSizeVal) { SearchCriteria sc = AllFieldsSearch.create(); if (zoneId != null) { sc.setParameters("zoneId", zoneId); @@ -68,6 +77,24 @@ public Pair, Integer> listImportVMTasks(Long zoneId, Long a if (state != null) { sc.setParameters("state", state); } + if (StringUtils.isNotBlank(migrationTool)) { + sc.setParameters("migrationTool", migrationTool); + } + if (StringUtils.isNotBlank(sourceProvider)) { + sc.setParameters("sourceProvider", sourceProvider); + } + if (StringUtils.isNotBlank(targetProvider)) { + sc.setParameters("targetProvider", targetProvider); + } + if (StringUtils.isNotBlank(targetProfile)) { + sc.setParameters("targetProfile", targetProfile); + } + if (StringUtils.isNotBlank(currentPhase)) { + sc.setParameters("currentPhase", currentPhase); + } + if (StringUtils.isNotBlank(migrationState)) { + sc.setParameters("migrationState", migrationState); + } Filter filter = new Filter(ImportVMTaskVO.class, "created", false, startIndex, pageSizeVal); return searchAndCount(sc, filter); } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDao.java new file mode 100644 index 000000000000..bce6839cf91d --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDao.java @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.ImportVMTaskEventVO; + +import java.util.List; + +public interface ImportVMTaskEventDao extends GenericDao { + + List listByTaskId(long taskId, Long startIndex, Long pageSize); + + Pair, Integer> listAndCountByTaskId(long taskId, Long startIndex, Long pageSize); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDaoImpl.java new file mode 100644 index 000000000000..6d1cb21d220a --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/ImportVMTaskEventDaoImpl.java @@ -0,0 +1,57 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.ImportVMTaskEventVO; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.util.List; + +@Component +public class ImportVMTaskEventDaoImpl extends GenericDaoBase implements ImportVMTaskEventDao { + + private SearchBuilder TaskSearch; + + @PostConstruct + void init() { + TaskSearch = createSearchBuilder(); + TaskSearch.and("taskId", TaskSearch.entity().getTaskId(), SearchCriteria.Op.EQ); + TaskSearch.done(); + } + + @Override + public List listByTaskId(long taskId, Long startIndex, Long pageSize) { + SearchCriteria sc = TaskSearch.create(); + sc.setParameters("taskId", taskId); + Filter filter = new Filter(ImportVMTaskEventVO.class, "created", false, startIndex, pageSize); + return listBy(sc, filter); + } + + @Override + public Pair, Integer> listAndCountByTaskId(long taskId, Long startIndex, Long pageSize) { + SearchCriteria sc = TaskSearch.create(); + sc.setParameters("taskId", taskId); + Filter filter = new Filter(ImportVMTaskEventVO.class, "created", false, startIndex, pageSize); + return searchAndCount(sc, filter); + } +} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index 3be0a0575bc4..c691e8f650ab 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -311,6 +311,8 @@ + + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql index ff9b1f7ed02e..ebabb125eeea 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql @@ -70,8 +70,35 @@ CREATE TABLE IF NOT EXISTS `cloud`.`import_vm_task`( `cluster_id` bigint unsigned COMMENT 'Cluster ID used by the import task', `service_offering_id` bigint unsigned COMMENT 'Service offering ID used by the import task', `v2k_target_storage_pool_id` bigint unsigned COMMENT 'Primary storage pool ID used as ablestack-v2k target', + `source_cluster_name` varchar(255) COMMENT 'Source VMware cluster name used by the import task', + `source_host_name` varchar(255) COMMENT 'Source VMware host name used by the import task', + `vcenter_id` bigint unsigned COMMENT 'Existing vCenter ID used by the import task', + `vcenter_username` varchar(255) COMMENT 'vCenter username used by the import task', + `vcenter_password` varchar(255) COMMENT 'vCenter password used by the import task', `service_offering_details` text COMMENT 'Serialized custom service offering details used by the import task', `nic_network_map` text COMMENT 'Serialized NIC selection map used by the import task, including network and optional IP address', + `migration_tool` varchar(32) DEFAULT 'legacy' COMMENT 'Migration tool used by the import task', + `source_provider` varchar(32) COMMENT 'Source provider used by the import task', + `target_provider` varchar(32) COMMENT 'Target provider used by the import task', + `target_profile` varchar(64) COMMENT 'Resolved target profile used by the import task', + `target_storage_pool_id` bigint unsigned COMMENT 'Resolved target primary storage pool ID used by the import task', + `target_format` varchar(16) COMMENT 'Resolved target disk format used by the import task', + `target_storage_type` varchar(32) COMMENT 'Resolved target storage type used by the import task', + `target_vm_name` varchar(255) COMMENT 'Target VM name used by the import task', + `source_endpoint` varchar(255) COMMENT 'Source endpoint used by the import task without secrets', + `source_ref` varchar(255) COMMENT 'Provider-specific source VM reference used by the import task', + `source_inventory_json` mediumtext COMMENT 'Serialized source VM inventory snapshot', + `source_context_json` mediumtext COMMENT 'Serialized non-secret source context', + `source_credential_id` bigint unsigned COMMENT 'Encrypted credential row used by the import task', + `target_context_json` mediumtext COMMENT 'Serialized target context and disk map', + `workdir` varchar(1024) COMMENT 'Tool workdir used by the import task', + `split_mode` varchar(16) COMMENT 'Requested split mode used by the import task', + `current_phase` varchar(32) COMMENT 'Current normalized migration phase', + `migration_state` varchar(32) COMMENT 'Current normalized migration state', + `migration_step` varchar(255) COMMENT 'Current normalized migration step', + `cutover_policy` varchar(32) COMMENT 'Cutover policy used by phase2 or full migration', + `status_json` mediumtext COMMENT 'Latest normalized migration status payload', + `error_code` varchar(64) COMMENT 'Normalized migration error code', `state` varchar(20) COMMENT 'Importing VM Task State', `description` varchar(255) COMMENT 'Importing VM Task Description', `duration` bigint unsigned COMMENT 'Duration in milliseconds for the completed tasks', @@ -85,8 +112,51 @@ CREATE TABLE IF NOT EXISTS `cloud`.`import_vm_task`( CONSTRAINT `fk_import_vm_task__vm_id` FOREIGN KEY `fk_import_vm_task__vm_id` (`vm_id`) REFERENCES `vm_instance`(`id`) ON DELETE CASCADE, CONSTRAINT `fk_import_vm_task__convert_host_id` FOREIGN KEY `fk_import_vm_task__convert_host_id` (`convert_host_id`) REFERENCES `host`(`id`) ON DELETE CASCADE, CONSTRAINT `fk_import_vm_task__import_host_id` FOREIGN KEY `fk_import_vm_task__import_host_id` (`import_host_id`) REFERENCES `host`(`id`) ON DELETE CASCADE, - INDEX `i_import_vm_task__zone_id`(`zone_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + INDEX `i_import_vm_task__zone_id`(`zone_id`), + INDEX `i_import_vm_task__zone_tool_state_created`(`zone_id`, `migration_tool`, `state`, `created`), + INDEX `i_import_vm_task__zone_source_state_created`(`zone_id`, `source_provider`, `state`, `created`), + INDEX `i_import_vm_task__target_phase_state`(`target_provider`, `current_phase`, `migration_state`), + INDEX `i_import_vm_task__source_credential_id`(`source_credential_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `cloud`.`import_vm_task_event`( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `task_id` bigint unsigned NOT NULL COMMENT 'Import VM task ID', + `event_type` varchar(64) NOT NULL COMMENT 'Import VM task event type', + `phase` varchar(32) COMMENT 'Migration phase at event time', + `state` varchar(32) COMMENT 'Migration state at event time', + `step` varchar(255) COMMENT 'Migration step at event time', + `message` text COMMENT 'Import VM task event message', + `payload_json` mediumtext COMMENT 'Serialized event payload without secrets', + `created` datetime NOT NULL COMMENT 'date created', + PRIMARY KEY (`id`), + CONSTRAINT `fk_import_vm_task_event__task_id` FOREIGN KEY `fk_import_vm_task_event__task_id` (`task_id`) REFERENCES `import_vm_task`(`id`) ON DELETE CASCADE, + INDEX `i_import_vm_task_event__task_id_created`(`task_id`, `created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `cloud`.`import_vm_task_credential`( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `task_id` bigint unsigned NOT NULL COMMENT 'Import VM task ID', + `provider` varchar(32) NOT NULL COMMENT 'Credential source provider', + `credential_type` varchar(32) NOT NULL COMMENT 'Credential type', + `username_hint` varchar(255) COMMENT 'Non-secret username hint', + `encrypted_payload` mediumtext NOT NULL COMMENT 'Encrypted credential payload', + `encryption_version` varchar(32) NOT NULL COMMENT 'Credential encryption version', + `key_id` varchar(128) COMMENT 'Credential encryption key ID', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_import_vm_task_credential__task_id` FOREIGN KEY `fk_import_vm_task_credential__task_id` (`task_id`) REFERENCES `import_vm_task`(`id`) ON DELETE CASCADE, + INDEX `i_import_vm_task_credential__task_id_created`(`task_id`, `created`), + INDEX `i_import_vm_task_credential__task_id_removed`(`task_id`, `removed`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `cloud`.`import_vm_task` CONVERT TO CHARACTER SET utf8mb4; +ALTER TABLE `cloud`.`import_vm_task_event` CONVERT TO CHARACTER SET utf8mb4; +ALTER TABLE `cloud`.`import_vm_task_credential` CONVERT TO CHARACTER SET utf8mb4; CALL `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`('MaaS', 'Baremetal Extension for Canonical MaaS written in Python', 'MaaS/maas.py'); CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('MaaS', 'orchestratorrequirespreparevm', 'true', 0); diff --git a/engine/schema/src/main/resources/META-INF/db/schema-Europa-After.sql b/engine/schema/src/main/resources/META-INF/db/schema-Europa-After.sql index 667a979fb4c4..89fdf6e74057 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-Europa-After.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-Europa-After.sql @@ -31,3 +31,73 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','vcenter_username', CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','vcenter_password', 'varchar(255) COMMENT "vCenter password used by the import task"'); CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','service_offering_details', 'text COMMENT "Serialized custom service offering details used by the import task"'); CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','nic_network_map', 'text COMMENT "Serialized NIC selection map used by the import task, including network and optional IP address"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','migration_tool', 'varchar(32) DEFAULT ''legacy'' COMMENT "Migration tool used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','source_provider', 'varchar(32) COMMENT "Source provider used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','target_provider', 'varchar(32) COMMENT "Target provider used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','target_profile', 'varchar(64) COMMENT "Resolved target profile used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','target_storage_pool_id', 'bigint unsigned COMMENT "Resolved target primary storage pool ID used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','target_format', 'varchar(16) COMMENT "Resolved target disk format used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','target_storage_type', 'varchar(32) COMMENT "Resolved target storage type used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','target_vm_name', 'varchar(255) COMMENT "Target VM name used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','source_endpoint', 'varchar(255) COMMENT "Source endpoint used by the import task without secrets"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','source_ref', 'varchar(255) COMMENT "Provider-specific source VM reference used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','source_inventory_json', 'mediumtext COMMENT "Serialized source VM inventory snapshot"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','source_context_json', 'mediumtext COMMENT "Serialized non-secret source context"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','source_credential_id', 'bigint unsigned COMMENT "Encrypted credential row used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','target_context_json', 'mediumtext COMMENT "Serialized target context and disk map"'); +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.import_vm_task', 'source_inventory_json', 'source_inventory_json', 'mediumtext COMMENT "Serialized source VM inventory snapshot"'); +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.import_vm_task', 'source_context_json', 'source_context_json', 'mediumtext COMMENT "Serialized non-secret source context"'); +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.import_vm_task', 'target_context_json', 'target_context_json', 'mediumtext COMMENT "Serialized target context and disk map"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','workdir', 'varchar(1024) COMMENT "Tool workdir used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','split_mode', 'varchar(16) COMMENT "Requested split mode used by the import task"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','current_phase', 'varchar(32) COMMENT "Current normalized migration phase"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','migration_state', 'varchar(32) COMMENT "Current normalized migration state"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','migration_step', 'varchar(255) COMMENT "Current normalized migration step"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','cutover_policy', 'varchar(32) COMMENT "Cutover policy used by phase2 or full migration"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','status_json', 'mediumtext COMMENT "Latest normalized migration status payload"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.import_vm_task','error_code', 'varchar(64) COMMENT "Normalized migration error code"'); + +ALTER TABLE `cloud`.`import_vm_task` CONVERT TO CHARACTER SET utf8mb4; + +CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_import_vm_task__zone_tool_state_created', 'cloud.import_vm_task', '(`zone_id`, `migration_tool`, `state`, `created`)'); +CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_import_vm_task__zone_source_state_created', 'cloud.import_vm_task', '(`zone_id`, `source_provider`, `state`, `created`)'); +CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_import_vm_task__target_phase_state', 'cloud.import_vm_task', '(`target_provider`, `current_phase`, `migration_state`)'); +CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_import_vm_task__source_credential_id', 'cloud.import_vm_task', '(`source_credential_id`)'); + +CREATE TABLE IF NOT EXISTS `cloud`.`import_vm_task_event`( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `task_id` bigint unsigned NOT NULL COMMENT 'Import VM task ID', + `event_type` varchar(64) NOT NULL COMMENT 'Import VM task event type', + `phase` varchar(32) COMMENT 'Migration phase at event time', + `state` varchar(32) COMMENT 'Migration state at event time', + `step` varchar(255) COMMENT 'Migration step at event time', + `message` text COMMENT 'Import VM task event message', + `payload_json` mediumtext COMMENT 'Serialized event payload without secrets', + `created` datetime NOT NULL COMMENT 'date created', + PRIMARY KEY (`id`), + CONSTRAINT `fk_import_vm_task_event__task_id` FOREIGN KEY `fk_import_vm_task_event__task_id` (`task_id`) REFERENCES `import_vm_task`(`id`) ON DELETE CASCADE, + INDEX `i_import_vm_task_event__task_id_created`(`task_id`, `created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `cloud`.`import_vm_task_credential`( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `task_id` bigint unsigned NOT NULL COMMENT 'Import VM task ID', + `provider` varchar(32) NOT NULL COMMENT 'Credential source provider', + `credential_type` varchar(32) NOT NULL COMMENT 'Credential type', + `username_hint` varchar(255) COMMENT 'Non-secret username hint', + `encrypted_payload` mediumtext NOT NULL COMMENT 'Encrypted credential payload', + `encryption_version` varchar(32) NOT NULL COMMENT 'Credential encryption version', + `key_id` varchar(128) COMMENT 'Credential encryption key ID', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_import_vm_task_credential__task_id` FOREIGN KEY `fk_import_vm_task_credential__task_id` (`task_id`) REFERENCES `import_vm_task`(`id`) ON DELETE CASCADE, + INDEX `i_import_vm_task_credential__task_id_created`(`task_id`, `created`), + INDEX `i_import_vm_task_credential__task_id_removed`(`task_id`, `removed`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `cloud`.`import_vm_task_event` CONVERT TO CHARACTER SET utf8mb4; +ALTER TABLE `cloud`.`import_vm_task_credential` CONVERT TO CHARACTER SET utf8mb4; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KCleanupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KCleanupCommandWrapper.java new file mode 100644 index 000000000000..0ad291d3c2cf --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KCleanupCommandWrapper.java @@ -0,0 +1,57 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.AblestackN2KCleanupCommand; +import com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.script.Script; +import org.apache.commons.lang3.StringUtils; + +@ResourceWrapper(handles = AblestackN2KCleanupCommand.class) +public class LibvirtAblestackN2KCleanupCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(AblestackN2KCleanupCommand cmd, LibvirtComputingResource serverResource) { + if (StringUtils.isBlank(cmd.getWorkdir())) { + return new Answer(cmd, false, "Missing workdir for ablestack_n2k cleanup command"); + } + + final long timeout = (long) Math.max(cmd.getWait(), 60) * 1000; + Script script = new Script("ablestack_n2k", timeout, logger); + script.add("--workdir", cmd.getWorkdir()); + script.add("--force"); + script.add("cleanup"); + if (cmd.isKeepSourcePoints()) { + script.add("--keep-source-points"); + } + if (cmd.isRemoveWorkdir()) { + script.add("--remove-workdir"); + } + script.add("--apply"); + + String result = script.execute(); + int exitValue = script.getExitValue(); + if (exitValue != 0) { + return new Answer(cmd, false, StringUtils.defaultIfBlank(result, + String.format("ablestack_n2k cleanup failed with exit code %d", exitValue))); + } + return new Answer(cmd, true, StringUtils.defaultIfBlank(result, "ablestack_n2k cleanup completed")); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapper.java new file mode 100644 index 000000000000..b4663d1a61f5 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapper.java @@ -0,0 +1,285 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.AblestackN2KConvertInstanceCommand; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.agent.api.to.NfsTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +@ResourceWrapper(handles = AblestackN2KConvertInstanceCommand.class) +public class LibvirtAblestackN2KConvertInstanceCommandWrapper extends CommandWrapper { + + private static final String DEFAULT_TARGET_PROVIDER = "libvirt"; + private static final String CLOUD_TARGET_PROVIDER = "ablestack-cloud"; + private static final String DEFAULT_CUTOVER_SHUTDOWN_POLICY = "guest"; + + @Override + public Answer execute(AblestackN2KConvertInstanceCommand cmd, LibvirtComputingResource serverResource) { + List missingParams = new ArrayList<>(); + if (StringUtils.isBlank(cmd.getVmName())) { + missingParams.add("vmName"); + } + if (StringUtils.isBlank(cmd.getPrismEndpoint())) { + missingParams.add("prismEndpoint"); + } + if (StringUtils.isBlank(cmd.getUsername())) { + missingParams.add("username"); + } + if (StringUtils.isBlank(cmd.getPassword())) { + missingParams.add("password"); + } + if (StringUtils.isBlank(cmd.getWorkdir())) { + missingParams.add("workdir"); + } + if (cmd.getTargetStorageLocation() == null) { + missingParams.add("targetStorageLocation"); + } + if (StringUtils.isBlank(cmd.getTargetFormat())) { + missingParams.add("targetFormat"); + } + if (StringUtils.isBlank(cmd.getTargetStorage())) { + missingParams.add("targetStorage"); + } + if ((StringUtils.equals(cmd.getTargetStorage(), "rbd") || StringUtils.equals(cmd.getTargetStorage(), "block")) + && StringUtils.isBlank(cmd.getTargetMapJson())) { + missingParams.add("targetMapJson"); + } + String targetProvider = StringUtils.defaultIfBlank(cmd.getTargetProvider(), DEFAULT_TARGET_PROVIDER); + if (StringUtils.equals(targetProvider, CLOUD_TARGET_PROVIDER)) { + if (StringUtils.isBlank(cmd.getCloudEndpoint())) { + missingParams.add("cloudEndpoint"); + } + if (StringUtils.isBlank(cmd.getCloudApiKey())) { + missingParams.add("cloudApiKey"); + } + if (StringUtils.isBlank(cmd.getCloudSecretKey())) { + missingParams.add("cloudSecretKey"); + } + if (StringUtils.isBlank(cmd.getCloudZoneId())) { + missingParams.add("cloudZoneId"); + } + if (StringUtils.isBlank(cmd.getCloudServiceOfferingId())) { + missingParams.add("cloudServiceOfferingId"); + } + if (StringUtils.isBlank(cmd.getCloudNetworkIds())) { + missingParams.add("cloudNetworkIds"); + } + if (StringUtils.isBlank(cmd.getCloudStorageId())) { + missingParams.add("cloudStorageId"); + } + } + if (!missingParams.isEmpty()) { + return new Answer(cmd, false, "Missing required parameter(s) for ablestack_n2k command: " + String.join(", ", missingParams)); + } + if (!StringUtils.equals(StringUtils.defaultIfBlank(cmd.getSourceApi(), "v3"), "v3")) { + return new Answer(cmd, false, "ablestack_n2k run currently supports sourceApi=v3 for Cloud-managed execution"); + } + + final long timeout = (long) cmd.getWait() * 1000; + final KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); + final KVMStoragePool targetStoragePool = getTargetStoragePool(cmd.getTargetStorageLocation(), storagePoolMgr); + final String targetStoragePath = StringUtils.defaultIfBlank(cmd.getTargetDestinationPath(), getTargetStoragePath(cmd, targetStoragePool)); + final boolean background = StringUtils.equalsIgnoreCase(cmd.getSplitMode(), "phase1") || StringUtils.equalsIgnoreCase(cmd.getSplitMode(), "phase2"); + + Path credentialFile = null; + try { + credentialFile = createN2KCredentialFile(cmd); + Path cloudCredentialFile = createN2KCloudCredentialFile(cmd, targetProvider); + Script script = new Script("ablestack_n2k", timeout, logger); + script.add("--workdir", cmd.getWorkdir()); + if (cmd.isResume()) { + script.add("--resume"); + } + script.add("run"); + if (background) { + script.add("--background"); + } + script.add("--vm", cmd.getVmName()); + script.add("--pc", cmd.getPrismEndpoint()); + script.add("--cred-file", credentialFile.toString()); + script.add("--insecure", cmd.isInsecure() ? "1" : "0"); + script.add("--split", StringUtils.defaultIfBlank(cmd.getSplitMode(), "phase1")); + script.add("--shutdown", DEFAULT_CUTOVER_SHUTDOWN_POLICY); + script.add("--source-api", "v3"); + if (cmd.getRetentionSeconds() != null && cmd.getRetentionSeconds() > 0) { + script.add("--retention-seconds", String.valueOf(cmd.getRetentionSeconds())); + } + addIfNotBlank(script, "--nfs-host", cmd.getNfsHost()); + script.add("--source-map-from-v3-nfs"); + script.add("--target-provider", targetProvider); + script.add("--target-format", cmd.getTargetFormat()); + script.add("--target-storage", cmd.getTargetStorage()); + script.add("--dst", targetStoragePath); + script.add("--cleanup-source-points"); + if (StringUtils.isNotBlank(cmd.getTargetMapJson())) { + script.add("--target-map-json", cmd.getTargetMapJson()); + } + if (StringUtils.equals(targetProvider, CLOUD_TARGET_PROVIDER)) { + script.add("--start"); + script.add("--cloud-cred-file", cloudCredentialFile.toString()); + addIfNotBlank(script, "--cloud-zone-id", cmd.getCloudZoneId()); + addIfNotBlank(script, "--cloud-service-offering-id", cmd.getCloudServiceOfferingId()); + addIfNotBlank(script, "--cloud-network-ids", cmd.getCloudNetworkIds()); + addIfNotBlank(script, "--cloud-storage-id", cmd.getCloudStorageId()); + addIfNotBlank(script, "--cloud-disk-offering-id", cmd.getCloudDiskOfferingId()); + addIfNotBlank(script, "--cloud-host-id", cmd.getCloudHostId()); + addIfNotBlank(script, "--cloud-account", cmd.getCloudAccount()); + addIfNotBlank(script, "--cloud-domain-id", cmd.getCloudDomainId()); + addIfNotBlank(script, "--cloud-project-id", cmd.getCloudProjectId()); + addIfNotBlank(script, "--cloud-name", cmd.getCloudName()); + addIfNotBlank(script, "--cloud-display-name", cmd.getCloudDisplayName()); + addIfNotBlank(script, "--cloud-cpu-speed", cmd.getCloudCpuSpeed()); + } + + String logPrefix = String.format("(%s) ablestack_n2k run progress", cmd.getVmName()); + OutputInterpreter outputLogger = new CapturingLineByLineOutputLogger(logPrefix); + String result = script.execute(outputLogger); + int exitValue = script.getExitValue(); + if (exitValue != 0) { + return new Answer(cmd, false, StringUtils.defaultIfBlank(result, + String.format("ablestack_n2k command failed with exit code %d", exitValue))); + } + } catch (IOException e) { + return new Answer(cmd, false, "Unable to create protected ablestack_n2k credential file: " + e.getMessage()); + } finally { + if (!background) { + deleteN2KCredentialFile(credentialFile); + } + } + return new Answer(cmd, true, "ablestack_n2k command completed successfully"); + } + + private KVMStoragePool getTargetStoragePool(DataStoreTO targetStorageLocation, KVMStoragePoolManager storagePoolMgr) { + if (targetStorageLocation instanceof NfsTO) { + NfsTO nfsTO = (NfsTO) targetStorageLocation; + return storagePoolMgr.getStoragePoolByURI(nfsTO.getUrl()); + } + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) targetStorageLocation; + return storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + } + + protected String getTargetStoragePath(AblestackN2KConvertInstanceCommand cmd, KVMStoragePool targetStoragePool) { + if (StringUtils.equals(cmd.getTargetStorage(), "rbd")) { + return "/var/lib/libvirt/images" + File.separator + cmd.getVmName(); + } + return targetStoragePool.getLocalPath(); + } + + private Path createN2KCredentialFile(AblestackN2KConvertInstanceCommand cmd) throws IOException { + Path workdir = Path.of(cmd.getWorkdir()); + Files.createDirectories(workdir); + Path credentialFile = workdir.resolve("nutanix.env"); + Set permissions = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + String content = String.format("NUTANIX_USERNAME=%s%nNUTANIX_PASSWORD=%s%nN2K_PC_USERNAME=%s%nN2K_PC_PASSWORD=%s%n", + shellQuote(cmd.getUsername()), shellQuote(cmd.getPassword()), + shellQuote(cmd.getUsername()), shellQuote(cmd.getPassword())); + Files.write(credentialFile, content.getBytes(StandardCharsets.UTF_8)); + Files.setPosixFilePermissions(credentialFile, permissions); + return credentialFile; + } + + private Path createN2KCloudCredentialFile(AblestackN2KConvertInstanceCommand cmd, String targetProvider) throws IOException { + if (!StringUtils.equals(targetProvider, CLOUD_TARGET_PROVIDER)) { + return null; + } + Path workdir = Path.of(cmd.getWorkdir()); + Files.createDirectories(workdir); + Path credentialFile = workdir.resolve("cloud.env"); + Set permissions = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + String content = String.format("N2K_CLOUD_ENDPOINT=%s%nN2K_CLOUD_API_KEY=%s%nN2K_CLOUD_SECRET_KEY=%s%n", + shellQuote(cmd.getCloudEndpoint()), shellQuote(cmd.getCloudApiKey()), shellQuote(cmd.getCloudSecretKey())); + Files.write(credentialFile, content.getBytes(StandardCharsets.UTF_8)); + Files.setPosixFilePermissions(credentialFile, permissions); + return credentialFile; + } + + private void deleteN2KCredentialFile(Path credentialFile) { + if (credentialFile == null) { + return; + } + try { + Files.deleteIfExists(credentialFile); + } catch (IOException e) { + logger.warn("Unable to delete temporary ablestack_n2k credential file {}", credentialFile, e); + } + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private void addIfNotBlank(Script script, String option, String value) { + if (StringUtils.isNotBlank(value)) { + script.add(option, value); + } + } + + private class CapturingLineByLineOutputLogger extends OutputInterpreter { + private final String logPrefix; + private final StringBuilder output = new StringBuilder(); + + private CapturingLineByLineOutputLogger(String logPrefix) { + this.logPrefix = logPrefix; + } + + @Override + public boolean drain() { + return true; + } + + @Override + public String interpret(java.io.BufferedReader reader) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + logger.info(StringUtils.isNotBlank(logPrefix) ? String.format("(%s) %s", logPrefix, line) : line); + synchronized (output) { + output.append(line).append(System.lineSeparator()); + } + } + return null; + } + + @Override + public String processError(java.io.BufferedReader reader) { + synchronized (output) { + return output.toString(); + } + } + } + +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KStatusCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KStatusCommandWrapper.java new file mode 100644 index 000000000000..331b3e509a2b --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KStatusCommandWrapper.java @@ -0,0 +1,190 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.AblestackN2KStatusAnswer; +import com.cloud.agent.api.AblestackN2KStatusCommand; +import com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.commons.lang3.StringUtils; + +@ResourceWrapper(handles = AblestackN2KStatusCommand.class) +public class LibvirtAblestackN2KStatusCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(AblestackN2KStatusCommand cmd, LibvirtComputingResource serverResource) { + if (StringUtils.isBlank(cmd.getWorkdir())) { + return new AblestackN2KStatusAnswer(cmd, false, "Missing workdir for ablestack_n2k status command"); + } + + final long timeout = (long) cmd.getWait() * 1000; + Script script = new Script("ablestack_n2k", timeout, logger); + script.add("--workdir", cmd.getWorkdir()); + script.add("--json"); + script.add("status"); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = script.execute(parser); + int exitValue = script.getExitValue(); + if (exitValue != 0) { + return new AblestackN2KStatusAnswer(cmd, false, + StringUtils.defaultIfBlank(result, parser.getLines())); + } + + String output = StringUtils.defaultIfBlank(parser.getLines(), StringUtils.defaultString(result)); + try { + JsonObject status = new JsonParser().parse(output).getAsJsonObject(); + JsonObject resume = getObject(status, "resume"); + JsonObject runtime = getObject(status, "runtime"); + JsonObject phases = getObject(status, "phases"); + + String phase = resolvePhase(runtime, phases, resume); + String migrationState = resolveMigrationState(runtime, phases, resume); + String migrationStep = StringUtils.defaultIfBlank(getString(resume, "next_step"), getString(resume, "last_step")); + String syncPhysical = getPercent(resume); + String workdir = StringUtils.defaultIfBlank(getString(status, "workdir"), cmd.getWorkdir()); + JsonObject target = getObject(status, "target"); + JsonObject targetResult = getObject(target, "result"); + JsonObject cloudRuntime = getObject(runtime, "cloud"); + String targetProvider = StringUtils.defaultIfBlank(getString(targetResult, "provider"), + StringUtils.defaultIfBlank(getString(cloudRuntime, "provider"), getString(target, "provider"))); + String cloudVmId = StringUtils.defaultIfBlank(getString(targetResult, "vm_id"), getString(cloudRuntime, "vm_id")); + AblestackN2KStatusAnswer answer = new AblestackN2KStatusAnswer(cmd, true, "OK", phase, migrationState, migrationStep, + syncPhysical, workdir, status.toString(), targetProvider, cloudVmId); + applyStructuredProgress(answer, status); + return answer; + } catch (RuntimeException e) { + return new AblestackN2KStatusAnswer(cmd, false, + String.format("Unable to parse ablestack_n2k status output for workdir %s: %s", cmd.getWorkdir(), e.getMessage())); + } + } + + private void applyStructuredProgress(AblestackN2KStatusAnswer answer, JsonObject status) { + answer.setDisplayStep(StringUtils.defaultIfBlank(getString(status, "display_step"), answer.getMigrationStep())); + JsonObject syncProgress = getObject(status, "sync_progress"); + if (syncProgress != null) { + answer.setSyncProgressLabel(getString(syncProgress, "mode")); + answer.setSyncDoneBytes(getLong(syncProgress, "done_bytes")); + answer.setSyncTotalBytes(getLong(syncProgress, "total_bytes")); + answer.setSyncPercent(getInteger(syncProgress, "percent")); + } + JsonObject syncTotal = getObject(status, "sync_total"); + if (syncTotal != null) { + answer.setSyncCumulativeDoneBytes(getLong(syncTotal, "done_bytes")); + answer.setSyncCumulativeKnownBytes(getLong(syncTotal, "known_total_bytes")); + answer.setSyncCumulativePercent(getInteger(syncTotal, "percent")); + } + } + + private String resolvePhase(JsonObject runtime, JsonObject phases, JsonObject resume) { + JsonObject split = getObject(runtime, "split"); + if (isDone(getObject(split, "phase2")) || isDone(getObject(phases, "cutover"))) { + return "phase2"; + } + if (isDone(getObject(split, "phase1"))) { + return "phase1"; + } + String nextStep = getString(resume, "next_step"); + if (StringUtils.contains(nextStep, "phase2")) { + return "phase1"; + } + String lastStep = getString(resume, "last_step"); + if (StringUtils.contains(lastStep, "final") || StringUtils.contains(lastStep, "cutover")) { + return "phase2"; + } + return "phase1"; + } + + private String resolveMigrationState(JsonObject runtime, JsonObject phases, JsonObject resume) { + if (getBoolean(resume, "completed") || isDone(getObject(getObject(runtime, "split"), "phase2")) || isDone(getObject(phases, "cutover"))) { + return "completed"; + } + if (isDone(getObject(getObject(runtime, "split"), "phase1"))) { + return "completed"; + } + return "running"; + } + + private boolean isDone(JsonObject object) { + return object != null && getBoolean(object, "done"); + } + + private String getPercent(JsonObject object) { + JsonElement element = object != null ? object.get("percent") : null; + if (element == null || element.isJsonNull()) { + return null; + } + return element.getAsString() + "%"; + } + + private JsonObject getObject(JsonObject object, String memberName) { + if (object == null) { + return null; + } + JsonElement element = object.get(memberName); + if (element == null || !element.isJsonObject()) { + return null; + } + return element.getAsJsonObject(); + } + + private String getString(JsonObject object, String memberName) { + if (object == null) { + return null; + } + JsonElement element = object.get(memberName); + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + return element.getAsString(); + } + + private boolean getBoolean(JsonObject object, String memberName) { + if (object == null) { + return false; + } + JsonElement element = object.get(memberName); + return element != null && element.isJsonPrimitive() && element.getAsBoolean(); + } + + private Long getLong(JsonObject object, String memberName) { + if (object == null) { + return null; + } + JsonElement element = object.get(memberName); + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + try { + return element.getAsLong(); + } catch (RuntimeException e) { + return null; + } + } + + private Integer getInteger(JsonObject object, String memberName) { + Long value = getLong(object, memberName); + return value != null ? value.intValue() : null; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KCleanupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KCleanupCommandWrapper.java new file mode 100644 index 000000000000..1cb35af40c6b --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KCleanupCommandWrapper.java @@ -0,0 +1,141 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.AblestackV2KCleanupCommand; +import com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.script.Script; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.io.IOException; + +@ResourceWrapper(handles = AblestackV2KCleanupCommand.class) +public class LibvirtAblestackV2KCleanupCommandWrapper extends CommandWrapper { + + private static final String ABLESTACK_V2K_WORKDIR_BASE = "/var/lib/ablestack-v2k"; + + @Override + public Answer execute(AblestackV2KCleanupCommand cmd, LibvirtComputingResource serverResource) { + if (StringUtils.isAllBlank(cmd.getWorkdir(), cmd.getDomainName())) { + return new Answer(cmd, false, "Missing workdir or domain name for ablestack-v2k cleanup command"); + } + + StringBuilder details = new StringBuilder(); + boolean success = true; + if (StringUtils.isNotBlank(cmd.getWorkdir())) { + Answer cleanupAnswer = runV2KCleanup(cmd); + details.append(cleanupAnswer.getDetails()); + success &= cleanupAnswer.getResult(); + } + if (cmd.isUndefineDomain() && StringUtils.isNotBlank(cmd.getDomainName())) { + Answer undefineAnswer = undefineDomain(cmd); + if (details.length() > 0) { + details.append("; "); + } + details.append(undefineAnswer.getDetails()); + success &= undefineAnswer.getResult(); + } + if (cmd.isRemoveWorkdir()) { + String cleanupPath = StringUtils.defaultIfBlank(cmd.getWorkdir(), buildLegacyVmWorkdir(cmd.getDomainName())); + try { + String pathMessage = removeV2KWorkdir(cleanupPath); + if (details.length() > 0) { + details.append("; "); + } + details.append(pathMessage); + } catch (CloudRuntimeException e) { + if (details.length() > 0) { + details.append("; "); + } + details.append(e.getMessage()); + success = false; + } + } + return new Answer(cmd, success, StringUtils.defaultIfBlank(details.toString(), "ablestack_v2k cleanup completed")); + } + + private Answer runV2KCleanup(AblestackV2KCleanupCommand cmd) { + final long timeout = (long) Math.max(cmd.getWait(), 60) * 1000; + Script script = new Script("ablestack_v2k", timeout, logger); + script.add("--workdir", cmd.getWorkdir()); + script.add("--force"); + script.add("cleanup"); + if (cmd.isKeepSourceSnapshots()) { + script.add("--keep-snapshots"); + } + if (!cmd.isRemoveWorkdir()) { + script.add("--keep-workdir"); + } + + String result = script.execute(); + int exitValue = script.getExitValue(); + boolean cleanupOk = exitValue == 0 + || StringUtils.containsIgnoreCase(result, "not found") + || StringUtils.containsIgnoreCase(result, "does not exist"); + return new Answer(cmd, cleanupOk, cleanupOk + ? StringUtils.defaultIfBlank(result, "ablestack_v2k cleanup completed") + : StringUtils.defaultIfBlank(result, String.format("ablestack_v2k cleanup failed with exit code %d", exitValue))); + } + + private Answer undefineDomain(AblestackV2KCleanupCommand cmd) { + final long timeout = (long) Math.max(cmd.getWait(), 30) * 1000; + Script script = new Script("virsh", timeout, logger); + script.add("undefine"); + script.add("--nvram"); + script.add("--domain", cmd.getDomainName()); + + String result = script.execute(); + int exitValue = script.getExitValue(); + String details = StringUtils.defaultIfBlank(result, String.format("Failed to undefine domain %s (exit=%d)", cmd.getDomainName(), exitValue)); + boolean undefined = exitValue == 0 || StringUtils.containsIgnoreCase(details, "domain not found"); + return new Answer(cmd, undefined, exitValue == 0 + ? String.format("Undefined domain %s", cmd.getDomainName()) + : String.format("Domain %s is already undefined", cmd.getDomainName())); + } + + private String buildLegacyVmWorkdir(String domainName) { + return StringUtils.isBlank(domainName) ? null : ABLESTACK_V2K_WORKDIR_BASE + File.separator + domainName; + } + + private String removeV2KWorkdir(String workdir) { + if (StringUtils.isBlank(workdir)) { + return "No v2k workdir to remove"; + } + File baseDir = new File(ABLESTACK_V2K_WORKDIR_BASE); + File target = new File(workdir); + try { + String baseCanonicalPath = baseDir.getCanonicalPath(); + String targetCanonicalPath = target.getCanonicalPath(); + if (!StringUtils.startsWith(targetCanonicalPath, baseCanonicalPath + File.separator)) { + throw new CloudRuntimeException(String.format("Invalid ablestack-v2k workdir path: %s", targetCanonicalPath)); + } + if (!target.exists()) { + return String.format("Workdir %s does not exist", targetCanonicalPath); + } + FileUtils.deleteDirectory(target); + return String.format("Removed workdir %s", targetCanonicalPath); + } catch (IOException e) { + throw new CloudRuntimeException(String.format("Failed to remove ablestack-v2k workdir %s", workdir), e); + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KConvertInstanceCommandWrapper.java index dae79eadaed8..885994dce4d9 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KConvertInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KConvertInstanceCommandWrapper.java @@ -31,12 +31,23 @@ import org.apache.commons.lang3.StringUtils; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Set; @ResourceWrapper(handles = AblestackV2KConvertInstanceCommand.class) public class LibvirtAblestackV2KConvertInstanceCommandWrapper extends CommandWrapper { + private static final String DEFAULT_TARGET_PROVIDER = "libvirt"; + private static final String CLOUD_TARGET_PROVIDER = "ablestack-cloud"; + private static final String DEFAULT_CUTOVER_SHUTDOWN_POLICY = "guest"; + @Override public Answer execute(AblestackV2KConvertInstanceCommand cmd, LibvirtComputingResource serverResource) { List missingParams = new ArrayList<>(); @@ -55,15 +66,43 @@ public Answer execute(AblestackV2KConvertInstanceCommand cmd, LibvirtComputingRe if (cmd.getTargetStorageLocation() == null) { missingParams.add("targetStorageLocation"); } + if (StringUtils.isBlank(cmd.getWorkdir())) { + missingParams.add("workdir"); + } if (StringUtils.isBlank(cmd.getTargetFormat())) { missingParams.add("targetFormat"); } if (StringUtils.isBlank(cmd.getTargetStorage())) { missingParams.add("targetStorage"); } - if (StringUtils.equals(cmd.getTargetStorage(), "rbd") && StringUtils.isBlank(cmd.getTargetMapJson())) { + if ((StringUtils.equals(cmd.getTargetStorage(), "rbd") || StringUtils.equals(cmd.getTargetStorage(), "block")) + && StringUtils.isBlank(cmd.getTargetMapJson())) { missingParams.add("targetMapJson"); } + String targetProvider = StringUtils.defaultIfBlank(cmd.getTargetProvider(), DEFAULT_TARGET_PROVIDER); + if (StringUtils.equals(targetProvider, CLOUD_TARGET_PROVIDER)) { + if (StringUtils.isBlank(cmd.getCloudEndpoint())) { + missingParams.add("cloudEndpoint"); + } + if (StringUtils.isBlank(cmd.getCloudApiKey())) { + missingParams.add("cloudApiKey"); + } + if (StringUtils.isBlank(cmd.getCloudSecretKey())) { + missingParams.add("cloudSecretKey"); + } + if (StringUtils.isBlank(cmd.getCloudZoneId())) { + missingParams.add("cloudZoneId"); + } + if (StringUtils.isBlank(cmd.getCloudServiceOfferingId())) { + missingParams.add("cloudServiceOfferingId"); + } + if (StringUtils.isBlank(cmd.getCloudNetworkIds())) { + missingParams.add("cloudNetworkIds"); + } + if (StringUtils.isBlank(cmd.getCloudStorageId())) { + missingParams.add("cloudStorageId"); + } + } if (!missingParams.isEmpty()) { return new Answer(cmd, false, "Missing required parameter(s) for ablestack_v2k command: " + String.join(", ", missingParams)); } @@ -71,29 +110,55 @@ public Answer execute(AblestackV2KConvertInstanceCommand cmd, LibvirtComputingRe final long timeout = (long) cmd.getWait() * 1000; final KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); final KVMStoragePool targetStoragePool = getTargetStoragePool(cmd.getTargetStorageLocation(), storagePoolMgr); - final String targetStoragePath = getTargetStoragePath(cmd, targetStoragePool); + final String targetStoragePath = StringUtils.defaultIfBlank(cmd.getTargetDestinationPath(), getTargetStoragePath(cmd, targetStoragePool)); - Script script = new Script("ablestack_v2k", timeout, logger); - script.add("run"); - script.add("--vcenter", cmd.getVcenter()); - script.add("--username", cmd.getUsername()); - script.add("--password", cmd.getPassword()); - script.add("--dst", targetStoragePath); - script.add("--split", StringUtils.defaultIfBlank(cmd.getSplitMode(), "phase1")); - script.add("--target-format", cmd.getTargetFormat()); - script.add("--target-storage", cmd.getTargetStorage()); - if (StringUtils.isNotBlank(cmd.getTargetMapJson())) { - script.add("--target-map-json", cmd.getTargetMapJson()); - } - script.add("--vm", cmd.getVmName()); + try { + Path credentialFile = createV2KCredentialFile(cmd); + Path cloudCredentialFile = createV2KCloudCredentialFile(cmd, targetProvider); + Script script = new Script("ablestack_v2k", timeout, logger); + script.add("--workdir", cmd.getWorkdir()); + if (cmd.isResume()) { + script.add("--resume"); + } + script.add("run"); + script.add("--vcenter", cmd.getVcenter()); + script.add("--cred-file", credentialFile.toString()); + script.add("--dst", targetStoragePath); + script.add("--split", StringUtils.defaultIfBlank(cmd.getSplitMode(), "phase1")); + script.add("--shutdown", DEFAULT_CUTOVER_SHUTDOWN_POLICY); + script.add("--target-provider", targetProvider); + script.add("--target-format", cmd.getTargetFormat()); + script.add("--target-storage", cmd.getTargetStorage()); + if (StringUtils.isNotBlank(cmd.getTargetMapJson())) { + script.add("--target-map-json", cmd.getTargetMapJson()); + } + if (StringUtils.equals(targetProvider, CLOUD_TARGET_PROVIDER)) { + script.add("--cloud-cred-file", cloudCredentialFile.toString()); + addIfNotBlank(script, "--cloud-zone-id", cmd.getCloudZoneId()); + addIfNotBlank(script, "--cloud-service-offering-id", cmd.getCloudServiceOfferingId()); + addIfNotBlank(script, "--cloud-network-ids", cmd.getCloudNetworkIds()); + addIfNotBlank(script, "--cloud-storage-id", cmd.getCloudStorageId()); + addIfNotBlank(script, "--cloud-disk-offering-id", cmd.getCloudDiskOfferingId()); + addIfNotBlank(script, "--cloud-host-id", cmd.getCloudHostId()); + addIfNotBlank(script, "--cloud-account", cmd.getCloudAccount()); + addIfNotBlank(script, "--cloud-domain-id", cmd.getCloudDomainId()); + addIfNotBlank(script, "--cloud-project-id", cmd.getCloudProjectId()); + addIfNotBlank(script, "--cloud-name", cmd.getCloudName()); + addIfNotBlank(script, "--cloud-display-name", cmd.getCloudDisplayName()); + addIfNotBlank(script, "--cloud-cpu-speed", cmd.getCloudCpuSpeed()); + } + script.add("--vm", cmd.getVmName()); - String logPrefix = String.format("(%s) ablestack_v2k run progress", cmd.getVmName()); - OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); - String result = script.execute(outputLogger); - int exitValue = script.getExitValue(); - if (exitValue != 0) { - return new Answer(cmd, false, StringUtils.defaultIfBlank(result, - String.format("ablestack_v2k command failed with exit code %d", exitValue))); + String logPrefix = String.format("(%s) ablestack_v2k run progress", cmd.getVmName()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); + String result = script.execute(outputLogger); + int exitValue = script.getExitValue(); + if (exitValue != 0) { + return new Answer(cmd, false, StringUtils.defaultIfBlank(result, + String.format("ablestack_v2k command failed with exit code %d", exitValue))); + } + } catch (IOException e) { + return new Answer(cmd, false, "Unable to create protected ablestack_v2k credential file: " + e.getMessage()); } return new Answer(cmd, true, "ablestack_v2k command started successfully"); } @@ -113,4 +178,52 @@ protected String getTargetStoragePath(AblestackV2KConvertInstanceCommand cmd, KV } return targetStoragePool.getLocalPath(); } + + private Path createV2KCredentialFile(AblestackV2KConvertInstanceCommand cmd) throws IOException { + Path workdir = Path.of(cmd.getWorkdir()); + Files.createDirectories(workdir); + Path credentialFile = workdir.resolve("govc.env"); + Set permissions = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + String content = String.format("GOVC_URL=%s%nGOVC_USERNAME=%s%nGOVC_PASSWORD=%s%nGOVC_INSECURE=1%n", + shellQuote(buildGovcUrl(cmd.getVcenter())), shellQuote(cmd.getUsername()), shellQuote(cmd.getPassword())); + Files.write(credentialFile, content.getBytes(StandardCharsets.UTF_8)); + Files.setPosixFilePermissions(credentialFile, permissions); + return credentialFile; + } + + private Path createV2KCloudCredentialFile(AblestackV2KConvertInstanceCommand cmd, String targetProvider) throws IOException { + if (!StringUtils.equals(targetProvider, CLOUD_TARGET_PROVIDER)) { + return null; + } + Path workdir = Path.of(cmd.getWorkdir()); + Files.createDirectories(workdir); + Path credentialFile = workdir.resolve("cloud.env"); + Set permissions = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + String content = String.format("V2K_CLOUD_ENDPOINT=%s%nV2K_CLOUD_API_KEY=%s%nV2K_CLOUD_SECRET_KEY=%s%n", + shellQuote(cmd.getCloudEndpoint()), shellQuote(cmd.getCloudApiKey()), shellQuote(cmd.getCloudSecretKey())); + Files.write(credentialFile, content.getBytes(StandardCharsets.UTF_8)); + Files.setPosixFilePermissions(credentialFile, permissions); + return credentialFile; + } + + private void addIfNotBlank(Script script, String option, String value) { + if (StringUtils.isNotBlank(value)) { + script.add(option, value); + } + } + + private String buildGovcUrl(String vcenter) { + String value = StringUtils.trimToEmpty(vcenter); + if (StringUtils.contains(value, "://")) { + return value; + } + if (StringUtils.endsWith(value, "/sdk")) { + return "https://" + value; + } + return "https://" + StringUtils.removeEnd(value, "/") + "/sdk"; + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KListVmwareVmsCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KListVmwareVmsCommandWrapper.java new file mode 100644 index 000000000000..99624b54fe54 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KListVmwareVmsCommandWrapper.java @@ -0,0 +1,290 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.AblestackV2KListVmwareVmsAnswer; +import com.cloud.agent.api.AblestackV2KListVmwareVmsCommand; +import com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.serializer.GsonHelper; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; +import com.google.gson.Gson; +import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.commons.lang3.StringUtils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@ResourceWrapper(handles = AblestackV2KListVmwareVmsCommand.class) +public class LibvirtAblestackV2KListVmwareVmsCommandWrapper extends CommandWrapper { + + private static final Gson GSON = GsonHelper.getGson(); + private static final String INVENTORY_SCRIPT = String.join("\n", + "import json, os, subprocess", + "govc = os.environ.get('V2K_GOVC_BIN') or '/usr/local/bin/govc'", + "datacenter = os.environ.get('V2K_DATACENTER', '').strip('/')", + "instance_name = os.environ.get('V2K_INSTANCE_NAME', '').strip()", + "keyword = os.environ.get('V2K_KEYWORD', '').strip().lower()", + "start_index = int(os.environ.get('V2K_START_INDEX') or 0)", + "page_size_value = os.environ.get('V2K_PAGE_SIZE')", + "page_size = int(page_size_value) if page_size_value else None", + "env = os.environ.copy()", + "def run(args):", + " try:", + " return subprocess.check_output(args, env=env, stderr=subprocess.STDOUT, text=True)", + " except subprocess.CalledProcessError as e:", + " raise SystemExit((e.output or str(e)).strip())", + "def run_optional(args):", + " try:", + " return subprocess.check_output(args, env=env, stderr=subprocess.STDOUT, text=True)", + " except subprocess.CalledProcessError:", + " return ''", + "def first(*values):", + " for value in values:", + " if value is not None and value != '':", + " return value", + " return None", + "def get_value(obj, *keys):", + " if not isinstance(obj, dict):", + " return None", + " for key in keys:", + " value = obj.get(key)", + " if value is not None and value != '':", + " return value", + " return None", + "def power_state(value):", + " value = str(value or '').lower()", + " if value in ('poweredon', 'on', 'poweron'):", + " return 'PowerOn'", + " if value in ('poweredoff', 'off', 'poweroff'):", + " return 'PowerOff'", + " return 'PowerUnknown'", + "def vm_name_from_path(path):", + " return (path or '').rstrip('/').split('/')[-1]", + "def vm_paths():", + " base = '/' + datacenter + '/vm' if datacenter else '/'", + " if instance_name:", + " if instance_name.startswith('/'):", + " return [instance_name]", + " found = run([govc, 'find', base, '-type', 'm', '-name', instance_name]).splitlines()", + " return found or [instance_name]", + " return [line for line in run([govc, 'find', base, '-type', 'm']).splitlines() if line]", + "def parse_datastore_path(path):", + " if not path or not path.startswith('[') or ']' not in path:", + " return None, path", + " datastore, rest = path[1:].split(']', 1)", + " return datastore, rest.strip()", + "def controller_prefix(controller_type, controller_key):", + " ctype = str(controller_type or '').upper()", + " if 'SCSI' in ctype or 'LSILOGIC' in ctype or 'BUSLOGIC' in ctype or 'PARAVIRTUAL' in ctype:", + " return 'scsi'", + " if 'SATA' in ctype:", + " return 'sata'", + " if 'NVME' in ctype:", + " return 'nvme'", + " return str(controller_key or 'disk')", + "def ref_parts(ref, default_type=None):", + " if isinstance(ref, dict):", + " return first(get_value(ref, 'type', 'Type', '_typeName'), default_type), get_value(ref, 'value', 'Value', 'val', 'Val')", + " text = str(ref or '').strip()", + " if not text or text == '':", + " return default_type, None", + " if ':' in text:", + " ref_type, ref_value = text.split(':', 1)", + " return ref_type, ref_value", + " return default_type, text", + "def collect_property(ref_type, ref_value, prop):", + " if not ref_type or not ref_value:", + " return None", + " value = run_optional([govc, 'object.collect', '-s', f'{ref_type}:{ref_value}', prop]).strip()", + " if not value or value == '':", + " return None", + " return value", + "def cluster_name_from_host(host_type, host_value):", + " cluster_name = collect_property(host_type, host_value, 'parent.name')", + " if cluster_name:", + " return cluster_name", + " parent = collect_property(host_type, host_value, 'parent')", + " parent_type, parent_value = ref_parts(parent, 'ClusterComputeResource')", + " for candidate_type in [parent_type, 'ClusterComputeResource', 'ComputeResource']:", + " cluster_name = collect_property(candidate_type, parent_value, 'name')", + " if cluster_name:", + " return cluster_name", + " return None", + "def build_basic(path):", + " return {'name': vm_name_from_path(path), 'path': path, 'powerState': 'PowerUnknown', 'hypervisorType': 'VMware'}", + "def compute_cpu_speed(summary_runtime, num_cpu):", + " try:", + " max_cpu_usage = summary_runtime.get('maxCpuUsage')", + " if max_cpu_usage and num_cpu:", + " return int(int(max_cpu_usage) / int(num_cpu))", + " except Exception:", + " pass", + " return None", + "def build_detail(path):", + " raw_info = run_optional([govc, 'vm.info', '-json', path])", + " if not raw_info:", + " return build_basic(path)", + " info = json.loads(raw_info)", + " vm = ((info.get('virtualMachines') or info.get('VirtualMachines') or [{}])[0])", + " config = vm.get('config') or {}", + " hardware = config.get('hardware') or {}", + " runtime = vm.get('runtime') or {}", + " summary = vm.get('summary') or {}", + " summary_runtime = summary.get('runtime') or {}", + " guest = vm.get('guest') or {}", + " host_ref_type, host_ref_value = ref_parts(first(runtime.get('host'), summary_runtime.get('host')), 'HostSystem')", + " host_name = collect_property(host_ref_type, host_ref_value, 'name')", + " cluster_name = cluster_name_from_host(host_ref_type, host_ref_value)", + " host_version = collect_property(host_ref_type, host_ref_value, 'config.product.version')", + " num_cpu = hardware.get('numCPU')", + " instance = build_basic(path)", + " instance.update({", + " 'name': first(vm.get('name'), vm_name_from_path(path)),", + " 'powerState': power_state(first(runtime.get('powerState'), summary_runtime.get('powerState'))),", + " 'cpuCores': num_cpu,", + " 'cpuCoresPerSocket': hardware.get('numCoresPerSocket'),", + " 'cpuSpeed': compute_cpu_speed(summary_runtime, num_cpu),", + " 'memory': hardware.get('memoryMB'),", + " 'operatingSystemId': first(config.get('guestId'), guest.get('guestId')),", + " 'operatingSystem': first(config.get('guestFullName'), guest.get('guestFullName')),", + " 'hostName': host_name,", + " 'clusterName': cluster_name,", + " 'hostHypervisorVersion': host_version,", + " 'bootType': config.get('firmware'),", + " 'bootMode': 'secure' if (config.get('bootOptions') or {}).get('efiSecureBootEnabled') else None", + " })", + " raw_devices = run_optional([govc, 'device.info', '-json', '-vm', path])", + " devices = []", + " if raw_devices:", + " parsed_devices = json.loads(raw_devices)", + " devices = parsed_devices.get('devices') or parsed_devices.get('Devices') or []", + " controllers = {device.get('key'): device for device in devices if str(device.get('type') or '').endswith('Controller')}", + " disks, nics = [], []", + " for device in devices:", + " dtype = str(device.get('type') or '')", + " info_obj = device.get('deviceInfo') or {}", + " if dtype == 'VirtualDisk':", + " controller = controllers.get(device.get('controllerKey')) or {}", + " controller_type = controller.get('type') or ''", + " bus = controller.get('busNumber')", + " unit = device.get('unitNumber')", + " prefix = controller_prefix(controller_type, device.get('controllerKey'))", + " disk_id = f'{prefix}{bus}:{unit}' if bus is not None and unit is not None else str(device.get('key'))", + " backing = device.get('backing') or {}", + " datastore, datastore_path = parse_datastore_path(backing.get('fileName'))", + " disks.append({", + " 'diskId': disk_id,", + " 'label': info_obj.get('label') or device.get('name') or disk_id,", + " 'capacity': first(device.get('capacityInBytes'), (device.get('capacityInKB') or 0) * 1024),", + " 'controller': controller_type or dtype,", + " 'controllerUnit': unit,", + " 'position': len(disks),", + " 'imagePath': backing.get('fileName'),", + " 'datastoreName': datastore,", + " 'datastorePath': datastore_path,", + " 'datastoreType': 'VMFS'", + " })", + " elif dtype.startswith('Virtual') and device.get('macAddress'):", + " backing = device.get('backing') or {}", + " mac = device.get('macAddress')", + " ip_addresses = []", + " for net in guest.get('net') or []:", + " if str(net.get('macAddress') or '').lower() == str(mac).lower():", + " ip_addresses = net.get('ipAddress') or []", + " break", + " nics.append({", + " 'nicId': str(device.get('key')),", + " 'adapterType': dtype,", + " 'macAddress': mac,", + " 'network': first(backing.get('deviceName'), info_obj.get('summary')),", + " 'ipAddress': ip_addresses", + " })", + " instance['disks'] = disks", + " instance['nics'] = nics", + " return instance", + "def matches_keyword(path):", + " if not keyword:", + " return True", + " lowered_path = str(path or '').lower()", + " return keyword in lowered_path or keyword in vm_name_from_path(path).lower()", + "run([govc, 'about'])", + "paths = [path for path in vm_paths() if matches_keyword(path)]", + "total = len(paths)", + "if instance_name:", + " page_paths = paths", + "elif page_size is None or page_size < 0:", + " page_paths = paths[start_index:]", + "else:", + " page_paths = paths[start_index:start_index + page_size]", + "instances = [build_detail(path) for path in page_paths]", + "print(json.dumps({'count': total, 'instances': instances}, ensure_ascii=False))"); + + @Override + public Answer execute(AblestackV2KListVmwareVmsCommand command, LibvirtComputingResource serverResource) { + if (StringUtils.isAnyBlank(command.getVcenter(), command.getDatacenterName(), command.getUsername(), command.getPassword())) { + return new Answer(command, false, "Missing required parameter(s) for ablestack_v2k VMware inventory: vcenter, datacenterName, username, password"); + } + + Script script = new Script("python3", 300_000L, logger); + script.setAvoidLoggingCommand(true); + script.add("-c"); + script.add(INVENTORY_SCRIPT); + + Map environment = new HashMap<>(); + environment.put("GOVC_URL", command.getVcenter()); + environment.put("GOVC_USERNAME", command.getUsername()); + environment.put("GOVC_PASSWORD", command.getPassword()); + environment.put("GOVC_INSECURE", "1"); + environment.put("V2K_DATACENTER", command.getDatacenterName()); + environment.put("V2K_INSTANCE_NAME", StringUtils.defaultString(command.getInstanceName())); + environment.put("V2K_KEYWORD", StringUtils.defaultString(command.getKeyword())); + if (command.getStartIndex() != null) { + environment.put("V2K_START_INDEX", command.getStartIndex().toString()); + } + if (command.getPageSize() != null) { + environment.put("V2K_PAGE_SIZE", command.getPageSize().toString()); + } + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + script.execute(parser, environment); + String output = StringUtils.trimToEmpty(parser.getLines()); + if (script.getExitValue() != 0) { + return new Answer(command, false, StringUtils.defaultIfBlank(output, + String.format("ablestack_v2k VMware inventory failed with exit code %d", script.getExitValue()))); + } + + try { + VmwareInventoryResult result = GSON.fromJson(output, VmwareInventoryResult.class); + List instances = result != null && result.instances != null ? result.instances : Collections.emptyList(); + Integer count = result != null && result.count != null ? result.count : instances.size(); + return new AblestackV2KListVmwareVmsAnswer(command, "VMware inventory listed successfully", instances, count); + } catch (RuntimeException e) { + return new Answer(command, false, "Unable to parse ablestack_v2k VMware inventory output: " + e.getMessage()); + } + } + + private static class VmwareInventoryResult { + private Integer count; + private List instances; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KStatusCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KStatusCommandWrapper.java index 48d4616aae78..8311bda25e36 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KStatusCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackV2KStatusCommandWrapper.java @@ -24,11 +24,30 @@ import com.cloud.resource.ResourceWrapper; import com.cloud.utils.script.OutputInterpreter; import com.cloud.utils.script.Script; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.apache.commons.lang3.StringUtils; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + @ResourceWrapper(handles = AblestackV2KStatusCommand.class) public class LibvirtAblestackV2KStatusCommandWrapper extends CommandWrapper { + private static final Path V2K_FLEET_ROOT = Path.of("/var/lib/ablestack-v2k/fleet"); + private static final Pattern JSON_STRING_FIELD_PATTERN = Pattern.compile("\"%s\"\\s*:\\s*\"([^\"]*)\""); + private static final Pattern CLOUD_CUTOVER_COMPLETED_PATTERN = Pattern.compile("Cloud cutover completed:\\s*([0-9a-fA-F-]{36})"); + private static final String CLOUD_TARGET_PROVIDER = "ablestack-cloud"; + @Override public Answer execute(AblestackV2KStatusCommand cmd, LibvirtComputingResource serverResource) { if (StringUtils.isBlank(cmd.getVmName())) { @@ -39,6 +58,7 @@ public Answer execute(AblestackV2KStatusCommand cmd, LibvirtComputingResource se Script script = new Script("ablestack_v2k", timeout, logger); script.add("status"); script.add("--vm", cmd.getVmName()); + script.add("--json"); OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); String result = script.execute(parser); @@ -49,6 +69,11 @@ public Answer execute(AblestackV2KStatusCommand cmd, LibvirtComputingResource se } String output = StringUtils.defaultIfBlank(parser.getLines(), StringUtils.defaultString(result)); + AblestackV2KStatusAnswer jsonAnswer = parseJsonStatus(cmd, output); + if (jsonAnswer != null) { + return jsonAnswer; + } + String[] lines = output.split("\\r?\\n"); String dataLine = null; for (String line : lines) { @@ -78,6 +103,236 @@ public Answer execute(AblestackV2KStatusCommand cmd, LibvirtComputingResource se String syncPhysical = columns[4]; String workdir = String.join(" ", java.util.Arrays.copyOfRange(columns, 5, columns.length)); - return new AblestackV2KStatusAnswer(cmd, true, "OK", phase, migrationState, migrationStep, syncPhysical, workdir); + AblestackV2KStatusAnswer fleetState = getLatestFleetState(cmd, phase, migrationState, migrationStep, syncPhysical, workdir); + if (fleetState != null) { + return fleetState; + } + + return buildStatusAnswer(cmd, "OK", phase, migrationState, migrationStep, syncPhysical, workdir); + } + + private AblestackV2KStatusAnswer parseJsonStatus(AblestackV2KStatusCommand cmd, String output) { + try { + JsonElement parsed = new JsonParser().parse(output); + JsonObject status = null; + if (parsed.isJsonArray()) { + JsonArray array = parsed.getAsJsonArray(); + for (JsonElement item : array) { + if (!item.isJsonObject()) { + continue; + } + JsonObject candidate = item.getAsJsonObject(); + if (StringUtils.equals(getString(candidate, "vm"), cmd.getVmName())) { + status = candidate; + break; + } + } + if (status == null && array.size() > 0 && array.get(0).isJsonObject()) { + status = array.get(0).getAsJsonObject(); + } + } else if (parsed.isJsonObject()) { + status = parsed.getAsJsonObject(); + } + if (status == null) { + return null; + } + String phase = getString(status, "phase"); + String migrationState = getString(status, "state"); + String migrationStep = StringUtils.defaultIfBlank(getString(status, "display_step"), getString(status, "step")); + String syncPhysical = getString(status, "sync"); + String workdir = getString(status, "workdir"); + AblestackV2KStatusAnswer answer = buildStatusAnswer(cmd, "OK", phase, migrationState, migrationStep, syncPhysical, workdir); + applyStructuredProgress(answer, status); + return answer; + } catch (RuntimeException e) { + logger.debug("Unable to parse ablestack_v2k JSON status output for VM {}", cmd.getVmName(), e); + return null; + } + } + + private void applyStructuredProgress(AblestackV2KStatusAnswer answer, JsonObject status) { + answer.setDisplayStep(StringUtils.defaultIfBlank(getString(status, "display_step"), getString(status, "step"))); + JsonObject syncProgress = getObject(status, "sync_progress"); + if (syncProgress != null) { + answer.setSyncProgressLabel(getString(syncProgress, "mode")); + answer.setSyncDoneBytes(getLong(syncProgress, "done_bytes")); + answer.setSyncTotalBytes(getLong(syncProgress, "total_bytes")); + answer.setSyncPercent(getInteger(syncProgress, "percent")); + } + JsonObject syncTotal = getObject(status, "sync_total"); + if (syncTotal != null) { + answer.setSyncCumulativeDoneBytes(getLong(syncTotal, "done_bytes")); + answer.setSyncCumulativeKnownBytes(getLong(syncTotal, "known_total_bytes")); + answer.setSyncCumulativePercent(getInteger(syncTotal, "percent")); + } + } + + private AblestackV2KStatusAnswer getLatestFleetState(AblestackV2KStatusCommand cmd, String phase, String migrationState, + String migrationStep, String syncPhysical, String workdir) { + if (!isUnknownStatus(phase, migrationState) || !Files.isDirectory(V2K_FLEET_ROOT)) { + return null; + } + Path latestStatePath = findLatestFleetStatePath(cmd.getVmName()); + if (latestStatePath == null) { + return null; + } + try { + String json = Files.readString(latestStatePath, StandardCharsets.UTF_8); + String fleetPhase = StringUtils.defaultIfBlank(getJsonStringField(json, "phase"), phase); + String fleetState = StringUtils.defaultIfBlank(getJsonStringField(json, "state"), migrationState); + String fleetWorkdir = StringUtils.defaultIfBlank(getJsonStringField(json, "workdir"), workdir); + String step = StringUtils.defaultIfBlank(migrationStep, "-"); + if (StringUtils.isBlank(step) || StringUtils.equals(step, "-") || StringUtils.equalsIgnoreCase(step, "unknown")) { + step = fleetState; + } + return buildStatusAnswer(cmd, "OK", fleetPhase, fleetState, step, syncPhysical, fleetWorkdir); + } catch (IOException e) { + logger.debug("Unable to read ablestack-v2k fleet state from {}", latestStatePath, e); + return null; + } + } + + private AblestackV2KStatusAnswer buildStatusAnswer(AblestackV2KStatusCommand cmd, String details, String phase, String migrationState, + String migrationStep, String syncPhysical, String workdir) { + String targetProvider = null; + String cloudVmId = null; + Path manifestPath = resolveManifestPath(workdir); + if (manifestPath != null) { + try { + String manifestJson = Files.readString(manifestPath, StandardCharsets.UTF_8); + targetProvider = getJsonStringField(manifestJson, "provider"); + cloudVmId = getJsonStringField(manifestJson, "vm_id"); + } catch (IOException e) { + logger.debug("Unable to read ablestack-v2k manifest from {}", manifestPath, e); + } + } + if (StringUtils.isBlank(cloudVmId) && isCompletedPhase2(phase, migrationState)) { + cloudVmId = getLatestCloudCutoverVmId(cmd.getVmName()); + if (StringUtils.isNotBlank(cloudVmId)) { + targetProvider = CLOUD_TARGET_PROVIDER; + } + } + return new AblestackV2KStatusAnswer(cmd, true, details, phase, migrationState, migrationStep, + syncPhysical, workdir, targetProvider, cloudVmId); + } + + private Path resolveManifestPath(String workdir) { + if (StringUtils.isBlank(workdir)) { + return null; + } + try { + Path workdirPath = Path.of(StringUtils.trim(workdir)); + if (!workdirPath.isAbsolute()) { + return null; + } + Path manifestPath = workdirPath.resolve("manifest.json"); + return Files.isRegularFile(manifestPath) ? manifestPath : null; + } catch (RuntimeException e) { + return null; + } + } + + private boolean isUnknownStatus(String phase, String migrationState) { + return StringUtils.equalsIgnoreCase(StringUtils.trimToEmpty(phase), "unknown") + && StringUtils.equalsIgnoreCase(StringUtils.trimToEmpty(migrationState), "unknown"); + } + + private boolean isCompletedPhase2(String phase, String migrationState) { + return StringUtils.equalsIgnoreCase(StringUtils.trimToEmpty(phase), "phase2") + && StringUtils.equalsAnyIgnoreCase(StringUtils.trimToEmpty(migrationState), "done", "completed", "success"); + } + + private Path findLatestFleetStatePath(String vmName) { + String stateFileName = vmName + ".json"; + try (Stream paths = Files.walk(V2K_FLEET_ROOT, 3)) { + Optional latestPath = paths + .filter(Files::isRegularFile) + .filter(path -> StringUtils.equals(path.getFileName().toString(), stateFileName)) + .max(Comparator.comparingLong(path -> path.toFile().lastModified())); + return latestPath.orElse(null); + } catch (IOException e) { + logger.debug("Unable to scan ablestack-v2k fleet state directory {}", V2K_FLEET_ROOT, e); + return null; + } + } + + private String getLatestCloudCutoverVmId(String vmName) { + Path latestOutputPath = findLatestFleetOutputPath(vmName); + if (latestOutputPath == null) { + return null; + } + try { + String output = Files.readString(latestOutputPath, StandardCharsets.UTF_8); + Matcher matcher = CLOUD_CUTOVER_COMPLETED_PATTERN.matcher(output); + String cloudVmId = null; + while (matcher.find()) { + cloudVmId = matcher.group(1); + } + return cloudVmId; + } catch (IOException e) { + logger.debug("Unable to read ablestack-v2k fleet output from {}", latestOutputPath, e); + return null; + } + } + + private Path findLatestFleetOutputPath(String vmName) { + String outputFileName = vmName + ".out"; + try (Stream paths = Files.walk(V2K_FLEET_ROOT, 2)) { + Optional latestPath = paths + .filter(Files::isRegularFile) + .filter(path -> StringUtils.equals(path.getFileName().toString(), outputFileName)) + .max(Comparator.comparingLong(path -> path.toFile().lastModified())); + return latestPath.orElse(null); + } catch (IOException e) { + logger.debug("Unable to scan ablestack-v2k fleet output directory {}", V2K_FLEET_ROOT, e); + return null; + } + } + + private String getJsonStringField(String json, String fieldName) { + Matcher matcher = Pattern.compile(String.format(JSON_STRING_FIELD_PATTERN.pattern(), Pattern.quote(fieldName))).matcher(json); + if (!matcher.find()) { + return null; + } + return matcher.group(1); + } + + private JsonObject getObject(JsonObject object, String memberName) { + if (object == null) { + return null; + } + JsonElement element = object.get(memberName); + return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; + } + + private String getString(JsonObject object, String memberName) { + if (object == null) { + return null; + } + JsonElement element = object.get(memberName); + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + return element.getAsString(); + } + + private Long getLong(JsonObject object, String memberName) { + if (object == null) { + return null; + } + JsonElement element = object.get(memberName); + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + try { + return element.getAsLong(); + } catch (RuntimeException e) { + return null; + } + } + + private Integer getInteger(JsonObject object, String memberName) { + Long value = getLong(object, memberName); + return value != null ? value.intValue() : null; } } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapperTest.java new file mode 100644 index 000000000000..190d67e08f08 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtAblestackN2KConvertInstanceCommandWrapperTest.java @@ -0,0 +1,143 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.AblestackN2KConvertInstanceCommand; +import com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.storage.Storage; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtAblestackN2KConvertInstanceCommandWrapperTest { + + private LibvirtAblestackN2KConvertInstanceCommandWrapper wrapper; + + @Mock + private LibvirtComputingResource libvirtComputingResource; + @Mock + private KVMStoragePoolManager storagePoolManager; + @Mock + private KVMStoragePool storagePool; + @Mock + private PrimaryDataStoreTO primaryDataStore; + + @Before + public void setUp() { + wrapper = new LibvirtAblestackN2KConvertInstanceCommandWrapper(); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolManager); + Mockito.when(primaryDataStore.getPoolType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(primaryDataStore.getUuid()).thenReturn("rbd-uuid"); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-uuid")).thenReturn(storagePool); + } + + @Test + public void executeRejectsRbdCommandWithoutTargetMap() { + AblestackN2KConvertInstanceCommand cmd = validCommand(); + cmd.setTargetStorage("rbd"); + cmd.setTargetMapJson(null); + + Answer answer = wrapper.execute(cmd, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("targetMapJson")); + } + + @Test + public void executeRejectsCloudManagedRunWhenSourceApiIsNotV3() { + AblestackN2KConvertInstanceCommand cmd = validCommand(); + cmd = new AblestackN2KConvertInstanceCommand("rhel", "https://pc:9440", "admin", "secret", + primaryDataStore, "phase1", "v4", true, "/work/rhel"); + cmd.setTargetFormat("raw"); + cmd.setTargetStorage("rbd"); + cmd.setTargetMapJson("{\"scsi0:0\":\"rbd:rbd/rhel-disk0\"}"); + + Answer answer = wrapper.execute(cmd, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("sourceApi=v3")); + } + + @Test + public void executeBuildsAblestackN2KRunCommandWithoutPassingPlainCredentialsAsArguments() { + AblestackN2KConvertInstanceCommand cmd = validCommand(); + + try (MockedConstruction + + + + diff --git a/ui/src/views/tools/ManageInstances.vue b/ui/src/views/tools/ManageInstances.vue index 8e464075bec8..a28dbaa1676b 100644 --- a/ui/src/views/tools/ManageInstances.vue +++ b/ui/src/views/tools/ManageInstances.vue @@ -52,6 +52,7 @@
- - VMware + + {{ $t('label.vmware') }} - + + {{ $t('label.nutanix') }} + + {{ $t('label.app.name') }} @@ -106,21 +110,21 @@ - + - + @@ -134,7 +138,7 @@ > - + @@ -148,7 +152,7 @@ > - + @@ -250,13 +254,79 @@ @change="onSelectClusterId" > - + + - +
- {{ $t('label.fetch.instances') }} + @click="() => { fetchUnmanagedInstances() }"> + {{ sourceFetchButtonLabel }}
@@ -373,10 +443,13 @@ size="middle" :rowClassName="getRowClassName" > -