diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml index f000c70..7518ad3 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml @@ -11,7 +11,7 @@ metadata: annotations: # Triggers update of config map mounted in pod # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion }" + vmrunner.jdrupes.org/cmVersion: "${ configMapResourceVersion }" vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 9c6dc3e..e136a31 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -19,11 +19,17 @@ package org.jdrupes.vmoperator.manager; import com.google.gson.JsonObject; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; import freemarker.template.TemplateException; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.utility.DeepUnwrap; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; @@ -31,7 +37,11 @@ import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.io.StringWriter; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8s; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; @@ -66,48 +76,59 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * * @param model the model * @param channel the channel - * @return the dynamic kubernetes object * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public Map reconcile(Map model, - VmChannel channel) + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + public void reconcile(Map model, VmChannel channel) throws IOException, TemplateException, ApiException { // Combine template and data and parse result + model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 - var mapDef = Dynamics.newFromYaml( + var newCm = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); // Maybe override logging.properties from reconciler configuration. DataPath. get(model, "reconciler", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); // Maybe override logging.properties from VM definition. DataPath. get(model, "cr", "spec", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); - // Get API + // Look for changes + var oldCm = channel + .associated(getClass(), DynamicKubernetesObject.class).orElse(null); + channel.setAssociated(getClass(), newCm); + if (oldCm != null && Objects.equals(oldCm.getRaw().get("data"), + newCm.getRaw().get("data"))) { + logger.finer(() -> "No changes in config map for " + + DataPath. get(model, "cr", "name").get()); + model.put("configMapResourceVersion", + oldCm.getMetadata().getResourceVersion()); + return; + } + + // Get API and update DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", "configmaps", channel.client()); // Apply and maybe force pod update - var newState = K8s.apply(cmApi, mapDef, mapDef.getRaw().toString()); - maybeForceUpdate(channel.client(), newState); - @SuppressWarnings("unchecked") - var res = (Map) channel.client().getJSON().getGson() - .fromJson(newState.getRaw(), Map.class); - return res; + var updatedCm = K8s.apply(cmApi, newCm, newCm.getRaw().toString()); + maybeForceUpdate(channel.client(), updatedCm); + model.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); } /** @@ -153,4 +174,28 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } + private final TemplateMethodModelEx adjustCloudInitMetaModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings("PMD.PreserveStackTrace") + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + @SuppressWarnings("unchecked") + var res = new HashMap<>((Map) DeepUnwrap + .unwrap((TemplateModel) arguments.get(0))); + var metadata + = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) + .getAdaptedObject(Object.class); + if (!res.containsKey("instance-id")) { + res.put("instance-id", + Optional.ofNullable(metadata.getGeneration()) + .map(s -> "v" + s).orElse("v1")); + } + if (!res.containsKey("local-hostname")) { + res.put("local-hostname", metadata.getName()); + } + return res; + } + }; + } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 8df5c88..700e068 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -27,12 +27,9 @@ import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; import freemarker.template.TemplateMethodModelEx; -import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; -import freemarker.template.utility.DeepUnwrap; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.lang.reflect.Modifier; @@ -226,13 +223,12 @@ public class Reconciler extends Component { // Create model for processing templates Map model = prepareModel(channel.client(), event.vmDefinition()); - var configMap = cmReconciler.reconcile(model, channel); + cmReconciler.reconcile(model, channel); // The remaining reconcilers depend only on changes of the spec part. if (!event.specChanged()) { return; } - model.put("cm", configMap); dsReconciler.reconcile(event, model, channel); // Manage (eventual) removal of stateful set. stsReconciler.reconcile(event, model, channel); @@ -279,7 +275,6 @@ public class Reconciler extends Component { model.put("parseQuantity", parseQuantityModel); model.put("formatMemory", formatMemoryModel); model.put("imageLocation", imgageLocationModel); - model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); model.put("toJson", toJsonModel); return model; } @@ -422,30 +417,6 @@ public class Reconciler extends Component { } }; - private final TemplateMethodModelEx adjustCloudInitMetaModel - = new TemplateMethodModelEx() { - @Override - @SuppressWarnings("PMD.PreserveStackTrace") - public Object exec(@SuppressWarnings("rawtypes") List arguments) - throws TemplateModelException { - @SuppressWarnings("unchecked") - var res = new HashMap<>((Map) DeepUnwrap - .unwrap((TemplateModel) arguments.get(0))); - var metadata - = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) - .getAdaptedObject(Object.class); - if (!res.containsKey("instance-id")) { - res.put("instance-id", - Optional.ofNullable(metadata.getResourceVersion()) - .map(s -> "v" + s).orElse("v1")); - } - if (!res.containsKey("local-hostname")) { - res.put("local-hostname", metadata.getName()); - } - return res; - } - }; - private final TemplateMethodModelEx toJsonModel = new TemplateMethodModelEx() { @Override 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()"; + } + +}