diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d46df2f395f5..c7f1e682b9b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,7 @@ jobs: smoke/test_affinity_groups_projects smoke/test_annotations smoke/test_async_job + smoke/test_async_jobs smoke/test_attach_multiple_volumes smoke/test_backup_recovery_dummy smoke/test_certauthority_root diff --git a/agent/src/main/java/com/cloud/agent/Agent.java b/agent/src/main/java/com/cloud/agent/Agent.java index 275fd41edc34..db5ded18ab1e 100644 --- a/agent/src/main/java/com/cloud/agent/Agent.java +++ b/agent/src/main/java/com/cloud/agent/Agent.java @@ -807,9 +807,7 @@ protected void processRequest(final Request request, final Link link) { } commandsInProgress.incrementAndGet(); try { - if (cmd.isReconcile()) { - cmd.setRequestSequence(request.getSequence()); - } + cmd.setRequestSequence(request.getSequence()); answer = serverResource.executeRequest(cmd); } finally { commandsInProgress.decrementAndGet(); @@ -1103,9 +1101,7 @@ public void processOtherTask(final Task task) { Answer answer = null; commandsInProgress.incrementAndGet(); try { - if (command.isReconcile()) { - command.setRequestSequence(req.getSequence()); - } + command.setRequestSequence(req.getSequence()); answer = serverResource.executeRequest(command); } finally { commandsInProgress.decrementAndGet(); diff --git a/api/src/main/java/com/cloud/agent/api/Answer.java b/api/src/main/java/com/cloud/agent/api/Answer.java index 1166c075a78e..861a38d79da6 100644 --- a/api/src/main/java/com/cloud/agent/api/Answer.java +++ b/api/src/main/java/com/cloud/agent/api/Answer.java @@ -21,6 +21,8 @@ public class Answer extends Command { protected boolean result; protected String details; + // set when the command was stopped because its async job was cancelled + protected boolean cancelled; protected Answer() { this(null); @@ -47,6 +49,20 @@ public String getDetails() { return details; } + public boolean isCancelled() { + return cancelled; + } + + public void setCancelled(final boolean cancelled) { + this.cancelled = cancelled; + } + + public static Answer createCancelledAnswer(final Command command, final String details) { + Answer answer = new Answer(command, false, details); + answer.setCancelled(true); + return answer; + } + @Override public boolean executeInSequence() { return false; @@ -69,6 +85,7 @@ public boolean equals(Object o) { Answer answer = (Answer) o; if (result != answer.result) return false; + if (cancelled != answer.cancelled) return false; if (details != null ? !details.equals(answer.details) : answer.details != null) return false; return true; @@ -78,6 +95,7 @@ public boolean equals(Object o) { public int hashCode() { int result1 = super.hashCode(); result1 = 31 * result1 + (result ? 1 : 0); + result1 = 31 * result1 + (cancelled ? 1 : 0); result1 = 31 * result1 + (details != null ? details.hashCode() : 0); return result1; } diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 7f45c8acd3f7..ea5f98e83fed 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -918,6 +918,7 @@ public class EventTypes { public static final String EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_UPDATE = "INSTANCE.BOOT.GROUP.READINESS.RULE.UPDATE"; public static final String EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_DELETE = "INSTANCE.BOOT.GROUP.READINESS.RULE.DELETE"; + public static final String EVENT_JOB_CANCEL = "JOB.CANCEL"; static { diff --git a/api/src/main/java/com/cloud/exception/OperationCancelledException.java b/api/src/main/java/com/cloud/exception/OperationCancelledException.java new file mode 100644 index 000000000000..88483afd0009 --- /dev/null +++ b/api/src/main/java/com/cloud/exception/OperationCancelledException.java @@ -0,0 +1,97 @@ +// 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.exception; + +import com.cloud.agent.api.Command; +import com.cloud.utils.SerialVersionUID; +import com.cloud.utils.exception.CloudRuntimeException; + +import java.util.Arrays; +import java.util.stream.Collectors; + +/** Thrown when an operation was stopped because its async job was cancelled. Unchecked: intermediate callers must not relabel it as a timeout. */ +public class OperationCancelledException extends CloudRuntimeException { + + private static final long serialVersionUID = SerialVersionUID.OperationCancelledException; + + long _agentId; + long _seqId; + long _elapsedSeconds; + boolean _isActive; + String _reason; + + transient Command[] _cmds; + + public OperationCancelledException(Command[] cmds, long agentId, long seqId, long elapsedSeconds, boolean isActive, String reason) { + super(commandNames(cmds) + " (seq " + seqId + ") on host " + agentId + " cancelled after " + elapsedSeconds + " s" + (reason != null ? ": " + reason : "")); + _agentId = agentId; + _seqId = seqId; + _elapsedSeconds = elapsedSeconds; + _cmds = cmds; + _isActive = isActive; + _reason = reason; + } + + private OperationCancelledException(String message, Command[] cmds, long agentId, String reason) { + super(message); + _agentId = agentId; + _cmds = cmds; + _reason = reason; + } + + public OperationCancelledException(String reason) { + super(reason); + _reason = reason; + } + + /** The commands were never sent: its job had already been cancelled. */ + public static OperationCancelledException updateCmdsNotSent(Command[] cmds, long agentId, long jobId) { + final String reason = "job-" + jobId + " was cancelled"; + return new OperationCancelledException(commandNames(cmds) + " to host " + agentId + " not sent: " + reason, cmds, agentId, reason); + } + + static String commandNames(Command[] cmds) { + if (cmds == null || cmds.length == 0) { + return "Command"; + } + return Arrays.stream(cmds).map(cmd -> cmd == null ? "null" : cmd.getClass().getSimpleName()).collect(Collectors.joining(", ")); + } + + public long getAgentId() { + return _agentId; + } + + public long getSequenceId() { + return _seqId; + } + + public long getElapsedSeconds() { + return _elapsedSeconds; + } + + public boolean isActive() { + return _isActive; + } + + public String getReason() { + return _reason; + } + + public Command[] getCommands() { + return _cmds; + } +} diff --git a/api/src/main/java/com/cloud/vm/VirtualMachine.java b/api/src/main/java/com/cloud/vm/VirtualMachine.java index 3adcc85d28a1..4b437b4bd8e9 100644 --- a/api/src/main/java/com/cloud/vm/VirtualMachine.java +++ b/api/src/main/java/com/cloud/vm/VirtualMachine.java @@ -105,6 +105,7 @@ public static StateMachine2 getStat s_fsm.addTransition(new Transition(State.Starting, VirtualMachine.Event.AgentReportRunning, State.Running, Arrays.asList(new Impact[]{Impact.USAGE}))); s_fsm.addTransition(new Transition(State.Starting, VirtualMachine.Event.AgentReportStopped, State.Stopped, null)); s_fsm.addTransition(new Transition(State.Starting, VirtualMachine.Event.AgentReportShutdowned, State.Stopped, null)); + s_fsm.addTransition(new Transition(State.Starting, VirtualMachine.Event.StopRequested, State.Stopping, null)); s_fsm.addTransition(new Transition(State.Destroyed, VirtualMachine.Event.RecoveryRequested, State.Stopped, Arrays.asList(new Impact[]{Impact.USAGE}))); s_fsm.addTransition(new Transition(State.Destroyed, VirtualMachine.Event.ExpungeOperation, State.Expunging, null)); s_fsm.addTransition(new Transition(State.Running, VirtualMachine.Event.MigrationRequested, State.Migrating, null)); diff --git a/api/src/main/java/org/apache/cloudstack/api/APICommand.java b/api/src/main/java/org/apache/cloudstack/api/APICommand.java index b77649046ca9..ee841b4e0ffa 100644 --- a/api/src/main/java/org/apache/cloudstack/api/APICommand.java +++ b/api/src/main/java/org/apache/cloudstack/api/APICommand.java @@ -52,4 +52,6 @@ Class[] entityType() default {}; String httpMethod() default ""; + + boolean cancellable() default false; } diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java index e12a709e6f5c..bc020728f397 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java @@ -94,7 +94,8 @@ public enum ApiCommandResourceType { KmsKey(org.apache.cloudstack.kms.KMSKey.class), HsmProfile(org.apache.cloudstack.kms.HSMProfile.class), InstanceBootGroup(org.apache.cloudstack.vm.bootgroup.InstanceBootGroup.class), - InstanceBootGroupReadinessRule(InstanceBootGroupReadinessRule.class); + InstanceBootGroupReadinessRule(InstanceBootGroupReadinessRule.class), + Job(org.apache.cloudstack.jobs.JobInfo.class); private final Class clazz; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 7485029f22cb..92a2ce3a8a3c 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -716,6 +716,7 @@ public class ApiConstants { public static final String USAGE_ID = "usageid"; public static final String USAGE_NAME = "usagename"; public static final String USAGE_TYPE = "usagetype"; + public static final String USAGE_SERVER = "usageserver"; public static final String INCLUDE_TAGS = "includetags"; public static final String VLAN = "vlan"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java index 6e880c89432f..e4b0e5d97184 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java +++ b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.Set; +import org.apache.cloudstack.api.command.user.job.CancelAsyncJobCmd; import org.apache.cloudstack.api.response.ConsoleSessionResponse; import org.apache.cloudstack.consoleproxy.ConsoleSession; import org.apache.cloudstack.acl.apikeypair.ApiKeyPair; @@ -397,6 +398,8 @@ TemplatePermissionsResponse createTemplatePermissionsResponse(ResponseView view, AsyncJobResponse queryJobResult(QueryAsyncJobResultCmd cmd); + AsyncJobResponse cancelJobResponse(CancelAsyncJobCmd cmd); + NetworkOfferingResponse createNetworkOfferingResponse(NetworkOffering offering); NetworkResponse createNetworkResponse(ResponseView view, Network network); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/usage/ListUsageJobsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/usage/ListUsageJobsCmd.java new file mode 100644 index 000000000000..eecbe27ebc9a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/usage/ListUsageJobsCmd.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.usage; + +import java.util.Date; + +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.response.ListResponse; +import org.apache.cloudstack.api.response.UsageJobResponse; + +@APICommand(name = "listUsageJobs", description = "Lists the usage jobs.", responseObject = UsageJobResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0") +public class ListUsageJobsCmd extends BaseListCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.START_DATE, type = CommandType.DATE, description = "The start date from which the usage jobs should be listed. Only jobs started on or after this date will be included. (use format \"yyyy-MM-dd'T'HH:mm:ss'+'SSSS\")") + private Date startDate; + + @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE, description = "The end date up to which the usage jobs should be listed. Only jobs started on or before this date will be included. (use format \"yyyy-MM-dd'T'HH:mm:ss'+'SSSS\")") + private Date endDate; + + @Parameter(name = ApiConstants.USAGE_SERVER, type = CommandType.STRING, description = "The usage server host name or ip") + private String usageServer; + + @Parameter(name = ApiConstants.DURATION, type = CommandType.INTEGER, description = "the duration in hours to list the usage jobs started or completed within that period up to now.") + private Integer duration; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Date getStartDate() { + return startDate; + } + + public Date getEndDate() { + return endDate; + } + + public String getUsageServer() { + return usageServer; + } + + public Integer getDuration() { + return duration; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + @Override + public void execute() { + ListResponse response = _usageService.getUsageJobs(this); + response.setResponseName(getCommandName()); + this.setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DestroyVMCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DestroyVMCmdByAdmin.java index cbe6494d4004..7ab285b18ed4 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DestroyVMCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DestroyVMCmdByAdmin.java @@ -28,7 +28,8 @@ @APICommand(name = "destroyVirtualMachine", description = "Destroys an Instance. Once destroyed, only the administrator can recover it.", responseObject = UserVmResponse.class, responseView = ResponseView.Full, entityType = {VirtualMachine.class}, requestHasSensitiveInfo = false, - responseHasSensitiveInfo = true) + responseHasSensitiveInfo = true, + cancellable = true) public class DestroyVMCmdByAdmin extends DestroyVMCmd implements AdminCmd { @Parameter( name = ApiConstants.FORCED, diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java index 467b92d415d2..45ac0a27bd36 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java @@ -48,10 +48,10 @@ description = "Attempts Migration of an Instance to a different host or Root volume of the Instance to a different storage pool", responseObject = UserVmResponse.class, entityType = {VirtualMachine.class}, requestHasSensitiveInfo = false, - responseHasSensitiveInfo = true) + responseHasSensitiveInfo = true, + cancellable = true) public class MigrateVMCmd extends BaseAsyncCmd { - ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVirtualMachineWithVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVirtualMachineWithVolumeCmd.java index ad298513ac0b..85c2f5c20274 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVirtualMachineWithVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVirtualMachineWithVolumeCmd.java @@ -49,9 +49,9 @@ description = "Attempts Migration of an Instance with its volumes to a different host", responseObject = UserVmResponse.class, entityType = {VirtualMachine.class}, requestHasSensitiveInfo = false, - responseHasSensitiveInfo = true) -public class MigrateVirtualMachineWithVolumeCmd extends BaseAsyncCmd { - + responseHasSensitiveInfo = true, + cancellable = true) +public class MigrateVirtualMachineWithVolumeCmd extends BaseAsyncCmd { ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RebootVMCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RebootVMCmdByAdmin.java index 36fdc9d8ec18..c55aaaf0501b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RebootVMCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RebootVMCmdByAdmin.java @@ -25,5 +25,5 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "rebootVirtualMachine", description = "Reboots an Instance.", responseObject = UserVmResponse.class, responseView = ResponseView.Full, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class RebootVMCmdByAdmin extends RebootVMCmd implements AdminCmd {} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVMCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVMCmdByAdmin.java index 30fab3b9382f..cecff0bd1c15 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVMCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVMCmdByAdmin.java @@ -25,5 +25,5 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "startVirtualMachine", responseObject = UserVmResponse.class, description = "Starts an Instance.", responseView = ResponseView.Full, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class StartVMCmdByAdmin extends StartVMCmd implements AdminCmd {} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StopVMCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StopVMCmdByAdmin.java index 6dc1947712d6..98ea91fb62ed 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StopVMCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StopVMCmdByAdmin.java @@ -25,5 +25,5 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "stopVirtualMachine", responseObject = UserVmResponse.class, description = "Stops an Instance.", responseView = ResponseView.Full, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class StopVMCmdByAdmin extends StopVMCmd implements AdminCmd {} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vmsnapshot/RevertToVMSnapshotCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vmsnapshot/RevertToVMSnapshotCmdByAdmin.java index a57362d62056..0c25c6c693e0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vmsnapshot/RevertToVMSnapshotCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vmsnapshot/RevertToVMSnapshotCmdByAdmin.java @@ -23,5 +23,5 @@ import org.apache.cloudstack.api.response.UserVmResponse; @APICommand(name = "revertToVMSnapshot", description = "Revert Instance from a vmsnapshot.", responseObject = UserVmResponse.class, since = "4.2.0", responseView = ResponseView.Full, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class RevertToVMSnapshotCmdByAdmin extends RevertToVMSnapshotCmd implements AdminCmd {} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/AttachVolumeCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/AttachVolumeCmdByAdmin.java index 20418d589405..5a6ee0f19d16 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/AttachVolumeCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/AttachVolumeCmdByAdmin.java @@ -25,5 +25,5 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "attachVolume", description = "Attaches a disk volume to an Instance.", responseObject = VolumeResponse.class, responseView = ResponseView.Full, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class AttachVolumeCmdByAdmin extends AttachVolumeCmd implements AdminCmd {} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/CreateVolumeCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/CreateVolumeCmdByAdmin.java index e156475b29ae..40b950bfbb33 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/CreateVolumeCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/CreateVolumeCmdByAdmin.java @@ -27,5 +27,5 @@ @APICommand(name = "createVolume", responseObject = VolumeResponse.class, description = "Creates a disk volume from a disk offering. This disk volume must still be attached to an Instance to make use of it.", responseView = ResponseView.Full, entityType = { Volume.class, VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class CreateVolumeCmdByAdmin extends CreateVolumeCmd implements AdminCmd {} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java index de90fee102de..5273d562dc49 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java @@ -33,7 +33,8 @@ since = "4.14.0", authorized = {RoleType.Admin}, requestHasSensitiveInfo = false, - responseHasSensitiveInfo = true) + responseHasSensitiveInfo = true, + cancellable = true) public class DestroyVolumeCmdByAdmin extends DestroyVolumeCmd implements AdminCmd { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DetachVolumeCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DetachVolumeCmdByAdmin.java index 05f9fe6a31a6..c39e487fc04f 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DetachVolumeCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DetachVolumeCmdByAdmin.java @@ -25,5 +25,5 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "detachVolume", description = "Detaches a disk volume from an Instance.", responseObject = VolumeResponse.class, responseView = ResponseView.Full, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class DetachVolumeCmdByAdmin extends DetachVolumeCmd implements AdminCmd {} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/MigrateVolumeCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/MigrateVolumeCmdByAdmin.java index 9dd544cae581..f30f3f788fbc 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/MigrateVolumeCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/MigrateVolumeCmdByAdmin.java @@ -25,6 +25,6 @@ import com.cloud.storage.Volume; @APICommand(name = "migrateVolume", description = "Migrate volume", responseObject = VolumeResponse.class, since = "3.0.0", responseView = ResponseView.Full, entityType = { - Volume.class}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + Volume.class}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class MigrateVolumeCmdByAdmin extends MigrateVolumeCmd implements AdminCmd { } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/event/ListEventsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/event/ListEventsCmd.java index b86d1f8b956a..2587044d7199 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/event/ListEventsCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/event/ListEventsCmd.java @@ -22,6 +22,7 @@ import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.BaseListProjectAndAccountResourcesCmd; import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.AsyncJobResponse; import org.apache.cloudstack.api.response.EventResponse; import org.apache.cloudstack.api.response.ListResponse; @@ -64,6 +65,9 @@ public class ListEventsCmd extends BaseListProjectAndAccountResourcesCmd { @Parameter(name = ApiConstants.START_ID, type = CommandType.UUID, entityType = EventResponse.class, description = "The parent/start ID of the event, when provided this will list all the events with the start/parent ID including the parent event") private Long startId; + @Parameter(name = ApiConstants.JOB_ID, type = CommandType.UUID, entityType = AsyncJobResponse.class, description = "the ID of the async job that raised the events", since = "24.0") + private Long jobId; + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, description = "The ID of the resource associated with the event", since="4.17.0") private String resourceId; @@ -112,6 +116,10 @@ public Long getStartId() { return startId; } + public Long getJobId() { + return jobId; + } + public String getResourceId() { return resourceId; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/job/CancelAsyncJobCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/job/CancelAsyncJobCmd.java new file mode 100644 index 000000000000..92f321671f32 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/job/CancelAsyncJobCmd.java @@ -0,0 +1,95 @@ +// 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.user.job; + + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AsyncJobResponse; + +import com.cloud.user.Account; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.jobs.AsyncJobService; + +import javax.inject.Inject; + +@APICommand(name = CancelAsyncJobCmd.APINAME, description = "Cancels the asynchronous job.", responseObject = AsyncJobResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, authorized = {RoleType.Admin}, since = "24.0") +public class CancelAsyncJobCmd extends BaseCmd { + public static final String APINAME = "cancelAsyncJob"; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.JOB_ID, type = CommandType.UUID, entityType = AsyncJobResponse.class, required = true, description = "the ID of the asynchronous job") + private Long id; + + @Inject + private AsyncJobService asyncJobService; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + BaseCmd.RESPONSE_SUFFIX; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Job; + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public void execute() { + try { + asyncJobService.cancelAsyncJob(id, "Cancelled by " + CallContext.current().getCallingUser().getUsername()); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + AsyncJobResponse response = _responseGenerator.cancelJobResponse(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/job/ListAsyncJobsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/job/ListAsyncJobsCmd.java index 2c8401831132..637d0d23d312 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/job/ListAsyncJobsCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/job/ListAsyncJobsCmd.java @@ -16,7 +16,14 @@ // under the License. package org.apache.cloudstack.api.command.user.job; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; +import java.util.List; +import java.util.Locale; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.StringUtils; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiArgValidator; @@ -26,8 +33,9 @@ import org.apache.cloudstack.api.response.AsyncJobResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.ManagementServerResponse; +import org.apache.cloudstack.jobs.JobInfo; -@APICommand(name = "listAsyncJobs", description = "Lists all pending asynchronous jobs for the Account.", responseObject = AsyncJobResponse.class, +@APICommand(name = "listAsyncJobs", description = "Lists asynchronous jobs for the Account.", responseObject = AsyncJobResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class ListAsyncJobsCmd extends BaseListAccountResourcesCmd { @@ -35,9 +43,15 @@ public class ListAsyncJobsCmd extends BaseListAccountResourcesCmd { //////////////// API parameters ///////////////////// ///////////////////////////////////////////////////// - @Parameter(name = ApiConstants.START_DATE, type = CommandType.DATE, description = "The start date of the async job (use format \"yyyy-MM-dd'T'HH:mm:ss'+'SSSS\")") + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = AsyncJobResponse.class, description = "The ID of the async job", since = "24.0") + private Long id; + + @Parameter(name = ApiConstants.START_DATE, type = CommandType.DATE, description = "The start date from which the async jobs should be listed. Only jobs created on or after this date will be included. (use format \"yyyy-MM-dd'T'HH:mm:ss'+'SSSS\")") private Date startDate; + @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE, description = "The end date up to which the async jobs should be listed. Only jobs created on or before this date will be included. (use format \"yyyy-MM-dd'T'HH:mm:ss'+'SSSS\")", since = "24.0") + private Date endDate; + @Parameter(name = ApiConstants.MANAGEMENT_SERVER_ID, type = CommandType.UUID, entityType = ManagementServerResponse.class, description = "The id of the management server", since="4.19") private Long managementServerId; @@ -47,6 +61,16 @@ public class ListAsyncJobsCmd extends BaseListAccountResourcesCmd { @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, description = "the type of the resource associated with the job", since="4.22.1") private String resourceType; + @Parameter(name = ApiConstants.JOB_STATUS, type = CommandType.LIST, collectionType = CommandType.STRING, description = "Comma-separated list of job statuses to list the async jobs by. " + + "Accepts names (IN_PROGRESS, SUCCEEDED, FAILED, CANCELLED) or ordinals (0, 1, 2, 3). Only pending jobs are listed by default.", since = "24.0") + private List jobStatuses; + + @Parameter(name = "cancelrequested", type = CommandType.BOOLEAN, description = "True to list only jobs whose cancellation has been requested, and False to list jobs whose cancellation has not been requested", since = "24.0") + private Boolean cancelRequested; + + @Parameter(name = ApiConstants.DURATION, type = CommandType.INTEGER, description = "the duration in hours to list the async jobs started or completed within that period up to now.", since = "24.0") + private Integer duration; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -55,10 +79,22 @@ public Date getStartDate() { return startDate; } + public Date getEndDate() { + return endDate; + } + public Long getManagementServerId() { return managementServerId; } + public Long getId() { + return id; + } + + public Boolean getCancelRequested() { + return cancelRequested; + } + public String getResourceId() { return resourceId; } @@ -67,15 +103,56 @@ public String getResourceType() { return resourceType; } + public List getJobStatuses() { + if (jobStatuses == null) { + return null; + } + + if (jobStatuses.isEmpty()) { + throw new InvalidParameterValueException("Empty job status"); + } + + List statuses = new ArrayList<>(jobStatuses.size()); + for (String status : jobStatuses) { + statuses.add(parseJobStatus(status).value()); + } + return statuses; + } + + private JobInfo.Status parseJobStatus(String status) { + if (StringUtils.isBlank(status)) { + throw new InvalidParameterValueException("Empty job status"); + } + + String value = status.trim(); + try { + return JobInfo.Status.fromValue(Integer.parseInt(value)); + } catch (NumberFormatException e) { + // not an ordinal, fall through and try the name + } catch (IllegalArgumentException e) { + throw new InvalidParameterValueException(String.format("Invalid job status: %s. Valid values are %s or their ordinals", + status, Arrays.toString(JobInfo.Status.values()))); + } + + try { + return JobInfo.Status.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new InvalidParameterValueException(String.format("Invalid job status: %s. Valid values are %s or their ordinals", + status, Arrays.toString(JobInfo.Status.values()))); + } + } + + public Integer getDuration() { + return duration; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// @Override public void execute() { - ListResponse response = _queryService.searchForAsyncJobs(this); response.setResponseName(getCommandName()); this.setResponseObject(response); - } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/CreateSnapshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/CreateSnapshotCmd.java index d03df501847a..8af8a7a2b8dd 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/CreateSnapshotCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/CreateSnapshotCmd.java @@ -50,7 +50,7 @@ import com.cloud.utils.exception.CloudRuntimeException; @APICommand(name = "createSnapshot", description = "Creates an instant Snapshot of a volume.", responseObject = SnapshotResponse.class, entityType = {Snapshot.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class CreateSnapshotCmd extends BaseAsyncCreateCmd { // /////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/DeleteSnapshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/DeleteSnapshotCmd.java index b4eaceb61ba6..b99e186ae034 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/DeleteSnapshotCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/DeleteSnapshotCmd.java @@ -36,7 +36,7 @@ import com.cloud.user.Account; @APICommand(name = "deleteSnapshot", description = "Deletes a Snapshot of a disk volume.", responseObject = SuccessResponse.class, entityType = {Snapshot.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class DeleteSnapshotCmd extends BaseAsyncCmd { ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/RevertSnapshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/RevertSnapshotCmd.java index 59881dfdabe2..17a84f080490 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/RevertSnapshotCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/RevertSnapshotCmd.java @@ -36,7 +36,7 @@ import com.cloud.user.Account; @APICommand(name = "revertSnapshot", description = "This is supposed to revert a volume Snapshot. This command is only supported with KVM so far", responseObject = SnapshotResponse.class, entityType = {Snapshot.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class RevertSnapshotCmd extends BaseAsyncCmd { ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java index 0a3e510d3d53..5be01287137a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java @@ -43,7 +43,8 @@ @APICommand(name = "destroyVirtualMachine", description = "Destroys an Instance.", responseObject = UserVmResponse.class, responseView = ResponseView.Restricted, entityType = {VirtualMachine.class}, requestHasSensitiveInfo = false, - responseHasSensitiveInfo = true) + responseHasSensitiveInfo = true, + cancellable = true) public class DestroyVMCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "destroyvirtualmachineresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/RebootVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/RebootVMCmd.java index 6f4431547848..35f776f08242 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/RebootVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/RebootVMCmd.java @@ -40,7 +40,7 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "rebootVirtualMachine", description = "Reboots an Instance.", responseObject = UserVmResponse.class, responseView = ResponseView.Restricted, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class RebootVMCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "rebootvirtualmachineresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StartVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StartVMCmd.java index 40ae91d4c264..272f8852215a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StartVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StartVMCmd.java @@ -48,7 +48,7 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "startVirtualMachine", responseObject = UserVmResponse.class, description = "Starts an Instance.", responseView = ResponseView.Restricted, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class StartVMCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "startvirtualmachineresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StopVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StopVMCmd.java index 232eeebd34be..963da6cf116b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StopVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/StopVMCmd.java @@ -38,7 +38,7 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "stopVirtualMachine", responseObject = UserVmResponse.class, description = "Stops an Instance.", responseView = ResponseView.Restricted, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class StopVMCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "stopvirtualmachineresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/CreateVMSnapshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/CreateVMSnapshotCmd.java index 35723a3bbb74..fd7a93e02aa1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/CreateVMSnapshotCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/CreateVMSnapshotCmd.java @@ -38,10 +38,9 @@ @APICommand(name = "createVMSnapshot", description = "Creates Snapshot for an Instance. Running KVM UEFI disk-only snapshots briefly suspend the Instance while copying NVRAM state.", responseObject = VMSnapshotResponse.class, since = "4.2.0", entityType = {VMSnapshot.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class CreateVMSnapshotCmd extends BaseAsyncCreateCmd { - @ACL(accessType = AccessType.OperateEntry) @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, required = true, entityType = UserVmResponse.class, description = "The ID of the Instance") private Long vmId; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/DeleteVMSnapshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/DeleteVMSnapshotCmd.java index 3373ac534cc8..a1f05919e5bb 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/DeleteVMSnapshotCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/DeleteVMSnapshotCmd.java @@ -36,7 +36,7 @@ import com.cloud.vm.snapshot.VMSnapshot; @APICommand(name = "deleteVMSnapshot", description = "Deletes an Instance Snapshot.", responseObject = SuccessResponse.class, since = "4.2.0", entityType = {VMSnapshot.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class DeleteVMSnapshotCmd extends BaseAsyncCmd { @ACL(accessType = AccessType.OperateEntry) diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/RevertToVMSnapshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/RevertToVMSnapshotCmd.java index d44cefca5027..3cff47d43ed7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/RevertToVMSnapshotCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vmsnapshot/RevertToVMSnapshotCmd.java @@ -42,7 +42,7 @@ import com.cloud.vm.snapshot.VMSnapshot; @APICommand(name = "revertToVMSnapshot", description = "Revert Instance from a vmsnapshot.", responseObject = UserVmResponse.class, since = "4.2.0", responseView = ResponseView.Restricted, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, cancellable = true) public class RevertToVMSnapshotCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "reverttovmsnapshotresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/AttachVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/AttachVolumeCmd.java index 8624043afc51..d89c92319509 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/AttachVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/AttachVolumeCmd.java @@ -38,7 +38,7 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "attachVolume", description = "Attaches a disk volume to an Instance.", responseObject = VolumeResponse.class, responseView = ResponseView.Restricted, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class AttachVolumeCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "attachvolumeresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java index 538e263ae9de..ca83e2e880eb 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java @@ -47,7 +47,7 @@ @APICommand(name = "createVolume", responseObject = VolumeResponse.class, description = "Creates a disk volume from a disk offering. This disk volume must still be attached to an Instance to make use of it.", responseView = ResponseView.Restricted, entityType = { Volume.class, VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class CreateVolumeCmd extends BaseAsyncCreateCustomIdCmd implements UserCmd { private static final String s_name = "createvolumeresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java index e9e388436642..73a393d73b21 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java @@ -39,7 +39,8 @@ since = "4.14.0", authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, requestHasSensitiveInfo = false, - responseHasSensitiveInfo = true) + responseHasSensitiveInfo = true, + cancellable = true) public class DestroyVolumeCmd extends BaseAsyncCmd { private static final String s_name = "destroyvolumeresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DetachVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DetachVolumeCmd.java index f6e811da6055..16534943bfe7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DetachVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DetachVolumeCmd.java @@ -39,7 +39,7 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "detachVolume", description = "Detaches a disk volume from an Instance.", responseObject = VolumeResponse.class, responseView = ResponseView.Restricted, entityType = {VirtualMachine.class}, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class DetachVolumeCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "detachvolumeresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/MigrateVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/MigrateVolumeCmd.java index 9927978ad55e..4bf446ee38ff 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/MigrateVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/MigrateVolumeCmd.java @@ -34,7 +34,7 @@ import com.cloud.user.Account; @APICommand(name = "migrateVolume", description = "Migrate volume", responseObject = VolumeResponse.class, since = "3.0.0", responseView = ResponseView.Restricted, entityType = { - Volume.class}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) + Volume.class}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, cancellable = true) public class MigrateVolumeCmd extends BaseAsyncCmd implements UserCmd { private static final String s_name = "migratevolumeresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/response/AsyncJobResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/AsyncJobResponse.java index 5be0052069ec..be5692117de4 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/AsyncJobResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/AsyncJobResponse.java @@ -31,6 +31,10 @@ @EntityReference(value = JobInfo.class) public class AsyncJobResponse extends BaseResponse { + @SerializedName(ApiConstants.ID) + @Param(description = "The ID of the async job", since = "24.0") + private String id; + @SerializedName("accountid") @Param(description = "The Account ID that executed the async command") private String accountId; @@ -43,6 +47,10 @@ public class AsyncJobResponse extends BaseResponse { @Param(description = "The domain ID that executed the async command") private String domainid; + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "The domain name that executed the async command", since = "24.0") + private String domain; + @SerializedName("domainpath") @Param(description = "The domain that executed the async command") private String domainPath; @@ -79,6 +87,22 @@ public class AsyncJobResponse extends BaseResponse { @Param(description = "The unique ID of the Instance/entity object related to the job") private String jobInstanceId; + @SerializedName("cancelrequested") + @Param(description = "True once the job's cancellation has been requested", since = "24.0") + private Boolean cancelRequested; + + @SerializedName("cancelreason") + @Param(description = "Who requested the job cancellation and through which management server", since = "24.0") + private String cancelReason; + + @SerializedName("cancellable") + @Param(description = "True if the job can be cancelled with cancelAsyncJob; a cancel may still be refused while the running job cannot be cancelled", since = "24.0") + private Boolean cancellable; + + @SerializedName("jobinstancename") + @Param(description = "The name of the Instance/entity object related to the job", since = "24.0") + private String jobInstanceName; + @SerializedName(ApiConstants.MANAGEMENT_SERVER_ID) @Param(description = "The MSID of the management server on which the job is running", since = "4.19") private String managementServerId; @@ -87,6 +111,18 @@ public class AsyncJobResponse extends BaseResponse { @Param(description = "the management server name of the host", since = "4.21.0") private String managementServerName; + @SerializedName("initmanagementservername") + @Param(description = "The name of the management server that initiated the job", since = "24.0") + private String initManagementServerName; + + @SerializedName("executingmanagementservername") + @Param(description = "The name of the management server executing the job", since = "24.0") + private String executingManagementServerName; + + @SerializedName("completemanagementservername") + @Param(description = "The name of the management server that completed the job", since = "24.0") + private String completeManagementServerName; + @SerializedName(ApiConstants.CREATED) @Param(description = "The created date of the job") private Date created; @@ -143,6 +179,46 @@ public void setJobInstanceId(String jobInstanceId) { this.jobInstanceId = jobInstanceId; } + public void setId(String id) { + this.id = id; + } + + public void setDomain(String domain) { + this.domain = domain; + } + + public void setInitManagementServerName(String initManagementServerName) { + this.initManagementServerName = initManagementServerName; + } + + public void setExecutingManagementServerName(String executingManagementServerName) { + this.executingManagementServerName = executingManagementServerName; + } + + public void setCompleteManagementServerName(String completeManagementServerName) { + this.completeManagementServerName = completeManagementServerName; + } + + public void setCancelRequested(Boolean cancelRequested) { + this.cancelRequested = cancelRequested; + } + + public void setCancelReason(String cancelReason) { + this.cancelReason = cancelReason; + } + + public void setCancellable(Boolean cancellable) { + this.cancellable = cancellable; + } + + public boolean getCancellable() { + return Boolean.TRUE.equals(cancellable); + } + + public void setJobInstanceName(String jobInstanceName) { + this.jobInstanceName = jobInstanceName; + } + public void setCreated(Date created) { this.created = created; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UsageJobResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UsageJobResponse.java new file mode 100644 index 000000000000..40b13c472516 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/UsageJobResponse.java @@ -0,0 +1,93 @@ +// 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 java.util.Date; + +import com.google.gson.annotations.SerializedName; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; + +public class UsageJobResponse extends BaseResponse { + + @SerializedName(ApiConstants.USAGE_SERVER) + @Param(description = "the usage server host") + private String usageServer; + + @SerializedName("jobtype") + @Param(description = "the job type (0 - Recurring, 1 - Single)") + private Integer jobType; + + @SerializedName("scheduled") + @Param(description = "the job is scheduled or not") + private Integer scheduled; + + @SerializedName(ApiConstants.START_DATE) + @Param(description = " the start date of the job") + private Date startDate; + + @SerializedName(ApiConstants.END_DATE) + @Param(description = " the end date of the job") + private Date endDate; + + @SerializedName("executiontime") + @Param(description = " the execution time of the job") + private Long executionTime; + + @SerializedName(ApiConstants.SUCCESS) + @Param(description = "the job is success or not") + private Boolean success; + + @SerializedName("heartbeat") + @Param(description = "the job heartbeat") + private Date heartbeat; + + public void setUsageServer(String usageServer) { + this.usageServer = usageServer; + } + + public void setJobType(Integer jobType) { + this.jobType = jobType; + } + + public void setScheduled(Integer scheduled) { + this.scheduled = scheduled; + } + + public void setStartDate(Date startDate) { + this.startDate = startDate; + } + + public void setEndDate(final Date endDate) { + this.endDate = endDate; + } + + public void setExecutionTime(Long executionTime) { + this.executionTime = executionTime; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public void setHeartbeat(final Date heartbeat) { + this.heartbeat = heartbeat; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/context/CallContext.java b/api/src/main/java/org/apache/cloudstack/context/CallContext.java index fcfb5b6b1e00..a6335aeb8f47 100644 --- a/api/src/main/java/org/apache/cloudstack/context/CallContext.java +++ b/api/src/main/java/org/apache/cloudstack/context/CallContext.java @@ -90,6 +90,9 @@ protected CallContext(User user, Account account, String contextId) { this.contextId = contextId; } + /** Context parameter a handler sets when it refuses a request for a stated reason: the action event then records a refusal with that reason, not an error. */ + public static final String REFUSAL_REASON_PARAM = "refusalReason"; + public void putContextParameter(Object key, Object value) { context.put(key, value); } diff --git a/api/src/main/java/org/apache/cloudstack/jobs/AsyncJobService.java b/api/src/main/java/org/apache/cloudstack/jobs/AsyncJobService.java new file mode 100644 index 000000000000..bef1c03620a7 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/jobs/AsyncJobService.java @@ -0,0 +1,27 @@ +// 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.jobs; + +import com.cloud.utils.component.PluggableService; +import org.apache.cloudstack.framework.config.ConfigKey; + +public interface AsyncJobService extends PluggableService { + ConfigKey CancelledJobInterval = new ConfigKey<>("Advanced", Integer.class, "job.cancelled.interval", "1", + "Interval in seconds to check the jobs cancelled", false); + + void cancelAsyncJob(long jobId, String reason); +} diff --git a/api/src/main/java/org/apache/cloudstack/jobs/JobCancellationHandler.java b/api/src/main/java/org/apache/cloudstack/jobs/JobCancellationHandler.java new file mode 100644 index 000000000000..994293dc31f5 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/jobs/JobCancellationHandler.java @@ -0,0 +1,31 @@ +// 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.jobs; + +/** Lets the job layer ask whoever runs a job's backend work whether it can be stopped, and stop it. An interface, not AgentManager: the job framework is built first. */ +public interface JobCancellationHandler { + + /** Asked before the job is marked cancelled: null when its work can be stopped, otherwise why not. Such a job is refused, not recorded as cancelled. */ + String checkJobCannotBeCancelled(long jobId); + + /** Returns true only if everything in flight was actually stopped. */ + boolean cancelJobExecution(long jobId, String reason); + + /** The cancel was taken back (its work could not be stopped after all): the job runs on and none of its commands is to be refused. */ + default void jobCancelWithdrawn(long jobId) { + } +} diff --git a/api/src/main/java/org/apache/cloudstack/jobs/JobInfo.java b/api/src/main/java/org/apache/cloudstack/jobs/JobInfo.java index 5b63e627d622..998f8a20a130 100644 --- a/api/src/main/java/org/apache/cloudstack/jobs/JobInfo.java +++ b/api/src/main/java/org/apache/cloudstack/jobs/JobInfo.java @@ -23,17 +23,32 @@ public interface JobInfo extends Identity, InternalIdentity { public enum Status { - IN_PROGRESS(false), SUCCEEDED(true), FAILED(true), CANCELLED(true); + IN_PROGRESS(0, false), SUCCEEDED(1, true), FAILED(2, true), CANCELLED(3, true); + private final int value; private final boolean done; - private Status(boolean done) { + private Status(int value, boolean done) { + this.value = value; this.done = done; } + public int value() { + return value; + } + public boolean done() { return done; } + + public static Status fromValue(int value) { + for (Status status : Status.values()) { + if (status.value() == value) { + return status; + } + } + throw new IllegalArgumentException("Invalid status value: " + value); + } } String getType(); diff --git a/api/src/main/java/org/apache/cloudstack/usage/UsageService.java b/api/src/main/java/org/apache/cloudstack/usage/UsageService.java index 00e8b431f8fe..32256c538fc3 100644 --- a/api/src/main/java/org/apache/cloudstack/usage/UsageService.java +++ b/api/src/main/java/org/apache/cloudstack/usage/UsageService.java @@ -18,8 +18,11 @@ import com.cloud.utils.Pair; import org.apache.cloudstack.api.command.admin.usage.GenerateUsageRecordsCmd; +import org.apache.cloudstack.api.command.admin.usage.ListUsageJobsCmd; import org.apache.cloudstack.api.command.admin.usage.ListUsageRecordsCmd; import org.apache.cloudstack.api.command.admin.usage.RemoveRawUsageRecordsCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.UsageJobResponse; import java.util.List; import java.util.TimeZone; @@ -61,4 +64,6 @@ public interface UsageService { TimeZone getUsageTimezone(); boolean removeRawUsageRecords(RemoveRawUsageRecordsCmd cmd); + + ListResponse getUsageJobs(ListUsageJobsCmd cmd); } diff --git a/core/src/main/java/com/cloud/agent/api/CancelCommand.java b/core/src/main/java/com/cloud/agent/api/CancelCommand.java index 5adace2d334e..ce63e4bce403 100644 --- a/core/src/main/java/com/cloud/agent/api/CancelCommand.java +++ b/core/src/main/java/com/cloud/agent/api/CancelCommand.java @@ -19,16 +19,23 @@ package com.cloud.agent.api; +/** Asks the executor of a request sequence to stop it; between management servers as a control request, to an agent as an ordinary command (checkOnly = just ask). */ public class CancelCommand extends Command { protected long sequence; protected String reason; + protected boolean checkOnly; protected CancelCommand() { } public CancelCommand(long sequence, String reason) { + this(sequence, reason, false); + } + + public CancelCommand(long sequence, String reason, boolean checkOnly) { this.sequence = sequence; this.reason = reason; + this.checkOnly = checkOnly; } public long getSequence() { @@ -39,6 +46,10 @@ public String getReason() { return reason; } + public boolean isCheckOnly() { + return checkOnly; + } + @Override public boolean executeInSequence() { return false; diff --git a/core/src/main/java/com/cloud/agent/api/CancelJobCommand.java b/core/src/main/java/com/cloud/agent/api/CancelJobCommand.java new file mode 100644 index 000000000000..288fc66a5f06 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/CancelJobCommand.java @@ -0,0 +1,52 @@ +// 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; + +/** Between management servers: asks the one running a job's work whether that work can be stopped (checkOnly), or to stop it. */ +public class CancelJobCommand extends Command { + protected long jobId; + protected String reason; + protected boolean checkOnly; + + protected CancelJobCommand() { + } + + public CancelJobCommand(long jobId, String reason, boolean checkOnly) { + this.jobId = jobId; + this.reason = reason; + this.checkOnly = checkOnly; + } + + public long getJobId() { + return jobId; + } + + public String getReason() { + return reason; + } + + public boolean isCheckOnly() { + return checkOnly; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/transport/Request.java b/core/src/main/java/com/cloud/agent/transport/Request.java index 3769dbbd612c..199a7e8b9865 100644 --- a/core/src/main/java/com/cloud/agent/transport/Request.java +++ b/core/src/main/java/com/cloud/agent/transport/Request.java @@ -116,6 +116,8 @@ public static Version get(final byte ver) throws UnsupportedVersionException { protected String _content; protected String _agentName; + private volatile boolean _cancelled = false; + protected Request() { } @@ -262,6 +264,14 @@ public Command[] getCommands() { return _cmds; } + public void cancel() { + _cancelled = true; + } + + public boolean isCancelled() { + return _cancelled; + } + protected String getType() { return "Cmd "; } diff --git a/core/src/main/java/com/cloud/resource/RequestExecutionContext.java b/core/src/main/java/com/cloud/resource/RequestExecutionContext.java new file mode 100644 index 000000000000..0f0cc4d442c4 --- /dev/null +++ b/core/src/main/java/com/cloud/resource/RequestExecutionContext.java @@ -0,0 +1,38 @@ +// 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.resource; + +/** The agent request sequence the current thread executes a command for; set by the direct-agent attache so resources can key backend work by it. */ +public final class RequestExecutionContext { + + private static final ThreadLocal REQUEST_SEQUENCE = new ThreadLocal<>(); + + private RequestExecutionContext() { + } + + public static void setRequestSequence(final long sequence) { + REQUEST_SEQUENCE.set(sequence); + } + + public static Long getRequestSequence() { + return REQUEST_SEQUENCE.get(); + } + + public static void clear() { + REQUEST_SEQUENCE.remove(); + } +} diff --git a/core/src/main/java/com/cloud/resource/ServerResource.java b/core/src/main/java/com/cloud/resource/ServerResource.java index 23d200942a27..4b2c42d75563 100644 --- a/core/src/main/java/com/cloud/resource/ServerResource.java +++ b/core/src/main/java/com/cloud/resource/ServerResource.java @@ -92,4 +92,14 @@ default boolean isAppendAgentNameToLogs() { } default void processPingAnswer(PingAnswer answer) {}; + + /** Null when the request's work can be stopped, otherwise what the hypervisor reports. Defaults to a refusal: a resource that has not implemented cancellation must not claim it. */ + default String checkRequestSequenceIsNotCancellable(long sequence) { + return "this host does not support cancelling a running command"; + } + + /** Returns true only when the backend operation was actually stopped. */ + default boolean cancelRequestSequence(long sequence) { + return false; + } } diff --git a/core/src/test/java/org/apache/cloudstack/api/agent/test/CancelCommandTest.java b/core/src/test/java/org/apache/cloudstack/api/agent/test/CancelCommandTest.java index 65fc5bc9e069..2aad6ee56427 100644 --- a/core/src/test/java/org/apache/cloudstack/api/agent/test/CancelCommandTest.java +++ b/core/src/test/java/org/apache/cloudstack/api/agent/test/CancelCommandTest.java @@ -41,6 +41,12 @@ public void testGetReason() { assertTrue(r.equals("goodreason")); } + @Test + public void testCheckOnlyDefaultsToFalse() { + assertFalse(cc.isCheckOnly()); + assertTrue(new CancelCommand(1L, "probe", true).isCheckOnly()); + } + @Test public void testExecuteInSequence() { boolean b = cc.executeInSequence(); diff --git a/engine/components-api/src/main/java/com/cloud/agent/AgentManager.java b/engine/components-api/src/main/java/com/cloud/agent/AgentManager.java index 4d63fae33560..ce776f836f9d 100644 --- a/engine/components-api/src/main/java/com/cloud/agent/AgentManager.java +++ b/engine/components-api/src/main/java/com/cloud/agent/AgentManager.java @@ -33,10 +33,12 @@ import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.resource.ServerResource; +import org.apache.cloudstack.jobs.JobCancellationHandler; + /** * AgentManager manages hosts. It directly coordinates between the DAOs and the connections it manages. */ -public interface AgentManager { +public interface AgentManager extends JobCancellationHandler { ConfigKey Wait = new ConfigKey("Advanced", Integer.class, "wait", "1800", "Time in seconds to wait for control commands to return", true); ConfigKey EnableKVMAutoEnableDisable = new ConfigKey<>(Boolean.class, @@ -178,4 +180,10 @@ enum TapAgentsAction { boolean transferDirectAgentsFromMS(String fromMsUuid, long fromMsId, long timeoutDurationInMs, boolean excludeHostsInMaintenance); int getHostSshPort(HostVO host); + + Long getAsyncJobId(); + + /** Answered from an in-memory view kept by the cancelled-jobs poller. */ + boolean isJobCancelled(Long jobId); + } diff --git a/engine/components-api/src/main/java/com/cloud/vm/VmWorkJobHandlerProxy.java b/engine/components-api/src/main/java/com/cloud/vm/VmWorkJobHandlerProxy.java index ef1c71e6b014..b7f6144a61f9 100644 --- a/engine/components-api/src/main/java/com/cloud/vm/VmWorkJobHandlerProxy.java +++ b/engine/components-api/src/main/java/com/cloud/vm/VmWorkJobHandlerProxy.java @@ -107,7 +107,7 @@ public Pair handleVmWorkJob(VmWork work) throws Exceptio assert (obj instanceof Pair); return (Pair)obj; } catch (InvocationTargetException e) { - logger.error("Invocation exception, caused by: " + e.getCause()); + logger.warn("Invocation exception, caused by: " + e.getCause()); // legacy CloudStack code relies on checked exception for error handling // we need to re-throw the real exception here diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentAttache.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentAttache.java index 402bd2b6b9b9..a8671751bff4 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentAttache.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentAttache.java @@ -33,6 +33,7 @@ import java.util.concurrent.TimeUnit; import com.cloud.agent.api.CleanupPersistentNetworkResourceCommand; +import com.cloud.exception.OperationCancelledException; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.utils.Pair; import com.cloud.utils.exception.CloudRuntimeException; @@ -45,6 +46,7 @@ import com.cloud.agent.Listener; import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelCommand; import com.cloud.agent.api.CheckHealthCommand; import com.cloud.agent.api.CheckNetworkCommand; import com.cloud.agent.api.CheckOnHostCommand; @@ -128,6 +130,8 @@ public int compare(final Object o1, final Object o2) { protected long _nextSequence; protected AgentManagerImpl _agentMgr; + // cancelled because the job was cancelled, as opposed to timed out + private final Map _cancelledSequences = new ConcurrentHashMap<>(); public final static String[] s_commandsAllowedInMaintenanceMode = new String[] { MaintainCommand.class.toString(), MigrateCommand.class.toString(), StopCommand.class.toString(), CheckVirtualMachineCommand.class.toString(), PingTestCommand.class.toString(), CheckHealthCommand.class.toString(), @@ -231,6 +235,70 @@ protected synchronized void cancel(final long seq) { } } + /** True once no sender waits for the request's answer: it has completed, and there is nothing of it left to stop. */ + protected boolean isExecutionFinished(final long seq) { + return !_waitForList.containsKey(seq); + } + + /** Null when the request's work can be stopped, otherwise why not. Defaults to a refusal: an attache that cannot ask its resource must not claim it. */ + protected String checkExecutionIsNotCancellable(final long seq) { + return "this host does not support cancelling a running command"; + } + + /** Returns true only when the resource's work was stopped (or nothing was left to stop). */ + protected boolean cancelRunning(final long seq) { + return false; + } + + /** Timeout path: best effort, the sender still reports the timeout; a request that cannot be stopped runs to completion as before. */ + boolean cancelRunningOnTimeout(final long seq, final Request req) { + if (_agentMgr == null) { + return false; + } + if (req != null && req.getCommands() != null) { + for (final Command cmd : req.getCommands()) { + if (cmd instanceof CancelCommand) { + return false; + } + } + } + try { + final String reason = checkExecutionIsNotCancellable(seq); + if (reason != null) { + logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Timed out; its hypervisor work is left to complete: " + reason); + return false; + } + final boolean stopped = cancelRunning(seq); + logger.info(LOG_SEQ_FORMATTED_STRING, seq, stopped ? "Timed out; its hypervisor work was stopped" : "Timed out; its hypervisor work could not be stopped and is left to complete"); + return stopped; + } catch (final Exception e) { + logger.warn(LOG_SEQ_FORMATTED_STRING, seq, "Timed out; failed to stop its hypervisor work: " + e.getMessage()); + return false; + } + } + + /** Job-cancel path; distinct from cancel(seq), which only drops the request on this side. */ + public boolean cancelExecution(final long seq) { + return cancelExecution(seq, null); + } + + public boolean cancelExecution(final long seq, final String reason) { + _cancelledSequences.put(seq, reason != null ? reason : "cancelled"); + if (!cancelRunning(seq)) { + // refused: the command runs on and its sender must see the real answer + _cancelledSequences.remove(seq); + return false; + } + cancel(seq); + return true; + } + + /** The job's reason when the cancel came through cancelExecution; otherwise what the layer that noticed it says. */ + private OperationCancelledException cancelled(final Request req, final long started, final boolean isActive, final String fallbackReason) { + final String reason = _cancelledSequences.getOrDefault(req.getSequence(), fallbackReason); + return new OperationCancelledException(req.getCommands(), _id, req.getSequence(), (System.currentTimeMillis() - started) / 1000, isActive, reason); + } + protected synchronized int findRequest(final Request req) { return Collections.binarySearch(_requests, req, s_reqComparator); } @@ -411,13 +479,16 @@ public void send(final Request req, final Listener listener) throws AgentUnavail public Answer[] send(final Request req, final int wait) throws AgentUnavailableException, OperationTimedoutException { SynchronousListener sl = new SynchronousListener(null); - long seq = req.getSequence(); + final long started = System.currentTimeMillis(); send(req, sl); try { for (int i = 0; i < 2; i++) { Answer[] answers = null; + if (_cancelledSequences.containsKey(seq)) { + throw cancelled(req, started, false, null); + } Command[] cmds = req.getCommands(); if (cmds != null && cmds.length == 1 && (cmds[0] != null) && cmds[0].isReconcile() && !sl.isDisconnected() && _agentMgr.isReconcileCommandsEnabled(_hypervisorType)) { @@ -427,14 +498,28 @@ public Answer[] send(final Request req, final int wait) throws AgentUnavailableE try { answers = sl.waitFor(wait); } catch (final InterruptedException e) { - logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Interrupted"); + logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Interrupted while waiting for the answer"); + Thread.currentThread().interrupt(); + if (_cancelledSequences.containsKey(seq)) { + throw cancelled(req, started, true, "cancelled while waiting for the answer"); + } } } if (answers != null) { + for (Answer answer : answers) { + if (answer != null && answer.isCancelled()) { + throw cancelled(req, started, true, answer.getDetails()); + } + } + new Response(req, answers).logD("Received: ", false); return answers; } + if (_cancelledSequences.containsKey(seq)) { + throw cancelled(req, started, true, "cancelled while waiting for the answer"); + } + answers = sl.getAnswers(); // Try it again. if (answers != null) { new Response(req, answers).logD("Received after timeout: ", true); @@ -457,6 +542,15 @@ public Answer[] send(final Request req, final int wait) throws AgentUnavailableE throw new OperationTimedoutException(req.getCommands(), _id, seq, wait * 2, true); } catch (OperationTimedoutException e) { logger.warn(LOG_SEQ_FORMATTED_STRING, seq, "Timed out on " + req.toString()); + cancelRunningOnTimeout(seq, req); + cancel(seq); + final Long current = _currentSequence; + if (req.executeInSequence() && (current != null && current == seq)) { + sendNext(seq); + } + throw e; + } catch (OperationCancelledException e) { + logger.warn(LOG_SEQ_FORMATTED_STRING, seq, "Cancelled: " + req.toString()); cancel(seq); final Long current = _currentSequence; if (req.executeInSequence() && (current != null && current == seq)) { @@ -473,6 +567,7 @@ public Answer[] send(final Request req, final int wait) throws AgentUnavailableE _agentMgr.updateReconcileCommandsIfNeeded(req.getSequence(), req.getCommands(), Command.State.TIMED_OUT); throw new OperationTimedoutException(req.getCommands(), _id, seq, wait, false); } finally { + _cancelledSequences.remove(seq); unregisterListener(seq); } } @@ -581,6 +676,7 @@ protected void runInContext() { try { Listener listener = unregisterListener(_seq); if (listener != null) { + cancelRunningOnTimeout(_seq, null); cancel(_seq); listener.processTimeout(_id, _seq); } diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java index 9eab15214aeb..a5edd1bbda10 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java @@ -37,11 +37,13 @@ import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import javax.inject.Inject; import javax.naming.ConfigurationException; +import com.cloud.exception.OperationCancelledException; import com.cloud.utils.StringUtils; import org.apache.cloudstack.agent.lb.IndirectAgentLB; import org.apache.cloudstack.ca.CAManager; @@ -55,6 +57,9 @@ import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.cloudstack.framework.jobs.AsyncJobExecutionContext; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; +import org.apache.cloudstack.jobs.JobInfo; import org.apache.cloudstack.maintenance.ManagementServerMaintenanceListener; import org.apache.cloudstack.maintenance.ManagementServerMaintenanceManager; import org.apache.cloudstack.managed.context.ManagedContextRunnable; @@ -62,6 +67,7 @@ import org.apache.cloudstack.outofbandmanagement.dao.OutOfBandManagementDao; import org.apache.cloudstack.utils.identity.ManagementServerNode; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; +import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; @@ -143,6 +149,8 @@ import com.cloud.utils.nio.Task; import com.cloud.utils.time.InaccurateClock; +import static org.apache.cloudstack.jobs.AsyncJobService.CancelledJobInterval; + /** * Implementation of the Agent Manager. This class controls the connection to the agents. **/ @@ -159,6 +167,21 @@ public class AgentManagerImpl extends ManagerBase implements AgentManager, Handl protected List _loadingAgents = new ArrayList<>(); protected Map _commandTimeouts = new HashMap<>(); private int _monitorId = 0; + // a job can have several commands in flight on several hosts + protected final Map>> _jobToHostIdAndReqSequenceMap = new ConcurrentHashMap<>(); + // sticky on purpose: workers consult it after the job row has been finalised + protected final Map _cancelledJobs = new ConcurrentHashMap<>(); + + protected static final class CancelledJob { + final long since = System.currentTimeMillis(); + volatile boolean handedToWorker; + // a refused attempt is retried, but not on every check and not while another thread is at it + volatile long retryAfter; + volatile int refusals; + final AtomicBoolean attempting = new AtomicBoolean(); + } + private static final long CANCELLED_JOB_MEMORY_MS = TimeUnit.HOURS.toMillis(1); + protected static final long CANCEL_RETRY_MS = TimeUnit.SECONDS.toMillis(10); @Inject protected CAManager caService; @@ -182,6 +205,8 @@ public class AgentManagerImpl extends ManagerBase implements AgentManager, Handl protected ConfigurationDao _configDao = null; @Inject protected ClusterDao _clusterDao = null; + @Inject + protected AsyncJobManager asyncJobManager = null; @Inject protected HighAvailabilityManager _haMgr = null; @@ -210,6 +235,7 @@ public class AgentManagerImpl extends ManagerBase implements AgentManager, Handl protected ScheduledExecutorService _directAgentExecutor; protected ScheduledExecutorService _cronJobExecutor; protected ScheduledExecutorService _monitorExecutor; + protected ScheduledExecutorService _cancelledJobsCheckExecutor; private int _directAgentThreadCap; @@ -390,6 +416,7 @@ public void onManagementServerCancelPreparingForMaintenance() { public void onManagementServerMaintenance() { logger.debug("Management server maintenance enabled"); _monitorExecutor.shutdownNow(); + _cancelledJobsCheckExecutor.shutdownNow(); newAgentConnectionsMonitor.shutdownNow(); if (_connection != null) { _connection.stop(); @@ -421,6 +448,9 @@ public void onManagementServerCancelMaintenance() { if (_monitorExecutor.isShutdown()) { initAndScheduleMonitorExecutor(); } + if (_cancelledJobsCheckExecutor.isShutdown()) { + initAndScheduleCancelJobExecutor(); + } if (newAgentConnectionsMonitor.isShutdown()) { initAndScheduleAgentConnectionsMonitor(); } @@ -437,6 +467,11 @@ private void initAndScheduleMonitorExecutor() { _monitorExecutor.scheduleWithFixedDelay(new MonitorTask(), mgmtServiceConf.getPingInterval(), mgmtServiceConf.getPingInterval(), TimeUnit.SECONDS); } + private void initAndScheduleCancelJobExecutor() { + _cancelledJobsCheckExecutor = new ScheduledThreadPoolExecutor(1, new NamedThreadFactory("CancelledJobsCheck")); + _cancelledJobsCheckExecutor.scheduleWithFixedDelay(new CancelledJobsCheckTask(), CancelledJobInterval.value(), CancelledJobInterval.value(), TimeUnit.SECONDS); + } + private void initAndScheduleAgentConnectionsMonitor() { final int cleanupTimeInSecs = Wait.value(); newAgentConnectionsMonitor = Executors.newScheduledThreadPool(1, new NamedThreadFactory("NewAgentConnectionsMonitor")); @@ -494,6 +529,8 @@ public Answer sendTo(final Long dcId, final HypervisorType type, final Command c try { final long targetHostId = _hvGuruMgr.getGuruProcessedCommandTargetHost(host.getId(), cmd, host.getHypervisorType()); answer = easySend(targetHostId, cmd); + } catch (final OperationCancelledException e) { + throw e; } catch (final Exception e) { String errorMsg = String.format("Error sending command %s to host %s, due to %s", cmd.getClass().getName(), host, e.getLocalizedMessage()); @@ -637,6 +674,12 @@ public Answer[] send(final Long hostId, final Commands commands, int timeout) th throw new AgentUnavailableException(-1); } + final Long jobId = getAsyncJobId(); + if (refusesCommandsOf(jobId)) { + logger.info("Not sending commands {} to host {}: job-{} was cancelled", commands, hostId, jobId); + throw OperationCancelledException.updateCmdsNotSent(commands.toCommands(), hostId, jobId); + } + int wait = getTimeout(commands, timeout); logger.debug("Wait time setting on {} is {} seconds", commands, wait); for (Command cmd : commands) { @@ -666,16 +709,29 @@ public Answer[] send(final Long hostId, final Commands commands, int timeout) th final Request req = new Request(hostId, agent.getName(), _nodeId, cmds, commands.stopOnError(), true); req.setSequence(agent.getNextSequence()); + final Pair inFlight = new Pair<>(hostId, req.getSequence()); + if (jobId != null) { + _jobToHostIdAndReqSequenceMap.computeIfAbsent(jobId, k -> ConcurrentHashMap.newKeySet()).add(inFlight); + } - reconcileCommandService.persistReconcileCommands(hostId, req.getSequence(), cmds); + try { + reconcileCommandService.persistReconcileCommands(hostId, req.getSequence(), cmds); - final Answer[] answers = agent.send(req, wait); + final Answer[] answers = agent.send(req, wait); - reconcileCommandService.processAnswers(req.getSequence(), cmds, answers); + reconcileCommandService.processAnswers(req.getSequence(), cmds, answers); - notifyAnswersToMonitors(hostId, req.getSequence(), answers); - commands.setAnswers(answers); - return answers; + notifyAnswersToMonitors(hostId, req.getSequence(), answers); + commands.setAnswers(answers); + return answers; + } finally { + if (jobId != null) { + _jobToHostIdAndReqSequenceMap.computeIfPresent(jobId, (k, v) -> { + v.remove(inFlight); + return v.isEmpty() ? null : v; + }); + } + } } protected Status investigate(final AgentAttache agent) { @@ -962,6 +1018,7 @@ public boolean start() { ManagementServerHostVO msHost = _mshostDao.findByMsid(_nodeId); if (msHost != null && (ManagementServerHost.State.Maintenance.equals(msHost.getState()) || ManagementServerHost.State.PreparingForMaintenance.equals(msHost.getState()))) { _monitorExecutor.shutdownNow(); + _cancelledJobsCheckExecutor.shutdownNow(); newAgentConnectionsMonitor.shutdownNow(); return true; } @@ -978,6 +1035,7 @@ public boolean start() { } initAndScheduleMonitorExecutor(); + initAndScheduleCancelJobExecutor(); initAndScheduleAgentConnectionsMonitor(); return true; } @@ -1140,6 +1198,7 @@ public boolean stop() { _connectExecutor.shutdownNow(); _monitorExecutor.shutdownNow(); + _cancelledJobsCheckExecutor.shutdownNow(); newAgentConnectionsMonitor.shutdownNow(); return true; } @@ -1361,6 +1420,9 @@ public Answer easySend(final Long hostId, final Command cmd) { } catch (final OperationTimedoutException e) { logger.warn("Operation timed out: {}", e.getMessage()); return null; + } catch (final OperationCancelledException e) { + logger.info(e.getMessage()); + throw e; } catch (final Exception e) { logger.warn("Exception while sending", e); return null; @@ -2135,6 +2197,196 @@ protected List findAgentsBehindOnPing() { } } + /** Stops in-flight work of jobs cancelled on this management server and acknowledges them. */ + protected class CancelledJobsCheckTask extends ManagedContextRunnable { + @Override + protected void runInContext() { + try { + final long now = System.currentTimeMillis(); + _cancelledJobs.values().removeIf(cancelled -> now - cancelled.since > CANCELLED_JOB_MEMORY_MS); + + for (final AsyncJobVO job : asyncJobManager.listCancelRequestedJobsExecutingOn(_nodeId)) { + // a work job's commands are keyed by the API job it runs for + final long key = commandKeyOf(job); + _cancelledJobs.computeIfAbsent(key, id -> new CancelledJob()); + if (_jobToHostIdAndReqSequenceMap.containsKey(key) && !stopInFlightCommandsOfJob(key, now)) { + continue; + } + if (key == job.getId()) { + asyncJobManager.finalizeCancelledJob(job.getId()); + } + } + + // commands in flight here for a job cancelled on another server, keyed by that job + for (final Long jobId : _jobToHostIdAndReqSequenceMap.keySet()) { + final CancelledJob known = _cancelledJobs.get(jobId); + if (known != null && (known.handedToWorker || now < known.retryAfter)) { + continue; + } + final AsyncJobVO job = asyncJobManager.getAsyncJob(jobId); + if (job != null && (job.isCancelRequested() || job.getStatus() == JobInfo.Status.CANCELLED)) { + stopInFlightCommandsOfJob(jobId, now); + } + } + } catch (final Throwable e) { + logger.error("Unexpected exception in the cancelled jobs check task", e); + } + } + } + + /** One attempt per retry interval, and none while another thread (the cancel API) is already stopping the job's commands. */ + protected boolean stopInFlightCommandsOfJob(final long jobId, final long now) { + final CancelledJob cancelled = _cancelledJobs.computeIfAbsent(jobId, id -> new CancelledJob()); + if (cancelled.handedToWorker) { + return true; + } + if (cancelled.attempting.get() || now < cancelled.retryAfter) { + return false; + } + logger.info("Cancellation of job-{} was requested, stopping its in-flight commands", jobId); + if (cancelJobExecutionHere(jobId, "Job was cancelled")) { + return true; + } + cancelled.retryAfter = now + CANCEL_RETRY_MS; + if (cancelled.refusals++ == 0) { + logger.warn("Not every in-flight command of cancelled job-{} could be stopped; retrying every {} s", jobId, CANCEL_RETRY_MS / 1000); + } else { + logger.debug("Not every in-flight command of cancelled job-{} could be stopped; retry {}", jobId, cancelled.refusals); + } + return false; + } + + @Override + public boolean isJobCancelled(final Long jobId) { + return jobId != null && _cancelledJobs.containsKey(jobId); + } + + /** True once per job across the cluster: the worker that has not yet seen its cancellation is stopped at this command; the commands it then sends to undo its work pass. */ + protected boolean refusesCommandsOf(final Long jobId) { + final CancelledJob cancelled = jobId == null ? null : _cancelledJobs.get(jobId); + if (cancelled == null || cancelled.handedToWorker) { + return false; + } + if (asyncJobManager.claimJobCancelDelivery(jobId)) { + cancelled.handedToWorker = true; + return true; + } + if (asyncJobManager.getCancellationReason(jobId) == null) { + // withdrawn: the job runs on + _cancelledJobs.remove(jobId); + } else { + // delivered through a command of the job on another server + cancelled.handedToWorker = true; + } + return false; + } + + static long commandKeyOf(final AsyncJobVO job) { + if (StringUtils.isNotBlank(job.getRelated())) { + try { + return Long.parseLong(job.getRelated()); + } catch (final NumberFormatException ignored) { + return job.getId(); + } + } + return job.getId(); + } + + @Override + public void jobCancelWithdrawn(final long jobId) { + _cancelledJobs.remove(jobId); + } + + @Override + public String checkJobCannotBeCancelled(final long jobId) { + final Set> inFlight = _jobToHostIdAndReqSequenceMap.get(jobId); + if (CollectionUtils.isEmpty(inFlight)) { + return null; + } + + for (final Pair request : inFlight) { + final AgentAttache attache = findAttache(request.first()); + if (attache == null) { + logger.debug("Agent {} for job-{} is no longer attached, treating sequence {} as not cancellable", + request.first(), jobId, request.second()); + return "host " + request.first() + " running its command is no longer connected"; + } + if (attache.isExecutionFinished(request.second())) { + // answered, its sender is just about to drop it from the map + continue; + } + final String reason = attache.checkExecutionIsNotCancellable(request.second()); + if (reason != null) { + logger.info("Job-{} cannot be cancelled, sequence {} on host {}: {}", jobId, request.second(), attache.getName(), reason); + return reason + " (host " + attache.getName() + ")"; + } + } + return null; + } + + @Override + public boolean cancelJobExecution(final long jobId, final String reason) { + return cancelJobExecutionHere(jobId, reason); + } + + /** This server's part of the job: what the clustered manager asks of each peer. */ + protected boolean cancelJobExecutionHere(final long jobId, final String reason) { + // mark first, so a worker that carries on to its next command is refused there + final CancelledJob cancelled = _cancelledJobs.computeIfAbsent(jobId, id -> new CancelledJob()); + final Set> inFlight = _jobToHostIdAndReqSequenceMap.get(jobId); + if (CollectionUtils.isEmpty(inFlight)) { + return true; + } + if (cancelled.handedToWorker || asyncJobManager.isJobCancelDelivered(jobId)) { + // the worker already knows: what it has in flight now is its cleanup + cancelled.handedToWorker = true; + logger.debug("Job-{} already received its cancellation, leaving its commands in flight alone", jobId); + return true; + } + + cancelled.attempting.set(true); + try { + boolean allCancelled = true; + for (final Pair request : inFlight) { + final AgentAttache attache = findAttache(request.first()); + if (attache == null) { + logger.debug("Agent {} for job-{} is no longer attached, cannot cancel sequence {}", + request.first(), jobId, request.second()); + allCancelled = false; + continue; + } + if (attache.isExecutionFinished(request.second())) { + continue; + } + final String refusal = attache.checkExecutionIsNotCancellable(request.second()); + if (refusal != null) { + logger.info("Not cancelling sequence {} on host {} for job-{}, letting it run to completion: {}", + request.second(), request.first(), jobId, refusal); + allCancelled = false; + continue; + } + logger.debug("Cancelling sequence {} on host {} for job-{} ({})", request.second(), request.first(), jobId, reason); + if (attache.cancelExecution(request.second(), reason)) { + cancelled.handedToWorker = true; + asyncJobManager.claimJobCancelDelivery(jobId); + } else { + allCancelled = false; + } + } + return allCancelled; + } finally { + cancelled.attempting.set(false); + } + } + + private AgentAttache findAttache(final Long hostId) { + try { + return getAttache(hostId); + } catch (final AgentUnavailableException e) { + return null; + } + } + protected class AgentNewConnectionsMonitorTask extends ManagedContextRunnable { @Override protected void runInContext() { @@ -2377,6 +2629,21 @@ public int getHostSshPort(HostVO host) { return Integer.parseInt(hostPort); } + @Override + public Long getAsyncJobId() { + Long jobId = null; + final AsyncJobExecutionContext context = AsyncJobExecutionContext.getCurrent(); + if (context != null && context.getJob() != null) { + AsyncJob job = context.getJob(); + if (StringUtils.isNotEmpty(job.getRelated())) { + jobId = Long.parseLong(job.getRelated()); + } else { + jobId = job.getId(); + } + } + return jobId; + } + private GlobalLock getHostJoinLock(Long hostId) { return GlobalLock.getInternLock(String.format("%s-%s", "Host-Join", hostId)); } diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java index 38a198b73040..c4c3e8b5c950 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java @@ -67,6 +67,7 @@ import com.cloud.agent.api.Answer; import com.cloud.agent.api.CancelCommand; +import com.cloud.agent.api.CancelJobCommand; import com.cloud.agent.api.ChangeAgentAnswer; import com.cloud.agent.api.ChangeAgentCommand; import com.cloud.agent.api.Command; @@ -921,6 +922,66 @@ private Answer[] sendRebalanceCommand(final long peer, final long agentId, final } } + /** A job's work may run on another server (its API thread, or a work job taken from the queue there): that server is asked too, so the answer is the same whichever server the cancel reaches. */ + @Override + public String checkJobCannotBeCancelled(final long jobId) { + final String here = checkJobCannotBeCancelledHere(jobId); + if (here != null) { + return here; + } + for (final String peer : peersExecuting(jobId)) { + final Answer answer = askPeerAboutJob(peer, new CancelJobCommand(jobId, null, true)); + if (answer == null) { + return "management server " + peer + " running its work could not be reached"; + } + if (!answer.getResult()) { + return answer.getDetails(); + } + } + return null; + } + + @Override + public boolean cancelJobExecution(final long jobId, final String reason) { + boolean all = cancelJobExecutionHere(jobId, reason); + for (final String peer : peersExecuting(jobId)) { + final Answer answer = askPeerAboutJob(peer, new CancelJobCommand(jobId, reason, false)); + if (answer == null || !answer.getResult()) { + logger.info("Management server {} could not stop the work of job-{}: {}", peer, jobId, answer != null ? answer.getDetails() : "not reachable"); + all = false; + } + } + return all; + } + + protected String checkJobCannotBeCancelledHere(final long jobId) { + return super.checkJobCannotBeCancelled(jobId); + } + + protected Set peersExecuting(final long jobId) { + final Set peers = new HashSet<>(); + for (final Long msid : asyncJobManager.listMgmtServersExecutingJob(jobId)) { + if (msid != null && msid != _nodeId) { + peers.add(Long.toString(msid)); + } + } + return peers; + } + + protected Answer askPeerAboutJob(final String peer, final CancelJobCommand cmd) { + try { + final String ansStr = _clusterMgr.execute(peer, 0, _gson.toJson(new Command[] {cmd}), true); + if (ansStr == null) { + return null; + } + final Answer[] answers = _gson.fromJson(ansStr, Answer[].class); + return answers != null && answers.length > 0 ? answers[0] : null; + } catch (final Exception e) { + logger.warn("Unable to ask management server {} about job-{}", peer, cmd.getJobId(), e); + return null; + } + } + public String getPeerName(final long agentHostId) { final HostVO host = _hostDao.findById(agentHostId); @@ -1325,6 +1386,17 @@ public String dispatch(final ClusterServicePdu pdu) { final Answer[] answers = new Answer[1]; answers[0] = new Answer(cmd, result, null); return _gson.toJson(answers); + } else if (cmds.length == 1 && cmds[0] instanceof CancelJobCommand) { + final CancelJobCommand cmd = (CancelJobCommand) cmds[0]; + final Answer[] answers = new Answer[1]; + if (cmd.isCheckOnly()) { + final String refusal = checkJobCannotBeCancelledHere(cmd.getJobId()); + answers[0] = new Answer(cmd, refusal == null, refusal); + } else { + final boolean stopped = cancelJobExecutionHere(cmd.getJobId(), cmd.getReason()); + answers[0] = new Answer(cmd, stopped, stopped ? null : "not every in-flight command of job-" + cmd.getJobId() + " on management server " + _nodeId + " could be stopped"); + } + return _gson.toJson(answers); } else if (cmds.length == 1 && cmds[0] instanceof ScheduleHostScanTaskCommand) { final ScheduleHostScanTaskCommand cmd = (ScheduleHostScanTaskCommand) cmds[0]; return handleScheduleHostScanTaskCommand(cmd); diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredDirectAgentAttache.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredDirectAgentAttache.java index 5e796d8e0d86..e55a43afb573 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredDirectAgentAttache.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredDirectAgentAttache.java @@ -16,6 +16,9 @@ // under the License. package com.cloud.agent.manager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelCommand; +import com.cloud.agent.api.Command; import com.cloud.agent.transport.Request; import com.cloud.agent.transport.Response; import com.cloud.exception.AgentUnavailableException; @@ -45,11 +48,32 @@ public void routeToAgent(byte[] data) throws AgentUnavailableException { if (req instanceof Response) { super.process(((Response)req).getAnswers()); + } else if (isCancelRequest(req)) { + // the resource cannot answer this; the task registry behind this attache can + final CancelCommand cancel = (CancelCommand) req.getCommands()[0]; + final Answer answer = answerCancelRequest(cancel); + processAnswers(req.getSequence(), new Response(req, answer, req.getManagementServerId(), req.getAgentId())); } else { super.send(req); } } + private static boolean isCancelRequest(final Request req) { + final Command[] cmds = req.getCommands(); + return cmds != null && cmds.length == 1 && cmds[0] instanceof CancelCommand; + } + + private Answer answerCancelRequest(final CancelCommand cancel) { + final long seq = cancel.getSequence(); + if (cancel.isCheckOnly()) { + final String reason = checkExecutionIsNotCancellable(seq); + return new Answer(cancel, reason == null, reason == null ? "request sequence " + seq + " can be cancelled" : reason); + } + final boolean cancelled = cancelExecution(seq, cancel.getReason()); + return new Answer(cancel, cancelled, cancelled ? "request sequence " + seq + " cancelled (" + cancel.getReason() + ")" + : "request sequence " + seq + " could not be cancelled on this host"); + } + @Override public boolean processAnswers(long seq, Response response) { long mgmtId = response.getManagementServerId(); diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/ConnectedAgentAttache.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/ConnectedAgentAttache.java index f4efaaa34a42..30f41b1e9fad 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/ConnectedAgentAttache.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/ConnectedAgentAttache.java @@ -19,8 +19,12 @@ import java.nio.channels.ClosedChannelException; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelCommand; +import com.cloud.agent.api.UnsupportedAnswer; import com.cloud.agent.transport.Request; import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationTimedoutException; import com.cloud.host.Status; import com.cloud.hypervisor.Hypervisor; import com.cloud.utils.nio.Link; @@ -31,6 +35,7 @@ public class ConnectedAgentAttache extends AgentAttache { protected Link _link; + private static final int CANCEL_COMMAND_WAIT_SECONDS = 60; public ConnectedAgentAttache(final AgentManagerImpl agentMgr, final long id, final String uuid, final String name, final Hypervisor.HypervisorType hypervisorType, final Link link, final boolean maintenance) { super(agentMgr, id, uuid, name, hypervisorType, maintenance); @@ -51,6 +56,45 @@ public synchronized boolean isClosed() { return _link == null; } + /** Only the agent knows whether its backend work can be stopped; an agent that does not understand the question answers unsupported. */ + @Override + protected String checkExecutionIsNotCancellable(final long seq) { + final Answer answer = requestAgentToCancel(seq, true); + if (answer == null) { + return "the agent on this host does not support cancelling a running command"; + } + return answer.getResult() ? null : answer.getDetails(); + } + + @Override + protected boolean cancelRunning(final long seq) { + final Answer answer = requestAgentToCancel(seq, false); + return answer != null && answer.getResult(); + } + + /** The agent's answer; null when it does not understand the question. */ + private Answer requestAgentToCancel(final long seq, final boolean checkOnly) { + if (_agentMgr == null) { + return null; + } + final CancelCommand cmd = new CancelCommand(seq, "Job cancelled", checkOnly); + cmd.setWait(CANCEL_COMMAND_WAIT_SECONDS); + try { + final Answer answer = _agentMgr.send(_id, cmd); + if (answer == null || answer instanceof UnsupportedAnswer) { + logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Agent does not support cancellation, treating as not cancellable"); + return null; + } + if (!answer.getResult()) { + logger.debug(LOG_SEQ_FORMATTED_STRING, seq, (checkOnly ? "Not cancellable: " : "Not cancelled: ") + answer.getDetails()); + } + return answer; + } catch (final AgentUnavailableException | OperationTimedoutException e) { + logger.warn(LOG_SEQ_FORMATTED_STRING, seq, "Unable to request the agent about cancellation: " + e.getMessage()); + return new Answer(cmd, false, "unable to request the agent on this host: " + e.getMessage()); + } + } + @Override public void disconnect(final Status state) { synchronized (this) { diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/DirectAgentAttache.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/DirectAgentAttache.java index 2edc9ad19bc3..37fc62eaec36 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/DirectAgentAttache.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/DirectAgentAttache.java @@ -17,8 +17,12 @@ package com.cloud.agent.manager; import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -37,6 +41,7 @@ import com.cloud.exception.AgentUnavailableException; import com.cloud.host.Status; import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.RequestExecutionContext; import com.cloud.resource.ServerResource; import org.apache.logging.log4j.ThreadContext; @@ -47,9 +52,11 @@ public class DirectAgentAttache extends AgentAttache { protected final ConfigKey _HostPingRetryTimer = new ConfigKey("Advanced", Integer.class, "host.ping.retry.timer", "5", "Interval to wait before retrying a host ping while waiting for check results", true); ServerResource _resource; - List> _futures = new ArrayList>(); + List> _futures = new ArrayList<>(); + private final Map> _taskFutures = new ConcurrentHashMap<>(); + private final Map _taskRequests = new ConcurrentHashMap<>(); long _seq = 0; - LinkedList tasks = new LinkedList(); + LinkedList tasks = new LinkedList<>(); AtomicInteger _outstandingTaskCount; AtomicInteger _outstandingCronTaskCount; @@ -68,12 +75,107 @@ public void disconnect(Status state) { future.cancel(false); } + synchronized (this) { + for (Task task : tasks) { + task._req.cancel(); + } + tasks.clear(); + } + + for (Request request : _taskRequests.values()) { + request.cancel(); + } + _taskRequests.clear(); + + for (Future future : _taskFutures.values()) { + boolean cancelled = future.cancel(false); + logger.debug("Running task {} for [id: {}, uuid: {}, name: {}]", cancelled ? "cancelled" : "not cancelled", _id, _uuid, _name); + } + _taskFutures.clear(); + synchronized (this) { if (_resource != null) { _resource.disconnected(); _resource = null; } } + + cleanup(state); + } + + private synchronized boolean isQueued(final long seq) { + for (Task task : tasks) { + if (task._req.getSequence() == seq) { + return true; + } + } + return false; + } + + @Override + protected String checkExecutionIsNotCancellable(final long seq) { + if (isQueued(seq)) { + return null; + } + + if (!_taskFutures.containsKey(seq)) { + return "request sequence " + seq + " is no longer running on this host"; + } + + final ServerResource resource = _resource; + if (resource == null) { + return "the host is not connected"; + } + // not under the attache lock: this call goes out to the hypervisor + return resource.checkRequestSequenceIsNotCancellable(seq); + } + + @Override + protected boolean cancelRunning(final long seq) { + if (removeQueuedTask(seq)) { + logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Cancelled queued task."); + return true; + } + + final Future future = _taskFutures.get(seq); + if (future == null) { + return true; + } + + final ServerResource resource = _resource; + final String reason = resource == null ? "the host is not connected" : resource.checkRequestSequenceIsNotCancellable(seq); + if (reason != null) { + logger.info(LOG_SEQ_FORMATTED_STRING, seq, "Cancellation requested but the command is left to run to completion: " + reason); + return false; + } + + logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Cancelling request sequence at the resource."); + if (!resource.cancelRequestSequence(seq)) { + logger.info(LOG_SEQ_FORMATTED_STRING, seq, "Resource could not cancel the command, letting it run to completion."); + return false; + } + + final Request request = _taskRequests.get(seq); + if (request != null) { + request.cancel(); + } + final boolean cancelled = future.cancel(true); + logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Running task " + (cancelled ? "cancelled" : "not cancelled")); + return true; + } + + private synchronized boolean removeQueuedTask(final long seq) { + final Iterator iterator = tasks.iterator(); + while (iterator.hasNext()) { + final Task task = iterator.next(); + if (task._req.getSequence() == seq) { + task._req.cancel(); + iterator.remove(); + _taskRequests.remove(seq); + return true; + } + } + return false; } @Override @@ -139,6 +241,7 @@ protected void finalize() throws Throwable { private synchronized void queueTask(Task task) { tasks.add(task); + _taskRequests.put(task._req.getSequence(), task._req); } private synchronized void scheduleFromQueue() { @@ -146,7 +249,13 @@ private synchronized void scheduleFromQueue() { _id, _uuid, _name, tasks.size(), _outstandingTaskCount.get()); while (!tasks.isEmpty() && _outstandingTaskCount.get() < _agentMgr.getDirectAgentThreadCap()) { _outstandingTaskCount.incrementAndGet(); - _agentMgr.getDirectAgentPool().execute(tasks.remove()); + Task task = tasks.remove(); + Future future = _agentMgr.getDirectAgentPool().submit(task); + _taskFutures.put(task._req.getSequence(), future); + if (future.isDone()) { + // the task may have finished (and cleaned up) before the future was recorded + _taskFutures.remove(task._req.getSequence()); + } } } @@ -227,6 +336,7 @@ private void bailout() { @Override protected void runInContext() { long seq = _req.getSequence(); + RequestExecutionContext.setRequestSequence(seq); try { if (_outstandingCronTaskCount.incrementAndGet() >= _agentMgr.getDirectAgentThreadCap()) { logger.warn( @@ -278,6 +388,7 @@ protected void runInContext() { } catch (Exception e) { logger.warn(LOG_SEQ_FORMATTED_STRING, seq, "Exception caught ", e); } finally { + RequestExecutionContext.clear(); _outstandingCronTaskCount.decrementAndGet(); } } @@ -293,14 +404,26 @@ public Task(Request req) { @Override protected void runInContext() { long seq = _req.getSequence(); + RequestExecutionContext.setRequestSequence(seq); try { - ServerResource resource = _resource; Command[] cmds = _req.getCommands(); + if (Thread.currentThread().isInterrupted() || _req.isCancelled()) { + throw new InterruptedException("Task execution cancelled"); + } + + ServerResource resource = _resource; boolean stopOnError = _req.stopOnError(); logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Executing request"); - ArrayList answers = new ArrayList(cmds.length); + ArrayList answers = new ArrayList<>(cmds.length); for (int i = 0; i < cmds.length; i++) { + if (Thread.currentThread().isInterrupted() || _req.isCancelled()) { + for (int j = i; j < cmds.length; j++) { + answers.add(Answer.createCancelledAnswer(cmds[j], "Command cancelled")); + } + break; + } + Answer answer = null; Command currentCmd = cmds[i]; if (currentCmd.getContextParam("logid") != null) { @@ -313,6 +436,15 @@ protected void runInContext() { logger.warn("Resource returned null answer!"); answer = new Answer(cmds[i], false, "Resource returned null answer"); } + + // cancelled mid-request: do not start the remaining commands + if (Thread.currentThread().isInterrupted() || _req.isCancelled()) { + answers.add(answer); + for (int j = i + 1; j < cmds.length; j++) { + answers.add(Answer.createCancelledAnswer(cmds[j], "Command cancelled")); + } + break; + } } else { answer = new Answer(cmds[i], false, "Agent is disconnected"); } @@ -334,13 +466,34 @@ protected void runInContext() { logger.debug(LOG_SEQ_FORMATTED_STRING, seq, "Response Received: "); processAnswers(seq, resp); + } catch (InterruptedException e) { + logger.warn(LOG_SEQ_FORMATTED_STRING, seq, "Task interrupted"); + Thread.currentThread().interrupt(); + handleCancellation(seq, "Task interrupted: " + e.getMessage()); } catch (Throwable t) { // This is pretty serious as processAnswers might not be called and the calling process is stuck waiting for the full timeout logger.error(LOG_SEQ_FORMATTED_STRING, seq, "Throwable caught in runInContext, this will cause the management to become unpredictable", t); } finally { + RequestExecutionContext.clear(); + _taskFutures.remove(seq); + _taskRequests.remove(seq); _outstandingTaskCount.decrementAndGet(); scheduleFromQueue(); } } + + private void handleCancellation(long seq, String reason) { + try { + Command[] cmds = _req.getCommands(); + ArrayList answers = new ArrayList<>(cmds.length); + for (Command cmd : cmds) { + answers.add(Answer.createCancelledAnswer(cmd, reason != null ? reason : "Task cancelled")); + } + Response resp = new Response(_req, answers.toArray(new Answer[answers.size()])); + processAnswers(seq, resp); + } catch (Exception e) { + logger.warn(LOG_SEQ_FORMATTED_STRING, seq, "Exception while handling cancellation", e); + } + } } } diff --git a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java index 8c9a1653a260..3d2f01f63544 100755 --- a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java @@ -210,6 +210,7 @@ import com.cloud.exception.InsufficientCapacityException; import com.cloud.exception.InsufficientServerCapacityException; import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.OperationCancelledException; import com.cloud.exception.OperationTimedoutException; import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.ResourceUnavailableException; @@ -662,7 +663,7 @@ public void allocate(final String vmInstanceName, final VirtualMachineTemplate t } VirtualMachineGuru getVmGuru(final VirtualMachine vm) { - if(vm != null) { + if (vm != null) { return _vmGurus.get(vm.getType()); } return null; @@ -973,6 +974,14 @@ public void start(final String vmUuid, final Map sendStop(final VirtualMachineGuru guru, final Vi final StopCommand stop = stpCmd; try { Answer answer = null; - if(vm.getHostId() != null) { + if (vm.getHostId() != null) { answer = _agentMgr.send(vm.getHostId(), stop); } if (answer != null && answer instanceof StopAnswer) { @@ -2288,8 +2307,7 @@ protected Pair sendStop(final VirtualMachineGuru guru, final Vi logger.error(errorMsg); return new Pair<>(false, errorMsg); } - - } catch (final AgentUnavailableException | OperationTimedoutException e) { + } catch (AgentUnavailableException | OperationTimedoutException e) { String errorMsg = String.format("Unable to stop %s due to [%s].", vm.toString(), e.getMessage()); logger.warn(errorMsg, e); if (!force) { @@ -2337,7 +2355,6 @@ protected Pair cleanup(final VirtualMachineGuru guru, final Vir } } } - } else if (state == State.Stopping) { if (vm.getHostId() != null) { Pair result = sendStop(guru, profile, cleanUpEvenIfUnableToStop, false); @@ -2407,7 +2424,6 @@ public void advanceStop(final String vmUuid, final boolean cleanUpEvenIfUnableTo _workJobDao.expunge(placeHolder.getId()); } } - } else { final Outcome outcome = stopVmThroughJobQueue(vmUuid, cleanUpEvenIfUnableToStop); @@ -2489,8 +2505,7 @@ private Pair getVMNetworkDetails(NetworkVO networkVO, boolean i return null; } - private void advanceStop(final VMInstanceVO vm, final boolean cleanUpEvenIfUnableToStop) throws AgentUnavailableException, OperationTimedoutException, - ConcurrentOperationException { + private void advanceStop(final VMInstanceVO vm, final boolean cleanUpEvenIfUnableToStop) throws AgentUnavailableException, OperationTimedoutException, ConcurrentOperationException { final State state = vm.getState(); if (state == State.Stopped) { logger.debug("VM is already stopped: {}", vm); @@ -2768,13 +2783,12 @@ public void doInTransactionWithoutResult(final TransactionStatus status) throws * @param expunge indicates if vm should be expunged */ private void deleteVMSnapshots(VMInstanceVO vm, boolean expunge) { - if (! vm.getHypervisorType().equals(HypervisorType.VMware)) { + if (!vm.getHypervisorType().equals(HypervisorType.VMware)) { if (!_vmSnapshotMgr.deleteAllVMSnapshots(vm.getId(), null)) { logger.debug("Unable to delete all Snapshots for {}", vm); throw new CloudRuntimeException("Unable to delete Instance Snapshots for " + vm); } - } - else { + } else { if (expunge) { _vmSnapshotMgr.deleteVMSnapshotsFromDB(vm.getId(), false); } @@ -2808,6 +2822,26 @@ protected boolean checkVmOnHost(final VirtualMachine vm, final long hostId) thro return true; } + protected boolean checkVmOnHostAfterMigration(final VirtualMachine vm, final long hostId) throws AgentUnavailableException, OperationTimedoutException { + try { + return checkVmOnHost(vm, hostId); + } catch (final OperationCancelledException e) { + logger.info("Cancellation of the migration of {} arrived after it moved to host {}; the migration completes: {}", vm, hostId, e.getMessage()); + return checkVmOnHost(vm, hostId); + } + } + + protected boolean isVmRunningOn(final VirtualMachine vm, final long hostId) { + try { + final Answer answer = _agentMgr.send(hostId, new CheckVirtualMachineCommand(vm.getInstanceName())); + return answer instanceof CheckVirtualMachineAnswer && answer.getResult() + && PowerState.PowerOn.equals(((CheckVirtualMachineAnswer)answer).getState()); + } catch (final Exception e) { + logger.warn("Unable to check whether {} runs on host {}: {}", vm, hostId, e.getMessage()); + return false; + } + } + @Override public void storageMigration(final String vmUuid, final Map volumeToPool) { final AsyncJobExecutionContext jobContext = AsyncJobExecutionContext.getCurrentExecutionContext(); @@ -3012,7 +3046,7 @@ private void checkDestinationForTags(StoragePool destPool, VMInstanceVO vm) { for(Volume vol : vols) { DiskOfferingVO diskOffering = _diskOfferingDao.findById(vol.getDiskOfferingId()); List volumeTags = StringUtils.csvTagsToList(diskOffering.getTags()); - if(! matches(volumeTags, storageTags)) { + if (!matches(volumeTags, storageTags)) { String msg = String.format("destination pool '%s' with tags '%s', does not support the volume diskoffering for volume '%s' (tags: '%s') ", destPool.getName(), StringUtils.listToCsvTags(storageTags), @@ -3204,8 +3238,8 @@ protected void migrate(final VMInstanceVO vm, final long srcHostId, final Deploy throw new AgentUnavailableException(msg, dstHostId); } logger.debug("Successfully prepared destination host {} for migration of VM {} ", dstHostId, vm.getInstanceName()); - } catch (final OperationTimedoutException e1) { - throw new AgentUnavailableException("Operation timed out", dstHostId); + } catch (final OperationTimedoutException e) { + throw new AgentUnavailableException("Operation timed out ", dstHostId); } finally { if (pfma == null) { _networkMgr.rollbackNicForMigration(vmSrc, profile); @@ -3266,7 +3300,12 @@ protected void migrate(final VMInstanceVO vm, final long srcHostId, final Deploy throw new CloudRuntimeException(details); } logger.info("Migration command successful for VM {}", vm.getInstanceName()); - } catch (final OperationTimedoutException e) { + } catch (final OperationCancelledException e) { + if (!isVmRunningOn(vm, dstHostId)) { + throw e; + } + logger.info("Cancellation of the migration of {} arrived after it moved to host {}; completing the migration: {}", vm, dstHostId, e.getMessage()); + } catch (OperationTimedoutException e) { boolean success = false; if (HypervisorType.KVM.equals(vm.getHypervisorType())) { try { @@ -3283,11 +3322,15 @@ protected void migrate(final VMInstanceVO vm, final long srcHostId, final Deploy } } if (!success) { - if (e.isActive()) { + if (e instanceof OperationTimedoutException && ((OperationTimedoutException)e).isActive()) { logger.warn("Active migration command so scheduling a restart for {}", vm, e); _haMgr.scheduleRestart(vm, true); throw new AgentUnavailableException("Operation timed out on migrating " + vm, dstHostId); + } else { + _haMgr.scheduleRestart(vm, true); + + throw new AgentUnavailableException("Operation cancelled on migrating " + vm, dstHostId); } } } @@ -3301,7 +3344,7 @@ protected void migrate(final VMInstanceVO vm, final long srcHostId, final Deploy } try { - if (!checkVmOnHost(vm, dstHostId)) { + if (!checkVmOnHostAfterMigration(vm, dstHostId)) { logger.error("Migration verification failed for VM {} : VM not found on destination host {} ", vm.getInstanceName(), dstHostId); try { _agentMgr.send(srcHostId, new Commands(cleanup(vm, dpdkInterfaceMapping)), null); @@ -3552,7 +3595,7 @@ protected void createStoragePoolMappingsForVolumes(VirtualMachineProfile profile executeManagedStorageChecksWhenTargetStoragePoolNotProvided(targetHost, currentPool, volume); if (ScopeType.HOST.equals(currentPool.getScope()) || isStorageCrossClusterMigration(plan.getClusterId(), currentPool)) { createVolumeToStoragePoolMappingIfPossible(profile, plan, volumeToPoolObjectMap, volume, currentPool); - } else if (shouldMapVolume(profile, currentPool)){ + } else if (shouldMapVolume(profile, currentPool)) { volumeToPoolObjectMap.put(volume, currentPool); } } @@ -3589,7 +3632,7 @@ protected void executeManagedStorageChecksWhenTargetStoragePoolNotProvided(Host } /** - * Return true if the VM migration is a cross cluster migration. To execute that, we check if the volume current storage pool cluster is different from the target cluster. + * Return true if the VM migration is a cross-cluster migration. To execute that, we check if the volume current storage pool cluster is different from the target cluster. */ protected boolean isStorageCrossClusterMigration(Long clusterId, StoragePoolVO currentPool) { return clusterId != null && ScopeType.CLUSTER.equals(currentPool.getScope()) && !currentPool.getClusterId().equals(clusterId); @@ -3714,8 +3757,7 @@ public void migrateWithStorage(final String vmUuid, final long srcHostId, final } } - private void orchestrateMigrateWithStorage(final String vmUuid, final long srcHostId, final long destHostId, final Map volumeToPool) throws ResourceUnavailableException, - ConcurrentOperationException { + private void orchestrateMigrateWithStorage(final String vmUuid, final long srcHostId, final long destHostId, final Map volumeToPool) throws ResourceUnavailableException, ConcurrentOperationException { final VMInstanceVO vm = _vmDao.findByUuid(vmUuid); @@ -3795,8 +3837,7 @@ private void orchestrateMigrateWithStorage(final String vmUuid, final long srcHo _agentMgr.send(srcHost.getId(), dettachCommand); logger.debug("Deleted config drive ISO for vm {} in host {}", vm.getInstanceName(), srcHost); } catch (OperationTimedoutException e) { - logger.error("TIme out occurred while exeuting command AttachOrDettachConfigDrive {}", e.getMessage(), e); - + logger.error("TIme out occurred while executing command AttachOrDettachConfigDrive {}", e.getMessage(), e); } } } @@ -3806,7 +3847,7 @@ private void orchestrateMigrateWithStorage(final String vmUuid, final long srcHo moveVmOutofMigratingStateOnSuccess(vm, destHost.getId(), work); try { - if (!checkVmOnHost(vm, destHostId)) { + if (!checkVmOnHostAfterMigration(vm, destHostId)) { logger.error("Vm not found on destination host. Unable to complete migration for {}", vm); try { _agentMgr.send(srcHostId, new Commands(cleanup(vm.getInstanceName())), null); @@ -3817,7 +3858,7 @@ private void orchestrateMigrateWithStorage(final String vmUuid, final long srcHo String errorDetails = (cleanupResult.second() != null) ? " due to " + cleanupResult.second() : ""; throw new CloudRuntimeException("VM not found on destination host. Unable to complete migration for " + vm + errorDetails); } - } catch (final OperationTimedoutException e) { + } catch (OperationTimedoutException e) { logger.error("Error while checking the vm {} is on host {}", vm, destHost, e); } migrated = true; @@ -4121,7 +4162,7 @@ public void advanceReboot(final String vmUuid, final Map params) throws InsufficientCapacityException, ConcurrentOperationException, - ResourceUnavailableException { + private void orchestrateReboot(final String vmUuid, final Map params) throws ConcurrentOperationException, ResourceUnavailableException { final VMInstanceVO vm = _vmDao.findByUuid(vmUuid); if (_vmSnapshotMgr.hasActiveVMSnapshotTasks(vm.getId())) { logger.error("Unable to reboot Instance: {} due to: {} has active Instance Snapshot tasks", vm, vm.getInstanceName()); @@ -4187,8 +4227,8 @@ private void orchestrateReboot(final String vmUuid, final Map vmMetadatum) { boolean found = false; for(Pair, Pair> vmDetail : vmDetails ) { Pair vmNameTypePair = vmDetail.first(); - if(vmNameTypePair.first().equals(name)) { + if (vmNameTypePair.first().equals(name)) { found = true; - if(vmNameTypePair.second() == VirtualMachine.Type.User) { + if (vmNameTypePair.second() == VirtualMachine.Type.User) { Pair detailPair = vmDetail.second(); String platformDetail = detailPair.second(); @@ -4354,9 +4394,9 @@ public void syncVMMetaData(final Map vmMetadatum) { } } - if(!found) { + if (!found) { VMInstanceVO vm = _vmDao.findVMByInstanceName(name); - if(vm != null && vm.getType() == VirtualMachine.Type.User) { + if (vm != null && vm.getType() == VirtualMachine.Type.User) { updateVmMetaData(vm.getId(), platform); } } @@ -4366,12 +4406,12 @@ public void syncVMMetaData(final Map vmMetadatum) { private void updateVmMetaData(Long vmId, String platform) { UserVmVO userVm = _userVmDao.findById(vmId); _userVmDao.loadDetails(userVm); - if ( userVm.details.containsKey(VmDetailConstants.TIME_OFFSET)) { + if (userVm.details.containsKey(VmDetailConstants.TIME_OFFSET)) { userVm.details.remove(VmDetailConstants.TIME_OFFSET); } userVm.setDetail(VmDetailConstants.PLATFORM, platform); String pvdriver = "xenserver56"; - if ( platform.contains("device_id")) { + if (platform.contains("device_id")) { pvdriver = "xenserver61"; } if (!userVm.details.containsKey(VmDetailConstants.HYPERVISOR_TOOLS_VERSION) || !userVm.details.get(VmDetailConstants.HYPERVISOR_TOOLS_VERSION).equals(pvdriver)) { @@ -4388,7 +4428,7 @@ public boolean isRecurring() { @Override public boolean processAnswers(final long agentId, final long seq, final Answer[] answers) { for (final Answer answer : answers) { - if ( answer instanceof ClusterVMMetaDataSyncAnswer) { + if (answer instanceof ClusterVMMetaDataSyncAnswer) { final ClusterVMMetaDataSyncAnswer cvms = (ClusterVMMetaDataSyncAnswer)answer; if (!cvms.isExecuted()) { syncVMMetaData(cvms.getVMMetaDatum()); @@ -4578,7 +4618,7 @@ protected void checkIfNewOfferingStorageScopeMatchesStoragePool(VirtualMachine v public boolean isRootVolumeOnLocalStorage(long vmId) { ScopeType poolScope = ScopeType.ZONE; List volumes = _volsDao.findByInstanceAndType(vmId, Type.ROOT); - if(CollectionUtils.isNotEmpty(volumes)) { + if (CollectionUtils.isNotEmpty(volumes)) { VolumeVO rootDisk = volumes.get(0); Long poolId = rootDisk.getPoolId(); if (poolId != null) { @@ -4654,8 +4694,7 @@ private void checkIfNetworkExistsForUserVM(VirtualMachine virtualMachine, Networ } } - private NicProfile orchestrateAddVmToNetwork(final VirtualMachine vm, final Network network, final NicProfile requested) throws ConcurrentOperationException, ResourceUnavailableException, - InsufficientCapacityException { + private NicProfile orchestrateAddVmToNetwork(final VirtualMachine vm, final Network network, final NicProfile requested) throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { final CallContext cctx = CallContext.current(); checkIfNetworkExistsForUserVM(vm, network); @@ -4688,7 +4727,7 @@ private NicProfile orchestrateAddVmToNetwork(final VirtualMachine vm, final Netw logger.debug("Nic is plugged successfully for vm {} in network {}. VM is a part of network now.", vm, network); final long isDefault = nic.isDefaultNic() ? 1 : 0; - if(VirtualMachine.Type.User.equals(vmVO.getType())) { + if (VirtualMachine.Type.User.equals(vmVO.getType())) { UsageEventUtils.publishUsageEvent(EventTypes.EVENT_NETWORK_OFFERING_ASSIGN, vmVO.getAccountId(), vmVO.getDataCenterId(), vmVO.getId(), Long.toString(nic.getId()), network.getNetworkOfferingId(), null, isDefault, VirtualMachine.class.getName(), vmVO.getUuid(), vm.isDisplay()); } @@ -4875,8 +4914,7 @@ private boolean orchestrateRemoveVmFromNetwork(final VirtualMachine vm, final Ne } @Override - public void findHostAndMigrate(final String vmUuid, final Long newSvcOfferingId, final Map customParameters, final ExcludeList excludes) throws InsufficientCapacityException, ConcurrentOperationException, - ResourceUnavailableException { + public void findHostAndMigrate(final String vmUuid, final Long newSvcOfferingId, final Map customParameters, final ExcludeList excludes) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException { final VMInstanceVO vm = _vmDao.findByUuid(vmUuid); if (vm == null) { @@ -5028,7 +5066,7 @@ private void orchestrateMigrateForScale(final String vmUuid, final long srcHostI throw new AgentUnavailableException(String.format("Unable to prepare for migration to destination host [%s] due to [%s].", dest.getHost(), details), dstHostId); } } catch (final OperationTimedoutException e1) { - throw new AgentUnavailableException("Operation timed out", dstHostId); + throw new AgentUnavailableException("Operation timed out ", dstHostId); } finally { if (pfma == null) { work.setStep(Step.Done); @@ -5060,6 +5098,11 @@ private void orchestrateMigrateForScale(final String vmUuid, final long srcHostI logger.error(msg); throw new CloudRuntimeException(msg); } + } catch (final OperationCancelledException e) { + if (!isVmRunningOn(vm, dstHostId)) { + throw e; + } + logger.info("Cancellation of the migration of {} arrived after it moved to host {}; completing the migration: {}", vm, dstHostId, e.getMessage()); } catch (final OperationTimedoutException e) { if (e.isActive()) { logger.warn("Active migration command so scheduling a restart for {}", vm, e); @@ -5080,7 +5123,7 @@ private void orchestrateMigrateForScale(final String vmUuid, final long srcHostI } try { - if (!checkVmOnHost(vm, dstHostId)) { + if (!checkVmOnHostAfterMigration(vm, dstHostId)) { logger.error("Unable to complete migration for {}", vm); try { _agentMgr.send(srcHostId, new Commands(cleanup(vm.getInstanceName())), null); @@ -5091,7 +5134,7 @@ private void orchestrateMigrateForScale(final String vmUuid, final long srcHostI String errorDetails = (cleanupResult.second() != null) ? " due to " + cleanupResult.second() : ""; throw new CloudRuntimeException("Unable to complete migration for " + vm + errorDetails); } - } catch (final OperationTimedoutException e) { + } catch (OperationTimedoutException e) { logger.debug("Error while checking the {} on {}", vm, dstHost, e); } @@ -5128,8 +5171,7 @@ private void orchestrateMigrateForScale(final String vmUuid, final long srcHostI } @Override - public boolean replugNic(final Network network, final NicTO nic, final VirtualMachineTO vm, final Host host) throws ConcurrentOperationException, - ResourceUnavailableException, InsufficientCapacityException { + public boolean replugNic(final Network network, final NicTO nic, final VirtualMachineTO vm, final Host host) throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { boolean result = true; final VMInstanceVO router = _vmDao.findById(vm.getId()); @@ -5144,7 +5186,7 @@ public boolean replugNic(final Network network, final NicTO nic, final VirtualMa logger.warn("Unable to replug nic for vm {}", vm.getName()); result = false; } - } catch (final OperationTimedoutException e) { + } catch (OperationTimedoutException e) { throw new AgentUnavailableException("Unable to plug nic for router " + vm.getName() + " in network " + network, host.getId(), e); } } else { @@ -5157,8 +5199,7 @@ public boolean replugNic(final Network network, final NicTO nic, final VirtualMa return result; } - public boolean plugNic(final Network network, final NicTO nic, final VirtualMachineTO vm, final ReservationContext context, final DeployDestination dest) throws ConcurrentOperationException, - ResourceUnavailableException, InsufficientCapacityException { + public boolean plugNic(final Network network, final NicTO nic, final VirtualMachineTO vm, final ReservationContext context, final DeployDestination dest) throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { boolean result = true; final VMInstanceVO router = _vmDao.findById(vm.getId()); @@ -5180,7 +5221,7 @@ public boolean plugNic(final Network network, final NicTO nic, final VirtualMach logger.warn("Unable to plug nic for vm {}", vm.getName()); result = false; } - } catch (final OperationTimedoutException e) { + } catch (OperationTimedoutException e) { throw new AgentUnavailableException("Unable to plug nic for router " + vm.getName() + " in network " + network, dest.getHost().getId(), e); } } else { @@ -5194,8 +5235,7 @@ public boolean plugNic(final Network network, final NicTO nic, final VirtualMach return result; } - public boolean unplugNic(final Network network, final NicTO nic, final VirtualMachineTO vm, final ReservationContext context, final DeployDestination dest) throws ConcurrentOperationException, - ResourceUnavailableException { + public boolean unplugNic(final Network network, final NicTO nic, final VirtualMachineTO vm, final ReservationContext context, final DeployDestination dest) throws ConcurrentOperationException, ResourceUnavailableException { boolean result = true; final VMInstanceVO router = _vmDao.findById(vm.getId()); @@ -5220,7 +5260,7 @@ public boolean unplugNic(final Network network, final NicTO nic, final VirtualMa logger.warn("Unable to unplug nic from router {}", router); result = false; } - } catch (final OperationTimedoutException e) { + } catch (OperationTimedoutException e) { throw new AgentUnavailableException("Unable to unplug nic from rotuer " + router + " from network " + network, dest.getHost().getId(), e); } } else if (router.getState() == State.Stopped || router.getState() == State.Stopping) { @@ -5463,9 +5503,6 @@ private ApiCommandResourceType getApiCommandResourceTypeForVm(VirtualMachine vm) } private void handlePowerOnReportWithNoPendingJobsOnVM(final VMInstanceVO vm) { - Host host = _hostDao.findById(vm.getHostId()); - Host poweredHost = _hostDao.findById(vm.getPowerHostId()); - switch (vm.getState()) { case Starting: logger.info("VM {} is at {} and we received a power-on report while there is no pending jobs on it.", vm.getInstanceName(), vm.getState()); @@ -5488,6 +5525,7 @@ private void handlePowerOnReportWithNoPendingJobsOnVM(final VMInstanceVO vm) { if (vm.getHostId() != null && !vm.getHostId().equals(vm.getPowerHostId())) { logger.info("Detected out of band VM migration from host {} to host {}", () -> _hostDao.findById(vm.getHostId()), () -> _hostDao.findById(vm.getPowerHostId())); } + stateTransitTo(vm, VirtualMachine.Event.FollowAgentPowerOnReport, vm.getPowerHostId()); } catch (final NoTransitionException e) { logger.warn("Unexpected VM state transition exception, race-condition?", e); @@ -5546,7 +5584,7 @@ private void handlePowerOffReportWithNoPendingJobsOnVM(final VMInstanceVO vm) { case Migrating: logger.info("VM {} is at {} and we received a {} report while there is no pending jobs on it" , vm, vm.getState(), vm.getPowerState()); - if((HighAvailabilityManager.ForceHA.value() || vm.isHaEnabled()) && vm.getState() == State.Running + if ((HighAvailabilityManager.ForceHA.value() || vm.isHaEnabled()) && vm.getState() == State.Running && HaVmRestartHostUp.value() && vm.getHypervisorType() != HypervisorType.VMware && vm.getHypervisorType() != HypervisorType.Hyperv) { @@ -5832,7 +5870,7 @@ public Outcome migrateVmThroughJobQueue(final String vmUuid, fin Map volumeStorageMap = dest.getStorageForDisks(); if (volumeStorageMap != null) { for (Volume vol : volumeStorageMap.keySet()) { - checkConcurrentJobsPerDatastoreThreshhold(volumeStorageMap.get(vol)); + checkConcurrentJobsPerDatastoreThreshold(volumeStorageMap.get(vol)); } } @@ -5927,7 +5965,7 @@ public Outcome migrateVmForScaleThroughJobQueue( return new VmJobVirtualMachineOutcome(workJob, vmId); } - private void checkConcurrentJobsPerDatastoreThreshhold(final StoragePool destPool) { + private void checkConcurrentJobsPerDatastoreThreshold(final StoragePool destPool) { final Long threshold = VolumeApiService.ConcurrentMigrationsThresholdPerDatastore.value(); if (threshold != null && threshold > 0) { long count = _jobMgr.countPendingJobs("\"storageid\":\"" + destPool.getUuid() + "\"", MigrateVMCmd.class.getName(), MigrateVolumeCmd.class.getName(), MigrateVolumeCmdByAdmin.class.getName()); @@ -5942,7 +5980,7 @@ public Outcome migrateVmStorageThroughJobQueue(final String vmUu Set uniquePoolIds = new HashSet<>(poolIds); for (Long poolId : uniquePoolIds) { StoragePoolVO pool = _storagePoolDao.findById(poolId); - checkConcurrentJobsPerDatastoreThreshhold(pool); + checkConcurrentJobsPerDatastoreThreshold(pool); } String commandName = VmWorkStorageMigration.class.getName(); @@ -6116,7 +6154,7 @@ private Pair orchestrateStart(final VmWorkStart work) th try { orchestrateStart(vm.getUuid(), work.getParams(), work.getPlan(), _dpMgr.getDeploymentPlannerByName(work.getDeploymentPlanner())); - } catch (CloudRuntimeException e){ + } catch (CloudRuntimeException e) { logger.error("Unable to orchestrate start {} due to [{}].", vm, e.getMessage()); CloudRuntimeException ex = new CloudRuntimeException(String.format("Unable to orchestrate the start of VM instance %s.", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(vm, "instanceName", "uuid"))); @@ -6258,7 +6296,7 @@ private VmWorkJobVO createPlaceHolderWork(final long instanceId, String secondar workJob.setStep(VmWorkJobVO.Step.Starting); workJob.setVmType(VirtualMachine.Type.Instance); workJob.setVmInstanceId(instanceId); - if(org.apache.commons.lang3.StringUtils.isNotBlank(secondaryObjectIdentifier)) { + if (org.apache.commons.lang3.StringUtils.isNotBlank(secondaryObjectIdentifier)) { workJob.setSecondaryObjectIdentifier(secondaryObjectIdentifier); } workJob.setInitMsid(ManagementServerNode.getManagementServerId()); @@ -6451,7 +6489,7 @@ private boolean orchestrateUpdateVmNic(final VirtualMachine vm, final Nic nic, f logger.warn("Unable to update VM %s NIC [{}].", vm.getName(), nic.getUuid()); return false; } - } catch (final OperationTimedoutException e) { + } catch (OperationTimedoutException e) { throw new AgentUnavailableException(String.format("Unable to update NIC %s for VM %s.", nic.getUuid(), vm.getUuid()), vm.getHostId(), e); } } @@ -6569,7 +6607,7 @@ private void executePreMigrationCommand(VMInstanceVO vm, VirtualMachineTO to, lo throw new CloudRuntimeException(msg); } logger.info("Successfully prepared source host {} for migration of VM {}", srcHostUuid, vmInstanceName); - } catch (final AgentUnavailableException | OperationTimedoutException e) { + } catch (AgentUnavailableException | OperationTimedoutException e) { logger.error("Failed to send PreMigrationCommand to source host {}: {}", srcHostUuid, e.getMessage(), e); throw new CloudRuntimeException("Failed to prepare source host for migration: " + e.getMessage(), e); } diff --git a/engine/orchestration/src/main/java/com/cloud/vm/VmWorkJobDispatcher.java b/engine/orchestration/src/main/java/com/cloud/vm/VmWorkJobDispatcher.java index d6c55d7038bb..c023a12da91c 100644 --- a/engine/orchestration/src/main/java/com/cloud/vm/VmWorkJobDispatcher.java +++ b/engine/orchestration/src/main/java/com/cloud/vm/VmWorkJobDispatcher.java @@ -108,7 +108,12 @@ public void runJob(AsyncJob job) { logger.error("Unable to complete {}, job origin: {}", job, job.getRelated()); _asyncJobMgr.completeAsyncJob(job.getId(), JobInfo.Status.FAILED, 0, _asyncJobMgr.marshallResultObject(e)); } catch(Throwable e) { - logger.error("Unable to complete {}, job origin: {}", job, job.getRelated(), e); + final String cancelReason = _asyncJobMgr.getCancellationReason(job.getId()); + if (cancelReason != null) { + logger.info("VM work job {} stopped by cancellation ({}), job origin: {}: {}", job.getId(), cancelReason, job.getRelated(), e.getMessage()); + } else { + logger.error("Unable to complete {}, job origin: {}", job, job.getRelated(), e); + } //RuntimeException ex = new RuntimeException("Job failed due to exception " + e.getMessage()); _asyncJobMgr.completeAsyncJob(job.getId(), JobInfo.Status.FAILED, 0, _asyncJobMgr.marshallResultObject(e)); diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index 2b9b41b6e8ac..34076f6b284b 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -74,6 +74,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo; import org.apache.cloudstack.engine.subsystem.api.storage.TemplateService; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; +import org.apache.cloudstack.framework.jobs.AsyncJobExecutionContext; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeService; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeService.VolumeApiResult; @@ -1644,7 +1645,11 @@ public Volume migrateVolume(Volume volume, StoragePool destPool) throws StorageU String volToString = getReflectOnlySelectedFields(vol.getVolume()); String msg = String.format("Volume [%s] migration failed due to [%s].", volToString, result.getResult()); - logger.error(msg); + if (currentJobCancellationReason() != null) { + logger.info("Volume [{}] migration was cancelled: {}", volToString, result.getResult()); + } else { + logger.error(msg); + } if (result.getResult() != null && result.getResult().contains("[UNSUPPORTED]")) { throw new CloudRuntimeException(msg); @@ -1730,7 +1735,12 @@ public void migrateVolumes(VirtualMachine vm, VirtualMachineTO vmTo, Host srcHos CommandResult result = future.get(); if (result.isFailed()) { String msg = String.format("Failed to migrate VM [%s] along with its volumes due to [%s].", vm, result.getResult()); - logger.error(msg); + final String cancelReason = currentJobCancellationReason(); + if (cancelReason != null) { + logger.info("Migration of VM [{}] along with its volumes was cancelled ({}): [{}].", vm, cancelReason, result.getResult()); + } else { + logger.error(msg); + } throw new CloudRuntimeException(msg); } for (Map.Entry entry : volumeToPool.entrySet()) { @@ -1755,6 +1765,14 @@ public void migrateVolumes(VirtualMachine vm, VirtualMachineTO vmTo, Host srcHos } } + private String currentJobCancellationReason() { + final AsyncJobExecutionContext context = AsyncJobExecutionContext.getCurrentExecutionContext(); + if (context == null || context.getJob() == null) { + return null; + } + return _jobMgr.getCancellationReason(context.getJob().getId()); + } + @Override public boolean storageMigration(VirtualMachineProfile vm, Map volumeToPool) throws StorageUnavailableException { Map volumeStoragePoolMap = new HashMap<>(); diff --git a/engine/orchestration/src/test/java/com/cloud/agent/manager/AgentManagerImplTest.java b/engine/orchestration/src/test/java/com/cloud/agent/manager/AgentManagerImplTest.java index 2377bbaefbc8..749964df7d7a 100644 --- a/engine/orchestration/src/test/java/com/cloud/agent/manager/AgentManagerImplTest.java +++ b/engine/orchestration/src/test/java/com/cloud/agent/manager/AgentManagerImplTest.java @@ -18,11 +18,13 @@ import com.cloud.agent.Listener; import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; import com.cloud.agent.api.ReadyAnswer; import com.cloud.agent.api.ReadyCommand; import com.cloud.agent.api.StartupCommand; import com.cloud.agent.api.StartupRoutingCommand; import com.cloud.exception.ConnectionException; +import com.cloud.exception.OperationCancelledException; import com.cloud.host.DetailVO; import com.cloud.host.Host; import com.cloud.host.HostVO; @@ -31,13 +33,19 @@ import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.Hypervisor; import com.cloud.utils.Pair; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; +import org.apache.cloudstack.jobs.JobInfo; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; public class AgentManagerImplTest { @@ -172,4 +180,279 @@ public void testGetHostSshPortWithKVMHostCustomPort() { int hostSshPort = mgr.getHostSshPort(host); Assert.assertEquals(3922, hostSshPort); } + + private AsyncJobManager cancelledJobOnThisServer(final long jobId) { + final AsyncJobManager asyncJobManager = Mockito.mock(AsyncJobManager.class); + mgr.asyncJobManager = asyncJobManager; + final AsyncJobVO job = new AsyncJobVO(); + job.setId(jobId); + Mockito.when(asyncJobManager.listCancelRequestedJobsExecutingOn(mgr._nodeId)).thenReturn(Collections.singletonList(job)); + return asyncJobManager; + } + + @Test + public void testCancelledJobWithoutInFlightCommandsIsFinalisedAtOnce() { + final AsyncJobManager asyncJobManager = cancelledJobOnThisServer(42L); + + mgr.new CancelledJobsCheckTask().runInContext(); + + Mockito.verify(asyncJobManager).finalizeCancelledJob(42L); + Assert.assertTrue(mgr.isJobCancelled(42L)); + } + + @Test + public void testCancelledJobStaysAssignedUntilItsCommandCanBeStopped() throws Exception { + final AsyncJobManager asyncJobManager = cancelledJobOnThisServer(42L); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn("vCenter reports task PowerOffVM_Task(task-1) as not cancelable"); + mgr.new CancelledJobsCheckTask().runInContext(); + Mockito.verify(asyncJobManager, Mockito.never()).finalizeCancelledJob(Mockito.anyLong()); + Mockito.verify(attache, Mockito.never()).cancelExecution(Mockito.anyLong(), Mockito.anyString()); + + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn(null); + Mockito.when(attache.cancelExecution(Mockito.eq(11L), Mockito.anyString())).thenReturn(true); + // the refusal is retried once its interval has passed + mgr._cancelledJobs.get(42L).retryAfter = 0; + mgr.new CancelledJobsCheckTask().runInContext(); + Mockito.verify(asyncJobManager).finalizeCancelledJob(42L); + } + + @Test + public void testCommandsInFlightHereForAJobCancelledElsewhereAreStopped() throws Exception { + final AsyncJobManager asyncJobManager = Mockito.mock(AsyncJobManager.class); + mgr.asyncJobManager = asyncJobManager; + Mockito.when(asyncJobManager.listCancelRequestedJobsExecutingOn(mgr._nodeId)).thenReturn(Collections.emptyList()); + final AsyncJobVO cancelled = new AsyncJobVO(); + cancelled.setId(42L); + cancelled.setStatus(JobInfo.Status.IN_PROGRESS); + cancelled.setCancelRequested(true); + Mockito.when(asyncJobManager.getAsyncJob(42L)).thenReturn(cancelled); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn(null); + Mockito.when(attache.cancelExecution(Mockito.eq(11L), Mockito.anyString())).thenReturn(true); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + + mgr.new CancelledJobsCheckTask().runInContext(); + + Mockito.verify(attache).cancelExecution(Mockito.eq(11L), Mockito.anyString()); + Mockito.verify(asyncJobManager, Mockito.never()).finalizeCancelledJob(Mockito.anyLong()); + } + + @Test + public void testEasySendHandsTheCancellationToTheCallerInsteadOfANullAnswer() throws Exception { + final HostVO up = Mockito.mock(HostVO.class); + Mockito.when(up.getStatus()).thenReturn(Status.Up); + Mockito.when(hostDao.findById(1L)).thenReturn(up); + final Command cmd = new ReadyCommand(); + final OperationCancelledException cancelled = new OperationCancelledException(new Command[] {cmd}, 1L, 11L, 5L, true, "Cancelled by admin via mgmt1"); + Mockito.doThrow(cancelled).when(mgr).send(1L, cmd); + + try { + mgr.easySend(1L, cmd); + Assert.fail("The cancellation was swallowed"); + } catch (final OperationCancelledException e) { + Assert.assertSame(cancelled, e); + } + } + + @Test + public void testRefusalCarriesWhatTheHypervisorReportedAndTheHost() throws Exception { + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + Mockito.when(attache.getName()).thenReturn("esxi-1"); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn("no vCenter task is running for StopCommand"); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + + Assert.assertEquals("no vCenter task is running for StopCommand (host esxi-1)", mgr.checkJobCannotBeCancelled(42L)); + Assert.assertNull(mgr.checkJobCannotBeCancelled(7L)); + } + + private AsyncJobManager deliveryNotYetClaimed() { + final AsyncJobManager asyncJobManager = Mockito.mock(AsyncJobManager.class); + mgr.asyncJobManager = asyncJobManager; + Mockito.when(asyncJobManager.claimJobCancelDelivery(Mockito.anyLong())).thenReturn(true); + return asyncJobManager; + } + + @Test + public void testCancelWithNothingInFlightRefusesTheNextCommandOnly() { + final AsyncJobManager asyncJobManager = deliveryNotYetClaimed(); + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + + Assert.assertTrue(mgr.isJobCancelled(42L)); + Assert.assertTrue(mgr.refusesCommandsOf(42L)); + Mockito.verify(asyncJobManager).claimJobCancelDelivery(42L); + // the worker is now unwinding: its rollback commands go through + Assert.assertFalse(mgr.refusesCommandsOf(42L)); + Assert.assertFalse(mgr.refusesCommandsOf(7L)); + Assert.assertFalse(mgr.refusesCommandsOf(null)); + } + + @Test + public void testRefusalIsSkippedWhenTheCancelReachedTheWorkerOnAnotherServer() { + final AsyncJobManager asyncJobManager = deliveryNotYetClaimed(); + // the work child on the other server was already refused or stopped + Mockito.when(asyncJobManager.claimJobCancelDelivery(42L)).thenReturn(false); + Mockito.when(asyncJobManager.getCancellationReason(42L)).thenReturn("Cancelled by admin via mgmt2"); + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + + // the API thread's cleanup command passes, here and on every later call + Assert.assertFalse(mgr.refusesCommandsOf(42L)); + Assert.assertFalse(mgr.refusesCommandsOf(42L)); + Mockito.verify(asyncJobManager, Mockito.times(1)).claimJobCancelDelivery(42L); + Assert.assertTrue(mgr.isJobCancelled(42L)); + } + + @Test + public void testCleanupCommandsOfAWorkerThatKnowsAreLeftAlone() throws Exception { + final AsyncJobManager asyncJobManager = deliveryNotYetClaimed(); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn(null); + Mockito.when(attache.cancelExecution(Mockito.eq(11L), Mockito.anyString())).thenReturn(true); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + + // the worker is unwinding with a new command in flight: a later pass must not stop it + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 12L)))); + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + Mockito.verify(attache, Mockito.never()).checkExecutionIsNotCancellable(12L); + Mockito.verify(attache, Mockito.never()).cancelExecution(Mockito.eq(12L), Mockito.anyString()); + + // the same when the delivery happened on the other server + mgr._cancelledJobs.clear(); + Mockito.when(asyncJobManager.isJobCancelDelivered(42L)).thenReturn(true); + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + Mockito.verify(attache, Mockito.never()).checkExecutionIsNotCancellable(12L); + } + + @Test + public void testAnAnsweredRequestStillInTheMapDoesNotBlockTheCancel() throws Exception { + deliveryNotYetClaimed(); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + // the destination's PrepareForMigration has answered, the source's MigrateCommand is running + Mockito.when(attache.isExecutionFinished(10L)).thenReturn(true); + Mockito.when(attache.isExecutionFinished(11L)).thenReturn(false); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn(null); + Mockito.when(attache.cancelExecution(Mockito.eq(11L), Mockito.anyString())).thenReturn(true); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Arrays.asList(new Pair<>(1L, 10L), new Pair<>(1L, 11L)))); + + Assert.assertNull(mgr.checkJobCannotBeCancelled(42L)); + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + + Mockito.verify(attache, Mockito.never()).checkExecutionIsNotCancellable(10L); + Mockito.verify(attache, Mockito.never()).cancelExecution(Mockito.eq(10L), Mockito.anyString()); + Mockito.verify(attache).cancelExecution(Mockito.eq(11L), Mockito.anyString()); + } + + @Test + public void testARefusedCancelIsRetriedAfterTheIntervalNotOnEveryCheck() throws Exception { + final AsyncJobManager asyncJobManager = cancelledJobOnThisServer(42L); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn("vCenter reports task RelocateVM_Task(task-1) as not cancelable"); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + + mgr.new CancelledJobsCheckTask().runInContext(); + mgr.new CancelledJobsCheckTask().runInContext(); + mgr.new CancelledJobsCheckTask().runInContext(); + + Mockito.verify(attache, Mockito.times(1)).checkExecutionIsNotCancellable(11L); + Mockito.verify(asyncJobManager, Mockito.never()).finalizeCancelledJob(Mockito.anyLong()); + + mgr._cancelledJobs.get(42L).retryAfter = 0; + mgr.new CancelledJobsCheckTask().runInContext(); + Mockito.verify(attache, Mockito.times(2)).checkExecutionIsNotCancellable(11L); + } + + @Test + public void testTheCheckDoesNotCancelWhileTheApiThreadIsAlreadyAtIt() throws Exception { + final AsyncJobManager asyncJobManager = cancelledJobOnThisServer(42L); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + mgr._cancelledJobs.computeIfAbsent(42L, id -> new AgentManagerImpl.CancelledJob()).attempting.set(true); + + mgr.new CancelledJobsCheckTask().runInContext(); + + Mockito.verify(attache, Mockito.never()).checkExecutionIsNotCancellable(Mockito.anyLong()); + Mockito.verify(asyncJobManager, Mockito.never()).finalizeCancelledJob(Mockito.anyLong()); + } + + @Test + public void testWithdrawnCancelIsForgotten() { + final AsyncJobManager asyncJobManager = deliveryNotYetClaimed(); + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + + mgr.jobCancelWithdrawn(42L); + Assert.assertFalse(mgr.isJobCancelled(42L)); + Assert.assertFalse(mgr.refusesCommandsOf(42L)); + + // the row says the cancel is gone although the entry is still here: the job runs on + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + Mockito.when(asyncJobManager.claimJobCancelDelivery(42L)).thenReturn(false); + Mockito.when(asyncJobManager.getCancellationReason(42L)).thenReturn(null); + Assert.assertFalse(mgr.refusesCommandsOf(42L)); + Assert.assertFalse(mgr.isJobCancelled(42L)); + } + + @Test + public void testWorkJobOfACancelledParentIsStoppedUnderItsParentsKey() throws Exception { + final AsyncJobManager asyncJobManager = Mockito.mock(AsyncJobManager.class); + mgr.asyncJobManager = asyncJobManager; + final AsyncJobVO work = new AsyncJobVO(); + work.setId(43L); + work.setRelated("42"); + Mockito.when(asyncJobManager.listCancelRequestedJobsExecutingOn(mgr._nodeId)).thenReturn(Collections.singletonList(work)); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn(null); + Mockito.when(attache.cancelExecution(Mockito.eq(11L), Mockito.anyString())).thenReturn(true); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + + mgr.new CancelledJobsCheckTask().runInContext(); + + Mockito.verify(attache).cancelExecution(Mockito.eq(11L), Mockito.anyString()); + Mockito.verify(asyncJobManager).claimJobCancelDelivery(42L); + Assert.assertTrue(mgr.isJobCancelled(42L)); + // the API job is finalised by the server executing it, not by the one running its work + Mockito.verify(asyncJobManager, Mockito.never()).finalizeCancelledJob(Mockito.anyLong()); + } + + @Test + public void testStoppedCommandHandsTheCancelToTheWorkerSoItsRollbackPasses() throws Exception { + final AsyncJobManager asyncJobManager = deliveryNotYetClaimed(); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn(null); + Mockito.when(attache.cancelExecution(Mockito.eq(11L), Mockito.anyString())).thenReturn(true); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + + Assert.assertTrue(mgr.cancelJobExecution(42L, "Job was cancelled")); + + Assert.assertTrue(mgr.isJobCancelled(42L)); + Assert.assertFalse(mgr.refusesCommandsOf(42L)); + // the other server's workers learn through the row that the cancel has been delivered + Mockito.verify(asyncJobManager).claimJobCancelDelivery(42L); + } + + @Test + public void testCommandThatCannotBeStoppedLeavesTheRefusalForTheNextOne() throws Exception { + deliveryNotYetClaimed(); + final AgentAttache attache = Mockito.mock(AgentAttache.class); + Mockito.doReturn(attache).when(mgr).getAttache(1L); + Mockito.when(attache.checkExecutionIsNotCancellable(11L)).thenReturn("vCenter reports task PowerOffVM_Task(task-1) as not cancelable"); + mgr._jobToHostIdAndReqSequenceMap.put(42L, new HashSet<>(Collections.singleton(new Pair<>(1L, 11L)))); + + Assert.assertFalse(mgr.cancelJobExecution(42L, "Job was cancelled")); + + Mockito.verify(attache, Mockito.never()).cancelExecution(Mockito.anyLong(), Mockito.anyString()); + Assert.assertTrue(mgr.refusesCommandsOf(42L)); + Assert.assertFalse(mgr.refusesCommandsOf(42L)); + } } diff --git a/engine/orchestration/src/test/java/com/cloud/agent/manager/ClusteredAgentManagerImplTest.java b/engine/orchestration/src/test/java/com/cloud/agent/manager/ClusteredAgentManagerImplTest.java index 5e4678f62225..a1d51e71e443 100644 --- a/engine/orchestration/src/test/java/com/cloud/agent/manager/ClusteredAgentManagerImplTest.java +++ b/engine/orchestration/src/test/java/com/cloud/agent/manager/ClusteredAgentManagerImplTest.java @@ -17,7 +17,14 @@ package com.cloud.agent.manager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelJobCommand; +import com.cloud.agent.api.Command; +import com.cloud.cluster.ClusterManager; +import com.cloud.cluster.ClusterServicePdu; import com.cloud.configuration.ManagementServiceConfiguration; +import com.cloud.serializer.GsonHelper; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; import com.cloud.ha.HighAvailabilityManagerImpl; import com.cloud.host.HostVO; import com.cloud.host.Status; @@ -31,9 +38,17 @@ import org.mockito.junit.MockitoJUnitRunner; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doReturn; @@ -54,6 +69,82 @@ public void setUp() throws Exception { _hostDao = mock(HostDao.class); } + private ClusteredAgentManagerImpl clusteredManagerOnServer1WithPeer2(final long jobId) { + final ClusteredAgentManagerImpl mgr = Mockito.spy(ClusteredAgentManagerImpl.class); + mgr._nodeId = 1L; + mgr._gson = GsonHelper.getGson(); + mgr._clusterMgr = mock(ClusterManager.class); + final AsyncJobManager asyncJobManager = mock(AsyncJobManager.class); + mgr.asyncJobManager = asyncJobManager; + when(asyncJobManager.listMgmtServersExecutingJob(jobId)).thenReturn(new HashSet<>(Arrays.asList(1L, 2L))); + return mgr; + } + + private String peerAnswer(final ClusteredAgentManagerImpl mgr, final CancelJobCommand cmd, final boolean result, final String details) { + return mgr._gson.toJson(new Answer[] {new Answer(cmd, result, details)}); + } + + @Test + public void testACancelAsksTheServerRunningTheWorkBeforeAccepting() { + final ClusteredAgentManagerImpl mgr = clusteredManagerOnServer1WithPeer2(42L); + doReturn(null).when(mgr).checkJobCannotBeCancelledHere(42L); + when(mgr._clusterMgr.execute(eq("2"), anyLong(), anyString(), anyBoolean())) + .thenReturn(peerAnswer(mgr, new CancelJobCommand(42L, null, true), false, "no vCenter task or transfer is running for StopCommand (host esxi-2)")); + + assertEquals("no vCenter task or transfer is running for StopCommand (host esxi-2)", mgr.checkJobCannotBeCancelled(42L)); + verify(mgr._clusterMgr, never()).execute(eq("1"), anyLong(), anyString(), anyBoolean()); + + when(mgr._clusterMgr.execute(eq("2"), anyLong(), anyString(), anyBoolean())) + .thenReturn(peerAnswer(mgr, new CancelJobCommand(42L, null, true), true, null)); + assertNull(mgr.checkJobCannotBeCancelled(42L)); + + when(mgr._clusterMgr.execute(eq("2"), anyLong(), anyString(), anyBoolean())).thenReturn(null); + assertTrue(mgr.checkJobCannotBeCancelled(42L).contains("could not be reached")); + } + + @Test + public void testALocalRefusalIsAnsweredWithoutAskingThePeers() { + final ClusteredAgentManagerImpl mgr = clusteredManagerOnServer1WithPeer2(42L); + doReturn("KVM: MigrateCommand has nothing that can be stopped on this host (host kvm-1)").when(mgr).checkJobCannotBeCancelledHere(42L); + + assertTrue(mgr.checkJobCannotBeCancelled(42L).startsWith("KVM:")); + verify(mgr._clusterMgr, never()).execute(anyString(), anyLong(), anyString(), anyBoolean()); + } + + @Test + public void testACancelStopsTheWorkOnEveryServerAndFailsIfOneCannot() { + final ClusteredAgentManagerImpl mgr = clusteredManagerOnServer1WithPeer2(42L); + doReturn(true).when(mgr).cancelJobExecutionHere(42L, "Cancelled by admin"); + when(mgr._clusterMgr.execute(eq("2"), anyLong(), anyString(), anyBoolean())) + .thenReturn(peerAnswer(mgr, new CancelJobCommand(42L, "Cancelled by admin", false), false, "not every in-flight command of job-42 on management server 2 could be stopped")); + assertFalse(mgr.cancelJobExecution(42L, "Cancelled by admin")); + + when(mgr._clusterMgr.execute(eq("2"), anyLong(), anyString(), anyBoolean())) + .thenReturn(peerAnswer(mgr, new CancelJobCommand(42L, "Cancelled by admin", false), true, null)); + assertTrue(mgr.cancelJobExecution(42L, "Cancelled by admin")); + } + + @Test + public void testThePeerAnswersAJobCancelWithItsOwnInFlightCommands() { + final ClusteredAgentManagerImpl mgr = clusteredManagerOnServer1WithPeer2(42L); + doReturn("vCenter reports task RelocateVM_Task(task-9) as not cancelable (host esxi-1)").when(mgr).checkJobCannotBeCancelledHere(42L); + doReturn(true).when(mgr).cancelJobExecutionHere(42L, "Cancelled by admin"); + + final ClusterServicePdu check = new ClusterServicePdu(); + check.setJsonPackage(mgr._gson.toJson(new Command[] {new CancelJobCommand(42L, null, true)})); + Answer[] answers = mgr._gson.fromJson(mgr.new ClusterDispatcher().dispatch(check), Answer[].class); + assertFalse(answers[0].getResult()); + assertEquals("vCenter reports task RelocateVM_Task(task-9) as not cancelable (host esxi-1)", answers[0].getDetails()); + + final ClusterServicePdu cancel = new ClusterServicePdu(); + cancel.setJsonPackage(mgr._gson.toJson(new Command[] {new CancelJobCommand(42L, "Cancelled by admin", false)})); + answers = mgr._gson.fromJson(mgr.new ClusterDispatcher().dispatch(cancel), Answer[].class); + assertTrue(answers[0].getResult()); + // the peer's own check and stop were used, not the forwarding ones + verify(mgr, never()).checkJobCannotBeCancelled(anyLong()); + verify(mgr, never()).cancelJobExecution(anyLong(), anyString()); + } + @Test public void scanDirectAgentToLoadNoHostsTest() { ClusteredAgentManagerImpl clusteredAgentManagerImpl = mock(ClusteredAgentManagerImpl.class); diff --git a/engine/orchestration/src/test/java/com/cloud/agent/manager/ClusteredDirectAgentAttacheTest.java b/engine/orchestration/src/test/java/com/cloud/agent/manager/ClusteredDirectAgentAttacheTest.java new file mode 100644 index 000000000000..2d2c04b53753 --- /dev/null +++ b/engine/orchestration/src/test/java/com/cloud/agent/manager/ClusteredDirectAgentAttacheTest.java @@ -0,0 +1,52 @@ +// 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.manager; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import com.cloud.agent.api.CancelCommand; +import com.cloud.agent.api.Command; +import com.cloud.agent.transport.Request; +import com.cloud.agent.transport.Response; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.ServerResource; + +public class ClusteredDirectAgentAttacheTest { + + @Test + public void testACancelForwardedByAPeerIsAnsweredByTheAttacheNotTheResource() throws Exception { + final ClusteredAgentManagerImpl agentMgr = Mockito.mock(ClusteredAgentManagerImpl.class); + final ServerResource resource = Mockito.mock(ServerResource.class); + final ClusteredDirectAgentAttache attache = new ClusteredDirectAgentAttache(agentMgr, 1L, "uuid", "esxi-1", Hypervisor.HypervisorType.VMware, 1L, resource, false); + + // peer 2 asks whether sequence 11, which this server is not running, can be cancelled + final Request request = new Request(1L, 2L, new Command[] {new CancelCommand(11L, "Job cancelled", true)}, true, true); + request.setSequence(5L); + attache.routeToAgent(request.getBytes()); + + Mockito.verify(resource, Mockito.never()).executeRequest(Mockito.any()); + final ArgumentCaptor routed = ArgumentCaptor.forClass(byte[].class); + Mockito.verify(agentMgr).routeToPeer(Mockito.eq("2"), routed.capture()); + final Response response = (Response) Request.parse(routed.getValue()); + Assert.assertEquals(5L, response.getSequence()); + Assert.assertFalse(response.getAnswers()[0].getResult()); + Assert.assertTrue(response.getAnswers()[0].getDetails(), response.getAnswers()[0].getDetails().contains("no longer running on this host")); + } +} diff --git a/engine/orchestration/src/test/java/com/cloud/agent/manager/ConnectedAgentAttacheTest.java b/engine/orchestration/src/test/java/com/cloud/agent/manager/ConnectedAgentAttacheTest.java index 72b217d0fdd9..8c6f7facc237 100644 --- a/engine/orchestration/src/test/java/com/cloud/agent/manager/ConnectedAgentAttacheTest.java +++ b/engine/orchestration/src/test/java/com/cloud/agent/manager/ConnectedAgentAttacheTest.java @@ -16,11 +16,24 @@ // under the License. package com.cloud.agent.manager; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelCommand; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.UnsupportedAnswer; +import com.cloud.exception.AgentUnavailableException; import com.cloud.hypervisor.Hypervisor; import com.cloud.utils.nio.Link; @@ -80,4 +93,69 @@ public void testEqualsFalseDiffClass() throws Exception { assertFalse(agentAttache1.equals("abc")); } + + @Test + public void isExecutionCancellableAsksTheAgentWithoutCancelling() throws Exception { + AgentManagerImpl agentMgr = mock(AgentManagerImpl.class); + ConnectedAgentAttache attache = new ConnectedAgentAttache(agentMgr, 5L, "uuid", "host", Hypervisor.HypervisorType.KVM, mock(Link.class), false); + when(agentMgr.send(Mockito.eq(5L), Mockito.any(Command.class))).thenReturn(new Answer(null, true, "can")); + + assertNull(attache.checkExecutionIsNotCancellable(77L)); + + ArgumentCaptor sent = ArgumentCaptor.forClass(Command.class); + verify(agentMgr).send(Mockito.eq(5L), sent.capture()); + CancelCommand cancel = (CancelCommand) sent.getValue(); + assertTrue(cancel.isCheckOnly()); + assertTrue(77L == cancel.getSequence()); + assertFalse(cancel.executeInSequence()); + } + + @Test + public void cancelRunningSendsARealCancelAndReportsTheAgentsAnswer() throws Exception { + AgentManagerImpl agentMgr = mock(AgentManagerImpl.class); + ConnectedAgentAttache attache = new ConnectedAgentAttache(agentMgr, 5L, "uuid", "host", Hypervisor.HypervisorType.KVM, mock(Link.class), false); + when(agentMgr.send(Mockito.eq(5L), Mockito.any(Command.class))).thenReturn(new Answer(null, false, "nothing to stop")); + + assertFalse(attache.cancelRunning(77L)); + + ArgumentCaptor sent = ArgumentCaptor.forClass(Command.class); + verify(agentMgr).send(Mockito.eq(5L), sent.capture()); + assertFalse(((CancelCommand) sent.getValue()).isCheckOnly()); + } + + @Test + public void anAgentThatDoesNotUnderstandCancellationIsNotCancellable() throws Exception { + AgentManagerImpl agentMgr = mock(AgentManagerImpl.class); + ConnectedAgentAttache attache = new ConnectedAgentAttache(agentMgr, 5L, "uuid", "host", Hypervisor.HypervisorType.KVM, mock(Link.class), false); + when(agentMgr.send(Mockito.eq(5L), Mockito.any(Command.class))).thenReturn(new UnsupportedAnswer(null, "unsupported")); + + assertNotNull(attache.checkExecutionIsNotCancellable(77L)); + assertFalse(attache.cancelRunning(77L)); + } + + @Test + public void anUnreachableAgentIsNotCancellable() throws Exception { + AgentManagerImpl agentMgr = mock(AgentManagerImpl.class); + ConnectedAgentAttache attache = new ConnectedAgentAttache(agentMgr, 5L, "uuid", "host", Hypervisor.HypervisorType.KVM, mock(Link.class), false); + when(agentMgr.send(Mockito.eq(5L), Mockito.any(Command.class))).thenThrow(new AgentUnavailableException(5L)); + + assertNotNull(attache.checkExecutionIsNotCancellable(77L)); + } + + @Test + public void theAgentsRefusalIsTheReason() throws Exception { + AgentManagerImpl agentMgr = mock(AgentManagerImpl.class); + ConnectedAgentAttache attache = new ConnectedAgentAttache(agentMgr, 5L, "uuid", "host", Hypervisor.HypervisorType.KVM, mock(Link.class), false); + when(agentMgr.send(Mockito.eq(5L), Mockito.any(Command.class))).thenReturn(new Answer(null, false, "StopCommand has nothing that can be stopped on this host")); + + assertEquals("StopCommand has nothing that can be stopped on this host", attache.checkExecutionIsNotCancellable(77L)); + } + + @Test + public void withoutAnAgentManagerNothingIsAsked() { + ConnectedAgentAttache attache = new ConnectedAgentAttache(null, 5L, "uuid", "host", Hypervisor.HypervisorType.KVM, mock(Link.class), false); + + assertNotNull(attache.checkExecutionIsNotCancellable(77L)); + assertFalse(attache.cancelRunning(77L)); + } } diff --git a/engine/orchestration/src/test/java/com/cloud/agent/manager/DirectAgentAttacheTest.java b/engine/orchestration/src/test/java/com/cloud/agent/manager/DirectAgentAttacheTest.java index 4ba276460e3a..359729d044a0 100644 --- a/engine/orchestration/src/test/java/com/cloud/agent/manager/DirectAgentAttacheTest.java +++ b/engine/orchestration/src/test/java/com/cloud/agent/manager/DirectAgentAttacheTest.java @@ -16,19 +16,25 @@ // under the License. package com.cloud.agent.manager; +import java.util.UUID; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; + +import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; import org.mockito.junit.MockitoJUnitRunner; +import com.cloud.agent.Listener; +import com.cloud.agent.api.CancelCommand; +import com.cloud.agent.api.CheckHealthCommand; +import com.cloud.agent.transport.Request; import com.cloud.hypervisor.Hypervisor; import com.cloud.resource.ServerResource; -import java.util.UUID; - @RunWith(MockitoJUnitRunner.class) public class DirectAgentAttacheTest { @Mock @@ -37,17 +43,22 @@ public class DirectAgentAttacheTest { @Mock private ServerResource _resource; + @Mock + private ScheduledExecutorService directAgentPool; + + @Mock + private Future runningTask; + long _id = 0L; String _uuid = UUID.randomUUID().toString(); + private DirectAgentAttache directAgentAttache; + @Before public void setup() { directAgentAttache = new DirectAgentAttache(_agentMgr, _id, _uuid, "myDirectAgentAttache", Hypervisor.HypervisorType.KVM, _resource, false); - - MockitoAnnotations.initMocks(directAgentAttache); } - private DirectAgentAttache directAgentAttache; @Test public void testPingTask() throws Exception { @@ -56,4 +67,111 @@ public void testPingTask() throws Exception { pt.runInContext(); Mockito.verify(_resource, Mockito.times(1)).getCurrentStatus(_id); } + + @Test + public void testCancelQueuedTaskDropsItBeforeItReachesTheResource() throws Exception { + // no free thread, so the task stays queued + Mockito.doReturn(0).when(_agentMgr).getDirectAgentThreadCap(); + final Request request = newRequest(202L); + + directAgentAttache.send(request, null); + Assert.assertEquals(1, directAgentAttache.tasks.size()); + Assert.assertNull(directAgentAttache.checkExecutionIsNotCancellable(202L)); + + directAgentAttache.cancelExecution(202L); + + Assert.assertTrue(request.isCancelled()); + Assert.assertEquals(0, directAgentAttache.tasks.size()); + Mockito.verify(_resource, Mockito.never()).cancelRequestSequence(Mockito.anyLong()); + } + + @Test + public void testCancelRunningTaskStopsTheResourceThenTheThread() throws Exception { + final Request request = submitRunning(101L); + directAgentAttache.registerListener(101L, waiter()); + Mockito.doReturn(null).when(_resource).checkRequestSequenceIsNotCancellable(101L); + Mockito.doReturn(true).when(_resource).cancelRequestSequence(101L); + + Assert.assertNull(directAgentAttache.checkExecutionIsNotCancellable(101L)); + Assert.assertTrue(directAgentAttache.cancelExecution(101L)); + + Mockito.verify(_resource).cancelRequestSequence(101L); + Mockito.verify(runningTask).cancel(true); + Assert.assertTrue(request.isCancelled()); + Assert.assertFalse(directAgentAttache._waitForList.containsKey(101L)); + } + + @Test + public void testCancelLeavesNonCancellableRunningTaskAlone() throws Exception { + final Request request = submitRunning(303L); + directAgentAttache.registerListener(303L, waiter()); + Mockito.doReturn("vCenter reports task PowerOffVM_Task(task-1) as not cancelable").when(_resource).checkRequestSequenceIsNotCancellable(303L); + + Assert.assertNotNull(directAgentAttache.checkExecutionIsNotCancellable(303L)); + Assert.assertFalse(directAgentAttache.cancelExecution(303L)); + + Mockito.verify(_resource, Mockito.never()).cancelRequestSequence(Mockito.anyLong()); + Mockito.verify(runningTask, Mockito.never()).cancel(Mockito.anyBoolean()); + Assert.assertFalse(request.isCancelled()); + Assert.assertTrue(directAgentAttache._waitForList.containsKey(303L)); + } + + @Test + public void testUnknownSequenceIsNotClaimedCancellable() { + Assert.assertNotNull(directAgentAttache.checkExecutionIsNotCancellable(999L)); + } + + @Test + public void testTimeoutStopsCancellableHypervisorWork() throws Exception { + final Request request = submitRunning(404L); + Mockito.doReturn(null).when(_resource).checkRequestSequenceIsNotCancellable(404L); + Mockito.doReturn(true).when(_resource).cancelRequestSequence(404L); + + Assert.assertTrue(directAgentAttache.cancelRunningOnTimeout(404L, request)); + + Mockito.verify(_resource).cancelRequestSequence(404L); + Mockito.verify(runningTask).cancel(true); + } + + @Test + public void testTimeoutLeavesNonCancellableHypervisorWorkAlone() throws Exception { + final Request request = submitRunning(505L); + Mockito.doReturn("vCenter reports task PowerOffVM_Task(task-1) as not cancelable").when(_resource).checkRequestSequenceIsNotCancellable(505L); + + Assert.assertFalse(directAgentAttache.cancelRunningOnTimeout(505L, request)); + + Mockito.verify(_resource, Mockito.never()).cancelRequestSequence(Mockito.anyLong()); + Mockito.verify(runningTask, Mockito.never()).cancel(Mockito.anyBoolean()); + } + + @Test + public void testTimedOutCancelCommandIsNotCancelledItself() throws Exception { + final Request request = new Request(_id, -1, new CancelCommand(1L, "probe", true), false); + request.setSequence(606L); + + Assert.assertFalse(directAgentAttache.cancelRunningOnTimeout(606L, request)); + + Mockito.verify(_resource, Mockito.never()).checkRequestSequenceIsNotCancellable(Mockito.anyLong()); + } + + private Request newRequest(final long seq) { + final Request request = new Request(_id, -1, new CheckHealthCommand(), false); + request.setSequence(seq); + return request; + } + + private Listener waiter() { + final Listener listener = Mockito.mock(Listener.class); + Mockito.when(listener.getTimeout()).thenReturn(-1); + return listener; + } + + private Request submitRunning(final long seq) throws Exception { + Mockito.doReturn(2).when(_agentMgr).getDirectAgentThreadCap(); + Mockito.doReturn(directAgentPool).when(_agentMgr).getDirectAgentPool(); + Mockito.doReturn(runningTask).when(directAgentPool).submit(Mockito.any(Runnable.class)); + final Request request = newRequest(seq); + directAgentAttache.send(request, null); + return request; + } } diff --git a/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java b/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java index 3b680f0bc74a..36643daefb92 100644 --- a/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java +++ b/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java @@ -97,6 +97,9 @@ import com.cloud.agent.AgentManager; import com.cloud.agent.api.Command; +import com.cloud.agent.api.CheckVirtualMachineCommand; +import com.cloud.exception.OperationCancelledException; +import com.cloud.agent.api.CheckVirtualMachineAnswer; import com.cloud.agent.api.PrepareExternalProvisioningAnswer; import com.cloud.agent.api.RebootCommand; import com.cloud.agent.api.StartCommand; @@ -2241,4 +2244,28 @@ public void testUpdateClvmLockHostForVmVolumes_MultipleClvmPools() throws Except verify(clvmPoolManagerMock, times(1)).setClvmLockHostId(3L, destHostId); } + @Test + public void isVmRunningOnReportsAPoweredOnVmOnTheHost() throws Exception { + when(vmInstanceMock.getInstanceName()).thenReturn("i-2-3-VM"); + when(agentManagerMock.send(eq(2L), any(CheckVirtualMachineCommand.class))) + .thenReturn(new CheckVirtualMachineAnswer(new CheckVirtualMachineCommand("i-2-3-VM"), VirtualMachine.PowerState.PowerOn, 1)); + assertTrue(virtualMachineManagerImpl.isVmRunningOn(vmInstanceMock, 2L)); + + when(agentManagerMock.send(eq(2L), any(CheckVirtualMachineCommand.class))) + .thenReturn(new CheckVirtualMachineAnswer(new CheckVirtualMachineCommand("i-2-3-VM"), VirtualMachine.PowerState.PowerOff, 0)); + assertFalse(virtualMachineManagerImpl.isVmRunningOn(vmInstanceMock, 2L)); + + when(agentManagerMock.send(eq(2L), any(CheckVirtualMachineCommand.class))).thenThrow(new AgentUnavailableException(2L)); + assertFalse(virtualMachineManagerImpl.isVmRunningOn(vmInstanceMock, 2L)); + } + + @Test + public void checkVmOnHostAfterMigrationRepeatsTheCheckRefusedByALateCancel() throws Exception { + // the cancel's one refused command landed on the post-migration check: the VM has moved, the check is asked again + doThrow(OperationCancelledException.updateCmdsNotSent(new Command[] {new CheckVirtualMachineCommand("i-2-3-VM")}, 2L, 42L)) + .doReturn(true).when(virtualMachineManagerImpl).checkVmOnHost(vmInstanceMock, 2L); + + assertTrue(virtualMachineManagerImpl.checkVmOnHostAfterMigration(vmInstanceMock, 2L)); + verify(virtualMachineManagerImpl, times(2)).checkVmOnHost(vmInstanceMock, 2L); + } } diff --git a/engine/schema/src/main/java/com/cloud/event/EventVO.java b/engine/schema/src/main/java/com/cloud/event/EventVO.java index 24c3e8cd0641..47c5cf36d383 100644 --- a/engine/schema/src/main/java/com/cloud/event/EventVO.java +++ b/engine/schema/src/main/java/com/cloud/event/EventVO.java @@ -73,6 +73,9 @@ public class EventVO implements Event { @Column(name = "start_id") private long startId; + @Column(name = "async_job_id") + private Long asyncJobId; + @Column(name = "parameters", length = 1024) private String parameters; @@ -209,6 +212,14 @@ public void setStartId(long startId) { this.startId = startId; } + public Long getAsyncJobId() { + return asyncJobId; + } + + public void setAsyncJobId(Long asyncJobId) { + this.asyncJobId = asyncJobId; + } + @Override public String getParameters() { return parameters; diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql index 3365f1f855ba..b248905e3094 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql @@ -239,3 +239,10 @@ CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_details` ( PRIMARY KEY (`id`), CONSTRAINT `fk_instance_boot_group_details__group_id` FOREIGN KEY (`boot_group_id`) REFERENCES `instance_boot_group` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Link every action event to the API job that raised it, so a job can be traced to its events +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.event', 'async_job_id', 'BIGINT UNSIGNED DEFAULT NULL COMMENT ''The async job that raised this event'' '); +CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_event__async_job_id', 'cloud.event', '(`async_job_id`)'); + +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.async_job', 'cancel_requested', 'TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT ''Cancel requested flag'' '); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.async_job', 'cancel_delivered', 'TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT ''Set once the cancel stopped or refused a command of the job, on any management server'' '); diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.async_job_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.async_job_view.sql index 8e941a04e8ea..fe3935adfa49 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.async_job_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.async_job_view.sql @@ -33,6 +33,7 @@ select user.uuid user_uuid, async_job.id, async_job.uuid, + async_job.related, async_job.job_cmd, async_job.job_status, async_job.job_process_status, @@ -42,41 +43,51 @@ select async_job.removed, async_job.instance_type, async_job.instance_id, + async_job.job_init_msid, async_job.job_executing_msid, - CASE - WHEN async_job.instance_type = 'Volume' THEN volumes.uuid - WHEN - async_job.instance_type = 'Template' - or async_job.instance_type = 'Iso' - THEN - vm_template.uuid - WHEN - async_job.instance_type = 'VirtualMachine' - or async_job.instance_type = 'ConsoleProxy' - or async_job.instance_type = 'SystemVm' - or async_job.instance_type = 'DomainRouter' - THEN - vm_instance.uuid - WHEN async_job.instance_type = 'Snapshot' THEN snapshots.uuid - WHEN async_job.instance_type = 'Host' THEN host.uuid - WHEN async_job.instance_type = 'StoragePool' THEN storage_pool.uuid - WHEN async_job.instance_type = 'IpAddress' THEN user_ip_address.uuid - WHEN async_job.instance_type = 'SecurityGroup' THEN security_group.uuid - WHEN async_job.instance_type = 'PhysicalNetwork' THEN physical_network.uuid - WHEN async_job.instance_type = 'TrafficType' THEN physical_network_traffic_types.uuid - WHEN async_job.instance_type = 'PhysicalNetworkServiceProvider' THEN physical_network_service_providers.uuid - WHEN async_job.instance_type = 'FirewallRule' THEN firewall_rules.uuid - WHEN async_job.instance_type = 'Account' THEN acct.uuid - WHEN async_job.instance_type = 'User' THEN us.uuid - WHEN async_job.instance_type = 'StaticRoute' THEN static_routes.uuid - WHEN async_job.instance_type = 'PrivateGateway' THEN vpc_gateways.uuid - WHEN async_job.instance_type = 'Counter' THEN counter.uuid - WHEN async_job.instance_type = 'Condition' THEN conditions.uuid - WHEN async_job.instance_type = 'AutoScalePolicy' THEN autoscale_policies.uuid - WHEN async_job.instance_type = 'AutoScaleVmProfile' THEN autoscale_vmprofiles.uuid - WHEN async_job.instance_type = 'AutoScaleVmGroup' THEN autoscale_vmgroups.uuid - ELSE null - END instance_uuid + async_job.job_complete_msid, + async_job.cancel_requested, + COALESCE( + volumes.uuid, + vm_template.uuid, + vm_instance.uuid, + snapshots.uuid, + host.uuid, + storage_pool.uuid, + user_ip_address.uuid, + security_group.uuid, + physical_network.uuid, + physical_network_traffic_types.uuid, + physical_network_service_providers.uuid, + firewall_rules.uuid, + acct.uuid, + us.uuid, + static_routes.uuid, + vpc_gateways.uuid, + counter.uuid, + conditions.uuid, + autoscale_policies.uuid, + autoscale_vmprofiles.uuid, + autoscale_vmgroups.uuid) instance_uuid, + COALESCE( + volumes.name, + vm_template.name, + vm_instance.name, + snapshots.name, + host.name, + storage_pool.name, + user_ip_address.public_ip_address, + security_group.name, + physical_network.name, + physical_network_traffic_types.traffic_type, + physical_network_service_providers.provider_name, + acct.account_name, + us.username, + static_routes.cidr, + vpc_gateways.ip4_address, + counter.name, + autoscale_policies.name, + autoscale_vmgroups.name) instance_name from `cloud`.`async_job` left join @@ -86,44 +97,44 @@ from left join `cloud`.`user` ON async_job.user_id = user.id left join - `cloud`.`volumes` ON async_job.instance_id = volumes.id + `cloud`.`volumes` ON async_job.instance_type = 'Volume' and async_job.instance_id = volumes.id left join - `cloud`.`vm_template` ON async_job.instance_id = vm_template.id + `cloud`.`vm_template` ON async_job.instance_type in ('Template', 'Iso') and async_job.instance_id = vm_template.id left join - `cloud`.`vm_instance` ON async_job.instance_id = vm_instance.id + `cloud`.`vm_instance` ON async_job.instance_type in ('VirtualMachine', 'ConsoleProxy', 'SystemVm', 'DomainRouter') and async_job.instance_id = vm_instance.id left join - `cloud`.`snapshots` ON async_job.instance_id = snapshots.id + `cloud`.`snapshots` ON async_job.instance_type = 'Snapshot' and async_job.instance_id = snapshots.id left join - `cloud`.`host` ON async_job.instance_id = host.id + `cloud`.`host` ON async_job.instance_type = 'Host' and async_job.instance_id = host.id left join - `cloud`.`storage_pool` ON async_job.instance_id = storage_pool.id + `cloud`.`storage_pool` ON async_job.instance_type = 'StoragePool' and async_job.instance_id = storage_pool.id left join - `cloud`.`user_ip_address` ON async_job.instance_id = user_ip_address.id + `cloud`.`user_ip_address` ON async_job.instance_type = 'IpAddress' and async_job.instance_id = user_ip_address.id left join - `cloud`.`security_group` ON async_job.instance_id = security_group.id + `cloud`.`security_group` ON async_job.instance_type = 'SecurityGroup' and async_job.instance_id = security_group.id left join - `cloud`.`physical_network` ON async_job.instance_id = physical_network.id + `cloud`.`physical_network` ON async_job.instance_type = 'PhysicalNetwork' and async_job.instance_id = physical_network.id left join - `cloud`.`physical_network_traffic_types` ON async_job.instance_id = physical_network_traffic_types.id + `cloud`.`physical_network_traffic_types` ON async_job.instance_type = 'TrafficType' and async_job.instance_id = physical_network_traffic_types.id left join - `cloud`.`physical_network_service_providers` ON async_job.instance_id = physical_network_service_providers.id + `cloud`.`physical_network_service_providers` ON async_job.instance_type = 'PhysicalNetworkServiceProvider' and async_job.instance_id = physical_network_service_providers.id left join - `cloud`.`firewall_rules` ON async_job.instance_id = firewall_rules.id + `cloud`.`firewall_rules` ON async_job.instance_type = 'FirewallRule' and async_job.instance_id = firewall_rules.id left join - `cloud`.`account` acct ON async_job.instance_id = acct.id + `cloud`.`account` acct ON async_job.instance_type = 'Account' and async_job.instance_id = acct.id left join - `cloud`.`user` us ON async_job.instance_id = us.id + `cloud`.`user` us ON async_job.instance_type = 'User' and async_job.instance_id = us.id left join - `cloud`.`static_routes` ON async_job.instance_id = static_routes.id + `cloud`.`static_routes` ON async_job.instance_type = 'StaticRoute' and async_job.instance_id = static_routes.id left join - `cloud`.`vpc_gateways` ON async_job.instance_id = vpc_gateways.id + `cloud`.`vpc_gateways` ON async_job.instance_type = 'PrivateGateway' and async_job.instance_id = vpc_gateways.id left join - `cloud`.`counter` ON async_job.instance_id = counter.id + `cloud`.`counter` ON async_job.instance_type = 'Counter' and async_job.instance_id = counter.id left join - `cloud`.`conditions` ON async_job.instance_id = conditions.id + `cloud`.`conditions` ON async_job.instance_type = 'Condition' and async_job.instance_id = conditions.id left join - `cloud`.`autoscale_policies` ON async_job.instance_id = autoscale_policies.id + `cloud`.`autoscale_policies` ON async_job.instance_type = 'AutoScalePolicy' and async_job.instance_id = autoscale_policies.id left join - `cloud`.`autoscale_vmprofiles` ON async_job.instance_id = autoscale_vmprofiles.id + `cloud`.`autoscale_vmprofiles` ON async_job.instance_type = 'AutoScaleVmProfile' and async_job.instance_id = autoscale_vmprofiles.id left join - `cloud`.`autoscale_vmgroups` ON async_job.instance_id = autoscale_vmgroups.id; + `cloud`.`autoscale_vmgroups` ON async_job.instance_type = 'AutoScaleVmGroup' and async_job.instance_id = autoscale_vmgroups.id; diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.event_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.event_view.sql index 0a15ae4c0c91..1066f567e95e 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.event_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.event_view.sql @@ -34,6 +34,8 @@ select `event`.`parameters` AS `parameters`, `event`.`start_id` AS `start_id`, `eve`.`uuid` AS `start_uuid`, + `event`.`async_job_id` AS `async_job_id`, + `async_job`.`uuid` AS `async_job_uuid`, `event`.`user_id` AS `user_id`, `event`.`archived` AS `archived`, `event`.`display` AS `display`, @@ -50,7 +52,7 @@ select `projects`.`uuid` AS `project_uuid`, `projects`.`name` AS `project_name` from - (((((`event` + ((((((`event` join `account` on ((`event`.`account_id` = `account`.`id`))) join `domain` on @@ -60,4 +62,6 @@ join `user` on left join `projects` on ((`projects`.`project_account_id` = `event`.`account_id`))) left join `event` `eve` on - ((`event`.`start_id` = `eve`.`id`))); + ((`event`.`start_id` = `eve`.`id`))) +left join `async_job` on + ((`event`.`async_job_id` = `async_job`.`id`))); diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java index 2a348cb058d1..0c3fdea567f4 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java @@ -103,7 +103,10 @@ import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.configuration.Config; import com.cloud.dc.dao.ClusterDao; +import com.cloud.event.EventTypes; +import com.cloud.event.UsageEventUtils; import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationCancelledException; import com.cloud.exception.OperationTimedoutException; import com.cloud.host.Host; import com.cloud.host.HostVO; @@ -2032,6 +2035,7 @@ protected void setVolumeMigrationOptions(VolumeInfo srcVolumeInfo, VolumeInfo de public void copyAsync(Map volumeDataStoreMap, VirtualMachineTO vmTO, Host srcHost, Host destHost, AsyncCompletionCallback callback) { String errMsg = null; boolean success = false; + boolean destVolumesHandled = false; Map srcVolumeInfoToDestVolumeInfo = new HashMap<>(); List samePoolClvmVolumes = new ArrayList<>(); @@ -2224,6 +2228,7 @@ public void copyAsync(Map volumeDataStoreMap, VirtualMach } handlePostMigration(success, srcVolumeInfoToDestVolumeInfo, vmTO, srcHost, destHost); + destVolumesHandled = true; if (!success) { if (migrateAnswer == null) { @@ -2239,17 +2244,27 @@ public void copyAsync(Map volumeDataStoreMap, VirtualMach } catch (AgentUnavailableException | OperationTimedoutException | CloudRuntimeException ex) { String volumesAndStorages = volumeDataStoreMap.entrySet().stream().map(entry -> formatEntryOfVolumesAndStoragesAsJsonToDisplayOnLog(entry)).collect(Collectors.joining(",")); - errMsg = String.format("Copy volume(s) to storage(s) [%s] and VM to host [%s] failed in StorageSystemDataMotionStrategy.copyAsync. Error message: [%s].", volumesAndStorages, formatMigrationElementsAsJsonToDisplayOnLog("vm", vmTO.getId(), srcHost.getId(), destHost.getId()), ex.getMessage()); - logger.error(errMsg, ex); + if (ex instanceof OperationCancelledException) { + errMsg = String.format("Copy volume(s) to storage(s) [%s] and VM to host [%s] was cancelled: [%s].", volumesAndStorages, formatMigrationElementsAsJsonToDisplayOnLog("vm", vmTO.getId(), srcHost.getId(), destHost.getId()), ex.getMessage()); + logger.info(errMsg); + } else { + errMsg = String.format("Copy volume(s) to storage(s) [%s] and VM to host [%s] failed in StorageSystemDataMotionStrategy.copyAsync. Error message: [%s].", volumesAndStorages, formatMigrationElementsAsJsonToDisplayOnLog("vm", vmTO.getId(), srcHost.getId(), destHost.getId()), ex.getMessage()); + logger.error(errMsg, ex); + } - throw new CloudRuntimeException(errMsg); + throw new CloudRuntimeException(errMsg, ex); } finally { - if (!success && !srcVolumeInfoToDestVolumeInfo.isEmpty()) { + // handlePostMigration did not run: the migration was cancelled, timed out or failed before it + if (!success && !destVolumesHandled && !srcVolumeInfoToDestVolumeInfo.isEmpty()) { for (VolumeInfo destVolumeInfo : srcVolumeInfoToDestVolumeInfo.values()) { logger.info(String.format("Expunging dest volume [id: %s, state: %s] as part of failed VM migration with volumes command for VM [%s].", destVolumeInfo.getId(), destVolumeInfo.getState(), vmTO.getId())); - destVolumeInfo.processEvent(Event.OperationFailed); - destVolumeInfo.processEvent(Event.DestroyRequested); - _volumeService.expungeVolumeAsync(destVolumeInfo); + try { + destVolumeInfo.processEvent(Event.OperationFailed); + destroyMigrationCopy(destVolumeInfo); + _volumeService.expungeVolumeAsync(_volumeDataFactory.getVolume(destVolumeInfo.getId())); + } catch (Exception e) { + logger.warn("Failed to clean up dest volume [id: {}] of VM [{}]: {}", destVolumeInfo.getId(), vmTO.getId(), e.getMessage()); + } } } @@ -2553,7 +2568,7 @@ private void handlePostMigration(boolean success, Map sr srcVolumeInfo.processEvent(Event.OperationFailed); try { - _volumeService.destroyVolume(destVolumeInfo.getId()); + destroyMigrationCopy(destVolumeInfo); destVolumeInfo = _volumeDataFactory.getVolume(destVolumeInfo.getId()); @@ -2569,6 +2584,19 @@ private void handlePostMigration(boolean success, Map sr } } + /** + * The copy's VOLUME.CREATE went out when its row reached Ready, before any data moved. Closing that usage record takes the + * Destroy to Destroy transition destroyVolume performs, which the listener turns into VOLUME.DELETE for a data disk; a ROOT + * copy is published here, since the listener leaves ROOT volumes to the VM's own destroy, which never knows this copy. + */ + protected void destroyMigrationCopy(VolumeInfo destVolumeInfo) { + _volumeService.destroyVolume(destVolumeInfo.getId()); + if (Volume.Type.ROOT.equals(destVolumeInfo.getVolumeType())) { + UsageEventUtils.publishUsageEvent(EventTypes.EVENT_VOLUME_DELETE, destVolumeInfo.getAccountId(), destVolumeInfo.getDataCenterId(), + destVolumeInfo.getId(), destVolumeInfo.getName(), Volume.class.getName(), destVolumeInfo.getUuid()); + } + } + private Long getSuitableDiskOfferingForVolumeOnPool(VolumeVO volume, StoragePoolVO pool) { List diskOfferings = _diskOfferingDao.listAllActiveAndNonComputeDiskOfferings(); for (DiskOfferingVO diskOffering : diskOfferings) { diff --git a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java index fc51eeba7b6e..552a20a80008 100644 --- a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java +++ b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java @@ -45,6 +45,10 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeService; +import com.cloud.event.UsageEventUtils; +import com.cloud.event.EventTypes; +import org.mockito.MockedStatic; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; @@ -80,6 +84,8 @@ public class StorageSystemDataMotionStrategyTest { private ImageStore destinationStore; @Mock private PrimaryDataStoreDao primaryDataStoreDao; + @Mock + private VolumeService volumeService; @Mock StoragePoolVO sourceStoragePoolVoMock, destinationStoragePoolVoMock; @@ -651,4 +657,35 @@ public void testInternalCanHandle_ClvmNgToClvm() { Assert.assertEquals(StrategyPriority.HIGHEST, result); } + + @Test + public void testDestroyingMigrationCopyClosesItsUsageRecord() { + VolumeInfo copy = mock(VolumeInfo.class); + Mockito.when(copy.getId()).thenReturn(16L); + Mockito.when(copy.getVolumeType()).thenReturn(Volume.Type.DATADISK); + + try (MockedStatic usage = Mockito.mockStatic(UsageEventUtils.class)) { + strategy.destroyMigrationCopy(copy); + // the Destroy to Destroy transition in destroyVolume publishes VOLUME.DELETE for a data disk + Mockito.verify(volumeService).destroyVolume(16L); + usage.verifyNoInteractions(); + } + } + + @Test + public void testDestroyingRootMigrationCopyPublishesTheDeleteTheListenerSkips() { + VolumeInfo copy = mock(VolumeInfo.class); + Mockito.when(copy.getId()).thenReturn(15L); + Mockito.when(copy.getVolumeType()).thenReturn(Volume.Type.ROOT); + Mockito.when(copy.getAccountId()).thenReturn(2L); + Mockito.when(copy.getDataCenterId()).thenReturn(1L); + Mockito.when(copy.getName()).thenReturn("ROOT-5"); + Mockito.when(copy.getUuid()).thenReturn("copy-uuid"); + + try (MockedStatic usage = Mockito.mockStatic(UsageEventUtils.class)) { + strategy.destroyMigrationCopy(copy); + Mockito.verify(volumeService).destroyVolume(15L); + usage.verify(() -> UsageEventUtils.publishUsageEvent(EventTypes.EVENT_VOLUME_DELETE, 2L, 1L, 15L, "ROOT-5", Volume.class.getName(), "copy-uuid")); + } + } } diff --git a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java index a94c04ff4aae..6c799f3615e0 100644 --- a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java +++ b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java @@ -838,6 +838,8 @@ public void doInTransactionWithoutResult(TransactionStatus status) { srcSnapshot.processEvent(Snapshot.Event.OperationFailed); _snapshotDetailsDao.removeDetail(srcSnapshot.getId(), AsyncJob.Constants.MS_ID); + UsageEventUtils.publishUsageEvent(EventTypes.EVENT_SNAPSHOT_OFF_PRIMARY, srcSnapshot.getAccountId(), srcSnapshot.getDataCenterId(), srcSnapshot.getId(), + srcSnapshot.getName(), null, null, 0L, 0L, srcSnapshot.getClass().getName(), srcSnapshot.getUuid()); _snapshotDao.remove(srcSnapshot.getId()); } catch (NoTransitionException ex) { logger.debug("Failed to create backup " + ex.toString()); diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java index 05bbf13c9745..33fb6b635568 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java @@ -212,7 +212,7 @@ public class VolumeServiceImpl implements VolumeService { @Inject private VolumeDetailsDao _volumeDetailsDao; @Inject - private VMTemplateDao templateDao; + VMTemplateDao templateDao; @Inject private TemplateDataFactory tmplFactory; @Inject @@ -434,8 +434,8 @@ public AsyncCallFuture expungeVolumeAsync(VolumeInfo volume) { if (logger.isDebugEnabled()) { logger.debug("Marking volume that was never created as destroyed: {}", vol); } - VMTemplateVO template = templateDao.findById(vol.getTemplateId()); - if (template != null && !template.isDeployAsIs()) { + VMTemplateVO template = vol.getTemplateId() != null ? templateDao.findById(vol.getTemplateId()) : null; + if (template == null || !template.isDeployAsIs()) { snapshotMgr.deletePoliciesForVolume(vol.getId()); volDao.remove(vol.getId()); future.complete(result); diff --git a/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceTest.java b/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceTest.java index 57d10dd800a1..2bfd3137b9dc 100644 --- a/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceTest.java +++ b/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceTest.java @@ -51,6 +51,9 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import com.cloud.storage.dao.VMTemplateDao; +import org.apache.cloudstack.storage.datastore.db.VolumeDataStoreDao; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; @@ -101,6 +104,12 @@ public class VolumeServiceTest extends TestCase{ @Mock DiskOfferingDao diskOfferingDaoMock; + @Mock + VolumeDataStoreDao volumeStoreDaoMock; + + @Mock + VMTemplateDao templateDaoMock; + @Before public void setup(){ volumeServiceImplSpy = Mockito.spy(new VolumeServiceImpl()); @@ -359,4 +368,25 @@ private void prepareOfferingsForEncryptionValidation(long diskOfferingId, boolea Mockito.when(diskOfferingDaoMock.findByIdIncludingRemoved(diskOfferingId)).thenReturn(diskOffering); Mockito.when(diskOfferingDaoMock.findById(diskOfferingId)).thenReturn(diskOffering); } + + @Test + public void testExpungeOfANeverCreatedVolumeWithoutATemplateRemovesTheRow() throws Exception { + DataStore primary = Mockito.mock(DataStore.class); + Mockito.doReturn(primary).when(volumeInfoMock).getDataStore(); + Mockito.doReturn(7L).when(volumeInfoMock).getId(); + Mockito.doReturn(null).when(volumeStoreDaoMock).findByVolume(7L); + Mockito.doReturn(volumeVoMock).when(volumeDaoMock).findById(7L); + Mockito.doReturn(7L).when(volumeVoMock).getId(); + // no path and no pool: nothing was ever created on primary storage; from a snapshot, so no template either + Mockito.doReturn(null).when(volumeVoMock).getPoolId(); + Mockito.doReturn(null).when(volumeVoMock).getTemplateId(); + volumeServiceImplSpy._volumeStoreDao = volumeStoreDaoMock; + volumeServiceImplSpy.templateDao = templateDaoMock; + + volumeServiceImplSpy.expungeVolumeAsync(volumeInfoMock); + + Mockito.verify(snapshotManagerMock).deletePoliciesForVolume(7L); + Mockito.verify(volumeDaoMock).remove(7L); + Mockito.verify(volumeInfoMock, Mockito.never()).processEvent(Mockito.any()); + } } diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobExecutionContext.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobExecutionContext.java index 465a80b62c7f..2d39e18edea3 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobExecutionContext.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobExecutionContext.java @@ -128,7 +128,7 @@ public void disjoinJob(long joinedJobId) throws InsufficientCapacityException, AsyncJobJoinMapVO record = s_joinMapDao.getJoinRecord(_job.getId(), joinedJobId); s_jobMgr.disjoinJob(_job.getId(), joinedJobId); - if (record.getJoinStatus() == JobInfo.Status.FAILED) { + if (record != null && record.getJoinStatus() == JobInfo.Status.FAILED) { if (record.getJoinResult() != null) { Object exception = JobSerializerHelper.fromObjectSerializedString(record.getJoinResult()); if (exception != null && exception instanceof Exception) { diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobManager.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobManager.java index 9f50a545efc7..9fe3039bbd06 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobManager.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/AsyncJobManager.java @@ -18,6 +18,7 @@ import java.io.Serializable; import java.util.List; +import java.util.Set; import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; import org.apache.cloudstack.jobs.JobInfo; @@ -40,6 +41,27 @@ public interface AsyncJobManager extends Manager { void completeAsyncJob(long jobId, JobInfo.Status jobStatus, int resultCode, String result); + /** In-progress jobs with a cancel requested that this server executes, and the work jobs it runs for such a job, whose in-flight work it has to stop. */ + List listCancelRequestedJobsExecutingOn(long msid); + + /** Records that the cancel reached the job's worker (a command was stopped or refused); true only for the caller that recorded it first. */ + boolean claimJobCancelDelivery(long jobId); + + /** True once the cancel reached the job's worker on any server: the commands it sends from then on are its cleanup. */ + boolean isJobCancelDelivered(long jobId); + + /** The management servers executing the job or one of its running work jobs. */ + Set listMgmtServersExecutingJob(long jobId); + + /** Completes a job whose cancellation was requested as CANCELLED once nothing of it is running any more. */ + void finalizeCancelledJob(long jobId); + + /** Why the job, or the API job a VM work job runs for, is being or has been cancelled; null when it is not. */ + String getCancellationReason(long jobId); + + /** True while the job's work jobs exist and all still wait in their sync queue, untaken by any server: nothing of its work has started. */ + boolean hasOnlyQueuedWork(long jobId); + void updateAsyncJobStatus(long jobId, int processStatus, String resultObject); void updateAsyncJobAttachment(long jobId, String instanceType, Long instanceId); diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDao.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDao.java index c334e91feb84..a738554bb66b 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDao.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDao.java @@ -70,4 +70,16 @@ public interface AsyncJobDao extends GenericDao { long countPendingNonPseudoJobs(Long... msIds); List listPendingJobIdsForAccount(long accountId); + + AsyncJobVO getRelatedJob(String jobId); + + List listChildJobs(long parentJobId); + + List getCancelRequestedJobs(long executingMsid); + + /** In-progress work jobs this server executes on behalf of another job. */ + List getRunningWorkJobs(long executingMsid); + + /** Flags the job's cancel as delivered to its worker; true only for the caller that flipped the flag. */ + boolean claimCancelDelivery(long jobId); } diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDaoImpl.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDaoImpl.java index d8385e9aecd1..a228d03a0238 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDaoImpl.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/dao/AsyncJobDaoImpl.java @@ -36,6 +36,7 @@ import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; import com.cloud.utils.db.TransactionLegacy; +import com.cloud.utils.db.UpdateBuilder; public class AsyncJobDaoImpl extends GenericDaoBase implements AsyncJobDao { @@ -50,6 +51,10 @@ public class AsyncJobDaoImpl extends GenericDaoBase implements private final SearchBuilder byIdResourceIdResourceTypeSearch; private final GenericSearchBuilder asyncJobTypeSearch; private final GenericSearchBuilder pendingNonPseudoAsyncJobsSearch; + private final SearchBuilder relatedAsyncJobSearch; + private final SearchBuilder cancelledAsyncJobSearch; + private final SearchBuilder runningWorkJobSearch; + private final SearchBuilder undeliveredCancelSearch; public AsyncJobDaoImpl() { pendingAsyncJobSearch = createSearchBuilder(); @@ -116,6 +121,31 @@ public AsyncJobDaoImpl() { pendingNonPseudoAsyncJobsSearch.and("instanceTypeNEQ", pendingNonPseudoAsyncJobsSearch.entity().getInstanceType(), SearchCriteria.Op.NEQ); pendingNonPseudoAsyncJobsSearch.and("jobStatusEQ", pendingNonPseudoAsyncJobsSearch.entity().getStatus(), SearchCriteria.Op.EQ); pendingNonPseudoAsyncJobsSearch.and("executingMsidIN", pendingNonPseudoAsyncJobsSearch.entity().getExecutingMsid(), SearchCriteria.Op.IN); + + relatedAsyncJobSearch = createSearchBuilder(); + relatedAsyncJobSearch.and("related", relatedAsyncJobSearch.entity().getRelated(), SearchCriteria.Op.EQ); + relatedAsyncJobSearch.done(); + + cancelledAsyncJobSearch = createSearchBuilder(); + cancelledAsyncJobSearch.and("status", cancelledAsyncJobSearch.entity().getStatus(), SearchCriteria.Op.EQ); + cancelledAsyncJobSearch.and("cancelRequested", cancelledAsyncJobSearch.entity().isCancelRequested(), SearchCriteria.Op.EQ); + cancelledAsyncJobSearch.and("completeMsId", cancelledAsyncJobSearch.entity().getCompleteMsid(), SearchCriteria.Op.NULL); + cancelledAsyncJobSearch.and("executingMsid", cancelledAsyncJobSearch.entity().getExecutingMsid(), SearchCriteria.Op.EQ); + cancelledAsyncJobSearch.done(); + + runningWorkJobSearch = createSearchBuilder(); + runningWorkJobSearch.and("status", runningWorkJobSearch.entity().getStatus(), SearchCriteria.Op.EQ); + runningWorkJobSearch.and("executingMsid", runningWorkJobSearch.entity().getExecutingMsid(), SearchCriteria.Op.EQ); + runningWorkJobSearch.and("related", runningWorkJobSearch.entity().getRelated(), SearchCriteria.Op.NNULL); + runningWorkJobSearch.done(); + + undeliveredCancelSearch = createSearchBuilder(); + undeliveredCancelSearch.and("id", undeliveredCancelSearch.entity().getId(), SearchCriteria.Op.EQ); + undeliveredCancelSearch.and("cancelDelivered", undeliveredCancelSearch.entity().isCancelDelivered(), SearchCriteria.Op.EQ); + undeliveredCancelSearch.and().op("cancelRequested", undeliveredCancelSearch.entity().isCancelRequested(), SearchCriteria.Op.EQ); + undeliveredCancelSearch.or("status", undeliveredCancelSearch.entity().getStatus(), SearchCriteria.Op.EQ); + undeliveredCancelSearch.cp(); + undeliveredCancelSearch.done(); } @Override @@ -309,4 +339,48 @@ public List listPendingJobIdsForAccount(long accountId) { sc.setParameters("accountId", accountId); return customSearch(sc, null); } + + @Override + public AsyncJobVO getRelatedJob(String jobId) { + SearchCriteria sc = relatedAsyncJobSearch.create(); + sc.setParameters("related", jobId); + return findOneIncludingRemovedBy(sc); + } + + @Override + public List listChildJobs(long parentJobId) { + SearchCriteria sc = relatedAsyncJobSearch.create(); + sc.setParameters("related", String.valueOf(parentJobId)); + return listBy(sc); + } + + @Override + public List getCancelRequestedJobs(long executingMsid) { + SearchCriteria sc = cancelledAsyncJobSearch.create(); + sc.setParameters("status", JobInfo.Status.IN_PROGRESS); + sc.setParameters("cancelRequested", true); + sc.setParameters("executingMsid", executingMsid); + return listBy(sc); + } + + @Override + public List getRunningWorkJobs(long executingMsid) { + SearchCriteria sc = runningWorkJobSearch.create(); + sc.setParameters("status", JobInfo.Status.IN_PROGRESS); + sc.setParameters("executingMsid", executingMsid); + return listBy(sc); + } + + @Override + public boolean claimCancelDelivery(long jobId) { + AsyncJobVO delivered = createForUpdate(); + delivered.setCancelDelivered(true); + UpdateBuilder ub = getUpdateBuilder(delivered); + SearchCriteria sc = undeliveredCancelSearch.create(); + sc.setParameters("id", jobId); + sc.setParameters("cancelDelivered", false); + sc.setParameters("cancelRequested", true); + sc.setParameters("status", JobInfo.Status.CANCELLED); + return update(ub, sc, null) == 1; + } } diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java index faa24bb88a90..f86b3860c69b 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java @@ -20,11 +20,14 @@ import static com.cloud.utils.HumanReadableJson.getHumanReadableBytesJson; import java.io.Serializable; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.Properties; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -35,9 +38,12 @@ import javax.inject.Inject; import javax.naming.ConfigurationException; +import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.command.user.job.CancelAsyncJobCmd; import org.apache.cloudstack.command.ReconcileCommandService; +import org.apache.cloudstack.api.response.ExceptionResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotDataFactory; @@ -60,6 +66,8 @@ import org.apache.cloudstack.framework.messagebus.MessageBus; import org.apache.cloudstack.framework.messagebus.MessageDetector; import org.apache.cloudstack.framework.messagebus.PublishScope; +import org.apache.cloudstack.jobs.AsyncJobService; +import org.apache.cloudstack.jobs.JobCancellationHandler; import org.apache.cloudstack.jobs.JobInfo; import org.apache.cloudstack.jobs.JobInfo.Status; import org.apache.cloudstack.managed.context.ManagedContextRunnable; @@ -68,6 +76,7 @@ import org.apache.logging.log4j.ThreadContext; import com.cloud.cluster.ClusterManagerListener; +import com.cloud.exception.OperationCancelledException; import com.cloud.network.Network; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; @@ -109,12 +118,16 @@ import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.cluster.dao.ManagementServerHostDao; +import com.cloud.cluster.ManagementServerHostVO; +import com.cloud.event.EventTypes; +import com.cloud.event.ActionEvent; import com.cloud.vm.snapshot.VMSnapshot; import com.cloud.vm.snapshot.VMSnapshotService; import com.cloud.vm.snapshot.VMSnapshotVO; import com.cloud.vm.snapshot.dao.VMSnapshotDao; -public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, ClusterManagerListener, Configurable { +public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, ClusterManagerListener, Configurable, AsyncJobService { // Advanced public static final ConfigKey JobExpireMinutes = new ConfigKey("Advanced", Long.class, "job.expire.minutes", "1440", @@ -125,12 +138,12 @@ public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, Integer.class, "vm.job.lock.timeout", "1800", "Time in seconds to wait in acquiring lock to submit a vm worker job", false); private static final ConfigKey HidePassword = new ConfigKey("Advanced", Boolean.class, "log.hide.password", "true", "If set to true, the password is hidden", true, ConfigKey.Scope.Global); + public static final ConfigKey ApiJobPoolSize = new ConfigKey<>("Advanced", Integer.class, "api.job.pool.size", "50", "Minimum size of the API job executor thread pool. Actual size is the max of this value and db.cloud.maxActive / 2.", false, ConfigKey.Scope.Global); public static final ConfigKey WorkJobPoolSize = new ConfigKey<>("Advanced", Integer.class, "work.job.pool.size", "50", "Minimum size of the Worker job executor thread pool. Actual size is the max of this value and db.cloud.maxActive * 2 / 3.", false, ConfigKey.Scope.Global); - private static final int ACQUIRE_GLOBAL_LOCK_TIMEOUT_FOR_COOPERATION = 3; // 3 seconds private static final int MAX_ONETIME_SCHEDULE_SIZE = 50; @@ -154,6 +167,8 @@ public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, @Inject private AsyncJobMonitor _jobMonitor; @Inject + private ManagementServerHostDao managementServerHostDao; + @Inject private VMInstanceDao _vmInstanceDao; @Inject private VmWorkJobDao _vmWorkJobDao; @@ -184,6 +199,9 @@ public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, private NetworkOrchestrationService networkOrchestrationService; @Inject private ReconcileCommandService reconcileCommandService; + // a list: Spring leaves it empty when no agent layer is present + @Inject + private List jobCancellationHandlers; private volatile long _executionRunNumber = 1; @@ -202,7 +220,7 @@ public String getConfigComponentName() { @Override public ConfigKey[] getConfigKeys() { - return new ConfigKey[] {JobExpireMinutes, JobCancelThresholdMinutes, VmJobLockTimeout, HidePassword, ApiJobPoolSize, WorkJobPoolSize}; + return new ConfigKey[] {JobExpireMinutes, JobCancelThresholdMinutes, VmJobLockTimeout, HidePassword, ApiJobPoolSize, WorkJobPoolSize, CancelledJobInterval}; } @Override @@ -339,18 +357,18 @@ public Long doInTransaction(TransactionStatus status) { @Override @DB - public void completeAsyncJob(final long jobId, final Status jobStatus, final int resultCode, final String resultObject) { + public void completeAsyncJob(final long jobId, final Status requestedStatus, final int requestedResultCode, final String requestedResult) { String resultObj = null; if (logger.isDebugEnabled()) { - resultObj = convertHumanReadableJson(obfuscatePassword(resultObject, HidePassword.value())); - logger.debug("Complete async job-" + jobId + ", jobStatus: " + jobStatus + ", resultCode: " + resultCode + ", result: " + resultObj); + resultObj = convertHumanReadableJson(obfuscatePassword(requestedResult, HidePassword.value())); + logger.debug("Complete async job-" + jobId + ", jobStatus: " + requestedStatus + ", resultCode: " + requestedResultCode + ", result: " + resultObj); } final AsyncJobVO job = _jobDao.findById(jobId); if (job == null) { if (logger.isDebugEnabled()) { - logger.debug("job-" + jobId + " no longer exists, we just log completion info here. " + jobStatus + ", resultCode: " + resultCode + ", result: " + + logger.debug("job-" + jobId + " no longer exists, we just log completion info here. " + requestedStatus + ", resultCode: " + requestedResultCode + ", result: " + resultObj); } // still purge item from queue to avoid any blocking @@ -358,7 +376,7 @@ public void completeAsyncJob(final long jobId, final Status jobStatus, final int return; } - if (job.getStatus() != JobInfo.Status.IN_PROGRESS) { + if (job.getStatus().done()) { if (logger.isDebugEnabled()) { logger.debug("job-" + jobId + " is already completed."); } @@ -367,6 +385,16 @@ public void completeAsyncJob(final long jobId, final Status jobStatus, final int return; } + // a job that fails after its cancellation was requested, or whose API job was cancelled, ended because of the cancellation + final String cancelReason = requestedStatus == JobInfo.Status.FAILED ? getCancellationReasonForJob(job) : null; + if (cancelReason != null) { + logger.info("job-{} failed while being cancelled; recording it as cancelled", jobId); + } + final Status jobStatus = cancelReason != null ? JobInfo.Status.CANCELLED : requestedStatus; + final int resultCode = cancelReason != null ? ApiErrorCode.INTERNAL_ERROR.getHttpCode() : requestedResultCode; + final String resultObject = cancelReason == null ? requestedResult + : isWorkJob(job) && requestedResult != null ? requestedResult : cancelledResultFor(job, cancelReason); + if (resultObject != null) { job.updateResultWithEncryptionIfNeeded(resultObject); } @@ -384,7 +412,6 @@ public List doInTransaction(final TransactionStatus status) { if (logger.isDebugEnabled()) { logger.debug("Update db status for job-" + jobId); } - job.setCompleteMsid(getMsid()); job.setStatus(jobStatus); job.setResultCode(resultCode); @@ -396,6 +423,7 @@ public List doInTransaction(final TransactionStatus status) { final Date currentGMTTime = DateUtil.currentGMTTime(); job.setLastUpdated(currentGMTTime); + job.setCompleteMsid(getMsid()); job.setRemoved(currentGMTTime); job.setExecutingMsid(null); _jobDao.update(jobId, job); @@ -671,6 +699,13 @@ protected void runInContext() { } ThreadContext.put("logcontextid", logContext); + // cancelled between being queued and being picked up: nothing to run + final AsyncJobVO current = _jobDao.findById(job.getId()); + if (current != null && current.getStatus().done()) { + logger.info("job-{} was completed as {} before it started, not executing it", job.getId(), current.getStatus()); + return; + } + // execute the job if (logger.isDebugEnabled()) { logger.debug("Executing " + StringUtils.cleanString(job.toString())); @@ -906,6 +941,9 @@ protected void reallyRun() { List l = _queueMgr.dequeueFromAny(getMsid(), MAX_ONETIME_SCHEDULE_SIZE); if (l != null && l.size() > 0) { for (SyncQueueItemVO item : l) { + if (isChildOfFinishedJob(item)) { + continue; + } if (logger.isDebugEnabled()) { logger.debug("Execute sync-queue item: " + item.toString()); } @@ -917,8 +955,13 @@ protected void reallyRun() { for (Long jobId : standaloneWakeupJobs) { // TODO, we assume that all jobs in this category is API job only AsyncJobVO job = _jobDao.findById(jobId); - if (job != null && (job.getPendingSignals() & AsyncJob.Constants.SIGNAL_MASK_WAKEUP) != 0) - scheduleExecution(job, false); + if (job != null && (job.getPendingSignals() & AsyncJob.Constants.SIGNAL_MASK_WAKEUP) != 0) { + if (job.isCancelRequested() || job.getStatus() == Status.CANCELLED) { + finalizeCancelledJob(job.getId()); + } else { + scheduleExecution(job, false); + } + } } } catch (Throwable e) { logger.error("Unexpected exception when trying to execute queue item, ", e); @@ -957,7 +1000,7 @@ public void reallyRun() { try { if (item.getContentType().equalsIgnoreCase(SyncQueueItem.AsyncJobContentType)) { logger.info("Remove Job-" + item.getContentId() + " from Queue-" + item.getId() + " since it has been blocked for too long"); - completeAsyncJob(item.getContentId(), JobInfo.Status.FAILED, 0, "Job is cancelled as it has been blocking others for too long"); + completeAsyncJob(item.getContentId(), JobInfo.Status.CANCELLED, 0, "Job is cancelled as it has been blocking others for too long"); _jobMonitor.unregisterByJobId(item.getContentId()); } @@ -1110,12 +1153,10 @@ public boolean configure(String name, Map params) throws Configu int apiPoolSize = Math.max(ApiJobPoolSize.value(), defaultApiPoolSize); int workPoolSize = Math.max(WorkJobPoolSize.value(), defaultWorkPoolSize); - logger.info("Start AsyncJobManager API executor thread pool in size " + apiPoolSize + - " (configured=" + ApiJobPoolSize.value() + ", db.derived=" + defaultApiPoolSize + ", db.cloud.maxActive=" + cloudMaxActive + ")"); + logger.info("Start AsyncJobManager API executor thread pool in size {} (configured={}, db.derived={}, db.cloud.maxActive={})", apiPoolSize, ApiJobPoolSize.value(), defaultApiPoolSize, cloudMaxActive); _apiJobExecutor = Executors.newFixedThreadPool(apiPoolSize, new NamedThreadFactory(AsyncJobManager.API_JOB_POOL_THREAD_PREFIX)); - logger.info("Start AsyncJobManager Work executor thread pool in size " + workPoolSize + - " (configured=" + WorkJobPoolSize.value() + ", db.derived=" + defaultWorkPoolSize + ", db.cloud.maxActive=" + cloudMaxActive + ")"); + logger.info("Start AsyncJobManager Work executor thread pool in size {} (configured={}, db.derived={}, db.cloud.maxActive={})", workPoolSize, WorkJobPoolSize.value(), defaultWorkPoolSize, cloudMaxActive); _workerJobExecutor = Executors.newFixedThreadPool(workPoolSize, new NamedThreadFactory(AsyncJobManager.WORK_JOB_POOL_THREAD_PREFIX)); } catch (final Exception e) { throw new ConfigurationException("Unable to load db.properties to configure AsyncJobManagerImpl"); @@ -1166,9 +1207,12 @@ public void doInTransactionWithoutResult(TransactionStatus status) { logger.debug("Cancel left-over job-" + job.getId()); } cleanupResources(job); - job.setStatus(JobInfo.Status.FAILED); + // a job whose cancellation was requested ends cancelled, as it would have through completeAsyncJob + final String cancelReason = job.isCancelRequested() ? getJobCancelReasonFromResult(job) : null; + job.setStatus(cancelReason != null ? JobInfo.Status.CANCELLED : JobInfo.Status.FAILED); job.setResultCode(ApiErrorCode.INTERNAL_ERROR.getHttpCode()); - job.updateResultWithEncryptionIfNeeded("job cancelled because of management server restart or shutdown"); + job.updateResultWithEncryptionIfNeeded(cancelReason != null ? cancelledResultFor(job, cancelReason + " (management server restart or shutdown)") + : "job cancelled because of management server restart or shutdown"); job.setCompleteMsid(msid); final Date currentGMTTime = DateUtil.currentGMTTime(); job.setLastUpdated(currentGMTTime); @@ -1486,6 +1530,377 @@ public boolean stop() { return true; } + + private String managementServerName(final long msid) { + final ManagementServerHostVO managementServer = managementServerHostDao.findByMsid(msid); + return managementServer != null ? managementServer.getName() : String.valueOf(msid); + } + + static String cancelledResult(final String text) { + final ExceptionResponse response = new ExceptionResponse(); + response.setErrorCode(ApiErrorCode.INTERNAL_ERROR.getHttpCode()); + response.setErrorText(text); + return JobSerializerHelper.toSerializedString(response); + } + + static String cancelledResultFor(final AsyncJob job, final String text) { + return isWorkJob(job) ? JobSerializerHelper.toObjectSerializedString(new OperationCancelledException(text)) : cancelledResult(text); + } + + static boolean isWorkJob(final AsyncJob job) { + final String dispatcher = job != null ? job.getDispatcher() : null; + return dispatcher != null && !dispatcher.equalsIgnoreCase("ApiAsyncJobDispatcher") && !AsyncJobVO.JOB_DISPATCHER_PSEUDO.equals(dispatcher); + } + + @Override + public String getCancellationReason(final long jobId) { + final AsyncJobVO job = _jobDao.findByIdIncludingRemoved(jobId); + return job != null ? getCancellationReasonForJob(job) : null; + } + + private String getCancellationReasonForJob(final AsyncJobVO job) { + if (isJobCancelling(job)) { + final String reason = getJobCancelReasonFromResult(job); + return reason != null ? reason : "job-" + job.getId() + " was cancelled"; + } + if (job.getRelated() != null && !job.getRelated().isEmpty()) { + try { + final AsyncJobVO parent = _jobDao.findByIdIncludingRemoved(Long.parseLong(job.getRelated())); + if (parent != null && isJobCancelling(parent)) { + final String reason = getJobCancelReasonFromResult(parent); + return reason != null ? reason : "job-" + parent.getId() + " was cancelled"; + } + } catch (final NumberFormatException ignored) { + return null; + } + } + for (final AsyncJobVO child : _jobDao.listChildJobs(job.getId())) { + if (isJobCancelling(child)) { + final String reason = getJobCancelReasonFromResult(child); + return reason != null ? reason : "job-" + child.getId() + " was cancelled"; + } + } + return null; + } + + private static boolean isJobCancelling(final AsyncJobVO job) { + return job.getStatus() == JobInfo.Status.CANCELLED || (job.isCancelRequested() && job.getStatus() == JobInfo.Status.IN_PROGRESS); + } + + static String getJobCancelReasonFromResult(final AsyncJobVO job) { + if (job.getResult() == null) { + return null; + } + try { + final Object result = JobSerializerHelper.fromSerializedString(job.getResult()); + if (result instanceof ExceptionResponse) { + return ((ExceptionResponse)result).getErrorText(); + } + } catch (final RuntimeException ignored) { + // not an API result; a work job's is tried next + } + try { + final Object result = JobSerializerHelper.fromObjectSerializedString(job.getResult()); + return result instanceof OperationCancelledException ? ((OperationCancelledException)result).getReason() : null; + } catch (final RuntimeException e) { + return null; + } + } + + private void requestJobCancel(final AsyncJobVO job, final String cancelledBy) { + job.setCancelRequested(true); + job.setCancelDelivered(false); + job.setResultCode(ApiErrorCode.INTERNAL_ERROR.getHttpCode()); + job.updateResultWithEncryptionIfNeeded(cancelledResult(cancelledBy)); + job.setLastUpdated(DateUtil.currentGMTTime()); + _jobDao.update(job.getId(), job); + } + + private void withdrawJobCancel(final long jobId) { + final AsyncJobVO job = _jobDao.findById(jobId); + if (job == null || !job.isCancelRequested() || job.getStatus() != JobInfo.Status.IN_PROGRESS) { + return; + } + job.setCancelRequested(false); + job.setCancelDelivered(false); + job.setResultCode(0); + job.updateResultWithEncryptionIfNeeded(null); + job.setLastUpdated(DateUtil.currentGMTTime()); + _jobDao.update(jobId, job); + for (final JobCancellationHandler handler : getJobCancellationHandlers()) { + handler.jobCancelWithdrawn(jobId); + } + } + + @Override + public boolean hasOnlyQueuedWork(final long jobId) { + final List children = _jobDao.listChildJobs(jobId); + if (children.isEmpty()) { + return false; + } + for (final AsyncJobVO child : children) { + if (child.getStatus() == JobInfo.Status.CANCELLED) { + // completed as cancelled at dequeue, after the request: it never ran + continue; + } + if (child.getStatus() != JobInfo.Status.IN_PROGRESS) { + return false; + } + final Long queueItemId = _queueItemDao.getQueueItemIdByContentIdAndType(child.getId(), SyncQueueItem.AsyncJobContentType); + final SyncQueueItemVO item = queueItemId != null ? _queueItemDao.findById(queueItemId) : null; + if (item == null || item.getLastProcessMsid() != null) { + return false; + } + } + return true; + } + + void cancelQueuedChildJobs(final long parentJobId, final String reason) { + for (final AsyncJobVO child : _jobDao.listChildJobs(parentJobId)) { + if (child.getStatus() != null && child.getStatus().done()) { + continue; + } + final Long queueItemId = _queueItemDao.getQueueItemIdByContentIdAndType(child.getId(), SyncQueueItem.AsyncJobContentType); + if (queueItemId == null) { + continue; + } + final SyncQueueItemVO item = _queueItemDao.findById(queueItemId); + if (item == null || item.getLastProcessMsid() != null) { + // dequeued already: it is executing, and the in-flight path owns it + continue; + } + logger.info("Cancelling queued job-{}, its parent job-{} was cancelled before it was scheduled", child.getId(), parentJobId); + completeAsyncJob(child.getId(), JobInfo.Status.CANCELLED, ApiErrorCode.INTERNAL_ERROR.getHttpCode(), cancelledResultFor(child, reason + " (parent job cancelled before this job was scheduled)")); + _jobMonitor.unregisterByJobId(child.getId()); + _queueMgr.purgeItem(queueItemId); + // the parent learns of the cancel from this outcome: what it sends next is its cleanup, not work to refuse + _jobDao.claimCancelDelivery(parentJobId); + } + } + + /** A queued VM work job whose parent already finished is completed with the parent's status instead of run. */ + private boolean isChildOfFinishedJob(final SyncQueueItemVO item) { + if (!SyncQueueItem.AsyncJobContentType.equalsIgnoreCase(item.getContentType())) { + return false; + } + final AsyncJobVO job = _jobDao.findById(item.getContentId()); + if (job == null || StringUtils.isBlank(job.getRelated())) { + return false; + } + final AsyncJobVO parentJob = _jobDao.findById(Long.parseLong(job.getRelated())); + if (parentJob == null || isPseudoJob(parentJob) || !(parentJob.getStatus().done() || parentJob.isCancelRequested())) { + return false; + } + + logger.debug("Not executing sync-queue item {}: parent job-{} is already {}", item, parentJob.getId(), parentJob.getStatus()); + if (isJobCancelling(parentJob)) { + final String reason = getJobCancelReasonFromResult(parentJob); + completeAsyncJob(item.getContentId(), Status.CANCELLED, ApiErrorCode.INTERNAL_ERROR.getHttpCode(), + cancelledResultFor(job, (reason != null ? reason : "job-" + parentJob.getId() + " was cancelled") + " (parent job cancelled before this job was scheduled)")); + _jobDao.claimCancelDelivery(parentJob.getId()); + } else { + completeAsyncJob(item.getContentId(), parentJob.getStatus(), 0, + "Job is not scheduled for execution as the parent job is done. Parent job state: " + parentJob.getStatus()); + } + _jobMonitor.unregisterByJobId(item.getContentId()); + _queueMgr.purgeItem(item.getId()); + if (parentJob.isCancelRequested() && parentJob.getStatus() == Status.IN_PROGRESS) { + finalizeCancelledJob(parentJob.getId()); + } + return true; + } + + private boolean isPseudoJob(final AsyncJob job) { + return AsyncJobVO.JOB_DISPATCHER_PSEUDO.equals(job.getDispatcher()) && AsyncJobVO.PSEUDO_JOB_INSTANCE_TYPE.equals(job.getInstanceType()); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_JOB_CANCEL, eventDescription = "cancelling async job") + public void cancelAsyncJob(final long jobId, final String reason) { + final AsyncJobVO job = _jobDao.findByIdIncludingRemoved(jobId); + String errMessage; + if (job == null) { + errMessage = "Cannot cancel, job no longer exists."; + logger.debug(errMessage); + _queueMgr.purgeAsyncJobQueueItemId(jobId); + throw new CloudRuntimeException(errMessage); + } + + final String cancelledBy = reason + " via " + managementServerName(getMsid()); + if (CallContext.current() != null) { + CallContext.current().setEventDetails("Job id: " + job.getUuid() + ". " + cancelledBy); + } + + if (job.isCancelRequested() && job.getStatus() == JobInfo.Status.IN_PROGRESS) { + final String earlier = getJobCancelReasonFromResult(job); + throw refused("Cannot cancel job-" + jobId + ", it is already being cancelled" + (earlier != null ? " (" + earlier + ")" : "") + "."); + } + + boolean onlyQueuedWork = false; + if (job.getExecutingMsid() != null || isActiveJob(jobId)) { + try { + final Class cmdClass = Class.forName(job.getCmd()); + final APICommand apiCommand = cmdClass.getAnnotation(APICommand.class); + if (apiCommand == null && job.getRelated() != null && !job.getRelated().isEmpty()) { + throw refused("Cannot cancel job-" + jobId + ", it runs on behalf of job-" + job.getRelated() + "; cancel that job instead."); + } + if (apiCommand == null || !apiCommand.cancellable()) { + // none of its work has started while its work jobs are all still queued; cancelling then only frees the queue + if (!hasOnlyQueuedWork(jobId)) { + throw refused("Cannot cancel, job-" + jobId + " is not cancellable."); + } + logger.info("job-{} is not cancellable, but its work jobs are all still queued and nothing has started; cancelling it", jobId); + onlyQueuedWork = true; + } + } catch (final ClassNotFoundException e) { + errMessage = "Command " + job.getCmd() + " of jobid " + job.getUuid() + " not found."; + logger.error(errMessage, e); + throw new CloudRuntimeException(errMessage); + } + } + + if (job.getStatus() != null && job.getStatus().done()) { + _queueMgr.purgeAsyncJobQueueItemId(jobId); + throw refused("Cannot cancel, job-" + jobId + " is not running. Current job status is " + job.getStatus() + "."); + } + + // ask before changing state: a job whose work cannot be stopped is refused, not recorded as cancelled + for (final JobCancellationHandler handler : getJobCancellationHandlers()) { + final String refusal = handler.checkJobCannotBeCancelled(jobId); + if (refusal != null) { + throw refused("Cannot cancel job-" + jobId + ", its work cannot be stopped at this point: " + refusal); + } + } + + logger.info("Cancellation of job-{} requested: {}", jobId, cancelledBy); + requestJobCancel(job, cancelledBy); + // the flag stops a queued work job from starting; one taken by a server before the flag is work under way that cannot be stopped + if (onlyQueuedWork && !hasOnlyQueuedWork(jobId)) { + withdrawJobCancel(jobId); + throw refused("Cannot cancel job-" + jobId + ", its work started meanwhile and cannot be stopped."); + } + try { + for (final JobCancellationHandler handler : getJobCancellationHandlers()) { + if (!handler.cancelJobExecution(jobId, reason)) { + withdrawJobCancel(jobId); + throw refused("Cannot cancel job-" + jobId + ", the operation it is running could not be stopped."); + } + } + } catch (final CloudRuntimeException e) { + throw e; + } catch (final Throwable t) { + withdrawJobCancel(jobId); + errMessage = "Unexpected exception when cancelling async job with id: " + jobId; + logger.error(errMessage, t); + throw new CloudRuntimeException(errMessage, t); + } + + final Long executingMsid = job.getExecutingMsid(); + if (executingMsid == null || executingMsid.equals(getMsid())) { + finalizeCancelledJob(jobId); + } + cancelQueuedChildJobs(jobId, cancelledBy); + } + + /** A refusal is the answer to a cancel the work cannot honour; the action event records it as such, with the reason, not as an error. */ + private CloudRuntimeException refused(final String message) { + logger.info(message); + if (CallContext.current() != null) { + CallContext.current().putContextParameter(CallContext.REFUSAL_REASON_PARAM, message); + } + return new CloudRuntimeException(message); + } + + @Override + public List listCancelRequestedJobsExecutingOn(final long msid) { + final List jobs = new ArrayList<>(_jobDao.getCancelRequestedJobs(msid)); + // a VM work job taken by this server runs for an API job that may be cancelled on another one + for (final AsyncJobVO work : _jobDao.getRunningWorkJobs(msid)) { + final AsyncJobVO parent = parentOf(work); + if (parent != null && isJobCancelling(parent)) { + jobs.add(work); + } + } + return jobs; + } + + private AsyncJobVO parentOf(final AsyncJobVO job) { + if (StringUtils.isBlank(job.getRelated())) { + return null; + } + try { + return _jobDao.findByIdIncludingRemoved(Long.parseLong(job.getRelated())); + } catch (final NumberFormatException e) { + return null; + } + } + + @Override + public boolean claimJobCancelDelivery(final long jobId) { + return _jobDao.claimCancelDelivery(jobId); + } + + @Override + public Set listMgmtServersExecutingJob(final long jobId) { + final Set servers = new HashSet<>(); + final AsyncJobVO job = _jobDao.findByIdIncludingRemoved(jobId); + if (job != null && job.getExecutingMsid() != null) { + servers.add(job.getExecutingMsid()); + } + for (final AsyncJobVO child : _jobDao.listChildJobs(jobId)) { + if (child.getStatus() == JobInfo.Status.IN_PROGRESS && child.getExecutingMsid() != null) { + servers.add(child.getExecutingMsid()); + } + } + return servers; + } + + @Override + public boolean isJobCancelDelivered(final long jobId) { + final AsyncJobVO job = _jobDao.findByIdIncludingRemoved(jobId); + return job != null && job.isCancelDelivered(); + } + + @Override + public void finalizeCancelledJob(final long jobId) { + final AsyncJobVO job = _jobDao.findById(jobId); + if (job == null || !job.isCancelRequested() || job.getStatus() != JobInfo.Status.IN_PROGRESS) { + return; + } + if (_jobMonitor.isActiveJob(jobId)) { + logger.debug("Job-{} is cancelled but a thread on this server is still running it, it ends when that does", jobId); + return; + } + for (final AsyncJobVO child : _jobDao.listChildJobs(jobId)) { + if (child.getStatus() == JobInfo.Status.IN_PROGRESS) { + logger.debug("Job-{} is cancelled but its child job-{} is still running, it ends when that does", jobId, child.getId()); + return; + } + } + final String reason = getJobCancelReasonFromResult(job); + completeAsyncJob(jobId, JobInfo.Status.CANCELLED, ApiErrorCode.INTERNAL_ERROR.getHttpCode(), cancelledResult(reason != null ? reason : "job-" + jobId + " was cancelled")); + } + + private List getJobCancellationHandlers() { + return jobCancellationHandlers != null ? jobCancellationHandlers : Collections.emptyList(); + } + + private boolean isActiveJob(final long jobId) { + // a VM work child runs on behalf of its API parent + final AsyncJobVO relatedJob = _jobDao.getRelatedJob(String.valueOf(jobId)); + if (relatedJob != null) { + return _jobMonitor.isActiveJob(relatedJob.getId()); + } + return _jobMonitor.isActiveJob(jobId); + } + + @Override + public List> getCommands() { + final List> cmdList = new ArrayList<>(); + cmdList.add(CancelAsyncJobCmd.class); + return cmdList; + } + private GenericSearchBuilder ContentIdsSearch; private GenericSearchBuilder JoinJobSearch; private SearchBuilder JobIdsSearch; diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobMonitor.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobMonitor.java index b2216cb75025..d69c5978456f 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobMonitor.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobMonitor.java @@ -17,8 +17,10 @@ package org.apache.cloudstack.framework.jobs.impl; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.Map; +import java.util.Set; import java.util.Timer; import java.util.concurrent.atomic.AtomicInteger; @@ -39,7 +41,8 @@ public class AsyncJobMonitor extends ManagerBase { @Inject private MessageBus _messageBus; - private final Map _activeTasks = new HashMap(); + private final Map _runNumberToActiveTasksMap = new HashMap<>(); + private final Set _activeJobs = new HashSet<>(); private final Timer _timer = new Timer(); private final AtomicInteger _activePoolThreads = new AtomicInteger(); @@ -72,7 +75,7 @@ public void setInactivityWarningThresholdMs(long thresholdMs) { public void onJobHeartbeatNotify(String subject, String senderAddress, Object args) { if (args != null && args instanceof Long) { synchronized (this) { - ActiveTaskRecord record = _activeTasks.get(args); + ActiveTaskRecord record = _runNumberToActiveTasksMap.get(args); if (record != null) { record.updateJobHeartbeatTick(); } @@ -82,7 +85,7 @@ public void onJobHeartbeatNotify(String subject, String senderAddress, Object ar private void heartbeat() { synchronized (this) { - for (Map.Entry entry : _activeTasks.entrySet()) { + for (Map.Entry entry : _runNumberToActiveTasksMap.entrySet()) { if (entry.getValue().millisSinceLastJobHeartbeat() > _inactivityWarningThresholdMs) { logger.warn("Task (job-" + entry.getValue().getJobId() + ") has been pending for " + entry.getValue().millisSinceLastJobHeartbeat() / 1000 + " seconds"); @@ -109,23 +112,24 @@ protected void runInContext() { public void registerActiveTask(long runNumber, long jobId) { synchronized (this) { logger.info("Add job-" + jobId + " into job monitoring"); - - assert (_activeTasks.get(runNumber) == null); + assert (_runNumberToActiveTasksMap.get(runNumber) == null); long threadId = Thread.currentThread().getId(); boolean fromPoolThread = Thread.currentThread().getName().contains(AsyncJobManager.API_JOB_POOL_THREAD_PREFIX); ActiveTaskRecord record = new ActiveTaskRecord(jobId, threadId, fromPoolThread); - _activeTasks.put(runNumber, record); - if (fromPoolThread) + _runNumberToActiveTasksMap.put(runNumber, record); + _activeJobs.add(jobId); + if (fromPoolThread) { _activePoolThreads.incrementAndGet(); - else + } else { _activeInplaceThreads.incrementAndGet(); + } } } public void unregisterActiveTask(long runNumber) { synchronized (this) { - ActiveTaskRecord record = _activeTasks.get(runNumber); + ActiveTaskRecord record = _runNumberToActiveTasksMap.get(runNumber); assert (record != null); if (record != null) { logger.info("Remove job-" + record.getJobId() + " from job monitoring"); @@ -135,18 +139,19 @@ public void unregisterActiveTask(long runNumber) { else _activeInplaceThreads.decrementAndGet(); - _activeTasks.remove(runNumber); + _runNumberToActiveTasksMap.remove(runNumber); + _activeJobs.remove(record.getJobId()); } } } public void unregisterByJobId(long jobId) { synchronized (this) { - Iterator> it = _activeTasks.entrySet().iterator(); + Iterator> it = _runNumberToActiveTasksMap.entrySet().iterator(); while (it.hasNext()) { Map.Entry entry = it.next(); if (entry.getValue().getJobId() == jobId) { - logger.info("Remove Job-" + entry.getValue().getJobId() + " from job monitoring due to job cancelling"); + logger.info("Remove Job-{} from job monitoring due to job cancelling", entry.getValue().getJobId()); if (entry.getValue().isPoolThread()) _activePoolThreads.decrementAndGet(); @@ -156,6 +161,13 @@ public void unregisterByJobId(long jobId) { it.remove(); } } + _activeJobs.remove(jobId); + } + } + + public boolean isActiveJob(long jobId) { + synchronized (this) { + return _activeJobs.contains(jobId); } } diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobVO.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobVO.java index 4ef7876f8030..dd7d5a9d1606 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobVO.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobVO.java @@ -91,6 +91,12 @@ public class AsyncJobVO implements AsyncJob, JobInfo { @Column(name = "job_process_status") private int processStatus; + @Column(name = "cancel_requested") + private boolean cancelRequested; + + @Column(name = "cancel_delivered") + private boolean cancelDelivered; + @Column(name = "job_result_code") private int resultCode; @@ -250,6 +256,22 @@ public Status getStatus() { return status; } + public boolean isCancelRequested() { + return cancelRequested; + } + + public void setCancelRequested(boolean cancelRequested) { + this.cancelRequested = cancelRequested; + } + + public boolean isCancelDelivered() { + return cancelDelivered; + } + + public void setCancelDelivered(boolean cancelDelivered) { + this.cancelDelivered = cancelDelivered; + } + public void setStatus(Status status) { this.status = status; } diff --git a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java index 7223b8d07f57..de2694744204 100644 --- a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java +++ b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java @@ -22,6 +22,9 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; +import com.cloud.cluster.dao.ManagementServerHostDao; +import com.cloud.exception.OperationCancelledException; +import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.network.Network; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; @@ -35,16 +38,22 @@ import com.cloud.vm.dao.VMInstanceDao; import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.response.ExceptionResponse; +import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.jobs.dao.AsyncJobDao; +import org.apache.cloudstack.framework.jobs.dao.SyncQueueItemDao; +import org.apache.cloudstack.jobs.JobCancellationHandler; +import org.apache.cloudstack.jobs.JobInfo; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -52,6 +61,7 @@ import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; +import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; @@ -81,6 +91,14 @@ public class AsyncJobManagerImplTest { NetworkOrchestrationService networkOrchestrationService; @Mock AsyncJobDao asyncJobDao; + @Mock + SyncQueueItemDao queueItemDao; + @Mock + SyncQueueManager queueMgr; + @Mock + AsyncJobMonitor jobMonitor; + @Mock + ManagementServerHostDao managementServerHostDao; private static final long NETWORK_ID = 42L; private static final long ACCOUNT_ID = 2L; @@ -323,6 +341,12 @@ private int workerPoolCoreSize() throws Exception { return ((ThreadPoolExecutor) executor).getCorePoolSize(); } + private void setField(String name, Object value) throws Exception { + Field f = AsyncJobManagerImpl.class.getDeclaredField(name); + f.setAccessible(true); + f.set(asyncJobManager, value); + } + private Object readField(String name) throws Exception { Field f = AsyncJobManagerImpl.class.getDeclaredField(name); f.setAccessible(true); @@ -364,4 +388,299 @@ private void setDbPropertiesStatics(Properties props, boolean loaded) throws Exc propsField.set(null, props); loadedField.set(null, loaded); } + + private AsyncJobVO childJob(long id, JobInfo.Status status) { + AsyncJobVO child = new AsyncJobVO(); + child.setId(id); + child.setStatus(status); + child.setRelated("10"); + return child; + } + + private AsyncJobVO workJob(long id, JobInfo.Status status) { + AsyncJobVO work = childJob(id, status); + work.setDispatcher("VmWorkJobDispatcher"); + return work; + } + + private SyncQueueItemVO queueItem(long id, Long lastProcessMsid) { + SyncQueueItemVO item = new SyncQueueItemVO(); + item.setId(id); + item.setLastProcessMsid(lastProcessMsid); + return item; + } + + @Test + public void testCancelQueuedChildJobsCompletesAndPurgesWaitingChild() { + when(asyncJobDao.listChildJobs(10L)).thenReturn(Collections.singletonList(workJob(11L, JobInfo.Status.IN_PROGRESS))); + when(queueItemDao.getQueueItemIdByContentIdAndType(11L, SyncQueueItem.AsyncJobContentType)).thenReturn(5L); + when(queueItemDao.findById(5L)).thenReturn(queueItem(5L, null)); + Mockito.doNothing().when(asyncJobManager).completeAsyncJob(Mockito.eq(11L), Mockito.eq(JobInfo.Status.CANCELLED), Mockito.eq(ApiErrorCode.INTERNAL_ERROR.getHttpCode()), Mockito.anyString()); + + asyncJobManager.cancelQueuedChildJobs(10L, "user request"); + + ArgumentCaptor result = ArgumentCaptor.forClass(String.class); + Mockito.verify(asyncJobManager).completeAsyncJob(Mockito.eq(11L), Mockito.eq(JobInfo.Status.CANCELLED), Mockito.eq(ApiErrorCode.INTERNAL_ERROR.getHttpCode()), result.capture()); + // the parent reads a work job's result as a serialized object + Object cause = JobSerializerHelper.fromObjectSerializedString(result.getValue()); + Assert.assertTrue(String.valueOf(cause), cause instanceof OperationCancelledException); + Assert.assertTrue(((OperationCancelledException) cause).getReason().contains("user request")); + Mockito.verify(jobMonitor).unregisterByJobId(11L); + Mockito.verify(queueMgr).purgeItem(5L); + // the parent's cleanup after this outcome must not be refused as the job's first command + Mockito.verify(asyncJobDao).claimCancelDelivery(10L); + } + + @Test + public void testCancelledResultOfAWorkJobIsAnExceptionItsParentCanRethrow() { + String serialized = AsyncJobManagerImpl.cancelledResultFor(workJob(11L, JobInfo.Status.IN_PROGRESS), "Cancelled by admin via mgmt2"); + + Object cause = JobSerializerHelper.fromObjectSerializedString(serialized); + Assert.assertTrue(cause instanceof OperationCancelledException); + Assert.assertEquals("Cancelled by admin via mgmt2", ((OperationCancelledException) cause).getReason()); + Assert.assertEquals("Cancelled by admin via mgmt2", ((OperationCancelledException) cause).getMessage()); + + AsyncJobVO work = workJob(11L, JobInfo.Status.CANCELLED); + work.setResult(serialized); + Assert.assertEquals("Cancelled by admin via mgmt2", AsyncJobManagerImpl.getJobCancelReasonFromResult(work)); + } + + @Test + public void testCancelledResultOfAnApiJobIsAnApiError() { + AsyncJobVO api = childJob(10L, JobInfo.Status.IN_PROGRESS); + api.setDispatcher("ApiAsyncJobDispatcher"); + Assert.assertFalse(AsyncJobManagerImpl.isWorkJob(api)); + Assert.assertFalse(AsyncJobManagerImpl.isWorkJob(childJob(10L, JobInfo.Status.IN_PROGRESS))); + Assert.assertTrue(AsyncJobManagerImpl.isWorkJob(workJob(11L, JobInfo.Status.IN_PROGRESS))); + + String serialized = AsyncJobManagerImpl.cancelledResultFor(api, "Cancelled by admin via mgmt2"); + Assert.assertEquals(AsyncJobManagerImpl.cancelledResult("Cancelled by admin via mgmt2"), serialized); + api.setResult(serialized); + Assert.assertEquals("Cancelled by admin via mgmt2", AsyncJobManagerImpl.getJobCancelReasonFromResult(api)); + } + + @Test + public void testCancelQueuedChildJobsLeavesDequeuedChildToTheInFlightPath() { + when(asyncJobDao.listChildJobs(10L)).thenReturn(Collections.singletonList(childJob(11L, JobInfo.Status.IN_PROGRESS))); + when(queueItemDao.getQueueItemIdByContentIdAndType(11L, SyncQueueItem.AsyncJobContentType)).thenReturn(5L); + when(queueItemDao.findById(5L)).thenReturn(queueItem(5L, 1L)); + + asyncJobManager.cancelQueuedChildJobs(10L, "user request"); + + Mockito.verify(asyncJobManager, Mockito.never()).completeAsyncJob(Mockito.anyLong(), Mockito.any(), Mockito.anyInt(), Mockito.anyString()); + Mockito.verify(jobMonitor, Mockito.never()).unregisterByJobId(Mockito.anyLong()); + Mockito.verify(queueMgr, Mockito.never()).purgeItem(Mockito.anyLong()); + } + + @Test + public void testCancelQueuedChildJobsSkipsFinishedAndUnqueuedChildren() { + when(asyncJobDao.listChildJobs(10L)).thenReturn(Arrays.asList(childJob(11L, JobInfo.Status.SUCCEEDED), childJob(12L, JobInfo.Status.IN_PROGRESS))); + when(queueItemDao.getQueueItemIdByContentIdAndType(12L, SyncQueueItem.AsyncJobContentType)).thenReturn(null); + + asyncJobManager.cancelQueuedChildJobs(10L, "user request"); + + Mockito.verify(queueItemDao, Mockito.never()).getQueueItemIdByContentIdAndType(Mockito.eq(11L), Mockito.anyString()); + Mockito.verify(asyncJobManager, Mockito.never()).completeAsyncJob(Mockito.anyLong(), Mockito.any(), Mockito.anyInt(), Mockito.anyString()); + Mockito.verify(queueMgr, Mockito.never()).purgeItem(Mockito.anyLong()); + } + + @Test + public void testCancellationReasonComesFromTheStoredRequest() { + AsyncJobVO parent = childJob(10L, JobInfo.Status.IN_PROGRESS); + parent.setCancelRequested(true); + parent.setRelated(null); + parent.setResult(AsyncJobManagerImpl.cancelledResult("Cancelled by admin via mgmt2")); + when(asyncJobDao.findByIdIncludingRemoved(10L)).thenReturn(parent); + when(asyncJobDao.findByIdIncludingRemoved(11L)).thenReturn(childJob(11L, JobInfo.Status.IN_PROGRESS)); + AsyncJobVO other = childJob(12L, JobInfo.Status.IN_PROGRESS); + other.setRelated(""); + when(asyncJobDao.findByIdIncludingRemoved(12L)).thenReturn(other); + + Assert.assertEquals("Cancelled by admin via mgmt2", asyncJobManager.getCancellationReason(10L)); + Assert.assertEquals("Cancelled by admin via mgmt2", asyncJobManager.getCancellationReason(11L)); + Assert.assertNull(asyncJobManager.getCancellationReason(12L)); + } + + @Test + public void testFinalisationWaitsForARunningChildAndThenCancelsTheParent() { + AsyncJobVO parent = childJob(10L, JobInfo.Status.IN_PROGRESS); + parent.setCancelRequested(true); + parent.setRelated(null); + parent.setResult(AsyncJobManagerImpl.cancelledResult("Cancelled by admin via mgmt2")); + when(asyncJobDao.findById(10L)).thenReturn(parent); + AsyncJobVO child = childJob(11L, JobInfo.Status.IN_PROGRESS); + when(asyncJobDao.listChildJobs(10L)).thenReturn(Collections.singletonList(child)); + Mockito.doNothing().when(asyncJobManager).completeAsyncJob(Mockito.anyLong(), Mockito.any(), Mockito.anyInt(), Mockito.anyString()); + + asyncJobManager.finalizeCancelledJob(10L); + Mockito.verify(asyncJobManager, Mockito.never()).completeAsyncJob(Mockito.anyLong(), Mockito.any(), Mockito.anyInt(), Mockito.anyString()); + + child.setStatus(JobInfo.Status.CANCELLED); + asyncJobManager.finalizeCancelledJob(10L); + Mockito.verify(asyncJobManager).completeAsyncJob(Mockito.eq(10L), Mockito.eq(JobInfo.Status.CANCELLED), Mockito.anyInt(), Mockito.contains("Cancelled by admin via mgmt2")); + } + + @Test + public void testFinalisationWaitsForTheThreadStillRunningTheJob() { + AsyncJobVO job = childJob(10L, JobInfo.Status.IN_PROGRESS); + job.setCancelRequested(true); + job.setRelated(null); + job.setResult(AsyncJobManagerImpl.cancelledResult("Cancelled by admin via mgmt2")); + when(asyncJobDao.findById(10L)).thenReturn(job); + when(jobMonitor.isActiveJob(10L)).thenReturn(true, false); + Mockito.doNothing().when(asyncJobManager).completeAsyncJob(Mockito.anyLong(), Mockito.any(), Mockito.anyInt(), Mockito.anyString()); + + // the API thread is still running: its own completion decides whether the work finished or was stopped + asyncJobManager.finalizeCancelledJob(10L); + Mockito.verify(asyncJobManager, Mockito.never()).completeAsyncJob(Mockito.anyLong(), Mockito.any(), Mockito.anyInt(), Mockito.anyString()); + Mockito.verify(asyncJobDao, Mockito.never()).listChildJobs(10L); + + asyncJobManager.finalizeCancelledJob(10L); + Mockito.verify(asyncJobManager).completeAsyncJob(Mockito.eq(10L), Mockito.eq(JobInfo.Status.CANCELLED), Mockito.anyInt(), Mockito.contains("Cancelled by admin via mgmt2")); + } + + @Test + public void testParentFailingAfterItsQueuedChildWasCancelledEndsForThatReason() { + AsyncJobVO parent = childJob(10L, JobInfo.Status.IN_PROGRESS); + parent.setRelated(null); + when(asyncJobDao.findByIdIncludingRemoved(10L)).thenReturn(parent); + AsyncJobVO child = childJob(11L, JobInfo.Status.CANCELLED); + child.setCancelRequested(true); + child.setResult(AsyncJobManagerImpl.cancelledResult("Cancelled by admin via mgmt1")); + when(asyncJobDao.listChildJobs(10L)).thenReturn(Collections.singletonList(child)); + + Assert.assertEquals("Cancelled by admin via mgmt1", asyncJobManager.getCancellationReason(10L)); + } + + @Test + public void testNotCancellableJobIsRefusedUnlessAllItsWorkIsStillQueued() { + AsyncJobVO job = childJob(10L, JobInfo.Status.IN_PROGRESS); + job.setRelated(null); + job.setUuid("job-uuid"); + job.setCmd("org.apache.cloudstack.api.command.user.vm.DeployVMCmd"); + job.setExecutingMsid(1L); + when(asyncJobDao.findByIdIncludingRemoved(10L)).thenReturn(job); + + AsyncJobVO child = childJob(11L, JobInfo.Status.IN_PROGRESS); + when(asyncJobDao.listChildJobs(10L)).thenReturn(Collections.singletonList(child)); + when(queueItemDao.getQueueItemIdByContentIdAndType(11L, SyncQueueItem.AsyncJobContentType)).thenReturn(5L); + // taken by a server: its work is under way + when(queueItemDao.findById(5L)).thenReturn(queueItem(5L, 1L)); + try { + asyncJobManager.cancelAsyncJob(10L, "Cancelled by admin"); + Assert.fail("A deploy with work under way was cancelled"); + } catch (CloudRuntimeException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("is not cancellable")); + } + + // still waiting in the queue: nothing has started + when(queueItemDao.findById(5L)).thenReturn(queueItem(5L, null)); + Mockito.lenient().when(asyncJobDao.findById(10L)).thenReturn(job); + try { + asyncJobManager.cancelAsyncJob(10L, "Cancelled by admin"); + } catch (CloudRuntimeException e) { + Assert.assertFalse("Refused although nothing had started: " + e.getMessage(), e.getMessage().contains("is not cancellable")); + } + Assert.assertTrue(job.isCancelRequested()); + } + + @Test + public void testWorkTakenBetweenTheCheckAndTheFlagWithdrawsTheCancel() { + AsyncJobVO job = childJob(10L, JobInfo.Status.IN_PROGRESS); + job.setRelated(null); + job.setUuid("job-uuid"); + job.setCmd("org.apache.cloudstack.api.command.user.vm.DeployVMCmd"); + job.setExecutingMsid(1L); + when(asyncJobDao.findByIdIncludingRemoved(10L)).thenReturn(job); + when(asyncJobDao.findById(10L)).thenReturn(job); + AsyncJobVO child = childJob(11L, JobInfo.Status.IN_PROGRESS); + when(asyncJobDao.listChildJobs(10L)).thenReturn(Collections.singletonList(child)); + when(queueItemDao.getQueueItemIdByContentIdAndType(11L, SyncQueueItem.AsyncJobContentType)).thenReturn(5L); + // queued at the check, taken by a server by the time the flag is written + when(queueItemDao.findById(5L)).thenReturn(queueItem(5L, null), queueItem(5L, 1L)); + + try { + asyncJobManager.cancelAsyncJob(10L, "Cancelled by admin"); + Assert.fail("A job whose work had started was cancelled"); + } catch (CloudRuntimeException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("started meanwhile")); + } + Assert.assertFalse(job.isCancelRequested()); + } + + @Test + public void testWithdrawnCancelIsAnnouncedToTheHandlers() throws Exception { + AsyncJobVO job = childJob(10L, JobInfo.Status.IN_PROGRESS); + job.setRelated(null); + job.setUuid("job-uuid"); + job.setCmd("org.apache.cloudstack.api.command.user.vm.DeployVMCmd"); + job.setExecutingMsid(1L); + when(asyncJobDao.findByIdIncludingRemoved(10L)).thenReturn(job); + when(asyncJobDao.findById(10L)).thenReturn(job); + AsyncJobVO child = childJob(11L, JobInfo.Status.IN_PROGRESS); + when(asyncJobDao.listChildJobs(10L)).thenReturn(Collections.singletonList(child)); + when(queueItemDao.getQueueItemIdByContentIdAndType(11L, SyncQueueItem.AsyncJobContentType)).thenReturn(5L); + when(queueItemDao.findById(5L)).thenReturn(queueItem(5L, null), queueItem(5L, 1L)); + JobCancellationHandler handler = Mockito.mock(JobCancellationHandler.class); + setField("jobCancellationHandlers", Collections.singletonList(handler)); + + try { + asyncJobManager.cancelAsyncJob(10L, "Cancelled by admin"); + Assert.fail("A job whose work had started was cancelled"); + } catch (CloudRuntimeException expected) { + // the cancel was withdrawn + } + Assert.assertFalse(job.isCancelRequested()); + Assert.assertFalse(job.isCancelDelivered()); + Mockito.verify(handler).jobCancelWithdrawn(10L); + } + + @Test + public void testCancelRequestedListingIncludesTheWorkJobsOfCancelledParents() { + AsyncJobVO parent = childJob(10L, JobInfo.Status.IN_PROGRESS); + parent.setRelated(null); + parent.setCancelRequested(true); + when(asyncJobDao.getCancelRequestedJobs(1L)).thenReturn(Collections.emptyList()); + AsyncJobVO ofCancelled = workJob(11L, JobInfo.Status.IN_PROGRESS); + AsyncJobVO ofRunning = workJob(12L, JobInfo.Status.IN_PROGRESS); + ofRunning.setRelated("20"); + AsyncJobVO orphan = workJob(13L, JobInfo.Status.IN_PROGRESS); + orphan.setRelated(""); + when(asyncJobDao.getRunningWorkJobs(1L)).thenReturn(Arrays.asList(ofCancelled, ofRunning, orphan)); + when(asyncJobDao.findByIdIncludingRemoved(10L)).thenReturn(parent); + when(asyncJobDao.findByIdIncludingRemoved(20L)).thenReturn(childJob(20L, JobInfo.Status.IN_PROGRESS)); + + List listed = asyncJobManager.listCancelRequestedJobsExecutingOn(1L); + + Assert.assertEquals(1, listed.size()); + Assert.assertEquals(11L, listed.get(0).getId()); + } + + @Test + public void testSecondCancelOfAJobBeingCancelledIsRefused() { + AsyncJobVO job = childJob(10L, JobInfo.Status.IN_PROGRESS); + job.setRelated(null); + job.setUuid("job-uuid"); + job.setCancelRequested(true); + job.setResult(AsyncJobManagerImpl.cancelledResult("Cancelled by admin via mgmt2")); + when(asyncJobDao.findByIdIncludingRemoved(10L)).thenReturn(job); + + try { + asyncJobManager.cancelAsyncJob(10L, "Cancelled by admin"); + Assert.fail("A job already being cancelled was cancelled again"); + } catch (CloudRuntimeException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("already being cancelled (Cancelled by admin via mgmt2)")); + } + Mockito.verify(asyncJobDao, Mockito.never()).update(Mockito.anyLong(), Mockito.any()); + } + + @Test + public void testCancelledResultReadsLikeAFailure() { + String serialized = AsyncJobManagerImpl.cancelledResult("Cancelled by admin via mgmt2"); + + Assert.assertTrue(serialized.startsWith(ExceptionResponse.class.getName() + "/")); + ExceptionResponse response = (ExceptionResponse) JobSerializerHelper.fromSerializedString(serialized); + Assert.assertEquals("Cancelled by admin via mgmt2", response.getErrorText()); + Assert.assertEquals(Integer.valueOf(ApiErrorCode.INTERNAL_ERROR.getHttpCode()), response.getErrorCode()); + } } diff --git a/plugins/api/discovery/src/main/java/org/apache/cloudstack/api/response/ApiDiscoveryResponse.java b/plugins/api/discovery/src/main/java/org/apache/cloudstack/api/response/ApiDiscoveryResponse.java index 90b3b89d3fb5..d9795ec5b174 100644 --- a/plugins/api/discovery/src/main/java/org/apache/cloudstack/api/response/ApiDiscoveryResponse.java +++ b/plugins/api/discovery/src/main/java/org/apache/cloudstack/api/response/ApiDiscoveryResponse.java @@ -43,6 +43,10 @@ public class ApiDiscoveryResponse extends BaseResponse { @Param(description = "True if API is asynchronous") private Boolean isAsync; + @SerializedName("cancellable") + @Param(description = "True if a job running this API can be cancelled with cancelAsyncJob", since = "24.0") + private Boolean cancellable; + @SerializedName("related") @Param(description = "Comma separated related apis") private String related; @@ -67,6 +71,7 @@ public ApiDiscoveryResponse() { params = new HashSet(); apiResponse = new HashSet(); isAsync = false; + cancellable = false; } public ApiDiscoveryResponse(ApiDiscoveryResponse another) { @@ -74,6 +79,7 @@ public ApiDiscoveryResponse(ApiDiscoveryResponse another) { this.description = another.getDescription(); this.since = another.getSince(); this.isAsync = another.getAsync(); + this.cancellable = another.getCancellable(); this.related = another.getRelated(); this.params = new HashSet<>(another.getParams()); this.apiResponse = new HashSet<>(another.getApiResponse()); @@ -114,6 +120,14 @@ public boolean getAsync() { return isAsync; } + public void setCancellable(Boolean cancellable) { + this.cancellable = cancellable; + } + + public boolean getCancellable() { + return Boolean.TRUE.equals(cancellable); + } + public String getRelated() { return related; } diff --git a/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java b/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java index 17bbc84e9f55..43bbd92fbc84 100644 --- a/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java +++ b/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java @@ -221,6 +221,7 @@ protected ApiDiscoveryResponse getCmdRequestMap(Class cmdClass, APICommand ap boolean isAsync = ReflectUtil.isCmdClassAsync(cmdClass, new Class[] {BaseAsyncCmd.class, BaseAsyncCreateCmd.class}); response.setAsync(isAsync); + response.setCancellable(isAsync && apiCmdAnnotation.cancellable()); for (Field field : fields) { Parameter parameterAnnotation = field.getAnnotation(Parameter.class); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/KvmCancellableRequests.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/KvmCancellableRequests.java new file mode 100644 index 000000000000..166719e04228 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/KvmCancellableRequests.java @@ -0,0 +1,161 @@ +// 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; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.agent.api.Command; +import com.cloud.hypervisor.kvm.resource.disconnecthook.DisconnectHook; + +/** Per agent request sequence, the DisconnectHooks the wrappers registered for its libvirt jobs: they are what a cancellation runs. A request without a hook has nothing that can be stopped. */ +public class KvmCancellableRequests { + private static final Logger LOGGER = LogManager.getLogger(KvmCancellableRequests.class); + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private final Map requests = new ConcurrentHashMap<>(); + + private static final class RequestState { + final Command command; + final List hooks = new CopyOnWriteArrayList<>(); + volatile boolean cancelRequested; + + RequestState(final Command command) { + this.command = command; + } + } + + /** A sequence of zero (not from the agent layer) leaves the thread unscoped. */ + public void begin(final long sequence, final Command command) { + if (sequence <= 0) { + return; + } + requests.putIfAbsent(sequence, new RequestState(command)); + CURRENT.set(sequence); + } + + public void end(final long sequence) { + CURRENT.remove(); + if (sequence > 0) { + requests.remove(sequence); + } + } + + public boolean wasCancelRequested(final long sequence) { + final RequestState state = requests.get(sequence); + return state != null && state.cancelRequested; + } + + /** Associates a hook with the request the current thread is executing, if any. */ + public void attach(final DisconnectHook hook) { + final Long sequence = CURRENT.get(); + if (sequence == null || hook == null) { + return; + } + final RequestState state = requests.get(sequence); + if (state != null) { + state.hooks.add(hook); + } + } + + public void detach(final DisconnectHook hook) { + if (hook == null) { + return; + } + final Long sequence = CURRENT.get(); + final RequestState scoped = sequence != null ? requests.get(sequence) : null; + if (scoped != null && scoped.hooks.remove(hook)) { + return; + } + for (final RequestState state : requests.values()) { + state.hooks.remove(hook); + } + } + + /** Null while the request has a hook that knows how to stop it. */ + public String checkRequestNotCancellable(final long sequence) { + final RequestState state = requests.get(sequence); + if (state == null) { + return "request sequence " + sequence + " is not running on this host"; + } + if (state.hooks.isEmpty()) { + return describe(state.command) + " has nothing that can be stopped on this host"; + } + return null; + } + + /** + * Runs each hook once, bounded by its timeout; a Thread cannot be started twice, so ran hooks are handed back for removal from the + * disconnect list. True only when every hook stopped its work: a job that finished before the abort reached it was not cancelled, + * and the request's answer is then the real one. + */ + public boolean cancel(final long sequence, final Consumer onHookRan) { + final RequestState state = requests.get(sequence); + if (state == null) { + return false; + } + if (state.hooks.isEmpty()) { + LOGGER.debug("Request sequence {} ({}) has nothing that can be stopped", sequence, describe(state.command)); + return false; + } + + boolean allStopped = true; + for (final DisconnectHook hook : state.hooks) { + try { + if (hook.getState() == Thread.State.NEW) { + hook.start(); + } + hook.join(hook.getTimeoutMs()); + if (hook.isAlive()) { + LOGGER.warn("Cancel hook {} for request sequence {} did not finish within {} ms", hook.getName(), sequence, hook.getTimeoutMs()); + allStopped = false; + } else if (!hook.stoppedTheWork()) { + LOGGER.info("Cancel hook {} for request sequence {} ({}) found nothing left to stop", hook.getName(), sequence, describe(state.command)); + allStopped = false; + } else { + state.cancelRequested = true; + LOGGER.info("Ran cancel hook {} for request sequence {} ({})", hook.getName(), sequence, describe(state.command)); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + allStopped = false; + } catch (final Exception e) { + LOGGER.warn("Cancel hook {} for request sequence {} failed", hook.getName(), sequence, e); + allStopped = false; + } finally { + onHookRan.accept(hook); + } + } + return allStopped; + } + + int hookCount(final long sequence) { + final RequestState state = requests.get(sequence); + return state == null ? 0 : state.hooks.size(); + } + + private static String describe(final Command command) { + return command == null ? "unknown command" : command.getClass().getSimpleName(); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index acffa395494d..d5ae70671e4f 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java @@ -676,6 +676,8 @@ public synchronized void registerStatusUpdater(AgentStatusUpdater updater) { } protected List _disconnectHooks = new CopyOnWriteArrayList<>(); + // the same hooks keyed by the request that registered them + private final KvmCancellableRequests cancellableRequests = new KvmCancellableRequests(); @Override public ExecutionResult executeInVR(final String routerIp, final String script, final String args) { @@ -2432,6 +2434,35 @@ public boolean stop() { */ @Override public Answer executeRequest(final Command cmd) { + final long requestSequence = cmd.getRequestSequence(); + cancellableRequests.begin(requestSequence, cmd); + try { + final Answer answer = executeRequestInternal(cmd); + // an aborted libvirt job surfaces as an ordinary failure; flag it as cancelled + if (answer != null && !answer.getResult() && cancellableRequests.wasCancelRequested(requestSequence)) { + answer.setCancelled(true); + } + return answer; + } finally { + cancellableRequests.end(requestSequence); + } + } + + public boolean wasRequestCancelled(final long sequence) { + return cancellableRequests.wasCancelRequested(sequence); + } + + @Override + public String checkRequestSequenceIsNotCancellable(final long sequence) { + return cancellableRequests.checkRequestNotCancellable(sequence); + } + + @Override + public boolean cancelRequestSequence(final long sequence) { + return cancellableRequests.cancel(sequence, this::removeDisconnectHook); + } + + private Answer executeRequestInternal(final Command cmd) { if (isReconcileCommandsEnabled) { ReconcileCommandUtils.updateLogFileForCommand(COMMANDS_LOG_PATH, cmd, Command.State.STARTED); } @@ -7155,10 +7186,12 @@ public void disconnected() { public void addDisconnectHook(DisconnectHook hook) { LOGGER.debug("Adding disconnect hook " + hook); _disconnectHooks.add(hook); + cancellableRequests.attach(hook); } public void removeDisconnectHook(DisconnectHook hook) { LOGGER.debug("Removing disconnect hook " + hook); + cancellableRequests.detach(hook); if (_disconnectHooks.contains(hook)) { LOGGER.debug("Removing disconnect hook " + hook); _disconnectHooks.remove(hook); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/DisconnectHook.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/DisconnectHook.java index 9cc0bb00eaa7..af6ba08837f6 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/DisconnectHook.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/DisconnectHook.java @@ -43,6 +43,8 @@ DisconnectHooks should implement a run() method that is safe to call and will public abstract class DisconnectHook extends Thread { // Default timeout is 10 seconds long timeoutMs = 10000; + // null until the hook says otherwise: a hook that does not report is taken at its word once it ran + private volatile Boolean stoppedTheWork; public DisconnectHook(String name) { super(); @@ -56,4 +58,12 @@ public DisconnectHook(String name, long timeout) { public long getTimeoutMs(){ return timeoutMs; } + /** For a cancellation: whether run() actually stopped the work. A libvirt job that had already finished cannot be aborted, and must not be reported as cancelled. */ + public boolean stoppedTheWork() { + return stoppedTheWork == null || stoppedTheWork; + } + + protected void setStoppedTheWork(boolean stopped) { + stoppedTheWork = stopped; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/MigrationCancelHook.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/MigrationCancelHook.java index 04e9e04c6457..635c16597e5a 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/MigrationCancelHook.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/MigrationCancelHook.java @@ -40,11 +40,17 @@ public MigrationCancelHook(Domain migratingDomain) throws LibvirtException { public void run() { LOGGER.info("Interrupted migration of " + _vmName); try { - if (_migratingDomain.abortJob() == 0) { - LOGGER.warn("Aborted migration job for " + _vmName); + final boolean aborted = _migratingDomain.abortJob() == 0; + setStoppedTheWork(aborted); + if (aborted) { + LOGGER.info("Aborted migration job for " + _vmName); + } else { + LOGGER.info("Could not abort the migration job for " + _vmName + ", it has most likely finished"); } } catch (LibvirtException ex) { - LOGGER.warn("Failed to abort migration job for " + _vmName, ex); + // "Domain not found" once the migration completed and the source domain is gone + setStoppedTheWork(false); + LOGGER.info("Could not abort the migration job for " + _vmName + ", it has most likely finished: " + ex.getMessage()); } } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/VolumeMigrationCancelHook.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/VolumeMigrationCancelHook.java index f73720f79086..8f9f8ffd40a0 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/VolumeMigrationCancelHook.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/disconnecthook/VolumeMigrationCancelHook.java @@ -44,9 +44,11 @@ public void run() { if (_migratingDomain != null && _destDiskLabel != null) { try { _migratingDomain.blockJobAbort(_destDiskLabel, Domain.BlockJobAbortFlags.ASYNC); - LOGGER.warn(String.format("Aborted block job for vm %s and volume: %s", _vmName, _destDiskLabel)); + setStoppedTheWork(true); + LOGGER.info(String.format("Aborted block job for vm %s and volume: %s", _vmName, _destDiskLabel)); } catch (LibvirtException ex) { - LOGGER.error(String.format("Failed to abort block job for vm %s and volume: %s due to %s", _vmName, _destDiskLabel, ex.getMessage())); + setStoppedTheWork(false); + LOGGER.info(String.format("Could not abort the block job for vm %s and volume %s, it has most likely finished: %s", _vmName, _destDiskLabel, ex.getMessage())); } } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCancelCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCancelCommandWrapper.java new file mode 100644 index 000000000000..0acf67598ceb --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCancelCommandWrapper.java @@ -0,0 +1,42 @@ +// 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.Answer; +import com.cloud.agent.api.CancelCommand; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; + +/** Can another request in flight on this agent be stopped (checkOnly), or stop it. Out of sequence, so it is not queued behind that request. */ +@ResourceWrapper(handles = CancelCommand.class) +public final class LibvirtCancelCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(final CancelCommand command, final LibvirtComputingResource libvirtComputingResource) { + final long sequence = command.getSequence(); + if (command.isCheckOnly()) { + final String reason = libvirtComputingResource.checkRequestSequenceIsNotCancellable(sequence); + return new Answer(command, reason == null, reason == null ? "request sequence " + sequence + " can be cancelled" : reason); + } + + final boolean cancelled = libvirtComputingResource.cancelRequestSequence(sequence); + return new Answer(command, cancelled, cancelled + ? "request sequence " + sequence + " cancelled (" + command.getReason() + ")" + : "request sequence " + sequence + " could not be cancelled on this host"); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java index ed02ae6da38d..ead3beb6b783 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java @@ -351,14 +351,28 @@ Use VIR_DOMAIN_XML_SECURE (value = 1) prior to v1.0.0. } } catch (final LibvirtException e) { - logger.error(String.format("Can't migrate domain [%s] due to: [%s].", vmName, e.getMessage()), e); + if (libvirtComputingResource.wasRequestCancelled(command.getRequestSequence())) { + logger.info("Migration of VM {} cancelled on request (seq {}): {}", vmName, command.getRequestSequence(), e.getMessage()); + } else { + logger.error(String.format("Can't migrate domain [%s] due to: [%s].", vmName, e.getMessage()), e); + } result = e.getMessage(); if (result.startsWith("unable to connect to server") && result.endsWith("refused")) { logger.debug("Migration failed as connection to destination [{}] was refused. Please check libvirt configuration compatibility and firewall rules on the source and destination hosts.", destinationUri); result = String.format("Failed to migrate domain [%s].", vmName); } + } catch (final ExecutionException e) { + // the migration thread's libvirt failure; after an abort that is "operation aborted: migration out job: canceled by client" + final Throwable cause = e.getCause() != null ? e.getCause() : e; + if (libvirtComputingResource.wasRequestCancelled(command.getRequestSequence())) { + logger.info("Migration of VM {} cancelled on request (seq {}): {}", vmName, command.getRequestSequence(), cause.getMessage()); + } else { + logger.error(String.format("Can't migrate domain [%s] due to: [%s].", vmName, cause.getMessage()), e); + } + if (result == null) { + result = "Exception during migrate: " + cause.getMessage(); + } } catch (final InterruptedException - | ExecutionException | TimeoutException | IOException | ParserConfigurationException @@ -416,7 +430,11 @@ Use VIR_DOMAIN_XML_SECURE (value = 1) prior to v1.0.0. commandState = Command.State.COMPLETED; libvirtComputingResource.createOrUpdateLogFileForCommand(command, commandState); } else if (commandState == null) { - logger.error("Migration of VM {} failed with result: {}", vmName, result); + if (libvirtComputingResource.wasRequestCancelled(command.getRequestSequence())) { + logger.info("Migration of VM {} was cancelled (seq {}): {}", vmName, command.getRequestSequence(), result); + } else { + logger.error("Migration of VM {} failed with result: {}", vmName, result); + } commandState = Command.State.FAILED; libvirtComputingResource.createOrUpdateLogFileForCommand(command, commandState); } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/KvmCancellableRequestsTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/KvmCancellableRequestsTest.java new file mode 100644 index 000000000000..65aeb3dcab30 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/KvmCancellableRequestsTest.java @@ -0,0 +1,167 @@ +// 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; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; + +import com.cloud.agent.api.CheckHealthCommand; +import com.cloud.hypervisor.kvm.resource.disconnecthook.DisconnectHook; + +public class KvmCancellableRequestsTest { + + private static final long SEQ = 42L; + + private final KvmCancellableRequests requests = new KvmCancellableRequests(); + private final List dropped = new ArrayList<>(); + + private static final class CountingHook extends DisconnectHook { + final AtomicInteger runs = new AtomicInteger(); + + CountingHook() { + super("counting", 2000); + } + + @Override + public void run() { + runs.incrementAndGet(); + } + } + + @After + public void tearDown() { + requests.end(SEQ); + } + + @Test + public void unknownSequenceIsNeitherCancellableNorCancelled() { + Assert.assertNotNull(requests.checkRequestNotCancellable(999L)); + Assert.assertFalse(requests.cancel(999L, dropped::add)); + Assert.assertFalse(requests.wasCancelRequested(999L)); + } + + @Test + public void requestWithoutAHookHasNothingToStop() { + requests.begin(SEQ, new CheckHealthCommand()); + + final String reason = requests.checkRequestNotCancellable(SEQ); + Assert.assertTrue(reason, reason.contains("CheckHealthCommand has nothing that can be stopped")); + Assert.assertFalse(requests.cancel(SEQ, dropped::add)); + // nothing was stopped, so a later failure of the request is not a cancellation + Assert.assertFalse(requests.wasCancelRequested(SEQ)); + } + + @Test + public void hookRegisteredOnTheExecutingThreadIsRunOnceOnCancel() { + final CountingHook hook = new CountingHook(); + requests.begin(SEQ, new CheckHealthCommand()); + requests.attach(hook); + + Assert.assertNull(requests.checkRequestNotCancellable(SEQ)); + Assert.assertTrue(requests.cancel(SEQ, dropped::add)); + Assert.assertEquals(1, hook.runs.get()); + Assert.assertEquals(1, dropped.size()); + + // a Thread runs only once + Assert.assertTrue(requests.cancel(SEQ, dropped::add)); + Assert.assertEquals(1, hook.runs.get()); + } + + @Test + public void detachedHookIsNotRun() { + final CountingHook hook = new CountingHook(); + requests.begin(SEQ, new CheckHealthCommand()); + requests.attach(hook); + requests.detach(hook); + + Assert.assertEquals(0, requests.hookCount(SEQ)); + Assert.assertNotNull(requests.checkRequestNotCancellable(SEQ)); + Assert.assertEquals(0, hook.runs.get()); + } + + @Test + public void hookAttachedFromAnUnscopedThreadIsIgnored() throws Exception { + final CountingHook hook = new CountingHook(); + requests.begin(SEQ, new CheckHealthCommand()); + + final Thread other = new Thread(() -> requests.attach(hook)); + other.start(); + other.join(); + + Assert.assertEquals(0, requests.hookCount(SEQ)); + } + + @Test + public void zeroSequenceLeavesTheThreadUnscoped() { + requests.begin(0L, new CheckHealthCommand()); + requests.attach(new CountingHook()); + + Assert.assertNotNull(requests.checkRequestNotCancellable(0L)); + requests.end(0L); + } + + @Test + public void endForgetsTheSequence() { + requests.begin(SEQ, new CheckHealthCommand()); + requests.attach(new CountingHook()); + requests.end(SEQ); + + Assert.assertNotNull(requests.checkRequestNotCancellable(SEQ)); + Assert.assertEquals(0, requests.hookCount(SEQ)); + } + + private static final class NothingLeftToStopHook extends DisconnectHook { + NothingLeftToStopHook() { + super("too-late", 2000); + } + + @Override + public void run() { + // the libvirt job had already finished: the abort found no job + setStoppedTheWork(false); + } + } + + @Test + public void testHookThatFoundNothingToStopIsNotACancellation() { + requests.begin(SEQ, new CheckHealthCommand()); + requests.attach(new NothingLeftToStopHook()); + + Assert.assertFalse(requests.cancel(SEQ, dropped::add)); + // the request's real answer stands: a success is a success, a later failure is not a cancellation + Assert.assertFalse(requests.wasCancelRequested(SEQ)); + Assert.assertEquals(1, dropped.size()); + } + + @Test + public void testRequestIsCancelledOnlyIfEveryHookStoppedItsWork() { + final CountingHook stopped = new CountingHook(); + requests.begin(SEQ, new CheckHealthCommand()); + requests.attach(stopped); + requests.attach(new NothingLeftToStopHook()); + + Assert.assertFalse(requests.cancel(SEQ, dropped::add)); + Assert.assertEquals(1, stopped.runs.get()); + // something was stopped, so the request's failure that follows is reported as cancelled + Assert.assertTrue(requests.wasCancelRequested(SEQ)); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/disconnecthook/MigrationCancelHookTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/disconnecthook/MigrationCancelHookTest.java new file mode 100644 index 000000000000..b6abee26fb20 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/disconnecthook/MigrationCancelHookTest.java @@ -0,0 +1,61 @@ +// 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.disconnecthook; + +import org.junit.Assert; +import org.junit.Test; +import org.libvirt.Domain; +import org.libvirt.LibvirtException; +import org.mockito.Mockito; + +public class MigrationCancelHookTest { + + @Test + public void testAbortedJobIsACancellation() throws Exception { + final Domain domain = Mockito.mock(Domain.class); + Mockito.when(domain.getName()).thenReturn("i-2-3-VM"); + Mockito.when(domain.abortJob()).thenReturn(0); + + final MigrationCancelHook hook = new MigrationCancelHook(domain); + hook.run(); + + Assert.assertTrue(hook.stoppedTheWork()); + } + + @Test + public void testJobThatFinishedBeforeTheAbortIsNotACancellation() throws Exception { + final Domain domain = Mockito.mock(Domain.class); + Mockito.when(domain.getName()).thenReturn("i-2-3-VM"); + final LibvirtException domainGone = Mockito.mock(LibvirtException.class); + Mockito.when(domainGone.getMessage()).thenReturn("Domain not found: no domain with matching name 'i-2-3-VM'"); + Mockito.when(domainGone.getStackTrace()).thenReturn(new StackTraceElement[0]); + Mockito.when(domain.abortJob()).thenThrow(domainGone); + + final MigrationCancelHook hook = new MigrationCancelHook(domain); + hook.run(); + + Assert.assertFalse(hook.stoppedTheWork()); + + final Domain stillThere = Mockito.mock(Domain.class); + Mockito.when(stillThere.getName()).thenReturn("i-2-3-VM"); + Mockito.when(stillThere.abortJob()).thenReturn(-1); + final MigrationCancelHook refused = new MigrationCancelHook(stillThere); + refused.run(); + Assert.assertFalse(refused.stoppedTheWork()); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCancelCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCancelCommandWrapperTest.java new file mode 100644 index 000000000000..374d5ffc4639 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCancelCommandWrapperTest.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 com.cloud.hypervisor.kvm.resource.wrapper; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelCommand; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtCancelCommandWrapperTest { + + @Mock + private LibvirtComputingResource resource; + + private final LibvirtCancelCommandWrapper wrapper = new LibvirtCancelCommandWrapper(); + + @Test + public void checkOnlyAsksWithoutCancelling() { + Mockito.when(resource.checkRequestSequenceIsNotCancellable(7L)).thenReturn(null); + + final Answer answer = wrapper.execute(new CancelCommand(7L, "probe", true), resource); + + Assert.assertTrue(answer.getResult()); + Mockito.verify(resource, Mockito.never()).cancelRequestSequence(Mockito.anyLong()); + } + + @Test + public void checkOnlyReportsNothingToStop() { + Mockito.when(resource.checkRequestSequenceIsNotCancellable(7L)).thenReturn("StopCommand has nothing that can be stopped on this host"); + + final Answer answer = wrapper.execute(new CancelCommand(7L, "probe", true), resource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("nothing that can be stopped")); + } + + @Test + public void cancelDelegatesAndReportsTheOutcome() { + Mockito.when(resource.cancelRequestSequence(7L)).thenReturn(true); + + final Answer answer = wrapper.execute(new CancelCommand(7L, "job cancelled"), resource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("job cancelled")); + Mockito.verify(resource, Mockito.never()).checkRequestSequenceIsNotCancellable(Mockito.anyLong()); + } + + @Test + public void failedCancelIsReportedAsSuch() { + Mockito.when(resource.cancelRequestSequence(7L)).thenReturn(false); + + final Answer answer = wrapper.execute(new CancelCommand(7L, "job cancelled"), resource); + + Assert.assertFalse(answer.getResult()); + } +} diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManager.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManager.java index 8be881b94940..4e4ba373789e 100644 --- a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManager.java +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManager.java @@ -31,6 +31,9 @@ public interface VmwareManager { public final String CONTEXT_STOCK_NAME = "vmwareMgr"; + public static final ConfigKey s_vmwareCancelTaskWait = new ConfigKey("Advanced", Integer.class, "vmware.cancel.task.wait", "120", + "Seconds to wait for vCenter to stop a task after a job cancel asked for it; a Storage vMotion takes 30 to 40 seconds to unwind. A task still running afterwards is left to complete and the cancel is refused.", true, ConfigKey.Scope.Global); + public static final ConfigKey s_vmwareNicHotplugWaitTimeout = new ConfigKey("Advanced", Long.class, "vmware.nic.hotplug.wait.timeout", "15000", "Wait timeout (milli seconds) for hot plugged NIC of VM to be detected by guest OS.", false, ConfigKey.Scope.Global); diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManagerImpl.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManagerImpl.java index 8052317817ad..3e8627d95de4 100644 --- a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManagerImpl.java +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareManagerImpl.java @@ -301,7 +301,7 @@ public String getConfigComponentName() { @Override public ConfigKey[] getConfigKeys() { - return new ConfigKey[] {s_vmwareNicHotplugWaitTimeout, s_vmwareCleanOldWorderVMs, templateCleanupInterval, s_vmwareSearchExcludeFolder, s_vmwareOVAPackageTimeout, s_vmwareCleanupPortGroups, VMWARE_STATS_TIME_WINDOW, VmwareUserVmNicDeviceType}; + return new ConfigKey[] {s_vmwareNicHotplugWaitTimeout, s_vmwareCleanOldWorderVMs, templateCleanupInterval, s_vmwareSearchExcludeFolder, s_vmwareOVAPackageTimeout, s_vmwareCleanupPortGroups, VMWARE_STATS_TIME_WINDOW, VmwareUserVmNicDeviceType, s_vmwareCancelTaskWait}; } @Override public boolean configure(String name, Map params) throws ConfigurationException { diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareStorageManagerImpl.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareStorageManagerImpl.java index 109456667710..300de20e3d4a 100644 --- a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareStorageManagerImpl.java +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareStorageManagerImpl.java @@ -1227,14 +1227,18 @@ public CreateVMSnapshotAnswer execute(VmwareHostService hostService, CreateVMSna } } catch (Exception e) { String msg = e.getMessage(); - logger.error("failed to create snapshot for vm:" + vmName + " due to " + msg, e); + logger.error("failed to create snapshot for vm:{} due to {}", vmName, msg, e); try { - if (vmMo.getSnapshotMor(vmSnapshotName) != null) { + if (vmMo != null && vmMo.getSnapshotMor(vmSnapshotName) != null) { vmMo.removeSnapshot(vmSnapshotName, false); } + if (vmMo != null && vmMo.isConsolidationNeeded()) { + logger.info("Consolidating the disks of VM {} after its snapshot was not created", vmName); + vmMo.consolidateVmDisks(); + } } catch (Exception e1) { - logger.info("[ignored]" + "error during snapshot remove: " + e1.getLocalizedMessage()); + logger.info("[ignored]error during snapshot remove: {}", e1.getLocalizedMessage()); } return new CreateVMSnapshotAnswer(cmd, false, e.getMessage()); diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/resource/VmwareResource.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/resource/VmwareResource.java index 89b5b111e14f..28eaeb8095d2 100644 --- a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/resource/VmwareResource.java +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/resource/VmwareResource.java @@ -239,6 +239,7 @@ import com.cloud.hypervisor.guru.VMwareGuru; import com.cloud.hypervisor.vmware.manager.VmwareHostService; import com.cloud.hypervisor.vmware.manager.VmwareManager; +import com.cloud.hypervisor.vmware.util.VmwareClient; import com.cloud.hypervisor.vmware.manager.VmwareStorageMount; import com.cloud.hypervisor.vmware.mo.ClusterMO; import com.cloud.hypervisor.vmware.mo.CustomFieldConstants; @@ -267,11 +268,13 @@ import com.cloud.hypervisor.vmware.util.VmwareContext; import com.cloud.hypervisor.vmware.util.VmwareContextPool; import com.cloud.hypervisor.vmware.util.VmwareHelper; +import com.cloud.hypervisor.vmware.util.VmwareTaskRegistry; import com.cloud.network.Networks; import com.cloud.network.Networks.BroadcastDomainType; import com.cloud.network.Networks.TrafficType; import com.cloud.network.VmwareTrafficLabel; import com.cloud.network.router.VirtualRouterAutoScale; +import com.cloud.resource.RequestExecutionContext; import com.cloud.resource.ServerResource; import com.cloud.resource.ServerResourceBase; import com.cloud.serializer.GsonHelper; @@ -429,6 +432,9 @@ public class VmwareResource extends ServerResourceBase implements StoragePoolRes protected volatile long _cmdSequence = 1; + // per resource, so two hosts cannot collide on a request sequence + private final VmwareTaskRegistry taskRegistry = new VmwareTaskRegistry(); + protected StorageSubsystemCommandHandler storageHandler; private VmwareStorageProcessor _storageProcessor; @@ -467,6 +473,36 @@ private String getCommandLogTitle(Command cmd) { @Override public Answer executeRequest(Command cmd) { + final Long requestSequence = RequestExecutionContext.getRequestSequence(); + taskRegistry.beginRequest(requestSequence, cmd.getClass().getSimpleName()); + try { + Answer answer = executeRequestInternal(cmd); + // a cancelled vCenter task surfaces as an ordinary failure; flag it as cancelled + if (answer != null && !answer.getResult() && taskRegistry.wasCancelRequested(requestSequence)) { + answer.setCancelled(true); + } + return answer; + } finally { + taskRegistry.endRequest(requestSequence); + } + } + + @Override + public String checkRequestSequenceIsNotCancellable(long sequence) { + return taskRegistry.checkTaskNotCancellable(sequence); + } + + @Override + public boolean cancelRequestSequence(long sequence) { + return taskRegistry.cancel(sequence, cancelTaskWaitMs()); + } + + protected long cancelTaskWaitMs() { + final Integer seconds = VmwareManager.s_vmwareCancelTaskWait.value(); + return seconds != null && seconds > 0 ? seconds * 1000L : VmwareClient.DEFAULT_CANCEL_TASK_WAIT_MS; + } + + private Answer executeRequestInternal(Command cmd) { logCommand(cmd); Answer answer; ThreadContext.push(getCommandLogTitle(cmd)); @@ -4904,6 +4940,34 @@ protected Answer execute(MigrateWithStorageCommand cmd) { } } + /** + * The worker's disk is detached by its own backing file: after a cancelled relocate it is still on the source datastore, and + * a path built from the destination finds no such device. A detach that fails must not keep the worker VM alive. + */ + protected String destroyWorkerVmAfterVolumeMigration(final VirtualMachineMO vmMo, final DatastoreMO destinationDsMo, String vmName) { + try { + // OfflineVmwareMigration: worker *may* have been renamed + vmName = vmMo.getVmName(); + logger.info("Dettaching disks before destroying worker VM '" + vmName + "' after volume migration"); + for (VirtualDisk disk : vmMo.getAllDiskDevice()) { + String vmdkDataStorePath = VmwareHelper.getAbsoluteVmdkFile(disk); + if (vmdkDataStorePath == null || vmdkDataStorePath.isEmpty()) { + vmdkDataStorePath = VmwareStorageLayoutHelper.getLegacyDatastorePathFromVmdkFileName(destinationDsMo, vmMo.getVmdkFileBaseName(disk) + VMDK_EXTENSION); + } + try { + vmMo.detachDisk(vmdkDataStorePath, false); + } catch (Exception e) { + logger.warn("Unable to detach " + vmdkDataStorePath + " from worker VM '" + vmName + "': " + e.getMessage()); + } + } + logger.info("Destroy worker VM '" + vmName + "' after volume migration"); + vmMo.destroy(); + } catch (Throwable e) { + logger.warn("Failed to destroy worker VM '" + vmName + "': " + e.getMessage()); + } + return vmName; + } + private Answer migrateVolume(MigrateVolumeCommand cmd) { Answer answer; String path = cmd.getVolumePath(); @@ -5017,26 +5081,15 @@ private Answer migrateVolume(MigrateVolumeCommand cmd) { answer = migrateAndAnswer(vmMo, cmd.getTargetPool().getUuid(), hyperHost, cmd); } catch (Exception e) { String msg = String.format("Migration of volume '%s' failed due to %s", cmd.getVolumePath(), e.getLocalizedMessage()); - logger.error(msg, e); + if (taskRegistry.wasCancelRequested(RequestExecutionContext.getRequestSequence())) { + logger.info("Migration of volume '{}' was cancelled: {}", cmd.getVolumePath(), e.getLocalizedMessage()); + } else { + logger.error(msg, e); + } answer = new Answer(cmd, false, msg); } finally { - try { - // OfflineVmwareMigration: worker *may* have been renamed - vmName = vmMo.getVmName(); - logger.info("Dettaching disks before destroying worker VM '" + vmName + "' after volume migration"); - VirtualDisk[] disks = vmMo.getAllDiskDevice(); - String format = "disk %d was migrated to %s"; - for (VirtualDisk disk : disks) { - if (logger.isTraceEnabled()) { - logger.trace(String.format(format, disk.getKey(), vmMo.getVmdkFileBaseName(disk))); - } - vmdkDataStorePath = VmwareStorageLayoutHelper.getLegacyDatastorePathFromVmdkFileName(destinationDsMo, vmMo.getVmdkFileBaseName(disk) + VMDK_EXTENSION); - vmMo.detachDisk(vmdkDataStorePath, false); - } - logger.info("Destroy worker VM '" + vmName + "' after volume migration"); - vmMo.destroy(); - } catch (Throwable e) { - logger.info("Failed to destroy worker VM: " + vmName); + if (vmMo != null) { + vmName = destroyWorkerVmAfterVolumeMigration(vmMo, destinationDsMo, vmName); } } if (answer instanceof MigrateVolumeAnswer) { diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/PremiumSecondaryStorageResource.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/PremiumSecondaryStorageResource.java index f5344119dede..106a86ce0398 100644 --- a/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/PremiumSecondaryStorageResource.java +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/PremiumSecondaryStorageResource.java @@ -26,6 +26,7 @@ import org.apache.cloudstack.storage.resource.SecondaryStorageResourceHandler; import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelCommand; import com.cloud.agent.api.Command; import com.cloud.hypervisor.Hypervisor; @@ -38,6 +39,9 @@ public class PremiumSecondaryStorageResource extends NfsSecondaryStorageResource @Override public Answer executeRequest(Command cmd) { + if (cmd instanceof CancelCommand) { + return cancel((CancelCommand) cmd); + } String hypervisor = cmd.getContextParam("hypervisor"); if (hypervisor != null) { Hypervisor.HypervisorType hypervisorType = Hypervisor.HypervisorType.getType(hypervisor); @@ -62,6 +66,41 @@ public Answer defaultAction(Command cmd) { return super.executeRequest(cmd); } + /** Can another request in flight here be stopped (checkOnly), or stop it; only the hypervisor handlers run anything that can be. */ + private Answer cancel(CancelCommand cmd) { + final long sequence = cmd.getSequence(); + if (cmd.isCheckOnly()) { + final String reason = checkRequestSequenceIsNotCancellable(sequence); + return new Answer(cmd, reason == null, reason == null ? "request sequence " + sequence + " can be cancelled" : reason); + } + final boolean cancelled = cancelRequestSequence(sequence); + return new Answer(cmd, cancelled, cancelled + ? "request sequence " + sequence + " cancelled (" + cmd.getReason() + ")" + : "request sequence " + sequence + " could not be cancelled on this host"); + } + + @Override + public String checkRequestSequenceIsNotCancellable(long sequence) { + String reason = "no handler on this host is running request sequence " + sequence; + for (SecondaryStorageResourceHandler handler : _handlers.values()) { + reason = handler.checkRequestSequenceIsNotCancellable(sequence); + if (reason == null) { + return null; + } + } + return reason; + } + + @Override + public boolean cancelRequestSequence(long sequence) { + for (SecondaryStorageResourceHandler handler : _handlers.values()) { + if (handler.cancelRequestSequence(sequence)) { + return true; + } + } + return false; + } + public void ensureOutgoingRuleForAddress(String address) { if (address == null || address.isEmpty() || address.startsWith("0.0.0.0")) { if (logger.isInfoEnabled()) @@ -84,7 +123,7 @@ public void ensureOutgoingRuleForAddress(String address) { } } - private void registerHandler(Hypervisor.HypervisorType hypervisorType, SecondaryStorageResourceHandler handler) { + void registerHandler(Hypervisor.HypervisorType hypervisorType, SecondaryStorageResourceHandler handler) { _handlers.put(hypervisorType, handler); } diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareSecondaryStorageResourceHandler.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareSecondaryStorageResourceHandler.java index ece6176547c0..024cfd0d46c8 100644 --- a/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareSecondaryStorageResourceHandler.java +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareSecondaryStorageResourceHandler.java @@ -43,6 +43,7 @@ import com.cloud.hypervisor.vmware.mo.VmwareHypervisorHost; import com.cloud.hypervisor.vmware.mo.VmwareHypervisorHostNetworkSummary; import com.cloud.hypervisor.vmware.util.VmwareContext; +import com.cloud.hypervisor.vmware.util.VmwareTaskRegistry; import com.cloud.hypervisor.vmware.util.VmwareHelper; import com.cloud.serializer.GsonHelper; import com.cloud.utils.NumbersUtil; @@ -62,6 +63,7 @@ public class VmwareSecondaryStorageResourceHandler implements SecondaryStorageRe private final StorageSubsystemCommandHandler storageSubsystemHandler; private static ThreadLocal currentContext = new ThreadLocal(); + private final VmwareTaskRegistry taskRegistry = new VmwareTaskRegistry(); /* * private Map _activeHosts = new HashMap(); @@ -92,7 +94,8 @@ private static String getCommandLogTitle(Command cmd) { @Override public Answer executeRequest(Command cmd) { - + final Long requestSequence = cmd.getRequestSequence() > 0 ? cmd.getRequestSequence() : null; + taskRegistry.beginRequest(requestSequence, cmd.getClass().getSimpleName()); try { Answer answer; ThreadContext.push(getCommandLogTitle(cmd)); @@ -131,18 +134,33 @@ public Answer executeRequest(Command cmd) { answer.setContextParam("checkpoint2", cmd.getContextParam("checkpoint2")); } - if (logger.isDebugEnabled()) - logger.debug("Command execution answer: " + _gson.toJson(answer)); + if (logger.isDebugEnabled()) { + logger.debug("Command execution answer: {}", _gson.toJson(answer)); + } + if (answer != null && !answer.getResult() && taskRegistry.wasCancelRequested(requestSequence)) { + answer.setCancelled(true); + } return answer; } finally { + taskRegistry.endRequest(requestSequence); if (logger.isDebugEnabled()) - logger.debug("Done executing " + _gson.toJson(cmd)); + logger.debug("Done executing {}", _gson.toJson(cmd)); recycleServiceContext(); ThreadContext.pop(); } } + @Override + public String checkRequestSequenceIsNotCancellable(long sequence) { + return taskRegistry.checkTaskNotCancellable(sequence); + } + + @Override + public boolean cancelRequestSequence(long sequence) { + return taskRegistry.cancel(sequence); + } + private void logCommand(Command cmd) { try { logger.debug(String.format("Executing command: [%s].", _gson.toJson(cmd))); diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareStorageProcessor.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareStorageProcessor.java index 253bbf2d0930..a31b553e45e2 100644 --- a/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareStorageProcessor.java +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/storage/resource/VmwareStorageProcessor.java @@ -1918,6 +1918,16 @@ public Answer backupSnapshot(CopyCommand cmd) { } finally { if (vmMo != null) { ManagedObjectReference snapshotMor = vmMo.getSnapshotMor(snapshotUuid); + if (snapshotMor == null && backupResult == null) { + try { + if (vmMo.isConsolidationNeeded()) { + logger.info("Consolidating the disks of VM {} after its snapshot was not created", vmMo.getName()); + vmMo.consolidateVmDisks(); + } + } catch (Exception e) { + logger.warn("Unable to consolidate the disks of VM {} after its snapshot was not created", vmMo.getName(), e); + } + } if (snapshotMor != null) { vmMo.removeSnapshot(snapshotUuid, false); @@ -3645,7 +3655,12 @@ private Long restoreVolumeFromSecStorage(VmwareHypervisorHost hyperHost, Datasto VirtualMachineMO workerVm = null; try { - hyperHost.importVmFromOVF(srcOVFFileName, newVolumeName, primaryDsMo, "thin", null); + try { + hyperHost.importVmFromOVF(srcOVFFileName, newVolumeName, primaryDsMo, "thin", null); + } catch (Exception e) { + removeLeftoversOfFailedImport(hyperHost, primaryDsMo, newVolumeName); + throw e; + } workerVm = hyperHost.findVmOnHyperHost(newVolumeName); if (workerVm == null) { throw new Exception("Unable to create container VM for volume creation"); @@ -3665,6 +3680,26 @@ private Long restoreVolumeFromSecStorage(VmwareHypervisorHost hyperHost, Datasto } } + protected void removeLeftoversOfFailedImport(VmwareHypervisorHost hyperHost, DatastoreMO dsMo, String vmName) { + try { + VirtualMachineMO leftover = hyperHost.findVmOnHyperHost(vmName); + if (leftover == null) { + leftover = hyperHost.findVmOnPeerHyperHost(vmName); + } + if (leftover != null) { + logger.info("Destroying VM {} left behind by the failed import", vmName); + leftover.destroy(); + } + String dsName = dsMo.getName(); + if (dsMo.folderExists(String.format("[%s]", dsName), vmName)) { + logger.info("Deleting folder {} left on datastore {} by the failed import", vmName, dsName); + dsMo.deleteFolder(String.format("[%s] %s", dsName, vmName), dsMo.getOwnerDatacenter().first().getMor()); + } + } catch (Exception e) { + logger.warn("Unable to clean up after the failed import of {}: {}", vmName, e.getMessage()); + } + } + @Override public Answer createVolumeFromSnapshot(CopyCommand cmd) { DataTO srcData = cmd.getSrcTO(); diff --git a/plugins/hypervisors/vmware/src/main/java/org/apache/cloudstack/storage/motion/VmwareStorageMotionStrategy.java b/plugins/hypervisors/vmware/src/main/java/org/apache/cloudstack/storage/motion/VmwareStorageMotionStrategy.java index d2d319ed9d03..7a38e6ed42e3 100644 --- a/plugins/hypervisors/vmware/src/main/java/org/apache/cloudstack/storage/motion/VmwareStorageMotionStrategy.java +++ b/plugins/hypervisors/vmware/src/main/java/org/apache/cloudstack/storage/motion/VmwareStorageMotionStrategy.java @@ -53,6 +53,7 @@ import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.agent.api.to.VolumeTO; import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationCancelledException; import com.cloud.exception.OperationTimedoutException; import com.cloud.host.Host; import com.cloud.host.HostVO; @@ -255,10 +256,16 @@ public void copyAsync(DataObject srcData, DataObject destData, Host destHost, As cmd.setContextParam(DiskTO.PROTOCOL_TYPE, Storage.StoragePoolType.DatastoreCluster.toString()); } Answer answer; - if (hostId != null) { - answer = agentMgr.easySend(hostId, cmd); - } else { - answer = agentMgr.sendTo(sourcePool.getDataCenterId(), HypervisorType.VMware, cmd); + try { + if (hostId != null) { + answer = agentMgr.easySend(hostId, cmd); + } else { + answer = agentMgr.sendTo(sourcePool.getDataCenterId(), HypervisorType.VMware, cmd); + } + } catch (OperationCancelledException e) { + logger.info("Migration of volume {} was cancelled: {}", srcData.getName(), e.getMessage()); + resetVolumeState(srcData, volDao.findById(destData.getId())); + throw e; } handleAnswerAndUpdateVolumeAfterMigration(answer, srcData, destData); CopyCommandResult result = new CopyCommandResult(null, answer); @@ -291,7 +298,7 @@ private void handleAnswerAndUpdateVolumeAfterMigration(Answer answer, DataObject if (!(answer instanceof MigrateVolumeAnswer)) { // OfflineVmwareMigration: reset states and such resetVolumeState(srcData, destinationVO); - throw new CloudRuntimeException("unexpected answer from hypervisor agent: " + answer.getDetails()); + throw new CloudRuntimeException(answer == null ? "no answer from the hypervisor agent" : "unexpected answer from hypervisor agent: " + answer.getDetails()); } MigrateVolumeAnswer ans = (MigrateVolumeAnswer) answer; if (!answer.getResult()) { diff --git a/plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/resource/VmwareResourceTest.java b/plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/resource/VmwareResourceTest.java index be7ee46f6dda..fc7bc003a6db 100644 --- a/plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/resource/VmwareResourceTest.java +++ b/plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/resource/VmwareResourceTest.java @@ -58,6 +58,9 @@ import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; import org.junit.Before; import org.junit.Test; +import org.junit.Assert; +import com.vmware.vim25.VirtualDiskFlatVer2BackingInfo; +import com.vmware.vim25.VirtualDisk; import org.junit.runner.RunWith; import org.mockito.InOrder; import org.mockito.InjectMocks; @@ -874,4 +877,31 @@ public void testRemoveVirtualTPMDevice() throws Exception { Mockito.verify(vmwareResource, Mockito.times(1)).removeVirtualTPMDevice(vmConfigSpec, tpm); Mockito.verify(deviceChanges, Mockito.times(1)).add(any(VirtualDeviceConfigSpec.class)); } + + @Test + public void testUnknownRequestSequenceIsNotCancellable() { + assertNotNull(vmwareResource.checkRequestSequenceIsNotCancellable(4242L)); + assertFalse(vmwareResource.cancelRequestSequence(4242L)); + } + + @Test + public void testWorkerVmIsDestroyedByItsDisksOwnBackingAfterACancelledVolumeMigration() throws Exception { + VirtualMachineMO worker = Mockito.mock(VirtualMachineMO.class); + DatastoreMO destination = Mockito.mock(DatastoreMO.class); + VirtualDisk disk = new VirtualDisk(); + VirtualDiskFlatVer2BackingInfo backing = new VirtualDiskFlatVer2BackingInfo(); + // the relocate was cancelled: the disk is still on the source datastore + backing.setFileName("[source-ds] i-2-7-VM/DATA-7.vmdk"); + disk.setBacking(backing); + Mockito.when(worker.getVmName()).thenReturn("worker-1"); + Mockito.when(worker.getAllDiskDevice()).thenReturn(new VirtualDisk[] {disk}); + Mockito.doThrow(new Exception("No such disk device")).when(worker).detachDisk(Mockito.anyString(), Mockito.eq(false)); + + String name = vmwareResource.destroyWorkerVmAfterVolumeMigration(worker, destination, "worker-0"); + + Mockito.verify(worker).detachDisk("[source-ds] i-2-7-VM/DATA-7.vmdk", false); + Mockito.verify(worker).destroy(); + Mockito.verifyNoInteractions(destination); + Assert.assertEquals("worker-1", name); + } } diff --git a/plugins/hypervisors/vmware/src/test/java/com/cloud/storage/resource/PremiumSecondaryStorageResourceTest.java b/plugins/hypervisors/vmware/src/test/java/com/cloud/storage/resource/PremiumSecondaryStorageResourceTest.java new file mode 100644 index 000000000000..b6b435ca68a3 --- /dev/null +++ b/plugins/hypervisors/vmware/src/test/java/com/cloud/storage/resource/PremiumSecondaryStorageResourceTest.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 com.cloud.storage.resource; + +import org.apache.cloudstack.storage.resource.SecondaryStorageResourceHandler; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CancelCommand; +import com.cloud.hypervisor.Hypervisor; + +@RunWith(MockitoJUnitRunner.class) +public class PremiumSecondaryStorageResourceTest { + + @Mock + private SecondaryStorageResourceHandler handler; + + private final PremiumSecondaryStorageResource resource = new PremiumSecondaryStorageResource(); + + @Test + public void cancelProbeIsAnsweredByTheHandlerRunningTheRequest() { + resource.registerHandler(Hypervisor.HypervisorType.VMware, handler); + Mockito.when(handler.checkRequestSequenceIsNotCancellable(7L)).thenReturn(null); + + final Answer answer = resource.executeRequest(new CancelCommand(7L, "probe", true)); + + Assert.assertTrue(answer.getResult()); + Mockito.verify(handler, Mockito.never()).cancelRequestSequence(Mockito.anyLong()); + } + + @Test + public void cancelProbeCarriesTheHandlersReason() { + resource.registerHandler(Hypervisor.HypervisorType.VMware, handler); + Mockito.when(handler.checkRequestSequenceIsNotCancellable(7L)).thenReturn("no vCenter task or transfer is running for CopyCommand"); + + final Answer answer = resource.executeRequest(new CancelCommand(7L, "probe", true)); + + Assert.assertFalse(answer.getResult()); + Assert.assertEquals("no vCenter task or transfer is running for CopyCommand", answer.getDetails()); + } + + @Test + public void cancelIsDelegatedToTheHandler() { + resource.registerHandler(Hypervisor.HypervisorType.VMware, handler); + Mockito.when(handler.cancelRequestSequence(7L)).thenReturn(true); + + final Answer answer = resource.executeRequest(new CancelCommand(7L, "job cancelled")); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("job cancelled")); + } + + @Test + public void withoutAHandlerNothingCanBeCancelled() { + Assert.assertFalse(resource.executeRequest(new CancelCommand(7L, "probe", true)).getResult()); + Assert.assertFalse(resource.executeRequest(new CancelCommand(7L, "job cancelled")).getResult()); + } +} diff --git a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBase.java b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBase.java index cdb4d7434aef..ce70d6e59198 100644 --- a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBase.java +++ b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBase.java @@ -126,6 +126,7 @@ import com.cloud.network.Networks; import com.cloud.network.Networks.BroadcastDomainType; import com.cloud.network.Networks.TrafficType; +import com.cloud.resource.RequestExecutionContext; import com.cloud.resource.ServerResource; import com.cloud.resource.ServerResourceBase; import com.cloud.resource.hypervisor.HypervisorResource; @@ -307,6 +308,9 @@ private static boolean isAlienVm(final VM vm, final Connection conn) throws XenA protected XenServerUtilitiesHelper xenServerUtilitiesHelper = new XenServerUtilitiesHelper(); + // per resource, so two hosts cannot collide on a request sequence + private final XenServerTaskRegistry taskRegistry = new XenServerTaskRegistry(); + protected int _wait; // Hypervisor specific params with generic value, may need to be overridden // for specific versions @@ -500,10 +504,21 @@ public boolean canBridgeFirewall(final Connection conn) { } public void checkForSuccess(final Connection c, final Task task) throws XenAPIException, XmlRpcException { - if (task.getStatus(c) == Types.TaskStatusType.SUCCESS) { + final Types.TaskStatusType status = task.getStatus(c); + if (status == Types.TaskStatusType.SUCCESS) { if (logger.isTraceEnabled()) { logger.trace("Task " + task.getNameLabel(c) + " (" + task.getUuid(c) + ") completed"); } + } else if (status == Types.TaskStatusType.CANCELLED) { + // a cancelled task cannot be cancelled again; it is destroyed and reported as the cancellation it is + final String msg = "Task " + task.getNameLabel(c) + " (" + task.getUuid(c) + ") was cancelled"; + logger.info(msg); + try { + task.destroy(c); + } catch (final Exception e) { + logger.debug("Unable to destroy cancelled task " + task + ": " + e.getMessage()); + } + throw new Types.BadAsyncResult(msg); } else { final String msg = "Task failed! Task record: " + task.getRecord(c); logger.warn(msg); @@ -765,6 +780,49 @@ public void cleanUpTmpDomVif(final Connection conn, final Network nw) throws Xen } } + /** Whether a cancel stopped a task of the request this thread is executing. */ + public boolean wasCurrentRequestCancelled() { + return taskRegistry.wasCancelRequested(RequestExecutionContext.getRequestSequence()); + } + + /** The VDIs an SR holds now, taken before a copy or migration so that what a cancelled one leaves behind can be told apart; null when unknown. */ + public Set vdisOn(final Connection conn, final SR sr) { + if (sr == null) { + return null; + } + try { + return new HashSet<>(sr.getVDIs(conn)); + } catch (final Exception e) { + logger.debug("Unable to list the VDIs of SR " + sr + ": " + e.getMessage()); + return null; + } + } + + /** + * A VDI.copy or pool_migrate cancelled half way leaves its destination VDI on the SR, unknown to CloudStack. The VDIs that + * appeared since the snapshot taken before the operation are destroyed; on a shared SR only one carrying the moved VDI's name + * label is touched, so that a VDI another operation created meanwhile is left alone. + */ + public void destroyVdisLeftBy(final Connection conn, final SR sr, final Set before, final String nameLabel) { + if (sr == null || before == null) { + return; + } + try { + for (final VDI vdi : sr.getVDIs(conn)) { + if (before.contains(vdi)) { + continue; + } + if (nameLabel != null && !nameLabel.equals(vdi.getNameLabel(conn))) { + continue; + } + logger.info("Destroying VDI " + vdi.getUuid(conn) + " left on SR " + sr.getUuid(conn) + " by a cancelled operation"); + vdi.destroy(conn); + } + } catch (final Exception e) { + logger.warn("Unable to clean up the VDIs a cancelled operation left on SR " + sr + ": " + e.getMessage()); + } + } + protected VDI cloudVDIcopy(final Connection conn, final VDI vdi, final SR sr, int wait) throws Exception { Task task = null; if (wait == 0) { @@ -1775,6 +1833,31 @@ public ExecutionResult executeInVR(final String routerIP, final String script, f @Override public Answer executeRequest(final Command cmd) { + final Long requestSequence = RequestExecutionContext.getRequestSequence(); + taskRegistry.beginRequest(requestSequence, cmd.getClass().getSimpleName()); + try { + final Answer answer = executeRequestInternal(cmd); + // a cancelled XenAPI task surfaces as an ordinary failure; flag it as cancelled + if (answer != null && !answer.getResult() && taskRegistry.wasCancelRequested(requestSequence)) { + answer.setCancelled(true); + } + return answer; + } finally { + taskRegistry.endRequest(requestSequence); + } + } + + @Override + public String checkRequestSequenceIsNotCancellable(final long sequence) { + return taskRegistry.checkTaskNotCancellable(sequence); + } + + @Override + public boolean cancelRequestSequence(final long sequence) { + return taskRegistry.cancel(sequence); + } + + private Answer executeRequestInternal(final Command cmd) { final CitrixRequestWrapper wrapper = CitrixRequestWrapper.getInstance(); try { return wrapper.execute(cmd, this); @@ -5280,24 +5363,38 @@ public String upgradeSnapshot(final Connection conn, final String templatePath, public void waitForTask(final Connection c, final Task task, final long pollInterval, final long timeout) throws XenAPIException, XmlRpcException, TimeoutException { final long beginTime = System.currentTimeMillis(); + final String taskName = task.getNameLabel(c); if (logger.isTraceEnabled()) { - logger.trace("Task " + task.getNameLabel(c) + " (" + task.getUuid(c) + ") sent to " + c.getSessionReference() + " is pending completion with a " + timeout + "ms timeout"); + logger.trace("Task " + taskName + " (" + task.getUuid(c) + ") sent to " + c.getSessionReference() + " is pending completion with a " + timeout + "ms timeout"); } - while (task.getStatus(c) == Types.TaskStatusType.PENDING) { - try { - if (logger.isTraceEnabled()) { - logger.trace("Task " + task.getNameLabel(c) + " (" + task.getUuid(c) + ") is pending, sleeping for " + pollInterval + "ms"); + // register with the executing request; a request already cancelled cancels the new task at once + if (XenServerTaskRegistry.taskStarted(task, c, taskName)) { + logger.info("Request executing on this thread was already cancelled, cancelling newly created task " + task); + XenServerTaskRegistry.cancelTask(task, c); + } + Types.TaskStatusType status = null; + try { + // wait through CANCELLING too: callers read the final status right after this returns + status = task.getStatus(c); + while (status == Types.TaskStatusType.PENDING || status == Types.TaskStatusType.CANCELLING) { + try { + if (logger.isTraceEnabled()) { + logger.trace("Task " + task.getNameLabel(c) + " (" + task.getUuid(c) + ") is " + status + ", sleeping for " + pollInterval + "ms"); + } + Thread.sleep(pollInterval); + } catch (final InterruptedException ignored) { } - Thread.sleep(pollInterval); - } catch (final InterruptedException ignored) { - } - if (System.currentTimeMillis() - beginTime > timeout) { - final String msg = "Async " + timeout / 1000 + " seconds timeout for task " + task; - logger.warn(msg); - task.cancel(c); - task.destroy(c); - throw new TimeoutException(msg); + if (System.currentTimeMillis() - beginTime > timeout) { + final String msg = "Async " + timeout / 1000 + " seconds timeout for task " + task; + logger.warn(msg); + task.cancel(c); + task.destroy(c); + throw new TimeoutException(msg); + } + status = task.getStatus(c); } + } finally { + XenServerTaskRegistry.taskFinished(task, status); } } diff --git a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessor.java b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessor.java index a1d27b65abac..7c33362ceddc 100644 --- a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessor.java +++ b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessor.java @@ -94,6 +94,8 @@ public class XenServerStorageProcessor implements StorageProcessor { protected Logger logger = LogManager.getLogger(getClass()); protected CitrixResourceBase hypervisorResource; + static final long SNAPSHOT_DESTROY_WAIT_MS = 60000; + static final long SNAPSHOT_DESTROY_POLL_MS = 1000; protected String BaseMountPointOnHost = "/var/run/cloud_mount"; protected final static String BASE_MOUNT_POINT_ON_REMOTE = "/var/cloud_mount/"; @@ -1176,8 +1178,23 @@ protected boolean destroySnapshotOnPrimaryStorage(final Connection conn, final S logger.warn("Could not destroy snapshot " + lastSnapshotUuid + " due to can not find it"); return false; } - snapshot.destroy(conn); - return true; + // a VDI.copy that was just cancelled still holds its source for a while; XAPI refuses the destroy until it lets go + final long deadline = System.currentTimeMillis() + SNAPSHOT_DESTROY_WAIT_MS; + while (true) { + try { + snapshot.destroy(conn); + return true; + } catch (final Types.OtherOperationInProgress | Types.VdiInUse e) { + if (System.currentTimeMillis() >= deadline) { + throw e; + } + logger.info("Snapshot " + lastSnapshotUuid + " is still in use by " + snapshot.getCurrentOperations(conn).values() + ", waiting before destroying it"); + Thread.sleep(SNAPSHOT_DESTROY_POLL_MS); + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while waiting to destroy snapshot " + lastSnapshotUuid); } catch (final XenAPIException e) { final String msg = "Destroying snapshot: " + lastSnapshotUuid + " failed due to " + e.toString(); logger.error(msg, e); diff --git a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/XenServerTaskRegistry.java b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/XenServerTaskRegistry.java new file mode 100644 index 000000000000..bba7ddfcc4ca --- /dev/null +++ b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/XenServerTaskRegistry.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.hypervisor.xenserver.resource; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.utils.Pair; +import com.xensource.xenapi.Connection; +import com.xensource.xenapi.Task; +import com.xensource.xenapi.Types; + +/** Per agent request sequence, the XenAPI tasks a resource is waiting on; one instance per host. waitForTask is the single registration point. */ +public class XenServerTaskRegistry { + private static final Logger LOGGER = LogManager.getLogger(XenServerTaskRegistry.class); + + private static final long CANCEL_TASK_WAIT_MS = 30000; + private static final long CANCEL_TASK_POLL_MS = 500; + + /** + * Power operations cannot be taken back once issued: XAPI lets the task be cancelled, but the guest has already been told to shut + * down or reboot, and a hard_shutdown cancelled half way leaves the VM paused. The resource would then fall back to the hard + * variant, which is the opposite of what a cancel means. + */ + static final Set UNCANCELLABLE_TASKS = new HashSet<>(Arrays.asList( + "Async.VM.clean_shutdown", "Async.VM.hard_shutdown", "Async.VM.clean_reboot", "Async.VM.hard_reboot", + "Async.VM.start", "Async.VM.start_on", "Async.VM.suspend", "Async.VM.resume", "Async.VM.resume_on", "Async.VM.pause", "Async.VM.unpause")); + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private final Map requests = new ConcurrentHashMap<>(); + + private static final class ActiveTask { + final Task task; + final Connection connection; + final String name; + + ActiveTask(final Task task, final Connection connection, final String name) { + this.task = task; + this.connection = connection; + this.name = name; + } + } + + private static final class RequestState { + final String command; + final Map tasks = new ConcurrentHashMap<>(); + // the status the waiting thread saw last, kept because it destroys the task object right after and the canceller may still be polling it + final Map finished = new ConcurrentHashMap<>(); + volatile boolean cancelling; + volatile boolean cancelRequested; + + RequestState(final String command) { + this.command = command; + } + } + + private static final class RequestScope { + final XenServerTaskRegistry registry; + final long sequence; + + RequestScope(final XenServerTaskRegistry registry, final long sequence) { + this.registry = registry; + this.sequence = sequence; + } + } + + /** A null sequence (not from the agent layer) leaves the thread unscoped. */ + public void beginRequest(final Long sequence, final String command) { + if (sequence == null) { + return; + } + requests.putIfAbsent(sequence, new RequestState(command)); + CURRENT.set(new RequestScope(this, sequence)); + } + + public void endRequest(final Long sequence) { + CURRENT.remove(); + if (sequence != null) { + requests.remove(sequence); + } + } + + public boolean wasCancelRequested(final Long sequence) { + if (sequence == null) { + return false; + } + final RequestState state = requests.get(sequence); + return state != null && state.cancelRequested; + } + + /** Returns true when the request is being cancelled right now, so the caller cancels the new task at once; a task started after the cancel is the request's undo and runs. */ + static boolean taskStarted(final Task task, final Connection connection) { + return taskStarted(task, connection, null); + } + + static boolean taskStarted(final Task task, final Connection connection, final String name) { + final RequestScope scope = CURRENT.get(); + if (scope == null || task == null) { + return false; + } + final RequestState state = scope.registry.requests.get(scope.sequence); + if (state == null) { + return false; + } + if (state.cancelling) { + return true; + } + state.tasks.put(refOf(task), new ActiveTask(task, connection, name)); + return false; + } + + static void taskFinished(final Task task) { + taskFinished(task, null); + } + + static void taskFinished(final Task task, final Types.TaskStatusType finalStatus) { + final RequestScope scope = CURRENT.get(); + if (scope == null || task == null) { + return; + } + final RequestState state = scope.registry.requests.get(scope.sequence); + if (state != null) { + final String ref = refOf(task); + if (finalStatus != null) { + state.finished.put(ref, finalStatus); + } + state.tasks.remove(ref); + } + } + + /** Null when every XenAPI task in flight is still pending (XenAPI has no cancelable flag). A request with none in flight is refused: a synchronous call already made cannot be undone by interrupting the thread. */ + public String checkTaskNotCancellable(final long sequence) { + final RequestState state = requests.get(sequence); + if (state == null) { + return "request sequence " + sequence + " is not running on this host"; + } + if (state.tasks.isEmpty()) { + return "no XenAPI task is running for " + state.command; + } + for (final ActiveTask active : state.tasks.values()) { + final String refusal = refusalForUncancellable(active); + if (refusal != null) { + return refusal; + } + try { + final Types.TaskStatusType status = active.task.getStatus(active.connection); + if (status != Types.TaskStatusType.PENDING) { + LOGGER.debug("XenAPI task {} of request sequence {} is already {}, not cancellable", refOf(active.task), sequence, status); + return "XenAPI task " + refOf(active.task) + " is already " + status; + } + } catch (final Exception e) { + final Types.TaskStatusType finished = state.finished.get(refOf(active.task)); + if (finished != null) { + return "XenAPI task " + refOf(active.task) + " is already " + finished; + } + LOGGER.info("Unable to check the status of XenAPI task {} of request sequence {}: {}", refOf(active.task), sequence, e.getMessage()); + return "unable to check XenAPI task " + refOf(active.task) + ": " + e.getMessage(); + } + } + return null; + } + + private static String refusalForUncancellable(final ActiveTask active) { + if (active.name != null && UNCANCELLABLE_TASKS.contains(active.name)) { + return "XenAPI task " + active.name + " cannot be stopped once issued: the guest has already been told"; + } + return null; + } + + /** Cancels the tasks in flight. One that starts meanwhile is cancelled on arrival; what the request starts afterwards is its undo and runs. */ + public boolean cancel(final long sequence) { + final RequestState state = requests.get(sequence); + if (state == null) { + return false; + } + if (state.tasks.isEmpty()) { + LOGGER.debug("Request sequence {} ({}) has no XenAPI task to cancel", sequence, state.command); + return false; + } + for (final ActiveTask active : state.tasks.values()) { + final String refusal = refusalForUncancellable(active); + if (refusal != null) { + LOGGER.info("Not cancelling request sequence {} ({}): {}", sequence, state.command, refusal); + return false; + } + } + state.cancelling = true; + boolean allCancelled = true; + boolean anyCancelled = false; + try { + for (final ActiveTask active : state.tasks.values()) { + final Pair result = cancelTask(active.task, active.connection, state.finished::get); + if (result.first()) { + anyCancelled = true; + LOGGER.info("Cancelled XenAPI task {} of request sequence {}", refOf(active.task), sequence); + } else { + LOGGER.info("Could not cancel XenAPI task {} of request sequence {}: {}", refOf(active.task), sequence, result.second()); + allCancelled = false; + } + } + } finally { + if (anyCancelled) { + state.cancelRequested = true; + } + state.cancelling = false; + } + return allCancelled; + } + + /** cancelAsync, then poll to a terminal state: the synchronous cancel would hold the caller on a XenServer round trip. */ + static Pair cancelTask(final Task task, final Connection connection) { + return cancelTask(task, connection, ref -> null); + } + + /** + * The thread waiting on the task destroys the task object as soon as it sees it finish, so a status poll here can meet an invalid + * handle. The final status that thread recorded then says whether the task ended CANCELLED or completed first. + */ + static Pair cancelTask(final Task task, final Connection connection, final Function finishedStatus) { + final String ref = refOf(task); + boolean cancelIssued = false; + try { + final Types.TaskStatusType status = task.getStatus(connection); + if (status != Types.TaskStatusType.PENDING && status != Types.TaskStatusType.CANCELLING) { + return new Pair<>(false, "task " + ref + " is already " + status); + } + if (status == Types.TaskStatusType.PENDING) { + task.cancelAsync(connection); + } + cancelIssued = true; + + final long deadline = System.currentTimeMillis() + CANCEL_TASK_WAIT_MS; + while (System.currentTimeMillis() < deadline) { + final Types.TaskStatusType now = task.getStatus(connection); + if (now == Types.TaskStatusType.CANCELLED) { + return new Pair<>(true, "task " + ref + " cancelled"); + } + if (now == Types.TaskStatusType.SUCCESS) { + return new Pair<>(false, "task " + ref + " completed before it could be cancelled"); + } + if (now == Types.TaskStatusType.FAILURE) { + return new Pair<>(false, "task " + ref + " failed before it could be cancelled"); + } + Thread.sleep(CANCEL_TASK_POLL_MS); + } + return new Pair<>(false, "task " + ref + " did not stop within " + CANCEL_TASK_WAIT_MS / 1000 + " seconds of the cancel request"); + } catch (final Types.OperationNotAllowed e) { + return new Pair<>(false, "XenServer does not allow cancelling task " + ref); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return new Pair<>(false, "interrupted while waiting for task " + ref + " to cancel"); + } catch (final Exception e) { + final Types.TaskStatusType finished = finishedStatus.apply(ref); + if (finished != null) { + LOGGER.debug("XenAPI task {} is gone, it ended {}", ref, finished); + return finished == Types.TaskStatusType.CANCELLED && cancelIssued + ? new Pair<>(true, "task " + ref + " cancelled") + : new Pair<>(false, "task " + ref + " is already " + finished); + } + LOGGER.warn("Failed to cancel XenAPI task {}", ref, e); + return new Pair<>(false, "failed to cancel task " + ref + ": " + e.getMessage()); + } + } + + // no opaque ref before a round trip (or on a test double): fall back to identity + private static String refOf(final Task task) { + final String ref = task.toWireString(); + return ref != null ? ref : "task@" + Integer.toHexString(System.identityHashCode(task)); + } + + int activeTaskCount(final long sequence) { + final RequestState state = requests.get(sequence); + return state == null ? 0 : state.tasks.size(); + } +} diff --git a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/Xenserver625StorageProcessor.java b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/Xenserver625StorageProcessor.java index c03b419fcdc4..9cfbfe594495 100644 --- a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/Xenserver625StorageProcessor.java +++ b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/Xenserver625StorageProcessor.java @@ -424,11 +424,13 @@ protected String backupSnapshot(final Connection conn, final String primaryStora prevBackupUuid = ""; } SR ssSR = null; + Set onSrBefore = null; final String remoteDir = secondaryStorageMountPath; try { ssSR = createFileSr(conn, remoteDir, path, nfsVersion); filesrcreated = true; + onSrBefore = hypervisorResource.vdisOn(conn, ssSR); final VDI snapshotvdi = VDI.getByUuid(conn, snapshotUuid); if (wait == 0) { @@ -460,6 +462,9 @@ protected String backupSnapshot(final Connection conn, final String primaryStora final String result = dvdi.getUuid(conn).concat("#").concat(dvdi.getPhysicalUtilisation(conn).toString()); return result; } catch (final Exception e) { + if (hypervisorResource.wasCurrentRequestCancelled()) { + hypervisorResource.destroyVdisLeftBy(conn, ssSR, onSrBefore, null); + } final String msg = "Exception in backupsnapshot stage due to " + e.toString(); logger.debug(msg); throw new CloudRuntimeException(msg, e); @@ -568,6 +573,8 @@ public Answer backupSnapshot(final CopyCommand cmd) { if (fullbackup) { SR snapshotSr = null; Task task = null; + Set onSrBefore = null; + boolean copied = false; try { final String localDir = BASE_MOUNT_POINT_ON_REMOTE + UuidUtils.nameUUIDFromBytes(secondaryStorageMountPath.getBytes()); mountNfs(conn, secondaryStorageMountPath, localDir, nfsVersion); @@ -579,11 +586,13 @@ public Answer backupSnapshot(final CopyCommand cmd) { } snapshotSr = createFileSr(conn, secondaryStorageMountPath, folder, nfsVersion); + onSrBefore = hypervisorResource.vdisOn(conn, snapshotSr); task = snapshotVdi.copyAsync(conn, snapshotSr, null, null); // poll every 1 seconds , hypervisorResource.waitForTask(conn, task, 1000, wait * 1000); hypervisorResource.checkForSuccess(conn, task); + copied = true; final VDI backedVdi = Types.toVDI(task, conn); snapshotBackupUuid = backedVdi.getUuid(conn); snapshotSr.scan(conn); @@ -630,6 +639,9 @@ public Answer backupSnapshot(final CopyCommand cmd) { logger.warn("unable to destroy task(" + task.toWireString() + ") due to " + e.toString()); } } + if (!copied && hypervisorResource.wasCurrentRequestCancelled()) { + hypervisorResource.destroyVdisLeftBy(conn, snapshotSr, onSrBefore, null); + } if (snapshotSr != null) { hypervisorResource.removeSR(conn, snapshotSr); } @@ -941,6 +953,8 @@ public Answer copyVolumeFromPrimaryToSecondary(final CopyCommand cmd) { if (destStore instanceof NfsTO) { SR secondaryStorage = null; Task task = null; + Set onSrBefore = null; + boolean copied = false; try { final NfsTO nfsStore = (NfsTO)destStore; final String nfsVersion = nfsStore.getNfsVersion(); @@ -952,6 +966,7 @@ public Answer copyVolumeFromPrimaryToSecondary(final CopyCommand cmd) { // Create a SR for the volume UUID folder secondaryStorage = createFileSr(conn, uri.getHost() + ":" + uri.getPath(), destVolume.getPath(), nfsVersion); + onSrBefore = hypervisorResource.vdisOn(conn, secondaryStorage); // Look up the volume on the source primary storage pool final VDI srcVdi = getVDIbyUuid(conn, srcVolume.getPath()); // Copy the volume to secondary storage @@ -959,6 +974,7 @@ public Answer copyVolumeFromPrimaryToSecondary(final CopyCommand cmd) { // poll every 1 seconds , hypervisorResource.waitForTask(conn, task, 1000, wait * 1000); hypervisorResource.checkForSuccess(conn, task); + copied = true; final VDI destVdi = Types.toVDI(task, conn); final String destVolumeUUID = destVdi.getUuid(conn); @@ -977,6 +993,9 @@ public Answer copyVolumeFromPrimaryToSecondary(final CopyCommand cmd) { logger.warn("unable to destroy task(" + task.toWireString() + ") due to " + e.toString()); } } + if (!copied && hypervisorResource.wasCurrentRequestCancelled()) { + hypervisorResource.destroyVdisLeftBy(conn, secondaryStorage, onSrBefore, null); + } hypervisorResource.removeSR(conn, secondaryStorage); } } @@ -1013,14 +1032,21 @@ public Answer copyVolumeFromImageCacheToPrimary(final CopyCommand cmd) { } final SR srcSr = createFileSr(conn, uri.getHost() + ":" + uri.getPath(), volumeDirectory, nfsVersion); Task task = null; + SR primaryStoragePool = null; + Set onSrBefore = null; + String srcVdiName = null; + boolean copied = false; try { - final SR primaryStoragePool = hypervisorResource.getStorageRepository(conn, + primaryStoragePool = hypervisorResource.getStorageRepository(conn, CitrixHelper.getSRNameLabel(primaryStore.getUuid(), primaryStore.getPoolType(), primaryStore.getPath())); final VDI srcVdi = VDI.getByUuid(conn, volumeUuid); + srcVdiName = srcVdi.getNameLabel(conn); + onSrBefore = hypervisorResource.vdisOn(conn, primaryStoragePool); task = srcVdi.copyAsync(conn, primaryStoragePool, null, null); // poll every 1 seconds , hypervisorResource.waitForTask(conn, task, 1000, wait * 1000); hypervisorResource.checkForSuccess(conn, task); + copied = true; final VDI destVdi = Types.toVDI(task, conn); final VolumeObjectTO newVol = new VolumeObjectTO(); destVdi.setNameLabel(conn, srcVolume.getName()); @@ -1040,6 +1066,9 @@ public Answer copyVolumeFromImageCacheToPrimary(final CopyCommand cmd) { logger.warn("unable to destroy task(" + task.toString() + ") due to " + e.toString()); } } + if (!copied && hypervisorResource.wasCurrentRequestCancelled()) { + hypervisorResource.destroyVdisLeftBy(conn, primaryStoragePool, onSrBefore, srcVdiName); + } if (srcSr != null) { hypervisorResource.removeSR(conn, srcSr); } diff --git a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateVolumeCommandWrapper.java b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateVolumeCommandWrapper.java index aac0af723baf..4802ddeb5844 100644 --- a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateVolumeCommandWrapper.java +++ b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateVolumeCommandWrapper.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Set; import com.cloud.agent.api.Answer; @@ -46,10 +47,13 @@ public Answer execute(final MigrateVolumeCommand command, final XenServer610Reso Connection connection = xenServer610Resource.getConnection(); String srcVolumeUuid = command.getVolumePath(); SR destPool = null; + Set onDestBefore = null; + String srcVolumeName = null; Map destDetails = command.getDestDetails(); try { VDI srcVolume = xenServer610Resource.getVDIbyUuid(connection, srcVolumeUuid); + srcVolumeName = srcVolume.getNameLabel(connection); if (destDetails != null && Boolean.parseBoolean(destDetails.get(DiskTO.MANAGED))) { String iScsiName = destDetails.get(DiskTO.IQN); @@ -69,6 +73,7 @@ public Answer execute(final MigrateVolumeCommand command, final XenServer610Reso other.put("live", "true"); + onDestBefore = xenServer610Resource.vdisOn(connection, destPool); // Live migrate the VDI. Task task = srcVolume.poolMigrateAsync(connection, destPool, other); @@ -81,13 +86,22 @@ public Answer execute(final MigrateVolumeCommand command, final XenServer610Reso return new MigrateVolumeAnswer(command, true, null, destVdi.getUuid(connection)); } catch (Exception ex) { + final boolean cancelled = xenServer610Resource.wasCurrentRequestCancelled(); + if (cancelled) { + // the mirror pool_migrate was writing on the destination is not cleaned up by XAPI + xenServer610Resource.destroyVdisLeftBy(connection, destPool, onDestBefore, srcVolumeName); + } if (destDetails != null && Boolean.parseBoolean(destDetails.get(DiskTO.MANAGED)) && destPool != null) { xenServer610Resource.removeSR(connection, destPool); } String msg = "Caught exception " + ex.getClass().getName() + " due to the following: " + ex.toString(); - logger.error(msg, ex); + if (cancelled) { + logger.info("Migration of volume " + srcVolumeUuid + " was cancelled: " + ex.getMessage()); + } else { + logger.error(msg, ex); + } return new MigrateVolumeAnswer(command, false, msg, null); } diff --git a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateWithStorageCommandWrapper.java b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateWithStorageCommandWrapper.java index e46b930b07a7..01acbf8d805d 100644 --- a/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateWithStorageCommandWrapper.java +++ b/plugins/hypervisors/xenserver/src/main/java/com/cloud/hypervisor/xenserver/resource/wrapper/xen610/XenServer610MigrateWithStorageCommandWrapper.java @@ -63,6 +63,8 @@ public Answer execute(final MigrateWithStorageCommand command, final XenServer61 final List> volumeToFiler = command.getVolumeToFilerAsList(); final String vmName = vmSpec.getName(); Task task = null; + final Map> onDestBefore = new HashMap<>(); + final Map migratingVdis = new HashMap<>(); final XsHost xsHost = xenServer610Resource.getHost(); final String uuid = xsHost.getUuid(); @@ -91,6 +93,10 @@ public Answer execute(final MigrateWithStorageCommand command, final XenServer61 xenServer610Resource.getStorageRepository(connection, CitrixHelper.getSRNameLabel(storageFiler.getUuid(), storageFiler.getType(), storageFiler.getPath()))); } + for (final SR destSr : vdiMap.values()) { + onDestBefore.computeIfAbsent(destSr, sr -> xenServer610Resource.vdisOn(connection, sr)); + } + migratingVdis.putAll(vdiMap); // Get the vm to migrate. final Set vms = VM.getByNameLabel(connection, vmSpec.getName()); @@ -127,7 +133,19 @@ public Answer execute(final MigrateWithStorageCommand command, final XenServer61 vmToMigrate.setAffinity(connection, host); return new MigrateWithStorageAnswer(command, volumeToList); } catch (final Exception e) { - logger.warn("Catch Exception " + e.getClass().getName() + ". Storage motion failed due to " + e.toString(), e); + if (xenServer610Resource.wasCurrentRequestCancelled()) { + logger.info("Storage motion of " + vmName + " was cancelled: " + e.getMessage()); + // the copies migrate_send was writing on the destination SRs are not cleaned up by XAPI + for (final Map.Entry entry : migratingVdis.entrySet()) { + try { + xenServer610Resource.destroyVdisLeftBy(connection, entry.getValue(), onDestBefore.get(entry.getValue()), entry.getKey().getNameLabel(connection)); + } catch (final Exception cleanup) { + logger.debug("Unable to clean up after the cancelled storage motion of " + vmName + ": " + cleanup.getMessage()); + } + } + } else { + logger.warn("Catch Exception " + e.getClass().getName() + ". Storage motion failed due to " + e.toString(), e); + } return new MigrateWithStorageAnswer(command, e); } finally { if (task != null) { diff --git a/plugins/hypervisors/xenserver/src/main/java/org/apache/cloudstack/storage/motion/XenServerStorageMotionStrategy.java b/plugins/hypervisors/xenserver/src/main/java/org/apache/cloudstack/storage/motion/XenServerStorageMotionStrategy.java index 76807b89d0f7..2ed4954e3b85 100644 --- a/plugins/hypervisors/xenserver/src/main/java/org/apache/cloudstack/storage/motion/XenServerStorageMotionStrategy.java +++ b/plugins/hypervisors/xenserver/src/main/java/org/apache/cloudstack/storage/motion/XenServerStorageMotionStrategy.java @@ -57,6 +57,7 @@ import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.agent.api.to.VolumeTO; import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationCancelledException; import com.cloud.exception.OperationTimedoutException; import com.cloud.host.Host; import com.cloud.hypervisor.Hypervisor.HypervisorType; @@ -126,6 +127,9 @@ public void copyAsync(Map volumeMap, VirtualMachineTO vmT } else { throw new CloudRuntimeException("Unsupported operation requested for moving data."); } + } catch (OperationCancelledException e) { + logger.info("Migration of {} with its volumes was cancelled: {}", vmTo.getName(), e.getMessage()); + errMsg = e.toString(); } catch (Exception e) { logger.error("copy failed", e); errMsg = e.toString(); diff --git a/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBaseTest.java b/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBaseTest.java index aac865622196..c99ad739c8ad 100644 --- a/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBaseTest.java +++ b/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/CitrixResourceBaseTest.java @@ -39,6 +39,9 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; +import com.xensource.xenapi.VDI; +import java.util.Collections; +import java.util.Arrays; import org.junit.runner.RunWith; import org.mockito.BDDMockito; import org.mockito.InOrder; @@ -68,6 +71,7 @@ import static com.cloud.hypervisor.xenserver.resource.CitrixResourceBase.PLATFORM_CORES_PER_SOCKET_KEY; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.mockito.Mockito.doReturn; @RunWith(MockitoJUnitRunner.class) @@ -616,4 +620,54 @@ public void testListFilesAtPathWithFile() throws IOException, XmlRpcException { Assert.assertEquals(1024L, listAnswer.getSizes().get(0).longValue()); Assert.assertEquals(123456789000L, listAnswer.getLastModified().get(0).longValue()); } + + @Test + public void testUnknownRequestSequenceIsNotCancellable() { + Assert.assertNotNull(citrixResourceBase.checkRequestSequenceIsNotCancellable(4242L)); + assertFalse(citrixResourceBase.cancelRequestSequence(4242L)); + } + + @Test + public void destroyVdisLeftByRemovesOnlyWhatAppearedWithTheMovedVdisName() throws Exception { + final Connection conn = Mockito.mock(Connection.class); + final SR sr = Mockito.mock(SR.class); + final VDI existing = Mockito.mock(VDI.class); + final VDI leftover = Mockito.mock(VDI.class); + final VDI someoneElses = Mockito.mock(VDI.class); + Mockito.when(sr.getVDIs(conn)).thenReturn(new HashSet<>(Arrays.asList(existing, leftover, someoneElses))); + Mockito.when(leftover.getNameLabel(conn)).thenReturn("DATA-7"); + Mockito.when(someoneElses.getNameLabel(conn)).thenReturn("ROOT-9"); + + citrixResourceBase.destroyVdisLeftBy(conn, sr, new HashSet<>(Collections.singletonList(existing)), "DATA-7"); + + Mockito.verify(leftover).destroy(conn); + Mockito.verify(existing, Mockito.never()).destroy(conn); + Mockito.verify(someoneElses, Mockito.never()).destroy(conn); + } + + @Test + public void destroyVdisLeftByOnADedicatedSrRemovesEverythingNew() throws Exception { + final Connection conn = Mockito.mock(Connection.class); + final SR sr = Mockito.mock(SR.class); + final VDI previousBackup = Mockito.mock(VDI.class); + final VDI partialCopy = Mockito.mock(VDI.class); + Mockito.when(sr.getVDIs(conn)).thenReturn(new HashSet<>(Arrays.asList(previousBackup, partialCopy))); + + citrixResourceBase.destroyVdisLeftBy(conn, sr, new HashSet<>(Collections.singletonList(previousBackup)), null); + + Mockito.verify(partialCopy).destroy(conn); + Mockito.verify(previousBackup, Mockito.never()).destroy(conn); + } + + @Test + public void destroyVdisLeftByDoesNothingWithoutABeforeSnapshot() throws Exception { + final Connection conn = Mockito.mock(Connection.class); + final SR sr = Mockito.mock(SR.class); + + citrixResourceBase.destroyVdisLeftBy(conn, sr, null, null); + citrixResourceBase.destroyVdisLeftBy(conn, null, new HashSet<>(), null); + + Mockito.verify(sr, Mockito.never()).getVDIs(Mockito.any()); + Assert.assertNull(citrixResourceBase.vdisOn(conn, null)); + } } diff --git a/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessorTest.java b/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessorTest.java index 75ac8b8ad4d4..763d135be403 100644 --- a/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessorTest.java +++ b/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/XenServerStorageProcessorTest.java @@ -21,6 +21,10 @@ import static org.mockito.Mockito.when; import java.util.List; +import com.xensource.xenapi.VDI; +import com.xensource.xenapi.Types; +import com.xensource.xenapi.Connection; +import java.util.Collections; import org.junit.Assert; import org.junit.Test; @@ -110,4 +114,29 @@ public void testOrderOfSwiftUplodScriptParamsWithStoragePolicy() { Assert.assertEquals("unexpected param.", "storagepolicy", params.get(18)); Assert.assertEquals("unexpected param.", "sample-storagepolicy", params.get(19)); } + + @Test + public void testDestroySnapshotOnPrimaryWaitsForTheCancelledCopyToLetGoOfIt() throws Exception { + CitrixResourceBase resource = Mockito.mock(CitrixResourceBase.class); + XenServerStorageProcessor processor = Mockito.spy(new XenServerStorageProcessor(resource)); + Connection conn = Mockito.mock(Connection.class); + VDI snapshot = Mockito.mock(VDI.class); + Mockito.doReturn(snapshot).when(processor).getVDIbyUuid(conn, "snap-uuid"); + // the cancelled VDI.copy still holds the snapshot on the first attempt + Mockito.doThrow(Mockito.mock(Types.OtherOperationInProgress.class)).doNothing().when(snapshot).destroy(conn); + when(snapshot.getCurrentOperations(conn)).thenReturn(Collections.singletonMap("OpaqueRef:copy", Types.VdiOperations.COPY)); + + Assert.assertTrue(processor.destroySnapshotOnPrimaryStorage(conn, "snap-uuid")); + Mockito.verify(snapshot, Mockito.times(2)).destroy(conn); + } + + @Test + public void testDestroySnapshotOnPrimaryReportsAMissingSnapshot() { + CitrixResourceBase resource = Mockito.mock(CitrixResourceBase.class); + XenServerStorageProcessor processor = Mockito.spy(new XenServerStorageProcessor(resource)); + Connection conn = Mockito.mock(Connection.class); + Mockito.doReturn(null).when(processor).getVDIbyUuid(conn, "gone"); + + Assert.assertFalse(processor.destroySnapshotOnPrimaryStorage(conn, "gone")); + } } diff --git a/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/XenServerTaskRegistryTest.java b/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/XenServerTaskRegistryTest.java new file mode 100644 index 000000000000..4682e8ee293f --- /dev/null +++ b/plugins/hypervisors/xenserver/src/test/java/com/cloud/hypervisor/xenserver/resource/XenServerTaskRegistryTest.java @@ -0,0 +1,247 @@ +// 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.xenserver.resource; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.Pair; +import com.xensource.xenapi.Connection; +import com.xensource.xenapi.Task; +import com.xensource.xenapi.Types; + +@RunWith(MockitoJUnitRunner.class) +public class XenServerTaskRegistryTest { + + private static final long SEQ = 42L; + + @Mock + private Connection connection; + + private final XenServerTaskRegistry registry = new XenServerTaskRegistry(); + + @After + public void tearDown() { + registry.endRequest(SEQ); + } + + @Test + public void unknownSequenceIsNeitherCancellableNorCancelled() { + Assert.assertNotNull(registry.checkTaskNotCancellable(999L)); + Assert.assertFalse(registry.cancel(999L)); + Assert.assertFalse(registry.wasCancelRequested(999L)); + } + + @Test + public void requestWithNoTaskInFlightIsRefused() { + registry.beginRequest(SEQ, "StopCommand"); + + final String reason = registry.checkTaskNotCancellable(SEQ); + Assert.assertTrue(reason, reason.contains("no XenAPI task is running for StopCommand")); + Assert.assertFalse(registry.cancel(SEQ)); + Assert.assertFalse(registry.wasCancelRequested(SEQ)); + } + + @Test + public void taskStartedWhileTheCancelRunsIsReportedButTheUndoAfterwardsIsNot() throws Exception { + final Task first = task("OpaqueRef:first", Types.TaskStatusType.PENDING, Types.TaskStatusType.CANCELLED); + final Task racingTask = task("OpaqueRef:racing", Types.TaskStatusType.PENDING); + final AtomicBoolean racing = new AtomicBoolean(); + Mockito.doAnswer(invocation -> { + racing.set(XenServerTaskRegistry.taskStarted(racingTask, connection)); + return null; + }).when(first).cancelAsync(connection); + registry.beginRequest(SEQ, "MigrateCommand"); + XenServerTaskRegistry.taskStarted(first, connection); + + Assert.assertTrue(registry.cancel(SEQ)); + + Assert.assertTrue(racing.get()); + Assert.assertFalse(XenServerTaskRegistry.taskStarted(task("OpaqueRef:undo", Types.TaskStatusType.PENDING), connection)); + Assert.assertTrue(registry.wasCancelRequested(SEQ)); + } + + @Test + public void pendingTaskIsCancelledAsynchronouslyAndConfirmed() throws Exception { + final Task task = task("OpaqueRef:1", Types.TaskStatusType.PENDING, Types.TaskStatusType.PENDING, + Types.TaskStatusType.CANCELLING, Types.TaskStatusType.CANCELLED); + + registry.beginRequest(SEQ, "MigrateCommand"); + Assert.assertFalse(XenServerTaskRegistry.taskStarted(task, connection)); + + Assert.assertNull(registry.checkTaskNotCancellable(SEQ)); + Assert.assertTrue(registry.cancel(SEQ)); + Mockito.verify(task).cancelAsync(connection); + Mockito.verify(task, Mockito.never()).cancel(Mockito.any()); + } + + @Test + public void taskNoLongerPendingRefusesAndIsLeftAlone() throws Exception { + final Task task = task("OpaqueRef:2", Types.TaskStatusType.SUCCESS); + + registry.beginRequest(SEQ, "MigrateCommand"); + XenServerTaskRegistry.taskStarted(task, connection); + + final String reason = registry.checkTaskNotCancellable(SEQ); + Assert.assertTrue(reason, reason.toLowerCase().contains("opaqueref:2 is already success")); + Assert.assertFalse(registry.cancel(SEQ)); + Mockito.verify(task, Mockito.never()).cancelAsync(Mockito.any()); + } + + @Test + public void cancelReportsFailureWhenTheTaskCompletesFirst() throws Exception { + final Task task = task("OpaqueRef:3", Types.TaskStatusType.PENDING, Types.TaskStatusType.PENDING, Types.TaskStatusType.SUCCESS); + + registry.beginRequest(SEQ, "MigrateCommand"); + XenServerTaskRegistry.taskStarted(task, connection); + + Assert.assertFalse(registry.cancel(SEQ)); + } + + @Test + public void cancelReportsFailureWhenXenServerDoesNotAllowIt() throws Exception { + final Task task = task("OpaqueRef:4", Types.TaskStatusType.PENDING); + Mockito.doThrow(new Types.OperationNotAllowed("not allowed")).when(task).cancelAsync(connection); + + final Pair result = XenServerTaskRegistry.cancelTask(task, connection); + + Assert.assertFalse(result.first()); + Assert.assertTrue(result.second(), result.second().contains("does not allow")); + } + + @Test + public void finishedTaskIsNoLongerTracked() throws Exception { + final Task task = task("OpaqueRef:5", Types.TaskStatusType.PENDING); + registry.beginRequest(SEQ, "MigrateCommand"); + XenServerTaskRegistry.taskStarted(task, connection); + Assert.assertEquals(1, registry.activeTaskCount(SEQ)); + + XenServerTaskRegistry.taskFinished(task); + + Assert.assertEquals(0, registry.activeTaskCount(SEQ)); + Assert.assertFalse(registry.cancel(SEQ)); + Mockito.verify(task, Mockito.never()).cancelAsync(Mockito.any()); + } + + @Test + public void endRequestForgetsTheSequence() throws Exception { + registry.beginRequest(SEQ, "MigrateCommand"); + registry.endRequest(SEQ); + + Assert.assertNotNull(registry.checkTaskNotCancellable(SEQ)); + Assert.assertFalse(XenServerTaskRegistry.taskStarted(task("OpaqueRef:6", Types.TaskStatusType.PENDING), connection)); + Assert.assertEquals(0, registry.activeTaskCount(SEQ)); + } + + @Test + public void unscopedThreadRegistersNothing() throws Exception { + registry.beginRequest(SEQ, "MigrateCommand"); + final Task task = task("OpaqueRef:7", Types.TaskStatusType.PENDING); + final AtomicBoolean registered = new AtomicBoolean(true); + + // scope is per thread + final Thread other = new Thread(() -> registered.set(XenServerTaskRegistry.taskStarted(task, connection))); + other.start(); + other.join(); + + Assert.assertFalse(registered.get()); + Assert.assertEquals(0, registry.activeTaskCount(SEQ)); + } + + @Test + public void taskWithoutAReferenceIsStillTracked() throws Exception { + final Task task = Mockito.mock(Task.class); + Mockito.when(task.getStatus(connection)).thenReturn(Types.TaskStatusType.PENDING); + + registry.beginRequest(SEQ, "MigrateCommand"); + XenServerTaskRegistry.taskStarted(task, connection); + + Assert.assertEquals(1, registry.activeTaskCount(SEQ)); + Assert.assertNull(registry.checkTaskNotCancellable(SEQ)); + } + + private Task task(final String ref, final Types.TaskStatusType first, final Types.TaskStatusType... rest) throws Exception { + final Task task = Mockito.mock(Task.class); + Mockito.when(task.toWireString()).thenReturn(ref); + Mockito.when(task.getStatus(connection)).thenReturn(first, rest); + return task; + } + + @Test + public void testPowerTaskIsRefusedWithoutAskingXenServer() throws Exception { + final Task shutdown = task("OpaqueRef:8", Types.TaskStatusType.PENDING); + registry.beginRequest(SEQ, "StopCommand"); + XenServerTaskRegistry.taskStarted(shutdown, connection, "Async.VM.clean_shutdown"); + + final String reason = registry.checkTaskNotCancellable(SEQ); + Assert.assertTrue(reason, reason.contains("Async.VM.clean_shutdown cannot be stopped")); + Assert.assertFalse(registry.cancel(SEQ)); + Assert.assertFalse(registry.wasCancelRequested(SEQ)); + Mockito.verify(shutdown, Mockito.never()).cancelAsync(Mockito.any()); + Mockito.verify(shutdown, Mockito.never()).getStatus(Mockito.any()); + } + + @Test + public void testCopyTaskIsStillCancellable() throws Exception { + final Task copy = task("OpaqueRef:9", Types.TaskStatusType.PENDING, Types.TaskStatusType.PENDING, Types.TaskStatusType.CANCELLED); + registry.beginRequest(SEQ, "CopyCommand"); + XenServerTaskRegistry.taskStarted(copy, connection, "Async.VDI.copy"); + + Assert.assertNull(registry.checkTaskNotCancellable(SEQ)); + Assert.assertTrue(registry.cancel(SEQ)); + Assert.assertTrue(registry.wasCancelRequested(SEQ)); + } + + @Test + public void testTaskDestroyedByItsWaiterAfterTheCancelCountsByItsFinalStatus() throws Exception { + final Task task = Mockito.mock(Task.class); + Mockito.when(task.toWireString()).thenReturn("OpaqueRef:10"); + registry.beginRequest(SEQ, "MigrateCommand"); + XenServerTaskRegistry.taskStarted(task, connection, "Async.VM.migrate_send"); + // first poll: pending; the waiting thread then sees CANCELLED, records it and destroys the task before the next poll + Mockito.when(task.getStatus(connection)).thenReturn(Types.TaskStatusType.PENDING).thenAnswer(invocation -> { + XenServerTaskRegistry.taskFinished(task, Types.TaskStatusType.CANCELLED); + throw new Types.HandleInvalid("task", "OpaqueRef:10"); + }); + + Assert.assertTrue(registry.cancel(SEQ)); + Mockito.verify(task).cancelAsync(connection); + Assert.assertTrue(registry.wasCancelRequested(SEQ)); + } + + @Test + public void testTaskThatCompletedBeforeTheCancelAndWasDestroyedIsNotACancellation() throws Exception { + final Task task = Mockito.mock(Task.class); + Mockito.when(task.toWireString()).thenReturn("OpaqueRef:11"); + registry.beginRequest(SEQ, "MigrateCommand"); + XenServerTaskRegistry.taskStarted(task, connection, "Async.VM.migrate_send"); + Mockito.when(task.getStatus(connection)).thenReturn(Types.TaskStatusType.PENDING).thenAnswer(invocation -> { + XenServerTaskRegistry.taskFinished(task, Types.TaskStatusType.SUCCESS); + throw new Types.HandleInvalid("task", "OpaqueRef:11"); + }); + + Assert.assertFalse(registry.cancel(SEQ)); + Assert.assertFalse(registry.wasCancelRequested(SEQ)); + } +} diff --git a/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java b/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java index e70a6b4da639..8eabd6b62457 100644 --- a/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java +++ b/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java @@ -31,6 +31,7 @@ import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.cloudstack.framework.jobs.AsyncJobDispatcher; import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; import org.apache.cloudstack.jobs.JobInfo; import com.cloud.exception.InvalidParameterValueException; @@ -117,10 +118,17 @@ public void runJob(final AsyncJob job) { CallContext.unregister(); } } catch (Throwable e) { + // a job cancelled mid-execution is already terminal; do not overwrite CANCELLED with the failure it caused + AsyncJobVO jobFromDb = _asyncJobMgr.getAsyncJob(job.getId()); + if (jobFromDb != null && jobFromDb.getStatus().done()) { + logger.debug("Not recording failure for job-{}, it is already in {}", job.getId(), jobFromDb.getStatus()); + return; + } + String errorMsg = null; int errorCode = ApiErrorCode.INTERNAL_ERROR.getHttpCode(); if (!(e instanceof ServerApiException)) { - logger.error("Unexpected exception while executing " + job.getCmd(), e); + logger.error("Unexpected exception while executing {}", job.getCmd(), e); errorMsg = e.getMessage(); } else { ServerApiException sApiEx = (ServerApiException)e; diff --git a/server/src/main/java/com/cloud/api/ApiDBUtils.java b/server/src/main/java/com/cloud/api/ApiDBUtils.java index bb9867fa622c..ad6a42d0a0c6 100644 --- a/server/src/main/java/com/cloud/api/ApiDBUtils.java +++ b/server/src/main/java/com/cloud/api/ApiDBUtils.java @@ -90,6 +90,7 @@ import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.jobs.JobInfo; import org.apache.cloudstack.framework.jobs.dao.AsyncJobDao; import org.apache.cloudstack.resourcedetail.SnapshotPolicyDetailVO; import org.apache.cloudstack.resourcedetail.dao.DiskOfferingDetailsDao; @@ -2188,7 +2189,11 @@ public static AccountJoinVO findAccountViewById(Long accountId) { } public static AsyncJobResponse newAsyncJobResponse(AsyncJobJoinVO ve) { - return s_jobJoinDao.newAsyncJobResponse(ve); + final AsyncJobResponse response = s_jobJoinDao.newAsyncJobResponse(ve); + if (!response.getCancellable() && ve.getStatus() == JobInfo.Status.IN_PROGRESS.value() && s_asyncMgr.hasOnlyQueuedWork(ve.getId())) { + response.setCancellable(true); + } + return response; } public static AsyncJobJoinVO newAsyncJobView(AsyncJob e) { diff --git a/server/src/main/java/com/cloud/api/ApiDispatcher.java b/server/src/main/java/com/cloud/api/ApiDispatcher.java index 90cbb6afc8e5..d7a99e8a2cc1 100644 --- a/server/src/main/java/com/cloud/api/ApiDispatcher.java +++ b/server/src/main/java/com/cloud/api/ApiDispatcher.java @@ -35,7 +35,6 @@ import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.cloudstack.framework.jobs.AsyncJobManager; -import org.apache.cloudstack.framework.jobs.impl.AsyncJobManagerImpl; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; @@ -66,9 +65,6 @@ public class ApiDispatcher { @Inject() protected DispatchChainFactory dispatchChainFactory; - @Inject - AsyncJobManagerImpl asyncJobManager; - protected DispatchChain standardDispatchChain; protected DispatchChain asyncCreationDispatchChain; @@ -91,7 +87,7 @@ public void setMigrateQueueSizeLimit(final Long migrateLimit) { } public void dispatchCreateCmd(final BaseAsyncCreateCmd cmd, final Map params) throws Exception { - if (asyncJobManager.isAsyncJobsEnabled()) { + if (_asyncMgr.isAsyncJobsEnabled()) { asyncCreationDispatchChain.dispatch(new DispatchTask(cmd, params)); } else { throw new CloudRuntimeException("Maintenance or Shutdown has been initiated on this management server. Can not accept new async creation jobs"); diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index 345f7e5f628e..7109ffb841d6 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -58,6 +58,7 @@ import org.apache.cloudstack.api.BaseResponseWithAssociatedNetwork; import org.apache.cloudstack.api.ResponseGenerator; import org.apache.cloudstack.api.ResponseObject.ResponseView; +import org.apache.cloudstack.api.command.user.job.CancelAsyncJobCmd; import org.apache.cloudstack.api.command.user.job.QueryAsyncJobResultCmd; import org.apache.cloudstack.api.response.ASNRangeResponse; import org.apache.cloudstack.api.response.ASNumberResponse; @@ -2396,6 +2397,12 @@ public AsyncJobResponse queryJobResult(final QueryAsyncJobResultCmd cmd) { return createAsyncJobResponse(_jobMgr.queryJob(jobId, true)); } + @Override + public AsyncJobResponse cancelJobResponse(CancelAsyncJobCmd cmd) { + AsyncJob job = _jobMgr.queryJob(cmd.getId(), true); + return createAsyncJobResponse(job); + } + public AsyncJobResponse createAsyncJobResponse(AsyncJob job) { AsyncJobJoinVO vJob = ApiDBUtils.newAsyncJobView(job); return ApiDBUtils.newAsyncJobResponse(vJob); diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index 083f4629740f..6a1a05e25604 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -911,6 +911,7 @@ public AsyncCmdResult processAsyncCmd(BaseAsyncCmd asyncCmd, Map logger.warn(errorMsg); throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, errorMsg); } + ActionEventUtils.linkEventsToJob(jobId, startEventId, eventId); return new AsyncCmdResult(objectId, objectUuid, asyncCmd, jobId); } diff --git a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java index 12ee7fbf35af..3b5fb106ee60 100644 --- a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java +++ b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java @@ -31,6 +31,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -925,6 +926,7 @@ private Pair, Integer> searchForEventIdsAndCount(ListEventsCmd cmd) { Integer entryTime = cmd.getEntryTime(); Integer duration = cmd.getDuration(); Long startId = cmd.getStartId(); + final Long jobId = cmd.getJobId(); final String resourceUuid = getResourceUuid(cmd.getResourceId()); final ApiCommandResourceType resourceType = getResourceType(cmd.getResourceType()); final String stateStr = cmd.getState(); @@ -982,6 +984,7 @@ private Pair, Integer> searchForEventIdsAndCount(ListEventsCmd cmd) { eventSearchBuilder.and("archived", eventSearchBuilder.entity().getArchived(), SearchCriteria.Op.EQ); eventSearchBuilder.and("resourceId", eventSearchBuilder.entity().getResourceId(), SearchCriteria.Op.EQ); eventSearchBuilder.and("resourceType", eventSearchBuilder.entity().getResourceType(), SearchCriteria.Op.EQ); + eventSearchBuilder.and("asyncJobId", eventSearchBuilder.entity().getAsyncJobId(), SearchCriteria.Op.EQ); if (keyword != null) { eventSearchBuilder.and().op("keywordType", eventSearchBuilder.entity().getType(), SearchCriteria.Op.LIKE); @@ -1010,6 +1013,10 @@ private Pair, Integer> searchForEventIdsAndCount(ListEventsCmd cmd) { } } + if (jobId != null) { + sc.setParameters("asyncJobId", jobId); + } + if (keyword != null) { sc.setParameters("keywordType", "%" + keyword + "%"); sc.setParameters("keywordDescription", "%" + keyword + "%"); @@ -3210,8 +3217,11 @@ public ListResponse searchForAsyncJobs(ListAsyncJobsCmd cmd) { } private Pair, Integer> searchForAsyncJobsInternal(ListAsyncJobsCmd cmd) { - Account caller = CallContext.current().getCallingAccount(); + final Map urlParams = cmd.getFullUrlParams(); + if (cmd.getId() == null && urlParams != null && urlParams.containsKey(ApiConstants.ID)) { + throw new InvalidParameterValueException("Unable to find a job with id " + urlParams.get(ApiConstants.ID)); + } List permittedAccounts = new ArrayList<>(); @@ -3221,8 +3231,18 @@ private Pair, Integer> searchForAsyncJobsInternal(ListAsync Boolean isRecursive = domainIdRecursiveListProject.second(); ListProjectResourcesCriteria listProjectResourcesCriteria = domainIdRecursiveListProject.third(); + // completed jobs are soft-deleted; anything but the pending-only default must include removed rows + final boolean filterByStatus = cmd.getJobStatuses() != null; + final boolean includeRemoved = filterByStatus || cmd.getEndDate() != null || cmd.getDuration() != null || cmd.getId() != null; + Filter searchFilter = new Filter(AsyncJobJoinVO.class, "id", true, cmd.getStartIndex(), cmd.getPageSizeVal()); SearchBuilder sb = _jobJoinDao.createSearchBuilder(); + + if (filterByStatus) { + sb.and("statuses", sb.entity().getStatus(), SearchCriteria.Op.IN); + } + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("cancelRequested", sb.entity().isCancelRequested(), SearchCriteria.Op.EQ); sb.and("instanceTypeNEQ", sb.entity().getInstanceType(), SearchCriteria.Op.NEQ); sb.and("accountIdIN", sb.entity().getAccountId(), SearchCriteria.Op.IN); boolean accountJoinIsDone = false; @@ -3233,7 +3253,6 @@ private Pair, Integer> searchForAsyncJobsInternal(ListAsync } if (listProjectResourcesCriteria != null) { - if (listProjectResourcesCriteria == Project.ListProjectResourcesCriteria.ListProjectResourcesOnly) { sb.and("type", sb.entity().getAccountType(), SearchCriteria.Op.EQ); } else if (listProjectResourcesCriteria == Project.ListProjectResourcesCriteria.SkipProjectResources) { @@ -3247,13 +3266,26 @@ private Pair, Integer> searchForAsyncJobsInternal(ListAsync } if (cmd.getManagementServerId() != null) { - sb.and("executingMsid", sb.entity().getExecutingMsid(), SearchCriteria.Op.EQ); + // a completed job keeps only the server that completed it + sb.and().op("executingMsid", sb.entity().getExecutingMsid(), SearchCriteria.Op.EQ); + sb.or("completeMsid", sb.entity().getCompleteMsid(), SearchCriteria.Op.EQ); + sb.cp(); } Object keyword = cmd.getKeyword(); Object startDate = cmd.getStartDate(); + Object endDate = cmd.getEndDate(); SearchCriteria sc = sb.create(); + if (filterByStatus) { + sc.setParameters("statuses", cmd.getJobStatuses().toArray()); + } + if (cmd.getCancelRequested() != null) { + sc.setParameters("cancelRequested", cmd.getCancelRequested()); + } + if (cmd.getId() != null) { + sc.setParameters("id", cmd.getId()); + } sc.setParameters("instanceTypeNEQ", AsyncJobVO.PSEUDO_JOB_INSTANCE_TYPE); if (listProjectResourcesCriteria != null) { sc.setParameters("type", Account.Type.PROJECT); @@ -3278,9 +3310,18 @@ private Pair, Integer> searchForAsyncJobsInternal(ListAsync sc.addAnd("created", SearchCriteria.Op.GTEQ, startDate); } - if (cmd.getManagementServerId() != null) { - ManagementServerHostVO msHost = msHostDao.findById(cmd.getManagementServerId()); - sc.setParameters("executingMsid", msHost.getMsid()); + if (endDate != null) { + sc.addAnd("created", SearchCriteria.Op.LTEQ, endDate); + } + + if (cmd.getDuration() != null) { + // rows are written in GMT + Date lastDate = new Date(DateUtil.currentGMTTime().getTime() - TimeUnit.HOURS.toMillis(cmd.getDuration())); + + SearchCriteria scc = _jobJoinDao.createSearchCriteria(); + scc.addOr("created", SearchCriteria.Op.GTEQ, lastDate); + scc.addOr("removed", SearchCriteria.Op.GTEQ, lastDate); + sc.addAnd("created", SearchCriteria.Op.SC, scc); } if (cmd.getResourceType() != null) { @@ -3295,7 +3336,16 @@ private Pair, Integer> searchForAsyncJobsInternal(ListAsync throw new InvalidParameterValueException(String.format("%s parameter must be used with %s parameter", ApiConstants.RESOURCE_ID, ApiConstants.RESOURCE_TYPE)); } - return _jobJoinDao.searchAndCount(sc, searchFilter); + if (cmd.getManagementServerId() != null) { + ManagementServerHostVO msHost = msHostDao.findById(cmd.getManagementServerId()); + if (msHost == null) { + throw new InvalidParameterValueException("Unable to find a management server with the specified id"); + } + sc.setParameters("executingMsid", msHost.getMsid()); + sc.setParameters("completeMsid", msHost.getMsid()); + } + + return _jobJoinDao.searchAndCount(sc, searchFilter, includeRemoved); } @Override diff --git a/server/src/main/java/com/cloud/api/query/dao/AsyncJobJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/AsyncJobJoinDaoImpl.java index 93af9a04e144..9a6054459955 100644 --- a/server/src/main/java/com/cloud/api/query/dao/AsyncJobJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/AsyncJobJoinDaoImpl.java @@ -19,11 +19,16 @@ import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import javax.inject.Inject; +import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ResponseObject; import org.apache.cloudstack.api.response.AsyncJobResponse; +import org.apache.cloudstack.api.response.ExceptionResponse; +import org.apache.cloudstack.jobs.JobInfo; import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; @@ -55,33 +60,70 @@ protected AsyncJobJoinDaoImpl() { _count = "select count(distinct id) from async_job_view WHERE "; } + private String managementServerName(final Long msid) { + if (msid == null) { + return null; + } + final ManagementServerHostVO managementServer = managementServerHostDao.findByMsid(msid); + return managementServer != null ? managementServer.getName() : null; + } + + private static final Map cancellableCommands = new ConcurrentHashMap<>(); + + /** The same annotation cancelAsyncJob checks; a command class that cannot be loaded is reported as not cancellable. */ + static boolean isCancellableCommand(final String cmd) { + if (cmd == null) { + return false; + } + return cancellableCommands.computeIfAbsent(cmd, name -> { + try { + final APICommand annotation = Class.forName(name).getAnnotation(APICommand.class); + return annotation != null && annotation.cancellable(); + } catch (final ClassNotFoundException e) { + return false; + } + }); + } + @Override public AsyncJobResponse newAsyncJobResponse(final AsyncJobJoinVO job) { final AsyncJobResponse jobResponse = new AsyncJobResponse(); jobResponse.setAccountId(job.getAccountUuid()); jobResponse.setAccount(job.getAccountName()); jobResponse.setDomainId(job.getDomainUuid()); + jobResponse.setDomain(job.getDomainName()); jobResponse.setDomainPath(ApiResponseHelper.getPrettyDomainPath(job.getDomainPath())); jobResponse.setUserId(job.getUserUuid()); jobResponse.setCmd(job.getCmd()); + jobResponse.setCancellable(isCancellableCommand(job.getCmd())); jobResponse.setCreated(job.getCreated()); jobResponse.setRemoved(job.getRemoved()); + jobResponse.setId(job.getUuid()); jobResponse.setJobId(job.getUuid()); jobResponse.setJobStatus(job.getStatus()); + // while the cancellation is carried out the job's result holds the request; it is reported as the reason, not as a result + final boolean cancelling = job.isCancelRequested() && job.getStatus() == JobInfo.Status.IN_PROGRESS.value(); + if (job.isCancelRequested()) { + jobResponse.setCancelRequested(true); + } jobResponse.setJobProcStatus(job.getProcessStatus()); - if (job.getExecutingMsid() != null) { - ManagementServerHostVO managementServer = managementServerHostDao.findByMsid(job.getExecutingMsid()); + // a finished job no longer has an executing server; the one that completed it is the answer then + final Long msid = job.getExecutingMsid() != null ? job.getExecutingMsid() : job.getCompleteMsid(); + if (msid != null) { + ManagementServerHostVO managementServer = managementServerHostDao.findByMsid(msid); if (managementServer != null) { jobResponse.setManagementServerId(managementServer.getUuid()); jobResponse.setManagementServerName(managementServer.getName()); } } + jobResponse.setInitManagementServerName(managementServerName(job.getInitMsid())); + jobResponse.setExecutingManagementServerName(managementServerName(job.getExecutingMsid())); + jobResponse.setCompleteManagementServerName(managementServerName(job.getCompleteMsid())); if (job.getInstanceType() != null && job.getInstanceId() != null) { jobResponse.setJobInstanceType(job.getInstanceType().toString()); - jobResponse.setJobInstanceId(job.getInstanceUuid()); - + jobResponse.setJobInstanceName(job.getInstanceName()); } jobResponse.setJobResultCode(job.getResultCode()); @@ -89,10 +131,16 @@ public AsyncJobResponse newAsyncJobResponse(final AsyncJobJoinVO job) { SerializationContext.current().setUuidTranslation(false); final Object resultObject = ApiSerializerHelper.fromSerializedString(job.getResult()); - jobResponse.setJobResult((ResponseObject)resultObject); + if (cancelling) { + if (resultObject instanceof ExceptionResponse) { + jobResponse.setCancelReason(((ExceptionResponse)resultObject).getErrorText()); + } + } else { + jobResponse.setJobResult((ResponseObject)resultObject); + } SerializationContext.current().setUuidTranslation(savedValue); - if (resultObject != null) { + if (resultObject != null && !cancelling) { final Class clz = resultObject.getClass(); if (clz.isPrimitive() || clz.getSuperclass() == Number.class || clz == String.class || clz == Date.class) { jobResponse.setJobResultType("text"); diff --git a/server/src/main/java/com/cloud/api/query/vo/AsyncJobJoinVO.java b/server/src/main/java/com/cloud/api/query/vo/AsyncJobJoinVO.java index b4714c75def4..ebe2450e7161 100644 --- a/server/src/main/java/com/cloud/api/query/vo/AsyncJobJoinVO.java +++ b/server/src/main/java/com/cloud/api/query/vo/AsyncJobJoinVO.java @@ -76,15 +76,24 @@ public class AsyncJobJoinVO extends BaseViewVO implements ControlledViewEntity { @Column(name = "job_cmd") private String cmd; + @Column(name = "job_init_msid") + private Long initMsid; + @Column(name = "job_executing_msid") private Long executingMsid; + @Column(name = "job_complete_msid") + private Long completeMsid; + @Column(name = "job_status") private int status; @Column(name = "job_process_status") private int processStatus; + @Column(name = "cancel_requested") + private boolean cancelRequested; + @Column(name = "job_result_code") private int resultCode; @@ -107,6 +116,12 @@ public class AsyncJobJoinVO extends BaseViewVO implements ControlledViewEntity { @Column(name = "instance_uuid") private String instanceUuid; + @Column(name = "instance_name") + private String instanceName; + + @Column(name = "related") + private String related; + public AsyncJobJoinVO() { } @@ -172,6 +187,10 @@ public String getCmd() { return cmd; } + public boolean isCancelRequested() { + return cancelRequested; + } + public int getStatus() { return status; } @@ -208,6 +227,14 @@ public String getInstanceUuid() { return instanceUuid; } + public String getInstanceName() { + return instanceName; + } + + public String getRelated() { + return related; + } + @Override public Class getEntityType() { return AsyncJob.class; @@ -218,10 +245,18 @@ public String getName() { return null; } + public Long getInitMsid() { + return initMsid; + } + public Long getExecutingMsid() { return executingMsid; } + public Long getCompleteMsid() { + return completeMsid; + } + @Override public String getProjectUuid() { // TODO Auto-generated method stub diff --git a/server/src/main/java/com/cloud/api/query/vo/EventJoinVO.java b/server/src/main/java/com/cloud/api/query/vo/EventJoinVO.java index bf731e0dbd6a..450616a61d22 100644 --- a/server/src/main/java/com/cloud/api/query/vo/EventJoinVO.java +++ b/server/src/main/java/com/cloud/api/query/vo/EventJoinVO.java @@ -69,6 +69,12 @@ public class EventJoinVO extends BaseViewVO implements ControlledViewEntity { @Column(name = "start_uuid") private String startUuid; + @Column(name = "async_job_id") + private Long asyncJobId; + + @Column(name = "async_job_uuid") + private String asyncJobUuid; + @Column(name = "parameters", length = 1024) private String parameters; @@ -229,6 +235,14 @@ public String getStartUuid() { return startUuid; } + public Long getAsyncJobId() { + return asyncJobId; + } + + public String getAsyncJobUuid() { + return asyncJobUuid; + } + public String getParameters() { return parameters; } diff --git a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java index 17dbced54233..a77968f1ec5d 100644 --- a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java +++ b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java @@ -123,6 +123,7 @@ import org.apache.cloudstack.framework.messagebus.MessageBus; import org.apache.cloudstack.framework.messagebus.MessageSubscriber; import org.apache.cloudstack.framework.messagebus.PublishScope; +import org.apache.cloudstack.jobs.AsyncJobService; import org.apache.cloudstack.network.RoutedIpv4Manager; import org.apache.cloudstack.query.QueryService; import org.apache.cloudstack.region.PortableIp; @@ -633,6 +634,7 @@ protected void populateConfigValuesForValidationSet() { configValuesForValidation.add(VMLeaseManager.InstanceLeaseExpiryEventSchedulerInterval.key()); configValuesForValidation.add(VMLeaseManager.InstanceLeaseExpiryEventDaysBefore.key()); configValuesForValidation.add(AutoScaleManager.AutoScaleErroredInstanceThreshold.key()); + configValuesForValidation.add(AsyncJobService.CancelledJobInterval.key()); } protected void weightBasedParametersForValidation() { diff --git a/server/src/main/java/com/cloud/event/ActionEventInterceptor.java b/server/src/main/java/com/cloud/event/ActionEventInterceptor.java index 9554ae34cb61..429dd5c80c28 100644 --- a/server/src/main/java/com/cloud/event/ActionEventInterceptor.java +++ b/server/src/main/java/com/cloud/event/ActionEventInterceptor.java @@ -129,15 +129,21 @@ public void interceptException(Method method, Object target, Object event) { if (eventType.equals("")) return; + final String cancelReason = ActionEventUtils.cancellationReasonOfCurrentJob(); + // a refusal (a cancel the work cannot honour) is an answer, not a fault + final Object refusal = ctx.getContextParameter(CallContext.REFUSAL_REASON_PARAM); + final String level = cancelReason != null || refusal != null ? EventVO.LEVEL_WARN : EventVO.LEVEL_ERROR; if (actionEvent.create()) { - long eventId = ActionEventUtils.onCreatedActionEvent(userId, accountId, EventVO.LEVEL_ERROR, eventType, - isEventDisplayEnabled, "Error while creating entity for " + eventDescription, - eventResourceId, eventResourceType); + final String description = cancelReason != null ? "Cancelled while creating entity for " + eventDescription + ": " + cancelReason + : refusal != null ? "Refused creating entity for " + eventDescription + ": " + refusal : "Error while creating entity for " + eventDescription; + long eventId = ActionEventUtils.onCreatedActionEvent(userId, accountId, level, eventType, + isEventDisplayEnabled, description, eventResourceId, eventResourceType); ctx.setStartEventId(eventId); } else { - ActionEventUtils.onCompletedActionEvent(userId, accountId, EventVO.LEVEL_ERROR, eventType, isEventDisplayEnabled, - "Error while " + eventDescription, - eventResourceId, eventResourceType, startEventId); + final String description = cancelReason != null ? getEventDescription(actionEvent, ctx, true) + " cancelled: " + cancelReason + : refusal != null ? getEventDescription(actionEvent, ctx, true) + " refused: " + refusal : "Error while " + eventDescription; + ActionEventUtils.onCompletedActionEvent(userId, accountId, level, eventType, isEventDisplayEnabled, + description, eventResourceId, eventResourceType, startEventId); } } } diff --git a/server/src/main/java/com/cloud/event/ActionEventUtils.java b/server/src/main/java/com/cloud/event/ActionEventUtils.java index 0ebb266fd7c2..611d7eb34872 100644 --- a/server/src/main/java/com/cloud/event/ActionEventUtils.java +++ b/server/src/main/java/com/cloud/event/ActionEventUtils.java @@ -32,6 +32,10 @@ import org.apache.cloudstack.api.Identity; import org.apache.cloudstack.api.InternalIdentity; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.jobs.AsyncJob; +import org.apache.cloudstack.framework.jobs.AsyncJobExecutionContext; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.events.EventDistributor; import org.apache.commons.lang3.ObjectUtils; @@ -60,6 +64,7 @@ public class ActionEventUtils { protected static Logger LOGGER = LogManager.getLogger(ActionEventUtils.class); private static EventDao s_eventDao; + private static AsyncJobManager s_jobMgr; private static AccountDao s_accountDao; private static ProjectDao s_projectDao; protected static UserDao s_userDao; @@ -76,6 +81,8 @@ public class ActionEventUtils { @Inject EventDao eventDao; @Inject + AsyncJobManager jobMgr; + @Inject AccountDao accountDao; @Inject UserDao userDao; @@ -92,6 +99,7 @@ public ActionEventUtils() { @PostConstruct void init() { s_eventDao = eventDao; + s_jobMgr = jobMgr; s_accountDao = accountDao; s_userDao = userDao; s_projectDao = projectDao; @@ -108,6 +116,19 @@ public static Long onActionEvent(Long userId, Long accountId, Long domainId, Str return event.getId(); } + public static void linkEventsToJob(final long jobId, final Long... eventIds) { + for (final Long eventId : eventIds) { + if (eventId == null || eventId == 0) { + continue; + } + final EventVO event = s_eventDao.findById(eventId); + if (event != null && event.getAsyncJobId() == null) { + event.setAsyncJobId(jobId); + s_eventDao.update(eventId, event); + } + } + } + /* * Save event after scheduling an async job */ @@ -183,6 +204,11 @@ private static Event persistActionEvent(Long userId, Long accountId, Long domain event.setState(state); event.setDescription(description); event.setDisplay(eventDisplayEnabled); + Long jobId = currentJobId(); + if (jobId == null && ApiCommandResourceType.Job.toString().equals(resourceType)) { + jobId = resourceId; + } + event.setAsyncJobId(jobId); if (domainId != null) { event.setDomainId(domainId); @@ -205,6 +231,26 @@ private static Event persistActionEvent(Long userId, Long accountId, Long domain return event; } + /** Why the job running on this thread is being or has been cancelled; null when it is not. */ + public static String cancellationReasonOfCurrentJob() { + final Long jobId = currentJobId(); + return jobId != null && s_jobMgr != null ? s_jobMgr.getCancellationReason(jobId) : null; + } + + /** The API job this thread works for (a VM work job's parent), or null outside a job. */ + private static Long currentJobId() { + final AsyncJobExecutionContext context = AsyncJobExecutionContext.getCurrent(); + if (context == null || context.getJob() == null) { + return null; + } + final AsyncJob job = context.getJob(); + if (AsyncJobVO.JOB_DISPATCHER_PSEUDO.equals(job.getDispatcher())) { + return null; + } + final String related = job.getRelated(); + return StringUtils.isNotEmpty(related) ? Long.valueOf(related) : job.getId(); + } + private static void publishOnEventBus(Event eventRecord, long userId, long accountId, Long domainId, String eventCategory, String eventType, Event.State state, String description, String resourceUuid, String resourceType) { diff --git a/server/src/main/java/com/cloud/event/dao/EventJoinDaoImpl.java b/server/src/main/java/com/cloud/event/dao/EventJoinDaoImpl.java index b4316051e439..00ec0b9faf15 100644 --- a/server/src/main/java/com/cloud/event/dao/EventJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/event/dao/EventJoinDaoImpl.java @@ -106,6 +106,7 @@ public EventResponse newEventResponse(EventJoinVO event) { responseEvent.setId(event.getUuid()); responseEvent.setLevel(event.getLevel()); responseEvent.setParentId(event.getStartUuid()); + responseEvent.setJobId(event.getAsyncJobUuid()); responseEvent.setState(event.getState()); responseEvent.setUsername(event.getUserName()); if (event.getArchived()) { diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index e48cf112806a..d60c5bb86e56 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -275,6 +275,7 @@ import org.apache.cloudstack.api.command.admin.usage.ListTrafficMonitorsCmd; import org.apache.cloudstack.api.command.admin.usage.ListTrafficTypeImplementorsCmd; import org.apache.cloudstack.api.command.admin.usage.ListTrafficTypesCmd; +import org.apache.cloudstack.api.command.admin.usage.ListUsageJobsCmd; import org.apache.cloudstack.api.command.admin.usage.ListUsageRecordsCmd; import org.apache.cloudstack.api.command.admin.usage.ListUsageTypesCmd; import org.apache.cloudstack.api.command.admin.usage.RemoveRawUsageRecordsCmd; @@ -3974,6 +3975,7 @@ public List> getCommands() { cmdList.add(DeleteTrafficTypeCmd.class); cmdList.add(GenerateUsageRecordsCmd.class); cmdList.add(ListUsageRecordsCmd.class); + cmdList.add(ListUsageJobsCmd.class); cmdList.add(RemoveRawUsageRecordsCmd.class); cmdList.add(ListTrafficMonitorsCmd.class); cmdList.add(ListTrafficTypeImplementorsCmd.class); diff --git a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java index 11b24a9beef6..ca78b929a2e0 100644 --- a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java +++ b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java @@ -1194,6 +1194,10 @@ public VolumeVO createVolume(long volumeId, Long vmId, Long snapshotId, Long sto if (e instanceof InvalidParameterValueException) { throw (InvalidParameterValueException) e; } + if (isCurrentJobCancelled()) { + // a volume created on a pool had its VOLUME.CREATE published at allocation; one from a snapshot only on success + expungeCancelledVolume(volumeId, snapshotId == null && volume.isDisplayVolume()); + } throw new CloudRuntimeException(String.format("Failed to create volume: %s", volume), e); } finally { if (!created) { @@ -1205,6 +1209,37 @@ public VolumeVO createVolume(long volumeId, Long vmId, Long snapshotId, Long sto } } + protected boolean isCurrentJobCancelled() { + final AsyncJobExecutionContext context = AsyncJobExecutionContext.getCurrentExecutionContext(); + return context != null && context.getJob() != null && _jobMgr.getCancellationReason(context.getJob().getId()) != null; + } + + protected void removeSnapshotIfCancelledBeforeItRan(final long snapshotId) { + if (isCurrentJobCancelled()) { + logger.info("Snapshot {} was cancelled before its work started, removing it", snapshotId); + _snapshotMgr.removeAllocatedSnapshot(snapshotId); + } + } + + protected void expungeCancelledVolume(long volumeId, boolean usageRecorded) { + final VolumeInfo volume = volFactory.getVolume(volumeId); + if (volume == null) { + return; + } + logger.info("Creation of volume {} was cancelled, expunging it", volume); + if (usageRecorded && volume.getState() == Volume.State.Destroy) { + volume.stateTransit(Volume.Event.OperationSucceeded); + } + try { + final VolumeApiResult result = volService.expungeVolumeAsync(volume).get(); + if (result.isFailed()) { + logger.warn("Could not expunge volume {} whose creation was cancelled: {}", volume, result.getResult()); + } + } catch (InterruptedException | ExecutionException e) { + logger.warn("Could not expunge volume {} whose creation was cancelled", volume, e); + } + } + protected VolumeVO createVolumeFromSnapshot(VolumeVO volume, long snapshotId, Long vmId) throws StorageUnavailableException { VolumeInfo createdVolume = null; SnapshotVO snapshot = _snapshotDao.findById(snapshotId); @@ -4315,6 +4350,7 @@ private Snapshot takeSnapshotInternal(Long volumeId, Long policyId, Long snapsho Object jobResult = _jobMgr.unmarshallResultObject(outcome.getJob()); if (jobResult != null) { + removeSnapshotIfCancelledBeforeItRan(snapshotId); if (jobResult instanceof ConcurrentOperationException) { throw (ConcurrentOperationException)jobResult; } else if (jobResult instanceof ResourceAllocationException) { diff --git a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManager.java b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManager.java index 6fcf124a961d..489de8d19ebb 100644 --- a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManager.java +++ b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManager.java @@ -106,4 +106,6 @@ public interface SnapshotManager extends Configurable { void copySnapshotPoliciesBetweenVolumes(VolumeVO srcVolume, VolumeVO destVolume); void endSnapshotChainForVolume(long volumeId, Hypervisor.HypervisorType hypervisorType); + + void removeAllocatedSnapshot(long snapshotId); } diff --git a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java index 6ce7a0f92bfb..296bf1a6e0ef 100755 --- a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java +++ b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java @@ -805,7 +805,8 @@ protected void endCurrentChainIfNeeded(Long snapshotId, Long zoneId) { *

* This method should only be called when kvm.incremental.snapshot = false and for volumes in KVM * */ - protected void endLastChainIfNeeded(long clusterId, long volumeId) { + protected void endLastChainIfNeeded(Long clusterId, long volumeId) { + // null for a detached volume: the setting's global value applies if (kvmIncrementalSnapshot.valueIn(clusterId)) { return; } @@ -2051,6 +2052,19 @@ public boolean backedUpSnapshotsExistsForVolume(Volume volume) { return false; } + @Override + public void removeAllocatedSnapshot(final long snapshotId) { + final SnapshotVO snapshot = _snapshotDao.findById(snapshotId); + if (snapshot == null || snapshot.getState() != Snapshot.State.Allocated) { + return; + } + final ResourceType storeResourceType = !isBackupSnapshotToSecondaryForZone(snapshot.getDataCenterId()) || Snapshot.LocationType.PRIMARY.equals(snapshot.getLocationType()) + ? ResourceType.primary_storage : ResourceType.secondary_storage; + _resourceLimitMgr.decrementResourceCount(snapshot.getAccountId(), ResourceType.snapshot); + _resourceLimitMgr.decrementResourceCount(snapshot.getAccountId(), storeResourceType, snapshot.getSize()); + _snapshotDao.remove(snapshotId); + } + @Override public void cleanupSnapshotsByVolume(Long volumeId) { List infos = snapshotFactory.getSnapshotsForVolumeAndStoreRole(volumeId, DataStoreRole.Primary); diff --git a/server/src/main/java/com/cloud/usage/UsageServiceImpl.java b/server/src/main/java/com/cloud/usage/UsageServiceImpl.java index de8d4633d224..014c31b367aa 100644 --- a/server/src/main/java/com/cloud/usage/UsageServiceImpl.java +++ b/server/src/main/java/com/cloud/usage/UsageServiceImpl.java @@ -21,14 +21,18 @@ import java.util.List; import java.util.Map; import java.util.TimeZone; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; import javax.naming.ConfigurationException; import com.cloud.configuration.ConfigurationManagerImpl; import org.apache.cloudstack.api.command.admin.usage.GenerateUsageRecordsCmd; +import org.apache.cloudstack.api.command.admin.usage.ListUsageJobsCmd; import org.apache.cloudstack.api.command.admin.usage.ListUsageRecordsCmd; import org.apache.cloudstack.api.command.admin.usage.RemoveRawUsageRecordsCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.UsageJobResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.usage.Usage; @@ -500,4 +504,72 @@ public boolean removeRawUsageRecords(RemoveRawUsageRecordsCmd cmd) throws Invali _usageDao.expungeAllOlderThan(interval, ConfigurationManagerImpl.DELETE_QUERY_BATCH_SIZE.value()); return true; } + + @Override + public ListResponse getUsageJobs(ListUsageJobsCmd cmd) { + Filter usageJobFilter = new Filter(UsageJobVO.class, "id", true, cmd.getStartIndex(), cmd.getPageSizeVal()); + SearchCriteria sc = _usageJobDao.createSearchCriteria(); + + if (StringUtils.isNotBlank(cmd.getUsageServer())) { + sc.addAnd("host", SearchCriteria.Op.EQ, cmd.getUsageServer().trim()); + } + + Object startDate = cmd.getStartDate(); + if (startDate != null) { + sc.addAnd("startDate", SearchCriteria.Op.GTEQ, startDate); + } + + Object endDate = cmd.getEndDate(); + if (endDate != null) { + sc.addAnd("startDate", SearchCriteria.Op.LTEQ, endDate); + } + + if (cmd.getDuration() != null) { + // usage job timestamps are GMT + Date lastDate = new Date(DateUtil.currentGMTTime().getTime() - TimeUnit.HOURS.toMillis(cmd.getDuration())); + + SearchCriteria scc = _usageJobDao.createSearchCriteria(); + scc.addOr("startDate", SearchCriteria.Op.GTEQ, lastDate); + scc.addOr("endDate", SearchCriteria.Op.GTEQ, lastDate); + sc.addAnd("startDate", SearchCriteria.Op.SC, scc); + } + + Pair, Integer> usageJobs = null; + TransactionLegacy txn = TransactionLegacy.open(TransactionLegacy.USAGE_DB); + try { + usageJobs = _usageJobDao.searchAndCount(sc, usageJobFilter); + } finally { + txn.close(); + + // switch back to VMOPS_DB + TransactionLegacy swap = TransactionLegacy.open(TransactionLegacy.CLOUD_DB); + swap.close(); + } + + ListResponse response = new ListResponse<>(); + if (usageJobs != null) { + List responses = new ArrayList<>(); + for (UsageJobVO job : usageJobs.first()) { + UsageJobResponse jobResponse = createUsageJobResponse(job); + responses.add(jobResponse); + } + response.setResponses(responses, usageJobs.second()); + } + + return response; + } + + private UsageJobResponse createUsageJobResponse(UsageJobVO job) { + UsageJobResponse jobResponse = new UsageJobResponse(); + jobResponse.setUsageServer(job.getHost()); + jobResponse.setJobType(job.getJobType()); + jobResponse.setScheduled(job.getScheduled()); + jobResponse.setStartDate(job.getStartDate()); + jobResponse.setEndDate(job.getEndDate()); + jobResponse.setExecutionTime(job.getExecTime()); + jobResponse.setSuccess(job.getSuccess()); + jobResponse.setHeartbeat(job.getHeartbeat()); + jobResponse.setObjectName("usagejobs"); + return jobResponse; + } } diff --git a/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java b/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java index e232b980b333..c3762bf4b972 100644 --- a/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java +++ b/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java @@ -581,6 +581,7 @@ public VMSnapshot createVMSnapshot(Long vmId, Long vmSnapshotId, Boolean quiesce Object jobResult = _jobMgr.unmarshallResultObject(outcome.getJob()); if (jobResult != null) { + removeVMSnapshotIfCancelledBeforeItRan(vmSnapshotId); if (jobResult instanceof ConcurrentOperationException) throw (ConcurrentOperationException)jobResult; else if (jobResult instanceof CloudRuntimeException) @@ -595,6 +596,14 @@ else if (jobResult instanceof Throwable) } } + protected void removeVMSnapshotIfCancelledBeforeItRan(final long vmSnapshotId) { + final VMSnapshotVO vmSnapshot = _vmSnapshotDao.findById(vmSnapshotId); + if (vmSnapshot != null && vmSnapshot.getState() == VMSnapshot.State.Allocated && isCurrentJobCancelled()) { + logger.info("Instance Snapshot {} was cancelled before its work started, removing it", vmSnapshot); + removeCancelledVMSnapshot(vmSnapshot); + } + } + private VMSnapshot orchestrateCreateVMSnapshot(Long vmId, Long vmSnapshotId, Boolean quiescevm) { UserVmVO userVm = _userVMDao.findById(vmId); if (userVm == null) { @@ -624,11 +633,26 @@ private VMSnapshot orchestrateCreateVMSnapshot(Long vmId, Long vmSnapshotId, Boo return snapshot; } catch (Exception e) { String errMsg = String.format("Failed to create Instance Snapshot: [%s] due to: %s", vmSnapshot, e.getMessage()); - logger.debug(errMsg, e); + if (isCurrentJobCancelled()) { + logger.info("Creation of Instance Snapshot {} was cancelled, removing it: {}", vmSnapshot, e.getMessage()); + removeCancelledVMSnapshot(vmSnapshot); + } else { + logger.debug(errMsg, e); + } throw new CloudRuntimeException(errMsg, e); } } + protected boolean isCurrentJobCancelled() { + final AsyncJobExecutionContext context = AsyncJobExecutionContext.getCurrentExecutionContext(); + return context != null && context.getJob() != null && _jobMgr.getCancellationReason(context.getJob().getId()) != null; + } + + protected void removeCancelledVMSnapshot(VMSnapshotVO vmSnapshot) { + annotationDao.removeByEntityType(AnnotationService.EntityType.VM_SNAPSHOT.name(), vmSnapshot.getUuid()); + _vmSnapshotDao.remove(vmSnapshot.getId()); + } + public VMSnapshotManagerImpl() { } diff --git a/server/src/test/java/com/cloud/api/query/dao/AsyncJobJoinDaoImplTest.java b/server/src/test/java/com/cloud/api/query/dao/AsyncJobJoinDaoImplTest.java new file mode 100644 index 000000000000..d4eef8c12d2e --- /dev/null +++ b/server/src/test/java/com/cloud/api/query/dao/AsyncJobJoinDaoImplTest.java @@ -0,0 +1,43 @@ +// 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.api.query.dao; + +import org.apache.cloudstack.api.command.user.vm.DeployVMCmd; +import org.apache.cloudstack.api.command.user.vm.StartVMCmd; +import org.apache.cloudstack.api.command.user.volume.CreateVolumeCmd; +import org.junit.Assert; +import org.junit.Test; + +public class AsyncJobJoinDaoImplTest { + + @Test + public void commandsThatSomeHypervisorCanStopAreCancellable() { + Assert.assertTrue(AsyncJobJoinDaoImpl.isCancellableCommand(CreateVolumeCmd.class.getName())); + Assert.assertTrue(AsyncJobJoinDaoImpl.isCancellableCommand(StartVMCmd.class.getName())); + } + + @Test + public void commandsNotAnnotatedAreNot() { + Assert.assertFalse(AsyncJobJoinDaoImpl.isCancellableCommand(DeployVMCmd.class.getName())); + } + + @Test + public void unknownOrMissingCommandsAreNotCancellable() { + Assert.assertFalse(AsyncJobJoinDaoImpl.isCancellableCommand("org.apache.cloudstack.api.command.NoSuchCmd")); + Assert.assertFalse(AsyncJobJoinDaoImpl.isCancellableCommand(null)); + } +} diff --git a/server/src/test/java/com/cloud/event/ActionEventInterceptorTest.java b/server/src/test/java/com/cloud/event/ActionEventInterceptorTest.java index f655c0d74c0b..5ab44d7c8ab9 100644 --- a/server/src/test/java/com/cloud/event/ActionEventInterceptorTest.java +++ b/server/src/test/java/com/cloud/event/ActionEventInterceptorTest.java @@ -310,4 +310,18 @@ public Boolean noEventMethod() { return false; } } + + @Test + public void testInterceptExceptionOfARefusalIsAWarningWithTheReason() throws NoSuchMethodException { + CallContext.current().putContextParameter(CallContext.REFUSAL_REASON_PARAM, "Cannot cancel job-7, its work cannot be stopped at this point"); + TestActionEventManagerImpl tester = new TestActionEventManagerImpl(); + Method m = tester.getClass().getMethod("testMethod"); + actionEventInterceptor.interceptException(m, tester, null); + + Assert.assertEquals(1, persistedEvents.size()); + EventVO eventVO = persistedEvents.get(0); + Assert.assertEquals(EventVO.LEVEL_WARN, eventVO.getLevel()); + Assert.assertTrue(eventVO.getDescription(), eventVO.getDescription().endsWith("refused: Cannot cancel job-7, its work cannot be stopped at this point")); + Assert.assertEquals(com.cloud.event.Event.State.Completed, eventVO.getState()); + } } diff --git a/server/src/test/java/com/cloud/storage/VolumeApiServiceImplTest.java b/server/src/test/java/com/cloud/storage/VolumeApiServiceImplTest.java index 401b12ebbf46..81dcd6877d13 100644 --- a/server/src/test/java/com/cloud/storage/VolumeApiServiceImplTest.java +++ b/server/src/test/java/com/cloud/storage/VolumeApiServiceImplTest.java @@ -1183,6 +1183,73 @@ public void cleanVolumesCacheTest() { Mockito.verify(volumeInfoMock2).delete(); } + @Test + public void createVolumeExpungesTheVolumeWhenItsJobWasCancelled() throws Exception { + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + Mockito.doReturn(volumeVoMock).when(volumeDaoMock).findById(volumeMockId); + Mockito.doReturn(volumeInfo).when(volumeDataFactoryMock).getVolume(volumeMockId); + Mockito.doThrow(new CloudRuntimeException("CopyCommand to host 3 not sent: job-7 was cancelled")).when(volumeApiServiceImpl).createVolumeFromSnapshot(volumeVoMock, 9L, null); + Mockito.doReturn(true).when(volumeApiServiceImpl).isCurrentJobCancelled(); + Mockito.doNothing().when(volumeApiServiceImpl).expungeCancelledVolume(volumeMockId, false); + + try { + volumeApiServiceImpl.createVolume(volumeMockId, null, 9L, null, true); + Assert.fail("The failed creation was not reported"); + } catch (CloudRuntimeException expected) { + } + + Mockito.verify(volumeInfo).stateTransit(Volume.Event.DestroyRequested); + // from a snapshot: VOLUME.CREATE is published only on success, so there is no usage record to close + Mockito.verify(volumeApiServiceImpl).expungeCancelledVolume(volumeMockId, false); + } + + @Test + public void expungeCancelledVolumeClosesTheUsageRecordBeforeRemovingTheVolume() throws Exception { + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + Mockito.doReturn(Volume.State.Destroy).when(volumeInfo).getState(); + Mockito.doReturn(volumeInfo).when(volumeDataFactoryMock).getVolume(volumeMockId); + AsyncCallFuture future = new AsyncCallFuture<>(); + future.complete(new VolumeApiResult(volumeInfo)); + Mockito.doReturn(future).when(volumeServiceMock).expungeVolumeAsync(volumeInfo); + + volumeApiServiceImpl.expungeCancelledVolume(volumeMockId, true); + + Mockito.verify(volumeInfo).stateTransit(Volume.Event.OperationSucceeded); + Mockito.verify(volumeServiceMock).expungeVolumeAsync(volumeInfo); + } + + @Test + public void testExpungeCancelledVolumeWithoutAUsageRecordPublishesNoDelete() throws Exception { + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + Mockito.doReturn(volumeInfo).when(volumeDataFactoryMock).getVolume(volumeMockId); + AsyncCallFuture future = new AsyncCallFuture<>(); + future.complete(new VolumeApiResult(volumeInfo)); + Mockito.doReturn(future).when(volumeServiceMock).expungeVolumeAsync(volumeInfo); + + volumeApiServiceImpl.expungeCancelledVolume(volumeMockId, false); + + Mockito.verify(volumeInfo, Mockito.never()).stateTransit(Mockito.any()); + Mockito.verify(volumeServiceMock).expungeVolumeAsync(volumeInfo); + } + + @Test + public void createVolumeLeavesAFailedVolumeToTheStorageCleanupWhenNotCancelled() throws Exception { + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + Mockito.doReturn(volumeVoMock).when(volumeDaoMock).findById(volumeMockId); + Mockito.doReturn(volumeInfo).when(volumeDataFactoryMock).getVolume(volumeMockId); + Mockito.doThrow(new CloudRuntimeException("no space")).when(volumeApiServiceImpl).createVolumeFromSnapshot(volumeVoMock, 9L, null); + Mockito.doReturn(false).when(volumeApiServiceImpl).isCurrentJobCancelled(); + + try { + volumeApiServiceImpl.createVolume(volumeMockId, null, 9L, null, true); + Assert.fail("The failed creation was not reported"); + } catch (CloudRuntimeException expected) { + } + + Mockito.verify(volumeInfo).stateTransit(Volume.Event.DestroyRequested); + Mockito.verify(volumeApiServiceImpl, Mockito.never()).expungeCancelledVolume(Mockito.anyLong(), Mockito.anyBoolean()); + } + @Test public void deleteVolumeTestVolumeStateAllocated() throws InterruptedException, ExecutionException, NoTransitionException { Mockito.doReturn(Volume.State.Allocated).when(volumeVoMock).getState(); @@ -3080,4 +3147,15 @@ public void getReservedDeviceIdsWithConfigDriveReservesTheCdromOfConfigDrive() { Assert.assertEquals(new HashSet<>(Arrays.asList(3L, 4L)), volumeApiServiceImpl.getReservedDeviceIds(vm, false)); Assert.assertEquals(new HashSet<>(Arrays.asList(3L, 4L, 5L)), volumeApiServiceImpl.getReservedDeviceIds(vm, true)); } + + @Test + public void testSnapshotCancelledBeforeItsWorkRanIsRemoved() { + Mockito.doReturn(true).when(volumeApiServiceImpl).isCurrentJobCancelled(); + volumeApiServiceImpl.removeSnapshotIfCancelledBeforeItRan(21L); + Mockito.verify(snapshotManagerMock).removeAllocatedSnapshot(21L); + + Mockito.doReturn(false).when(volumeApiServiceImpl).isCurrentJobCancelled(); + volumeApiServiceImpl.removeSnapshotIfCancelledBeforeItRan(22L); + Mockito.verify(snapshotManagerMock, Mockito.never()).removeAllocatedSnapshot(22L); + } } diff --git a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java index 8eee0ed66261..572158f4dd75 100755 --- a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java +++ b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java @@ -670,4 +670,62 @@ public void extractSnapshotTestRootAdminDisabledExtractionCreateExtractUrlReturn Mockito.verify(snapshotSrv).syncVolumeSnapshotsToRegionStore(TEST_VOLUME_ID, imageStoreEntityMock); Mockito.verify(snapshotStoreDao).update(TEST_SNAPSHOTDATASTORE_ID, snapshotStoreMock); } + + @Test + public void removeAllocatedSnapshotUndoesTheAllocationCounts() { + SnapshotManagerImpl mgr = Mockito.spy(_snapshotMgr); + doReturn(true).when(mgr).isBackupSnapshotToSecondaryForZone(anyLong()); + when(snapshotMock.getState()).thenReturn(Snapshot.State.Allocated); + when(snapshotMock.getAccountId()).thenReturn(7L); + when(snapshotMock.getSize()).thenReturn(1024L); + when(snapshotMock.getLocationType()).thenReturn(null); + + mgr.removeAllocatedSnapshot(TEST_SNAPSHOT_ID); + + Mockito.verify(_resourceLimitMgr).decrementResourceCount(7L, ResourceType.snapshot); + Mockito.verify(_resourceLimitMgr).decrementResourceCount(7L, ResourceType.secondary_storage, 1024L); + Mockito.verify(_snapshotDao).remove(TEST_SNAPSHOT_ID); + } + + @Test + public void removeAllocatedSnapshotCountsPrimaryStorageWhenTheSnapshotStaysOnPrimary() { + SnapshotManagerImpl mgr = Mockito.spy(_snapshotMgr); + doReturn(true).when(mgr).isBackupSnapshotToSecondaryForZone(anyLong()); + when(snapshotMock.getState()).thenReturn(Snapshot.State.Allocated); + when(snapshotMock.getAccountId()).thenReturn(7L); + when(snapshotMock.getSize()).thenReturn(1024L); + when(snapshotMock.getLocationType()).thenReturn(Snapshot.LocationType.PRIMARY); + + mgr.removeAllocatedSnapshot(TEST_SNAPSHOT_ID); + + Mockito.verify(_resourceLimitMgr).decrementResourceCount(7L, ResourceType.primary_storage, 1024L); + Mockito.verify(_snapshotDao).remove(TEST_SNAPSHOT_ID); + } + + @Test + public void removeAllocatedSnapshotLeavesASnapshotWhoseWorkStarted() { + when(snapshotMock.getState()).thenReturn(Snapshot.State.Creating); + + _snapshotMgr.removeAllocatedSnapshot(TEST_SNAPSHOT_ID); + + Mockito.verify(_resourceLimitMgr, Mockito.never()).decrementResourceCount(anyLong(), any(ResourceType.class)); + Mockito.verify(_snapshotDao, Mockito.never()).remove(anyLong()); + } + + @Test + public void endLastChainIfNeededAcceptsADetachedVolumeWithNoCluster() { + SnapshotDataStoreVO olderChainMember = Mockito.mock(SnapshotDataStoreVO.class); + when(olderChainMember.getCreated()).thenReturn(new java.util.Date(1000L)); + when(olderChainMember.getKvmCheckpointPath()).thenReturn("/checkpoints/old"); + when(olderChainMember.isEndOfChain()).thenReturn(false); + SnapshotDataStoreVO newest = Mockito.mock(SnapshotDataStoreVO.class); + when(newest.getCreated()).thenReturn(new java.util.Date(2000L)); + when(snapshotStoreDao.listReadyByVolumeId(5L)).thenReturn(new java.util.ArrayList<>(java.util.Arrays.asList(olderChainMember, newest))); + + // a detached volume has no VM and so no cluster; this used to unbox a null + _snapshotMgr.endLastChainIfNeeded(null, 5L); + + Mockito.verify(olderChainMember).setEndOfChain(true); + Mockito.verify(snapshotStoreDao).update(Mockito.anyLong(), Mockito.eq(olderChainMember)); + } } diff --git a/server/src/test/java/com/cloud/vm/snapshot/VMSnapshotManagerTest.java b/server/src/test/java/com/cloud/vm/snapshot/VMSnapshotManagerTest.java index 0ef131faa95b..c79ca9571636 100644 --- a/server/src/test/java/com/cloud/vm/snapshot/VMSnapshotManagerTest.java +++ b/server/src/test/java/com/cloud/vm/snapshot/VMSnapshotManagerTest.java @@ -70,6 +70,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.Spy; @@ -470,4 +471,21 @@ public void testRevertUserVmDetailsFromVmSnapshotDynamicServiceOffering() { assertTrue(appliedNames.contains(VmDetailConstants.MEMORY)); assertFalse("UEFI must not be applied from snapshot so that existing UEFI setting is preserved", appliedNames.contains("UEFI")); } + + @Test + public void testVMSnapshotCancelledBeforeItsWorkRanIsRemoved() { + VMSnapshotVO allocated = Mockito.mock(VMSnapshotVO.class); + when(allocated.getState()).thenReturn(VMSnapshot.State.Allocated); + when(_vmSnapshotDao.findById(21L)).thenReturn(allocated); + doReturn(true).when(_vmSnapshotMgr).isCurrentJobCancelled(); + doNothing().when(_vmSnapshotMgr).removeCancelledVMSnapshot(allocated); + + _vmSnapshotMgr.removeVMSnapshotIfCancelledBeforeItRan(21L); + Mockito.verify(_vmSnapshotMgr).removeCancelledVMSnapshot(allocated); + + // a snapshot whose work started is undone by that work, not here + when(allocated.getState()).thenReturn(VMSnapshot.State.Creating); + _vmSnapshotMgr.removeVMSnapshotIfCancelledBeforeItRan(21L); + Mockito.verify(_vmSnapshotMgr, Mockito.times(1)).removeCancelledVMSnapshot(allocated); + } } diff --git a/server/src/test/java/org/apache/cloudstack/affinity/AffinityApiUnitTest.java b/server/src/test/java/org/apache/cloudstack/affinity/AffinityApiUnitTest.java index e8cc05c137e5..3411e9a05b56 100644 --- a/server/src/test/java/org/apache/cloudstack/affinity/AffinityApiUnitTest.java +++ b/server/src/test/java/org/apache/cloudstack/affinity/AffinityApiUnitTest.java @@ -53,6 +53,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentMatchers; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; import org.mockito.Mockito; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -269,6 +270,11 @@ public EventDao eventDao() { return Mockito.mock(EventDao.class); } + @Bean + public AsyncJobManager asyncJobManager() { + return Mockito.mock(AsyncJobManager.class); + } + @Bean public UserVmDao userVMDao() { return Mockito.mock(UserVmDao.class); diff --git a/server/src/test/java/org/apache/cloudstack/affinity/AffinityGroupServiceImplTest.java b/server/src/test/java/org/apache/cloudstack/affinity/AffinityGroupServiceImplTest.java index 495561429ea5..cc01baace065 100644 --- a/server/src/test/java/org/apache/cloudstack/affinity/AffinityGroupServiceImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/affinity/AffinityGroupServiceImplTest.java @@ -55,6 +55,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentMatchers; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; import org.mockito.Mockito; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -331,6 +332,11 @@ public EventDao eventDao() { return Mockito.mock(EventDao.class); } + @Bean + public AsyncJobManager asyncJobManager() { + return Mockito.mock(AsyncJobManager.class); + } + @Bean public UserVmDao userVMDao() { return Mockito.mock(UserVmDao.class); diff --git a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/SecondaryStorageResourceHandler.java b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/SecondaryStorageResourceHandler.java index 5c97e48e5e98..0f5207f758be 100644 --- a/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/SecondaryStorageResourceHandler.java +++ b/services/secondary-storage/server/src/main/java/org/apache/cloudstack/storage/resource/SecondaryStorageResourceHandler.java @@ -23,4 +23,12 @@ public interface SecondaryStorageResourceHandler { Answer executeRequest(Command cmd); + /** Null when the request's work can be stopped, otherwise reason for why not. */ + default String checkRequestSequenceIsNotCancellable(long sequence) { + return "this handler does not support cancelling a running command"; + } + + default boolean cancelRequestSequence(long sequence) { + return false; + } } diff --git a/test/integration/smoke/test_async_jobs.py b/test/integration/smoke/test_async_jobs.py new file mode 100644 index 000000000000..be1c1216719a --- /dev/null +++ b/test/integration/smoke/test_async_jobs.py @@ -0,0 +1,489 @@ +# 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. +""" Tests for listing async jobs by status, period, resource and management server, and for cancelling them """ + +import time +import uuid + +from marvin.cloudstackAPI import (cancelAsyncJob, + listApis, + createSnapshot, + createVolume, + listAsyncJobs, + listEvents, + listUsageJobs, + migrateVirtualMachine, + migrateVirtualMachineWithVolume, + migrateVolume, + queryAsyncJobResult, + stopVirtualMachine) +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.codes import (JOB_CANCELLED, + JOB_FAILED, + JOB_INPROGRESS, + JOB_SUCCEEDED, + PASS) +from marvin.lib.base import (Account, + DiskOffering, + Host, + ServiceOffering, + Snapshot, + StoragePool, + VirtualMachine, + Volume) +from marvin.lib.common import (get_domain, + get_test_template, + get_zone, + list_hosts) +from nose.plugins.attrib import attr + +ALL_STATUSES = "%d,%d,%d,%d" % (JOB_INPROGRESS, JOB_SUCCEEDED, JOB_FAILED, JOB_CANCELLED) +CANCELLABLE_HYPERVISORS = ["kvm", "vmware", "xenserver"] + + +class TestAsyncJobsManagement(cloudstackTestCase): + + @classmethod + def setUpClass(cls): + cls.testClient = super(TestAsyncJobsManagement, cls).getClsTestClient() + cls.apiclient = cls.testClient.getApiClient() + cls.testdata = cls.testClient.getParsedTestDataConfig() + cls.domain = get_domain(cls.apiclient) + cls.zone = get_zone(cls.apiclient, cls.testClient.getZoneForTests()) + cls.hypervisor = cls.testClient.getHypervisorInfo() + cls.dbclient = cls.testClient.getDbConnection() + cls.template = get_test_template(cls.apiclient, cls.zone.id, cls.hypervisor) + cls._cleanup = [] + + cls.service_offering = ServiceOffering.create(cls.apiclient, cls.testdata["service_offering"]) + cls._cleanup.append(cls.service_offering) + cls.disk_offering = DiskOffering.create(cls.apiclient, cls.testdata["disk_offering"]) + cls._cleanup.append(cls.disk_offering) + cls.account = Account.create(cls.apiclient, cls.testdata["account"], domainid=cls.domain.id) + cls._cleanup.append(cls.account) + + cls.testdata["virtual_machine"]["zoneid"] = cls.zone.id + cls.testdata["virtual_machine"]["template"] = cls.template.id + cls.virtual_machine = VirtualMachine.create( + cls.apiclient, + cls.testdata["virtual_machine"], + accountid=cls.account.name, + domainid=cls.account.domainid, + serviceofferingid=cls.service_offering.id, + diskofferingid=cls.disk_offering.id, + hypervisor=cls.hypervisor + ) + cls._cleanup.append(cls.virtual_machine) + # the deploy job is the completed job every listing test looks for + cls.deploy_job_id = cls.virtual_machine.jobid + + @classmethod + def tearDownClass(cls): + super(TestAsyncJobsManagement, cls).tearDownClass() + + def setUp(self): + self.cleanup = [] + + def tearDown(self): + super(TestAsyncJobsManagement, self).tearDown() + + def list_jobs(self, **kwargs): + cmd = listAsyncJobs.listAsyncJobsCmd() + cmd.listall = True + for key, value in kwargs.items(): + setattr(cmd, key, value) + return self.apiclient.listAsyncJobs(cmd) or [] + + @staticmethod + def find_job(jobs, jobid): + return next((job for job in jobs if job.jobid == jobid), None) + + def query_job(self, jobid): + cmd = queryAsyncJobResult.queryAsyncJobResultCmd() + cmd.jobid = jobid + return self.apiclient.queryAsyncJobResult(cmd) + + def wait_for_job(self, jobid, timeout=300): + deadline = time.time() + timeout + while time.time() < deadline: + job = self.query_job(jobid) + if job.jobstatus != JOB_INPROGRESS: + return job + time.sleep(3) + self.fail("Job %s still in progress after %d seconds" % (jobid, timeout)) + + def cancel_job(self, jobid): + cmd = cancelAsyncJob.cancelAsyncJobCmd() + cmd.jobid = jobid + return self.apiclient.cancelAsyncJob(cmd) + + def work_was_stopped(self, job, cancelled): + """An accepted cancel ends the job CANCELLED, or SUCCEEDED with the request recorded when the work finished before the cancel took effect; a refused cancel lets the job run its course""" + if cancelled: + self.assertIn(job.jobstatus, [JOB_CANCELLED, JOB_SUCCEEDED], "Accepted cancel left job %s in status %s" % (job.jobid, job.jobstatus)) + self.assertTrue(job.cancelrequested, "Accepted cancel is not recorded on job %s" % job.jobid) + else: + self.assertIn(job.jobstatus, [JOB_SUCCEEDED, JOB_FAILED], "Refused cancel left job %s in status %s" % (job.jobid, job.jobstatus)) + return job.jobstatus == JOB_CANCELLED + + def start_instance_again(self): + """A start right after a volume copy can lose the race for the disk's lock on the host, so it is retried""" + for attempt in range(1, 4): + try: + self.virtual_machine.start(self.apiclient) + return + except Exception as e: + self.debug("Start of instance %s failed on attempt %d: %s" % (self.virtual_machine.id, attempt, e)) + time.sleep(20) + self.virtual_machine.start(self.apiclient) + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_00_cancellable_is_published(self): + """listApis and listAsyncJobs say in advance whether a command can be cancelled""" + cmd = listApis.listApisCmd() + apis = {api.name: api for api in (self.apiclient.listApis(cmd) or [])} + self.assertTrue(apis["createVolume"].cancellable, "createVolume is not published as cancellable") + self.assertTrue(apis["migrateVirtualMachine"].cancellable, "migrateVirtualMachine is not published as cancellable") + self.assertFalse(apis["deployVirtualMachine"].cancellable, "deployVirtualMachine is published as cancellable") + + deploy_job = self.find_job(self.list_jobs(jobstatus=ALL_STATUSES), self.deploy_job_id) + self.assertIsNotNone(deploy_job, "Deploy job %s not listed" % self.deploy_job_id) + self.assertFalse(deploy_job.cancellable, "The deploy job is published as cancellable") + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_01_default_listing_is_pending_only(self): + """Without a status filter only pending jobs are listed, so a completed job is absent""" + jobs = self.list_jobs() + self.assertIsNone(self.find_job(jobs, self.deploy_job_id), + "Completed deploy job %s listed without a status filter" % self.deploy_job_id) + for job in jobs: + self.assertEqual(job.jobstatus, JOB_INPROGRESS, "Non-pending job %s in the default listing" % job.jobid) + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_02_list_completed_jobs_by_status_and_duration(self): + """A completed job is reachable by status, with the period, by ordinal or by name""" + job = self.find_job(self.list_jobs(jobstatus=JOB_SUCCEEDED, duration=1), self.deploy_job_id) + self.assertIsNotNone(job, "Deploy job %s not listed with jobstatus=%d duration=1" % (self.deploy_job_id, JOB_SUCCEEDED)) + self.assertEqual(job.jobstatus, JOB_SUCCEEDED) + self.assertTrue(job.completed, "Completed job has no completion time") + self.assertIn("DeployVMCmd", job.cmd) + self.assertEqual(job.jobinstanceid, self.virtual_machine.id) + + self.assertEqual(job.jobinstancename, self.virtual_machine.name) + + by_id = self.list_jobs(id=self.deploy_job_id) + + self.assertEqual(len(by_id), 1, "listAsyncJobs id= did not return exactly the deploy job") + + self.assertEqual(by_id[0].jobid, self.deploy_job_id) + by_name = self.find_job(self.list_jobs(jobstatus="SUCCEEDED", duration=1), self.deploy_job_id) + self.assertIsNotNone(by_name, "Status filter by name did not match the deploy job") + + failed = self.find_job(self.list_jobs(jobstatus=JOB_FAILED, duration=1), self.deploy_job_id) + self.assertIsNone(failed, "Succeeded deploy job listed under the failed status") + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_03_list_jobs_by_resource(self): + """Jobs are reachable from the resource they acted on""" + jobs = self.list_jobs(resourcetype="VirtualMachine", resourceid=self.virtual_machine.id, + jobstatus=ALL_STATUSES, duration=1) + self.assertIsNotNone(self.find_job(jobs, self.deploy_job_id), "Deploy job not listed for its instance") + for job in jobs: + self.assertEqual(job.jobinstanceid, self.virtual_machine.id, "Job %s for another resource listed" % job.jobid) + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_04_list_jobs_by_management_server(self): + """Jobs are reachable from the management server that ran them""" + job = self.find_job(self.list_jobs(jobstatus=JOB_SUCCEEDED, duration=1), self.deploy_job_id) + self.assertIsNotNone(job) + msid = getattr(job, "managementserverid", None) + if not msid: + self.skipTest("Deploy job carries no management server id") + + jobs = self.list_jobs(managementserverid=msid, jobstatus=JOB_SUCCEEDED, duration=1) + self.assertIsNotNone(self.find_job(jobs, self.deploy_job_id), "Deploy job not listed for its management server") + for entry in jobs: + self.assertEqual(entry.managementserverid, msid, "Job %s of another management server listed" % entry.jobid) + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_05_events_link_to_job(self): + """The events a job raised are reachable by its id""" + cmd = listEvents.listEventsCmd() + cmd.listall = True + cmd.jobid = self.deploy_job_id + events = self.apiclient.listEvents(cmd) or [] + self.assertTrue(events, "No events listed for deploy job %s" % self.deploy_job_id) + self.assertTrue(any(event.type.startswith("VM.CREATE") for event in events), "No VM.CREATE event for the deploy job") + states = set(event.state for event in events) + self.assertIn("Scheduled", states, "The Scheduled event of the deploy job is not linked to it") + self.assertIn("Created", states, "The Created event of the deploy job is not linked to it") + self.assertIn("Completed", states, "The Completed event of the deploy job is not linked to it") + for event in events: + self.assertEqual(event.jobid, self.deploy_job_id, "Event %s of another job listed" % event.id) + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_06_cancel_completed_job_is_refused(self): + """A finished job cannot be cancelled and is left as it was""" + with self.assertRaises(Exception): + self.cancel_job(self.deploy_job_id) + self.assertEqual(self.query_job(self.deploy_job_id).jobstatus, JOB_SUCCEEDED, "Refused cancel changed the job") + cmd = listEvents.listEventsCmd() + cmd.listall = True + cmd.type = "JOB.CANCEL" + cmd.jobid = self.deploy_job_id + events = self.apiclient.listEvents(cmd) or [] + self.assertTrue(events, "No JOB.CANCEL event recorded for the refused cancel of job %s" % self.deploy_job_id) + self.assertTrue(all(event.resourceid == self.deploy_job_id for event in events), "JOB.CANCEL event not tied to the job") + self.assertTrue(any("Cancelled by" in (event.description or "") for event in events), "JOB.CANCEL event does not name who cancelled") + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_07_cancel_unknown_job_is_refused(self): + with self.assertRaises(Exception): + self.cancel_job(str(uuid.uuid4())) + + @attr(tags=["advanced", "advancedns", "smoke"], required_hardware="true") + def test_08_cancel_running_migration(self): + """Cancelling a live migration stops it, finds it finished, or is refused; the instance ends Running either way""" + if self.hypervisor.lower() not in CANCELLABLE_HYPERVISORS: + self.skipTest("Cancellation of hypervisor jobs is not implemented for %s" % self.hypervisor) + + vm = VirtualMachine.list(self.apiclient, id=self.virtual_machine.id)[0] + source_host = list_hosts(self.apiclient, id=vm.hostid)[0] + targets = [h for h in (list_hosts(self.apiclient, clusterid=source_host.clusterid, type="Routing", + state="Up", resourcestate="Enabled") or []) if h.id != source_host.id] + if not targets: + self.skipTest("Need a second host in cluster %s to migrate to" % source_host.clusterid) + + cmd = migrateVirtualMachine.migrateVirtualMachineCmd() + cmd.virtualmachineid = vm.id + cmd.hostid = targets[0].id + cmd.isAsync = "false" + jobid = self.apiclient.migrateVirtualMachine(cmd, method="GET").jobid + + cancelled = True + try: + self.cancel_job(jobid) + except Exception as e: + # the migration finished, or the hypervisor would not stop it: a refusal, not a failure + self.debug("Cancel of migration job %s refused: %s" % (jobid, e)) + cancelled = False + + job = self.wait_for_job(jobid) + stopped = self.work_was_stopped(job, cancelled) + + response = self.virtual_machine.getState(self.apiclient, VirtualMachine.RUNNING) + self.assertEqual(response[0], PASS, response[1]) + if stopped: + vm = VirtualMachine.list(self.apiclient, id=self.virtual_machine.id)[0] + self.assertEqual(vm.hostid, source_host.id, "Cancelled migration left the instance on the target host") + + @attr(tags=["advanced", "advancedns", "smoke"], required_hardware="true") + def test_09_cancelled_storage_migration_leaves_no_volume_behind(self): + """Cancelling a migration with storage rolls back the volume created on the target pool; one that finished first, or was refused, leaves one usable root volume""" + if self.hypervisor.lower() not in CANCELLABLE_HYPERVISORS: + self.skipTest("Cancellation of hypervisor jobs is not implemented for %s" % self.hypervisor) + + vm = VirtualMachine.list(self.apiclient, id=self.virtual_machine.id)[0] + root_volume = [v for v in Volume.list(self.apiclient, virtualmachineid=vm.id, listall=True) if v.type == "ROOT"][0] + target_hosts = Host.listForMigration(self.apiclient, virtualmachineid=vm.id) or [] + target_pools = StoragePool.listForMigration(self.apiclient, id=root_volume.id) or [] + if not target_hosts or not target_pools: + self.skipTest("Need a second host and a second primary storage pool to migrate to") + + cmd = migrateVirtualMachineWithVolume.migrateVirtualMachineWithVolumeCmd() + cmd.virtualmachineid = vm.id + cmd.hostid = target_hosts[0].id + cmd.migrateto = [{"volume": str(root_volume.id), "pool": str(target_pools[0].id)}] + cmd.isAsync = "false" + jobid = self.apiclient.migrateVirtualMachineWithVolume(cmd, method="GET").jobid + + # let the migration create its target volume and start copying before cancelling + time.sleep(10) + cancelled = True + try: + self.cancel_job(jobid) + except Exception as e: + self.debug("Cancel of storage migration job %s refused: %s" % (jobid, e)) + cancelled = False + + job = self.wait_for_job(jobid) + stopped = self.work_was_stopped(job, cancelled) + + response = self.virtual_machine.getState(self.apiclient, VirtualMachine.RUNNING) + self.assertEqual(response[0], PASS, response[1]) + + # the rollback runs after the job is reported cancelled; give it a moment + deadline = time.time() + 120 + while True: + volumes = Volume.list(self.apiclient, virtualmachineid=vm.id, listall=True) or [] + roots = [v for v in volumes if v.type == "ROOT"] + if len(roots) == 1 and roots[0].state != "Destroy" or time.time() > deadline: + break + time.sleep(5) + self.assertEqual(len(roots), 1, "A volume created for the cancelled migration was left behind: %s" + % [(v.id, v.state, v.storageid) for v in roots]) + self.assertNotEqual(roots[0].state, "Destroy", "The instance's root volume is left in Destroy") + if stopped: + self.assertEqual(roots[0].id, root_volume.id, "Cancelled migration replaced the root volume") + self.assertEqual(roots[0].storageid, root_volume.storageid, "Cancelled migration moved the root volume") + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_10_cancel_of_power_operation_is_refused(self): + """A stop's command cannot be stopped: the cancel is refused while it runs, or accepted only when nothing of the stop was in flight""" + cmd = stopVirtualMachine.stopVirtualMachineCmd() + cmd.id = self.virtual_machine.id + cmd.isAsync = "false" + jobid = self.apiclient.stopVirtualMachine(cmd, method="GET").jobid + + try: + cancelled = True + try: + self.cancel_job(jobid) + except Exception as e: + self.debug("Cancel of stop job %s refused: %s" % (jobid, e)) + cancelled = False + + job = self.wait_for_job(jobid) + if cancelled: + # the cancel landed before the stop command went out (a stop takes milliseconds on the simulator): either nothing ran, or it finished regardless + self.work_was_stopped(job, cancelled) + else: + self.assertEqual(job.jobstatus, JOB_SUCCEEDED, "Stop did not complete after its cancel was refused") + response = self.virtual_machine.getState(self.apiclient, VirtualMachine.STOPPED) + self.assertEqual(response[0], PASS, response[1]) + finally: + if VirtualMachine.list(self.apiclient, id=self.virtual_machine.id)[0].state != VirtualMachine.RUNNING: + self.start_instance_again() + + @attr(tags=["advanced", "advancedns", "smoke"], required_hardware="true") + def test_11_cancelled_offline_volume_migration_keeps_the_volume(self): + """Cancelling an offline volume migration leaves the volume Ready on its source pool; one that finished first, or was refused, leaves it Ready on the target""" + if self.hypervisor.lower() not in CANCELLABLE_HYPERVISORS: + self.skipTest("Cancellation of hypervisor jobs is not implemented for %s" % self.hypervisor) + + root_volume = [v for v in Volume.list(self.apiclient, virtualmachineid=self.virtual_machine.id, listall=True) if v.type == "ROOT"][0] + self.virtual_machine.stop(self.apiclient) + try: + target_pools = StoragePool.listForMigration(self.apiclient, id=root_volume.id) or [] + if not target_pools: + self.skipTest("Need a second primary storage pool to migrate to") + + cmd = migrateVolume.migrateVolumeCmd() + cmd.volumeid = root_volume.id + cmd.storageid = target_pools[0].id + cmd.isAsync = "false" + jobid = self.apiclient.migrateVolume(cmd, method="GET").jobid + + time.sleep(5) + cancelled = True + try: + self.cancel_job(jobid) + except Exception as e: + self.debug("Cancel of volume migration job %s refused: %s" % (jobid, e)) + cancelled = False + + job = self.wait_for_job(jobid) + stopped = self.work_was_stopped(job, cancelled) + self.assertNotIn("NullPointerException", str(job.jobresult), "The cancel surfaced as a server error") + + volume = Volume.list(self.apiclient, id=root_volume.id)[0] + self.assertEqual(volume.state, "Ready", "Volume left in state %s after the migration job ended" % volume.state) + if stopped: + self.assertEqual(volume.storageid, root_volume.storageid, "Cancelled migration moved the volume") + finally: + self.start_instance_again() + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_12_cancelled_volume_creation_leaves_no_volume(self): + """A cancelled createVolume leaves no volume behind; a refused cancel leaves a usable one""" + pools = [p for p in (StoragePool.list(self.apiclient, zoneid=self.zone.id) or []) if p.state == "Up"] + if not pools: + self.skipTest("No primary storage pool in zone %s" % self.zone.id) + + cmd = createVolume.createVolumeCmd() + cmd.name = "cancelled-" + str(uuid.uuid4())[:8] + cmd.zoneid = self.zone.id + cmd.diskofferingid = self.disk_offering.id + cmd.storageid = pools[0].id + cmd.isAsync = "false" + response = self.apiclient.createVolume(cmd, method="GET") + jobid, volumeid = response.jobid, response.id + + cancelled = True + try: + self.cancel_job(jobid) + except Exception as e: + self.debug("Cancel of create volume job %s refused: %s" % (jobid, e)) + cancelled = False + + job = self.wait_for_job(jobid) + stopped = self.work_was_stopped(job, cancelled) + volumes = Volume.list(self.apiclient, id=volumeid, listall=True) or [] + if stopped: + self.assertFalse(volumes, "Cancelled creation left volume %s in state %s" % (volumeid, volumes[0].state if volumes else None)) + # the usage record opened at allocation must be closed, or never opened + usage_events = [row[0] for row in (self.dbclient.execute( + "select type from cloud.usage_event where resource_name = '%s' order by id" % cmd.name) or [])] + self.assertIn(usage_events, [[], ["VOLUME.CREATE", "VOLUME.DELETE"]], + "Cancelled creation left usage events %s for volume %s" % (usage_events, volumeid)) + else: + self.assertNotEqual([v.state for v in volumes], ["Destroy"], "Volume %s left in Destroy" % volumeid) + if volumes and volumes[0].state == "Ready": + Volume(volumes[0].__dict__).delete(self.apiclient) + + @attr(tags=["advanced", "advancedns", "smoke"], required_hardware="true") + def test_13_cancelled_volume_snapshot_leaves_nothing(self): + """Cancelling a volume snapshot during its backup leaves no snapshot behind; one that finished first, or was refused, completes""" + if self.hypervisor.lower() not in CANCELLABLE_HYPERVISORS: + self.skipTest("Cancellation of hypervisor jobs is not implemented for %s" % self.hypervisor) + + root_volume = [v for v in Volume.list(self.apiclient, virtualmachineid=self.virtual_machine.id, listall=True) if v.type == "ROOT"][0] + cmd = createSnapshot.createSnapshotCmd() + cmd.volumeid = root_volume.id + cmd.isAsync = "false" + response = self.apiclient.createSnapshot(cmd, method="GET") + jobid, snapshotid = response.jobid, response.id + + # let the backup to secondary storage start before cancelling + time.sleep(5) + cancelled = True + try: + self.cancel_job(jobid) + except Exception as e: + self.debug("Cancel of snapshot job %s refused: %s" % (jobid, e)) + cancelled = False + + job = self.wait_for_job(jobid, timeout=1800) + stopped = self.work_was_stopped(job, cancelled) + snapshots = Snapshot.list(self.apiclient, id=snapshotid, listall=True) or [] + if stopped: + self.assertFalse(snapshots, "Cancelled snapshot %s left behind in state %s" % (snapshotid, snapshots[0].state if snapshots else None)) + else: + self.assertNotEqual([s.state for s in snapshots], ["BackingUp"], "Snapshot %s stuck in BackingUp" % snapshotid) + if snapshots and snapshots[0].state == "BackedUp": + Snapshot(snapshots[0].__dict__).delete(self.apiclient) + + @attr(tags=["advanced", "advancedns", "smoke", "basic"], required_hardware="false") + def test_14_list_usage_jobs(self): + """Usage server jobs list without error, and filter by usage server""" + cmd = listUsageJobs.listUsageJobsCmd() + cmd.duration = 24 + self.apiclient.listUsageJobs(cmd) + + cmd = listUsageJobs.listUsageJobsCmd() + cmd.usageserver = "no-such-usage-server" + self.assertFalse(self.apiclient.listUsageJobs(cmd), "Jobs listed for a usage server that does not exist") diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 0acafc3b8015..1e3a485be42d 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -4522,5 +4522,30 @@ "GB*Month": "GB * Month", "IP*Month": "IP * Month", "Policy*Month": "Policy * Month", -"message.kms.key.optional": "Optional: Select a KMS key for encryption. If not selected, legacy passphrase encryption will be used." +"message.kms.key.optional": "Optional: Select a KMS key for encryption. If not selected, legacy passphrase encryption will be used.", +"label.jobs": "Jobs", +"label.usage.jobs": "Usage Jobs", +"label.jobresult": "Result", +"label.jobid": "Job ID", +"label.managementserver.initiated": "Management Server (Initiated)", +"label.managementserver.executing": "Management Server (Executing)", +"label.managementserver.completed": "Management Server (Completed)", +"label.pending": "Pending", +"label.period": "Period", +"label.cancelled": "Cancelled", +"label.cancelling": "Cancelling", +"label.cancelreason": "Cancel reason", +"label.last.hour": "Last hour", +"label.last.24.hours": "Last 24 hours", +"label.last.7.days": "Last 7 days", +"label.usage.server": "Usage server", +"label.job.type": "Job type", +"label.execution.time": "Execution time (ms)", +"label.heartbeat": "Heartbeat", +"label.recurring": "Recurring", +"label.single": "Single", +"label.cancel.job": "Cancel job", +"message.confirm.cancel.job": "Please confirm that you want to cancel this job. Where the hypervisor supports it, the operation in progress is stopped as well; if it cannot be stopped, the request is refused and the job is left to finish.", +"message.cancel.job.success": "Job cancelled", +"label.activity": "Activity" } diff --git a/ui/src/components/view/ActionButton.vue b/ui/src/components/view/ActionButton.vue index 14128a5ce554..8e59b51c913f 100644 --- a/ui/src/components/view/ActionButton.vue +++ b/ui/src/components/view/ActionButton.vue @@ -66,7 +66,7 @@ @@ -85,7 +85,7 @@ )" :disabled="'disabled' in action ? action.disabled(resource, $store.getters) : false" :type="(primaryIconList.includes(action.icon) ? 'primary' : 'default')" - :danger="dangerIconList.includes(action.icon)" + :danger="dangerIconList.includes(action.icon) || action.danger === true" :shape="!dataView && ['PlusOutlined', 'plus-outlined', 'UserAddOutlined', 'user-add-outlined'].includes(action.icon) ? 'round' : 'circle'" style="margin-left: 5px" :size="size" diff --git a/ui/src/components/view/InfoCard.vue b/ui/src/components/view/InfoCard.vue index 70a4b594ca7a..cc2ad607b90d 100644 --- a/ui/src/components/view/InfoCard.vue +++ b/ui/src/components/view/InfoCard.vue @@ -933,11 +933,11 @@
-
{{ $t('label.management.servers') }}
+
{{ $t('label.management.server') }}
- {{ resource.managementserver || resource.managementserverid }} - {{ resource.managementserver || resource.managementserverid }} + {{ resource.managementservername || resource.managementserver || resource.managementserverid }} + {{ resource.managementservername || resource.managementserver || resource.managementserverid }}
@@ -1143,7 +1143,8 @@ export default { }, name () { return this.resource.displayname || this.resource.name || this.resource.displaytext || this.resource.username || - this.resource.ipaddress || this.resource.virtualmachinename || this.resource.osname || this.resource.osdisplayname || this.resource.templatetype + this.resource.ipaddress || this.resource.virtualmachinename || this.resource.osname || this.resource.osdisplayname || this.resource.templatetype || + (this.resource.cmd && this.resource.cmd.split('.').pop()) }, keypairs () { if (!this.resource.keypairs) { diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue index a585c5c449ae..adefdd6a5a19 100644 --- a/ui/src/components/view/ListView.vue +++ b/ui/src/components/view/ListView.vue @@ -711,6 +711,46 @@ + + + + -