From 1b1e5ffb8c1462073e7f3c878507233d2af27f06 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 14 Mar 2025 21:16:01 +0100 Subject: [PATCH 01/50] Reduce default logging. --- .../org/jdrupes/vmoperator/manager/logging.properties | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties index 9e6d0f5..2a16af6 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties @@ -1,6 +1,6 @@ # # VM-Operator -# Copyright (C) 2023 Michael N. Lipp +# 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 General Public License as published by @@ -19,10 +19,7 @@ handlers=java.util.logging.ConsoleHandler, \ org.jgrapes.webconlet.logviewer.LogViewerHandler -org.jgrapes.level=FINE -org.jgrapes.core.handlerTracking.level=FINER - -org.jdrupes.vmoperator.manager.level=FINE +org.jdrupes.vmoperator.level=FINE java.util.logging.ConsoleHandler.level=ALL java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter From dc228295d177c7f7e2eb0576b593ff352b100ca7 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 15 Mar 2025 11:25:13 +0100 Subject: [PATCH 02/50] Update. --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e9eeb5e..09fcd25 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,23 @@ ![Latest Manager](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=manager*&label=latest) ![Latest Runner](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=runner-qemu*&label=latest) -# Run Qemu in Kubernetes Pods +# Run QEMU/KVM in Kubernetes Pods -The goal of this project is to provide simply to use and flexible components -for running Qemu based VMs in Kubernetes pods. +![Overview picture](webpages/index-pic.svg) + +This project provides an easy to use and flexible solution for running +QEMU/KVM based VMs in Kubernetes pods. + +The central component of this solution is the kubernetes operator that +manages "runners". These run in pods and are used to start and manage +the QEMU/KVM process for the VMs (optionally together with a SW-TPM). + +A web GUI for administrators provides an overview of the VMs together +with some basic control over the VMs. A web GUI for users provides an +interface to access and optionally start, stop and reset the VMs. + +Advanced features of the operator include pooling of VMs and automatic +login. See the [project's home page](https://vm-operator.jdrupes.org/) for details. From 5309460fbf6da606a505d7174e9ff285c12b5df9 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 15 Mar 2025 12:33:20 +0100 Subject: [PATCH 03/50] Prevent update of lastTransitionTime if we have no transition. --- .../vmoperator/runner/qemu/VmDefUpdater.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java index 4c64ff1..49c9e67 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java @@ -125,16 +125,19 @@ public class VmDefUpdater extends Component { protected JsonObject updateCondition(VmDefinition from, String type, boolean state, String reason, String message) { JsonObject status = from.statusJson(); - // Optimize, as we can get this several times + // Avoid redundant updates, as this may be called several times var current = status.getAsJsonArray("conditions").asList().stream() .map(cond -> (JsonObject) cond) .filter(cond -> type.equals(cond.get("type").getAsString())) .findFirst(); - if (current.isPresent() - && current.map(c -> c.get("status").getAsString()) - .map("True"::equals).map(s -> s == state).orElse(false) + var stateUnchanged = current.map(c -> c.get("status").getAsString()) + .map("True"::equals).map(s -> s == state).orElse(false); + if (stateUnchanged && current.map(c -> c.get("reason").getAsString()) - .map(reason::equals).orElse(false)) { + .map(reason::equals).orElse(false) + && current.map(c -> c.get("observedGeneration").getAsLong()) + .map(from.getMetadata().getGeneration()::equals) + .orElse(false)) { return status; } @@ -143,7 +146,9 @@ public class VmDefUpdater extends Component { "status", state ? "True" : "False", "observedGeneration", from.getMetadata().getGeneration(), "reason", reason, - "lastTransitionTime", Instant.now().toString())); + "lastTransitionTime", stateUnchanged + ? current.get().get("lastTransitionTime").getAsString() + : Instant.now().toString())); if (message != null) { condition.put("message", message); } From 017607f2e29ae22b425ccbfc59003c179a51d7f3 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 15 Mar 2025 15:21:44 +0100 Subject: [PATCH 04/50] Prune not required data before transfer. --- .../src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java index 00df484..3d58d09 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -236,7 +236,10 @@ public class VmMgmt extends FreeMarkerConlet { String user, List roles) { // Convert RAM sizes to unitless numbers var spec = DataPath.deepCopy(vmDef.spec()); + spec.remove("cloudInit"); var vmSpec = DataPath.> get(spec, "vm").get(); + vmSpec.remove("networks"); + vmSpec.remove("disks"); vmSpec.put("maximumRam", Quantity.fromString( DataPath. get(vmSpec, "maximumRam").orElse("0")).getNumber() .toBigInteger()); From ce4d0bfb727b9f2e9739c949191d5d34f9a9c1ff Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 14:53:01 +0100 Subject: [PATCH 05/50] More features, more resources. --- deploy/vmop-deployment.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml index 648cc39..4a22d9e 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -36,7 +36,13 @@ spec: resources: requests: cpu: 100m - memory: 128Mi + # The VM operator needs about 25 MB of memory, plus 1 MB for + # each VM. The reason is that for the sake of effeciency, we + # have to keep a parsed representation of the CRD in memory, + # which requires about 512 KB per VM. While handling updates, + # we temporarily have the old and the new version of the CRD + # in memory, so we need another 512 KB per VM. + memory: 256Mi volumes: - name: config configMap: From 227c097c01fbceb00f18103bfbb4cec4c0488c02 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 15:13:16 +0100 Subject: [PATCH 06/50] Actively add pod info, don't run queries. --- .../jdrupes/vmoperator/common/Constants.java | 1 - .../vmoperator/common/VmExtraData.java | 9 ++ .../vmoperator/manager/events/PodChanged.java | 75 ++++++++++ .../vmoperator/manager/events/VmChannel.java | 14 ++ org.jdrupes.vmoperator.manager/.gitignore | 1 + .../vmoperator/manager/Controller.java | 1 + .../vmoperator/manager/PodMonitor.java | 138 ++++++++++++++++++ .../jdrupes/vmoperator/manager/VmMonitor.java | 89 ++++++----- 8 files changed, 287 insertions(+), 41 deletions(-) create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java create mode 100644 org.jdrupes.vmoperator.manager/.gitignore create mode 100644 org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 67939de..b9de69f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -18,7 +18,6 @@ package org.jdrupes.vmoperator.common; -// TODO: Auto-generated Javadoc /** * Some constants. */ diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java index 85913c2..79f262f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java @@ -75,6 +75,15 @@ public class VmExtraData { return nodeName; } + /** + * Gets the node addresses. + * + * @return the nodeAddresses + */ + public List nodeAddresses() { + return nodeAddresses; + } + /** * Sets the reset count. * diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java new file mode 100644 index 0000000..8bbcfe8 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java @@ -0,0 +1,75 @@ +/* + * 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.manager.events; + +import io.kubernetes.client.openapi.models.V1Pod; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates a change in a pod that runs a VM. + */ +public class PodChanged extends Event { + + private final V1Pod pod; + private final K8sObserver.ResponseType type; + + /** + * Instantiates a new VM changed event. + * + * @param pod the pod + * @param type the type + */ + public PodChanged(V1Pod pod, K8sObserver.ResponseType type) { + this.pod = pod; + this.type = type; + } + + /** + * Gets the pod. + * + * @return the pod + */ + public V1Pod pod() { + return pod; + } + + /** + * Returns the type. + * + * @return the type + */ + public K8sObserver.ResponseType type() { + return type; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(pod.getMetadata().getName()).append(' ').append(type); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java index 5ea282a..7b03ad3 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java @@ -21,6 +21,7 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Subchannel.DefaultSubchannel; @@ -104,6 +105,19 @@ public class VmChannel extends DefaultSubchannel { return pipeline; } + /** + * Fire the given event on this channel, using the associated + * {@link #pipeline()}. + * + * @param the generic type + * @param event the event + * @return the t + */ + public > T fire(T event) { + pipeline.fire(event, this); + return event; + } + /** * Returns the API client. * diff --git a/org.jdrupes.vmoperator.manager/.gitignore b/org.jdrupes.vmoperator.manager/.gitignore new file mode 100644 index 0000000..50a6b62 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/.gitignore @@ -0,0 +1 @@ +/logging.properties diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 5d5c592..5531d8d 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -113,6 +113,7 @@ public class Controller extends Component { // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); attach(new Reconciler(channel())); attach(new PoolMonitor(channel())); + attach(new PodMonitor(channel(), chanMgr)); } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java new file mode 100644 index 0000000..9145d9d --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java @@ -0,0 +1,138 @@ +/* + * 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.manager; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.manager.events.ChannelDictionary; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; + +/** + * Watches for changes of pods that run VMs. + */ +public class PodMonitor extends AbstractMonitor { + + private final ChannelDictionary channelDictionary; + + private final Map pendingChanges + = new ConcurrentHashMap<>(); + + /** + * Instantiates a new pod monitor. + * + * @param componentChannel the component channel + * @param channelDictionary the channel dictionary + */ + public PodMonitor(Channel componentChannel, + ChannelDictionary channelDictionary) { + super(componentChannel, V1Pod.class, V1PodList.class); + this.channelDictionary = channelDictionary; + context(K8sV1PodStub.CONTEXT); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + APP_NAME + "," + + "app.kubernetes.io/managed-by=" + VM_OP_NAME); + options(options); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + } + + @Override + protected void handleChange(K8sClient client, Response change) { + String vmName = change.object.getMetadata().getLabels() + .get("app.kubernetes.io/instance"); + if (vmName == null) { + return; + } + var channel = channelDictionary.channel(vmName).orElse(null); + var responseType = ResponseType.valueOf(change.type); + if (channel != null && channel.vmDefinition() != null) { + pendingChanges.remove(vmName); + channel.fire(new PodChanged(change.object, responseType)); + return; + } + + // VM definition not available yet, may happen during startup + if (responseType == ResponseType.DELETED) { + return; + } + purgePendingChanges(); + logger.finer(() -> "Add pending pod change for " + vmName); + pendingChanges.put(vmName, new PendingChange(Instant.now(), change)); + } + + private void purgePendingChanges() { + Instant tooOld = Instant.now().minus(Duration.ofMinutes(15)); + for (var itr = pendingChanges.entrySet().iterator(); itr.hasNext();) { + var change = itr.next(); + if (change.getValue().from().isBefore(tooOld)) { + itr.remove(); + logger.finer( + () -> "Cleaned pending pod change for " + change.getKey()); + } + } + } + + /** + * Check for pending changes. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onVmDefChanged(VmDefChanged event, VmChannel channel) { + Optional.ofNullable(pendingChanges.remove(event.vmDefinition().name())) + .map(PendingChange::change).ifPresent(change -> { + logger.finer(() -> "Firing pending pod change for " + + event.vmDefinition().name()); + channel.fire(new PodChanged(change.object, + ResponseType.valueOf(change.type))); + if (logger.isLoggable(Level.FINER) + && pendingChanges.isEmpty()) { + logger.finer("No pending pod changes left."); + } + }); + } + + private record PendingChange(Instant from, Response change) { + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index e4ee0a0..f729240 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -25,11 +25,12 @@ import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.logging.Level; import java.util.stream.Collectors; import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; @@ -37,7 +38,6 @@ import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; -import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Assignment; @@ -53,6 +53,7 @@ import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.GetVms; import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -140,7 +141,7 @@ public class VmMonitor extends } if (vmDef.data() != null) { // New data, augment and save - addExtraData(channel.client(), vmDef, channel.vmDefinition()); + addExtraData(vmDef, channel.vmDefinition()); channel.setVmDefinition(vmDef); } else { // Reuse cached (e.g. if deleted) @@ -166,7 +167,7 @@ public class VmMonitor extends chgEvt = Event.onCompletion(chgEvt, e -> channelManager.remove(e.vmDefinition().name())); } - channel.pipeline().fire(chgEvt, channel); + channel.fire(chgEvt); } private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { @@ -179,46 +180,56 @@ public class VmMonitor extends } @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private void addExtraData(K8sClient client, VmDefinition vmDef, - VmDefinition prevState) { + private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { var extra = new VmExtraData(vmDef); + var prevExtra + = Optional.ofNullable(prevState).flatMap(VmDefinition::extra); // Maintain (or initialize) the resetCount - extra.resetCount( - Optional.ofNullable(prevState).flatMap(VmDefinition::extra) - .map(VmExtraData::resetCount).orElse(0L)); + extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L)); - // VM definition status changes before the pod terminates. - // This results in pod information being shown for a stopped - // VM which is irritating. So check condition first. - if (!vmDef.conditionStatus("Running").orElse(false)) { + // Maintain node info + prevExtra + .ifPresent(e -> extra.nodeInfo(e.nodeName(), e.nodeAddresses())); + } + + /** + * On pod changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onPodChanged(PodChanged event, VmChannel channel) { + if (channel.vmDefinition().extra().isEmpty()) { return; } - - // Get pod and extract node information. - var podSearch = new ListOptions(); - podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME - + ",app.kubernetes.io/component=" + APP_NAME - + ",app.kubernetes.io/instance=" + vmDef.name()); - try { - var podList - = K8sV1PodStub.list(client, namespace(), podSearch); - for (var podStub : podList) { - var nodeName = podStub.model().get().getSpec().getNodeName(); - logger.finer(() -> "Adding node name " + nodeName - + " to VM info for " + vmDef.name()); - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var addrs = new ArrayList(); - podStub.model().get().getStatus().getPodIPs().stream() - .map(ip -> ip.getIp()).forEach(addrs::add); - logger.finer(() -> "Adding node addresses " + addrs - + " to VM info for " + vmDef.name()); - extra.nodeInfo(nodeName, addrs); + var extra = channel.vmDefinition().extra().get(); + var pod = event.pod(); + if (event.type() == ResponseType.DELETED) { + // The status of a deleted pod is the status before deletion, + // i.e. the node info is still there. + extra.nodeInfo("", Collections.emptyList()); + } else { + var nodeName = Optional + .ofNullable(pod.getSpec().getNodeName()).orElse(""); + logger.finer(() -> "Adding node name " + nodeName + + " to VM info for " + channel.vmDefinition().name()); + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var addrs = new ArrayList(); + Optional.ofNullable(pod.getStatus().getPodIPs()) + .orElse(Collections.emptyList()).stream() + .map(ip -> ip.getIp()).forEach(addrs::add); + logger.finer(() -> "Adding node addresses " + addrs + + " to VM info for " + channel.vmDefinition().name()); + if (Objects.equals(nodeName, extra.nodeName()) + && Objects.equals(addrs, extra.nodeAddresses())) { + return; } - } catch (ApiException e) { - logger.log(Level.WARNING, e, - () -> "Cannot access node information: " + e.getMessage()); + extra.nodeInfo(nodeName, addrs); } + channel.fire(new VmDefChanged(ResponseType.MODIFIED, false, + channel.vmDefinition())); } /** @@ -293,10 +304,8 @@ public class VmMonitor extends // Assign to user var chosenVm = vmQuery.get(); - var vmPipeline = chosenVm.pipeline(); - if (Optional.ofNullable(vmPipeline.fire(new UpdateAssignment( - vmPool, event.toUser()), chosenVm).get()) - .orElse(false)) { + if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment( + vmPool, event.toUser())).get()).orElse(false)) { var vmDef = chosenVm.vmDefinition(); event.setResult(new VmData(vmDef, chosenVm)); From 9bd17e88998d5718cb4f19c15ce6d5fad7c07014 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 15:44:27 +0100 Subject: [PATCH 07/50] Update. --- .../org/jdrupes/vmoperator/manager/console-footer.ftl.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html index 8147dca..72596d5 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html @@ -1,3 +1,3 @@
-Copyright © Michael N. Lipp 2023 +Copyright © Michael N. Lipp 2023, 2025
From fd0f4f8eb2979859fff78821584dcac973eccc99 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 17:01:36 +0100 Subject: [PATCH 08/50] Fetch display secret only when needed. --- .../vmoperator/manager/PodReconciler.java | 22 ++++++++++++++ .../vmoperator/manager/Reconciler.java | 30 +++---------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java index c3ab422..5e9b409 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java @@ -22,12 +22,18 @@ import freemarker.template.Configuration; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.generic.dynamic.Dynamics; +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.Map; import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -92,6 +98,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Create pod. First combine template and data and parse result logger.fine(() -> "Create/update pod " + podStub.name()); + addDisplaySecret(channel.client(), model, vmDef); var fmTemplate = fmConfig.getTemplate("runnerPod.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -110,4 +117,19 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } + private void addDisplaySecret(K8sClient client, Map model, + VmDefinition vmDef) throws ApiException { + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var dsStub = K8sV1SecretStub + .list(client, vmDef.namespace(), options).stream().findFirst(); + if (dsStub.isPresent()) { + dsStub.get().model().ifPresent(m -> { + model.put("displaySecret", m.getMetadata().getName()); + }); + } + } + } 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 77fa281..fddd5c8 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 @@ -30,7 +30,6 @@ import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateModelException; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.lang.reflect.Modifier; import java.math.BigDecimal; @@ -43,12 +42,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.Convertions; -import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmPool; @@ -220,7 +215,7 @@ public class Reconciler extends Component { // Create model for processing templates Map model - = prepareModel(channel.client(), event.vmDefinition()); + = prepareModel(event.vmDefinition()); cmReconciler.reconcile(model, channel); // The remaining reconcilers depend only on changes of the spec part. @@ -251,13 +246,12 @@ public class Reconciler extends Component { var vmDef = channel.vmDefinition(); vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1)); Map model - = prepareModel(channel.client(), channel.vmDefinition()); + = prepareModel(channel.vmDefinition()); cmReconciler.reconcile(model, channel); } - @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity" }) - private Map prepareModel(K8sClient client, - VmDefinition vmDef) throws TemplateModelException, ApiException { + private Map prepareModel(VmDefinition vmDef) + throws TemplateModelException, ApiException { @SuppressWarnings("PMD.UseConcurrentHashMap") Map model = new HashMap<>(); model.put("managerVersion", @@ -267,7 +261,6 @@ public class Reconciler extends Component { model.put("reconciler", config); model.put("constants", constantsMap(Constants.class)); addLoginRequestedFor(model, vmDef); - addDisplaySecret(client, model, vmDef); // Methods model.put("parseQuantity", parseQuantityModel); @@ -325,21 +318,6 @@ public class Reconciler extends Component { .ifPresent(u -> model.put("loginRequestedFor", u)); } - private void addDisplaySecret(K8sClient client, Map model, - VmDefinition vmDef) throws ApiException { - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," - + "app.kubernetes.io/instance=" + vmDef.name()); - var dsStub = K8sV1SecretStub - .list(client, vmDef.namespace(), options).stream().findFirst(); - if (dsStub.isPresent()) { - dsStub.get().model().ifPresent(m -> { - model.put("displaySecret", m.getMetadata().getName()); - }); - } - } - private final TemplateMethodModelEx parseQuantityModel = new TemplateMethodModelEx() { @Override From fe18bb3cdf1decc5e10868084075a0347a66f2ce Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 22:09:20 +0100 Subject: [PATCH 09/50] Consolidate debug messages. --- .../jdrupes/vmoperator/common/K8sObserver.java | 17 ++++++++++++----- .../vmoperator/manager/AbstractMonitor.java | 9 +-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java index 80e3863..a0cd16c 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java @@ -27,6 +27,7 @@ import io.kubernetes.client.util.generic.GenericKubernetesApi; import io.kubernetes.client.util.generic.options.ListOptions; import java.time.Duration; import java.time.Instant; +import java.util.Optional; import java.util.function.BiConsumer; import java.util.logging.Level; import java.util.logging.Logger; @@ -89,10 +90,11 @@ public class K8sObserver { try { - logger - .config(() -> "Watching " + context.getResourcePlural() - + " (" + context.getPreferredVersion() + ")" - + " in " + namespace); + logger.fine(() -> "Observing " + context.getResourcePlural() + + " (" + context.getPreferredVersion() + ")" + + Optional.ofNullable(options.getLabelSelector()) + .map(ls -> " with labels " + ls).orElse("") + + " in " + namespace); // Watch sometimes terminates without apparent reason. while (!Thread.currentThread().isInterrupted()) { @@ -102,7 +104,12 @@ public class K8sObserver "Resource " + + context.getKind() + "/" + + response.object.getMetadata().getName() + + " " + response.type); + handler.accept(client, response); } } catch (ApiException | RuntimeException e) { logger.log(Level.FINE, e, () -> "Problem watching" diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java index 56440f9..eb545a3 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java @@ -199,8 +199,6 @@ public abstract class AbstractMonitor "Observing " + K8s.toString(context) - + " objects in " + namespace); // Monitor all versions for (var version : context.getVersions()) { @@ -219,12 +217,7 @@ public abstract class AbstractMonitor(objectClass, objectListClass, client, K8s.preferred(context, version), namespace, options) - .handler((c, r) -> { - logger.fine(() -> "Resource " + context.getKind() - + "/" + r.object.getMetadata().getName() + " " - + r.type); - handleChange(c, r); - }).onTerminated((o, t) -> { + .handler(this::handleChange).onTerminated((o, t) -> { if (observerCounter.decrementAndGet() == 0) { unregisterAsGenerator(); } From 9644e5fd83f0c6e53d70fa78f7286dcf8d8b1f21 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 22:33:01 +0100 Subject: [PATCH 10/50] Improve debug message. --- .../src/org/jdrupes/vmoperator/common/K8sObserver.java | 1 + 1 file changed, 1 insertion(+) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java index a0cd16c..f3e10bb 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java @@ -113,6 +113,7 @@ public class K8sObserver "Problem watching" + + " resource " + context.getKind() + " (will retry): " + e.getMessage()); delayRestart(startedAt); } From efd489b22ff21302ba442815afb22feb56e95c49 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 22:44:44 +0100 Subject: [PATCH 11/50] Allow VM operator to watch pods. --- deploy/vmop-role.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/vmop-role.yaml b/deploy/vmop-role.yaml index c96fb47..e1ae7bc 100644 --- a/deploy/vmop-role.yaml +++ b/deploy/vmop-role.yaml @@ -38,6 +38,7 @@ rules: - persistentvolumeclaims - pods verbs: + - watch - list - get - create From 5ca45d76202df8b976ec41c1ce72ae26bb6351ec Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Mar 2025 23:12:22 +0100 Subject: [PATCH 12/50] Minor edit. --- deploy/vmop-deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml index 4a22d9e..b1f5075 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -21,12 +21,12 @@ spec: - name: vm-operator image: >- ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + imagePullPolicy: Always volumeMounts: - name: config mountPath: /etc/opt/vmoperator - name: vmop-image-repository mountPath: /var/local/vmop-image-repository - imagePullPolicy: Always securityContext: capabilities: drop: From 3b0a4c8a2314604aadad7af5ae3539ae6f3d8b58 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 17 Mar 2025 16:49:10 +0100 Subject: [PATCH 13/50] Rate limit for RAM size updates. --- .../vmoperator/runner/qemu/StatusUpdater.java | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java index bd4ddb4..17f1915 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -31,6 +31,8 @@ import io.kubernetes.client.openapi.JSON; import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; import java.util.Optional; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; @@ -55,6 +57,8 @@ import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; import org.jdrupes.vmoperator.util.GsonPtr; 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.HandlingError; import org.jgrapes.core.events.Start; @@ -62,7 +66,8 @@ import org.jgrapes.core.events.Start; /** * Updates the CR status. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", + "PMD.CouplingBetweenObjects" }) public class StatusUpdater extends VmDefUpdater { @SuppressWarnings("PMD.FieldNamingConventions") @@ -76,6 +81,10 @@ public class StatusUpdater extends VmDefUpdater { private boolean shutdownByGuest; private VmDefinitionStub vmStub; private String loggedInUser; + private BigInteger lastRamValue; + private Instant lastRamChange; + private Timer balloonTimer; + private BigInteger targetRamValue; /** * Instantiates a new status updater. @@ -151,6 +160,7 @@ public class StatusUpdater extends VmDefUpdater { throws ApiException { guestShutdownStops = event.configuration().guestShutdownStops; loggedInUser = event.configuration().vm.display.loggedInUser; + targetRamValue = event.configuration().vm.currentRam; // Remainder applies only if we have a connection to k8s. if (vmStub == null) { @@ -279,7 +289,11 @@ public class StatusUpdater extends VmDefUpdater { } /** - * On ballon change. + * Update the current RAM size in the status. Balloon changes happen + * more than once every second during changes. While this is nice + * to watch, this puts a heavy load on the system. Therefore we + * only update the status once every 15 seconds or when the target + * value is reached. * * @param event the event * @throws ApiException @@ -289,10 +303,44 @@ public class StatusUpdater extends VmDefUpdater { if (vmStub == null) { return; } + Instant now = Instant.now(); + if (lastRamChange == null + || lastRamChange.isBefore(now.minusSeconds(15)) + || event.size().equals(targetRamValue)) { + if (balloonTimer != null) { + balloonTimer.cancel(); + balloonTimer = null; + } + lastRamChange = now; + lastRamValue = event.size(); + updateRam(lastRamValue); + return; + } + + // Save for later processing and maybe start timer + lastRamChange = now; + lastRamValue = event.size(); + if (balloonTimer != null) { + return; + } + balloonTimer = Components.schedule(t -> { + activeEventPipeline().submit("Update RAM size", () -> { + try { + updateRam(lastRamValue); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Failed to update ram size: " + e.getMessage()); + } + balloonTimer = null; + }); + }, now.plusSeconds(15)); + } + + private void updateRam(BigInteger size) throws ApiException { vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); status.addProperty(Status.RAM, - new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) + new Quantity(new BigDecimal(size), Format.BINARY_SI) .toSuffixedString()); return status; }); From 3686629a2851f4436df3a8ec5c35933ca51f249a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 18 Mar 2025 16:44:15 +0100 Subject: [PATCH 14/50] Fix race condition. --- .../jdrupes/vmoperator/runner/qemu/StatusUpdater.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java index 17f1915..8eaeea8 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -313,7 +313,7 @@ public class StatusUpdater extends VmDefUpdater { } lastRamChange = now; lastRamValue = event.size(); - updateRam(lastRamValue); + updateRam(); return; } @@ -323,10 +323,11 @@ public class StatusUpdater extends VmDefUpdater { if (balloonTimer != null) { return; } + final var pipeline = activeEventPipeline(); balloonTimer = Components.schedule(t -> { - activeEventPipeline().submit("Update RAM size", () -> { + pipeline.submit("Update RAM size", () -> { try { - updateRam(lastRamValue); + updateRam(); } catch (ApiException e) { logger.log(Level.WARNING, e, () -> "Failed to update ram size: " + e.getMessage()); @@ -336,11 +337,11 @@ public class StatusUpdater extends VmDefUpdater { }, now.plusSeconds(15)); } - private void updateRam(BigInteger size) throws ApiException { + private void updateRam() throws ApiException { vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); status.addProperty(Status.RAM, - new Quantity(new BigDecimal(size), Format.BINARY_SI) + new Quantity(new BigDecimal(lastRamValue), Format.BINARY_SI) .toSuffixedString()); return status; }); From 9baf9b7673b2848d3c5983922b231d70ac971040 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 18 Mar 2025 21:48:10 +0100 Subject: [PATCH 15/50] Reset runner info when pod is deleted. --- .../vmoperator/manager/Controller.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 5531d8d..54bf831 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -33,10 +33,12 @@ import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.Exit; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -248,4 +250,28 @@ public class Controller extends Component { } event.setResult(false); } + + /** + * Remove runner version from status when pod is deleted + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onPodChange(PodChanged event, VmChannel channel) + throws ApiException { + if (event.type() == ResponseType.DELETED) { + // Remove runner info from status + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.RUNNER_VERSION); + return status; + }); + } + } } From dbc89e6e090a7d463dfb92ee6f55e8f3bad7e6eb Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 19 Mar 2025 22:57:58 +0100 Subject: [PATCH 16/50] Avoid unnecessary processing. --- .../manager/ConfigMapReconciler.java | 34 +++++++++++-------- .../vmoperator/manager/Reconciler.java | 7 ++-- 2 files changed, 22 insertions(+), 19 deletions(-) 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 e136a31..f133542 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 @@ -76,13 +76,24 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * * @param model the model * @param channel the channel + * @param modelChanged the model has changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception - * @throws ApiException the api exception + * @throws ApiException the API exception */ @SuppressWarnings("PMD.AvoidDuplicateLiterals") - public void reconcile(Map model, VmChannel channel) + public void reconcile(Map model, VmChannel channel, + boolean modelChanged) throws IOException, TemplateException, ApiException { + // Check if an update is needed + Object prevInputs + = channel.associated(PrevData.class, Object.class).orElse(null); + Object newInputs = model.get("loginRequestedFor"); + if (!modelChanged && Objects.equals(prevInputs, newInputs)) { + return; + } + channel.setAssociated(PrevData.class, newInputs); + // Combine template and data and parse result model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); @@ -107,19 +118,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; .get().addProperty("logging.properties", props); }); - // 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()); @@ -131,6 +129,12 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; updatedCm.getMetadata().getResourceVersion()); } + /** + * Key for association. + */ + private final class PrevData { + } + /** * Triggers update of config map mounted in pod * See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ 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 fddd5c8..70f684a 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 @@ -214,9 +214,8 @@ public class Reconciler extends Component { } // Create model for processing templates - Map model - = prepareModel(event.vmDefinition()); - cmReconciler.reconcile(model, channel); + Map model = prepareModel(event.vmDefinition()); + cmReconciler.reconcile(model, channel, event.specChanged()); // The remaining reconcilers depend only on changes of the spec part. if (!event.specChanged()) { @@ -247,7 +246,7 @@ public class Reconciler extends Component { vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1)); Map model = prepareModel(channel.vmDefinition()); - cmReconciler.reconcile(model, channel); + cmReconciler.reconcile(model, channel, true); } private Map prepareModel(VmDefinition vmDef) From 16a15bc9ad1930eceb8a617af93e77e662cc554a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 20 Mar 2025 09:33:10 +0100 Subject: [PATCH 17/50] Document memory allocation. --- deploy/vmop-deployment.yaml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml index b1f5075..08316f6 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -22,6 +22,19 @@ spec: image: >- ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest imagePullPolicy: Always + env: + - name: JAVA_OPTS + # The VM operator needs about 25 MB of memory, plus 1 MB for + # each VM. The reason is that for the sake of effeciency, we + # have to keep a parsed representation of the CRD in memory, + # which requires about 512 KB per VM. While handling updates, + # we temporarily have the old and the new version of the CRD + # in memory, so we need another 512 KB per VM. + value: "-Xmx128m" + resources: + requests: + cpu: 100m + memory: 128Mi volumeMounts: - name: config mountPath: /etc/opt/vmoperator @@ -33,16 +46,6 @@ spec: - ALL readOnlyRootFilesystem: true allowPrivilegeEscalation: false - resources: - requests: - cpu: 100m - # The VM operator needs about 25 MB of memory, plus 1 MB for - # each VM. The reason is that for the sake of effeciency, we - # have to keep a parsed representation of the CRD in memory, - # which requires about 512 KB per VM. While handling updates, - # we temporarily have the old and the new version of the CRD - # in memory, so we need another 512 KB per VM. - memory: 256Mi volumes: - name: config configMap: From 359b1fdb84baf3310eefce7a2ea92d1563a00c3f Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 20 Mar 2025 18:02:14 +0100 Subject: [PATCH 18/50] Clarify pipeline usage. --- .../jdrupes/vmoperator/manager/VmMonitor.java | 3 +-- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 9 +++++---- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 18 ++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index f729240..7470f4e 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -310,8 +310,7 @@ public class VmMonitor extends event.setResult(new VmData(vmDef, chosenVm)); // Make sure that a newly assigned VM is running. - chosenVm.pipeline().fire(new ModifyVm(vmDef.name(), - "state", "Running", chosenVm)); + chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running")); return; } } diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index 91642f1..d3de96e 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -129,6 +129,7 @@ public class VmAccess extends FreeMarkerConlet { private EventPipeline appPipeline; private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private Class preferredIpVersion = Inet4Address.class; private Set syncUsers = Collections.emptySet(); private Set syncRoles = Collections.emptySet(); @@ -785,12 +786,12 @@ public class VmAccess extends FreeMarkerConlet { switch (event.method()) { case "start": if (perms.contains(VmDefinition.Permission.START)) { - fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + vmChannel.fire(new ModifyVm(vmName, "state", "Running")); } break; case "stop": if (perms.contains(VmDefinition.Permission.STOP)) { - fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); } break; case "reset": @@ -800,7 +801,7 @@ public class VmAccess extends FreeMarkerConlet { break; case "resetConfirmed": if (perms.contains(VmDefinition.Permission.RESET)) { - fire(new ResetVm(vmName), vmChannel); + vmChannel.fire(new ResetVm(vmName)); } break; case "openConsole": @@ -838,7 +839,7 @@ public class VmAccess extends FreeMarkerConlet { } var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), e -> gotPassword(channel, model, vmDef, e)); - fire(pwQuery, vmChannel); + vmChannel.fire(pwQuery); } private void gotPassword(ConsoleConnection channel, ResourceModel model, diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java index 3d58d09..f1c5d70 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -423,12 +423,12 @@ public class VmMgmt extends FreeMarkerConlet { switch (event.method()) { case "start": if (perms.contains(VmDefinition.Permission.START)) { - fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + vmChannel.fire(new ModifyVm(vmName, "state", "Running")); } break; case "stop": if (perms.contains(VmDefinition.Permission.STOP)) { - fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); } break; case "reset": @@ -438,22 +438,20 @@ public class VmMgmt extends FreeMarkerConlet { break; case "resetConfirmed": if (perms.contains(VmDefinition.Permission.RESET)) { - fire(new ResetVm(vmName), vmChannel); + vmChannel.fire(new ResetVm(vmName)); } break; case "openConsole": openConsole(channel, model, vmChannel, vmDef, user, perms); break; case "cpus": - fire(new ModifyVm(vmName, "currentCpus", - new BigDecimal(event.param(1).toString()).toBigInteger(), - vmChannel)); + vmChannel.fire(new ModifyVm(vmName, "currentCpus", + new BigDecimal(event.param(1).toString()).toBigInteger())); break; case "ram": - fire(new ModifyVm(vmName, "currentRam", + vmChannel.fire(new ModifyVm(vmName, "currentRam", new Quantity(new BigDecimal(event.param(1).toString()), - Format.BINARY_SI).toSuffixedString(), - vmChannel)); + Format.BINARY_SI).toSuffixedString())); break; default:// ignore break; @@ -488,7 +486,7 @@ public class VmMgmt extends FreeMarkerConlet { } var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), e -> gotPassword(channel, model, vmDef, e)); - fire(pwQuery, vmChannel); + vmChannel.fire(pwQuery); } private void gotPassword(ConsoleConnection channel, VmsModel model, From fe1d56517b763ade0a215c70d717ab6790e67a31 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 20 Mar 2025 18:29:45 +0100 Subject: [PATCH 19/50] Reorganize handlers. --- .../vmoperator/manager/Controller.java | 166 +++++++++++------- .../jdrupes/vmoperator/manager/VmMonitor.java | 158 +++++++---------- 2 files changed, 166 insertions(+), 158 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 54bf831..a2e5aae 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -20,29 +20,33 @@ package org.jdrupes.vmoperator.manager; import com.google.gson.JsonObject; import io.kubernetes.client.apimachinery.GroupVersionKind; -import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.Configuration; import java.io.IOException; -import java.net.HttpURLConnection; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; +import java.util.Comparator; +import java.util.Optional; import java.util.logging.Level; import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.Exit; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -89,6 +93,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; public class Controller extends Component { private String namespace; + private final ChannelManager chanMgr; /** * Creates a new instance. @@ -97,17 +102,16 @@ public class Controller extends Component { public Controller(Channel componentChannel) { super(componentChannel); // Prepare component tree - ChannelManager chanMgr - = new ChannelManager<>(name -> { - try { - return new VmChannel(channel(), newEventPipeline(), - new K8sClient()); - } catch (IOException e) { - logger.log(Level.SEVERE, e, () -> "Failed to create client" - + " for handling changes: " + e.getMessage()); - return null; - } - }); + chanMgr = new ChannelManager<>(name -> { + try { + return new VmChannel(channel(), newEventPipeline(), + new K8sClient()); + } catch (IOException e) { + logger.log(Level.SEVERE, e, () -> "Failed to create client" + + " for handling changes: " + e.getMessage()); + return null; + } + }); attach(new VmMonitor(channel(), chanMgr)); attach(new DisplaySecretMonitor(channel(), chanMgr)); // Currently, we don't use the IP assigned by the load balancer @@ -181,76 +185,102 @@ public class Controller extends Component { } /** - * On modify vm. + * Returns the VM data. * * @param event the event - * @throws ApiException the api exception - * @throws IOException Signals that an I/O exception has occurred. */ @Handler - public void onModifyVm(ModifyVm event, VmChannel channel) - throws ApiException, IOException { - patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), - event.value()); - } - - private void patchVmDef(K8sClient client, String name, String path, - Object value) throws ApiException, IOException { - var vmStub = K8sDynamicStub.get(client, - new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, - name); - - // Patch running - String valueAsText = value instanceof String - ? "\"" + value + "\"" - : value.toString(); - var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, - new V1Patch("[{\"op\": \"replace\", \"path\": \"/" - + path + "\", \"value\": " + valueAsText + "}]"), - client.defaultPatchOptions()); - if (!res.isPresent()) { - logger.warning( - () -> "Cannot patch definition for Vm " + vmStub.name()); - } + public void onGetVms(GetVms event) { + event.setResult(chanMgr.channels().stream() + .filter(c -> event.name().isEmpty() + || c.vmDefinition().name().equals(event.name().get())) + .filter(c -> event.user().isEmpty() && event.roles().isEmpty() + || !c.vmDefinition().permissionsFor(event.user().orElse(null), + event.roles()).isEmpty()) + .filter(c -> event.fromPool().isEmpty() + || c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool().get())).orElse(false)) + .filter(c -> event.toUser().isEmpty() + || c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser().get())).orElse(false)) + .map(c -> new VmData(c.vmDefinition(), c)) + .toList()); } /** - * Attempt to Update the assignment information in the status of the - * VM CR. Returns true if successful. The handler does not attempt - * retries, because in case of failure it will be necessary to - * re-evaluate the chosen VM. + * Assign a VM if not already assigned. * * @param event the event - * @param channel the channel * @throws ApiException the api exception + * @throws InterruptedException */ @Handler - public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) - throws ApiException { - try { - var vmDef = channel.vmDefinition(); - var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), - vmDef.namespace(), vmDef.name()); - if (vmStub.updateStatus(vmDef, from -> { - JsonObject status = from.statusJson(); - var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); - assignment.set("pool", event.fromPool().name()); - assignment.set("user", event.toUser()); - assignment.set("lastUsed", Instant.now().toString()); - return status; - }).isPresent()) { - event.setResult(true); + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onAssignVm(AssignVm event) + throws ApiException, InterruptedException { + while (true) { + // Search for existing assignment. + var vmQuery = chanMgr.channels().stream() + .filter(c -> c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser())).orElse(false)) + .findFirst(); + if (vmQuery.isPresent()) { + var vmDef = vmQuery.get().vmDefinition(); + event.setResult(new VmData(vmDef, vmQuery.get())); + return; } - } catch (ApiException e) { - // Log exceptions except for conflict, which can be expected - if (HttpURLConnection.HTTP_CONFLICT != e.getCode()) { - throw e; + + // Get the pool definition for checking possible assignment + VmPool vmPool = newEventPipeline().fire(new GetPools() + .withName(event.fromPool())).get().stream().findFirst() + .orElse(null); + if (vmPool == null) { + return; + } + + // Find available VM. + vmQuery = chanMgr.channels().stream() + .filter(c -> vmPool.isAssignable(c.vmDefinition())) + .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() + .assignment().map(Assignment::lastUsed) + .orElse(Instant.ofEpochSecond(0))) + .thenComparing(preferRunning)) + .findFirst(); + + // None found + if (vmQuery.isEmpty()) { + return; + } + + // Assign to user + var chosenVm = vmQuery.get(); + if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment( + vmPool, event.toUser())).get()).orElse(false)) { + var vmDef = chosenVm.vmDefinition(); + event.setResult(new VmData(vmDef, chosenVm)); + + // Make sure that a newly assigned VM is running. + chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running")); + return; } } - event.setResult(false); } + private static Comparator preferRunning + = new Comparator<>() { + @Override + public int compare(VmChannel ch1, VmChannel ch2) { + if (ch1.vmDefinition().conditionStatus("Running").orElse(false) + && !ch2.vmDefinition().conditionStatus("Running") + .orElse(false)) { + return -1; + } + return 0; + } + }; + /** * Remove runner version from status when pod is deleted * diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 7470f4e..9e6e5c9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -18,21 +18,25 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import java.net.HttpURLConnection; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; @@ -40,23 +44,18 @@ import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; import org.jdrupes.vmoperator.common.VmDefinition; -import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitions; import org.jdrupes.vmoperator.common.VmExtraData; -import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; -import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; -import org.jdrupes.vmoperator.manager.events.GetPools; -import org.jdrupes.vmoperator.manager.events.GetVms; -import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; import org.jgrapes.core.annotation.Handler; @@ -233,100 +232,79 @@ public class VmMonitor extends } /** - * Returns the VM data. - * - * @param event the event - */ - @Handler - public void onGetVms(GetVms event) { - event.setResult(channelManager.channels().stream() - .filter(c -> event.name().isEmpty() - || c.vmDefinition().name().equals(event.name().get())) - .filter(c -> event.user().isEmpty() && event.roles().isEmpty() - || !c.vmDefinition().permissionsFor(event.user().orElse(null), - event.roles()).isEmpty()) - .filter(c -> event.fromPool().isEmpty() - || c.vmDefinition().assignment().map(Assignment::pool) - .map(p -> p.equals(event.fromPool().get())).orElse(false)) - .filter(c -> event.toUser().isEmpty() - || c.vmDefinition().assignment().map(Assignment::user) - .map(u -> u.equals(event.toUser().get())).orElse(false)) - .map(c -> new VmData(c.vmDefinition(), c)) - .toList()); - } - - /** - * Assign a VM if not already assigned. + * On modify vm. * * @param event the event * @throws ApiException the api exception - * @throws InterruptedException + * @throws IOException Signals that an I/O exception has occurred. */ @Handler - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - public void onAssignVm(AssignVm event) - throws ApiException, InterruptedException { - while (true) { - // Search for existing assignment. - var vmQuery = channelManager.channels().stream() - .filter(c -> c.vmDefinition().assignment().map(Assignment::pool) - .map(p -> p.equals(event.fromPool())).orElse(false)) - .filter(c -> c.vmDefinition().assignment().map(Assignment::user) - .map(u -> u.equals(event.toUser())).orElse(false)) - .findFirst(); - if (vmQuery.isPresent()) { - var vmDef = vmQuery.get().vmDefinition(); - event.setResult(new VmData(vmDef, vmQuery.get())); - return; - } + public void onModifyVm(ModifyVm event, VmChannel channel) + throws ApiException, IOException { + patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), + event.value()); + } - // Get the pool definition for checking possible assignment - VmPool vmPool = newEventPipeline().fire(new GetPools() - .withName(event.fromPool())).get().stream().findFirst() - .orElse(null); - if (vmPool == null) { - return; - } + private void patchVmDef(K8sClient client, String name, String path, + Object value) throws ApiException, IOException { + var vmStub = K8sDynamicStub.get(client, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace(), + name); - // Find available VM. - vmQuery = channelManager.channels().stream() - .filter(c -> vmPool.isAssignable(c.vmDefinition())) - .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() - .assignment().map(Assignment::lastUsed) - .orElse(Instant.ofEpochSecond(0))) - .thenComparing(preferRunning)) - .findFirst(); - - // None found - if (vmQuery.isEmpty()) { - return; - } - - // Assign to user - var chosenVm = vmQuery.get(); - if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment( - vmPool, event.toUser())).get()).orElse(false)) { - var vmDef = chosenVm.vmDefinition(); - event.setResult(new VmData(vmDef, chosenVm)); - - // Make sure that a newly assigned VM is running. - chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running")); - return; - } + // Patch running + String valueAsText = value instanceof String + ? "\"" + value + "\"" + : value.toString(); + var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/" + + path + "\", \"value\": " + valueAsText + "}]"), + client.defaultPatchOptions()); + if (!res.isPresent()) { + logger.warning( + () -> "Cannot patch definition for Vm " + vmStub.name()); } } - private static Comparator preferRunning - = new Comparator<>() { - @Override - public int compare(VmChannel ch1, VmChannel ch2) { - if (ch1.vmDefinition().conditionStatus("Running").orElse(false) - && !ch2.vmDefinition().conditionStatus("Running") - .orElse(false)) { - return -1; + /** + * Attempt to Update the assignment information in the status of the + * VM CR. Returns true if successful. The handler does not attempt + * retries, because in case of failure it will be necessary to + * re-evaluate the chosen VM. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) + throws ApiException { + try { + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + if (vmStub.updateStatus(vmDef, from -> { + JsonObject status = from.statusJson(); + if (event.toUser() == null) { + ((JsonObject) GsonPtr.to(status).get()) + .remove(Status.ASSIGNMENT); + } else { + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); + assignment.set("pool", event.fromPool().name()); + assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); } - return 0; + return status; + }).isPresent()) { + event.setResult(true); } - }; + } catch (ApiException e) { + // Log exceptions except for conflict, which can be expected + if (HttpURLConnection.HTTP_CONFLICT != e.getCode()) { + throw e; + } + } + event.setResult(false); + } } From d9799df8610551e165bf2818afe1d570af02236e Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 20 Mar 2025 18:46:45 +0100 Subject: [PATCH 20/50] Delete assignments when pool is deleted. --- .../vmoperator/manager/Controller.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index a2e5aae..ed404c0 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -47,6 +47,7 @@ import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -281,6 +282,25 @@ public class Controller extends Component { } }; + /** + * When s pool is deleted, remove all related assignments. + * + * @param event the event + * @throws InterruptedException + */ + @Handler + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onPoolChanged(VmPoolChanged event) throws InterruptedException { + if (!event.deleted()) { + return; + } + var vms = newEventPipeline() + .fire(new GetVms().assignedFrom(event.vmPool().name())).get(); + for (var vm : vms) { + vm.channel().fire(new UpdateAssignment(event.vmPool(), null)); + } + } + /** * Remove runner version from status when pod is deleted * From 331b6d8d61e7056242e765fce64b2e590d3b6a76 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 21 Mar 2025 09:18:38 +0100 Subject: [PATCH 21/50] Minor edit. --- webpages/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webpages/index.md b/webpages/index.md index 3c7101e..34a3c99 100644 --- a/webpages/index.md +++ b/webpages/index.md @@ -11,8 +11,8 @@ layout: vm-operator ![Overview picture](index-pic.svg) -This project provides an easy to use and flexible solution -for running QEMU/KVM based VMs in Kubernetes pods. +This project provides an easy to use and flexible solution for +running QEMU/KVM based virtual machines (VMs) in Kubernetes pods. The image used for the VM pods combines QEMU and a control program for starting and managing the QEMU process. This application is called From 4bcbafb4f19abc7962245ba0a92c15476018e2d2 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 22 Mar 2025 11:25:02 +0100 Subject: [PATCH 22/50] Improve label. --- .../org/jdrupes/vmoperator/vmmgmt/l10n.properties | 12 ++++++------ .../org/jdrupes/vmoperator/vmmgmt/l10n_de.properties | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties index ef55662..95cb839 100644 --- a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties @@ -3,15 +3,15 @@ conletName = VM Management VMsSummary = VMs (running/total) assignedTo = Assigned to -currentCpus = Current CPUs -currentRam = Current RAM +currentCpus = Current vCPUs +currentRam = Current vRAM guestOs = Guest OS -maximumCpus = Maximum CPUs -maximumRam = Maximum RAM +maximumCpus = Maximum vCPUs +maximumRam = Maximum vRAM notInUse = Currently closed nodeName = Node -requestedCpus = Requested CPUs -requestedRam = Requested RAM +requestedCpus = Requested vCPUs +requestedRam = Requested vRAM runnerVersion = Runner version running = Running since = Since diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties index b8ece92..abe0d46 100644 --- a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties @@ -7,15 +7,15 @@ Last\ hour = Letzte Stunde Last\ day = Letzter Tag assignedTo = Zugewiesen an -currentCpus = Aktuelle CPUs -currentRam = Akuelles RAM +currentCpus = Aktuelle vCPUs +currentRam = Akuelles vRAM guestOs = Gast BS -maximumCpus = Maximale CPUs -maximumRam = Maximales RAM +maximumCpus = Maximale vCPUs +maximumRam = Maximales vRAM nodeName = Knoten notInUse = Derzeit geschlossen -requestedCpus = Angeforderte CPUs -requestedRam = Angefordertes RAM +requestedCpus = Angeforderte vCPUs +requestedRam = Angefordertes vRAM runnerVersion = Runner-Version running = Gestartet since = Seit From 3143a1be93eb0a76ff7d8d30bb2666fb2d11a407 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Mar 2025 15:04:39 +0100 Subject: [PATCH 23/50] Remove no longer valid optimization. --- .../vmoperator/runner/qemu/StatusUpdater.java | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java index 8eaeea8..b5d02c2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -76,7 +76,6 @@ public class StatusUpdater extends VmDefUpdater { private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); - private long observedGeneration; private boolean guestShutdownStops; private boolean shutdownByGuest; private VmDefinitionStub vmStub; @@ -131,7 +130,6 @@ public class StatusUpdater extends VmDefUpdater { if (vmDef == null) { return; } - observedGeneration = vmDef.getMetadata().getGeneration(); vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); status.addProperty(Status.RUNNER_VERSION, Optional.ofNullable( @@ -166,21 +164,6 @@ public class StatusUpdater extends VmDefUpdater { if (vmStub == null) { return; } - - // A change of the runner configuration is typically caused - // by a new version of the CR. So we update only if we have - // a new version of the CR. There's one exception: the display - // password is configured by a file, not by the CR. - var vmDef = vmStub.model().orElse(null); - if (vmDef == null) { - return; - } - if (vmDef.metadata().getGeneration() == observedGeneration - && (event.configuration().hasDisplayPassword - || vmDef.statusJson().getAsJsonPrimitive( - Status.DISPLAY_PASSWORD_SERIAL).getAsInt() == -1)) { - return; - } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { @@ -194,7 +177,7 @@ public class StatusUpdater extends VmDefUpdater { from.getMetadata().getGeneration())); updateUserLoggedIn(from); return status; - }, vmDef); + }); } /** From e8097d87d9194c2d23a1b9fe91f90d2d7a59f50a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 28 Mar 2025 18:03:09 +0100 Subject: [PATCH 24/50] Let the operator manage pod restarts. --- .../vmoperator/manager/runnerPod.ftl.yaml | 1 + .../manager/ConfigMapReconciler.java | 16 ++++-- .../manager/DisplaySecretReconciler.java | 9 ++-- .../manager/LoadBalancerReconciler.java | 6 +-- .../vmoperator/manager/PodReconciler.java | 6 +-- .../vmoperator/manager/PvcReconciler.java | 19 +++---- .../vmoperator/manager/Reconciler.java | 51 ++++++++++++++++--- .../manager/StatefulSetReconciler.java | 10 ++-- 8 files changed, 77 insertions(+), 41 deletions(-) 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 7518ad3..2f082ab 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 @@ -133,3 +133,4 @@ spec: affinity: ${ toJson(spec.affinity) } serviceAccountName: vm-runner + restartPolicy: Never 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 f133542..161678a 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 @@ -86,16 +86,20 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; boolean modelChanged) throws IOException, TemplateException, ApiException { // Check if an update is needed - Object prevInputs - = channel.associated(PrevData.class, Object.class).orElse(null); + var prevData = channel.associated(PrevData.class) + .orElseGet(() -> new PrevData(null, new HashMap<>())); Object newInputs = model.get("loginRequestedFor"); - if (!modelChanged && Objects.equals(prevInputs, newInputs)) { + if (!modelChanged && Objects.equals(prevData.inputs, newInputs)) { + // Make added data available in new model + model.putAll(prevData.added); return; } - channel.setAssociated(PrevData.class, newInputs); + prevData = new PrevData(newInputs, prevData.added); + channel.setAssociated(PrevData.class, prevData); // Combine template and data and parse result model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); + prevData.added.put("adjustCloudInitMeta", adjustCloudInitMetaModel); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -127,12 +131,14 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; maybeForceUpdate(channel.client(), updatedCm); model.put("configMapResourceVersion", updatedCm.getMetadata().getResourceVersion()); + prevData.added.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); } /** * Key for association. */ - private final class PrevData { + private record PrevData(Object inputs, Map added) { } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index dabffb6..2770347 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -120,25 +120,24 @@ public class DisplaySecretReconciler extends Component { * secret with a random password and immediate expiration, thus * preventing access to the display. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, - Map model, VmChannel channel) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel) throws IOException, TemplateException, ApiException { // Secret needed at all? - var display = event.vmDefinition().fromVm("display").get(); + var display = vmDef.fromVm("display").get(); if (!DataPath. get(display, "spice", "generateSecret") .orElse(true)) { return; } // Check if exists - var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java index 2d632b9..16af2c8 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -36,7 +36,6 @@ import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8sV1ServiceStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; @@ -69,14 +68,14 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, + public void reconcile(VmDefinition vmDef, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { // Check if to be generated @@ -95,7 +94,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Load balancer can also be turned off for VM - var vmDef = event.vmDefinition(); if (vmDef .>> fromSpec(LOAD_BALANCER_SERVICE) .map(m -> m.isEmpty()).orElse(false)) { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java index 5e9b409..acda24e 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java @@ -36,7 +36,6 @@ import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -62,14 +61,14 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile the pod. * - * @param event the event + * @param vmDef the vm def * @param model the model * @param channel the channel * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, Map model, + public void reconcile(VmDefinition vmDef, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { // Don't do anything if stateful set is still in use (pre v3.4) @@ -78,7 +77,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Get pod stub. - var vmDef = event.vmDefinition(); var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), vmDef.name()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java index 34085f0..107dca7 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -38,8 +38,8 @@ import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; @@ -67,7 +67,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile the PVCs. * - * @param event the event + * @param vmDef the vm def * @param model the model * @param channel the channel * @throws IOException Signals that an I/O exception has occurred. @@ -75,11 +75,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * @throws ApiException the api exception */ @SuppressWarnings("PMD.AvoidDuplicateLiterals") - public void reconcile(VmDefChanged event, Map model, + public void reconcile(VmDefinition vmDef, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { - var vmDef = event.vmDefinition(); - // Existing disks ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( @@ -92,7 +90,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; .collect(Collectors.toSet()); // Reconcile runner data pvc - reconcileRunnerDataPvc(event, model, channel, knownPvcs); + reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs); // Reconcile pvcs for defined disks var diskDefs = vmDef.>> fromVm("disks") @@ -117,17 +115,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Update PVC model.put("disk", diskDef); - reconcileRunnerDiskPvc(event, model, channel); + reconcileRunnerDiskPvc(vmDef, model, channel); } model.remove("disk"); } - private void reconcileRunnerDataPvc(VmDefChanged event, + private void reconcileRunnerDataPvc(VmDefinition vmDef, Map model, VmChannel channel, Set knownPvcs) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var vmDef = event.vmDefinition(); // Look for old (sts generated) name. var stsRunnerDataPvcName @@ -161,12 +158,10 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } - private void reconcileRunnerDiskPvc(VmDefChanged event, + private void reconcileRunnerDiskPvc(VmDefinition vmDef, Map model, VmChannel channel) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var vmDef = event.vmDefinition(); - // Generate PVC @SuppressWarnings("unchecked") var diskDef = (Map) model.get("disk"); 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 70f684a..170263e 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 @@ -44,10 +44,12 @@ import java.util.Optional; import java.util.logging.Level; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -213,20 +215,57 @@ public class Reconciler extends Component { return; } + // Reconcile + reconcile(event, channel); + } + + private void reconcile(VmDefChanged event, VmChannel channel) + throws TemplateModelException, ApiException, IOException, + TemplateException { // Create model for processing templates - Map model = prepareModel(event.vmDefinition()); + var vmDef = event.vmDefinition(); + Map model = prepareModel(vmDef); cmReconciler.reconcile(model, channel, event.specChanged()); // The remaining reconcilers depend only on changes of the spec part. if (!event.specChanged()) { return; } - dsReconciler.reconcile(event, model, channel); + dsReconciler.reconcile(vmDef, model, channel); // Manage (eventual) removal of stateful set. - stsReconciler.reconcile(event, model, channel); - pvcReconciler.reconcile(event, model, channel); - podReconciler.reconcile(event, model, channel); - lbReconciler.reconcile(event, model, channel); + stsReconciler.reconcile(vmDef, model, channel); + pvcReconciler.reconcile(vmDef, model, channel); + podReconciler.reconcile(vmDef, model, channel); + lbReconciler.reconcile(vmDef, model, channel); + } + + /** + * On pod changed. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + */ + @Handler + public void onPodChanged(PodChanged event, VmChannel channel) + throws ApiException, IOException, TemplateException { + if (event.type() != ResponseType.DELETED) { + // Nothing to reconcile + return; + } + + // If the pod was deleted, it may be necessary to recreate it + var vmDef = channel.vmDefinition(); + Map model = prepareModel(vmDef); + + // Call all steps because they may augment the model + cmReconciler.reconcile(model, channel, false); + dsReconciler.reconcile(vmDef, model, channel); + stsReconciler.reconcile(vmDef, model, channel); + pvcReconciler.reconcile(vmDef, model, channel); + podReconciler.reconcile(vmDef, model, channel); } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java index 8803e61..854f630 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java @@ -27,9 +27,9 @@ import java.io.IOException; import java.util.Map; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; /** * Before version 3.4, the pod running the VM was created by a stateful set. @@ -54,7 +54,7 @@ import org.jdrupes.vmoperator.manager.events.VmDefChanged; /** * Reconcile stateful set. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel * @throws IOException Signals that an I/O exception has occurred. @@ -62,14 +62,14 @@ import org.jdrupes.vmoperator.manager.events.VmDefChanged; * @throws ApiException the api exception */ @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - public void reconcile(VmDefChanged event, Map model, + public void reconcile(VmDefinition vmDef, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { model.put("usingSts", false); // If exists, delete when not running or supposed to be not running. var stsStub = K8sV1StatefulSetStub.get(channel.client(), - event.vmDefinition().namespace(), event.vmDefinition().name()); + vmDef.namespace(), vmDef.name()); if (stsStub.model().isEmpty()) { return; } @@ -88,7 +88,7 @@ import org.jdrupes.vmoperator.manager.events.VmDefChanged; // Check if VM is supposed to be stopped. If so, // set replicas to 0. This is the first step of the transition, // the stateful set will be deleted when the VM is restarted. - if (event.vmDefinition().vmState() == RequestedVmState.RUNNING) { + if (vmDef.vmState() == RequestedVmState.RUNNING) { return; } From db49f5ba2fe56f10386c834371da4e955b168668 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 28 Mar 2025 21:28:55 +0100 Subject: [PATCH 25/50] Restart non-deleted pods. --- .../resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml | 1 - 1 file changed, 1 deletion(-) 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 2f082ab..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 @@ -133,4 +133,3 @@ spec: affinity: ${ toJson(spec.affinity) } serviceAccountName: vm-runner - restartPolicy: Never From 991763f228dd0d77ff84c80d50b92583348e10dd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 29 Mar 2025 15:09:38 +0100 Subject: [PATCH 26/50] Optimize state change handling. --- .../vmoperator/common/VmDefinition.java | 4 +- .../vmoperator/common/VmExtraData.java | 4 +- .../org/jdrupes/vmoperator/common/VmPool.java | 2 +- ...DefChanged.java => VmResourceChanged.java} | 43 ++++--- .../vmoperator/manager/runnerConfig.ftl.yaml | 2 +- .../vmoperator/manager/Controller.java | 6 +- .../manager/DisplaySecretReconciler.java | 12 +- .../manager/LoadBalancerReconciler.java | 10 +- .../vmoperator/manager/PodMonitor.java | 5 +- .../vmoperator/manager/PodReconciler.java | 8 +- .../vmoperator/manager/PoolMonitor.java | 5 +- .../vmoperator/manager/PvcReconciler.java | 59 ++++++---- .../vmoperator/manager/Reconciler.java | 62 ++-------- .../manager/StatefulSetReconciler.java | 107 ------------------ .../jdrupes/vmoperator/manager/VmMonitor.java | 62 +++++----- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 8 +- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 11 +- webpages/upgrading.md | 4 + 18 files changed, 152 insertions(+), 262 deletions(-) rename org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/{VmDefChanged.java => VmResourceChanged.java} (75%) delete mode 100644 org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java index f763c47..0a25dd6 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -300,8 +300,8 @@ public class VmDefinition extends K8sDynamicModel { * * @return the data */ - public Optional extra() { - return Optional.ofNullable(extraData); + public VmExtraData extra() { + return extraData; } /** diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java index 79f262f..83d9577 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java @@ -112,7 +112,7 @@ public class VmExtraData { * @param deleteConnectionFile the delete connection file * @return the string */ - public String connectionFile(String password, + public Optional connectionFile(String password, Class preferredIpVersion, boolean deleteConnectionFile) { var addr = displayIp(preferredIpVersion); if (addr.isEmpty()) { @@ -144,7 +144,7 @@ public class VmExtraData { if (deleteConnectionFile) { data.append("delete-this-file=1\n"); } - return data.toString(); + return Optional.of(data.toString()); } private Optional displayIp(Class preferredIpVersion) { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java index 7c13ddb..2bf1c30 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java @@ -177,7 +177,7 @@ public class VmPool { } // Additional check in case lastUsed has not been updated - // by PoolMonitor#onVmDefChanged() yet ("race condition") + // by PoolMonitor#onVmResourceChanged() yet ("race condition") if (vmDef.condition("ConsoleConnected") .map(cc -> cc.getLastTransitionTime().toInstant()) .map(this::retainUntil) diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java similarity index 75% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java index a8873cf..eac30fb 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java @@ -25,31 +25,35 @@ import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** - * Indicates a change in a VM definition. Note that the definition - * consists of the metadata (mostly immutable), the "spec" and the - * "status" parts. Consumers that are only interested in "spec" - * changes should check {@link #specChanged()} before processing - * the event any further. + * Indicates a change in a VM "resource". Note that the resource + * combines the VM CR's metadata (mostly immutable), the VM CR's + * "spec" part, the VM CR's "status" subresource and state information + * from the pod. Consumers that are only interested in "spec" changes + * should check {@link #specChanged()} before processing the event any + * further. */ @SuppressWarnings("PMD.DataClass") -public class VmDefChanged extends Event { +public class VmResourceChanged extends Event { private final K8sObserver.ResponseType type; - private final boolean specChanged; private final VmDefinition vmDefinition; + private final boolean specChanged; + private final boolean podChanged; /** * Instantiates a new VM changed event. * * @param type the type - * @param specChanged the spec part changed * @param vmDefinition the VM definition + * @param specChanged the spec part changed */ - public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged, - VmDefinition vmDefinition) { + public VmResourceChanged(K8sObserver.ResponseType type, + VmDefinition vmDefinition, boolean specChanged, + boolean podChanged) { this.type = type; - this.specChanged = specChanged; this.vmDefinition = vmDefinition; + this.specChanged = specChanged; + this.podChanged = podChanged; } /** @@ -61,6 +65,15 @@ public class VmDefChanged extends Event { return type; } + /** + * Return the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDefinition; + } + /** * Indicates if the "spec" part changed. */ @@ -69,12 +82,10 @@ public class VmDefChanged extends Event { } /** - * Return the VM definition. - * - * @return the VM definition + * Indicates if the pod status changed. */ - public VmDefinition vmDefinition() { - return vmDefinition; + public boolean podChanged() { + return podChanged; } @Override diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml index 2a59a2c..5dc8d9b 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml @@ -53,7 +53,7 @@ data: # i.e. if you start the VM without a value for this property, and # decide to trigger a reset later, you have to first set the value # and then inrement it. - resetCounter: ${ cr.extra().get().resetCount()?c } + resetCounter: ${ cr.extra().resetCount()?c } # Forward the cloud-init data if provided <#if spec.cloudInit??> diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index ed404c0..eaf2447 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 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 @@ -46,8 +46,8 @@ import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -61,7 +61,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; * * The implementation splits the controller in two components. The * {@link VmMonitor} and the {@link Reconciler}. The former watches - * the VM definitions (CRs) and generates {@link VmDefChanged} events + * the VM definitions (CRs) and generates {@link VmResourceChanged} events * when they change. The latter handles the changes and reconciles the * resources in the cluster. * diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index 2770347..5111438 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -45,7 +45,7 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.CompletionLock; @@ -123,13 +123,19 @@ public class DisplaySecretReconciler extends Component { * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ public void reconcile(VmDefinition vmDef, Map model, - VmChannel channel) + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { + // Nothing to do unless spec changed + if (!specChanged) { + return; + } + // Secret needed at all? var display = vmDef.fromVm("display").get(); if (!DataPath. get(display, "spice", "generateSecret") @@ -292,7 +298,7 @@ public class DisplaySecretReconciler extends Component { */ @Handler @SuppressWarnings("PMD.AvoidSynchronizedStatement") - public void onVmDefChanged(VmDefChanged event, Channel channel) { + public void onVmResourceChanged(VmResourceChanged event, Channel channel) { synchronized (pendingPrepares) { String vmName = event.vmDefinition().name(); for (var pending : pendingPrepares) { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java index 16af2c8..c0d183b 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -71,13 +71,19 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefinition vmDef, - Map model, VmChannel channel) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { + // Nothing to do unless spec changed + if (!specChanged) { + return; + } + // Check if to be generated @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" }) var lbsDef = Optional.of(model) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java index 9145d9d..cfb49e5 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java @@ -38,7 +38,7 @@ import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.annotation.Handler; @@ -118,7 +118,8 @@ public class PodMonitor extends AbstractMonitor { * @param channel the channel */ @Handler - public void onVmDefChanged(VmDefChanged event, VmChannel channel) { + public void onVmResourceChanged(VmResourceChanged event, + VmChannel channel) { Optional.ofNullable(pendingChanges.remove(event.vmDefinition().name())) .map(PendingChange::change).ifPresent(change -> { logger.finer(() -> "Firing pending pod change for " diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java index acda24e..4b7f394 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java @@ -64,18 +64,14 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * @param vmDef the vm def * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ public void reconcile(VmDefinition vmDef, Map model, - VmChannel channel) + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { - // Don't do anything if stateful set is still in use (pre v3.4) - if ((Boolean) model.get("usingSts")) { - return; - } - // Get pod stub. var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), vmDef.name()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index 5d85280..1bc323c 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -40,8 +40,8 @@ import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.GetPools; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.EventPipeline; @@ -142,7 +142,8 @@ public class PoolMonitor extends * @throws ApiException */ @Handler - public void onVmDefChanged(VmDefChanged event) throws ApiException { + public void onVmResourceChanged(VmResourceChanged event) + throws ApiException { final var vmDef = event.vmDefinition(); final String vmName = vmDef.name(); switch (event.type()) { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java index 107dca7..47aa8be 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -67,30 +67,35 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile the PVCs. * - * @param vmDef the vm def + * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - @SuppressWarnings("PMD.AvoidDuplicateLiterals") + @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" }) public void reconcile(VmDefinition vmDef, Map model, - VmChannel channel) + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { - // Existing disks - ListOptions listOpts = new ListOptions(); - listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/instance=" + vmDef.name()); - var knownDisks = K8sV1PvcStub.list(channel.client(), - vmDef.namespace(), listOpts); - var knownPvcs = knownDisks.stream().map(K8sV1PvcStub::name) - .collect(Collectors.toSet()); + Set knownPvcs; + if (!specChanged && channel.associated(this, Set.class).isPresent()) { + knownPvcs = (Set) channel.associated(this, Set.class).get(); + } else { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + knownPvcs = K8sV1PvcStub.list(channel.client(), + vmDef.namespace(), listOpts).stream().map(K8sV1PvcStub::name) + .collect(Collectors.toSet()); + channel.setAssociated(this, knownPvcs); + } // Reconcile runner data pvc - reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs); + reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs, specChanged); // Reconcile pvcs for defined disks var diskDefs = vmDef.>> fromVm("disks") @@ -114,15 +119,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Update PVC - model.put("disk", diskDef); - reconcileRunnerDiskPvc(vmDef, model, channel); + reconcileRunnerDiskPvc(vmDef, model, channel, specChanged, diskDef); } - model.remove("disk"); } private void reconcileRunnerDataPvc(VmDefinition vmDef, Map model, VmChannel channel, - Set knownPvcs) + Set knownPvcs, boolean specChanged) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { @@ -135,7 +138,12 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Generate PVC - model.put("runnerDataPvcName", vmDef.name() + "-runner-data"); + var runnerDataPvcName = vmDef.name() + "-runner-data"; + model.put("runnerDataPvcName", runnerDataPvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -159,17 +167,24 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } private void reconcileRunnerDiskPvc(VmDefinition vmDef, - Map model, VmChannel channel) + Map model, VmChannel channel, boolean specChanged, + Map diskDef) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { // Generate PVC - @SuppressWarnings("unchecked") - var diskDef = (Map) model.get("disk"); var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName"); diskDef.put("generatedPvcName", pvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } + + // Generate PVC + model.put("disk", diskDef); var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); + model.remove("disk"); // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 var pvcDef = Dynamics.newFromYaml( 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 170263e..700b081 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 @@ -44,15 +44,13 @@ import java.util.Optional; import java.util.logging.Level; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.GetPools; -import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -151,7 +149,6 @@ public class Reconciler extends Component { private final Configuration fmConfig; private final ConfigMapReconciler cmReconciler; private final DisplaySecretReconciler dsReconciler; - private final StatefulSetReconciler stsReconciler; private final PvcReconciler pvcReconciler; private final PodReconciler podReconciler; private final LoadBalancerReconciler lbReconciler; @@ -179,7 +176,6 @@ public class Reconciler extends Component { cmReconciler = new ConfigMapReconciler(fmConfig); dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); - stsReconciler = new StatefulSetReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig); lbReconciler = new LoadBalancerReconciler(fmConfig); @@ -208,64 +204,27 @@ public class Reconciler extends Component { */ @Handler @SuppressWarnings("PMD.ConfusingTernary") - public void onVmDefChanged(VmDefChanged event, VmChannel channel) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) throws ApiException, TemplateException, IOException { // Ownership relationships takes care of deletions if (event.type() == K8sObserver.ResponseType.DELETED) { return; } - // Reconcile - reconcile(event, channel); - } - - private void reconcile(VmDefChanged event, VmChannel channel) - throws TemplateModelException, ApiException, IOException, - TemplateException { // Create model for processing templates var vmDef = event.vmDefinition(); Map model = prepareModel(vmDef); cmReconciler.reconcile(model, channel, event.specChanged()); - // The remaining reconcilers depend only on changes of the spec part. - if (!event.specChanged()) { + // The remaining reconcilers depend only on changes of the spec part + // or the pod state. + if (!event.specChanged() && !event.podChanged()) { return; } - dsReconciler.reconcile(vmDef, model, channel); - // Manage (eventual) removal of stateful set. - stsReconciler.reconcile(vmDef, model, channel); - pvcReconciler.reconcile(vmDef, model, channel); - podReconciler.reconcile(vmDef, model, channel); - lbReconciler.reconcile(vmDef, model, channel); - } - - /** - * On pod changed. - * - * @param event the event - * @param channel the channel - * @throws ApiException the api exception - * @throws IOException Signals that an I/O exception has occurred. - * @throws TemplateException the template exception - */ - @Handler - public void onPodChanged(PodChanged event, VmChannel channel) - throws ApiException, IOException, TemplateException { - if (event.type() != ResponseType.DELETED) { - // Nothing to reconcile - return; - } - - // If the pod was deleted, it may be necessary to recreate it - var vmDef = channel.vmDefinition(); - Map model = prepareModel(vmDef); - - // Call all steps because they may augment the model - cmReconciler.reconcile(model, channel, false); - dsReconciler.reconcile(vmDef, model, channel); - stsReconciler.reconcile(vmDef, model, channel); - pvcReconciler.reconcile(vmDef, model, channel); - podReconciler.reconcile(vmDef, model, channel); + dsReconciler.reconcile(vmDef, model, channel, event.specChanged()); + pvcReconciler.reconcile(vmDef, model, channel, event.specChanged()); + podReconciler.reconcile(vmDef, model, channel, event.specChanged()); + lbReconciler.reconcile(vmDef, model, channel, event.specChanged()); } /** @@ -282,7 +241,8 @@ public class Reconciler extends Component { public void onResetVm(ResetVm event, VmChannel channel) throws ApiException, IOException, TemplateException { var vmDef = channel.vmDefinition(); - vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1)); + var extra = vmDef.extra(); + extra.resetCount(extra.resetCount() + 1); Map model = prepareModel(channel.vmDefinition()); cmReconciler.reconcile(model, channel, true); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java deleted file mode 100644 index 854f630..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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.manager; - -import freemarker.template.Configuration; -import freemarker.template.TemplateException; -import io.kubernetes.client.custom.V1Patch; -import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.options.PatchOptions; -import java.io.IOException; -import java.util.Map; -import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; -import org.jdrupes.vmoperator.common.VmDefinition; -import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; -import org.jdrupes.vmoperator.manager.events.VmChannel; - -/** - * Before version 3.4, the pod running the VM was created by a stateful set. - * Starting with version 3.4, this reconciler simply deletes the stateful - * set, provided that the VM is not running. - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -/* default */ class StatefulSetReconciler { - - protected final Logger logger = Logger.getLogger(getClass().getName()); - - /** - * Instantiates a new stateful set reconciler. - * - * @param fmConfig the fm config - */ - @SuppressWarnings("PMD.UnusedFormalParameter") - public StatefulSetReconciler(Configuration fmConfig) { - // Nothing to do - } - - /** - * Reconcile stateful set. - * - * @param vmDef the VM definition - * @param model the model - * @param channel the channel - * @throws IOException Signals that an I/O exception has occurred. - * @throws TemplateException the template exception - * @throws ApiException the api exception - */ - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - public void reconcile(VmDefinition vmDef, Map model, - VmChannel channel) - throws IOException, TemplateException, ApiException { - model.put("usingSts", false); - - // If exists, delete when not running or supposed to be not running. - var stsStub = K8sV1StatefulSetStub.get(channel.client(), - vmDef.namespace(), vmDef.name()); - if (stsStub.model().isEmpty()) { - return; - } - - // Stateful set still exists, check if replicas is 0 so we can - // delete it. - var stsModel = stsStub.model().get(); - if (stsModel.getSpec().getReplicas() == 0) { - stsStub.delete(); - return; - } - - // Cannot yet delete the stateful set. - model.put("usingSts", true); - - // Check if VM is supposed to be stopped. If so, - // set replicas to 0. This is the first step of the transition, - // the stateful set will be deleted when the VM is restarted. - if (vmDef.vmState() == RequestedVmState.RUNNING) { - return; - } - - // Do apply changes (set replicas to 0) - PatchOptions opts = new PatchOptions(); - opts.setForce(true); - opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (stsStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, - new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/replicas" - + "\", \"value\": 0}]"), - channel.client().defaultPatchOptions()).isEmpty()) { - logger.warning( - () -> "Could not patch stateful set for " + stsStub.name()); - } - } -} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 9e6e5c9..09bade8 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -30,7 +30,6 @@ import java.net.HttpURLConnection; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -54,7 +53,7 @@ import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; @@ -157,11 +156,11 @@ public class VmMonitor extends // Create and fire changed event. Remove channel from channel // manager on completion. - VmDefChanged chgEvt - = new VmDefChanged(ResponseType.valueOf(response.type), + VmResourceChanged chgEvt + = new VmResourceChanged(ResponseType.valueOf(response.type), vmDef, channel.setGeneration(response.object.getMetadata() .getGeneration()), - vmDef); + false); if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { chgEvt = Event.onCompletion(chgEvt, e -> channelManager.remove(e.vmDefinition().name())); @@ -181,8 +180,7 @@ public class VmMonitor extends @SuppressWarnings("PMD.AvoidDuplicateLiterals") private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { var extra = new VmExtraData(vmDef); - var prevExtra - = Optional.ofNullable(prevState).flatMap(VmDefinition::extra); + var prevExtra = Optional.ofNullable(prevState).map(VmDefinition::extra); // Maintain (or initialize) the resetCount extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L)); @@ -200,35 +198,35 @@ public class VmMonitor extends */ @Handler public void onPodChanged(PodChanged event, VmChannel channel) { - if (channel.vmDefinition().extra().isEmpty()) { - return; - } - var extra = channel.vmDefinition().extra().get(); - var pod = event.pod(); + var vmDef = channel.vmDefinition(); + updateNodeInfo(event, vmDef); + channel + .fire(new VmResourceChanged(ResponseType.MODIFIED, vmDef, false, true)); + } + + private void updateNodeInfo(PodChanged event, VmDefinition vmDef) { + var extra = vmDef.extra(); if (event.type() == ResponseType.DELETED) { // The status of a deleted pod is the status before deletion, - // i.e. the node info is still there. + // i.e. the node info is still cached and must be removed. extra.nodeInfo("", Collections.emptyList()); - } else { - var nodeName = Optional - .ofNullable(pod.getSpec().getNodeName()).orElse(""); - logger.finer(() -> "Adding node name " + nodeName - + " to VM info for " + channel.vmDefinition().name()); - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var addrs = new ArrayList(); - Optional.ofNullable(pod.getStatus().getPodIPs()) - .orElse(Collections.emptyList()).stream() - .map(ip -> ip.getIp()).forEach(addrs::add); - logger.finer(() -> "Adding node addresses " + addrs - + " to VM info for " + channel.vmDefinition().name()); - if (Objects.equals(nodeName, extra.nodeName()) - && Objects.equals(addrs, extra.nodeAddresses())) { - return; - } - extra.nodeInfo(nodeName, addrs); + return; } - channel.fire(new VmDefChanged(ResponseType.MODIFIED, false, - channel.vmDefinition())); + + // Get current node info from pod + var pod = event.pod(); + var nodeName = Optional + .ofNullable(pod.getSpec().getNodeName()).orElse(""); + logger.finer(() -> "Adding node name " + nodeName + + " to VM info for " + vmDef.name()); + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var addrs = new ArrayList(); + Optional.ofNullable(pod.getStatus().getPodIPs()) + .orElse(Collections.emptyList()).stream() + .map(ip -> ip.getIp()).forEach(addrs::add); + logger.finer(() -> "Adding node addresses " + addrs + + " to VM info for " + vmDef.name()); + extra.nodeInfo(nodeName, addrs); } /** diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index d3de96e..b48aa7e 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -57,8 +57,8 @@ import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -658,7 +658,7 @@ public class VmAccess extends FreeMarkerConlet { @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", "PMD.ConfusingArgumentToVarargsMethod" }) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) throws IOException, InterruptedException { var vmDef = event.vmDefinition(); @@ -847,8 +847,8 @@ public class VmAccess extends FreeMarkerConlet { if (!event.secretAvailable()) { return; } - vmDef.extra().map(xtra -> xtra.connectionFile(event.secret(), - preferredIpVersion, deleteConnectionFile)) + vmDef.extra().connectionFile(event.secret(), + preferredIpVersion, deleteConnectionFile) .ifPresent(cf -> channel.respond(new NotifyConletView(type(), model.getConletId(), "openConsole", cf))); } diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java index f1c5d70..97d6867 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -42,13 +42,12 @@ import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; -import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; @@ -258,7 +257,7 @@ public class VmMgmt extends FreeMarkerConlet { "name", vmDef.name()), "spec", spec, "status", status, - "nodeName", vmDef.extra().map(VmExtraData::nodeName).orElse(""), + "nodeName", vmDef.extra().nodeName(), "consoleAccessible", vmDef.consoleAccessible(user, perms), "permissions", perms); } @@ -274,7 +273,7 @@ public class VmMgmt extends FreeMarkerConlet { @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", "PMD.ConfusingArgumentToVarargsMethod" }) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) throws IOException { var vmName = event.vmDefinition().name(); if (event.type() == K8sObserver.ResponseType.DELETED) { @@ -494,8 +493,8 @@ public class VmMgmt extends FreeMarkerConlet { if (!event.secretAvailable()) { return; } - vmDef.extra().map(xtra -> xtra.connectionFile(event.secret(), - preferredIpVersion, deleteConnectionFile)).ifPresent( + vmDef.extra().connectionFile(event.secret(), + preferredIpVersion, deleteConnectionFile).ifPresent( cf -> channel.respond(new NotifyConletView(type(), model.getConletId(), "openConsole", cf))); } diff --git a/webpages/upgrading.md b/webpages/upgrading.md index a590bcc..644dce6 100644 --- a/webpages/upgrading.md +++ b/webpages/upgrading.md @@ -34,6 +34,10 @@ layout: vm-operator update the template manually. If you're using your own template, you have to add a virtual serial port (see the git history of the standard template for the required addition). + + * Stateful sets from pre 3.4.0 versions are no longer removed automatically + (see notes below). However, PVCs with the old naming scheme are still + reused. ## To version 3.4.0 From f30ea79abb3b52a378a123fc04e7b04e363b5767 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 29 Mar 2025 17:41:01 +0100 Subject: [PATCH 27/50] Minor editorial changes. --- dev-example/Readme.md | 4 ++-- webpages/upgrading.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-example/Readme.md b/dev-example/Readme.md index ba381e1..d794b24 100644 --- a/dev-example/Readme.md +++ b/dev-example/Readme.md @@ -3,9 +3,9 @@ The CRD must be deployed independently. Apart from that, the `kustomize.yaml` -* creates a small cdrom image repository and + * creates a small cdrom image repository and -* deploys the operator in namespace `vmop-dev` with a replica of 0. + * deploys the operator in namespace `vmop-dev` with a replica of 0. This allows you to run the manager in your IDE. diff --git a/webpages/upgrading.md b/webpages/upgrading.md index 644dce6..f3119b0 100644 --- a/webpages/upgrading.md +++ b/webpages/upgrading.md @@ -34,7 +34,7 @@ layout: vm-operator update the template manually. If you're using your own template, you have to add a virtual serial port (see the git history of the standard template for the required addition). - + * Stateful sets from pre 3.4.0 versions are no longer removed automatically (see notes below). However, PVCs with the old naming scheme are still reused. From c79d678a2a8928e6ba9a78ea9e7748d36e71665d Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 29 Mar 2025 18:38:09 +0100 Subject: [PATCH 28/50] More consistent logging. --- .../org/jdrupes/vmoperator/manager/ConfigMapReconciler.java | 2 ++ .../src/org/jdrupes/vmoperator/manager/Controller.java | 2 +- .../jdrupes/vmoperator/manager/DisplaySecretReconciler.java | 4 +++- .../jdrupes/vmoperator/manager/LoadBalancerReconciler.java | 3 +++ .../src/org/jdrupes/vmoperator/manager/Manager.java | 2 +- .../src/org/jdrupes/vmoperator/manager/PvcReconciler.java | 2 ++ 6 files changed, 12 insertions(+), 3 deletions(-) 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 161678a..264a166 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 @@ -98,6 +98,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; channel.setAssociated(PrevData.class, prevData); // Combine template and data and parse result + logger.fine(() -> "Create/update configmap " + + DataPath. get(model, "cr", "name").orElse("unknown")); model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); prevData.added.put("adjustCloudInitMeta", adjustCloudInitMetaModel); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index eaf2447..c15acc5 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -182,7 +182,7 @@ public class Controller extends Component { fire(new Exit(2)); return; } - logger.fine(() -> "Controlling namespace \"" + namespace + "\"."); + logger.config(() -> "Controlling namespace \"" + namespace + "\"."); } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index 5111438..60d27d4 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -155,9 +155,11 @@ public class DisplaySecretReconciler extends Component { } // Create secret + var secretName = vmDef.name() + "-" + DisplaySecret.NAME; + logger.fine(() -> "Create/update secret " + secretName); var secret = new V1Secret(); secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) - .name(vmDef.name() + "-" + DisplaySecret.NAME) + .name(secretName) .putLabelsItem("app.kubernetes.io/name", APP_NAME) .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java index c0d183b..d190cef 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -36,6 +36,7 @@ import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8sV1ServiceStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; @@ -107,6 +108,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Combine template and data and parse result + logger.fine(() -> "Create/update load balancer service for " + + DataPath. get(model, "cr", "name").orElse("unknown")); var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java index 9d291cf..41b59f9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java @@ -264,7 +264,7 @@ public class Manager extends Component { */ @Handler(priority = -1000) public void onStop(Stop event) { - logger.fine(() -> "Application stopped."); + logger.info(() -> "Application stopped."); } static { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java index 47aa8be..e297183 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -139,6 +139,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Generate PVC var runnerDataPvcName = vmDef.name() + "-runner-data"; + logger.fine(() -> "Create/update pvc " + runnerDataPvcName); model.put("runnerDataPvcName", runnerDataPvcName); if (!specChanged) { // Augmenting the model is all we have to do @@ -180,6 +181,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Generate PVC + logger.fine(() -> "Create/update pvc " + pvcName); model.put("disk", diskDef); var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); StringWriter out = new StringWriter(); From c716e32534918af32700d72555106efb98e6f1f1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 30 Mar 2025 11:42:03 +0200 Subject: [PATCH 29/50] Make tests work again. --- .../test-resources/basic-vm.yaml | 4 +- .../test-resources/kustomization.yaml | 111 ++++++++++++++++++ .../vmoperator/manager/BasicTests.java | 28 ++--- 3 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml diff --git a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml index 54ea110..36054a2 100644 --- a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml +++ b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml @@ -1,8 +1,8 @@ apiVersion: "vmoperator.jdrupes.org/v1" kind: VirtualMachine metadata: - namespace: vmop-dev - name: unittest-vm + namespace: vmop-test + name: test-vm spec: image: repository: docker-registry.lan.mnl.de diff --git a/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml new file mode 100644 index 0000000..3a8451e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml @@ -0,0 +1,111 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../../deploy + +namespace: vmop-test + +patches: +- patch: |- + kind: PersistentVolumeClaim + apiVersion: v1 + metadata: + name: vmop-image-repository + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: local-path + +- patch: |- + kind: ConfigMap + apiVersion: v1 + metadata: + name: vm-operator + data: + # Keep in sync with config.yaml + config.yaml: | + "/Manager": + # clusterName: "test" + "/Controller": + "/Reconciler": + runnerData: + storageClassName: null + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common + "/GuiSocketServer": + port: 8888 + "/GuiHttpServer": + # This configures the GUI + "/ConsoleWeblet": + "/WebConsole": + "/LoginConlet": + users: + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test1 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + "/RoleConfigurator": + rolesByUser: + # User admin has role admin + admin: + - admin + test1: + - user + test2: + - user + test3: + - user + # All users have role other + "*": + - other + replace: false + "/RoleConletFilter": + conletTypesByRole: + # Admins can use all conlets + admin: + - "*" + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer + # Others cannot use any conlet (except login conlet to log out) + other: + - org.jgrapes.webconlet.locallogin.LoginConlet + "/ComponentCollector": + "/VmAccess": + displayResource: + preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user +- target: + group: apps + version: v1 + kind: Deployment + name: vm-operator + patch: |- + - op: replace + path: /spec/template/spec/containers/0/image + value: docker-registry.lan.mnl.de/vmoperator/org.jdrupes.vmoperator.manager:test + - op: replace + path: /spec/template/spec/containers/0/imagePullPolicy + value: Always + - op: replace + path: /spec/replicas + value: 0 + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index 03db0d2..d600d3c 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -41,7 +41,7 @@ class BasicTests { private static APIResource vmsContext; private static K8sV1DeploymentStub mgrDeployment; private static K8sDynamicStub vmStub; - private static final String VM_NAME = "unittest-vm"; + private static final String VM_NAME = "test-vm"; private static final Object EXISTS = new Object(); @BeforeAll @@ -54,7 +54,7 @@ class BasicTests { // Update manager pod by scaling deployment mgrDeployment - = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator"); + = K8sV1DeploymentStub.get(client, "vmop-test", "vm-operator"); mgrDeployment.scale(0); mgrDeployment.scale(1); waitForManager(); @@ -65,13 +65,13 @@ class BasicTests { vmsContext = apiRes.get(); // Cleanup existing VM - K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME) + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) .delete(); ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," + "app.kubernetes.io/component=" + DisplaySecret.NAME); - var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + var secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); for (var secret : secrets) { secret.delete(); } @@ -103,7 +103,7 @@ class BasicTests { "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME); - var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts); + var knownPvcs = K8sV1PvcStub.list(client, "vmop-test", listOpts); for (var pvc : knownPvcs) { pvc.delete(); } @@ -112,7 +112,7 @@ class BasicTests { @AfterAll static void tearDownAfterClass() throws Exception { // Cleanup - K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME) + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) .delete(); deletePvcs(); @@ -124,7 +124,7 @@ class BasicTests { void testConfigMap() throws IOException, InterruptedException, ApiException { K8sV1ConfigMapStub stub - = K8sV1ConfigMapStub.get(client, "vmop-dev", VM_NAME); + = K8sV1ConfigMapStub.get(client, "vmop-test", VM_NAME); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -134,7 +134,7 @@ class BasicTests { // Check config map var config = stub.model().get(); Map, Object> toCheck = Map.of( - List.of("namespace"), "vmop-dev", + List.of("namespace"), "vmop-test", List.of("name"), VM_NAME, List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, @@ -191,7 +191,7 @@ class BasicTests { + "app.kubernetes.io/component=" + DisplaySecret.NAME); Collection secrets = null; for (int i = 0; i < 10; i++) { - secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); if (secrets.size() > 0) { break; } @@ -207,7 +207,7 @@ class BasicTests { @Test void testRunnerPvc() throws ApiException, InterruptedException { var stub - = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-runner-data"); + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-runner-data"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -227,7 +227,7 @@ class BasicTests { @Test void testSystemDiskPvc() throws ApiException, InterruptedException { var stub - = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-system-disk"); + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-system-disk"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -248,7 +248,7 @@ class BasicTests { @Test void testDisk1Pvc() throws ApiException, InterruptedException { var stub - = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-disk-1"); + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-disk-1"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -274,7 +274,7 @@ class BasicTests { new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + "\", \"value\": \"Running\"}]"), client.defaultPatchOptions()).isPresent()); - var stub = K8sV1PodStub.get(client, "vmop-dev", VM_NAME); + var stub = K8sV1PodStub.get(client, "vmop-test", VM_NAME); for (int i = 0; i < 20; i++) { if (stub.model().isPresent()) { break; @@ -303,7 +303,7 @@ class BasicTests { @Test public void testLoadBalancer() throws ApiException, InterruptedException { - var stub = K8sV1ServiceStub.get(client, "vmop-dev", VM_NAME); + var stub = K8sV1ServiceStub.get(client, "vmop-test", VM_NAME); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; From 592b30f6c5d9e1a3e4dbb544d8afe1a77e41b3d9 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 30 Mar 2025 12:17:14 +0200 Subject: [PATCH 30/50] Update state diagram. --- .../src/org/jdrupes/vmoperator/runner/qemu/Runner.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 d8ac5d8..5c0da21 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 @@ -157,6 +157,15 @@ import org.jgrapes.util.events.WatchFile; * * success --> Running * + * state Running { + * state Booting + * state Booted + * + * [*] -right-> Booting + * Booting -down-> Booting: VserportChanged[guest agent connected]/fire GetOsinfo + * Booting --> Booted: Osinfo + * } + * * state Terminating { * state terminate <> * state qemuRunning <> From af112bb66b4088cd7409370c0bcddb88f931e1f0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 30 Mar 2025 12:37:49 +0200 Subject: [PATCH 31/50] Editorial changes. --- webpages/auto-login.md | 8 ++++---- webpages/pools.md | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/webpages/auto-login.md b/webpages/auto-login.md index 59856b2..66f0edf 100644 --- a/webpages/auto-login.md +++ b/webpages/auto-login.md @@ -9,7 +9,7 @@ layout: vm-operator When users log into the web GUI, they have already authenticated with the VM-Operator. In some environments, requiring an additional login on the -guest OS can be cumbersome. To enhance the user experience, the VM-Operator +guest OS can be annoying. To enhance the user experience, the VM-Operator supports automatic login on the guest operating system, thus eliminating the need for multiple logins. However, this feature requires specific support from the guest OS. @@ -18,9 +18,9 @@ support from the guest OS. Automatic login requires an agent running inside the guest OS. Similar to QEMU's standard guest agent, the VM-Operator agent communicates with -the host via a tty device (`/dev/virtio-ports/org.jdrupes.vmop_agent.0`). On -modern Linux systems, `udev` can detect this device and trigger the start -of an associated systemd service. +the host via a tty device (provided in the guest as +`/dev/virtio-ports/org.jdrupes.vmop_agent.0`). On modern Linux systems, `udev` can +detect this device and trigger the start of an associated systemd service. Sample configuration files for a VM-Operator agent are available [here](https://github.com/mnlipp/VM-Operator/tree/main/dev-example/vmop-agent). diff --git a/webpages/pools.md b/webpages/pools.md index 41e26ef..614ec3e 100644 --- a/webpages/pools.md +++ b/webpages/pools.md @@ -46,8 +46,11 @@ spec: The `retention` specifies how long the assignment of a VM from the pool to a user remains valid after the user closes the console. This ensures that a user can resume work within this timeframe without the risk of another -user taking over the VM. The time is specified as +user taking over the VM. The time is specified as an [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations). +Specifying an ISO 8601 time is also supported, but if you consider to +using an absolute time, check again whether a dedicated VM for the user +is more appropriate. Setting `loginOnAssignment` to `true` triggers automatic login of the user (as described in [section auto login](auto-login.html)) when From fb976802cf794af916af86d2c31b8a7bf53b0313 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 30 Mar 2025 13:05:33 +0200 Subject: [PATCH 32/50] Minor edit. --- webpages/pools.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/webpages/pools.md b/webpages/pools.md index 614ec3e..c84264f 100644 --- a/webpages/pools.md +++ b/webpages/pools.md @@ -48,17 +48,18 @@ a user remains valid after the user closes the console. This ensures that a user can resume work within this timeframe without the risk of another user taking over the VM. The time is specified as an [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations). -Specifying an ISO 8601 time is also supported, but if you consider to +Specifying an ISO 8601 time is also supported, but if you consider using an absolute time, check again whether a dedicated VM for the user -is more appropriate. +isn't the more appropriate choice. -Setting `loginOnAssignment` to `true` triggers automatic login of the -user (as described in [section auto login](auto-login.html)) when -the VM is assigned. The `permissions` property specifies the actions -that users or roles can perform on assigned VMs. +Setting `loginOnAssignment` to `true` (defaults to `false`) triggers automatic +login of the user (as described in [section auto login](auto-login.html)) +when the VM is assigned. The `permissions` property specifies the actions +that users or roles can perform on assigned VMs. The `may` property defaults +to `[accessConsole]` if not specified. VMs become members of one (or more) pools by adding the pool name to -the `spec.pools` array, as shown below: +the `spec.pools` array in the VM definition, as shown below: ```yaml apiVersion: "vmoperator.jdrupes.org/v1" From 85a9b4104631e1785da62e0962198edf353b4ced Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 30 Mar 2025 21:12:18 +0200 Subject: [PATCH 33/50] Update picture. --- webpages/index-pic.svg | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/webpages/index-pic.svg b/webpages/index-pic.svg index e912900..d6b0ef9 100644 --- a/webpages/index-pic.svg +++ b/webpages/index-pic.svg @@ -8,7 +8,7 @@ version="1.1" id="svg1" xml:space="preserve" - inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)" + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" sodipodi:docname="index-pic.svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" @@ -24,13 +24,13 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="mm" - inkscape:zoom="0.67704826" - inkscape:cx="757.70079" - inkscape:cy="438.66888" + inkscape:zoom="0.95749083" + inkscape:cx="689.30164" + inkscape:cy="285.64242" inkscape:window-width="1920" - inkscape:window-height="1011" + inkscape:window-height="1008" inkscape:window-x="0" - inkscape:window-y="32" + inkscape:window-y="35" inkscape:window-maximized="1" inkscape:current-layer="g1"> + xlink:href="data:image/png;base64,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 SUVORK5CYII= " + id="image1-35" + x="395.12466" + y="387.88336" /> From cb2ae7c33e476b03f7add65359725d76b9fe0bdd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 30 Mar 2025 21:32:55 +0200 Subject: [PATCH 34/50] Update. --- webpages/VM-Operator-GUI-view.png | Bin 42919 -> 70852 bytes webpages/admin-gui.md | 4 ++-- webpages/index.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webpages/VM-Operator-GUI-view.png b/webpages/VM-Operator-GUI-view.png index 0463cc58f2e4739353d8fa5505920254691db7ba..dbda8005f36256b7aef1db2f05c8b29ac3fac914 100644 GIT binary patch literal 70852 zcmcG$1yq&m_bt2u1wl|sNkNd7mX?wRk&x~ZC8Qfkr6oiqrMpv7Iu($T5)=duAR*l$ zcfFo-e)rz*`;TvY|8d9NH;1L zh4%3hCj5lnnCJogaluJKUi}ihye>TofxnYEOKUr;**$i4GjTLWJ+Za3F=ul!b2K-% zb+WK?-oR)UMWJq@*`^P`s`qPfJ^^8V2mB>ApeSJH;al78sJeD&ucf4}mG^P4bk(*Aj6Ooqy1{QbYQgq>Mv1b@Fm zxf{Gss+-LIeV49WTPS|$-yfI54Q9mu`)$!o40!*1sT}beOsT(LbYcJHSDFs{{qtTa zE=%!$-kTB6^UpD8RTatqzrVJpEXQs0WE9=YjTY-+DB+#pkw7!2%*1K>e@FCKb?I-qb>MXY6c!E@%DbrGjf< z{Z_$mT#u~viQh#}!9-yj(!cKn%5OmIoN~td;07v9r}FVcja_P!WWk`@jCU~diz`>I zY;JD0q}_XA{Nzb+U!S6stgIw^TzgLs4zk9Hyw+tFL$uz(r^6~NF|Xw4s;Zk3ch!k# zdakw1ifwcH>`Ag7vESf-?V?PYmi449dsW(fqp&-^Y>_5XW+~?D-FxP{KZopHU4tqs z@A#bj;veEx$R=E0Uzezxs9_ zi+Jw(y?aN|6+sc~vN}d1C|EbVH>!DbbTm<6{<5i^NlR6gVA^w)ZEdpZ{!IGMQpVTm^EIX6jXT>qfWPb}!pq`yb-7 zTcJdZQoZ_3US#?C`6$0?o2d+sokd;qjLb~ll->o$j;=1vt!pTpK=ga}?rrYvNn2Pj zy?*^#MMcH$+c$mvW}h1$Tz|EFjK1aYb1wxrh8qb6E zh$yk&q_R;|fti`N_BUp(!i!bA43(aq{;|J*%q=g2kdV;+*Cg-zr(X$A4mNxrpZprm zz6{q86of~iX|00_iHMMf4aX~`b7xQFva-uTK(j50MEV6UT(>kOAC~=*gfR8GYp9kE zIsZ>btLa5%{-t3v{x!~6eV5hJdAK{ZdUPai7`{%<)7wQUnBDQpjt_0Ny=H13R}svD zOC?OIS7UoofxNV{vy+o&#EXnkIfa<&Dmjnb`;_MM-xRPTIJrD3N^5Hc{#snrJkoyp zwc_Fjw+)}|$p?y@rX81@e-5I-XG6lnJ4%~>hh=2YvKlo}UcY|*C&XOLLAKpHe!G&r zMuzoobuWHR)tC)sNON+}-ME_)2-jyilBcTO=*g?EuOBdQ9q!~%Wtut*0R_cNh6jl) z5Ge=i-zAlluDn&Chp_$e|H#%AkR&5Y@Amb_=ER$sbg@L;u#9xfU81-Y7BD=6O-1O7ehnMT2eYZIcfj?{YFYkiguHi;O~RxbL{USqQ4K#h_A<< z3oS3NeXApqk&zkb4B+Q@O>j%*CNJ-`;H%VQZcMO4c3(&LF2oDDuzdUeeRgi{IhR@2 zO%ahBj&+oGp7cvK`9_37#9pF^2g4ULUkkRdNk zT{{1@vF>%22=jb$4zP`SwmNJv} zfZ3)3q5WS|!TuOnrW0i*a>~lGCMGm|Hj@FWnKG@NofmCwZR2^Zufu-nDZWZCU;645 zemL13OgL}I^5eCnhGU|=Y6ctLF>f$0N{gSK(W1oPc3eXZs*>F*@-;ZhBA_xKod1NL zP53qEGb)t0_^lQi$|l^lolfkcU~F+(#~b;ZbL-b`HRqkCUs;hPZfkGn9W!Y5F%Uqb zym4cw*1;@d&(+mcLW_8h28WAF*>qzhU(NjL#Vb_8m!v;=)1RH54rWMSF6RFJ!L56u z+|2*A1M7<9ZFRdONU>DH?tX!nN!mVt#ydMX=w?jyqCL*e5z239X!x?>{NuaZ z%(Vv@dE?Xs1Zc|2${!zbN7(8zGcz9^&PQ>19opB{*JrqGOviJYpu7H>q;KHUFn}fc zQt;vZZLJbxh(0JvelK5M78VgHw;E;n*6iD?u%#j6=_z!DS~T3S(KB5snbB?O5-x54 z6qf!}u~LoS+Y7x=XpoW>;0~u^zdFX&mv9HG(Q~iO4-KQtbI*$S`gK|^E`r~`f7?4b zwYT_Vlshj!EWTPKkuEMLcL^4p=FO)ci;F>S(;irmI|;ncHk!{xAwhFl4%5LkASLM? zpUv=L+px5ck0{^MiJO#?)YR1a_0IC|;aghPzkirrSh$#=802$y+&VKujg5^hk~(&2pv?OS0Sb8xU#F(DJl`Ql+i2K{?C&k?86GCg$;r|F;K~*j7REE? z;o)HdIiak&daSPL$+ZKx>#q_MaopV8cq|4{-|Jn7Ze*0XtZI5~b&??ad#uFZG6e;N z*YR>Lxj8Y;#aLFI=jCSIroSeuUiB^jj%*puzK>MCBKu4cPyVjlt$*7$#NiH`>^cL9l#( z`b)QAf6`jJ%-DZSC207BRoSKSKtqXKze(p(-JHCn*km4~3XA0+fijgW#YYrMK~B}! zI8ti9+P-B5s^{y%kNnm5GPY-eyvrp*o9(Q91QgVoJ-xR7l8=xn_Th2@tV8ew6|y1KfLA3wIh+5cEt z3Z9!Yji{jZIi%4nHH@$xFGN3j)FWOH*?l7^_> z_uR+b!y~k5e|xU5NG(?>JuMA&cDzRv+jzA24&`Uo9cdXO=DEwH!oB`rVQaoSp3jCH z7Z;b)bJs#!Tl*#*-386B0sA z+Jn-KntkNtGzQx>pK0C>S{*NK9~ig_=@)>*^`}pt;!{$F3py=YB+V>9qTbxu zX@@*!0@!dUQ#P}(`n^C$|`?#2vt zjor-H$2>x)m!sE%f`WA0-??bimuq~-+qmnt{$t$8xAS&s!*hUQ*0_~bRaFS|@Htu_ z0lb*0*@=gOiV^hU#f!n)ULRl40KDk@{Ek168)^WgBP<5Rct2RU*3r>xkT3!=Gcz^4 zMMXtPMZE+d$3ygQ{v5icm@IUyJ@~3DQdG=)h`-glT-MRi$=FhS$A{M*geO^Q*!W|9 z{sn-B;>+G!TU#Fo2D$-I{a9NIhkHeH^QO3&lJKY*78X{q2_Ag=&##q?m{+NA110>! z!*QVsnstN_*gHBt&&_4bQBIA7f3dG3Sn?=Ame3$0Q^XY5~NwDIa#F%+(DBj2;Y#%BjOM8|=_!Sm3cp!{I*ZrqZMlmhtebGju9*NIW6RT=~r z3Ok5rW#HpSaR~KUxVg^`)?1-YpvqvYr@M7^gc1!kc|Ysu?!KH*Iyk7(mm)&JL}pzD z*}$ZFU|6RWzQ*)(A}?esP9C0)!>#$DoCg&84ekWNS8hny+Ol6~*AIb{vBJB^e7mXV zAwB(w*vJhbXYtC%pY0*`k&tX7DT9QrtxZbKZyOG0YQU75m>6~rIHirW9D>N~aC26` z@vlokS8jxv_r{{px9fL>lf8iO!*+TU5EMkq%8JX*&VC68hp@|mo{`b)cy}3UI0m2u z(i7jieF@LsE0=^NX((QvbTj_#iPr{tbi4(e}b6Z0!792Q{&E zRaI3BD2wznBG5@^Bx>mEhnJOc^4Lrg!&d7AoXTZ4Eexe}<-PYZrnZacLW~xt!<liK*#xsN(aD2h$h| zi_yKky+~Z}RBy~Qi~T;`QI0T&+lx;%UTQ?mv@BneQQu>zIV^tWmY1A-LuL53mI3S% z_zqfJwhhPv&|^3^-3|0SQG(0>nM_<>Zw~TD?X!cD!8=V2v&#++4opl;;?PL(#vZJW zUxi+W+-W0!LrqKhWLm2(w>6t0kwZvf4qeiNYMQeleE)H1UYz$0)!}vBsqe4eOX>-e zr3~tl1+Nb8>zbREXbDdniBx8+tfVF-g%@h2j+}K-*j-|jwJhimht2u>c$LE>{&{pX z!DP*NWG-z1QB^|#ngWBIIO5MX>GLplPs{QHRd! zk-0gxh@=WPtMq7uLNc@m(El`PE#B95u0J6d$4SdRpba=2DG@ZOUM>%xVWsVRzF zrQ|k9$K}w#DvTNsX{~73PB(;V7VFY5Gt2*+=7^T#1dvAwN7>)s{|T5QhT)_nzqY-t z?Nw@O6nruh_UF&38V=Ym$WI_kUv6hrC$YM3B>cXrYP8@Bw7k|j<_wdXcxp1A?5yAF zAF?#gKpBPB=nBM#e`zTPU_*zkIqBqm)ni&4MMd1MDC(<#jQW!Al0b_};#u{U-&t!k zg;iutYEPJvF}KfqWyNy(gk_IMg{kTdX7occ8I(n7_`UiDG}KK6+Aov)^}D=0{f2nA zcf8*7$+cKY`#P%|jxp^leP(nsf2Gh9X#$IUc)By-|N8a@fXD?Uzq}GSC(b)}Bq22c zPINdsc1fdi(ijU?UgL8t=&H!iXRfHM{3$(8NTFou3ksa2OJMQop4l zSEU-og2b)(bFzdR;2eS~#Xn)Kh@BrrDQ#(tK@TC_eb4|P;&7#~)P8M(YqmC@d@|dt zs-|ZAFyr2*6I|4TM4lmS+oz{lTH!0boT8#sc;r0dUveH)nYzzM@cX@fnjv)g^5yq7 zQ<`Qv^IZ`p=H}U4!BQS21|Kk=J$qKnK>`1rn4Gkt^DQxK#8FdIdpgr(P?!Pza#5F zLQJlg`C^W6IJ^24Mf_aSH)-oB3d~=qZ1fkaMlxqpzO(hC*5dhmmYON(*rC!EQ(0?k zNVzriJ#zu7XAUl+*6XNZuq;j?o1FUnFu=OOax+=|T6;7|`?W5s*Hv1*Fbj0pN2O(C z(CH)A0fhnqMus4m8o7I)yzgr$C=Z5kuYWU!iVaylf}I%xZz{W*Jr;nU9_myAXy- zeI$MS} zGGcXgbxCjphA|u|$;pXB>>}lmY6|t9L4u@kptTi^fq?8=%mc=-gN0@*xL^%GK^+LnQ+4iN-YXC zWYRB~+Fg%*$0IIphpypj1C_6h^94ka=7dcqV2)b*S#-!8fwn9f2^R?HMbOo?UihBv zX+_xnS{Y$vBK|P%r!8r;XP%pK#&hQm5qTHj3BvDj+H)C2>%!J!MF_!nGe&^`AK$O_ z1r}sjbO_c$=k|fFF2Jh7Es$E%)meB6u~tfzS?{SNtjjFq1A0hk7n}Cld2j&}bM9cq zH#jsDd-%&20BmY(CmLLE9B+>FF?^2Xn=GsB*n)zB5PIQRjgc-H-zX{H9GTvlG*TJN&TGq&*8Uo9ge>OU2X^zz8x0Re#U zgaC!=U4Mlk<${(Lz4>gP0*)P>oE)*Xw&uv;EOHyL9zZ67Mhk0WV?TsK0fJ3VK|un_ zAJSyu<3$sEE7J2;5i1Rq!8%y0P*37IBSsLb*|juWu$6qIMf&T3} zmEGif`FO2+2JY?xS11KgZ+YT7Mb(iNNXVO*(4OsSJkIhcU_T`zBeVFD6Tw+34k*iE ze@zD{>acxXChr9!>6ow96J_c4xd?AOM0SuKd*g*%*8>+^Cnu+>MU9EdCsCUdpFYVi z9kTR}jcE$lW58hMOCn`UhQ7|{4S76Anz{+ zwZ_O{Cj~@_8Kn!A%HWdHV2Q~nD^oGcEB5#I=VYy^{?ymkvHT^MfQl+ID~k?kAwekk zIg(EVJ!0^++nPV1Gdw(AEuH=KD?-cALwbe=gMP=(Wo0D4FQt--iV748grq>B%(}u` zU(?^WUm0O;IGBDG5)}nvK+B!T<4{OTvk2q=Fux;1`eoVEuiWo#zmo$9l^~Oql|_i= z*49>NdWnK?-#)gse#vV+_J{0;ssZpX`rW&iz=gK8w20SE7-)LmdED1hq*H1CbC3o( z&f!w3FYJ^&2U!8{{D;afQX*aKd4!a$J>LH{Gka-hm~n@USld=QxyaH_3O#gN9^))$ z1TAvgtd`k(_%H^&?K=@loE9x)6zokBVgEI2YR~; zSXh^PN4q1h0*?h-4w|fZK_>>_y-YxAvYD!${e567es;tKc>s_p& zb5o5!MRF0ei$xs-B}u^PUV~n{)@>tYtN~q9Q`6qTp%u6Y%4>=>asY&g_L@ zrMvjXE)ai*KQp6P!m&`E`)kZXOG(E8bEbRgbju5nl^@$pSL`3Lsxw<|KGsq@b;`w` zk+pF3i0row#fUVzhjWpA+V*lb4|c)jqbG(QS7jbv#J_rK=tlQ;zm!I(hW8a#k{ut> zUih~E>$+K)+IW-Nm$n8rn0Vw_(L8Yfhw``V9zPBOIR~c^y}+?h+NJWz0MJ@AK8Led z#MiFT0MP_xl-wL{DHIx&MX$?ytK-4?>?92C>dRxr6ad8om+YF37@!E?L-=-dNENf+ zh6@jV`EuWj(436>Q3xl^G^{VroQC;hwTBM_Fx^k~e+BAg5!7N-zy>g}ut)%k}fDOjI6&S7bLgSdUh1Pp6n1y*lwPcdz{RUx#)bkT6YLum8gu(;~ST zbw1L6y#Uay1!UdRSh}MUt&klr<|_)oARGh-ka}-{H1jZ@7`imr2L5qzabtZaE5P9( zY&irBEdv8)Y-}t-#+R0scD+J{Ns_jiL#g>*Kk2b}!;vVfH%!cE@D z(IEnz&+nrrr`x?80`@;pP!%vBr8k`IPh}jSY=^V3PtVS_mUB~SVKYZi@CP+Fi-B}p ztnljuVXdu88-w6XXCZ~lK#x!kgw)zhv(YU}sD_Z!LqG?ahy4QbIkn=02M71Iq?A3;ns@r|A`OjFl7K&CFKs=&c2IpVu&#`_x$hFb zX1bk-SwXohh|_T65dTxuc``|hBD-w&;00o5OH})ammJk%X5Je-T+@dHtJCDt+ZC&o z)o)F6o7vWit)IlDCuSrPmqSKAgqym1qyO^ke3V!h+;ayYPj#IxCszpEz1z&WaTar1 zi_>HKk=JG`7Aco$KvO%iRcSMF)w4tKHH!MO51%IDt>k_G>*jb1O8!i@?XQh)CQYWB zzMpY&melOMRSF%6Fds5+&Q57^7_|9W1w}sL1<9KsdE~+x z!zdJGBALmS`{&sUp+T4*EvzFD_;GSwqSAV`wvQAr?f3bwOg2m}W>^|n9s}Dx}C^KW>zh067{VIAq z)Xk%E^yz!~A3ynWb7lCWrKzA47T$=-=QsG}mtVL`GLhln-Ea2KN7Sd(t^eanF{B*j z&i&T_)9C)+FsA%>{4ThAH&CNV?w`=TgT*=0t9x$3pU7U68VwNnue>x08R{%C$5;OK z?pRUp|Nl44DBQMe@gVre>~d-CKc*XV6S!!pU+1?ejEp`G>ZT;ocUPE2*)uk3b2uOi z0K&Oh-UccpZy)>jEsQU|J}7#>cb3wJ6YbZTr2?WI;~*ZFGtDD;Q=N(r60vgEA2HwFL$O#|<)zha%Kab(~@d*j(K)khagMtA- z$udU7ZJiQ?MwV|&Uu=~pP0h^U5UTk$K|W4Kw@e17`$;KeCR)X9>fb20ec9X#eByg> zfgzr!acxEoSs3-o-B{#Q+<|@C`uZd_9S)1|gk_+rjsRZ_AOZoXyVnxapk_~0+fb16 zJ=S9;!XYy^7%NuFDi)p}Lz)DLtj zW6&S~%S+bSen(VYDDLeb$)2CgoNq#!U%4%e5(lU_1gr}w1;r}w6>lzHUTNS72(Dkp z43tPOXNT(d>C-0!2Xr~|^YfqnKG|SmXTKi%X~8i`ZokQE6M1<|;Q%2@N=i1jwm=VT=y%CG-E6=5k5q-|M!?ot8+`f= zwTaX5FFD}z=Zb5l+djQv40M6gEdi&GPeg=aXJ-e$G6k&#TnqP}K4k^m4w~Fm_h--M zQ+efK;3##S?LtGfrdI2F6R<9x+7cjm_b0Wl5CANg$}FBt-7NG zJ7Tc5grM4Sh$ zIKVuLS`4v+^{0sF1>QPX^YHYf1sKA`#YNsLDJeMwa+dS@HyyJ&MFvS9pC*|j0Ppef z@o7+=udSDIPxi_9zI}>0Kv9DokadZ-(Ev zdmTu(Kz(-T9#IGbiRd*8Jux4lPlfi9MZ-?IbPII8iztvnO@Jl3)`W#Je$3k1%KX57 zwz>2YHb#s?GMj_1{B6ZmL*?9hD+O~R^#yNNv>&HOI}+PnV4{K66ZDWlkF!bzagD%- z`BGp+05u8&a1TVs%d!0XPyggV$D#YfKR`)wKis4NA*z@4OO2fovLR6@;Jks<#{w;f zXlw?gXOMb2L8bFo;3;ukoA3vT%VdA;E7$|H1NUO5s~fz*$pBy!X8}}G|JO35J>V(; zuVK;7H6w2)aOwd`2~o?ydYgr%4XDx|%ggy1)8D>PLgpej`w4}4@PnIO@_z2>KIqH9 zLx4}9JA~lYkH7x909FshRM8u-V}xY#J9rGrDY!;PMzyuIH*elV0h!PR0toNi{?--&xw)aC zq5JMn0_bSN0PtjjpdHU`j$`}1KCq~$NMTfO%=&^Ka1IzKN=izEP)(i=Ub<&asCdoW z70BTq8yiueb|5`QH+1fsy&TPm%qdjdeMd=LV+>?k1ndD*gX|OcUsdB}fc7H>64=X; zF4aoypd-pS=3?2vN163ke1SmX@2F5XhI1moFt16mWnP zv;TeSAs{Gd*@vO>PM(es*~T+X-iW&b2-;1c)oFmSlMKQo_}&FtE7$}H>pNwoq>Q)b zIzRUIez8ogf65V~Q0{Z)i4q4j!FHx80#Sj%^}tlOQaD^K2PeC`?Z`X1<~z_XfLHPg`{^>p$*Hi*Du3X!3m zGDvuEFg8w$w6>(J$&aY7f(;MxNPy=ORs(w#vP~o`XbaT8!l9hwH=iC!WGZJvRFI!A zl^VL#uOTcfv=ZImQb9vOKLM=_4`?d^)YD4?so;qeDOflZxs8-kWn~h>@#tH!NI92@ zAP#kN6cH9{KYTC)2h0Tcmo}U7uPW!MIPF+1Xi|-JE=jMwZZp_4P>L zU!}mk28B()VeUejBzggNxu&MRJ|zwg&imKQz#mFVNT549Ik|*IAPC1$$#dBT3T9=ZPUU00dC|=czD;6DV4+7UGhpM z3twZgtPHTRv7w=Wfx;I_eaB<@99qhsr^nXy(gt6OS$T9T@FRd}#t*bSL+?F}*^?X=?7Rw7AOdZ( zBULpuV}JT+3^+MDI{)YP$&g^%>a=fS%>h5h@mnQ z$@F)dUwBG-3bm5iPsHWNx7hTmZvyP#JACuVQ0!xE{@e7Sjsi$<~l8wZ>e`d13vE#-6ashWA#+ zAbo4r>XRBc7wu*r5ugPTb;av!Z#*iKKqSB-F6gMS=1IMertUMj+~Z*MU&S1sWTea`1*2MY^w^MOmf1$i2htpxbyii(R1y?Xms3Tg}+8Qla0 zpzvN66g7x3CZtG2-4Oc#{#qSxE)M8|^L;ieY`_AI+u(RZd~Tp;-$9fXz%!I6)tv+Cw+*a25kOHI&vk@??@c$`=tKQV-Z%(7fCyyl?Kx4P#$#}( z*X&F;KD#9#Knmng1~!9%=m$WoAO#?9$1M3bD%#p1;FLjf4cI74>>EANt>7C0$IDn) z?z=T5w_9T);1F8-FYx2A;cA| zI_f7e1%eA2ljwRLddJ2nn8%bnMo?S_*c8g13{WA)C^#*PnI;hgtv5b%8?phLLKOJV zVU=X9zXL@W1z8&#pek}lflzsl*xCjM@nNZ+14RXpFL&r#V}oaBHM1Qx3#i%Ma1LAT zSH&*5nS)skvqZn{Iq-8VMjkm&l=Ic+fN2IMb2$ER>z!RwE4(-GF56(`t^GPm?njqk z#cqAWlhjy*J7@8&mMg9O$pPpZhjTAEU|(QC>kR3&FI9{h6rn-OGb9&)pn^DfB+#w4 z|N4X+aN76WJDijY9aRPr+bOsqkeEccTR<=KfTyI~-D^3G{Ml#jWJd+ZU%4S<3`8%i z2Lf;;BqR`H9->|VB`68TJf5Ca!oTmKKJ>LWJ*l9bf%y|NI8#`zuF*oRa$s=~7H`02 zfWUL#$_c%IulrBYKZda))r$ZTHesCMDp(_=TwVDRA?HJdHf;;QJ}pwo9$CDgZWH;Z ztnCKF^ial=EI!6GE2lr8I!o+-nG8}2eSjbdc>Q-qhyFVZ&HLAwAdrhi|1*rFT5QTx zt&*=Uk**@n9*0;fVL$=8Y51qjv)~NK&(aP2pY*`!-TIfxQI;P~7w17JNl<(PyD>2_ zk@vI2D3ao<)dWk?~_)*(Dn~h3tnqnW}%@IdqwszZAv4LEl?>{ivIO zl)8ThR=#^uLPYEXisdAMADb5t-hsB@^~if??eR*#ViytbU3WLJa22eBr9n6KOEjaC-3Uit--{&r|6w)$6o=K(Z*#rxToTqSi$x|8H8P zYULZy@R(O9LlB#;h{zdP137Jh+$J>{Y1J))jR4`6j6ro1564(aZ~pI{!QIs92VEa( z-x%tfadH05RjR*)IT3)D)~C$GVK_CGb zDrlFyn>`_U6|qzg0LEm&)~V#eIt zQ2!*^###Uce?#+M^2i_>kQTC6qY0u*9#$+&xGOz#9DXl+u}R=vWR%ELYv=Q%OVNKI z&&8WDqDPjcVBH*stgYg0=Ssnn5?o!SVcO*P^w!D8DUwE#1eKlH{MDN?G=0? zu>SW(aQJ!dr6>jfB;!Ir6EHN7g8<8zg3NR2$`u*tRA2-p5Hx*6q=76sph~3b@&BMe z*=7#p-N~DpT0ZDduo5lbGF;Iw61Db!t847CIA?#RWC@&MRZM=X3Ep!+; z8?Qq1(5n|Q+vfLOyZ+|ka8+i$iN{l_(xR=rBYUei@Z}ZL=n2Q4yhE_ewAjPjFgqbL zV}jpU=6E@VwB`;0^H)d8q~Qy$*UuTS5N5mK#np0C6>p%H>h#z(7b|;HF#j%KQdO@K z$}eE$@(>vCmsHYsxl|WzkqZ1rg809k%?q-cGz{o+vcTLSH%c{zUJt&ZQ+c69EKbN!H*7NzcxRtID zw&w|!HUZ4r zQ>lZFul%qSyn7b|g-NtYBD$~^CZCgYE5}$u5K}(HPT1RspKl%;IaKi3PU_Cz{I`p0XMFW z*@urG@#H#k{3fdPZuBMt(;G1$z3NI*tlV5X|caIU$B!SoZ~j0-rqLkrWo2h$n|l$+P==t@4I$@4A#(Ft9K{x*?HxozTo9c zK5$|en0V;pNu_b%p*H5=Jv|Ud^7Zl?9+Y2NTH4Zqq(M;5qoyck#rB;7=Br_BpXh67?pBZ8A~k#K_9Z z3bS5eK)GFli?=oejt1xp@bbX`(o4`rfCa)tW?#Ut0#k%#*5mBn!8*)97maFaX+%Y> zJi85U9K^nuicVRR_OBN})Ek8YPNb-M!U|^q`@8z$c@Ee#B-2#@ijXqNr^I$c?*ZKi z=uhY<@RZRC3LYh#fAlW#-FY>wzVGF$`(NLO7@k_kg)pUrQfST)jRLG%Sjb#4bTgLv z^#2TiBfSdr6p{>YOdwYxO%z;Ij$#5jlp?5tv(ScdeUw}FtM@P9kWnaWYwKAoduZi>9=-}X3yIXhL#HAf98APVn6RUeF+ad)^FTls z53iPnlq`K?<@FJ;Z$yQK~DEUryI9g|!H zjZzlQM>#S`7%bC{2RuPI0(8P$`7PnjlQI}VL)8+{f_5s&*LX}t<+ibjET!HZ>g$C5lAm5 zzxXgiZF11v`rW~e^5&s;vtbkAzU$Is*Cz*$pETd;)l?BpjiTVUg?*xYs&>Fl#f@HC z=F3-Yfua7kDlN^=`(bWI20A#L^S|hn8KZ$uCu(^3VS~>ZIWR7gIywm>t8#DFAqEj& z5e$vL7+9JICj_EIjd;0xNd2;oI}E9k4I||y(PrjdM zU#MB!24w@7tt$^6JOE8#4mKjd3&M)ud1_(75$iKL)e`^J0@zv|s z=u}a`Fti6Wu*UsTF8x$`L27!o;H(qrx8UOkk;%V%o`#dgjiU@M5sl* z4*h_QMu`ItzVKf5cPh`G!Rs@o-YVruzPQ>|Sp#ZWhjB*vf#MwTM9*9Dca?`%MITKJ z%&pw_{;kO`{UGNH!mxXKdV&+sSh6V4Vw3{a5!m(eh)!T$Vfii>O{Gg>={I^_ zgLHz9f~OI*O-_<)mFTxZ=P+Te7_|sAi7I&1V7Q2eg#`yv0^(TS-q}Ii?9m>k8Mveo z%#(i0G;81zfEoF6ppnw%mZJbzjx2*2bPlFDwQFo|pl-rbL1674mfm_$540aDs{2K8 zF&ohT$WYABAT2|Htxc88}aoCNT zaABqchNS0)vwiROJ`#ya%-G#^PV2c^)F2=vgdF)0gtLnb{e|^^Zl3-iaIuz93yy zC}Q@Q_0tiPr6ApRjtpQOE2g+^i1cka!f|3_uY(}+wZ8=fU^Ennd%BEoh!oUj=)Xo) z=$~ZSIE8-j8QF6ncJqF{`^^d`E1&=M`yIN*b;+PJ){>@?B|8_jk)JauErY|uZP4eG zgPjh*tTEWmCB`Xut%yNA3Ov}DnQsfcjNX2^AhMK9*q!rjOw2`cbO0fN1qB5y+>h@v z6ax%Fb_uFw!SSMKVUEIEwD~UsamL$m%{(8{?!hXj!}>u8i+2*m!{C?FV!0fcpZ&wa za6ET^wz3y`>eK1SCOW4Id$g6b^DqD~dI@3Js~*(V*U@nS#H~(Hy&T}d9>~n{#fuk_ zDQ4(7Tj#fsLBet*zbLj_{Pi(tBO^_7(s6)i0+WOL+!4j^>@12{@l9zK)+ z4!46nZr}Z*qmO2GP7WpjH^lD(@*#|BU`$Qqol5D9owWz#-ezXLcmMunz<5Y^1iUD{ zl4SbuJ6l8sg4}hw`$ZY(W=W`EpjaLYEqi4N(KIP>(jD2%Io3UeCaejC0u}7%$_gz= zlHg-Tw;M5rzhyvs=zez0n*8*WosaOOMcdl9phK_q++$J67wviT{(sLU-Zx}T=tOGB?^>=#(ESp&l>3m{IU09-T5KDZsF;Pu zV!I7=Ig+Y@h{rKlf)zpfe&D(T{QOX^w6`vKXff^R=JdRw;EG_-(9m!t+u7x1WDGvr z0I6eZYhJe6fyHV~eUu49#w?T%n%FL|yjlOw`}l;T?RD>{CoK_uLf9m0cDWFrbC`dfehV(Py)}D!9e|hC(Q8X(AXN?NjHz- z7(6~cMi-;2NRoYUZHGrkyAG5IYnKv@l8w38`_F_Q35o@pNVt^(u7gAYnH)uVMb|hSw}UYQ|Jt=caQCRMavC!{D@b%>=!JNUX>4*PH6k^y|l}M%}8UPz$=0CjZm|^5OL-4tv?6zJ>2H7)CAA z^72A1UcQcs@ysdEEjPUgj|1TXo2C8-S3G!*%=LFEbiJf~7dJuCxCb&TpmTh19fRUN z2dz&@lYa=ZZV6K`pc)8ugm zQi16w{g7u+<>lx5_b$*12*exogCTj=WUh;y_X30d9s~(u`~YACZ3VfI-X54^t|TTV z{@6JJ-O2cwU1dcDGLMe(i->qo-1H)WChqGvSo@IC;_DoSef`92zdT0XLi}F=;93jG z2sk7^0EV0H!j!`9_yBg$3i>4UNtO~DqJm$)CREvapPz%Rm{8`FGxU%b*I@GO79O-M zX{M)aU&KH4_doW%6Ca8$=5>fdoHHLj%!77_h<4DKOvn^8r1GgI;~Oys5bRIAO-T4O zt&Th(3Fg@G51&Ba5)#U1EQA;DDkva-UxDT)3ArqiOf}2nMW+`sY74XHNyYTSbn>=R! zUd`z!^V8;X$uEDApmSHrm0?9O*A7zL;mmHy=z$-UZ=K%$4E#@OJC$L zYU%(1{0EFSHF=-R!59=Ir?sq5cygI3MJt#!k-;>WR!1KH0>vZUPB0G-LDvDCZ9eai zG55D|agxf)SD`8mLI;1?jjJW-ww^SIS)7nSMlqXKS~`=)e`#(~7<9K6)zy6PpcjCG zmtoo*I@*hrSArX-Gc%f;1SrABQs!bXv0?nbWljKOKWCc7pjdD1E~`P*n*z;+zN%jG zMORn%CuH*C11*i=F*o`B%%X8okLtRWd4aHcQF%{W-p3XCjP#aE_rg}28up#M&k-

n=!P} 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%0D{rnnqehKW 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 Date: Sun, 30 Mar 2025 21:58:22 +0200 Subject: [PATCH 35/50] Update picture. --- webpages/VmAccess-preview.png | Bin 13757 -> 14080 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/webpages/VmAccess-preview.png b/webpages/VmAccess-preview.png index d13387f723813849a9bbd485f17f3b9d4abbf7d7..a97f7e18aad0dc3554dd43313b82ccf4a19bbefe 100644 GIT binary patch literal 14080 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 literal 13757 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 Date: Mon, 31 Mar 2025 12:21:47 +0200 Subject: [PATCH 36/50] Update component picture. --- .../jdrupes/vmoperator/manager/package-info.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java index 337b5e3..1d05ec9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 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 @@ -83,8 +83,18 @@ * [YamlConfigurationStore] *-right[hidden]- [Controller] * * [Manager] *-- [Controller] - * [Controller] *-- [VmWatcher] - * [Controller] *-- [Reconciler] + * Component VmMonitor as VmMonitor <> + * [Controller] *-- [VmMonitor] + * [VmMonitor] -right[hidden]- [PoolMonitor] + * Component PoolMonitor as PoolMonitor <> + * [Controller] *-- [PoolMonitor] + * Component PodMonitor as PodMonitor <> + * [Controller] *-- [PodMonitor] + * [PodMonitor] -up[hidden]- VmMonitor + * Component DisplaySecretMonitor as DisplaySecretMonitor <> + * [Controller] *-- [DisplaySecretMonitor] + * [DisplaySecretMonitor] -up[hidden]- VmMonitor + * [Controller] *-left- [Reconciler] * [Controller] -right[hidden]- [GuiHttpServer] * * [Manager] *-down- [GuiSocketServer:8080] From d67f374de7b5393024bcd4092f5d9b13155840d1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 13 Apr 2025 16:48:42 +0200 Subject: [PATCH 37/50] Try umami. --- webpages/_includes/umami.html | 1 + webpages/_layouts/vm-operator.html | 1 + 2 files changed, 2 insertions(+) create mode 100644 webpages/_includes/umami.html diff --git a/webpages/_includes/umami.html b/webpages/_includes/umami.html new file mode 100644 index 0000000..8066278 --- /dev/null +++ b/webpages/_includes/umami.html @@ -0,0 +1 @@ + diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index b85f650..40bdff7 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -8,6 +8,7 @@ + {% include umami.html %} From 5bcf0ba0512950ba73adafe692998749f9afc4d6 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 13 Apr 2025 17:01:38 +0200 Subject: [PATCH 38/50] Add umami to javadoc. --- misc/javadoc.bottom.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index dfc3373..d5589ac 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -32,4 +32,5 @@

+ \ No newline at end of file From 6ef4c2aaa26eb9480edcecffc5239a8ae2979e44 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 14 Apr 2025 12:08:48 +0200 Subject: [PATCH 39/50] Fix return value. --- .../src/org/jdrupes/vmoperator/common/VmExtraData.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java index 83d9577..6a94c9c 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java @@ -118,14 +118,14 @@ public class VmExtraData { if (addr.isEmpty()) { logger .severe(() -> "Failed to find display IP for " + vmDef.name()); - return null; + return Optional.empty(); } var port = vmDef. fromVm("display", "spice", "port") .map(Number::longValue); if (port.isEmpty()) { logger .severe(() -> "No port defined for display of " + vmDef.name()); - return null; + return Optional.empty(); } StringBuffer data = new StringBuffer(100) .append("[virt-viewer]\ntype=spice\nhost=") From 7d298ce24b91269dcb4cb68af2ae01397339ba10 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 14 Apr 2025 21:39:35 +0200 Subject: [PATCH 40/50] Clarify intend. --- .../src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java | 1 + .../src/org/jdrupes/vmoperator/common/K8sGenericStub.java | 1 + 2 files changed, 2 insertions(+) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java index af87af2..6b5fd7e 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java @@ -240,6 +240,7 @@ public class K8sClusterGenericStub the object list type * @param the result type */ + @FunctionalInterface public interface GenericSupplier> { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java index b8f1992..aef791f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java @@ -359,6 +359,7 @@ public class K8sGenericStub the object list type * @param the result type */ + @FunctionalInterface public interface GenericSupplier> { From b7fad4614db37bc9f72b3d622fc708d15bdde5e9 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 29 Apr 2025 14:02:12 +0200 Subject: [PATCH 41/50] Improve debug messages. --- .../runner/qemu/GuestAgentClient.java | 16 ++++++----- .../vmoperator/runner/qemu/QemuMonitor.java | 25 ++++++++++------- .../runner/qemu/VmopAgentClient.java | 27 +++++++++++++------ .../runner/qemu/events/MonitorEvent.java | 18 +++++++++++++ .../runner/qemu/events/OsinfoEvent.java | 19 +++++++++++++ 5 files changed, 81 insertions(+), 24 deletions(-) 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 b0001e4..45d2487 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 @@ -69,14 +69,14 @@ public class GuestAgentClient extends AgentConnector { */ @Override protected void agentConnected() { - logger.fine(() -> "guest agent connected"); + logger.fine(() -> "Guest agent connected"); connected = true; rep().fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); } @Override protected void agentDisconnected() { - logger.fine(() -> "guest agent disconnected"); + logger.fine(() -> "Guest agent disconnected"); connected = false; } @@ -88,15 +88,16 @@ public class GuestAgentClient extends AgentConnector { */ @Override protected void processInput(String line) throws IOException { - logger.fine(() -> "guest agent(in): " + line); + logger.finer(() -> "guest agent(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("return") || response.has("error")) { QmpCommand executed = executing.poll(); - logger.fine(() -> String.format("(Previous \"guest agent(in)\"" + logger.finer(() -> String.format("(Previous \"guest agent(in)\"" + " is result from executing %s)", executed)); if (executed instanceof QmpGuestGetOsinfo) { var osInfo = new OsinfoEvent(response.get("return")); + logger.fine(() -> "Guest agent triggers: " + osInfo); rep().fire(osInfo); } } @@ -120,10 +121,11 @@ public class GuestAgentClient extends AgentConnector { return; } var command = event.command(); - logger.fine(() -> "guest agent(out): " + command.toString()); + logger.fine(() -> "Guest handles: " + event); String asText; try { asText = command.asText(); + logger.finer(() -> "guest agent(out): " + asText); } catch (JsonProcessingException e) { logger.log(Level.SEVERE, e, () -> "Cannot serialize Json: " + e.getMessage()); @@ -163,8 +165,8 @@ public class GuestAgentClient extends AgentConnector { } event.suspendHandling(); suspendedStop = event; - logger.fine(() -> "Sending powerdown command, waiting for" - + " termination until " + waitUntil); + logger.fine(() -> "Attempting shutdown through guest agent," + + " waiting for termination until " + waitUntil); powerdownTimer = Components.schedule(t -> { logger.fine(() -> "Powerdown timeout reached."); synchronized (this) { 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 6be7603..e844bc4 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 @@ -108,24 +108,30 @@ public class QemuMonitor extends QemuConnector { @Override protected void processInput(String line) throws IOException { - logger.fine(() -> "monitor(in): " + line); + logger.finer(() -> "monitor(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("QMP")) { monitorReady = true; + logger.fine(() -> "QMP connection ready"); rep().fire(new MonitorReady()); return; } if (response.has("return") || response.has("error")) { QmpCommand executed = executing.poll(); - logger.fine( + logger.finer( () -> String.format("(Previous \"monitor(in)\" is result " + "from executing %s)", executed)); - rep().fire(MonitorResult.from(executed, response)); + var monRes = MonitorResult.from(executed, response); + logger.fine(() -> "QMP triggers: " + monRes); + rep().fire(monRes); return; } if (response.has("event")) { - MonitorEvent.from(response).ifPresent(rep()::fire); + MonitorEvent.from(response).ifPresent(me -> { + logger.fine(() -> "QMP triggers: " + me); + rep().fire(me); + }); } } catch (JsonProcessingException e) { throw new IOException(e); @@ -141,7 +147,7 @@ public class QemuMonitor extends QemuConnector { public void onClosed(Closed event, SocketIOChannel channel) { channel.associated(this, getClass()).ifPresent(qm -> { super.onClosed(event, channel); - logger.finer(() -> "QMP socket closed."); + logger.fine(() -> "QMP connection closed."); monitorReady = false; }); } @@ -158,7 +164,7 @@ public class QemuMonitor extends QemuConnector { public void onMonitorCommand(MonitorCommand event) throws IOException { // Check prerequisites if (!monitorReady && !(event.command() instanceof QmpCapabilities)) { - logger.severe(() -> "Premature monitor command (not ready): " + logger.severe(() -> "Premature QMP command (not ready): " + event.command()); rep().fire(new Stop()); return; @@ -166,10 +172,11 @@ public class QemuMonitor extends QemuConnector { // Send the command var command = event.command(); - logger.fine(() -> "monitor(out): " + command.toString()); + logger.fine(() -> "QMP handles: " + event.toString()); String asText; try { asText = command.asText(); + logger.finer(() -> "monitor(out): " + asText); } catch (JsonProcessingException e) { logger.log(Level.SEVERE, e, () -> "Cannot serialize Json: " + e.getMessage()); @@ -192,8 +199,8 @@ public class QemuMonitor extends QemuConnector { @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onStop(Stop event) { if (!monitorReady) { - logger.fine(() -> "No QMP connection," - + " cannot send powerdown command"); + logger.fine(() -> "Not sending QMP powerdown command" + + " because QMP connection is closed"); return; } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java index f50d397..cac41d4 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -59,10 +59,14 @@ public class VmopAgentClient extends AgentConnector { */ @Handler public void onVmopAgentLogIn(VmopAgentLogIn event) throws IOException { - logger.fine(() -> "vmop agent(out): login " + event.user()); if (writer().isPresent()) { + logger.fine(() -> "Vmop agent handles:" + event); executing.add(event); + logger.finer(() -> "vmop agent(out): login " + event.user()); sendCommand("login " + event.user()); + } else { + logger + .warning(() -> "No vmop agent connection for sending " + event); } } @@ -74,9 +78,10 @@ public class VmopAgentClient extends AgentConnector { */ @Handler public void onVmopAgentLogout(VmopAgentLogOut event) throws IOException { - logger.fine(() -> "vmop agent(out): logout"); if (writer().isPresent()) { + logger.fine(() -> "Vmop agent handles:" + event); executing.add(event); + logger.finer(() -> "vmop agent(out): logout"); sendCommand("logout"); } } @@ -85,23 +90,27 @@ public class VmopAgentClient extends AgentConnector { @SuppressWarnings({ "PMD.UnnecessaryReturn", "PMD.AvoidLiteralsInIfCondition" }) protected void processInput(String line) throws IOException { - logger.fine(() -> "vmop agent(in): " + line); + logger.finer(() -> "vmop agent(in): " + line); // Check validity if (line.isEmpty() || !Character.isDigit(line.charAt(0))) { - logger.warning(() -> "Illegal response: " + line); + logger.warning(() -> "Illegal vmop agent response: " + line); return; } // Check positive responses if (line.startsWith("220 ")) { - rep().fire(new VmopAgentConnected()); + var evt = new VmopAgentConnected(); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); return; } if (line.startsWith("201 ")) { Event cmd = executing.pop(); if (cmd instanceof VmopAgentLogIn login) { - rep().fire(new VmopAgentLoggedIn(login)); + var evt = new VmopAgentLoggedIn(login); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); } else { logger.severe(() -> "Response " + line + " does not match executing command " + cmd); @@ -111,7 +120,9 @@ public class VmopAgentClient extends AgentConnector { if (line.startsWith("202 ")) { Event cmd = executing.pop(); if (cmd instanceof VmopAgentLogOut logout) { - rep().fire(new VmopAgentLoggedOut(logout)); + var evt = new VmopAgentLoggedOut(logout); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); } else { logger.severe(() -> "Response " + line + "does not match executing command " + cmd); @@ -125,7 +136,7 @@ public class VmopAgentClient extends AgentConnector { } // Error - logger.warning(() -> "Error response: " + line); + logger.warning(() -> "Error response from vmop agent: " + line); executing.pop(); } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java index e35a172..6663fa4 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java @@ -20,6 +20,8 @@ package org.jdrupes.vmoperator.runner.qemu.events; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** @@ -112,4 +114,20 @@ public class MonitorEvent extends Event { public JsonNode data() { return data; } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [").append(data); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java index 294ac7b..0e90019 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java @@ -19,6 +19,8 @@ package org.jdrupes.vmoperator.runner.qemu.events; import com.fasterxml.jackson.databind.JsonNode; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** @@ -40,4 +42,21 @@ public class OsinfoEvent extends Event { public JsonNode osinfo() { return osinfo; } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(osinfo); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } } From 10f3028f06d58d81f6972d5c29fcf8f1dfc698a7 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 30 Apr 2025 16:27:15 +0200 Subject: [PATCH 42/50] Increase concurrency and avoid race condition. --- .../vmoperator/manager/Controller.java | 3 +- .../jdrupes/vmoperator/manager/VmMonitor.java | 58 +++++++++++++------ 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index c15acc5..ce14488 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -50,6 +50,7 @@ import org.jdrupes.vmoperator.manager.events.VmPoolChanged; import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; +import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; @@ -94,7 +95,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; public class Controller extends Component { private String namespace; - private final ChannelManager chanMgr; + private final ChannelManager chanMgr; /** * Creates a new instance. diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 09bade8..b667aa6 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -22,7 +22,6 @@ import com.google.gson.JsonObject; import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; @@ -32,7 +31,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Optional; import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.Constants.Status; @@ -57,16 +55,28 @@ import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; +import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; /** - * Watches for changes of VM definitions. + * Watches for changes of VM definitions. When a VM definition (CR) + * becomes known, is is registered with a {@link ChannelManager} and thus + * gets an associated {@link VmChannel} and an associated + * {@link EventPipeline}. + * + * The {@link EventPipeline} is used for submitting an action that processes + * the change data from kubernetes, eventually transforming it to a + * {@link VmResourceChanged} event that is handled by another + * {@link EventPipeline} associated with the {@link VmChannel}. This + * event pipeline should be used for all events related to changes of + * a particular VM. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) public class VmMonitor extends AbstractMonitor { - private final ChannelManager channelManager; + private final ChannelManager channelManager; /** * Instantiates a new VM definition watcher. @@ -75,7 +85,7 @@ public class VmMonitor extends * @param channelManager the channel manager */ public VmMonitor(Channel componentChannel, - ChannelManager channelManager) { + ChannelManager channelManager) { super(componentChannel, VmDefinition.class, VmDefinitions.class); this.channelManager = channelManager; @@ -122,14 +132,18 @@ public class VmMonitor extends @Override protected void handleChange(K8sClient client, Watch.Response response) { - V1ObjectMeta metadata = response.object.getMetadata(); - AtomicBoolean toBeAdded = new AtomicBoolean(false); - VmChannel channel = channelManager.channel(metadata.getName()) - .orElseGet(() -> { - toBeAdded.set(true); - return channelManager.createChannel(metadata.getName()); - }); + var name = response.object.getMetadata().getName(); + // Process the response data on a VM specific pipeline to + // increase concurrency when e.g. starting many VMs. + var preparing = channelManager.associated(name) + .orElseGet(() -> newEventPipeline()); + preparing.submit("VmChange[" + name + "]", + () -> processChange(client, response, preparing)); + } + + private void processChange(K8sClient client, + Watch.Response response, EventPipeline preparing) { // Get full definition and associate with channel as backup var vmDef = response.object; if (vmDef.data() == null) { @@ -137,6 +151,9 @@ public class VmMonitor extends // https://github.com/kubernetes-client/java/issues/3215 vmDef = getModel(client, vmDef); } + var name = response.object.getMetadata().getName(); + var channel = channelManager.channel(name) + .orElseGet(() -> channelManager.createChannel(name)); if (vmDef.data() != null) { // New data, augment and save addExtraData(vmDef, channel.vmDefinition()); @@ -150,9 +167,7 @@ public class VmMonitor extends + response.object.getMetadata()); return; } - if (toBeAdded.get()) { - channelManager.put(vmDef.name(), channel); - } + channelManager.put(name, channel, preparing); // Create and fire changed event. Remove channel from channel // manager on completion. @@ -199,9 +214,16 @@ public class VmMonitor extends @Handler public void onPodChanged(PodChanged event, VmChannel channel) { var vmDef = channel.vmDefinition(); - updateNodeInfo(event, vmDef); - channel - .fire(new VmResourceChanged(ResponseType.MODIFIED, vmDef, false, true)); + + // Make sure that this is properly sync'd with VM CR changes. + channelManager.associated(vmDef.name()) + .orElseGet(() -> activeEventPipeline()) + .submit("NodeInfo[" + vmDef.name() + "]", + () -> { + updateNodeInfo(event, vmDef); + channel.fire(new VmResourceChanged(ResponseType.MODIFIED, + vmDef, false, true)); + }); } private void updateNodeInfo(PodChanged event, VmDefinition vmDef) { From a5433c869bb5d25601060200cba2335bf250788a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 3 May 2025 22:29:42 +0200 Subject: [PATCH 43/50] Upgrade webconsole base library. --- org.jdrupes.vmoperator.manager/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index eda5ce0..4ce4ed0 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -17,7 +17,7 @@ dependencies { implementation 'org.jgrapes:org.jgrapes.io:[2.12.1,3)' implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.2.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.3.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.8.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)' implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)' From 76b579c404f4ea691bf5a00cde47ddb8d35b1342 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 4 May 2025 11:36:55 +0200 Subject: [PATCH 44/50] Add key, allowing vue to optimize. --- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html index 5a28cb8..3197440 100644 --- a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html @@ -30,7 +30,7 @@ -