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()";
+ }
+
+}