diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java index 11b840c..6303794 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java @@ -92,8 +92,12 @@ public abstract class AgentConnector extends QemuConnector { */ @Handler public void onVserportChanged(VserportChangeEvent event) { - if (event.id().equals(channelId) && event.isOpen()) { - agentConnected(); + if (event.id().equals(channelId)) { + if (event.isOpen()) { + agentConnected(); + } else { + agentDisconnected(); + } } } @@ -105,4 +109,14 @@ public abstract class AgentConnector extends QemuConnector { protected void agentConnected() { // Default is to do nothing. } + + /** + * Called when the agent in the VM closes the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentDisconnected() { + // Default is to do nothing. + } + } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java new file mode 100644 index 0000000..ca88f0b --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java @@ -0,0 +1,42 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +/** + * Some constants. + */ +@SuppressWarnings("PMD.DataClass") +public class Constants extends org.jdrupes.vmoperator.common.Constants { + + /** + * Process names. + */ + public static class ProcessName { + + /** The Constant QEMU. */ + public static final String QEMU = "qemu"; + + /** The Constant SWTPM. */ + public static final String SWTPM = "swtpm"; + + /** The Constant CLOUD_INIT_IMG. */ + public static final String CLOUD_INIT_IMG = "cloudInitImg"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index e0d0a89..f5deda8 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -116,9 +116,11 @@ public class DisplayController extends Component { @Handler @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { - if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD)) - && canBeUpdated) { - configurePassword(); + if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { + logger.fine(() -> "Display password updated"); + if (canBeUpdated) { + configurePassword(); + } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java index ead7dd7..b0001e4 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -21,15 +21,23 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; +import java.time.Instant; import java.util.LinkedList; import java.util.Queue; import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestPowerdown; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Components.Timer; import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.ProcessExited; /** * A component that handles the communication with the guest agent. @@ -39,7 +47,12 @@ import org.jgrapes.core.annotation.Handler; */ public class GuestAgentClient extends AgentConnector { + private boolean connected; + private Instant powerdownStartedAt; + private int powerdownTimeout; + private Timer powerdownTimer; private final Queue executing = new LinkedList<>(); + private Stop suspendedStop; /** * Instantiates a new guest agent client. @@ -56,9 +69,17 @@ public class GuestAgentClient extends AgentConnector { */ @Override protected void agentConnected() { + logger.fine(() -> "guest agent connected"); + connected = true; rep().fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); } + @Override + protected void agentDisconnected() { + logger.fine(() -> "guest agent disconnected"); + connected = false; + } + /** * Process agent input. * @@ -88,10 +109,11 @@ public class GuestAgentClient extends AgentConnector { * On guest agent command. * * @param event the event - * @throws IOException + * @throws IOException Signals that an I/O exception has occurred. */ @Handler - @SuppressWarnings("PMD.AvoidSynchronizedStatement") + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) public void onGuestAgentCommand(GuestAgentCommand event) throws IOException { if (qemuChannel() == null) { @@ -114,4 +136,89 @@ public class GuestAgentClient extends AgentConnector { } } } + + /** + * Shutdown the VM. + * + * @param event the event + */ + @Handler(priority = 200) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onStop(Stop event) { + if (!connected) { + logger.fine(() -> "No guest agent connection," + + " cannot send shutdown command"); + return; + } + + // We have a connection to the guest agent attempt shutdown. + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); + if (waitUntil.isBefore(Instant.now())) { + return; + } + event.suspendHandling(); + suspendedStop = event; + logger.fine(() -> "Sending powerdown command, waiting for" + + " termination until " + waitUntil); + powerdownTimer = Components.schedule(t -> { + logger.fine(() -> "Powerdown timeout reached."); + synchronized (this) { + powerdownTimer = null; + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + }, waitUntil); + rep().fire(new GuestAgentCommand(new QmpGuestPowerdown())); + } + + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onConfigureQemu(ConfigureQemu event) { + int newTimeout = event.configuration().vm.powerdownTimeout; + if (powerdownTimeout != newTimeout) { + powerdownTimeout = newTimeout; + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer + .reschedule(powerdownStartedAt.plusSeconds(newTimeout)); + } + + } + } + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java index 2e1dbfa..777478e 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -242,7 +242,7 @@ public abstract class QemuConnector extends Component { */ @Handler public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(this, getClass()).ifPresent(qm -> { + channel.associated(this, getClass()).ifPresent(qc -> { qemuChannel = null; }); } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 75310f8..6be7603 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java @@ -27,6 +27,7 @@ import java.time.Instant; import java.util.LinkedList; import java.util.Queue; import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpPowerdown; @@ -42,6 +43,7 @@ import org.jgrapes.core.Components.Timer; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.Stop; import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ProcessExited; import org.jgrapes.net.SocketIOChannel; import org.jgrapes.util.events.ConfigurationUpdate; @@ -136,24 +138,12 @@ public class QemuMonitor extends QemuConnector { * @param event the event */ @Handler - @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", - "PMD.AvoidDuplicateLiterals" }) public void onClosed(Closed event, SocketIOChannel channel) { - super.onClosed(event, channel); - logger.finer(() -> "QMP socket closed."); - monitorReady = false; channel.associated(this, getClass()).ifPresent(qm -> { - synchronized (this) { - if (powerdownTimer != null) { - powerdownTimer.cancel(); - } - if (suspendedStop != null) { - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } + super.onClosed(event, channel); + logger.finer(() -> "QMP socket closed."); + monitorReady = false; }); - logger.finer(() -> "QMP socket closed."); } /** @@ -163,7 +153,8 @@ public class QemuMonitor extends QemuConnector { * @throws IOException */ @Handler - @SuppressWarnings("PMD.AvoidSynchronizedStatement") + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) public void onMonitorCommand(MonitorCommand event) throws IOException { // Check prerequisites if (!monitorReady && !(event.command() instanceof QmpCapabilities)) { @@ -205,14 +196,22 @@ public class QemuMonitor extends QemuConnector { + " cannot send powerdown command"); return; } - // We have a connection to Qemu, attempt ACPI shutdown. + + // We have a connection to Qemu, attempt ACPI shutdown if time left + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + if (powerdownStartedAt.plusSeconds(powerdownTimeout) + .isBefore(Instant.now())) { + return; + } event.suspendHandling(); suspendedStop = event; - // Attempt powerdown command. If not confirmed, assume - // "hanging" qemu process. + // Send command. If not confirmed, assume "hanging" qemu process. powerdownTimer = Components.schedule(t -> { - // Powerdown not confirmed logger.fine(() -> "QMP powerdown command not confirmed"); synchronized (this) { powerdownTimer = null; @@ -221,14 +220,15 @@ public class QemuMonitor extends QemuConnector { suspendedStop = null; } } - }, Duration.ofSeconds(1)); - logger.fine(() -> "Attempting QMP powerdown."); - powerdownStartedAt = Instant.now(); - fire(new MonitorCommand(new QmpPowerdown())); + }, Duration.ofSeconds(5)); + logger.fine(() -> "Attempting QMP (ACPI) powerdown."); + rep().fire(new MonitorCommand(new QmpPowerdown())); } /** - * On powerdown event. + * When the powerdown event is confirmed, wait for termination + * or timeout. Termination is detected by the qemu process exiting + * (see {@link #onProcessExited(ProcessExited)}). * * @param event the event */ @@ -248,6 +248,7 @@ public class QemuMonitor extends QemuConnector { powerdownTimer = Components.schedule(t -> { logger.fine(() -> "Powerdown timeout reached."); synchronized (this) { + powerdownTimer = null; if (suspendedStop != null) { suspendedStop.resumeHandling(); suspendedStop = null; @@ -258,6 +259,29 @@ public class QemuMonitor extends QemuConnector { } } + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + /** * On configure qemu. * diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index 6c69191..d8ac5d8 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023,2024 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -57,6 +57,7 @@ import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -195,9 +196,6 @@ import org.jgrapes.util.events.WatchFile; "PMD.CouplingBetweenObjects", "PMD.TooManyFields" }) public class Runner extends Component { - private static final String QEMU = "qemu"; - private static final String SWTPM = "swtpm"; - private static final String CLOUD_INIT_IMG = "cloudInitImg"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -313,6 +311,7 @@ public class Runner extends Component { @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { + logger.fine(() -> "Runner configuratation updated"); var newConf = yamlMapper.convertValue(c, Configuration.class); // Add some values from other sources to configuration @@ -350,15 +349,19 @@ public class Runner extends Component { initialConfig = newConfig; // Configure - swtpmDefinition = Optional.ofNullable(tplData.get(SWTPM)) - .map(d -> new CommandDefinition(SWTPM, d)).orElse(null); + swtpmDefinition + = Optional.ofNullable(tplData.get(ProcessName.SWTPM)) + .map(d -> new CommandDefinition(ProcessName.SWTPM, d)) + .orElse(null); logger.finest(() -> swtpmDefinition.toString()); - qemuDefinition = Optional.ofNullable(tplData.get(QEMU)) - .map(d -> new CommandDefinition(QEMU, d)).orElse(null); + qemuDefinition = Optional.ofNullable(tplData.get(ProcessName.QEMU)) + .map(d -> new CommandDefinition(ProcessName.QEMU, d)) + .orElse(null); logger.finest(() -> qemuDefinition.toString()); cloudInitImgDefinition - = Optional.ofNullable(tplData.get(CLOUD_INIT_IMG)) - .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) + = Optional.ofNullable(tplData.get(ProcessName.CLOUD_INIT_IMG)) + .map(d -> new CommandDefinition(ProcessName.CLOUD_INIT_IMG, + d)) .orElse(null); logger.finest(() -> cloudInitImgDefinition.toString()); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java new file mode 100644 index 0000000..04110a5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that powers down the guest. + */ +public class QmpGuestPowerdown extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-shutdown"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestPowerdown()"; + } + +}