From d9eaea2cc3f26945db99ee4d624c3a4afb58f703 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 13 Jun 2024 15:45:20 +0200 Subject: [PATCH 001/274] Set version. --- deploy/vmop-deployment.yaml | 2 +- dev-example/test-vm.yaml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml index 648cc39..63b3d1e 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -20,7 +20,7 @@ spec: containers: - name: vm-operator image: >- - ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:3.0.0 volumeMounts: - name: config mountPath: /etc/opt/vmoperator diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index e874ef8..4acbfe4 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -7,8 +7,7 @@ spec: image: repository: docker-registry.lan.mnl.de path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine - version: latest - pullPolicy: Always + version: 3.0.0 permissions: - user: admin From bda983f753d7771f2226107e93456c05664cb3d6 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 22 Jun 2024 15:22:57 +0200 Subject: [PATCH 002/274] Update. --- 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 648cc39..b7467d4 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -20,7 +20,7 @@ spec: containers: - name: vm-operator image: >- - ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:3.1.1 volumeMounts: - name: config mountPath: /etc/opt/vmoperator From 6c1dde7e822f12ced48ffd48912b54098d64ac4e Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 10 Aug 2024 13:41:05 +0200 Subject: [PATCH 003/274] Update image version. --- 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 648cc39..adbfb3b 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -20,7 +20,7 @@ spec: containers: - name: vm-operator image: >- - ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:3.2.0 volumeMounts: - name: config mountPath: /etc/opt/vmoperator From 69767d6f5e474cdcccc3de86bfe46ca3a85c03e2 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 20 Aug 2024 10:04:23 +0200 Subject: [PATCH 004/274] Minor edits. --- webpages/vm-operator/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webpages/vm-operator/index.md b/webpages/vm-operator/index.md index 32d2cd5..9859fe2 100644 --- a/webpages/vm-operator/index.md +++ b/webpages/vm-operator/index.md @@ -1,6 +1,6 @@ --- -title: Run Qemu based VMs on Kubernetes -description: A Kubernetes operator for running virtual machines (notably Qemu VMs) in pods on Kubernetes with a web interface for admins and users. +title: Run VMs on Kubernetes using Qemu/KVM and SPICE +description: A solution for running VMs on Kubernetes with a web interface for admins and users. Focuses on running Qemu/KVM virtual machines and using SPICE as display protocol. layout: vm-operator --- @@ -8,8 +8,8 @@ layout: vm-operator ![Overview picture](index-pic.svg) -The goal of this project is to provide easy to use and flexible components -for running Qemu based VMs in Kubernetes pods. +The goal of this project is to provide an easy to use and flexible solution +for running Qemu/KVM based 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 8adb1d2c1cd1d05323334dcab8a2cc95d61ee8ee Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 20 Aug 2024 10:30:31 +0200 Subject: [PATCH 005/274] Update version. --- dev-example/test-vm.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index 4acbfe4..b716159 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -7,7 +7,7 @@ spec: image: repository: docker-registry.lan.mnl.de path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine - version: 3.0.0 + version: 3.2.0 permissions: - user: admin From 452e0604cacf5765129a780b5f97079eb015af98 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 6 Oct 2024 14:07:09 +0200 Subject: [PATCH 006/274] Update version. --- 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 648cc39..33d9674 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -20,7 +20,7 @@ spec: containers: - name: vm-operator image: >- - ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:3.4.0 volumeMounts: - name: config mountPath: /etc/opt/vmoperator From 86d3c757796a0613b30ed8d9db3fd1ffb60da213 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 26 Oct 2024 22:56:11 +0200 Subject: [PATCH 007/274] Update. --- 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 33d9674..f7cc45b 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -20,7 +20,7 @@ spec: containers: - name: vm-operator image: >- - ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:3.4.0 + ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:3.4.1 volumeMounts: - name: config mountPath: /etc/opt/vmoperator From c8781c2d8eedd193e90744412aee3bbd5e4c75a7 Mon Sep 17 00:00:00 2001 From: Michael Lipp Date: Fri, 8 Nov 2024 16:48:07 +0000 Subject: [PATCH 008/274] Use less gson internally. --- dev-example/config.yaml | 20 +- dev-example/kustomization.yaml | 20 +- dev-example/test-vm.yaml | 14 +- .../org/jdrupes/vmoperator/common/K8s.java | 21 -- .../vmoperator/common/K8sGenericStub.java | 22 +- .../vmoperator/common/VmDefinition.java | 332 ++++++++++++++++++ .../vmoperator/common/VmDefinitionModel.java | 104 ------ .../manager/events/GetDisplayPassword.java | 8 +- .../vmoperator/manager/events/VmChannel.java | 14 +- .../manager/events/VmDefChanged.java | 18 +- .../vmoperator/manager/runnerConfig.ftl.yaml | 139 ++++---- .../vmoperator/manager/runnerDataPvc.ftl.yaml | 4 +- .../vmoperator/manager/runnerDiskPvc.ftl.yaml | 8 +- .../manager/runnerLoadBalancer.ftl.yaml | 16 +- .../vmoperator/manager/runnerPod.ftl.yaml | 73 ++-- .../vmoperator/manager/runnerSts.ftl.yaml | 194 ---------- .../manager/ConfigMapReconciler.java | 7 +- .../manager/DisplaySecretMonitor.java | 5 +- .../manager/DisplaySecretReconciler.java | 24 +- .../manager/LoadBalancerReconciler.java | 104 ++++-- .../vmoperator/manager/PodReconciler.java | 17 +- .../vmoperator/manager/PvcReconciler.java | 52 ++- .../vmoperator/manager/Reconciler.java | 193 +++++----- .../manager/StatefulSetReconciler.java | 33 +- .../jdrupes/vmoperator/manager/VmMonitor.java | 90 ++--- .../test-resources/basic-vm.yaml | 64 ++++ .../test-resources/unittest-vm.yaml | 35 -- .../vmoperator/manager/BasicTests.java | 319 ++++++++++++++--- .../org/jdrupes/vmoperator/util/DataPath.java | 176 ++++++++++ org.jdrupes.vmoperator.vmconlet/build.gradle | 2 +- .../jdrupes/vmoperator/vmconlet/VmConlet.java | 113 +++--- org.jdrupes.vmoperator.vmviewer/build.gradle | 2 +- .../jdrupes/vmoperator/vmviewer/VmViewer.java | 67 ++-- 33 files changed, 1405 insertions(+), 905 deletions(-) create mode 100644 org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java delete mode 100644 org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml create mode 100644 org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml delete mode 100644 org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml create mode 100644 org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 579103d..3f973e4 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -9,6 +9,14 @@ "/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": @@ -17,12 +25,12 @@ "/WebConsole": "/LoginConlet": users: - - name: admin - fullName: Administrator - password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - - name: test - fullName: Test Account - password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml index 19b6295..f6e51b8 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -35,6 +35,14 @@ patches: "/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": @@ -43,12 +51,12 @@ patches: "/WebConsole": "/LoginConlet": users: - admin: - fullName: Administrator - password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - test: - fullName: Test Account - password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index e874ef8..6f71b6a 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -11,12 +11,12 @@ spec: pullPolicy: Always permissions: - - user: admin - may: - - "*" - - user: test - may: - - "accessConsole" + - user: admin + may: + - "*" + - user: test + may: + - "accessConsole" resources: requests: @@ -62,3 +62,5 @@ spec: spice: port: 5810 generateSecret: true + + loadBalancerService: {} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java index 481f724..7a28185 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java @@ -157,27 +157,6 @@ public class K8s { return Optional.of(apiRes); } - /** - * Get an object from its metadata. - * - * @param the generic type - * @param the generic type - * @param api the api - * @param meta the meta - * @return the object - */ - @Deprecated - @SuppressWarnings("PMD.GenericsNaming") - public static - Optional - get(GenericKubernetesApi api, V1ObjectMeta meta) { - var response = api.get(meta.getNamespace(), meta.getName()); - if (response.isSuccess()) { - return Optional.of(response.getObject()); - } - return Optional.empty(); - } - /** * Apply the given patch data. * 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 f118a17..09516a0 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 @@ -27,6 +27,7 @@ import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.Strings; import io.kubernetes.client.util.generic.GenericKubernetesApi; import io.kubernetes.client.util.generic.KubernetesApiResponse; +import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.options.GetOptions; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; @@ -47,7 +48,7 @@ import java.util.function.Function; * @param the generic type * @param the generic type */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" }) public class K8sGenericStub { protected final K8sClient client; @@ -224,7 +225,7 @@ public class K8sGenericStub patch(String patchType, V1Patch patch, @@ -239,7 +240,7 @@ public class K8sGenericStub @@ -248,6 +249,21 @@ public class K8sGenericStub apply(DynamicKubernetesObject def) throws ApiException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + return patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(client.getJSON().serialize(def)), opts); + } + /** * Update the object. * 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 new file mode 100644 index 0000000..71ea7f1 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -0,0 +1,332 @@ +/* + * VM-Operator + * Copyright (C) 2024 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.common; + +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.util.DataPath; + +/** + * Represents a VM definition. + */ +@SuppressWarnings({ "PMD.DataClass" }) +public class VmDefinition { + + private String kind; + private String apiVersion; + private V1ObjectMeta metadata; + private Map spec; + private Map status; + private final Map extra = new ConcurrentHashMap<>(); + + /** + * The VM state from the VM definition. + */ + public enum RequestedVmState { + STOPPED, RUNNING + } + + /** + * Permissions for accessing and manipulating the VM. + */ + public enum Permission { + START("start"), STOP("stop"), RESET("reset"), + ACCESS_CONSOLE("accessConsole"); + + @SuppressWarnings("PMD.UseConcurrentHashMap") + private static Map reprs = new HashMap<>(); + + static { + for (var value : EnumSet.allOf(Permission.class)) { + reprs.put(value.repr, value); + } + } + + private final String repr; + + Permission(String repr) { + this.repr = repr; + } + + /** + * Create permission from representation in CRD. + * + * @param value the value + * @return the permission + */ + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public static Set parse(String value) { + if ("*".equals(value)) { + return EnumSet.allOf(Permission.class); + } + return Set.of(reprs.get(value)); + } + + @Override + public String toString() { + return repr; + } + } + + /** + * Gets the kind. + * + * @return the kind + */ + public String getKind() { + return kind; + } + + /** + * Sets the kind. + * + * @param kind the kind to set + */ + public void setKind(String kind) { + this.kind = kind; + } + + /** + * Gets the api version. + * + * @return the apiVersion + */ + public String getApiVersion() { + return apiVersion; + } + + /** + * Sets the api version. + * + * @param apiVersion the apiVersion to set + */ + public void setApiVersion(String apiVersion) { + this.apiVersion = apiVersion; + } + + /** + * Gets the metadata. + * + * @return the metadata + */ + public V1ObjectMeta getMetadata() { + return metadata; + } + + /** + * Gets the metadata. + * + * @return the metadata + */ + public V1ObjectMeta metadata() { + return metadata; + } + + /** + * Sets the metadata. + * + * @param metadata the metadata to set + */ + public void setMetadata(V1ObjectMeta metadata) { + this.metadata = metadata; + } + + /** + * Gets the spec. + * + * @return the spec + */ + public Map getSpec() { + return spec; + } + + /** + * Gets the spec. + * + * @return the spec + */ + public Map spec() { + return spec; + } + + /** + * Get a value from the spec using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromSpec(Object... selectors) { + return DataPath.get(spec, selectors); + } + + /** + * Get a value from the `spec().get("vm")` using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromVm(Object... selectors) { + return DataPath.get(spec, "vm") + .flatMap(vm -> DataPath.get(vm, selectors)); + } + + /** + * Sets the spec. + * + * @param spec the spec to set + */ + public void setSpec(Map spec) { + this.spec = spec; + } + + /** + * Gets the status. + * + * @return the status + */ + public Map getStatus() { + return status; + } + + /** + * Gets the status. + * + * @return the status + */ + public Map status() { + return status; + } + + /** + * Get a value from the status using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromStatus(Object... selectors) { + return DataPath.get(status, selectors); + } + + /** + * Sets the status. + * + * @param status the status to set + */ + public void setStatus(Map status) { + this.status = status; + } + + /** + * Set extra data (locally used, unknown to kubernetes). + * + * @param property the property + * @param value the value + * @return the VM definition + */ + public VmDefinition extra(String property, Object value) { + extra.put(property, value); + return this; + } + + /** + * Return extra data. + * + * @param property the property + * @return the object + */ + @SuppressWarnings("unchecked") + public T extra(String property) { + return (T) extra.get(property); + } + + /** + * Returns the definition's name. + * + * @return the string + */ + public String name() { + return metadata.getName(); + } + + /** + * Returns the definition's namespace. + * + * @return the string + */ + public String namespace() { + return metadata.getNamespace(); + } + + /** + * Return the requested VM state + * + * @return the string + */ + public RequestedVmState vmState() { + // TODO + return fromVm("state") + .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING + : RequestedVmState.STOPPED) + .orElse(RequestedVmState.STOPPED); + } + + /** + * Collect all permissions for the given user with the given roles. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + return this.>> fromSpec("permissions") + .orElse(Collections.emptyList()).stream() + .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(p, "role").map(roles::contains).orElse(false)) + .map(p -> DataPath.> get(p, "may") + .orElse(Collections.emptyList()).stream()) + .flatMap(Function.identity()) + .map(Permission::parse).map(Set::stream) + .flatMap(Function.identity()).collect(Collectors.toSet()); + } + + /** + * Get the display password serial. + * + * @return the optional + */ + public Optional displayPasswordSerial() { + return this. fromStatus("displayPasswordSerial") + .map(Number::longValue); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java index 987b4c8..d4ae5da 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java @@ -20,16 +20,6 @@ package org.jdrupes.vmoperator.common; import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; -import java.util.Collection; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; -import org.jdrupes.vmoperator.util.GsonPtr; /** * Represents a VM definition. @@ -37,55 +27,6 @@ import org.jdrupes.vmoperator.util.GsonPtr; @SuppressWarnings("PMD.DataClass") public class VmDefinitionModel extends K8sDynamicModel { - /** - * The VM state from the VM definition. - */ - public enum RequestedVmState { - STOPPED, RUNNING - } - - /** - * Permissions for accessing and manipulating the VM. - */ - public enum Permission { - START("start"), STOP("stop"), RESET("reset"), - ACCESS_CONSOLE("accessConsole"); - - @SuppressWarnings("PMD.UseConcurrentHashMap") - private static Map reprs = new HashMap<>(); - - static { - for (var value : EnumSet.allOf(Permission.class)) { - reprs.put(value.repr, value); - } - } - - private final String repr; - - Permission(String repr) { - this.repr = repr; - } - - /** - * Create permission from representation in CRD. - * - * @param value the value - * @return the permission - */ - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - public static Set parse(String value) { - if ("*".equals(value)) { - return EnumSet.allOf(Permission.class); - } - return Set.of(reprs.get(value)); - } - - @Override - public String toString() { - return repr; - } - } - /** * Instantiates a new model from the JSON representation. * @@ -95,49 +36,4 @@ public class VmDefinitionModel extends K8sDynamicModel { public VmDefinitionModel(Gson delegate, JsonObject json) { super(delegate, json); } - - /** - * Collect all permissions for the given user with the given roles. - * - * @param user the user - * @param roles the roles - * @return the sets the - */ - public Set permissionsFor(String user, - Collection roles) { - return GsonPtr.to(data()) - .getAsListOf(JsonObject.class, "spec", "permissions") - .stream().filter(p -> GsonPtr.to(p).getAsString("user") - .map(u -> u.equals(user)).orElse(false) - || GsonPtr.to(p).getAsString("role").map(roles::contains) - .orElse(false)) - .map(p -> GsonPtr.to(p).getAsListOf(JsonPrimitive.class, "may") - .stream()) - .flatMap(Function.identity()).map(p -> p.getAsString()) - .map(Permission::parse).map(Set::stream) - .flatMap(Function.identity()).collect(Collectors.toSet()); - } - - /** - * Return the requested VM state - * - * @return the string - */ - public RequestedVmState vmState() { - return GsonPtr.to(data()).getAsString("spec", "vm", "state") - .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING - : RequestedVmState.STOPPED) - .orElse(RequestedVmState.STOPPED); - } - - /** - * Get the display password serial. - * - * @return the optional - */ - public Optional displayPasswordSerial() { - return GsonPtr.to(status()) - .get(JsonPrimitive.class, "displayPasswordSerial") - .map(JsonPrimitive::getAsLong); - } } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java index 37eddec..3322f1a 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.manager.events; import java.util.Optional; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Event; /** @@ -28,14 +28,14 @@ import org.jgrapes.core.Event; @SuppressWarnings("PMD.DataClass") public class GetDisplayPassword extends Event { - private final VmDefinitionModel vmDef; + private final VmDefinition vmDef; /** * Instantiates a new returns the display secret. * * @param vmDef the vm name */ - public GetDisplayPassword(VmDefinitionModel vmDef) { + public GetDisplayPassword(VmDefinition vmDef) { this.vmDef = vmDef; } @@ -44,7 +44,7 @@ public class GetDisplayPassword extends Event { * * @return the vm definition */ - public VmDefinitionModel vmDefinition() { + public VmDefinition vmDefinition() { return vmDef; } 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 46861ce..5ea282a 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 @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Subchannel.DefaultSubchannel; @@ -32,7 +32,7 @@ public class VmChannel extends DefaultSubchannel { private final EventPipeline pipeline; private final K8sClient client; - private VmDefinitionModel vmDefinition; + private VmDefinition definition; private long generation = -1; /** @@ -56,18 +56,18 @@ public class VmChannel extends DefaultSubchannel { * @return the watch channel */ @SuppressWarnings("PMD.LinguisticNaming") - public VmChannel setVmDefinition(VmDefinitionModel definition) { - this.vmDefinition = definition; + public VmChannel setVmDefinition(VmDefinition definition) { + this.definition = definition; return this; } /** * Returns the last known definition of the VM. * - * @return the json object + * @return the defintion */ - public VmDefinitionModel vmDefinition() { - return vmDefinition; + public VmDefinition vmDefinition() { + return definition; } /** 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/VmDefChanged.java index a2bafb7..a8873cf 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/VmDefChanged.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -36,7 +36,7 @@ public class VmDefChanged extends Event { private final K8sObserver.ResponseType type; private final boolean specChanged; - private final VmDefinitionModel vmDef; + private final VmDefinition vmDefinition; /** * Instantiates a new VM changed event. @@ -46,10 +46,10 @@ public class VmDefChanged extends Event { * @param vmDefinition the VM definition */ public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged, - VmDefinitionModel vmDefinition) { + VmDefinition vmDefinition) { this.type = type; this.specChanged = specChanged; - this.vmDef = vmDefinition; + this.vmDefinition = vmDefinition; } /** @@ -69,19 +69,19 @@ public class VmDefChanged extends Event { } /** - * Returns the object. + * Return the VM definition. * - * @return the object. + * @return the VM definition */ - public VmDefinitionModel vmDefinition() { - return vmDef; + public VmDefinition vmDefinition() { + return vmDefinition; } @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append(Components.objectName(this)).append(" [") - .append(vmDef.getMetadata().getName()).append(' ').append(type); + .append(vmDefinition.name()).append(' ').append(type); if (channels() != null) { builder.append(", channels=").append(Channel.toString(channels())); } 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 9e3d5ef..214b5b8 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 @@ -1,141 +1,142 @@ apiVersion: v1 kind: ConfigMap metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } + - apiVersion: ${ cr.apiVersion } kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false - + data: config.yaml: | "/Runner": # The directory used to store data files. Defaults to (depending on # values available): - # * $XDG_DATA_HOME/vmrunner/${ cr.metadata.name.asString } - # * $HOME/.local/share/vmrunner/${ cr.metadata.name.asString } - # * ./${ cr.metadata.name.asString } + # * $XDG_DATA_HOME/vmrunner/${ cr.name() } + # * $HOME/.local/share/vmrunner/${ cr.name() } + # * ./${ cr.name() } dataDir: /var/local/vm-data # The directory used to store runtime files. Defaults to (depending on # values available): - # * $XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString } - # * /tmp/$USER/vmrunner/${ cr.metadata.name.asString } - # * /tmp/vmrunner/${ cr.metadata.name.asString } - # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString }" + # * $XDG_RUNTIME_DIR/vmrunner/${ cr.name() } + # * /tmp/$USER/vmrunner/${ cr.name() } + # * /tmp/vmrunner/${ cr.name() } + # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.name() }" + <#assign spec = cr.spec() /> # The template to use. Resolved relative to /usr/share/vmrunner/templates. # template: "Standard-VM-latest.ftl.yaml" - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.source?? > - template: ${ cr.spec.runnerTemplate.source.asString } + <#if spec.runnerTemplate?? && spec.runnerTemplate.source?? > + template: ${ cm.spec().runnerTemplate.source } # The template is copied to the data diretory when the VM starts for # the first time. Subsequent starts use the copy unless this option is set. - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.update?? > - updateTemplate: ${ cr.spec.runnerTemplate.update.asBoolean?c } + <#if spec.runnerTemplate?? && spec.runnerTemplate.update?? > + updateTemplate: ${ spec.runnerTemplate.update?c } # Whether a shutdown initiated by the guest stops the pod deployment - guestShutdownStops: ${ cr.spec.guestShutdownStops!false?c } + guestShutdownStops: ${ (spec.guestShutdownStops!false)?c } # When incremented, the VM is reset. The value has no default value, # 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.resetCount } + resetCounter: ${ cr.extra("resetCount")?c } # Forward the cloud-init data if provided - <#if cr.spec.cloudInit??> + <#if spec.cloudInit??> cloudInit: - <#if cr.spec.cloudInit.metaData??> - metaData: ${ cr.spec.cloudInit.metaData.toString() } + <#if spec.cloudInit.metaData??> + metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData, cr.metadata())) } <#else> metaData: {} - <#if cr.spec.cloudInit.userData??> - userData: ${ cr.spec.cloudInit.userData.toString() } + <#if spec.cloudInit.userData??> + userData: ${ toJson(spec.cloudInit.userData) } <#else> userData: {} - <#if cr.spec.cloudInit.networkConfig??> - networkConfig: ${ cr.spec.cloudInit.networkConfig.toString() } + <#if spec.cloudInit.networkConfig??> + networkConfig: ${ toJson(spec.cloudInit.networkConfig) } # Define the VM (required) vm: # The VM's name (required) - name: ${ cr.metadata.name.asString } + name: ${ cr.name() } # The machine's uuid. If none is specified, a uuid is generated # and stored in the data directory. If the uuid is important # (e.g. because licenses depend on it) it is recommaned to specify # it here explicitly or to carefully backup the data directory. # uuid: "generated uuid" - <#if cr.spec.vm.machineUuid??> - uuid: "${ cr.spec.vm.machineUuid.asString }" + <#if spec.vm.machineUuid??> + uuid: "${ spec.vm.machineUuid }" # Whether to provide a software TPM (defaults to false) # useTpm: false - useTpm: ${ cr.spec.vm.useTpm.asBoolean?c } + useTpm: ${ spec.vm.useTpm?c } # How to boot (see https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml): # * bios # * uefi[-4m] # * secure[-4m] - firmware: ${ cr.spec.vm.firmware.asString } + firmware: ${ spec.vm.firmware } # Whether to show a boot menu. # bootMenu: false - bootMenu: ${ cr.spec.vm.bootMenu.asBoolean?c } + bootMenu: ${ spec.vm.bootMenu?c } # When terminating, a graceful powerdown is attempted. If it # doesn't succeed within the given timeout (seconds) SIGTERM # is sent to Qemu. # powerdownTimeout: 900 - powerdownTimeout: ${ cr.spec.vm.powerdownTimeout.asLong?c } + powerdownTimeout: ${ spec.vm.powerdownTimeout?c } # CPU settings - cpuModel: ${ cr.spec.vm.cpuModel.asString } + cpuModel: ${ spec.vm.cpuModel } # Setting maximumCpus to 1 omits the "-smp" options. The defaults (0) # cause the corresponding property to be omitted from the "-smp" option. # If currentCpus is greater than maximumCpus, the latter is adjusted. - <#if cr.spec.vm.maximumCpus?? > - maximumCpus: ${ parseQuantity(cr.spec.vm.maximumCpus.asString)?c } + <#if spec.vm.maximumCpus?? > + maximumCpus: ${ parseQuantity(spec.vm.maximumCpus)?c } - <#if cr.spec.vm.cpuTopology?? > - sockets: ${ cr.spec.vm.cpuTopology.sockets.asInt?c } - diesPerSocket: ${ cr.spec.vm.cpuTopology.diesPerSocket.asInt?c } - coresPerDie: ${ cr.spec.vm.cpuTopology.coresPerDie.asInt?c } - threadsPerCore: ${ cr.spec.vm.cpuTopology.threadsPerCore.asInt?c } + <#if spec.vm.cpuTopology?? > + sockets: ${ spec.vm.cpuTopology.sockets?c } + diesPerSocket: ${ spec.vm.cpuTopology.diesPerSocket?c } + coresPerDie: ${ spec.vm.cpuTopology.coresPerDie?c } + threadsPerCore: ${ spec.vm.cpuTopology.threadsPerCore?c } - <#if cr.spec.vm.currentCpus?? > - currentCpus: ${ parseQuantity(cr.spec.vm.currentCpus.asString)?c } + <#if spec.vm.currentCpus?? > + currentCpus: ${ parseQuantity(spec.vm.currentCpus)?c } # RAM settings # Maximum defaults to 1G - maximumRam: "${ formatMemory(parseQuantity(cr.spec.vm.maximumRam.asString)) }" - <#if cr.spec.vm.currentRam?? > - currentRam: "${ formatMemory(parseQuantity(cr.spec.vm.currentRam.asString)) }" + maximumRam: "${ formatMemory(parseQuantity(spec.vm.maximumRam)) }" + <#if spec.vm.currentRam?? > + currentRam: "${ formatMemory(parseQuantity(spec.vm.currentRam)) }" # RTC settings. # rtcBase: utc # rtcClock: rt - rtcBase: ${ cr.spec.vm.rtcBase.asString } - rtcClock: ${ cr.spec.vm.rtcClock.asString } + rtcBase: ${ spec.vm.rtcBase } + rtcClock: ${ spec.vm.rtcClock } # Network settings # Supported types are "tap" and "user" (for debugging). Type "user" @@ -147,19 +148,19 @@ data: # mac: (undefined) network: <#assign nwCounter = 0/> - <#list cr.spec.vm.networks.asList() as itf> + <#list spec.vm.networks as itf> <#if itf.tap??> - type: tap - device: ${ itf.tap.device.asString } - bridge: ${ itf.tap.bridge.asString } + device: ${ itf.tap.device } + bridge: ${ itf.tap.bridge } <#if itf.tap.mac??> - mac: "${ itf.tap.mac.asString }" + mac: "${ itf.tap.mac }" <#elseif itf.user??> - type: user - device: ${ itf.user.device.asString } + device: ${ itf.user.device } <#if itf.user.net??> - net: "${ itf.user.net.asString }" + net: "${ itf.user.net }" <#assign nwCounter += 1/> @@ -175,11 +176,11 @@ data: # file: (undefined) drives: <#assign drvCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate?? && disk.volumeClaimTemplate.metadata?? && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> + <#assign diskName = disk.volumeClaimTemplate.metadata.name + "-disk"> <#else> <#assign diskName = "disk-" + drvCounter> @@ -187,33 +188,33 @@ data: - type: raw resource: /dev/${ diskName } <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } <#assign drvCounter = drvCounter + 1/> <#if disk.cdrom??> - type: ide-cd - file: "${ disk.cdrom.image.asString }" + file: "${ imageLocation(disk.cdrom.image) }" <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } display: - <#if cr.spec.vm.display.outputs?? > - outputs: ${ cr.spec.vm.display.outputs.asInt?c } + <#if spec.vm.display.outputs?? > + outputs: ${ spec.vm.display.outputs?c } - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.spice??> spice: - port: ${ cr.spec.vm.display.spice.port.asInt?c } - <#if cr.spec.vm.display.spice.ticket??> - ticket: "${ cr.spec.vm.display.spice.ticket.asString }" + port: ${ spec.vm.display.spice.port?c } + <#if spec.vm.display.spice.ticket??> + ticket: "${ spec.vm.display.spice.ticket }" - <#if cr.spec.vm.display.spice.streamingVideo??> - streaming-video: "${ cr.spec.vm.display.spice.streamingVideo.asString }" + <#if spec.vm.display.spice.streamingVideo??> + streaming-video: "${ spec.vm.display.spice.streamingVideo }" - usbRedirects: ${ cr.spec.vm.display.spice.usbRedirects.asInt?c } + usbRedirects: ${ spec.vm.display.spice.usbRedirects?c } logging.properties: | diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml index a1a94e1..ddb638c 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml @@ -1,11 +1,11 @@ kind: PersistentVolumeClaim apiVersion: v1 metadata: - namespace: ${ cr.metadata.namespace.asString } + namespace: ${ cr.namespace() } name: ${ runnerDataPvcName } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } spec: accessModes: diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml index 732f592..8258d55 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml @@ -1,16 +1,16 @@ kind: PersistentVolumeClaim apiVersion: v1 metadata: - namespace: ${ cr.metadata.namespace.asString } + namespace: ${ cr.namespace() } name: ${ disk.generatedPvcName } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } <#if disk.volumeClaimTemplate.metadata?? && disk.volumeClaimTemplate.metadata.annotations??> annotations: - ${ disk.volumeClaimTemplate.metadata.annotations.toString() } + ${ toJson(disk.volumeClaimTemplate.metadata.annotations) } spec: - ${ disk.volumeClaimTemplate.spec.toString() } + ${ toJson(disk.volumeClaimTemplate.spec) } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml index 2c32aa6..9a70e19 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -1,26 +1,26 @@ apiVersion: v1 kind: Service metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } + - apiVersion: ${ cr.apiVersion } kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false spec: type: LoadBalancer ports: - name: spice - port: ${ cr.spec.vm.display.spice.port.asInt?c } + port: ${ cr.spec().vm.display.spice.port?c } selector: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } 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 ad652e4..e62ac70 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 @@ -1,42 +1,43 @@ kind: Pod apiVersion: v1 metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/component: ${ constants.APP_NAME } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: # Triggers update of config map mounted in pod # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }" + vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion }" vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } + - apiVersion: ${ cr.apiVersion } kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } blockOwnerDeletion: true controller: false +<#assign spec = cr.spec() /> spec: containers: - - name: ${ cr.metadata.name.asString } - <#assign image = cr.spec.image> + - name: ${ cr.name() } + <#assign image = spec.image> <#if image.source??> - image: ${ image.source.asString } + image: ${ image.source } <#else> - image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString } + image: ${ image.repository }/${ image.path }<#if image.version??>:${ image.version } <#if image.pullPolicy??> - imagePullPolicy: ${ image.pullPolicy.asString } + imagePullPolicy: ${ image.pullPolicy } - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.spice??> ports: - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.spice??> - name: spice - containerPort: ${ cr.spec.vm.display.spice.port.asInt?c } + containerPort: ${ spec.vm.display.spice.port?c } protocol: TCP @@ -55,33 +56,33 @@ spec: - name: vmop-image-repository mountPath: ${ constants.IMAGE_REPO_PATH } volumeDevices: - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate??> - - name: ${ disk.generatedDiskName.asString } - devicePath: /dev/${ disk.generatedDiskName.asString } + - name: ${ disk.generatedDiskName } + devicePath: /dev/${ disk.generatedDiskName } securityContext: privileged: true - <#if cr.spec.resources??> - resources: ${ cr.spec.resources.toString() } + <#if spec.resources??> + resources: ${ toJson(spec.resources) } <#else> - <#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? > + <#if spec.vm.currentCpus?? || spec.vm.currentRam?? > resources: requests: - <#if cr.spec.vm.currentCpus?? > + <#if spec.vm.currentCpus?? > <#assign factor = 2.0 /> <#if reconciler.cpuOvercommit??> <#assign factor = reconciler.cpuOvercommit * 1.0 /> - cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c } + cpu: ${ (parseQuantity(spec.vm.currentCpus) / factor)?c } - <#if cr.spec.vm.currentRam?? > + <#if spec.vm.currentRam?? > <#assign factor = 1.25 /> <#if reconciler.ramOvercommit??> <#assign factor = reconciler.ramOvercommit * 1.0 /> - memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c } + memory: ${ (parseQuantity(spec.vm.currentRam) / factor)?floor?c } @@ -102,7 +103,7 @@ spec: projected: sources: - configMap: - name: ${ cr.metadata.name.asString } + name: ${ cr.name() } <#if displaySecret??> - secret: name: ${ displaySecret } @@ -113,22 +114,22 @@ spec: - name: runner-data persistentVolumeClaim: claimName: ${ runnerDataPvcName } - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate??> - - name: ${ disk.generatedDiskName.asString } + - name: ${ disk.generatedDiskName } persistentVolumeClaim: - claimName: ${ disk.generatedPvcName.asString } + claimName: ${ disk.generatedPvcName } hostNetwork: true - terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c } - <#if cr.spec.nodeName??> - nodeName: ${ cr.spec.nodeName.asString } + terminationGracePeriodSeconds: ${ (spec.vm.powerdownTimeout + 5)?c } + <#if spec.nodeName??> + nodeName: ${ spec.nodeName } - <#if cr.spec.nodeSelector??> - nodeSelector: ${ cr.spec.nodeSelector.toString() } + <#if spec.nodeSelector??> + nodeSelector: ${ toJson(spec.nodeSelector) } - <#if cr.spec.affinity??> - affinity: ${ cr.spec.affinity.toString() } + <#if spec.affinity??> + affinity: ${ toJson(spec.affinity) } serviceAccountName: vm-runner diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml deleted file mode 100644 index 3d4a316..0000000 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml +++ /dev/null @@ -1,194 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - vmoperator.jdrupes.org/version: ${ managerVersion } - ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } - blockOwnerDeletion: true - controller: false - -spec: - selector: - matchLabels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - replicas: ${ (cr.spec.vm.state.asString == "Running")?then(1, 0) } - updateStrategy: - type: OnDelete - template: - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/component: ${ constants.APP_NAME } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - # Triggers update of config map mounted in pod - # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }" - vmoperator.jdrupes.org/version: ${ managerVersion } - spec: - containers: - - name: ${ cr.metadata.name.asString } - <#assign image = cr.spec.image> - <#if image.source??> - image: ${ image.source.asString } - <#else> - image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString } - - <#if image.pullPolicy??> - imagePullPolicy: ${ image.pullPolicy.asString } - - <#if cr.spec.vm.display.spice??> - ports: - <#if cr.spec.vm.display.spice??> - - name: spice - containerPort: ${ cr.spec.vm.display.spice.port.asInt?c } - protocol: TCP - - - volumeMounts: - # Not needed because pod is priviledged: - # - mountPath: /dev/kvm - # name: dev-kvm - # - mountPath: /dev/net/tun - # name: dev-tun - # - mountPath: /sys/fs/cgroup - # name: cgroup - - name: config - mountPath: /etc/opt/vmrunner - - name: runner-data - mountPath: /var/local/vm-data - - name: vmop-image-repository - mountPath: ${ constants.IMAGE_REPO_PATH } - volumeDevices: - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - name: ${ diskName } - devicePath: /dev/${ diskName } - <#assign diskCounter = diskCounter + 1/> - - - securityContext: - privileged: true - <#if cr.spec.resources??> - resources: ${ cr.spec.resources.toString() } - <#else> - <#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? > - resources: - requests: - <#if cr.spec.vm.currentCpus?? > - <#assign factor = 2.0 /> - <#if reconciler.cpuOvercommit??> - <#assign factor = reconciler.cpuOvercommit * 1.0 /> - - cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c } - - <#if cr.spec.vm.currentRam?? > - <#assign factor = 1.25 /> - <#if reconciler.ramOvercommit??> - <#assign factor = reconciler.ramOvercommit * 1.0 /> - - memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c } - - - - volumes: - # Not needed because pod is priviledged: - # - name: dev-kvm - # hostPath: - # path: /dev/kvm - # type: CharDevice - # - hostPath: - # path: /dev/net/tun - # type: CharDevice - # name: dev-tun - # - name: cgroup - # hostPath: - # path: /sys/fs/cgroup - - name: config - projected: - sources: - - configMap: - name: ${ cr.metadata.name.asString } - <#if displaySecret??> - - secret: - name: ${ displaySecret } - - - name: vmop-image-repository - persistentVolumeClaim: - claimName: vmop-image-repository - hostNetwork: true - terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c } - <#if cr.spec.nodeName??> - nodeName: ${ cr.spec.nodeName.asString } - - <#if cr.spec.nodeSelector??> - nodeSelector: ${ cr.spec.nodeSelector.toString() } - - <#if cr.spec.affinity??> - affinity: ${ cr.spec.affinity.toString() } - - serviceAccountName: vm-runner - volumeClaimTemplates: - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: runner-data - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - spec: - accessModes: - - ReadWriteOnce - <#if reconciler.runnerDataPvc?? && reconciler.runnerDataPvc.storageClassName??> - storageClassName: ${ reconciler.runnerDataPvc.storageClassName } - - resources: - requests: - storage: 1Mi - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ diskName } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.annotations??> - annotations: - ${ disk.volumeClaimTemplate.metadata.annotations.toString() } - - spec: - ${ disk.volumeClaimTemplate.spec.toString() } - <#assign diskCounter = diskCounter + 1/> - - 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 4219e53..129a54d 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 @@ -68,7 +68,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * @throws TemplateException the template exception * @throws ApiException the api exception */ - public DynamicKubernetesObject reconcile(Map model, + public Map reconcile(Map model, VmChannel channel) throws IOException, TemplateException, ApiException { // Get API @@ -87,7 +87,10 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Apply and maybe force pod update var newState = K8s.apply(cmApi, mapDef, out.toString()); maybeForceUpdate(channel.client(), newState); - return newState; + @SuppressWarnings("unchecked") + var res = (Map) channel.client().getJSON().getGson() + .fromJson(newState.getRaw(), Map.class); + return res; } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index fa0bbf0..69d4058 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -256,10 +256,9 @@ public class DisplaySecretMonitor @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onVmDefChanged(VmDefChanged event, Channel channel) { synchronized (pendingGets) { - String vmName = event.vmDefinition().metadata().getName(); + String vmName = event.vmDefinition().name(); for (var pending : pendingGets) { - if (pending.event.vmDefinition().metadata().getName() - .equals(vmName) + if (pending.event.vmDefinition().name().equals(vmName) && event.vmDefinition().displayPasswordSerial() .map(s -> s >= pending.expectedSerial).orElse(false)) { pending.lock.remove(); 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 17456aa..0665d32 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 @@ -18,7 +18,6 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonPrimitive; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; @@ -36,7 +35,7 @@ import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jose4j.base64url.Base64; /** @@ -61,32 +60,31 @@ import org.jose4j.base64url.Base64; Map model, VmChannel channel) throws IOException, TemplateException, ApiException { // Secret needed at all? - var display = GsonPtr.to(event.vmDefinition().data()).to("spec", "vm", - "display"); - if (!display.get(JsonPrimitive.class, "spice", "generateSecret") - .map(JsonPrimitive::getAsBoolean).orElse(true)) { + var display = event.vmDefinition().fromVm("display").get(); + if (!DataPath. get(display, "spice", "generateSecret") + .orElse(true)) { return; } // Check if exists - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + metadata.getName()); - var stubs = K8sV1SecretStub.list(channel.client(), - metadata.getNamespace(), options); + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); if (!stubs.isEmpty()) { return; } // Create secret var secret = new V1Secret(); - secret.setMetadata(new V1ObjectMeta().namespace(metadata.getNamespace()) - .name(metadata.getName() + "-" + COMP_DISPLAY_SECRET) + secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) + .name(vmDef.name() + "-" + COMP_DISPLAY_SECRET) .putLabelsItem("app.kubernetes.io/name", APP_NAME) .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) - .putLabelsItem("app.kubernetes.io/instance", metadata.getName())); + .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); secret.setType("Opaque"); SecureRandom random = null; try { 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 85158c7..2d632b9 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 @@ -18,22 +18,23 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonObject; +import com.google.gson.Gson; import freemarker.template.Configuration; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1APIService; import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; import java.io.IOException; import java.io.StringWriter; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.common.K8sDynamicModel; +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; @@ -92,11 +93,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; if (lbsDef instanceof Boolean isOn && !isOn) { return; } - JsonObject cfgMeta = new JsonObject(); - if (lbsDef instanceof Map) { - var json = channel.client().getJSON(); - cfgMeta - = json.deserialize(json.serialize(lbsDef), JsonObject.class); + + // 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)) { + return; } // Combine template and data and parse result @@ -107,53 +110,78 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // https://github.com/kubernetes-client/java/issues/2741 var svcDef = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - mergeMetadata(svcDef, cfgMeta, event.vmDefinition()); + @SuppressWarnings("unchecked") + var defaults = lbsDef instanceof Map + ? (Map>) lbsDef + : null; + var client = channel.client(); + mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef); // Apply - DynamicKubernetesApi svcApi = new DynamicKubernetesApi("", "v1", - "services", channel.client()); - K8s.apply(svcApi, svcDef, svcDef.getRaw().toString()); + var svcStub = K8sV1ServiceStub + .get(client, vmDef.namespace(), vmDef.name()); + if (svcStub.apply(svcDef).isEmpty()) { + logger.warning( + () -> "Could not patch service for " + svcStub.name()); + } } - private void mergeMetadata(DynamicKubernetesObject svcDef, - JsonObject cfgMeta, K8sDynamicModel vmDefinition) { - // Get metadata from VM definition - var vmMeta = GsonPtr.to(vmDefinition.data()).to("spec") - .get(JsonObject.class, LOAD_BALANCER_SERVICE) - .map(JsonObject::deepCopy).orElseGet(() -> new JsonObject()); + private void mergeMetadata(Gson gson, DynamicKubernetesObject svcDef, + Map> defaults, + VmDefinition vmDefinition) { + // Get specific load balancer metadata from VM definition + var vmLbMeta = vmDefinition + .>> fromSpec(LOAD_BALANCER_SERVICE) + .orElse(Collections.emptyMap()); - // Merge Data from VM definition into config data - mergeReplace(GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(LABELS).get(JsonObject.class)); - mergeReplace( - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(ANNOTATIONS).get(JsonObject.class)); - - // Merge additional data into service definition - var svcMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); - mergeIfAbsent(svcMeta.to(LABELS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class)); - mergeIfAbsent(svcMeta.to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class)); + // Merge + var svcMeta = svcDef.getMetadata(); + var svcJsonMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); + Optional.ofNullable(mergeIfAbsent(svcMeta.getLabels(), + mergeReplace(defaults.get(LABELS), vmLbMeta.get(LABELS)))) + .ifPresent(lbls -> svcJsonMeta.set(LABELS, gson.toJsonTree(lbls))); + Optional.ofNullable(mergeIfAbsent(svcMeta.getAnnotations(), + mergeReplace(defaults.get(ANNOTATIONS), vmLbMeta.get(ANNOTATIONS)))) + .ifPresent(as -> svcJsonMeta.set(ANNOTATIONS, gson.toJsonTree(as))); } - private void mergeReplace(JsonObject dest, JsonObject src) { + private Map mergeReplace(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (e.getValue().isJsonNull()) { + if (e.getValue() == null) { dest.remove(e.getKey()); continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } - private void mergeIfAbsent(JsonObject dest, JsonObject src) { + private Map mergeIfAbsent(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (dest.has(e.getKey())) { + if (dest.containsKey(e.getKey())) { continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } } 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 33c2221..4ee96d8 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 @@ -20,7 +20,6 @@ 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.dynamic.Dynamics; import io.kubernetes.client.util.generic.options.PatchOptions; @@ -29,7 +28,7 @@ import java.io.StringWriter; import java.util.Map; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8sV1PodStub; -import org.jdrupes.vmoperator.common.VmDefinitionModel.RequestedVmState; +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; @@ -73,18 +72,18 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Get pod stub. - var metadata = event.vmDefinition().getMetadata(); - var podStub = K8sV1PodStub.get(channel.client(), - metadata.getNamespace(), metadata.getName()); + var vmDef = event.vmDefinition(); + var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), + vmDef.name()); // Nothing to do if exists and should be running - if (event.vmDefinition().vmState() == RequestedVmState.RUNNING + if (vmDef.vmState() == RequestedVmState.RUNNING && podStub.model().isPresent()) { return; } // Delete if running but should be stopped - if (event.vmDefinition().vmState() == RequestedVmState.STOPPED) { + if (vmDef.vmState() == RequestedVmState.STOPPED) { if (podStub.model().isPresent()) { podStub.delete(); } @@ -104,9 +103,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (podStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, - new V1Patch(channel.client().getJSON().serialize(podDef)), opts) - .isEmpty()) { + if (podStub.apply(podDef).isEmpty()) { logger.warning( () -> "Could not patch pod for " + podStub.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 39fcfe9..d044199 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 @@ -18,8 +18,6 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import freemarker.core.ParseException; import freemarker.template.Configuration; import freemarker.template.MalformedTemplateNameException; @@ -32,6 +30,7 @@ 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.List; import java.util.Map; import java.util.Set; import java.util.logging.Logger; @@ -41,7 +40,7 @@ import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sV1PvcStub; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -78,16 +77,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; public void reconcile(VmDefChanged event, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); // 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=" + metadata.getName()); + + "app.kubernetes.io/instance=" + vmDef.name()); var knownDisks = K8sV1PvcStub.list(channel.client(), - metadata.getNamespace(), listOpts); + vmDef.namespace(), listOpts); var knownPvcs = knownDisks.stream().map(K8sV1PvcStub::name) .collect(Collectors.toSet()); @@ -95,23 +94,23 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; reconcileRunnerDataPvc(event, model, channel, knownPvcs); // Reconcile pvcs for defined disks - var diskDefs = GsonPtr.to((JsonObject) model.get("cr")) - .getAsListOf(JsonObject.class, "spec", "vm", "disks"); + var diskDefs = vmDef.>> fromVm("disks") + .orElse(List.of()); var diskCounter = 0; for (var diskDef : diskDefs) { - if (!diskDef.has("volumeClaimTemplate")) { + if (!diskDef.containsKey("volumeClaimTemplate")) { continue; } - var diskName = GsonPtr.to(diskDef) - .getAsString("volumeClaimTemplate", "metadata", "name") - .map(name -> name + "-disk").orElse("disk-" + diskCounter); + var diskName = DataPath.get(diskDef, "volumeClaimTemplate", + "metadata", "name").map(name -> name + "-disk") + .orElse("disk-" + diskCounter); diskCounter += 1; - diskDef.addProperty("generatedDiskName", diskName); + diskDef.put("generatedDiskName", diskName); // Don't do anything if pvc with old (sts generated) name exists. - var stsDiskPvcName = diskName + "-" + metadata.getName() + "-0"; + var stsDiskPvcName = diskName + "-" + vmDef.name() + "-0"; if (knownPvcs.contains(stsDiskPvcName)) { - diskDef.addProperty("generatedPvcName", stsDiskPvcName); + diskDef.put("generatedPvcName", stsDiskPvcName); continue; } @@ -127,18 +126,18 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; Set knownPvcs) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); // Look for old (sts generated) name. var stsRunnerDataPvcName - = "runner-data" + "-" + metadata.getName() + "-0"; + = "runner-data" + "-" + vmDef.name() + "-0"; if (knownPvcs.contains(stsRunnerDataPvcName)) { model.put("runnerDataPvcName", stsRunnerDataPvcName); return; } // Generate PVC - model.put("runnerDataPvcName", metadata.getName() + "-runner-data"); + model.put("runnerDataPvcName", vmDef.name() + "-runner-data"); var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -149,7 +148,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Do apply changes var pvcStub = K8sV1PvcStub.get(channel.client(), - metadata.getNamespace(), (String) model.get("runnerDataPvcName")); + vmDef.namespace(), (String) model.get("runnerDataPvcName")); PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); @@ -165,13 +164,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; Map model, VmChannel channel) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); // Generate PVC - var diskDef = GsonPtr.to((JsonElement) model.get("disk")); - var pvcName = metadata.getName() + "-" - + diskDef.getAsString("generatedDiskName").get(); - diskDef.set("generatedPvcName", pvcName); + @SuppressWarnings("unchecked") + var diskDef = (Map) model.get("disk"); + var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName"); + diskDef.put("generatedPvcName", pvcName); var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -181,9 +180,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); // Do apply changes - var pvcStub = K8sV1PvcStub.get(channel.client(), - metadata.getNamespace(), GsonPtr.to((JsonElement) model.get("disk")) - .getAsString("generatedPvcName").get()); + var pvcStub + = K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); 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 17ba60d..32753ac 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 @@ -18,11 +18,13 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.SimpleNumber; +import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; import freemarker.template.TemplateHashModel; @@ -30,7 +32,7 @@ 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.dynamic.DynamicKubernetesObject; +import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.math.BigDecimal; @@ -44,15 +46,15 @@ import java.util.Optional; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; 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.util.DataPath; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; -import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -135,6 +137,10 @@ import org.jgrapes.util.events.ConfigurationUpdate; "PMD.AvoidDuplicateLiterals" }) public class Reconciler extends Component { + /** The Constant mapper. */ + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + @SuppressWarnings("PMD.SingularField") private final Configuration fmConfig; private final ConfigMapReconciler cmReconciler; @@ -203,17 +209,17 @@ public class Reconciler extends Component { } // Ownership relationships takes care of deletions - var defMeta = event.vmDefinition().getMetadata(); if (event.type() == K8sObserver.ResponseType.DELETED) { - logger.fine(() -> "VM \"" + defMeta.getName() + "\" deleted"); + logger.fine( + () -> "VM \"" + event.vmDefinition().name() + "\" deleted"); return; } - // Reconcile, use "augmented" vm definition for model + // Create model for processing templates Map model - = prepareModel(channel.client(), patchCr(event.vmDefinition())); + = prepareModel(channel.client(), event.vmDefinition()); var configMap = cmReconciler.reconcile(model, channel); - model.put("cm", configMap.getRaw()); + model.put("cm", configMap); dsReconciler.reconcile(event, model, channel); // Manage (eventual) removal of stateful set. stsReconciler.reconcile(event, model, channel); @@ -235,81 +241,22 @@ public class Reconciler extends Component { @Handler public void onResetVm(ResetVm event, VmChannel channel) throws ApiException, IOException, TemplateException { - var defRoot - = GsonPtr.to(channel.vmDefinition().data()).get(JsonObject.class); - defRoot.addProperty("resetCount", - defRoot.get("resetCount").getAsLong() + 1); + var vmDef = channel.vmDefinition(); + vmDef.extra("resetCount", vmDef. extra("resetCount") + 1); Map model - = prepareModel(channel.client(), patchCr(channel.vmDefinition())); + = prepareModel(channel.client(), channel.vmDefinition()); cmReconciler.reconcile(model, channel); } - private DynamicKubernetesObject patchCr(K8sDynamicModel vmDef) { - var json = vmDef.data().deepCopy(); - // Adjust cdromImage path - adjustCdRomPaths(json); - - // Adjust cloud-init data - adjustCloudInitData(json); - - return new DynamicKubernetesObject(json); - } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private void adjustCdRomPaths(JsonObject json) { - var disks - = GsonPtr.to(json).to("spec", "vm", "disks").get(JsonArray.class); - for (var disk : disks) { - var cdrom = (JsonObject) ((JsonObject) disk).get("cdrom"); - if (cdrom == null) { - continue; - } - String image = cdrom.get("image").getAsString(); - if (image.isEmpty()) { - continue; - } - try { - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH - + "/").resolve(image); - if ("file".equals(imageUri.getScheme())) { - cdrom.addProperty("image", imageUri.getPath()); - } else { - cdrom.addProperty("image", imageUri.toString()); - } - } catch (URISyntaxException e) { - logger.warning(() -> "Invalid CDROM image: " + image); - } - } - } - - private void adjustCloudInitData(JsonObject json) { - var spec = GsonPtr.to(json).to("spec").get(JsonObject.class); - if (!spec.has("cloudInit")) { - return; - } - var metaData = GsonPtr.to(spec).to("cloudInit", "metaData"); - if (metaData.getAsString("instance-id").isEmpty()) { - metaData.set("instance-id", - GsonPtr.to(json).getAsString("metadata", "resourceVersion") - .map(s -> "v" + s).orElse("v1")); - } - if (metaData.getAsString("local-hostname").isEmpty()) { - metaData.set("local-hostname", - GsonPtr.to(json).getAsString("metadata", "name").get()); - } - } - - @SuppressWarnings("PMD.CognitiveComplexity") + @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity" }) private Map prepareModel(K8sClient client, - DynamicKubernetesObject vmDef) - throws TemplateModelException, ApiException { + VmDefinition vmDef) throws TemplateModelException, ApiException { @SuppressWarnings("PMD.UseConcurrentHashMap") Map model = new HashMap<>(); model.put("managerVersion", Optional.ofNullable(Reconciler.class.getPackage() .getImplementationVersion()).orElse("(Unknown)")); - model.put("cr", vmDef.getRaw()); + model.put("cr", vmDef); model.put("constants", (TemplateHashModel) new DefaultObjectWrapperBuilder( Configuration.VERSION_2_3_32) @@ -321,9 +268,10 @@ public class Reconciler extends Component { ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + vmDef.getMetadata().getName()); + + "app.kubernetes.io/instance=" + vmDef.name()); var dsStub = K8sV1SecretStub - .list(client, vmDef.getMetadata().getNamespace(), options).stream() + .list(client, vmDef.namespace(), options) + .stream() .findFirst(); if (dsStub.isPresent()) { dsStub.get().model().ifPresent(m -> { @@ -332,14 +280,23 @@ public class Reconciler extends Component { } // Methods - model.put("parseQuantity", new TemplateMethodModelEx() { + model.put("parseQuantity", parseQuantityModel); + model.put("formatMemory", formatMemoryModel); + model.put("imageLocation", imgageLocationModel); + model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); + model.put("toJson", toJsonModel); + return model; + } + + private final TemplateMethodModelEx parseQuantityModel + = new TemplateMethodModelEx() { @Override @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var arg = arguments.get(0); - if (arg instanceof Number number) { - return number; + if (arg instanceof SimpleNumber number) { + return number.getAsNumber(); } try { return Quantity.fromString(arg.toString()).getNumber(); @@ -348,8 +305,10 @@ public class Reconciler extends Component { + "specified as \"" + arg + "\": " + e.getMessage()); } } - }); - model.put("formatMemory", new TemplateMethodModelEx() { + }; + + private final TemplateMethodModelEx formatMemoryModel + = new TemplateMethodModelEx() { @Override @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) @@ -376,7 +335,71 @@ public class Reconciler extends Component { } return Convertions.formatMemory(bigInt); } - }); - return model; - } + }; + + private final TemplateMethodModelEx imgageLocationModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings({ "PMD.PreserveStackTrace", + "PMD.AvoidLiteralsInIfCondition" }) + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + var image = ((SimpleScalar) arguments.get(0)).getAsString(); + if (image.isEmpty()) { + return ""; + } + try { + var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH + + "/").resolve(image); + if ("file".equals(imageUri.getScheme())) { + return imageUri.getPath(); + } + return imageUri.toString(); + } catch (URISyntaxException e) { + logger.warning(() -> "Invalid CDROM image: " + image); + } + return image; + } + }; + + private final TemplateMethodModelEx adjustCloudInitMetaModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings("PMD.PreserveStackTrace") + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + @SuppressWarnings("unchecked") + var res = (Map) DataPath + .deepCopy(((AdapterTemplateModel) arguments.get(0)) + .getAdaptedObject(Object.class)); + var metadata + = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) + .getAdaptedObject(Object.class); + if (!res.containsKey("instance-id")) { + res.put("instance-id", + Optional.ofNullable(metadata.getResourceVersion()) + .map(s -> "v" + s).orElse("v1")); + } + if (!res.containsKey("local-hostname")) { + res.put("local-hostname", metadata.getName()); + } + return res; + } + }; + + private final TemplateMethodModelEx toJsonModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings("PMD.PreserveStackTrace") + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + try { + return mapper.writeValueAsString( + ((AdapterTemplateModel) arguments.get(0)) + .getAdaptedObject(Object.class)); + } catch (JsonProcessingException e) { + return "{}"; + } + } + }; } 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 a58d169..8803e61 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 @@ -22,19 +22,14 @@ 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.dynamic.Dynamics; 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 org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; +import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; 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; -import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Before version 3.4, the pod running the VM was created by a stateful set. @@ -45,15 +40,15 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /* default */ class StatefulSetReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); - private final Configuration fmConfig; /** * Instantiates a new stateful set reconciler. * * @param fmConfig the fm config */ + @SuppressWarnings("PMD.UnusedFormalParameter") public StatefulSetReconciler(Configuration fmConfig) { - this.fmConfig = fmConfig; + // Nothing to do } /** @@ -70,12 +65,11 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; public void reconcile(VmDefChanged event, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); model.put("usingSts", false); // If exists, delete when not running or supposed to be not running. var stsStub = K8sV1StatefulSetStub.get(channel.client(), - metadata.getNamespace(), metadata.getName()); + event.vmDefinition().namespace(), event.vmDefinition().name()); if (stsStub.model().isEmpty()) { return; } @@ -94,16 +88,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // 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. - var fmTemplate = fmConfig.getTemplate("runnerSts.ftl.yaml"); - StringWriter out = new StringWriter(); - fmTemplate.process(model, out); - // Avoid Yaml.load due to - // https://github.com/kubernetes-client/java/issues/2741 - var stsDef = Dynamics.newFromYaml( - new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - var desired = GsonPtr.to(stsDef.getRaw()) - .to("spec").getAsInt("replicas").orElse(1); - if (desired == 1) { + if (event.vmDefinition().vmState() == RequestedVmState.RUNNING) { return; } @@ -111,12 +96,12 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (stsStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, - new V1Patch(channel.client().getJSON().serialize(stsDef)), opts) - .isEmpty()) { + 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 0ad5017..cc8ae7b 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,13 +18,15 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; 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.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.logging.Level; @@ -37,6 +39,7 @@ 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.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionModels; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -46,7 +49,7 @@ import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; @@ -116,42 +119,47 @@ public class VmMonitor extends V1ObjectMeta metadata = response.object.getMetadata(); VmChannel channel = channelManager.channelGet(metadata.getName()); + // Remove from channel manager if deleted + if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { + channelManager.remove(metadata.getName()); + } + // Get full definition and associate with channel as backup - var vmDef = response.object; - if (vmDef.data() == null) { + var vmModel = response.object; + if (vmModel.data() == null) { // ADDED event does not provide data, see // https://github.com/kubernetes-client/java/issues/3215 - vmDef = getModel(client, vmDef); + vmModel = getModel(client, vmModel); } - if (vmDef.data() != null) { + VmDefinition vmDef = null; + if (vmModel.data() != null) { // New data, augment and save + vmDef = client.getJSON().getGson().fromJson(vmModel.data(), + VmDefinition.class); addDynamicData(channel.client(), vmDef, channel.vmDefinition()); channel.setVmDefinition(vmDef); - } else { - // Reuse cached + } + if (vmDef == null) { + // Reuse cached (e.g. if deleted) vmDef = channel.vmDefinition(); } if (vmDef == null) { - logger.warning( - () -> "Cannot get model for " + response.object.getMetadata()); + logger.warning(() -> "Cannot get defintion for " + + response.object.getMetadata()); return; } - if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { - channelManager.remove(metadata.getName()); - } // Create and fire changed event. Remove channel from channel // manager on completion. channel.pipeline() .fire(Event.onCompletion( new VmDefChanged(ResponseType.valueOf(response.type), - channel.setGeneration( - response.object.getMetadata().getGeneration()), + channel.setGeneration(response.object.getMetadata() + .getGeneration()), vmDef), e -> { if (e.type() == ResponseType.DELETED) { - channelManager - .remove(e.vmDefinition().metadata().getName()); + channelManager.remove(e.vmDefinition().name()); } }), channel); } @@ -166,51 +174,53 @@ public class VmMonitor extends } } - private void addDynamicData(K8sClient client, VmDefinitionModel vmState, - VmDefinitionModel prevState) { - var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class); - + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + private void addDynamicData(K8sClient client, VmDefinition vmDef, + VmDefinition prevState) { // Maintain (or initialize) the resetCount - rootNode.addProperty("resetCount", Optional.ofNullable(prevState) - .map(ps -> GsonPtr.to(ps.data())) - .flatMap(d -> d.getAsLong("resetCount")).orElse(0L)); + vmDef.extra("resetCount", + Optional.ofNullable(prevState).map(d -> d.extra("resetCount")) + .orElse(0L)); + // Node information // Add defaults in case the VM is not running - rootNode.addProperty("nodeName", ""); - rootNode.addProperty("nodeAddress", ""); + vmDef.extra("nodeName", ""); + vmDef.extra("nodeAddress", ""); // 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. - var isRunning = GsonPtr.to(rootNode).to("status", "conditions") - .get(JsonArray.class) - .asList().stream().filter(el -> "Running" - .equals(((JsonObject) el).get("type").getAsString())) - .findFirst().map(el -> "True" - .equals(((JsonObject) el).get("status").getAsString())) - .orElse(false); + @SuppressWarnings("PMD.LambdaCanBeMethodReference") + var isRunning + = vmDef.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(t -> "Running".equals(t)).orElse(false)) + .findFirst().map(cond -> DataPath.get(cond, "status") + .map(s -> "True".equals(s)).orElse(false)) + .orElse(false); if (!isRunning) { return; } var podSearch = new ListOptions(); podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ",app.kubernetes.io/component=" + APP_NAME - + ",app.kubernetes.io/instance=" + vmState.getMetadata().getName()); + + ",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(); - rootNode.addProperty("nodeName", nodeName); + vmDef.extra("nodeName", nodeName); logger.fine(() -> "Added node name " + nodeName - + " to VM info for " + vmState.getMetadata().getName()); + + " to VM info for " + vmDef.name()); @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var addrs = new JsonArray(); + var addrs = new ArrayList(); podStub.model().get().getStatus().getPodIPs().stream() .map(ip -> ip.getIp()).forEach(addrs::add); - rootNode.add("nodeAddresses", addrs); + vmDef.extra("nodeAddresses", addrs); logger.fine(() -> "Added node addresses " + addrs - + " to VM info for " + vmState.getMetadata().getName()); + + " to VM info for " + vmDef.name()); } } catch (ApiException e) { logger.log(Level.WARNING, e, diff --git a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml new file mode 100644 index 0000000..54ea110 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml @@ -0,0 +1,64 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine +metadata: + namespace: vmop-dev + name: unittest-vm +spec: + image: + repository: docker-registry.lan.mnl.de + path: vmoperator/this.will.never.start + version: 0.0.0 + + cloudInit: + metaData: {} + + vm: + # state: Running + maximumRam: 4Gi + currentRam: 2Gi + maximumCpus: 4 + currentCpus: 2 + powerdownTimeout: 1 + + networks: + - user: {} + disks: + - cdrom: + image: https://test.com/test.iso + bootindex: 0 + - cdrom: + image: "image.iso" + - volumeClaimTemplate: + metadata: + name: system + annotations: + use_as: system-disk + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + - volumeClaimTemplate: + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + + display: + outputs: 2 + spice: + port: 5812 + usbRedirects: 2 + + resources: + requests: + cpu: 1 + memory: 2Gi + + loadBalancerService: + labels: + label2: replaced + label3: added + annotations: + anno1: added diff --git a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml deleted file mode 100644 index 0d395bd..0000000 --- a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: "vmoperator.jdrupes.org/v1" -kind: VirtualMachine -metadata: - namespace: vmop-dev - name: unittest-vm -spec: - resources: - requests: - cpu: 1 - memory: 2Gi - - loadBalancerService: - labels: - test2: null - test3: added - - vm: - # state: Running - maximumRam: 4Gi - currentRam: 2Gi - maximumCpus: 4 - currentCpus: 2 - powerdownTimeout: 1 - - networks: - - user: {} - disks: - - cdrom: - # image: "" - image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso - # image: "Fedora-Workstation-Live-x86_64-38-1.6.iso" - - display: - spice: - port: 5812 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 bd479d0..4f5d7a3 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 @@ -1,12 +1,19 @@ package org.jdrupes.vmoperator.manager; import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.FileReader; import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; @@ -15,7 +22,11 @@ import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; import org.jdrupes.vmoperator.common.K8sV1DeploymentStub; +import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.K8sV1ServiceStub; +import org.jdrupes.vmoperator.util.DataPath; import org.junit.jupiter.api.AfterAll; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.BeforeAll; @@ -29,6 +40,9 @@ class BasicTests { private static K8sClient client; private static APIResource vmsContext; private static K8sV1DeploymentStub mgrDeployment; + private static K8sDynamicStub vmStub; + private static final String VM_NAME = "unittest-vm"; + private static final Object EXISTS = new Object(); @BeforeAll static void setUpBeforeClass() throws Exception { @@ -38,23 +52,40 @@ class BasicTests { // Get client client = new K8sClient(); + // Update manager pod by scaling deployment + mgrDeployment + = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator"); + mgrDeployment.scale(0); + mgrDeployment.scale(1); + waitForManager(); + // Context for working with our CR var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM); assertTrue(apiRes.isPresent()); vmsContext = apiRes.get(); // Cleanup existing VM - K8sDynamicStub.get(client, vmsContext, "vmop-dev", "unittest-vm") + K8sDynamicStub.get(client, vmsContext, "vmop-dev", 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=" + COMP_DISPLAY_SECRET); + var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + for (var secret : secrets) { + secret.delete(); + } + deletePvcs(); - // Update manager pod by scaling deployment - mgrDeployment - = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator"); - mgrDeployment.scale(0); - mgrDeployment.scale(1); + // Load from Yaml + var rdr = new FileReader("test-resources/basic-vm.yaml"); + vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr); + assertTrue(vmStub.model().isPresent()); + } + private static void waitForManager() + throws ApiException, InterruptedException { // Wait until available - for (int i = 0; i < 10; i++) { if (mgrDeployment.model().get().getStatus().getConditions() .stream().filter(c -> "Available".equals(c.getType())).findAny() @@ -66,70 +97,250 @@ class BasicTests { fail("vm-operator not deployed."); } - @AfterAll - static void tearDownAfterClass() throws Exception { - // Bring down manager - mgrDeployment.scale(0); - } - - @Test - void test() throws IOException, InterruptedException, ApiException { - // Load from Yaml - var rdr = new FileReader("test-resources/unittest-vm.yaml"); - var vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr); - assertTrue(vmStub.model().isPresent()); - - // Wait for created resources - assertTrue(waitForConfigMap(client)); - assertTrue(waitForPvc(client)); - - // Check config map - var config = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm") - .model().get(); - var yaml = new Yaml(new SafeConstructor(new LoaderOptions())) - .load(config.getData().get("config.yaml")); - @SuppressWarnings("unchecked") - var maximumRam = ((Map>>) yaml) - .get("/Runner").get("vm").get("maximumRam"); - assertEquals("4 GiB", maximumRam); - - // Cleanup - K8sDynamicStub.get(client, vmsContext, "vmop-dev", "unittest-vm") - .delete(); + private static void deletePvcs() throws ApiException { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/instance=unittest-vm"); + + "app.kubernetes.io/instance=" + VM_NAME); var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts); for (var pvc : knownPvcs) { pvc.delete(); } } - private boolean waitForConfigMap(K8sClient client) - throws InterruptedException, ApiException { - var stub = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm"); - for (int i = 0; i < 10; i++) { - if (stub.model().isPresent()) { - return true; - } - Thread.sleep(1000); - } - return false; + @AfterAll + static void tearDownAfterClass() throws Exception { + // Cleanup + K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME) + .delete(); + deletePvcs(); + + // Bring down manager + mgrDeployment.scale(0); } - private boolean waitForPvc(K8sClient client) - throws InterruptedException, ApiException { - var stub - = K8sV1PvcStub.get(client, "vmop-dev", "unittest-vm-runner-data"); + @Test + void testConfigMap() + throws IOException, InterruptedException, ApiException { + K8sV1ConfigMapStub stub + = K8sV1ConfigMapStub.get(client, "vmop-dev", VM_NAME); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { - return true; + break; } Thread.sleep(1000); } - return false; + // Check config map + var config = stub.model().get(); + Map, Object> toCheck = Map.of( + List.of("namespace"), "vmop-dev", + List.of("name"), VM_NAME, + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS); + checkProps(config.getMetadata(), toCheck); + + toCheck = new LinkedHashMap<>(); + toCheck.put(List.of("/Runner", "guestShutdownStops"), false); + toCheck.put(List.of("/Runner", "cloudInit", "metaData", "instance-id"), + EXISTS); + toCheck.put( + List.of("/Runner", "cloudInit", "metaData", "local-hostname"), + VM_NAME); + toCheck.put(List.of("/Runner", "cloudInit", "userData"), Map.of()); + toCheck.put(List.of("/Runner", "vm", "maximumRam"), "4 GiB"); + toCheck.put(List.of("/Runner", "vm", "currentRam"), "2 GiB"); + toCheck.put(List.of("/Runner", "vm", "maximumCpus"), 4); + toCheck.put(List.of("/Runner", "vm", "currentCpus"), 2); + toCheck.put(List.of("/Runner", "vm", "powerdownTimeout"), 1); + toCheck.put(List.of("/Runner", "vm", "network", 0, "type"), "user"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "file"), + "https://test.com/test.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "bootindex"), 0); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "file"), + "/var/local/vmop-image-repository/image.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "resource"), + "/dev/system-disk"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "resource"), + "/dev/disk-1"); + toCheck.put(List.of("/Runner", "vm", "display", "outputs"), 2); + toCheck.put(List.of("/Runner", "vm", "display", "spice", "port"), 5812); + toCheck.put( + List.of("/Runner", "vm", "display", "spice", "usbRedirects"), 2); + var cm = new Yaml(new SafeConstructor(new LoaderOptions())) + .load(config.getData().get("config.yaml")); + checkProps(cm, toCheck); + } + + @Test + void testDisplaySecret() throws ApiException, InterruptedException { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + Collection secrets = null; + for (int i = 0; i < 10; i++) { + secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + if (secrets.size() > 0) { + break; + } + Thread.sleep(1000); + } + assertEquals(1, secrets.size()); + var secretData = secrets.iterator().next().model().get().getData(); + checkProps(secretData, Map.of( + List.of("display-password"), EXISTS)); + assertEquals("now", new String(secretData.get("password-expiry"))); + } + + @Test + void testRunnerPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-runner-data"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Mi"))); + } + + @Test + void testSystemDiskPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-system-disk"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME, + List.of("annotations", "use_as"), "system-disk")); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); + } + + @Test + void testDisk1Pvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-disk-1"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); + } + + @Test + void testPod() throws ApiException, InterruptedException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + assertTrue(vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + + "\", \"value\": \"Running\"}]"), + client.defaultPatchOptions()).isPresent()); + var stub = K8sV1PodStub.get(client, "vmop-dev", VM_NAME); + for (int i = 0; i < 20; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pod = stub.model().get(); + checkProps(pod.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/component"), APP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME, + List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS)); + checkProps(pod.getSpec(), Map.of( + List.of("containers", 0, "image"), EXISTS, + List.of("containers", 0, "name"), VM_NAME, + List.of("containers", 0, "resources", "requests", "cpu"), + Quantity.fromString("1"))); + } + + @Test + public void testLoadBalancer() throws ApiException, InterruptedException { + var stub = K8sV1ServiceStub.get(client, "vmop-dev", VM_NAME); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var svc = stub.model().get(); + checkProps(svc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("labels", "label1"), "label1", + List.of("labels", "label2"), "replaced", + List.of("labels", "label3"), "added", + List.of("annotations", "metallb.universe.tf/loadBalancerIPs"), + "192.168.168.1", + List.of("annotations", "anno1"), "added")); + } + + private void checkProps(Object obj, + Map, Object> toCheck) { + for (var entry : toCheck.entrySet()) { + var prop = DataPath.get(obj, entry.getKey().toArray()); + assertTrue(prop.isPresent(), () -> "Property " + entry.getKey() + + " not found in " + obj); + + // Check for existance only + if (entry.getValue() == EXISTS) { + continue; + } + assertEquals(entry.getValue(), prop.get()); + } } } diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java new file mode 100644 index 0000000..be5b530 --- /dev/null +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java @@ -0,0 +1,176 @@ +/* + * 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.util; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Utility class that supports navigation through arbitrary data structures. + */ +public final class DataPath { + + private static final Logger logger + = Logger.getLogger(DataPath.class.getName()); + + private DataPath() { + } + + /** + * Apply the given selectors on the given object and return the + * value reached. + * + * Selectors can be if type {@link String} or {@link Number}. The + * former are used to access a property of an object, the latter to + * access an element in an array or a {@link List}. + * + * Depending on the object currently visited, a {@link String} can + * be the key of a {@link Map}, the property part of a getter method + * or the name of a method that has an empty parameter list. + * + * @param the generic type + * @param from the from + * @param selectors the selectors + * @return the result + */ + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public static Optional get(Object from, Object... selectors) { + Object cur = from; + for (var selector : selectors) { + if (cur == null) { + return Optional.empty(); + } + if (selector instanceof String && cur instanceof Map map) { + cur = map.get(selector); + continue; + } + if (selector instanceof Number index && cur instanceof List list) { + cur = list.get(index.intValue()); + continue; + } + if (selector instanceof String property) { + var retrieved = tryAccess(cur, property); + if (retrieved.isEmpty()) { + return Optional.empty(); + } + cur = retrieved.get(); + } + } + @SuppressWarnings("unchecked") + var result = Optional.ofNullable((T) cur); + return result; + } + + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + private static Optional tryAccess(Object obj, String property) { + Method acc = null; + try { + // Try getter + acc = obj.getClass().getMethod("get" + property.substring(0, 1) + .toUpperCase() + property.substring(1)); + } catch (SecurityException e) { + return Optional.empty(); + } catch (NoSuchMethodException e) { // NOPMD + // Can happen... + } + if (acc == null) { + try { + // Try method + acc = obj.getClass().getMethod(property); + } catch (SecurityException | NoSuchMethodException e) { + return Optional.empty(); + } + } + if (acc != null) { + try { + return Optional.ofNullable(acc.invoke(obj)); + } catch (IllegalAccessException + | InvocationTargetException e) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + /** + * Attempts to make a as-deep-as-possible copy of the given + * container. New containers will be created for Maps, Lists and + * Arrays. The method is invoked recursively for the entries/items. + * + * If invoked with an object that is neither a map, list or array, + * the methods checks if the object implements {@link Cloneable} + * and if it does, invokes its {@link Object#clone()} method. + * Else the method return the object. + * + * @param the generic type + * @param object the container + * @return the t + */ + @SuppressWarnings({ "PMD.CognitiveComplexity", "unchecked" }) + public static T deepCopy(T object) { + if (object instanceof Map map) { + @SuppressWarnings("PMD.UseConcurrentHashMap") + Map copy; + try { + copy = (Map) object.getClass().getConstructor() + .newInstance(); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + logger.severe( + () -> "Cannot create new instance of " + object.getClass()); + return null; + } + for (var entry : ((Map) map).entrySet()) { + copy.put(entry.getKey(), + deepCopy(entry.getValue())); + } + return (T) copy; + } + if (object instanceof List list) { + List copy = new ArrayList<>(); + for (var item : list) { + copy.add(deepCopy(item)); + } + return (T) copy; + } + if (object.getClass().isArray()) { + var copy = new ArrayList<>(); + for (var item : (Object[]) object) { + copy.add(deepCopy(item)); + } + return (T) copy.toArray(); + } + if (object instanceof Cloneable) { + try { + return (T) object.getClass().getMethod("clone") + .invoke(object); + } catch (IllegalAccessException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return object; + } + } + return object; + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/build.gradle b/org.jdrupes.vmoperator.vmconlet/build.gradle index 4056256..606c6cd 100644 --- a/org.jdrupes.vmoperator.vmconlet/build.gradle +++ b/org.jdrupes.vmoperator.vmconlet/build.gradle @@ -5,7 +5,7 @@ plugins { dependencies { implementation project(':org.jdrupes.vmoperator.manager.events') - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.0.0.3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java index ea59767..69418af 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java @@ -18,8 +18,6 @@ package org.jdrupes.vmoperator.vmconlet; -import com.google.gson.Gson; -import com.google.gson.JsonObject; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; import freemarker.template.Template; @@ -31,16 +29,19 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.time.Duration; import java.time.Instant; +import java.util.Collections; import java.util.EnumSet; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; import org.jgrapes.core.Manager; @@ -62,13 +63,14 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; /** * The Class VmConlet. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", + "PMD.CouplingBetweenObjects" }) public class VmConlet extends FreeMarkerConlet { private static final Set MODES = RenderMode.asSet( RenderMode.Preview, RenderMode.View); private final ChannelTracker channelTracker = new ChannelTracker<>(); + VmDefinition> channelTracker = new ChannelTracker<>(); private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); private Summary cachedSummary; @@ -160,22 +162,45 @@ public class VmConlet extends FreeMarkerConlet { } if (sendVmInfos) { for (var item : channelTracker.values()) { - Gson gson = item.channel().client().getJSON().getGson(); - var def = gson.fromJson(item.associated().data(), Object.class); channel.respond(new NotifyConletView(type(), - conletId, "updateVm", def)); + conletId, "updateVm", + simplifiedVmDefinition(item.associated()))); } } return renderedAs; } + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + private Map simplifiedVmDefinition(VmDefinition vmDef) { + // Convert RAM sizes to unitless numbers + var spec = DataPath.deepCopy(vmDef.spec()); + var vmSpec = DataPath.> get(spec, "vm").get(); + vmSpec.put("maximumRam", Quantity.fromString( + DataPath. get(vmSpec, "maximumRam").orElse("0")).getNumber() + .toBigInteger()); + vmSpec.put("currentRam", Quantity.fromString( + DataPath. get(vmSpec, "currentRam").orElse("0")).getNumber() + .toBigInteger()); + var status = DataPath.deepCopy(vmDef.status()); + status.put("ram", Quantity.fromString( + DataPath. get(status, "ram").orElse("0")).getNumber() + .toBigInteger()); + + // Build result + return Map.of("metadata", + Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", spec, + "status", status, + "nodeName", vmDef.extra("nodeName")); + } + /** * Track the VM definitions. * * @param event the event * @param channel the channel - * @throws JsonDecodeException the json decode exception * @throws IOException */ @Handler(namedChannels = "manager") @@ -184,7 +209,7 @@ public class VmConlet extends FreeMarkerConlet { "PMD.ConfusingArgumentToVarargsMethod" }) public void onVmDefChanged(VmDefChanged event, VmChannel channel) throws IOException { - var vmName = event.vmDefinition().getMetadata().getName(); + var vmName = event.vmDefinition().name(); if (event.type() == K8sObserver.ResponseType.DELETED) { channelTracker.remove(vmName); for (var entry : conletIdsByConsoleConnection().entrySet()) { @@ -194,15 +219,12 @@ public class VmConlet extends FreeMarkerConlet { } } } else { - var gson = channel.client().getJSON().getGson(); - var vmDef = new VmDefinitionModel(gson, - cleanup(event.vmDefinition().data())); + var vmDef = event.vmDefinition(); channelTracker.put(vmName, channel, vmDef); - var def = gson.fromJson(vmDef.data(), Object.class); for (var entry : conletIdsByConsoleConnection().entrySet()) { for (String conletId : entry.getValue()) { entry.getKey().respond(new NotifyConletView(type(), - conletId, "updateVm", def)); + conletId, "updateVm", simplifiedVmDefinition(vmDef))); } } } @@ -217,28 +239,6 @@ public class VmConlet extends FreeMarkerConlet { } } - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private JsonObject cleanup(JsonObject vmDef) { - // Clone and remove managed fields - var json = vmDef.deepCopy(); - GsonPtr.to(json).to("metadata").get(JsonObject.class) - .remove("managedFields"); - - // Convert RAM sizes to unitless numbers - var vmSpec = GsonPtr.to(json).to("spec", "vm"); - vmSpec.set("maximumRam", Quantity.fromString( - vmSpec.getAsString("maximumRam").orElse("0")).getNumber() - .toBigInteger()); - vmSpec.set("currentRam", Quantity.fromString( - vmSpec.getAsString("currentRam").orElse("0")).getNumber() - .toBigInteger()); - var status = GsonPtr.to(json).to("status"); - status.set("ram", Quantity.fromString( - status.getAsString("ram").orElse("0")).getNumber() - .toBigInteger()); - return json; - } - /** * Handle the periodic update event by sending {@link NotifyConletView} * events. @@ -267,10 +267,10 @@ public class VmConlet extends FreeMarkerConlet { public int totalVms; /** The running vms. */ - public int runningVms; + public long runningVms; /** The used cpus. */ - public int usedCpus; + public long usedCpus; /** The used ram. */ public BigInteger usedRam = BigInteger.ZERO; @@ -289,7 +289,7 @@ public class VmConlet extends FreeMarkerConlet { * * @return the runningVms */ - public int getRunningVms() { + public long getRunningVms() { return runningVms; } @@ -298,7 +298,7 @@ public class VmConlet extends FreeMarkerConlet { * * @return the usedCpus */ - public int getUsedCpus() { + public long getUsedCpus() { return usedCpus; } @@ -313,7 +313,8 @@ public class VmConlet extends FreeMarkerConlet { } - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.LambdaCanBeMethodReference" }) private Summary evaluateSummary(boolean force) { if (!force && cachedSummary != null) { return cachedSummary; @@ -321,18 +322,20 @@ public class VmConlet extends FreeMarkerConlet { Summary summary = new Summary(); for (var vmDef : channelTracker.associated()) { summary.totalVms += 1; - var status = GsonPtr.to(vmDef.data()).to("status"); - summary.usedCpus += status.getAsInt("cpus").orElse(0); - summary.usedRam = summary.usedRam.add(status.getAsString("ram") - .map(BigInteger::new).orElse(BigInteger.ZERO)); - for (var c : status.getAsListOf(JsonObject.class, "conditions")) { - if ("Running".equals(GsonPtr.to(c).getAsString("type") - .orElse(null)) - && "True".equals(GsonPtr.to(c).getAsString("status") - .orElse(null))) { - summary.runningVms += 1; - } - } + summary.usedCpus += vmDef. fromStatus("cpus") + .map(Number::intValue).orElse(0); + summary.usedRam = summary.usedRam + .add(vmDef. fromStatus("ram") + .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) + .orElse(BigInteger.ZERO)); + summary.runningVms + = vmDef.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(t -> "Running".equals(t)).orElse(false) + && DataPath.get(cond, "status") + .map(s -> "True".equals(s)).orElse(false)) + .count(); } cachedSummary = summary; return summary; diff --git a/org.jdrupes.vmoperator.vmviewer/build.gradle b/org.jdrupes.vmoperator.vmviewer/build.gradle index b5faf7c..606c6cd 100644 --- a/org.jdrupes.vmoperator.vmviewer/build.gradle +++ b/org.jdrupes.vmoperator.vmviewer/build.gradle @@ -5,7 +5,7 @@ plugins { dependencies { implementation project(':org.jdrupes.vmoperator.manager.events') - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.0.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index 59cc98f..8920e4c 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java @@ -23,8 +23,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; import com.google.gson.JsonSyntaxException; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; @@ -48,17 +46,16 @@ import java.util.ResourceBundle; import java.util.Set; import java.util.logging.Level; import org.bouncycastle.util.Objects; -import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; -import org.jdrupes.vmoperator.common.VmDefinitionModel.Permission; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; 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.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -122,7 +119,7 @@ public class VmViewer extends FreeMarkerConlet { private static final Set MODES_FOR_GENERATED = RenderMode.asSet( RenderMode.Preview, RenderMode.StickyPreview); private final ChannelTracker channelTracker = new ChannelTracker<>(); + VmDefinition> channelTracker = new ChannelTracker<>(); private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private Class preferredIpVersion = Inet4Address.class; @@ -399,8 +396,7 @@ public class VmViewer extends FreeMarkerConlet { .map(d -> d.getMetadata().getName()).sorted().toList(); } - private Set permissions(VmDefinitionModel vmDef, - Session session) { + private Set permissions(VmDefinition vmDef, Session session) { var user = WebConsoleUtils.userFromSession(session) .map(ConsoleUser::getName).orElse(null); var roles = WebConsoleUtils.rolesFromSession(session) @@ -421,15 +417,16 @@ public class VmViewer extends FreeMarkerConlet { channelTracker.value(model.vmName()).ifPresent(item -> { try { var vmDef = item.associated(); - @SuppressWarnings("unchecked") - var def = (Map) item.channel().client() - .getJSON().getGson() - .fromJson(vmDef.data().toString(), Map.class); - def.put("userPermissions", + var data = Map.of("metadata", + Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", vmDef.spec(), + "status", vmDef.getStatus(), + "userPermissions", permissions(vmDef, channel.session()).stream() .map(Permission::toString).toList()); channel.respond(new NotifyConletView(type(), - model.getConletId(), "updateVmDefinition", def)); + model.getConletId(), "updateVmDefinition", data)); } catch (JsonSyntaxException e) { logger.log(Level.SEVERE, e, () -> "Failed to serialize VM definition"); @@ -452,7 +449,6 @@ public class VmViewer extends FreeMarkerConlet { * * @param event the event * @param channel the channel - * @throws JsonDecodeException the json decode exception * @throws IOException */ @Handler(namedChannels = "manager") @@ -461,11 +457,8 @@ public class VmViewer extends FreeMarkerConlet { "PMD.ConfusingArgumentToVarargsMethod" }) public void onVmDefChanged(VmDefChanged event, VmChannel channel) throws IOException { - var vmDef = new VmDefinitionModel(channel.client().getJSON() - .getGson(), event.vmDefinition().data()); - GsonPtr.to(vmDef.data()).to("metadata").get(JsonObject.class) - .remove("managedFields"); - var vmName = vmDef.getMetadata().getName(); + var vmDef = event.vmDefinition(); + var vmName = vmDef.name(); if (event.type() == K8sObserver.ResponseType.DELETED) { channelTracker.remove(vmName); } else { @@ -567,27 +560,26 @@ public class VmViewer extends FreeMarkerConlet { logger.severe(() -> "Failed to find display IP for " + vmName); return; } - var port = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec", - "vm", "display", "spice", "port"); + var port = vmDef. fromVm("display", "spice", "port") + .map(Number::longValue); if (port.isEmpty()) { logger.severe(() -> "No port defined for display of " + vmName); return; } - var proxyUrl = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec", - "vm", "display", "spice", "proxyUrl"); StringBuffer data = new StringBuffer(100) .append("[virt-viewer]\ntype=spice\nhost=") .append(addr.get().getHostAddress()).append("\nport=") - .append(Integer.toString(port.get().getAsInt())) + .append(port.get().toString()) .append('\n'); if (password != null) { data.append("password=").append(password).append('\n'); } - proxyUrl.map(JsonPrimitive::getAsString).ifPresent(u -> { - if (!Strings.isNullOrEmpty(u)) { - data.append("proxy=").append(u).append('\n'); - } - }); + vmDef. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); if (deleteConnectionFile) { data.append("delete-this-file=1\n"); } @@ -596,11 +588,10 @@ public class VmViewer extends FreeMarkerConlet { Base64.getEncoder().encodeToString(data.toString().getBytes()))); } - private Optional displayIp(K8sDynamicModel vmDef) { - var server = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec", - "vm", "display", "spice", "server"); + private Optional displayIp(VmDefinition vmDef) { + Optional server = vmDef.fromVm("display", "spice", "server"); if (server.isPresent()) { - var srv = server.get().getAsString(); + var srv = server.get(); try { var addr = InetAddress.getByName(srv); logger.fine(() -> "Using IP address from CRD for " @@ -612,8 +603,8 @@ public class VmViewer extends FreeMarkerConlet { return Optional.empty(); } } - var addrs = GsonPtr.to(vmDef.data()).getAsListOf(JsonPrimitive.class, - "nodeAddresses").stream().map(JsonPrimitive::getAsString) + var addrs = Optional.> ofNullable(vmDef + .extra("nodeAddresses")).orElse(Collections.emptyList()).stream() .map(a -> { try { return InetAddress.getByName(a); @@ -623,7 +614,7 @@ public class VmViewer extends FreeMarkerConlet { } }).filter(a -> a != null).toList(); logger.fine(() -> "Known IP addresses for " - + vmDef.getMetadata().getName() + ": " + addrs); + + vmDef.name() + ": " + addrs); return addrs.stream() .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) .findFirst().or(() -> addrs.stream().findFirst()); From abe06b46582408b0e7ecdbe43ef985a71806b55d Mon Sep 17 00:00:00 2001 From: Michael Lipp Date: Sat, 9 Nov 2024 11:08:59 +0000 Subject: [PATCH 009/274] Adapt viewer preview controls to permission changes. --- .../org/jdrupes/vmoperator/util/DataPath.java | 1 + .../jdrupes/vmoperator/vmviewer/VmViewer.java | 24 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java index be5b530..7a6596b 100644 --- a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java @@ -31,6 +31,7 @@ import java.util.logging.Logger; */ public final class DataPath { + @SuppressWarnings("PMD.FieldNamingConventions") private static final Logger logger = Logger.getLogger(DataPath.class.getName()); diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index 8920e4c..a21c420 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java @@ -45,6 +45,7 @@ import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; import java.util.logging.Level; +import java.util.stream.Collectors; import org.bouncycastle.util.Objects; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.VmDefinition; @@ -55,7 +56,6 @@ 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.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -123,7 +123,7 @@ public class VmViewer extends FreeMarkerConlet { private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private Class preferredIpVersion = Inet4Address.class; - private final Set syncUsers = new HashSet<>(); + private Set syncUsers = new HashSet<>(); private final Set syncRoles = new HashSet<>(); private boolean deleteConnectionFile = true; @@ -173,15 +173,12 @@ public class VmViewer extends FreeMarkerConlet { .filter(v -> v instanceof String).map(v -> (String) v) .map(Boolean::parseBoolean).orElse(true); - // Sync - for (var entry : (List>) c.getOrDefault( - "syncPreviewsFor", Collections.emptyList())) { - if (entry.containsKey("user")) { - syncUsers.add(entry.get("user")); - } else if (entry.containsKey("role")) { - syncRoles.add(entry.get("role")); - } - } + // Sync preview for users or roles + syncUsers = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> Optional.ofNullable(m.get("user")) + .orElse(m.get("role"))) + .filter(s -> s != null).collect(Collectors.toSet()); } catch (ClassCastException e) { logger.config("Malformed configuration: " + e.getMessage()); } @@ -367,8 +364,9 @@ public class VmViewer extends FreeMarkerConlet { fmModel(event, channel, conletId, model))) .setRenderAs( RenderMode.Preview.addModifiers(event.renderAs())) - .setSupportedModes( - model.isGenerated() ? MODES_FOR_GENERATED : MODES)); + .setSupportedModes(syncPreviews(channel.session()) + ? MODES_FOR_GENERATED + : MODES)); renderedAs.add(RenderMode.Preview); if (!Strings.isNullOrEmpty(model.vmName())) { Optional.ofNullable(channel.session().get(RENDERED)) From 3d446836b52163feffcc6739dfa2f481900b747a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 9 Nov 2024 12:41:22 +0100 Subject: [PATCH 010/274] Add "testing" to branches with default versioning. --- .../src/org.jdrupes.vmoperator.versioning-conventions.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle index a9e8dfe..49b6f74 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle @@ -19,6 +19,7 @@ if (shortened == "manager") { var tagName = shortened.replace('.', '-') + "-" if (grgit.branch.current.name != "main" && grgit.branch.current.name != "HEAD" + && !grgit.branch.current.name.startsWith("testing") && !grgit.branch.current.name.startsWith("release") && !grgit.branch.current.name.startsWith("develop")) { tagName = tagName + grgit.branch.current.name.replace('/', '-') + "-" From 5b209c935ee4051d16aca10465504f4e266714b9 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 9 Nov 2024 12:45:39 +0100 Subject: [PATCH 011/274] Pull from "testing" when using this branch. --- deploy/vmop-deployment.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml index 648cc39..e232134 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -20,13 +20,13 @@ spec: containers: - name: vm-operator image: >- - ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:testing + 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 90199072241c449f6b8481bb659415545827667f Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 9 Nov 2024 13:03:45 +0100 Subject: [PATCH 012/274] Add "testing" to branches with default versioning. --- .../src/org.jdrupes.vmoperator.versioning-conventions.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle index a9e8dfe..49b6f74 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle @@ -19,6 +19,7 @@ if (shortened == "manager") { var tagName = shortened.replace('.', '-') + "-" if (grgit.branch.current.name != "main" && grgit.branch.current.name != "HEAD" + && !grgit.branch.current.name.startsWith("testing") && !grgit.branch.current.name.startsWith("release") && !grgit.branch.current.name.startsWith("develop")) { tagName = tagName + grgit.branch.current.name.replace('/', '-') + "-" From 4d762254424cc7159105c7d02b07623dccbfc6b3 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 9 Nov 2024 13:09:11 +0100 Subject: [PATCH 013/274] Fix image path. --- 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 e232134..f61d5f1 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -20,7 +20,7 @@ spec: containers: - name: vm-operator image: >- - ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:testing + registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.manager:testing imagePullPolicy: Always volumeMounts: - name: config From c7b65ca581aa5eb8f6513ac05f4f4815d6ad1796 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 14:13:34 +0100 Subject: [PATCH 014/274] Report console connection events. --- deploy/crds/vms-crd.yaml | 11 +++ .../vmoperator/runner/qemu/StatusUpdater.java | 92 +++++++++++++++++++ .../runner/qemu/events/MonitorEvent.java | 14 ++- .../qemu/events/SpiceConnectedEvent.java | 37 ++++++++ .../qemu/events/SpiceDisconnectedEvent.java | 37 ++++++++ .../runner/qemu/events/SpiceEvent.java | 46 ++++++++++ 6 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index cda817c..492deae 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1457,6 +1457,11 @@ spec: Amount of memory in use. type: string default: "0" + consoleClient: + description: >- + The hostname of the currently connected client. + type: string + default: "" displayPasswordSerial: description: >- Counts changes of the display password. Set to -1 @@ -1473,6 +1478,12 @@ spec: lastTransitionTime: "1970-01-01T00:00:00Z" reason: Creation message: "Creation of CR" + - type: ConsoleConnected + status: "False" + observedGeneration: 1 + lastTransitionTime: "1970-01-01T00:00:00Z" + reason: Creation + message: "Creation of CR" type: array items: type: object 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 412681f..bac272e 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 @@ -30,10 +30,13 @@ import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.logging.Level; +import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; @@ -50,6 +53,8 @@ import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceConnectedEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -363,4 +368,91 @@ public class StatusUpdater extends Component { public void onShutdown(ShutdownEvent event) throws ApiException { shutdownByGuest = event.byGuest(); } + + /** + * On spice connected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onSpiceConnected(SpiceConnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", event.clientHost()); + updateConsoleConnectedCondition(from, status, true); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Connection from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + /** + * On spice disconnected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onSpiceDisconnected(SpiceDisconnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", ""); + updateConsoleConnectedCondition(from, status, false); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Disconnected from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + private void updateConsoleConnectedCondition(VmDefinitionModel from, + JsonObject status, boolean connected) { + // Optimize, as we can get this several times + var current = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .filter(cond -> "ConsoleConnected" + .equals(cond.get("type").getAsString())) + .findFirst() + .map(cond -> "True".equals(cond.get("status").getAsString())); + if (current.isPresent() && current.get() == connected) { + return; + } + + // Do update + final var condition = Map.of("type", "ConsoleConnected", + "status", connected ? "True" : "False", + "observedGeneration", from.getMetadata().getGeneration(), + "reason", connected ? "Connected" : "Disconnected", + "lastTransitionTime", Instant.now().toString()); + List toReplace = new ArrayList<>(List.of(condition)); + List newConds + = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .map(cond -> "ConsoleConnected" + .equals(cond.get("type").getAsString()) + ? toReplace.remove(0) + : cond) + .collect(Collectors.toCollection(() -> new ArrayList<>())); + newConds.addAll(toReplace); + status.add("conditions", + apiClient.getJSON().getGson().toJsonTree(newConds)); + } } 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 ba04a26..2cc0f33 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 @@ -34,7 +34,8 @@ public class MonitorEvent extends Event { * The kind of monitor event. */ public enum Kind { - READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN + READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, + SPICE_CONNECTED, SPICE_DISCONNECTED } private final Kind kind; @@ -49,8 +50,7 @@ public class MonitorEvent extends Event { @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") public static Optional from(JsonNode response) { try { - var kind = MonitorEvent.Kind - .valueOf(response.get("event").asText()); + var kind = Kind.valueOf(response.get("event").asText()); switch (kind) { case POWERDOWN: return Optional.of(new PowerdownEvent(kind, null)); @@ -63,6 +63,14 @@ public class MonitorEvent extends Event { case SHUTDOWN: return Optional .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); + case SPICE_CONNECTED: + return Optional + .of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_DISCONNECTED: + return Optional + .of(new SpiceDisconnectedEvent(kind, + response.get(EVENT_DATA))); default: return Optional .of(new MonitorEvent(kind, response.get(EVENT_DATA))); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java new file mode 100644 index 0000000..c133307 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceConnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceConnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java new file mode 100644 index 0000000..cfcb489 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceDisconnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice disconnected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceDisconnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java new file mode 100644 index 0000000..6706f0c --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java @@ -0,0 +1,46 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceEvent extends MonitorEvent { + + /** + * Instantiates a new tray moved. + * + * @param kind the kind + * @param data the data + */ + public SpiceEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the client's host. + * + * @return the client's host address + */ + public String clientHost() { + return data().get("client").get("host").asText(); + } +} From 12408143a7491489ed547ca23af5715b56bc548f Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 14:15:17 +0100 Subject: [PATCH 015/274] Fix warning. --- .../src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java | 1 + 1 file changed, 1 insertion(+) 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 bac272e..f6814b3 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 @@ -280,6 +280,7 @@ public class StatusUpdater extends Component { private void updateRunningCondition(RunnerStateChange event, K8sDynamicModel from, JsonObject cond) { + @SuppressWarnings("PMD.AvoidDuplicateLiterals") boolean reportedRunning = "True".equals(cond.get("status").getAsString()); if (RUNNING_STATES.contains(event.runState()) From e5fd45ebcba7120d2f68ff5e0a2539af69a71132 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 14:41:45 +0100 Subject: [PATCH 016/274] Show console client. --- .../resources/org/jdrupes/vmoperator/vmconlet/l10n.properties | 1 + .../org/jdrupes/vmoperator/vmconlet/l10n_de.properties | 1 + .../jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties index 880369b..41bf670 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties @@ -11,5 +11,6 @@ nodeName = Node requestedCpus = Requested CPUs requestedRam = Requested RAM running = Running +usedBy = Used by vmActions = Actions vmname = Name diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties index 7e1d95e..819db03 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties @@ -15,6 +15,7 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM +usedBy = Benutzt von vmActions = Aktionen vmname = Name Value\ is\ above\ maximum = Wert ist zu groß diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts index 44d6471..8daf3a9 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts @@ -111,7 +111,8 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["runningConditionSince", "since"], ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], - ["nodeName", "nodeName"] + ["nodeName", "nodeName"], + ["usedBy", "usedBy"] ], { sortKey: "name", sortOrder: "up" @@ -179,6 +180,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet", vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; From 090d504b7757d3e2194fcf3c1ac69df23669efa1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 17:10:08 +0100 Subject: [PATCH 017/274] Add in-use visualization. --- .../vmoperator/vmviewer/computer-in-use.svg | 86 +++++++++++++++++++ .../vmviewer/browser/VmViewer-functions.ts | 7 +- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg new file mode 100644 index 0000000..90339c1 --- /dev/null +++ b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts index a14e83c..42c7d10 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts @@ -72,6 +72,7 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); + const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.vmDefinition.spec ? previewApi.vmDefinition.userPermissions : []); @@ -88,7 +89,7 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, }; return { localize, resourceBase, vmAction, configured, - startable, stoppable, running, permissions }; + startable, stoppable, running, inUse, permissions }; }, template: ` @@ -101,7 +102,8 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, || !permissions.includes('accessConsole')" v-on:click="vmAction('openConsole')" :src="resourceBase + (running - ? 'computer.svg' : 'computer-off.svg')" + ? (inUse ? 'computer-in-use.svg' : 'computer.svg') + : 'computer-off.svg')" :title="localize('Open console')"> @@ -159,6 +161,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmviewer.VmViewer", vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; From 355eded86b5f03b09c9e947ca7908cafb396799c Mon Sep 17 00:00:00 2001 From: Michael Lipp Date: Sun, 10 Nov 2024 16:19:46 +0000 Subject: [PATCH 018/274] Add tracking of client (viewer) connection state and visualize in GUI. --- deploy/crds/vms-crd.yaml | 11 +++ .../vmoperator/runner/qemu/StatusUpdater.java | 93 +++++++++++++++++++ .../runner/qemu/events/MonitorEvent.java | 14 ++- .../qemu/events/SpiceConnectedEvent.java | 37 ++++++++ .../qemu/events/SpiceDisconnectedEvent.java | 37 ++++++++ .../runner/qemu/events/SpiceEvent.java | 46 +++++++++ .../vmoperator/vmconlet/l10n.properties | 1 + .../vmoperator/vmconlet/l10n_de.properties | 1 + .../vmconlet/browser/VmConlet-functions.ts | 4 +- .../vmoperator/vmviewer/computer-in-use.svg | 86 +++++++++++++++++ .../vmviewer/browser/VmViewer-functions.ts | 7 +- 11 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java create mode 100644 org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index cda817c..492deae 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1457,6 +1457,11 @@ spec: Amount of memory in use. type: string default: "0" + consoleClient: + description: >- + The hostname of the currently connected client. + type: string + default: "" displayPasswordSerial: description: >- Counts changes of the display password. Set to -1 @@ -1473,6 +1478,12 @@ spec: lastTransitionTime: "1970-01-01T00:00:00Z" reason: Creation message: "Creation of CR" + - type: ConsoleConnected + status: "False" + observedGeneration: 1 + lastTransitionTime: "1970-01-01T00:00:00Z" + reason: Creation + message: "Creation of CR" type: array items: type: object 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 412681f..f6814b3 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 @@ -30,10 +30,13 @@ import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.logging.Level; +import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; @@ -50,6 +53,8 @@ import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceConnectedEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -275,6 +280,7 @@ public class StatusUpdater extends Component { private void updateRunningCondition(RunnerStateChange event, K8sDynamicModel from, JsonObject cond) { + @SuppressWarnings("PMD.AvoidDuplicateLiterals") boolean reportedRunning = "True".equals(cond.get("status").getAsString()); if (RUNNING_STATES.contains(event.runState()) @@ -363,4 +369,91 @@ public class StatusUpdater extends Component { public void onShutdown(ShutdownEvent event) throws ApiException { shutdownByGuest = event.byGuest(); } + + /** + * On spice connected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onSpiceConnected(SpiceConnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", event.clientHost()); + updateConsoleConnectedCondition(from, status, true); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Connection from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + /** + * On spice disconnected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onSpiceDisconnected(SpiceDisconnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", ""); + updateConsoleConnectedCondition(from, status, false); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Disconnected from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + private void updateConsoleConnectedCondition(VmDefinitionModel from, + JsonObject status, boolean connected) { + // Optimize, as we can get this several times + var current = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .filter(cond -> "ConsoleConnected" + .equals(cond.get("type").getAsString())) + .findFirst() + .map(cond -> "True".equals(cond.get("status").getAsString())); + if (current.isPresent() && current.get() == connected) { + return; + } + + // Do update + final var condition = Map.of("type", "ConsoleConnected", + "status", connected ? "True" : "False", + "observedGeneration", from.getMetadata().getGeneration(), + "reason", connected ? "Connected" : "Disconnected", + "lastTransitionTime", Instant.now().toString()); + List toReplace = new ArrayList<>(List.of(condition)); + List newConds + = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .map(cond -> "ConsoleConnected" + .equals(cond.get("type").getAsString()) + ? toReplace.remove(0) + : cond) + .collect(Collectors.toCollection(() -> new ArrayList<>())); + newConds.addAll(toReplace); + status.add("conditions", + apiClient.getJSON().getGson().toJsonTree(newConds)); + } } 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 ba04a26..2cc0f33 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 @@ -34,7 +34,8 @@ public class MonitorEvent extends Event { * The kind of monitor event. */ public enum Kind { - READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN + READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, + SPICE_CONNECTED, SPICE_DISCONNECTED } private final Kind kind; @@ -49,8 +50,7 @@ public class MonitorEvent extends Event { @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") public static Optional from(JsonNode response) { try { - var kind = MonitorEvent.Kind - .valueOf(response.get("event").asText()); + var kind = Kind.valueOf(response.get("event").asText()); switch (kind) { case POWERDOWN: return Optional.of(new PowerdownEvent(kind, null)); @@ -63,6 +63,14 @@ public class MonitorEvent extends Event { case SHUTDOWN: return Optional .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); + case SPICE_CONNECTED: + return Optional + .of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_DISCONNECTED: + return Optional + .of(new SpiceDisconnectedEvent(kind, + response.get(EVENT_DATA))); default: return Optional .of(new MonitorEvent(kind, response.get(EVENT_DATA))); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java new file mode 100644 index 0000000..c133307 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceConnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceConnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java new file mode 100644 index 0000000..cfcb489 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceDisconnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice disconnected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceDisconnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java new file mode 100644 index 0000000..6706f0c --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java @@ -0,0 +1,46 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceEvent extends MonitorEvent { + + /** + * Instantiates a new tray moved. + * + * @param kind the kind + * @param data the data + */ + public SpiceEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the client's host. + * + * @return the client's host address + */ + public String clientHost() { + return data().get("client").get("host").asText(); + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties index 880369b..41bf670 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties @@ -11,5 +11,6 @@ nodeName = Node requestedCpus = Requested CPUs requestedRam = Requested RAM running = Running +usedBy = Used by vmActions = Actions vmname = Name diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties index 7e1d95e..819db03 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties @@ -15,6 +15,7 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM +usedBy = Benutzt von vmActions = Aktionen vmname = Name Value\ is\ above\ maximum = Wert ist zu groß diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts index 44d6471..8daf3a9 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts @@ -111,7 +111,8 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["runningConditionSince", "since"], ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], - ["nodeName", "nodeName"] + ["nodeName", "nodeName"], + ["usedBy", "usedBy"] ], { sortKey: "name", sortOrder: "up" @@ -179,6 +180,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet", vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg new file mode 100644 index 0000000..90339c1 --- /dev/null +++ b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts index a14e83c..42c7d10 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts @@ -72,6 +72,7 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); + const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.vmDefinition.spec ? previewApi.vmDefinition.userPermissions : []); @@ -88,7 +89,7 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, }; return { localize, resourceBase, vmAction, configured, - startable, stoppable, running, permissions }; + startable, stoppable, running, inUse, permissions }; }, template: `
@@ -101,7 +102,8 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, || !permissions.includes('accessConsole')" v-on:click="vmAction('openConsole')" :src="resourceBase + (running - ? 'computer.svg' : 'computer-off.svg')" + ? (inUse ? 'computer-in-use.svg' : 'computer.svg') + : 'computer-off.svg')" :title="localize('Open console')"> @@ -159,6 +161,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmviewer.VmViewer", vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; From 12d6745d759581dd4aee82f0aeabb27e7cfe92dd Mon Sep 17 00:00:00 2001 From: Michael Lipp Date: Tue, 12 Nov 2024 19:29:46 +0000 Subject: [PATCH 019/274] Add some logging messages and enhance logging configurability. --- deploy/crds/vms-crd.yaml | 5 ++++ dev-example/config.yaml | 12 +++++++++ .../manager/ConfigMapReconciler.java | 27 +++++++++++++++---- .../vmoperator/manager/Reconciler.java | 5 ++++ .../runner/qemu/CommandDefinition.java | 5 ++++ .../vmoperator/runner/qemu/Runner.java | 18 +++++++++++++ 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 492deae..f1bbaf2 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1019,6 +1019,11 @@ spec: - accessConsole - "*" default: [] + loggingProperties: + type: string + description: >- + Override the default logging properties for + the runner for this VM. vm: type: object description: Defines the VM. diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 3f973e4..af1f3b8 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -17,6 +17,18 @@ 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 + loggingProperties: | + # Defaults for namespace (VM domain) + handlers=java.util.logging.ConsoleHandler + + #org.jgrapes.level=FINE + #org.jgrapes.core.handlerTracking.level=FINER + + org.jdrupes.vmoperator.runner.qemu.level=FINEST + + java.util.logging.ConsoleHandler.level=ALL + java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter + java.util.logging.SimpleFormatter.format=%1$tb %1$td %1$tT %4$s %5$s%6$s%n "/GuiSocketServer": port: 8888 "/GuiHttpServer": 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 129a54d..68ca7fa 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 @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; import freemarker.template.Configuration; import freemarker.template.TemplateException; import io.kubernetes.client.custom.V1Patch; @@ -36,6 +37,8 @@ import org.jdrupes.vmoperator.common.K8s; 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.VmChannel; +import org.jdrupes.vmoperator.util.DataPath; +import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -71,10 +74,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; public Map reconcile(Map model, VmChannel channel) throws IOException, TemplateException, ApiException { - // Get API - DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", - "configmaps", channel.client()); - // Combine template and data and parse result var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); StringWriter out = new StringWriter(); @@ -84,8 +83,26 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; var mapDef = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + // Maybe override logging.properties from reconciler configuration. + DataPath. get(model, "reconciler", "loggingProperties") + .ifPresent(props -> { + GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") + .get().addProperty("logging.properties", props); + }); + + // Maybe override logging.properties from VM definition. + DataPath. get(model, "cr", "spec", "loggingProperties") + .ifPresent(props -> { + GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") + .get().addProperty("logging.properties", props); + }); + + // Get API + DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", + "configmaps", channel.client()); + // Apply and maybe force pod update - var newState = K8s.apply(cmApi, mapDef, out.toString()); + var newState = K8s.apply(cmApi, mapDef, mapDef.getRaw().toString()); maybeForceUpdate(channel.client(), newState); @SuppressWarnings("unchecked") var res = (Map) channel.client().getJSON().getGson() 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 32753ac..3fa2ffe 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 @@ -132,6 +132,11 @@ import org.jgrapes.util.events.ConfigurationUpdate; * ``` * This makes all VM consoles available at IP address 192.168.168.1 * with the port numbers from the VM definitions. + * + * * `loggingProperties`: If defined, specifies the default logging + * properties to be used by the runners managed by the controller. + * This property is a string that holds the content of + * a logging.properties file. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.AvoidDuplicateLiterals" }) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java index 9057606..7aec209 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java @@ -69,4 +69,9 @@ class CommandDefinition { public String name() { return name; } + + @Override + public String toString() { + return "Command " + name + ": " + command; + } } \ No newline at end of file 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 0b6e22e..fedaf1e 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 @@ -48,6 +48,8 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.CommandLineParser; import org.apache.commons.cli.DefaultParser; @@ -318,6 +320,7 @@ public class Runner extends Component { }); } + @SuppressWarnings("PMD.LambdaCanBeMethodReference") private void processInitialConfiguration(Configuration newConfig) { try { config = newConfig; @@ -333,12 +336,15 @@ public class Runner extends Component { var tplData = dataFromTemplate(); swtpmDefinition = Optional.ofNullable(tplData.get(SWTPM)) .map(d -> new CommandDefinition(SWTPM, d)).orElse(null); + logger.finest(() -> swtpmDefinition.toString()); qemuDefinition = Optional.ofNullable(tplData.get(QEMU)) .map(d -> new CommandDefinition(QEMU, d)).orElse(null); + logger.finest(() -> qemuDefinition.toString()); cloudInitImgDefinition = Optional.ofNullable(tplData.get(CLOUD_INIT_IMG)) .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) .orElse(null); + logger.finest(() -> cloudInitImgDefinition.toString()); // Forward some values to child components qemuMonitor.configure(config.monitorSocket, @@ -364,6 +370,12 @@ public class Runner extends Component { break; } } + if (codePaths.iterator().hasNext() && config.firmwareRom == null) { + throw new IllegalArgumentException("No ROM found, candidates were: " + + StreamSupport.stream(codePaths.spliterator(), false) + .map(JsonNode::asText).collect(Collectors.joining(", "))); + } + // Get file for firmware vars, if necessary config.firmwareVars = config.dataDir.resolve(FW_VARS); if (!Files.exists(config.firmwareVars)) { @@ -405,12 +417,14 @@ public class Runner extends Component { model.put("hasDisplayPassword", config.hasDisplayPassword); model.put("cloudInit", config.cloudInit); model.put("vm", config.vm); + logger.finest(() -> "Processing template with model: " + model); // Combine template and data and parse result // (tempting, but no need to use a pipe here) var fmTemplate = fmConfig.getTemplate(templatePath.toString()); StringWriter out = new StringWriter(); fmTemplate.process(model, out); + logger.finest(() -> "Result of processing template: " + out); return yamlMapper.readValue(out.toString(), JsonNode.class); } @@ -746,6 +760,10 @@ public class Runner extends Component { props = Runner.class.getResourceAsStream("logging.properties"); } LogManager.getLogManager().readConfiguration(props); + Logger.getLogger(Runner.class.getName()).log(Level.CONFIG, + () -> path.isPresent() + ? "Using logging configuration from " + path.get() + : "Using default logging configuration"); } catch (IOException e) { e.printStackTrace(); } From 40cbeb694bfd37aaa446b3d3ef80c8c56d4de1b3 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 12 Nov 2024 22:04:15 +0100 Subject: [PATCH 020/274] Avoid NPE. --- .../src/org/jdrupes/vmoperator/runner/qemu/Runner.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 fedaf1e..52db0ce 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 @@ -204,7 +204,7 @@ public class Runner extends Component { private static final String FW_VARS = "fw-vars.fd"; private static int exitStatus; - private EventPipeline rep; + private final EventPipeline rep = newEventPipeline(); private final ObjectMapper yamlMapper = new ObjectMapper(YAMLFactory .builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) .build()); @@ -446,8 +446,7 @@ public class Runner extends Component { // https://github.com/kubernetes-client/java/issues/100 io.kubernetes.client.openapi.Configuration.setDefaultApiClient(null); - // Prepare specific event pipeline to avoid concurrency. - rep = newEventPipeline(); + // Provide specific event pipeline to avoid concurrency. event.setAssociated(EventPipeline.class, rep); try { // Store process id From 228322748b494c9a3c9b1f1db755f9f4de7fe9c7 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 12 Nov 2024 22:41:54 +0100 Subject: [PATCH 021/274] Use 4m bios if smaller version is not available. --- .../resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml index 9aadbf6..32fdf55 100644 --- a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml @@ -8,6 +8,9 @@ - "/usr/share/edk2/ovmf/OVMF_CODE.fd" - "/usr/share/edk2/x64/OVMF_CODE.fd" - "/usr/share/OVMF/OVMF_CODE.fd" + # Use 4M version as fallback (if smaller version not available) + - "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd" + - "/usr/share/edk2/x64/OVMF_CODE.4m.fd" "vars": - "/usr/share/edk2/ovmf/OVMF_VARS.fd" - "/usr/share/edk2/x64/OVMF_VARS.fd" From 97732073078bec55dcb2f3490a4cd4c1b34cb101 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 13 Nov 2024 11:14:53 +0100 Subject: [PATCH 022/274] Better name. --- .../resources/org/jdrupes/vmoperator/vmconlet/l10n.properties | 2 +- .../org/jdrupes/vmoperator/vmconlet/l10n_de.properties | 2 +- .../jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties index 41bf670..f4165c4 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties @@ -11,6 +11,6 @@ nodeName = Node requestedCpus = Requested CPUs requestedRam = Requested RAM running = Running -usedBy = Used by +usedFrom = Used from vmActions = Actions vmname = Name diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties index 819db03..29239ed 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties @@ -15,7 +15,7 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM -usedBy = Benutzt von +usedFrom = Benutzt von vmActions = Aktionen vmname = Name Value\ is\ above\ maximum = Wert ist zu groß diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts index 8daf3a9..b171569 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts @@ -112,7 +112,7 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], ["nodeName", "nodeName"], - ["usedBy", "usedBy"] + ["usedFrom", "usedFrom"] ], { sortKey: "name", sortOrder: "up" @@ -180,7 +180,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet", vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); - vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; + vmDefinition.usedFrom = vmDefinition.status.consoleClient || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; From 4d447717c2a40a9e933bf95ab60921fa72e88366 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 13 Nov 2024 23:45:54 +0100 Subject: [PATCH 023/274] Improve tracking. --- .../runner/qemu/ConsoleTracker.java | 159 +++++++++++++++ .../vmoperator/runner/qemu/StatusUpdater.java | 183 ++---------------- .../vmoperator/runner/qemu/VmDefUpdater.java | 141 ++++++++++++++ .../runner/qemu/events/MonitorEvent.java | 15 +- .../runner/qemu/events/SpiceEvent.java | 9 + .../qemu/events/SpiceInitializedEvent.java | 46 +++++ 6 files changed, 375 insertions(+), 178 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java new file mode 100644 index 0000000..7d54235 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -0,0 +1,159 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.EventsV1Event; +import java.io.IOException; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceInitializedEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; + +/** + * A (sub)component that updates the console status in the CR status. + * Created as child of {@link StatusUpdater}. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class ConsoleTracker extends VmDefUpdater { + + private final K8sClient apiClient; + private VmDefinitionStub vmStub; + private String mainChannelClientHost; + private long mainChannelClientPort; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public ConsoleTracker(Channel componentChannel) { + super(componentChannel); + apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration + .getDefaultApiClient(); + } + + /** + * Handle the start event. + * + * @param event the event + * @throws IOException + * @throws ApiException + */ + @Handler + public void onStart(Start event) { + if (namespace == null) { + return; + } + try { + vmStub = VmDefinitionStub.get(apiClient, + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + namespace, vmName); + } catch (ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access VM object, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + /** + * On spice connected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidDuplicateLiterals" }) + public void onSpiceInitialized(SpiceInitializedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process connections using main channel. + if (event.channelType() != 1) { + return; + } + mainChannelClientHost = event.clientHost(); + mainChannelClientPort = event.clientPort(); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", event.clientHost()); + updateCondition(apiClient, from, status, "ConsoleConnected", + true, "Connection from " + event.clientHost(), null); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Connection from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + /** + * On spice disconnected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + public void onSpiceDisconnected(SpiceDisconnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process disconnects from main channel. + if (!event.clientHost().equals(mainChannelClientHost) + || event.clientPort() != mainChannelClientPort) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", ""); + updateCondition(apiClient, from, status, "ConsoleConnected", + false, event.clientHost() + " has disconnected", null); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Disconnected from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } +} 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 f6814b3..ca5d46a 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 @@ -27,22 +27,13 @@ import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.logging.Level; -import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; @@ -53,28 +44,21 @@ import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; -import org.jdrupes.vmoperator.runner.qemu.events.SpiceConnectedEvent; -import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.InitialConfiguration; /** * Updates the CR status. */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class StatusUpdater extends Component { +public class StatusUpdater extends VmDefUpdater { private static final Set RUNNING_STATES = Set.of(RunState.RUNNING, RunState.TERMINATING); - private String namespace; - private String vmName; private K8sClient apiClient; private long observedGeneration; private boolean guestShutdownStops; @@ -98,6 +82,7 @@ public class StatusUpdater extends Component { () -> "Cannot access events API, terminating."); fire(new Exit(1)); } + attach(new ConsoleTracker(componentChannel)); } /** @@ -114,43 +99,6 @@ public class StatusUpdater extends Component { } } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @SuppressWarnings("unchecked") - public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured("/Runner").ifPresent(c -> { - if (event instanceof InitialConfiguration) { - namespace = (String) c.get("namespace"); - updateNamespace(); - vmName = Optional.ofNullable((Map) c.get("vm")) - .map(vm -> vm.get("name")).orElse(null); - } - }); - } - - private void updateNamespace() { - if (namespace == null) { - var path = Path - .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); - if (Files.isReadable(path)) { - try { - namespace = Files.lines(path).findFirst().orElse(null); - } catch (IOException e) { - logger.log(Level.WARNING, e, - () -> "Cannot read namespace."); - } - } - } - if (namespace == null) { - logger.warning(() -> "Namespace is unknown, some functions" - + " won't be available."); - } - } - /** * Handle the start event. * @@ -238,13 +186,9 @@ public class StatusUpdater extends Component { } vmStub.updateStatus(vmDef, from -> { JsonObject status = from.status(); - status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .forEach(cond -> { - if ("Running".equals(cond.get("type").getAsString())) { - updateRunningCondition(event, from, cond); - } - }); + boolean running = RUNNING_STATES.contains(event.runState()); + updateCondition(apiClient, vmDef, vmDef.status(), "Running", + running, event.reason(), event.message()); if (event.runState() == RunState.STARTING) { status.addProperty("ram", GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); @@ -253,6 +197,13 @@ public class StatusUpdater extends Component { status.addProperty("ram", "0"); status.addProperty("cpus", 0); } + + // In case console connection was still present + if (!running) { + status.addProperty("consoleClient", ""); + updateCondition(apiClient, from, status, "ConsoleConnected", + false, "VM has stopped", null); + } return status; }); @@ -278,29 +229,6 @@ public class StatusUpdater extends Component { K8s.createEvent(apiClient, vmDef, evt); } - private void updateRunningCondition(RunnerStateChange event, - K8sDynamicModel from, JsonObject cond) { - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - boolean reportedRunning - = "True".equals(cond.get("status").getAsString()); - if (RUNNING_STATES.contains(event.runState()) - && !reportedRunning) { - cond.addProperty("status", "True"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); - } - if (!RUNNING_STATES.contains(event.runState()) - && reportedRunning) { - cond.addProperty("status", "False"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); - } - cond.addProperty("reason", event.reason()); - cond.addProperty("message", event.message()); - cond.addProperty("observedGeneration", - from.getMetadata().getGeneration()); - } - /** * On ballon change. * @@ -369,91 +297,4 @@ public class StatusUpdater extends Component { public void onShutdown(ShutdownEvent event) throws ApiException { shutdownByGuest = event.byGuest(); } - - /** - * On spice connected. - * - * @param event the event - * @throws ApiException the api exception - */ - @Handler - public void onSpiceConnected(SpiceConnectedEvent event) - throws ApiException { - if (vmStub == null) { - return; - } - vmStub.updateStatus(from -> { - JsonObject status = from.status(); - status.addProperty("consoleClient", event.clientHost()); - updateConsoleConnectedCondition(from, status, true); - return status; - }); - - // Log event - var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) - .action("ConsoleConnectionUpdate") - .reason("Connection from " + event.clientHost()); - K8s.createEvent(apiClient, vmStub.model().get(), evt); - } - - /** - * On spice disconnected. - * - * @param event the event - * @throws ApiException the api exception - */ - @Handler - public void onSpiceDisconnected(SpiceDisconnectedEvent event) - throws ApiException { - if (vmStub == null) { - return; - } - vmStub.updateStatus(from -> { - JsonObject status = from.status(); - status.addProperty("consoleClient", ""); - updateConsoleConnectedCondition(from, status, false); - return status; - }); - - // Log event - var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) - .action("ConsoleConnectionUpdate") - .reason("Disconnected from " + event.clientHost()); - K8s.createEvent(apiClient, vmStub.model().get(), evt); - } - - private void updateConsoleConnectedCondition(VmDefinitionModel from, - JsonObject status, boolean connected) { - // Optimize, as we can get this several times - var current = status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .filter(cond -> "ConsoleConnected" - .equals(cond.get("type").getAsString())) - .findFirst() - .map(cond -> "True".equals(cond.get("status").getAsString())); - if (current.isPresent() && current.get() == connected) { - return; - } - - // Do update - final var condition = Map.of("type", "ConsoleConnected", - "status", connected ? "True" : "False", - "observedGeneration", from.getMetadata().getGeneration(), - "reason", connected ? "Connected" : "Disconnected", - "lastTransitionTime", Instant.now().toString()); - List toReplace = new ArrayList<>(List.of(condition)); - List newConds - = status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .map(cond -> "ConsoleConnected" - .equals(cond.get("type").getAsString()) - ? toReplace.remove(0) - : cond) - .collect(Collectors.toCollection(() -> new ArrayList<>())); - newConds.addAll(toReplace); - status.add("conditions", - apiClient.getJSON().getGson().toJsonTree(newConds)); - } } 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 new file mode 100644 index 0000000..893fc61 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java @@ -0,0 +1,141 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import io.kubernetes.client.openapi.ApiClient; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.InitialConfiguration; + +/** + * Updates the CR status. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class VmDefUpdater extends Component { + + protected String namespace; + protected String vmName; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public VmDefUpdater(Channel componentChannel) { + super(componentChannel); + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + @SuppressWarnings("unchecked") + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured("/Runner").ifPresent(c -> { + if (event instanceof InitialConfiguration) { + namespace = (String) c.get("namespace"); + updateNamespace(); + vmName = Optional.ofNullable((Map) c.get("vm")) + .map(vm -> vm.get("name")).orElse(null); + } + }); + } + + private void updateNamespace() { + if (namespace == null) { + var path = Path + .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); + if (Files.isReadable(path)) { + try { + namespace = Files.lines(path).findFirst().orElse(null); + } catch (IOException e) { + logger.log(Level.WARNING, e, + () -> "Cannot read namespace."); + } + } + } + if (namespace == null) { + logger.warning(() -> "Namespace is unknown, some functions" + + " won't be available."); + } + } + + /** + * Update condition. + * + * @param apiClient the api client + * @param from the vM definition + * @param status the current status + * @param type the condition type + * @param state the new state + * @param reason the reason for the change + */ + protected void updateCondition(ApiClient apiClient, VmDefinitionModel from, + JsonObject status, String type, boolean state, String reason, + String message) { + // Optimize, as we can get this several times + var current = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .filter(cond -> type.equals(cond.get("type").getAsString())) + .findFirst() + .map(cond -> "True".equals(cond.get("status").getAsString())); + if (current.isPresent() && current.get() == state) { + return; + } + + // Do update + final var condition = new HashMap<>(Map.of("type", type, + "status", state ? "True" : "False", + "observedGeneration", from.getMetadata().getGeneration(), + "reason", reason, + "lastTransitionTime", Instant.now().toString())); + if (message != null) { + condition.put("message", message); + } + List toReplace = new ArrayList<>(List.of(condition)); + List newConds + = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .map(cond -> type.equals(cond.get("type").getAsString()) + ? toReplace.remove(0) + : cond) + .collect(Collectors.toCollection(() -> new ArrayList<>())); + newConds.addAll(toReplace); + status.add("conditions", + apiClient.getJSON().getGson().toJsonTree(newConds)); + } +} 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 2cc0f33..df981c8 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 @@ -35,7 +35,7 @@ public class MonitorEvent extends Event { */ public enum Kind { READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, - SPICE_CONNECTED, SPICE_DISCONNECTED + SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED } private final Kind kind; @@ -64,13 +64,14 @@ public class MonitorEvent extends Event { return Optional .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); case SPICE_CONNECTED: - return Optional - .of(new SpiceConnectedEvent(kind, - response.get(EVENT_DATA))); + return Optional.of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_INITIALIZED: + return Optional.of(new SpiceInitializedEvent(kind, + response.get(EVENT_DATA))); case SPICE_DISCONNECTED: - return Optional - .of(new SpiceDisconnectedEvent(kind, - response.get(EVENT_DATA))); + return Optional.of(new SpiceDisconnectedEvent(kind, + response.get(EVENT_DATA))); default: return Optional .of(new MonitorEvent(kind, response.get(EVENT_DATA))); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java index 6706f0c..4ce27e2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java @@ -43,4 +43,13 @@ public class SpiceEvent extends MonitorEvent { public String clientHost() { return data().get("client").get("host").asText(); } + + /** + * Returns the client's port. + * + * @return the client's port number + */ + public long clientPort() { + return data().get("client").get("port").asLong(); + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java new file mode 100644 index 0000000..7bb84b7 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java @@ -0,0 +1,46 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceInitializedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceInitializedEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the channel type. + * + * @return the channel type + */ + public int channelType() { + return data().get("client").get("channel-type").asInt(); + } +} From f1d973502ddf819638e3e543638acd2e2a9fa3af Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 11:58:51 +0100 Subject: [PATCH 024/274] Fix transparency. --- .../vmoperator/vmviewer/computer-in-use.svg | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg index 90339c1..00e4cc0 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg +++ b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg @@ -51,9 +51,9 @@ inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" - inkscape:zoom="1.28" - inkscape:cx="326.5625" - inkscape:cy="548.04688" + inkscape:zoom="0.90509668" + inkscape:cx="345.81941" + inkscape:cy="376.2029" inkscape:window-width="1920" inkscape:window-height="1008" inkscape:window-x="0" @@ -63,18 +63,17 @@ - + + style="opacity:1;stroke-width:0.00145614;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;paint-order:fill markers stroke" + d="m 0,13.998258 h 5.4336202 v 2.001741 H 0 Z" + sodipodi:nodetypes="ccccc" /> + From 811164f7b9c34c9e26fb19211b67cc48e69e6503 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 11:59:14 +0100 Subject: [PATCH 025/274] Move api client to base class. --- .../runner/qemu/ConsoleTracker.java | 5 ++--- .../vmoperator/runner/qemu/StatusUpdater.java | 19 ++++------------- .../vmoperator/runner/qemu/VmDefUpdater.java | 21 +++++++++++++++---- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java index 7d54235..95b748c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -44,7 +44,6 @@ import org.jgrapes.core.events.Start; @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class ConsoleTracker extends VmDefUpdater { - private final K8sClient apiClient; private VmDefinitionStub vmStub; private String mainChannelClientHost; private long mainChannelClientPort; @@ -109,7 +108,7 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", event.clientHost()); - updateCondition(apiClient, from, status, "ConsoleConnected", + updateCondition(from, status, "ConsoleConnected", true, "Connection from " + event.clientHost(), null); return status; }); @@ -144,7 +143,7 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", ""); - updateCondition(apiClient, from, status, "ConsoleConnected", + updateCondition(from, status, "ConsoleConnected", false, event.clientHost() + " has disconnected", null); return status; }); 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 ca5d46a..f663476 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 @@ -33,7 +33,6 @@ import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; @@ -59,7 +58,6 @@ public class StatusUpdater extends VmDefUpdater { private static final Set RUNNING_STATES = Set.of(RunState.RUNNING, RunState.TERMINATING); - private K8sClient apiClient; private long observedGeneration; private boolean guestShutdownStops; private boolean shutdownByGuest; @@ -73,15 +71,6 @@ public class StatusUpdater extends VmDefUpdater { @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public StatusUpdater(Channel componentChannel) { super(componentChannel); - try { - apiClient = new K8sClient(); - io.kubernetes.client.openapi.Configuration - .setDefaultApiClient(apiClient); - } catch (IOException e) { - logger.log(Level.SEVERE, e, - () -> "Cannot access events API, terminating."); - fire(new Exit(1)); - } attach(new ConsoleTracker(componentChannel)); } @@ -187,8 +176,8 @@ public class StatusUpdater extends VmDefUpdater { vmStub.updateStatus(vmDef, from -> { JsonObject status = from.status(); boolean running = RUNNING_STATES.contains(event.runState()); - updateCondition(apiClient, vmDef, vmDef.status(), "Running", - running, event.reason(), event.message()); + updateCondition(vmDef, vmDef.status(), "Running", running, + event.reason(), event.message()); if (event.runState() == RunState.STARTING) { status.addProperty("ram", GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); @@ -201,8 +190,8 @@ public class StatusUpdater extends VmDefUpdater { // In case console connection was still present if (!running) { status.addProperty("consoleClient", ""); - updateCondition(apiClient, from, status, "ConsoleConnected", - false, "VM has stopped", null); + updateCondition(from, status, "ConsoleConnected", false, + "VM has stopped", null); } return status; }); 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 893fc61..1c260c7 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 @@ -19,7 +19,6 @@ package org.jdrupes.vmoperator.runner.qemu; import com.google.gson.JsonObject; -import io.kubernetes.client.openapi.ApiClient; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -31,7 +30,9 @@ import java.util.Map; import java.util.Optional; import java.util.logging.Level; import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -46,15 +47,28 @@ public class VmDefUpdater extends Component { protected String namespace; protected String vmName; + protected K8sClient apiClient; /** * Instantiates a new status updater. * * @param componentChannel the component channel + * @throws IOException */ @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public VmDefUpdater(Channel componentChannel) { super(componentChannel); + if (apiClient == null) { + try { + apiClient = new K8sClient(); + io.kubernetes.client.openapi.Configuration + .setDefaultApiClient(apiClient); + } catch (IOException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access events API, terminating."); + fire(new Exit(1)); + } + } } /** @@ -104,9 +118,8 @@ public class VmDefUpdater extends Component { * @param state the new state * @param reason the reason for the change */ - protected void updateCondition(ApiClient apiClient, VmDefinitionModel from, - JsonObject status, String type, boolean state, String reason, - String message) { + protected void updateCondition(VmDefinitionModel from, JsonObject status, + String type, boolean state, String reason, String message) { // Optimize, as we can get this several times var current = status.getAsJsonArray("conditions").asList().stream() .map(cond -> (JsonObject) cond) From 4ea568ea17ec338897b192ae0b3cd5c22a9cded1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 12:40:45 +0100 Subject: [PATCH 026/274] Automatically repeat status update in case of conflict. --- .../vmoperator/common/K8sGenericStub.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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 09516a0..0689a97 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 @@ -193,7 +193,33 @@ public class K8sGenericStub updateStatus(O object, + Function status, int retries) throws ApiException { + while (true) { + try { + return K8s.optional(api.updateStatus(object, status)); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } + } + } + } + + /** + * Updates the object's status, retrying up to 16 times if there + * is a conflict. * * @param object the current state of the object (passed to `status`) * @param status function that returns the new status @@ -202,7 +228,7 @@ public class K8sGenericStub updateStatus(O object, Function status) throws ApiException { - return K8s.optional(api.updateStatus(object, status)); + return updateStatus(object, status, 16); } /** From 0ba8d922ef486854da01603410952506a7755635 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 18:46:19 +0100 Subject: [PATCH 027/274] Add used by information. --- deploy/crds/vms-crd.yaml | 6 ++++++ .../manager/events/GetDisplayPassword.java | 16 ++++++++++++++-- .../manager/DisplaySecretMonitor.java | 17 ++++++++++++++++- .../jdrupes/vmoperator/vmconlet/l10n.properties | 1 + .../vmoperator/vmconlet/l10n_de.properties | 1 + .../vmconlet/browser/VmConlet-functions.ts | 4 +++- .../jdrupes/vmoperator/vmviewer/VmViewer.java | 9 ++++++--- 7 files changed, 47 insertions(+), 7 deletions(-) diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index f1bbaf2..93c70cc 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1467,6 +1467,12 @@ spec: The hostname of the currently connected client. type: string default: "" + consoleUser: + description: >- + The id of the user who has last requested a console + connection. + type: string + default: "" displayPasswordSerial: description: >- Counts changes of the display password. Set to -1 diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java index 3322f1a..f6fa555 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java @@ -29,14 +29,17 @@ import org.jgrapes.core.Event; public class GetDisplayPassword extends Event { private final VmDefinition vmDef; + private final String user; /** - * Instantiates a new returns the display secret. + * Instantiates a new request for the display secret. * * @param vmDef the vm name + * @param user the requesting user */ - public GetDisplayPassword(VmDefinition vmDef) { + public GetDisplayPassword(VmDefinition vmDef, String user) { this.vmDef = vmDef; + this.user = user; } /** @@ -48,6 +51,15 @@ public class GetDisplayPassword extends Event { return vmDef; } + /** + * Return the id of the user who has requested the password. + * + * @return the string + */ + public String user() { + return user; + } + /** * Return the password. May only be called when the event is completed. * diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index 69d4058..2f480a3 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -18,6 +18,8 @@ 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.V1Secret; @@ -37,10 +39,13 @@ import java.util.Optional; import java.util.Scanner; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinitionStub; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; @@ -181,12 +186,22 @@ public class DisplaySecretMonitor + "app.kubernetes.io/instance=" + event.vmDefinition().metadata().getName()); var stubs = K8sV1SecretStub.list(client(), - event.vmDefinition().metadata().getNamespace(), options); + event.vmDefinition().namespace(), options); if (stubs.isEmpty()) { return; } var stub = stubs.iterator().next(); + // Valid request, update console user in status + var vmStub = VmDefinitionStub.get(client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + event.vmDefinition().namespace(), event.vmDefinition().name()); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleUser", event.user()); + return status; + }); + // Check validity var model = stub.model().get(); @SuppressWarnings("PMD.StringInstantiation") diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties index f4165c4..4ab3a3f 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties @@ -11,6 +11,7 @@ nodeName = Node requestedCpus = Requested CPUs requestedRam = Requested RAM running = Running +usedBy = Used by usedFrom = Used from vmActions = Actions vmname = Name diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties index 29239ed..15a8b68 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties @@ -15,6 +15,7 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM +usedBy = Benutzt durch usedFrom = Benutzt von vmActions = Aktionen vmname = Name diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts index b171569..cfda2de 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts @@ -112,7 +112,8 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], ["nodeName", "nodeName"], - ["usedFrom", "usedFrom"] + ["usedFrom", "usedFrom"], + ["usedBy", "usedBy"] ], { sortKey: "name", sortOrder: "up" @@ -181,6 +182,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet", vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); vmDefinition.usedFrom = vmDefinition.status.consoleClient || ""; + vmDefinition.usedBy = vmDefinition.status.consoleUser || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index a21c420..f87b341 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java @@ -527,9 +527,12 @@ public class VmViewer extends FreeMarkerConlet { break; case "openConsole": if (perms.contains(Permission.ACCESS_CONSOLE)) { - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef), - e -> openConsole(vmName, channel, model, - e.password().orElse(null))); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + var pwQuery + = Event.onCompletion(new GetDisplayPassword(vmDef, user), + e -> openConsole(vmName, channel, model, + e.password().orElse(null))); fire(pwQuery, vmChannel); } break; From 69507b540cb4c753616b43872e8ef6c73aded85e Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 20:17:33 +0100 Subject: [PATCH 028/274] Improve messages. --- .../jdrupes/vmoperator/runner/qemu/ConsoleTracker.java | 8 ++++---- .../org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java index 95b748c..f2309df 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -108,8 +108,8 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", event.clientHost()); - updateCondition(from, status, "ConsoleConnected", - true, "Connection from " + event.clientHost(), null); + updateCondition(from, status, "ConsoleConnected", true, "Connected", + "Connection from " + event.clientHost()); return status; }); @@ -143,8 +143,8 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", ""); - updateCondition(from, status, "ConsoleConnected", - false, event.clientHost() + " has disconnected", null); + updateCondition(from, status, "ConsoleConnected", false, + "Disconnected", event.clientHost() + " has disconnected"); return status; }); 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 f663476..0b18df0 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 @@ -191,7 +191,7 @@ public class StatusUpdater extends VmDefUpdater { if (!running) { status.addProperty("consoleClient", ""); updateCondition(from, status, "ConsoleConnected", false, - "VM has stopped", null); + "VmStopped", "The VM has been shut down"); } return status; }); From e864f677c3aab675653d0673130c7e7688c01152 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 22:10:18 +0100 Subject: [PATCH 029/274] Allow operator to patch CR status. --- deploy/vmop-role.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deploy/vmop-role.yaml b/deploy/vmop-role.yaml index 16c7cfb..55447e6 100644 --- a/deploy/vmop-role.yaml +++ b/deploy/vmop-role.yaml @@ -11,6 +11,12 @@ rules: - vms verbs: - '*' +- apiGroups: + - vmoperator.jdrupes.org + resources: + - vms/status + verbs: + - patch - apiGroups: - apps resources: From dc7382dc8669483e33ad4cb7efb449bea432ce36 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 15 Nov 2024 09:53:34 +0100 Subject: [PATCH 030/274] Always update console user. --- .../manager/DisplaySecretMonitor.java | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index 2f480a3..141c806 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -180,19 +180,7 @@ public class DisplaySecretMonitor @SuppressWarnings("PMD.StringInstantiation") public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) throws ApiException { - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" - + event.vmDefinition().metadata().getName()); - var stubs = K8sV1SecretStub.list(client(), - event.vmDefinition().namespace(), options); - if (stubs.isEmpty()) { - return; - } - var stub = stubs.iterator().next(); - - // Valid request, update console user in status + // Update console user in status var vmStub = VmDefinitionStub.get(client(), new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), event.vmDefinition().namespace(), event.vmDefinition().name()); @@ -202,6 +190,20 @@ public class DisplaySecretMonitor return status; }); + // Look for secret + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/instance=" + + event.vmDefinition().metadata().getName()); + var stubs = K8sV1SecretStub.list(client(), + event.vmDefinition().namespace(), options); + if (stubs.isEmpty()) { + // No secret means no password for this VM wanted + return; + } + var stub = stubs.iterator().next(); + // Check validity var model = stub.model().get(); @SuppressWarnings("PMD.StringInstantiation") @@ -209,6 +211,7 @@ public class DisplaySecretMonitor .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); if (model.getData().get(DATA_DISPLAY_PASSWORD) != null && stillValid(expiry)) { + // Fixed secret, don't touch event.setResult( new String(model.getData().get(DATA_DISPLAY_PASSWORD))); return; From 558d8f95485c66049a763cce32a096154ba563b5 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 15 Nov 2024 09:58:30 +0100 Subject: [PATCH 031/274] Fix layout. --- .../org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html index 708a1a3..0af656b 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html @@ -68,7 +68,7 @@ -
+ From 28df6ede159571f93690c9b0a300867aa151277a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 15 Nov 2024 15:29:19 +0100 Subject: [PATCH 032/274] Force fixed webconsole 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 ab41391..e286f7b 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -18,7 +18,7 @@ dependencies { implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' - implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.5.0,2)' + 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)' implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)' From 31758b5ef107c33f029c442ef00e5748255259a0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 15 Nov 2024 17:30:50 +0100 Subject: [PATCH 033/274] Add fallbacks for efi vars as well. --- .../resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml index 32fdf55..600f0ad 100644 --- a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml @@ -15,6 +15,9 @@ - "/usr/share/edk2/ovmf/OVMF_VARS.fd" - "/usr/share/edk2/x64/OVMF_VARS.fd" - "/usr/share/OVMF/OVMF_VARS.fd" + # Use 4M version as fallback (if smaller version not available) + - "/usr/share/edk2/ovmf-4m/OVMF_VARS.fd" + - "/usr/share/edk2/x64/OVMF_VARS.4m.fd" "uefi-4m": "rom": - "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd" From 13cd262a47c6f83f4102073d2060295223f74619 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 16 Nov 2024 17:15:31 +0100 Subject: [PATCH 034/274] New CRD. --- deploy/crds/vmpools-crd.yaml | 59 ++++++++++++++++++++++++++++++++++++ deploy/crds/vms-crd.yaml | 7 +++++ 2 files changed, 66 insertions(+) create mode 100644 deploy/crds/vmpools-crd.yaml diff --git a/deploy/crds/vmpools-crd.yaml b/deploy/crds/vmpools-crd.yaml new file mode 100644 index 0000000..5f8414f --- /dev/null +++ b/deploy/crds/vmpools-crd.yaml @@ -0,0 +1,59 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: vmpools.vmoperator.jdrupes.org +spec: + group: vmoperator.jdrupes.org + # list of versions supported by this CustomResourceDefinition + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + permissions: + type: array + description: >- + Defines permissions for accessing and manipulating the Pool. + items: + type: object + description: >- + Permissions can be granted to a user or to a role. + oneOf: + - required: + - user + - required: + - role + properties: + user: + type: string + role: + type: string + may: + type: array + items: + type: string + enum: + - start + - stop + - reset + - accessConsole + - "*" + default: [] + required: + - permissions + # either Namespaced or Cluster + scope: Namespaced + names: + # plural name to be used in the URL: /apis/// + plural: vmpools + # singular name to be used as an alias on the CLI and for display + singular: vmpool + # kind is normally the CamelCased singular type. Your resource manifests use this. + kind: VmPool + listKind: VmPoolList diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 93c70cc..5f67b4c 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1019,6 +1019,13 @@ spec: - accessConsole - "*" default: [] + pools: + type: array + description: >- + List of pools to which this VM belongs. + items: + type: string + default: [] loggingProperties: type: string description: >- From dec4c11785f0c730736095ccefc350ae9782c0b9 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 16 Nov 2024 17:33:37 +0100 Subject: [PATCH 035/274] Fix sync selection. --- .../jdrupes/vmoperator/vmviewer/VmViewer.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index f87b341..b0d8502 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java @@ -123,8 +123,8 @@ public class VmViewer extends FreeMarkerConlet { private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private Class preferredIpVersion = Inet4Address.class; - private Set syncUsers = new HashSet<>(); - private final Set syncRoles = new HashSet<>(); + private Set syncUsers = Collections.emptySet(); + private Set syncRoles = Collections.emptySet(); private boolean deleteConnectionFile = true; /** @@ -173,12 +173,19 @@ public class VmViewer extends FreeMarkerConlet { .filter(v -> v instanceof String).map(v -> (String) v) .map(Boolean::parseBoolean).orElse(true); - // Sync preview for users or roles + // Users or roles for which previews should be synchronized syncUsers = ((List>) c.getOrDefault( "syncPreviewsFor", Collections.emptyList())).stream() - .map(m -> Optional.ofNullable(m.get("user")) - .orElse(m.get("role"))) + .map(m -> m.get("user")) .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for users: " + + syncUsers.toString()); + syncRoles = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("role")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for roles: " + + syncRoles.toString()); } catch (ClassCastException e) { logger.config("Malformed configuration: " + e.getMessage()); } From 5d90a6a8a9b4f1c8fe82982f2193bf6429b48320 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 17 Nov 2024 12:21:16 +0100 Subject: [PATCH 036/274] Move test to tc1. --- dev-example/.gitignore | 1 + dev-example/config.yaml | 4 +- dev-example/gen-pool-vm-crds.sh | 47 +++++++++++++++++++++++ dev-example/test-vm.tpl.yaml | 67 +++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100755 dev-example/gen-pool-vm-crds.sh create mode 100644 dev-example/test-vm.tpl.yaml diff --git a/dev-example/.gitignore b/dev-example/.gitignore index 925478d..728072e 100644 --- a/dev-example/.gitignore +++ b/dev-example/.gitignore @@ -1 +1,2 @@ /test-vm-ci.yaml +/kubeconfig.yaml diff --git a/dev-example/config.yaml b/dev-example/config.yaml index af1f3b8..1c80ab8 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -7,8 +7,8 @@ "/Controller": namespace: vmop-dev "/Reconciler": - runnerData: - storageClassName: null + runnerDataPvc: + storageClassName: rook-cephfs loadBalancerService: labels: label1: label1 diff --git a/dev-example/gen-pool-vm-crds.sh b/dev-example/gen-pool-vm-crds.sh new file mode 100755 index 0000000..264e3ba --- /dev/null +++ b/dev-example/gen-pool-vm-crds.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +function usage() { + cat >&2 <&2 "Unknown option: $1"; exit 1;; + *) template="$1";; + esac + shift +done + +if [ -z "$template" ]; then + usage +fi + +if [ "$count" = "0" ]; then + exit 0 +fi +for number in $(seq 1 $count); do + if [ -z "$prefix" ]; then + prefix=$(basename $template .tpl.yaml) + fi + name="$prefix$number" + index=$(($number - 1)) + esh -o $destination/$name.yaml $template number=$number index=$index +done diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml new file mode 100644 index 0000000..aa6f3d1 --- /dev/null +++ b/dev-example/test-vm.tpl.yaml @@ -0,0 +1,67 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine +metadata: + namespace: vmop-dev + name: test-vm<%= ${number} %> + annotations: + argocd.argoproj.io/sync-wave: "20" + +spec: + image: +# repository: docker-registry.lan.mnl.de +# path: vmoperator/org.jdrupes.vmoperator.runner.qemu-arch +# pullPolicy: Always +# repository: ghcr.io +# path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine +# version: "3.0.0" + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing + pullPolicy: Always + + permissions: + - role: admin + may: + - "*" + - user: test + may: + - accessConsole + + guestShutdownStops: true + + cloudInit: {} + + vm: + # state: Running + bootMenu: true + maximumCpus: 4 + currentCpus: 2 + maximumRam: 4Gi + currentRam: 3Gi + + networks: + # No bridge on TC1 + # - tap: {} + - user: {} + + disks: + - volumeClaimTemplate: + metadata: + name: system + spec: + storageClassName: ceph-rbd3slow + dataSource: + name: test-vm-snapshot-1 + kind: VolumeSnapshot + apiGroup: snapshot.storage.k8s.io + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 40Gi + - cdrom: + image: "" + # image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso + + display: + spice: + port: <%= $((5910 + number)) %> + server: 192.168.179.20 From e7da41f8388949a2418193e1e7f652ba15e84096 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 17 Nov 2024 13:20:20 +0100 Subject: [PATCH 037/274] Fix summary evaluation. --- .../src/org/jdrupes/vmoperator/vmconlet/VmConlet.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java index 69418af..7244f56 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java @@ -329,7 +329,7 @@ public class VmConlet extends FreeMarkerConlet { .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) .orElse(BigInteger.ZERO)); summary.runningVms - = vmDef.>> fromStatus("conditions") + += vmDef.>> fromStatus("conditions") .orElse(Collections.emptyList()).stream() .filter(cond -> DataPath.get(cond, "type") .map(t -> "Running".equals(t)).orElse(false) From 043666a9328bce525a014f94d9d7e40182daf2e5 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 17 Nov 2024 15:08:22 +0100 Subject: [PATCH 038/274] Generate test VMs. --- dev-example/.gitignore | 1 + dev-example/test-vm-snapshot.yaml | 10 ++++++++++ dev-example/test-vm.tpl.yaml | 6 +++--- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 dev-example/test-vm-snapshot.yaml diff --git a/dev-example/.gitignore b/dev-example/.gitignore index 728072e..16e0d04 100644 --- a/dev-example/.gitignore +++ b/dev-example/.gitignore @@ -1,2 +1,3 @@ /test-vm-ci.yaml /kubeconfig.yaml +/crds/ diff --git a/dev-example/test-vm-snapshot.yaml b/dev-example/test-vm-snapshot.yaml new file mode 100644 index 0000000..fd60a25 --- /dev/null +++ b/dev-example/test-vm-snapshot.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshot +metadata: + namespace: vmop-dev + name: test-vm-system-disk-snapshot +spec: + volumeSnapshotClassName: csi-rbdplugin-snapclass + source: + persistentVolumeClaimName: test-vm-system-disk diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index aa6f3d1..6d8a89c 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -27,7 +27,8 @@ spec: guestShutdownStops: true - cloudInit: {} + cloudInit: + metaData: {} vm: # state: Running @@ -49,7 +50,7 @@ spec: spec: storageClassName: ceph-rbd3slow dataSource: - name: test-vm-snapshot-1 + name: test-vm-system-disk-snapshot kind: VolumeSnapshot apiGroup: snapshot.storage.k8s.io accessModes: @@ -64,4 +65,3 @@ spec: display: spice: port: <%= $((5910 + number)) %> - server: 192.168.179.20 From 4690b897e959629185c44dc7b080510147ee4356 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 17 Nov 2024 16:32:55 +0100 Subject: [PATCH 039/274] Adapt to changed timestamp format. --- .../jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts index cfda2de..01bd897 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts @@ -204,8 +204,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet", "summarySeries", function(_conletId: string, series: any[]) { chartData.clear(); for (const entry of series) { - chartData.push(new Date(entry.time.epochSecond * 1000 - + entry.time.nano / 1000000), + chartData.push(new Date(entry.time * 1000), entry.values[0], entry.values[1]); } chartDateUpdate.value = new Date(); From 00adeba62521d263add55311885b5e6c70422e15 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 19 Nov 2024 17:10:55 +0100 Subject: [PATCH 040/274] Add pool manager. --- .../jdrupes/vmoperator/common/Constants.java | 3 + .../org/jdrupes/vmoperator/common/VmPool.java | 164 +++++++++++++++ .../manager/events/VmPoolChanged.java | 88 ++++++++ org.jdrupes.vmoperator.manager/build.gradle | 1 + .../vmoperator/manager/Controller.java | 1 + .../vmoperator/manager/PoolManager.java | 191 ++++++++++++++++++ 6 files changed, 448 insertions(+) create mode 100644 org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java create mode 100644 org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.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 150bb3b..5837264 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 @@ -38,4 +38,7 @@ public class Constants { /** The Constant VM_OP_KIND_VM. */ public static final String VM_OP_KIND_VM = "VirtualMachine"; + + /** The Constant VM_OP_KIND_VM_POOL. */ + public static final String VM_OP_KIND_VM_POOL = "VmPool"; } 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 new file mode 100644 index 0000000..aaa2746 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java @@ -0,0 +1,164 @@ +/* + * VM-Operator + * Copyright (C) 2024 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.common; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Represents a VM pool. + */ +@SuppressWarnings({ "PMD.DataClass" }) +public class VmPool { + + private String name; + private List permissions = Collections.emptyList(); + private final Set vms + = Collections.synchronizedSet(new HashSet<>()); + + /** + * Returns the name. + * + * @return the name + */ + public String name() { + return name; + } + + /** + * Sets the name. + * + * @param name the name to set + */ + public void setName(String name) { + this.name = name; + } + + /** + * @return the permissions + */ + public List permissions() { + return permissions; + } + + /** + * Sets the permissions. + * + * @param permissions the permissions to set + */ + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + /** + * Returns the VM names. + * + * @return the vms + */ + public Set vms() { + return vms; + } + + @Override + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public String toString() { + StringBuilder builder = new StringBuilder(50); + builder.append("VmPool [name=").append(name).append(", permissions=") + .append(permissions).append(", vms="); + if (vms.size() <= 3) { + builder.append(vms); + } else { + builder.append('['); + vms.stream().limit(3).map(s -> s + ",").forEach(builder::append); + builder.append("...]"); + } + builder.append(']'); + return builder.toString(); + } + + /** + * A permission grant to a user or role. + * + * @param user the user + * @param role the role + * @param may the may + */ + public record Grant(String user, String role, Set may) { + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (user != null) { + builder.append("User ").append(user); + } else { + builder.append("Role ").append(role); + } + builder.append(" may=").append(may).append(']'); + return builder.toString(); + } + } + + /** + * Permissions for accessing and manipulating the pool. + */ + public enum Permission { + START("start"), STOP("stop"), RESET("reset"), + ACCESS_CONSOLE("accessConsole"); + + @SuppressWarnings("PMD.UseConcurrentHashMap") + private static Map reprs = new HashMap<>(); + + static { + for (var value : EnumSet.allOf(Permission.class)) { + reprs.put(value.repr, value); + } + } + + private final String repr; + + Permission(String repr) { + this.repr = repr; + } + + /** + * Create permission from representation in CRD. + * + * @param value the value + * @return the permission + */ + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public static Set parse(String value) { + if ("*".equals(value)) { + return EnumSet.allOf(Permission.class); + } + return Set.of(reprs.get(value)); + } + + @Override + public String toString() { + return repr; + } + } + +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java new file mode 100644 index 0000000..0c506a1 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java @@ -0,0 +1,88 @@ +/* + * 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 org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates a change in a pool configuration. + */ +@SuppressWarnings("PMD.DataClass") +public class VmPoolChanged extends Event { + + private final VmPool vmPool; + private final boolean deleted; + + /** + * Instantiates a new VM changed event. + * + * @param pool the pool + * @param deleted true, if the pool was deleted + */ + public VmPoolChanged(VmPool pool, boolean deleted) { + vmPool = pool; + this.deleted = deleted; + } + + /** + * Instantiates a new VM changed event for an existing pool. + * + * @param pool the pool + */ + public VmPoolChanged(VmPool pool) { + this(pool, false); + } + + /** + * Returns the VM pool. + * + * @return the vm pool + */ + public VmPool vmPool() { + return vmPool; + } + + /** + * Pool has been deleted. + * + * @return true, if successful + */ + public boolean deleted() { + return deleted; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(30); + builder.append(Components.objectName(this)) + .append(" ["); + if (deleted) { + builder.append("Deleted: "); + } + builder.append(vmPool); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index e286f7b..d3d80cb 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -33,6 +33,7 @@ dependencies { runtimeOnly project(':org.jdrupes.vmoperator.vmconlet') runtimeOnly project(':org.jdrupes.vmoperator.vmviewer') + runtimeOnly project(':org.jdrupes.vmoperator.poolaccess') } application { 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 effc938..d847785 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 @@ -106,6 +106,7 @@ public class Controller extends Component { // to access the VM's console. Might change in the future. // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); attach(new Reconciler(channel())); + attach(new PoolManager(channel())); } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java new file mode 100644 index 0000000..f47c569 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java @@ -0,0 +1,191 @@ +/* + * VM-Operator + * Copyright (C) 2023,2024 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.V1ObjectMeta; +import io.kubernetes.client.util.Watch; +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sDynamicModels; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmPool; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; +import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; + +/** + * Watches for changes of VM pools. + */ +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) +public class PoolManager extends + AbstractMonitor { + + private final ReentrantLock pendingLock = new ReentrantLock(); + private final Map> pending = new ConcurrentHashMap<>(); + private final Map pools = new ConcurrentHashMap<>(); + + /** + * Instantiates a new VM pool manager. + * + * @param componentChannel the component channel + * @param channelManager the channel manager + */ + public PoolManager(Channel componentChannel) { + super(componentChannel, K8sDynamicModel.class, + K8sDynamicModels.class); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + + // Get all our API versions + var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM_POOL); + if (ctx.isEmpty()) { + logger.severe(() -> "Cannot get CRD context."); + return; + } + context(ctx.get()); + } + + @Override + protected void handleChange(K8sClient client, + Watch.Response response) { + + var type = ResponseType.valueOf(response.type); + var poolName = response.object.metadata().getName(); + + // When pool is deleted, save VMs in pending + if (type == ResponseType.DELETED) { + try { + pendingLock.lock(); + Optional.ofNullable(pools.get(poolName)).ifPresent( + p -> { + pending.computeIfAbsent(poolName, k -> Collections + .synchronizedSet(new HashSet<>())).addAll(p.vms()); + pools.remove(poolName); + fire(new VmPoolChanged(p, true)); + }); + } finally { + pendingLock.unlock(); + } + return; + } + + // Get full definition + var poolModel = response.object; + if (poolModel.data() == null) { + // ADDED event does not provide data, see + // https://github.com/kubernetes-client/java/issues/3215 + try { + poolModel = K8sDynamicStub.get(client(), context(), namespace(), + poolModel.metadata().getName()).model().orElse(null); + } catch (ApiException e) { + return; + } + } + + // Convert to VM pool + var vmPool = client().getJSON().getGson().fromJson( + GsonPtr.to(poolModel.data()).to("spec").get(), + VmPool.class); + V1ObjectMeta metadata = response.object.getMetadata(); + vmPool.setName(metadata.getName()); + + // If modified, merge changes + if (type == ResponseType.MODIFIED && pools.containsKey(poolName)) { + pools.get(poolName).setPermissions(vmPool.permissions()); + return; + } + + // Add new pool + try { + pendingLock.lock(); + Optional.ofNullable(pending.get(poolName)).ifPresent(s -> { + vmPool.vms().addAll(s); + }); + pending.remove(poolName); + pools.put(poolName, vmPool); + fire(new VmPoolChanged(vmPool)); + } finally { + pendingLock.unlock(); + } + } + + /** + * Track VM definition changes. + * + * @param event the event + */ + @Handler + public void onVmDefChanged(VmDefChanged event) { + String vmName = event.vmDefinition().name(); + switch (event.type()) { + case ADDED: + try { + pendingLock.lock(); + event.vmDefinition().> fromSpec("pools") + .orElse(Collections.emptyList()).stream().forEach(p -> { + if (pools.containsKey(p)) { + pools.get(p).vms().add(vmName); + } else { + pending.computeIfAbsent(p, k -> Collections + .synchronizedSet(new HashSet<>())).add(vmName); + } + fire(new VmPoolChanged(pools.get(p))); + }); + } finally { + pendingLock.unlock(); + } + break; + case DELETED: + try { + pendingLock.lock(); + pools.values().stream().forEach(p -> { + if (p.vms().remove(vmName)) { + fire(new VmPoolChanged(p)); + } + }); + // Should not be necessary, but just in case + pending.values().stream().forEach(s -> s.remove(vmName)); + } finally { + pendingLock.unlock(); + } + break; + default: + break; + } + } +} From 27f983c18d9f51668b9e7c619ab5bc6248e2a268 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 12:50:25 +0100 Subject: [PATCH 041/274] Rename vmviewer to vmaccess. --- org.jdrupes.vmoperator.manager/build.gradle | 2 +- .../.checkstyle | 0 .../.eclipse-pmd | 0 .../.eslintignore | 0 .../.eslintrc.json | 0 .../.gitignore | 0 .../org.eclipse.buildship.core.prefs | 0 .../org.eclipse.core.resources.prefs | 0 .../.settings/org.eclipse.core.runtime.prefs | 0 .../.settings/org.eclipse.jdt.ui.prefs | 0 .../build.gradle | 0 .../package.json | 0 ...pes.webconsole.base.ConletComponentFactory | 1 + .../vmaccess/VmAccess-confirmReset.ftl.html | 4 +-- .../vmaccess/VmAccess-edit.ftl.html | 6 ++-- .../vmaccess/VmAccess-l10nBundles.ftl.js | 0 .../vmaccess/VmAccess-preview.ftl.html | 4 +-- .../vmoperator/vmaccess}/computer-in-use.svg | 0 .../vmoperator/vmaccess}/computer-off.svg | 0 .../jdrupes/vmoperator/vmaccess}/computer.svg | 0 .../vmoperator/vmaccess}/l10n.properties | 2 +- .../vmoperator/vmaccess}/l10n_de.properties | 2 +- .../vmoperator/vmaccess}/l10n_en.properties | 0 .../vmoperator/vmaccess}/reset-icon.svg | 0 .../rollup.config.mjs | 4 +-- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 24 ++++++------- .../vmoperator/vmaccess/VmAccessFactory.java | 10 +++--- .../vmaccess/browser/VmAccess-functions.ts | 32 +++++++++--------- .../vmaccess/browser/VmAccess-style.scss | 10 +++--- .../vmaccess}/browser/l10nBundles-stub.d.ts | 0 .../vmoperator/vmaccess}/package-info.java | 4 +-- .../tsconfig.json | 2 +- ...pes.webconsole.base.ConletComponentFactory | 1 - settings.gradle | 2 ++ ...iewer-preview.png => VmAccess-preview.png} | Bin webpages/vm-operator/user-gui.md | 4 +-- 36 files changed, 58 insertions(+), 56 deletions(-) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.checkstyle (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.eclipse-pmd (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.eslintignore (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.eslintrc.json (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.gitignore (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.settings/org.eclipse.buildship.core.prefs (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.settings/org.eclipse.core.resources.prefs (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.settings/org.eclipse.core.runtime.prefs (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/.settings/org.eclipse.jdt.ui.prefs (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/build.gradle (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/package.json (100%) create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory rename org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-confirmReset.ftl.html => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html (91%) rename org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-edit.ftl.html => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html (73%) rename org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-l10nBundles.ftl.js => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js (100%) rename org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-preview.ftl.html => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html (59%) rename {org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess}/computer-in-use.svg (100%) rename {org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess}/computer-off.svg (100%) rename {org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess}/computer.svg (100%) rename {org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess}/l10n.properties (86%) rename {org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess}/l10n_de.properties (93%) rename {org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess}/l10n_en.properties (100%) rename {org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess}/reset-icon.svg (100%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/rollup.config.mjs (92%) rename org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java => org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java (97%) rename org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewerFactory.java => org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java (85%) rename org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts => org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts (91%) rename org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-style.scss => org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss (87%) rename {org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess}/browser/l10nBundles-stub.d.ts (100%) rename {org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer => org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess}/package-info.java (89%) rename {org.jdrupes.vmoperator.vmviewer => org.jdrupes.vmoperator.vmaccess}/tsconfig.json (92%) delete mode 100644 org.jdrupes.vmoperator.vmviewer/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory rename webpages/vm-operator/{VmViewer-preview.png => VmAccess-preview.png} (100%) diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index d3d80cb..f5ffe2c 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -31,8 +31,8 @@ dependencies { runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' runtimeOnly 'org.apache.logging.log4j:log4j-to-jul:2.20.0' + runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') runtimeOnly project(':org.jdrupes.vmoperator.vmconlet') - runtimeOnly project(':org.jdrupes.vmoperator.vmviewer') runtimeOnly project(':org.jdrupes.vmoperator.poolaccess') } diff --git a/org.jdrupes.vmoperator.vmviewer/.checkstyle b/org.jdrupes.vmoperator.vmaccess/.checkstyle similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.checkstyle rename to org.jdrupes.vmoperator.vmaccess/.checkstyle diff --git a/org.jdrupes.vmoperator.vmviewer/.eclipse-pmd b/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.eclipse-pmd rename to org.jdrupes.vmoperator.vmaccess/.eclipse-pmd diff --git a/org.jdrupes.vmoperator.vmviewer/.eslintignore b/org.jdrupes.vmoperator.vmaccess/.eslintignore similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.eslintignore rename to org.jdrupes.vmoperator.vmaccess/.eslintignore diff --git a/org.jdrupes.vmoperator.vmviewer/.eslintrc.json b/org.jdrupes.vmoperator.vmaccess/.eslintrc.json similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.eslintrc.json rename to org.jdrupes.vmoperator.vmaccess/.eslintrc.json diff --git a/org.jdrupes.vmoperator.vmviewer/.gitignore b/org.jdrupes.vmoperator.vmaccess/.gitignore similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.gitignore rename to org.jdrupes.vmoperator.vmaccess/.gitignore diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.buildship.core.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.resources.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.runtime.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.jdt.ui.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs diff --git a/org.jdrupes.vmoperator.vmviewer/build.gradle b/org.jdrupes.vmoperator.vmaccess/build.gradle similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/build.gradle rename to org.jdrupes.vmoperator.vmaccess/build.gradle diff --git a/org.jdrupes.vmoperator.vmviewer/package.json b/org.jdrupes.vmoperator.vmaccess/package.json similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/package.json rename to org.jdrupes.vmoperator.vmaccess/package.json diff --git a/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory new file mode 100644 index 0000000..ec5cf30 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory @@ -0,0 +1 @@ +org.jdrupes.vmoperator.vmaccess.VmAccessFactory diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-confirmReset.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html similarity index 91% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-confirmReset.ftl.html rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html index f7e3840..d7b9405 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-confirmReset.ftl.html +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html @@ -1,9 +1,9 @@
+ class="jdrupes-vmoperator-vmaccess jdrupes-vmoperator-vmaccess-confirm-reset">

${_("confirmResetMsg")}

+ onclick="orgJDrupesVmOperatorVmAccess.confirmReset('${conletType}', '${conletId}')"> diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-edit.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html similarity index 73% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-edit.ftl.html rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html index e86d9db..ba61399 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-edit.ftl.html +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html @@ -1,7 +1,7 @@

diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-l10nBundles.ftl.js b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-l10nBundles.ftl.js rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-preview.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html similarity index 59% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-preview.ftl.html rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html index c034504..57693ea 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-preview.ftl.html +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html @@ -1,7 +1,7 @@
diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-in-use.svg similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-in-use.svg diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-off.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-off.svg similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-off.svg rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-off.svg diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer.svg similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer.svg rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer.svg diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties similarity index 86% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n.properties rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties index 05309d6..d755e7a 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties @@ -1,4 +1,4 @@ -conletName = VM Console +conletName = VM Access okayLabel = Apply and Close diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_de.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties similarity index 93% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_de.properties rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties index 5226cc3..bcdc332 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties @@ -1,4 +1,4 @@ -conletName = VM-Konsole +conletName = VM-Zugriff okayLabel = Anwenden und Schließen Select\ VM = VM auswählen diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_en.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_en.properties similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_en.properties rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_en.properties diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/reset-icon.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/reset-icon.svg rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg diff --git a/org.jdrupes.vmoperator.vmviewer/rollup.config.mjs b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs similarity index 92% rename from org.jdrupes.vmoperator.vmviewer/rollup.config.mjs rename to org.jdrupes.vmoperator.vmaccess/rollup.config.mjs index f00a51f..ab1aae9 100644 --- a/org.jdrupes.vmoperator.vmviewer/rollup.config.mjs +++ b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs @@ -1,8 +1,8 @@ import typescript from 'rollup-plugin-typescript2'; import postcss from 'rollup-plugin-postcss'; -let packagePath = "org/jdrupes/vmoperator/vmviewer"; -let baseName = "VmViewer" +let packagePath = "org/jdrupes/vmoperator/vmaccess"; +let baseName = "VmAccess" let module = "build/generated/resources/" + packagePath + "/" + baseName + "-functions.js"; diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java similarity index 97% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index b0d8502..b323084 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.vmviewer; +package org.jdrupes.vmoperator.vmaccess; import com.fasterxml.jackson.annotation.JsonGetter; import com.fasterxml.jackson.annotation.JsonProperty; @@ -89,7 +89,7 @@ import org.jgrapes.webconsole.base.events.UpdateConletType; import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; /** - * The Class VmViewer. The component supports the following + * The Class {@link VmAccess}. The component supports the following * configuration properties: * * * `displayResource`: a map with the following entries: @@ -107,13 +107,13 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods" }) -public class VmViewer extends FreeMarkerConlet { +public class VmAccess extends FreeMarkerConlet { private static final String VM_NAME_PROPERTY = "vmName"; private static final String RENDERED - = VmViewer.class.getName() + ".rendered"; + = VmAccess.class.getName() + ".rendered"; private static final String PENDING - = VmViewer.class.getName() + ".pending"; + = VmAccess.class.getName() + ".pending"; private static final Set MODES = RenderMode.asSet( RenderMode.Preview, RenderMode.Edit); private static final Set MODES_FOR_GENERATED = RenderMode.asSet( @@ -140,7 +140,7 @@ public class VmViewer extends FreeMarkerConlet { * on by default and that {@link Manager#fire(Event, Channel...)} * sends the event to */ - public VmViewer(Channel componentChannel) { + public VmAccess(Channel componentChannel) { super(componentChannel); } @@ -222,7 +222,7 @@ public class VmViewer extends FreeMarkerConlet { .addRenderMode(RenderMode.Preview) .addScript(new ScriptResource().setScriptType("module") .setScriptUri(event.renderSupport().conletResource( - type(), "VmViewer-functions.js")))); + type(), "VmAccess-functions.js")))); channel.session().put(RENDERED, new HashSet<>()); } @@ -259,7 +259,7 @@ public class VmViewer extends FreeMarkerConlet { foundMissing = true; } fire(new AddConletRequest(event.event().event().renderSupport(), - VmViewer.class.getName(), + VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview)) .addProperty(VM_NAME_PROPERTY, vmName), connection); @@ -283,7 +283,7 @@ public class VmViewer extends FreeMarkerConlet { private String storagePath(Session session, String conletId) { return "/" + WebConsoleUtils.userFromSession(session) .map(ConsoleUser::getName).orElse("") - + "/" + VmViewer.class.getName() + "/" + conletId; + + "/" + VmAccess.class.getName() + "/" + conletId; } @Override @@ -365,7 +365,7 @@ public class VmViewer extends FreeMarkerConlet { // Render Template tpl - = freemarkerConfig().getTemplate("VmViewer-preview.ftl.html"); + = freemarkerConfig().getTemplate("VmAccess-preview.ftl.html"); channel.respond(new RenderConlet(type(), conletId, processTemplate(event, tpl, fmModel(event, channel, conletId, model))) @@ -383,7 +383,7 @@ public class VmViewer extends FreeMarkerConlet { } if (event.renderAs().contains(RenderMode.Edit)) { Template tpl = freemarkerConfig() - .getTemplate("VmViewer-edit.ftl.html"); + .getTemplate("VmAccess-edit.ftl.html"); var fmModel = fmModel(event, channel, conletId, model); fmModel.put("vmNames", accessibleVms(channel)); channel.respond(new OpenModalDialog(type(), conletId, @@ -633,7 +633,7 @@ public class VmViewer extends FreeMarkerConlet { ResourceBundle resourceBundle) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException { Template tpl = freemarkerConfig() - .getTemplate("VmViewer-confirmReset.ftl.html"); + .getTemplate("VmAccess-confirmReset.ftl.html"); channel.respond(new OpenModalDialog(type(), model.getConletId(), processTemplate(event, tpl, fmModel(event, channel, model.getConletId(), model))) diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewerFactory.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java similarity index 85% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewerFactory.java rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java index 6748f47..5140056 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewerFactory.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.vmviewer; +package org.jdrupes.vmoperator.vmaccess; import java.util.Map; import java.util.Optional; @@ -25,9 +25,9 @@ import org.jgrapes.core.ComponentType; import org.jgrapes.webconsole.base.ConletComponentFactory; /** - * The factory service for {@link VmViewer}s. + * The factory service for {@link VmAccess}s. */ -public class VmViewerFactory implements ConletComponentFactory { +public class VmAccessFactory implements ConletComponentFactory { /* * (non-Javadoc) @@ -36,7 +36,7 @@ public class VmViewerFactory implements ConletComponentFactory { */ @Override public Class componentType() { - return VmViewer.class; + return VmAccess.class; } /* @@ -48,7 +48,7 @@ public class VmViewerFactory implements ConletComponentFactory { @Override public Optional create(Channel componentChannel, Map properties) { - return Optional.of(new VmViewer(componentChannel)); + return Optional.of(new VmAccess(componentChannel)); } } diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts similarity index 91% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index 42c7d10..fb52353 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -24,12 +24,12 @@ import JgwcPlugin, { JGWC } from "jgwc"; import { provideApi, getApi } from "aash-plugin"; import l10nBundles from "l10nBundles"; -import "./VmViewer-style.scss"; +import "./VmAccess-style.scss"; // For global access declare global { interface Window { - orgJDrupesVmOperatorVmViewer: { + orgJDrupesVmOperatorVmAccess: { initPreview?: (previewDom: HTMLElement, isUpdate: boolean) => void, initEdit?: (viewDom: HTMLElement, isUpdate: boolean) => void, applyEdit?: (viewDom: HTMLElement, apply: boolean) => void, @@ -38,7 +38,7 @@ declare global { } } -window.orgJDrupesVmOperatorVmViewer = {}; +window.orgJDrupesVmOperatorVmAccess = {}; interface Api { /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -51,7 +51,7 @@ const localize = (key: string) => { l10nBundles, JGWC.lang(), key); }; -window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, +window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, _isUpdate: boolean) => { const app = createApp({ setup(_props: object) { @@ -107,7 +107,7 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, :title="localize('Open console')"> -
diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts index d96ad65..6ab15b8 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -113,7 +113,6 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, ["currentRam", "currentRam"], ["nodeName", "nodeName"], ["assignedTo", "assignedTo"], - ["usedFrom", "usedFrom"], ["usedBy", "usedBy"] ], { sortKey: "name", diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss index e02fe24..607b0c5 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss @@ -78,6 +78,8 @@ padding-left: 1em; table { + display: inline; + td:nth-child(2) { min-width: 7em; From fb69c1d793c8faa7063b34d91bd4d807c4a4ebe3 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 23 Jan 2025 18:25:37 +0100 Subject: [PATCH 069/274] Minor style change. --- .../src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss index 607b0c5..9d7721a 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss @@ -75,7 +75,7 @@ } td.details { - padding-left: 1em; + padding-left: 0; table { display: inline; From 9318b1279abbe48a229d614be2f3a4d86684eacc Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 23 Jan 2025 21:17:06 +0100 Subject: [PATCH 070/274] Move jackson to base library. --- org.jdrupes.vmoperator.common/build.gradle | 1 + org.jdrupes.vmoperator.manager.events/build.gradle | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/org.jdrupes.vmoperator.common/build.gradle b/org.jdrupes.vmoperator.common/build.gradle index 07877fe..e72cb14 100644 --- a/org.jdrupes.vmoperator.common/build.gradle +++ b/org.jdrupes.vmoperator.common/build.gradle @@ -13,4 +13,5 @@ dependencies { api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' api 'io.kubernetes:client-java:[19.0.0,20.0.0)' api 'org.yaml:snakeyaml' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' } diff --git a/org.jdrupes.vmoperator.manager.events/build.gradle b/org.jdrupes.vmoperator.manager.events/build.gradle index cfdd79e..bb4b8d8 100644 --- a/org.jdrupes.vmoperator.manager.events/build.gradle +++ b/org.jdrupes.vmoperator.manager.events/build.gradle @@ -10,5 +10,4 @@ plugins { dependencies { api project(':org.jdrupes.vmoperator.common') - api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' } From a5ddf6ac977c2301569bd757898c8d65a1823684 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 13:32:00 +0100 Subject: [PATCH 071/274] Avoid updating immutable fields. --- .../vmoperator/manager/PvcReconciler.java | 43 +++++++++++++- .../org/jdrupes/vmoperator/util/GsonPtr.java | 58 ++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) 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 d044199..4ced1b1 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 @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; import freemarker.core.ParseException; import freemarker.template.Configuration; import freemarker.template.MalformedTemplateNameException; @@ -25,6 +26,7 @@ import freemarker.template.TemplateException; import freemarker.template.TemplateNotFoundException; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; @@ -41,6 +43,7 @@ import org.jdrupes.vmoperator.common.K8sV1PvcStub; 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; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -179,17 +182,51 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; var pvcDef = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - // Do apply changes + // Apply changes var pvcStub = K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); + var pvc = pvcStub.model(); + if (pvc.isEmpty() + || !"Bound".equals(pvc.get().getStatus().getPhase())) { + // Does not exist or isn't bound, use apply + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + return; + } + + // If bound, use json merge, omitting immutable fields + var spec = GsonPtr.to(pvcDef.getRaw()).to("spec"); + spec.removeExcept("volumeAttributesClassName", "resources"); + spec.access("resources").ifPresent(p -> p.removeExcept("requests")); PatchOptions opts = new PatchOptions(); - opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) .isEmpty()) { logger.warning( () -> "Could not patch pvc for " + pvcStub.name()); } } + + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + private void removeImmutable(DynamicKubernetesObject pvcDef) { + var spec = GsonPtr.to(pvcDef.getRaw()).to("spec").get(JsonObject.class); + for (var itr = spec.entrySet().iterator(); itr.hasNext();) { + var entry = itr.next(); + if ("volumeAttributesClassName".equals(entry.getKey())) { + continue; + } + if ("resources".equals(entry.getKey())) { + continue; + } + itr.remove(); + } + } } diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java index 8b84ed3..de96ec6 100644 --- a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java @@ -23,6 +23,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import java.math.BigInteger; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -62,7 +63,8 @@ public class GsonPtr { * @param selectors the selectors * @return the Gson pointer */ - @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) + @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace", + "PMD.AvoidDuplicateLiterals" }) public GsonPtr to(Object... selectors) { JsonElement element = position; for (Object sel : selectors) { @@ -91,6 +93,42 @@ public class GsonPtr { return new GsonPtr(element); } + /** + * Create a new instance pointing to the {@link JsonElement} + * selected by the given selectors. If a selector of type + * {@link String} denotes a non-existant member of a + * {@link JsonObject} the result is empty. + * + * @param selectors the selectors + * @return the Gson pointer + */ + @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) + public Optional access(Object... selectors) { + JsonElement element = position; + for (Object sel : selectors) { + if (element instanceof JsonObject obj + && sel instanceof String member) { + element = obj.get(member); + if (element == null) { + return Optional.empty(); + } + continue; + } + if (element instanceof JsonArray arr + && sel instanceof Integer index) { + try { + element = arr.get(index); + } catch (IndexOutOfBoundsException e) { + throw new IllegalStateException("Selected array index" + + " may not be empty."); + } + continue; + } + throw new IllegalStateException("Invalid selection"); + } + return Optional.of(new GsonPtr(element)); + } + /** * Returns {@link JsonElement} that the pointer points to. * @@ -336,4 +374,22 @@ public class GsonPtr { return this; } + /** + * Removes all properties except the specified ones. + * + * @param properties the properties + */ + public void removeExcept(String... properties) { + if (!position.isJsonObject()) { + return; + } + for (var itr = ((JsonObject) position).entrySet().iterator(); + itr.hasNext();) { + var entry = itr.next(); + if (Arrays.asList(properties).contains(entry.getKey())) { + continue; + } + itr.remove(); + } + } } From 80d416550050588c7567fd95b6988cf8a70b9448 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 13:32:27 +0100 Subject: [PATCH 072/274] Upgrade 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 b581971..eda5ce0 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.1.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.2.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 877d4c69cde50345521b6ca7858c1a2506b5b2aa Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 13:34:16 +0100 Subject: [PATCH 073/274] Remove obsolete method. --- .../jdrupes/vmoperator/manager/PvcReconciler.java | 15 --------------- 1 file changed, 15 deletions(-) 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 4ced1b1..5f10f47 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 @@ -214,19 +214,4 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; () -> "Could not patch pvc for " + pvcStub.name()); } } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private void removeImmutable(DynamicKubernetesObject pvcDef) { - var spec = GsonPtr.to(pvcDef.getRaw()).to("spec").get(JsonObject.class); - for (var itr = spec.entrySet().iterator(); itr.hasNext();) { - var entry = itr.next(); - if ("volumeAttributesClassName".equals(entry.getKey())) { - continue; - } - if ("resources".equals(entry.getKey())) { - continue; - } - itr.remove(); - } - } } From 5d722abd2e95cc052124d3fe41ce27568c8be596 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 13:35:51 +0100 Subject: [PATCH 074/274] Add assignment based on last usage. --- deploy/crds/vmpools-crd.yaml | 9 +++ dev-example/test-pool.yaml | 1 + .../vmoperator/common/VmDefinition.java | 34 +++++++- .../org/jdrupes/vmoperator/common/VmPool.java | 38 +++++++++ .../vmoperator/manager/PoolMonitor.java | 40 +++++++++- .../jdrupes/vmoperator/manager/VmMonitor.java | 80 +++++++++++++------ .../vmoperator/vmaccess/l10n.properties | 1 + .../vmoperator/vmaccess/l10n_de.properties | 3 + .../jdrupes/vmoperator/vmaccess/VmAccess.java | 12 ++- 9 files changed, 186 insertions(+), 32 deletions(-) diff --git a/deploy/crds/vmpools-crd.yaml b/deploy/crds/vmpools-crd.yaml index 193d547..b34d096 100644 --- a/deploy/crds/vmpools-crd.yaml +++ b/deploy/crds/vmpools-crd.yaml @@ -16,6 +16,15 @@ spec: spec: type: object properties: + retention: + description: >- + Defines the timeout for assignments. The time may be + specified as ISO 8601 time or duration. When specifying + a duration, it will be added to the last time the VM's + console was used to obtain the timeout. + type: string + pattern: '^(?:\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3])(?:|:?[0-5]\d))|P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+[Hh])?(?:\d+[Mm])?(?:\d+(?:\.\d{1,9})?[Ss])?)?)$' + default: "PT1h" permissions: type: array description: >- diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml index 43c36b8..96289e3 100644 --- a/dev-example/test-pool.yaml +++ b/dev-example/test-pool.yaml @@ -4,6 +4,7 @@ metadata: namespace: vmop-dev name: test-vms spec: + retention: "PT1m" permissions: - user: admin may: 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 375612b..2742b88 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 @@ -18,7 +18,11 @@ package org.jdrupes.vmoperator.common; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.kubernetes.client.openapi.models.V1Condition; import io.kubernetes.client.openapi.models.V1ObjectMeta; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; @@ -36,9 +40,12 @@ import org.jdrupes.vmoperator.util.DataPath; /** * Represents a VM definition. */ -@SuppressWarnings({ "PMD.DataClass" }) +@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) public class VmDefinition { + private static ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + private String kind; private String apiVersion; private V1ObjectMeta metadata; @@ -306,6 +313,31 @@ public class VmDefinition { return fromStatus("assignment", "user"); } + /** + * Last usage of assigned VM. + * + * @return the optional + */ + public Optional assignmentLastUsed() { + return this. fromStatus("assignment", "lastUsed") + .map(Instant::parse); + } + + /** + * Return a condition from the status. + * + * @param name the condition's name + * @return the status, if the condition is defined + */ + public Optional condition(String name) { + return this.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(name::equals).orElse(false)) + .findFirst() + .map(cond -> objectMapper.convertValue(cond, V1Condition.class)); + } + /** * Return a condition's status. * 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 426a69c..8bf6dee 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 @@ -18,6 +18,8 @@ package org.jdrupes.vmoperator.common; +import java.time.Duration; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -36,6 +38,7 @@ import org.jdrupes.vmoperator.util.DataPath; public class VmPool { private String name; + private String retention; private boolean defined; private List permissions = Collections.emptyList(); private final Set vms @@ -86,6 +89,24 @@ public class VmPool { this.defined = defined; } + /** + * Gets the retention. + * + * @return the retention + */ + public String retention() { + return retention; + } + + /** + * Sets the retention. + * + * @param retention the retention to set + */ + public void setRetention(String retention) { + this.retention = retention; + } + /** * Permissions granted for a VM from the pool. * @@ -113,6 +134,11 @@ public class VmPool { return vms; } + /** + * To string. + * + * @return the string + */ @Override @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") public String toString() { @@ -147,4 +173,16 @@ public class VmPool { .flatMap(Function.identity()).collect(Collectors.toSet()); } + /** + * Return the instant until which an assignment should be retained. + * + * @param lastUsed the last used + * @return the instant + */ + public Instant retainUntil(Instant lastUsed) { + if (retention.startsWith("P")) { + return lastUsed.plus(Duration.parse(retention)); + } + return Instant.parse(retention); + } } 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 e11f667..4a7a1cb 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 @@ -18,21 +18,26 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.Watch; import java.io.IOException; +import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sDynamicModels; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; import org.jdrupes.vmoperator.manager.events.GetPools; @@ -129,6 +134,7 @@ public class PoolMonitor extends var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName)); var newData = client().getJSON().getGson().fromJson( GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class); + vmPool.setRetention(newData.retention()); vmPool.setPermissions(newData.permissions()); vmPool.setDefined(true); poolPipeline.fire(new VmPoolChanged(vmPool)); @@ -138,13 +144,15 @@ public class PoolMonitor extends * Track VM definition changes. * * @param event the event + * @throws ApiException */ @Handler - public void onVmDefChanged(VmDefChanged event) { - String vmName = event.vmDefinition().name(); + public void onVmDefChanged(VmDefChanged event) throws ApiException { + final var vmDef = event.vmDefinition(); + final String vmName = vmDef.name(); switch (event.type()) { case ADDED: - event.vmDefinition().> fromSpec("pools") + vmDef.> fromSpec("pools") .orElse(Collections.emptyList()).stream().forEach(p -> { pools.computeIfAbsent(p, k -> new VmPool(p)) .vms().add(vmName); @@ -157,10 +165,34 @@ public class PoolMonitor extends poolPipeline.fire(new VmPoolChanged(p)); } }); - break; + return; default: break; } + + // Sync last usage to console state change if user matches + var assignedTo = vmDef.assignedTo().orElse(null); + if (assignedTo == null || !assignedTo + .equals(vmDef. fromStatus("consoleUser").orElse(null))) { + return; + } + var lastUsed + = vmDef.assignmentLastUsed().orElse(Instant.ofEpochSecond(0)); + var conChange = vmDef.condition("ConsoleConnected") + .map(c -> c.getLastTransitionTime().toInstant()) + .orElse(Instant.ofEpochSecond(0)); + if (!conChange.isAfter(lastUsed)) { + return; + } + var vmStub = VmDefinitionStub.get(client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + var assignment = GsonPtr.to(status).to("assignment"); + assignment.set("lastUsed", conChange.toString()); + return status; + }); } /** 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 b8ba467..9b6f75f 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 @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023,2024 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -25,7 +25,9 @@ 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.time.Instant; import java.util.ArrayList; +import java.util.Comparator; import java.util.Optional; import java.util.Set; import java.util.logging.Level; @@ -43,10 +45,12 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionModels; import org.jdrupes.vmoperator.common.VmDefinitionStub; +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; @@ -245,28 +249,59 @@ public class VmMonitor extends * * @param event the event * @throws ApiException the api exception + * @throws InterruptedException */ @Handler - public void onAssignVm(AssignVm event) throws ApiException { - // Search for existing assignment. - var assignedVm = channelManager.channels().stream() - .filter(c -> c.vmDefinition().assignedFrom() - .map(p -> p.equals(event.fromPool())).orElse(false)) - .filter(c -> c.vmDefinition().assignedTo() - .map(u -> u.equals(event.toUser())).orElse(false)) - .findFirst(); - if (assignedVm.isPresent()) { - event.setResult(new VmData(assignedVm.get().vmDefinition(), - assignedVm.get())); - return; - } + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onAssignVm(AssignVm event) + throws ApiException, InterruptedException { + VmPool vmPool = null; + while (true) { + // Search for existing assignment. + var assignedVm = channelManager.channels().stream() + .filter(c -> c.vmDefinition().assignedFrom() + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignedTo() + .map(u -> u.equals(event.toUser())).orElse(false)) + .findFirst(); + if (assignedVm.isPresent()) { + var vmDef = assignedVm.get().vmDefinition(); + event.setResult(new VmData(vmDef, assignedVm.get())); + return; + } - // Find available VM. - assignedVm = channelManager.channels().stream() - .filter(c -> c.vmDefinition().pools().contains(event.fromPool())) - .filter(c -> c.vmDefinition().assignedTo().isEmpty()) - .findFirst(); - if (assignedVm.isPresent()) { + // Get the pool definition for retention time calculations + if (vmPool == null) { + vmPool = newEventPipeline().fire(new GetPools() + .withName(event.fromPool())).get().stream().findFirst() + .orElse(null); + if (vmPool == null) { + return; + } + } + + // Find available VM. + var pool = vmPool; + assignedVm = channelManager.channels().stream() + .filter(c -> c.vmDefinition().pools() + .contains(event.fromPool())) + .filter(c -> !c.vmDefinition() + .conditionStatus("ConsoleConnected").orElse(false)) + .filter(c -> c.vmDefinition().assignedTo().isEmpty() + || pool.retainUntil(c.vmDefinition() + . fromStatus("assignment", "lastUsed") + .map(Instant::parse).orElse(Instant.ofEpochSecond(0))) + .isBefore(Instant.now())) + .sorted(Comparator.comparing(c -> c.vmDefinition() + .assignmentLastUsed().orElse(Instant.ofEpochSecond(0)))) + .findFirst(); + + // None found + if (assignedVm.isEmpty()) { + return; + } + + // Assign to user var vmDef = assignedVm.get().vmDefinition(); var vmStub = VmDefinitionStub.get(client(), new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), @@ -276,14 +311,13 @@ public class VmMonitor extends var assignment = GsonPtr.to(status).to("assignment"); assignment.set("pool", event.fromPool()); assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); return status; }); - // Always start a newly assigned VM. + // Make sure that a newly assigned VM is running. fire(new ModifyVm(vmDef.name(), "state", "Running", assignedVm.get())); - event.setResult(new VmData(vmDef, assignedVm.get())); } } - } diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties index d755e7a..6305a4b 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties @@ -5,3 +5,4 @@ okayLabel = Apply and Close confirmResetTitle = Confirm reset confirmResetMsg = Resetting the VM may cause loss of data. \ Please confirm to continue. +poolEmptyNotification = No VM available. Please consult your administrator. diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties index 7d4ff23..dbd3b11 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties @@ -11,3 +11,6 @@ Open\ console = Konsole anzeigen confirmResetTitle = Zurücksetzen bestätigen confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ Bitte bestätigen um fortzufahren. +poolEmptyNotification = Keine VM verfügbar. Wenden Sie sich bitte an den \ + Systemadministrator. + \ No newline at end of file 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 e6d4e58..5c72309 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 @@ -86,6 +86,7 @@ import org.jgrapes.webconsole.base.events.ConsoleConfigured; import org.jgrapes.webconsole.base.events.ConsolePrepared; import org.jgrapes.webconsole.base.events.ConsoleReady; import org.jgrapes.webconsole.base.events.DeleteConlet; +import org.jgrapes.webconsole.base.events.DisplayNotification; import org.jgrapes.webconsole.base.events.NotifyConletModel; import org.jgrapes.webconsole.base.events.NotifyConletView; import org.jgrapes.webconsole.base.events.OpenModalDialog; @@ -717,10 +718,9 @@ public class VmAccess extends FreeMarkerConlet { } } - @Override - @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", - "PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount", + @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity", "PMD.AvoidLiteralsInIfCondition" }) + @Override protected void doUpdateConletState(NotifyConletModel event, ConsoleConnection channel, ResourceModel model) throws Exception { event.stop(); @@ -741,7 +741,11 @@ public class VmAccess extends FreeMarkerConlet { vmData = Optional.ofNullable(appPipeline .fire(new AssignVm(model.name(), user)).get()); if (vmData.isEmpty()) { - // TODO message + ResourceBundle resourceBundle + = resourceBundle(channel.locale()); + channel.respond(new DisplayNotification( + resourceBundle.getString("poolEmptyNotification"), + Map.of("autoClose", 15_000, "type", "Error"))); return; } } From 2a70c74234c6fc354403eb7444f00117b32cd372 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 13:39:22 +0100 Subject: [PATCH 075/274] Use consistent method names. --- .../manager/ConfigMapReconciler.java | 4 ++-- .../vmoperator/manager/PvcReconciler.java | 2 +- .../org/jdrupes/vmoperator/util/GsonPtr.java | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 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 68ca7fa..9c6dc3e 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,14 +86,14 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Maybe override logging.properties from reconciler configuration. DataPath. get(model, "reconciler", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") + GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); // Maybe override logging.properties from VM definition. DataPath. get(model, "cr", "spec", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") + GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); 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 5f10f47..caae5a3 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 @@ -204,7 +204,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // If bound, use json merge, omitting immutable fields var spec = GsonPtr.to(pvcDef.getRaw()).to("spec"); spec.removeExcept("volumeAttributesClassName", "resources"); - spec.access("resources").ifPresent(p -> p.removeExcept("requests")); + spec.get("resources").ifPresent(p -> p.removeExcept("requests")); PatchOptions opts = new PatchOptions(); opts.setFieldManager("kubernetes-java-kubectl-apply"); if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java index de96ec6..36c3444 100644 --- a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java @@ -103,7 +103,7 @@ public class GsonPtr { * @return the Gson pointer */ @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) - public Optional access(Object... selectors) { + public Optional get(Object... selectors) { JsonElement element = position; for (Object sel : selectors) { if (element instanceof JsonObject obj @@ -147,7 +147,7 @@ public class GsonPtr { * @return the result */ @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) - public T get(Class cls) { + public T getAs(Class cls) { if (cls.isAssignableFrom(position.getClass())) { return cls.cast(position); } @@ -166,7 +166,7 @@ public class GsonPtr { */ @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) public Optional - get(Class cls, Object... selectors) { + getAs(Class cls, Object... selectors) { JsonElement element = position; for (Object sel : selectors) { if (element instanceof JsonObject obj @@ -201,7 +201,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsString(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsString); } @@ -212,7 +212,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsInt(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsInt); } @@ -223,7 +223,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsBigInteger(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBigInteger); } @@ -234,7 +234,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsLong(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsLong); } @@ -245,7 +245,7 @@ public class GsonPtr { * @return the boolean */ public Optional getAsBoolean(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBoolean); } @@ -260,7 +260,7 @@ public class GsonPtr { @SuppressWarnings("unchecked") public List getAsListOf(Class cls, Object... selectors) { - return get(JsonArray.class, selectors).map(a -> (List) a.asList()) + return getAs(JsonArray.class, selectors).map(a -> (List) a.asList()) .orElse(Collections.emptyList()); } From a0d626cc319f22966ac843201f7d0a878a3c2592 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 13:40:51 +0100 Subject: [PATCH 076/274] Fix warnings. --- .../org/jdrupes/vmoperator/common/K8sClusterGenericStub.java | 5 +++-- .../src/org/jdrupes/vmoperator/manager/PvcReconciler.java | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) 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 81a4eab..af87af2 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 @@ -45,7 +45,8 @@ import java.util.function.Function; * @param the generic type * @param the generic type */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", + "PMD.CouplingBetweenObjects" }) public class K8sClusterGenericStub { protected final K8sClient client; @@ -373,7 +374,7 @@ public class K8sClusterGenericStub> Collection list(Class objectClass, Class objectListClass, - K8sClient client, APIResource context, + K8sClient client, APIResource context, ListOptions options, GenericSupplier provider) throws ApiException { var result = new ArrayList(); 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 caae5a3..34085f0 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 @@ -18,7 +18,6 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonObject; import freemarker.core.ParseException; import freemarker.template.Configuration; import freemarker.template.MalformedTemplateNameException; @@ -26,7 +25,6 @@ import freemarker.template.TemplateException; import freemarker.template.TemplateNotFoundException; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; From 574ad5226bd5d03b4b46566b82b767544a047700 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 14:33:56 +0100 Subject: [PATCH 077/274] Fix typescript error. --- .../jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts index 6ab15b8..f18836a 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -75,17 +75,17 @@ window.orgJDrupesVmOperatorVmMgmt.initPreview = (previewDom: HTMLElement, chart = new CpuRamChart(canvas, chartData); }) - watch(chartDateUpdate, (_) => { + watch(chartDateUpdate, (_: never) => { chart?.update(); }) - watch(JGWC.langRef(), (_) => { + watch(JGWC.langRef(), (_: never) => { chart?.localizeChart(); }) const period: Ref = ref("day"); - watch(period, (_) => { + watch(period, (_: never) => { const hours = (period.value === "day") ? 24 : 1; chart?.setPeriod(hours * 3600 * 1000); }); From 53a58a2aca1fdda39e159ab2064fbfca08230d61 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 15:12:49 +0100 Subject: [PATCH 078/274] Add users for pool testing. --- dev-example/config.yaml | 18 ++++++++++++++---- dev-example/kustomization.yaml | 14 ++++++++++++-- dev-example/test-pool.yaml | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/dev-example/config.yaml b/dev-example/config.yaml index f2e0563..c0dc4b1 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -40,15 +40,25 @@ - name: admin fullName: Administrator password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - - name: test - fullName: Test Account + - name: test1 + fullName: Test Account 1 + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account 2 + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account 3 password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin admin: - admin - test: + test1: + - user + test2: + - user + test3: - user # All users have role other "*": @@ -60,7 +70,7 @@ admin: - "*" user: - - org.jdrupes.vmoperator.vmviewer.VmViewer + - org.jdrupes.vmoperator.vmaccess.VmAccess # Others cannot use any conlet (except login conlet to log out) other: - org.jgrapes.webconlet.oidclogin.LoginConlet diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml index 7dc4a15..975d95f 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -54,7 +54,13 @@ patches: - name: admin fullName: Administrator password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - - name: test + - 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": @@ -62,7 +68,11 @@ patches: # User admin has role admin admin: - admin - test: + test1: + - user + test2: + - user + test3: - user # All users have role other "*": diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml index 96289e3..82b9131 100644 --- a/dev-example/test-pool.yaml +++ b/dev-example/test-pool.yaml @@ -9,6 +9,6 @@ spec: - user: admin may: - accessConsole - - user: test + - role: user may: - accessConsole From aaf1a0c545790aa55a3a120c5f22e27fd9f01cac Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 22:27:07 +0100 Subject: [PATCH 079/274] Add operator for testing. --- dev-example/config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dev-example/config.yaml b/dev-example/config.yaml index c0dc4b1..586a16e 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -40,6 +40,9 @@ - name: admin fullName: Administrator password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: operator + fullName: Operator + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" - name: test1 fullName: Test Account 1 password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" @@ -54,6 +57,8 @@ # User admin has role admin admin: - admin + operator: + - operator test1: - user test2: @@ -69,6 +74,8 @@ # Admins can use all conlets admin: - "*" + operator: + - org.jdrupes.vmoperator.vmaccess.VmAccess user: - org.jdrupes.vmoperator.vmaccess.VmAccess # Others cannot use any conlet (except login conlet to log out) From 224855efd3b12350966030c5453d34120fcca146 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 22:27:52 +0100 Subject: [PATCH 080/274] Disable empty lists. --- .../org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html index afbff3a..a34f725 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html @@ -10,7 +10,8 @@
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 51ca610..0ab15fd 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 @@ -7,6 +7,7 @@ currentCpus = Current CPUs currentRam = Current RAM maximumCpus = Maximum CPUs maximumRam = Maximum RAM +notInUse = Currently closed nodeName = Node requestedCpus = Requested CPUs requestedRam = Requested RAM 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 3e67928..c8d8c4d 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 @@ -12,6 +12,7 @@ currentRam = Akuelles RAM maximumCpus = Maximale CPUs maximumRam = Maximales RAM nodeName = Knoten +notInUse = Derzeit geschlossen requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM running = Gestartet diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts index f18836a..d8247ba 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -164,7 +164,7 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, return { controller, vmInfos, filteredData, detailsByName, localize, shortDateTime, formatMemory, vmAction, cic, parseMemory, - maximumCpus, + maximumCpus, scopedId: (id: string) => { return idScope.scopedId(id); } }; } diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss index 9d7721a..3a3f0d7 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss @@ -72,6 +72,10 @@ } } } + + .console-conection-closed { + color: var(--disabled); + } } td.details { From 3ca632c8dab3047a7f42013ae023a4a932d66c9f Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 26 Jan 2025 17:21:36 +0100 Subject: [PATCH 083/274] Exchange columns. --- .../org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts index d8247ba..c7f8519 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -112,8 +112,8 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], ["nodeName", "nodeName"], - ["assignedTo", "assignedTo"], - ["usedBy", "usedBy"] + ["usedBy", "usedBy"], + ["assignedTo", "assignedTo"] ], { sortKey: "name", sortOrder: "up" From 1b5ad5b73e0e98edc8bca1e99618c764e3af5f35 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 26 Jan 2025 21:49:37 +0100 Subject: [PATCH 084/274] Prevent unauthorized console take over. --- deploy/crds/vms-crd.yaml | 5 +++++ .../jdrupes/vmoperator/common/VmDefinition.java | 2 +- .../jdrupes/vmoperator/vmaccess/l10n.properties | 1 + .../vmoperator/vmaccess/l10n_de.properties | 1 + .../jdrupes/vmoperator/vmaccess/VmAccess.java | 16 +++++++++++++--- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 9d7bbb8..7b46dc7 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -994,6 +994,10 @@ spec: type: array description: >- Defines permissions for accessing and manipulating the VM. + The meaning of most permissions should be obvious. The + difference between "accessConsole" and "takeConsole" is + that "takeConsole" allows the user to take control of + the console even if it is already in use by another user. items: type: object description: >- @@ -1017,6 +1021,7 @@ spec: - stop - reset - accessConsole + - takeConsole - "*" default: [] pools: 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 da639f4..f577d28 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 @@ -65,7 +65,7 @@ public class VmDefinition { */ public enum Permission { START("start"), STOP("stop"), RESET("reset"), - ACCESS_CONSOLE("accessConsole"); + ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole"); @SuppressWarnings("PMD.UseConcurrentHashMap") private static Map reprs = new HashMap<>(); diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties index 6305a4b..8f4051e 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties @@ -5,4 +5,5 @@ okayLabel = Apply and Close confirmResetTitle = Confirm reset confirmResetMsg = Resetting the VM may cause loss of data. \ Please confirm to continue. +consoleTakenNotification = Console access is locked by another user. poolEmptyNotification = No VM available. Please consult your administrator. diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties index dbd3b11..e51eb5e 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties @@ -11,6 +11,7 @@ Open\ console = Konsole anzeigen confirmResetTitle = Zurücksetzen bestätigen confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ Bitte bestätigen um fortzufahren. +consoleTakenNotification = Die Konsole wird von einem anderen Benutzer verwendet. poolEmptyNotification = Keine VM verfügbar. Wenden Sie sich bitte an den \ Systemadministrator. \ No newline at end of file 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 5c72309..5f2d747 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 @@ -779,9 +779,19 @@ public class VmAccess extends FreeMarkerConlet { } break; case "openConsole": - if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) { - var user = WebConsoleUtils.userFromSession(channel.session()) - .map(ConsoleUser::getName).orElse(""); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + if (vmDef.conditionStatus("ConsoleConnected").orElse(false) + && vmDef.consoleUser().map(cu -> !cu.equals(user) + && !perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) + .orElse(false)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleTakenNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE) + || perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) { var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), e -> openConsole(vmDef, channel, model, From 86f6ece2648bbc514cd7fbb9a7ae8a4e54a21c78 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 26 Jan 2025 21:55:24 +0100 Subject: [PATCH 085/274] Adjust auto close time. --- .../src/org/jdrupes/vmoperator/vmaccess/VmAccess.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5f2d747..786fedf 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 @@ -745,7 +745,7 @@ public class VmAccess extends FreeMarkerConlet { = resourceBundle(channel.locale()); channel.respond(new DisplayNotification( resourceBundle.getString("poolEmptyNotification"), - Map.of("autoClose", 15_000, "type", "Error"))); + Map.of("autoClose", 10_000, "type", "Error"))); return; } } From 5cd4edcec12672a74b07df52ac11aa4c1bfdc258 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 28 Jan 2025 18:08:28 +0100 Subject: [PATCH 086/274] Don't add channels until fully initialized. --- .../manager/events/ChannelManager.java | 17 +++++++++++++++++ .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 2 ++ 2 files changed, 19 insertions(+) diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java index 2cf7a85..ce0e4f0 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java @@ -62,6 +62,11 @@ public class ChannelManager this(k -> null); } + /** + * Return all keys. + * + * @return the keys. + */ @Override public Set keys() { return entries.keySet(); @@ -113,6 +118,18 @@ public class ChannelManager return this; } + /** + * Creates a new channel without adding it to the channel manager. + * After fully initializing the channel, it should be added to the + * manager using {@link #put(K, C)}. + * + * @param key the key + * @return the c + */ + public C createChannel(K key) { + return supplier.apply(key); + } + /** * Returns the {@link Channel} for the given name, creating it using * the supplier passed to the constructor if it doesn't exist yet. 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 dbe17ca..3842cbc 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 @@ -352,6 +352,8 @@ public class VmMgmt extends FreeMarkerConlet { case "stop": fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); break; + case "openConsole": + break; case "cpus": fire(new ModifyVm(vmName, "currentCpus", new BigDecimal(event.param(1).toString()).toBigInteger(), From af41c78c078b09068f5c5bdbc44087e8e98990a3 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 29 Jan 2025 17:33:16 +0100 Subject: [PATCH 087/274] Add console access to VM management. --- dev-example/config.yaml | 1 + .../vmoperator/common/VmDefinition.java | 114 ++++++++++++++- .../jdrupes/vmoperator/manager/VmMonitor.java | 11 +- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 119 ++++------------ .../vmaccess/browser/VmAccess-functions.ts | 5 +- .../vmoperator/vmmgmt/VmMgmt-view.ftl.html | 16 ++- .../vmoperator/vmmgmt/computer-in-use.svg | 90 ++++++++++++ .../vmoperator/vmmgmt/computer-off.svg | 74 ++++++++++ .../jdrupes/vmoperator/vmmgmt/computer.svg | 74 ++++++++++ .../jdrupes/vmoperator/vmmgmt/l10n.properties | 5 + .../vmoperator/vmmgmt/l10n_de.properties | 6 + .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 132 +++++++++++++++--- .../vmmgmt/browser/VmMgmt-functions.ts | 24 +++- .../vmmgmt/browser/VmMgmt-style.scss | 14 ++ 14 files changed, 561 insertions(+), 124 deletions(-) create mode 100644 org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-in-use.svg create mode 100644 org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-off.svg create mode 100644 org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer.svg diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 586a16e..2a72bc8 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -75,6 +75,7 @@ admin: - "*" operator: + - org.jdrupes.vmoperator.vmmgmt.VmMgmt - org.jdrupes.vmoperator.vmaccess.VmAccess user: - org.jdrupes.vmoperator.vmaccess.VmAccess 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 f577d28..ffb1bf2 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 @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2024 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 Affero General Public License as @@ -22,11 +22,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.kubernetes.client.openapi.models.V1Condition; import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.Strings; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -34,6 +38,8 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.stream.Collectors; import org.jdrupes.vmoperator.util.DataPath; @@ -43,7 +49,11 @@ import org.jdrupes.vmoperator.util.DataPath; @SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) public class VmDefinition { - private static ObjectMapper objectMapper + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Logger logger + = Logger.getLogger(VmDefinition.class.getName()); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private String kind; @@ -427,6 +437,8 @@ public class VmDefinition { /** * Collect all permissions for the given user with the given roles. + * If permission "takeConsole" is granted, the result will also + * contain "accessConsole" to simplify checks. * * @param user the user * @param roles the roles @@ -434,7 +446,7 @@ public class VmDefinition { */ public Set permissionsFor(String user, Collection roles) { - return this.>> fromSpec("permissions") + var result = this.>> fromSpec("permissions") .orElse(Collections.emptyList()).stream() .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user)) .orElse(false) @@ -443,7 +455,29 @@ public class VmDefinition { .orElse(Collections.emptyList()).stream()) .flatMap(Function.identity()) .map(Permission::parse).map(Set::stream) - .flatMap(Function.identity()).collect(Collectors.toSet()); + .flatMap(Function.identity()) + .collect(Collectors.toCollection(HashSet::new)); + + // Take console implies access console, simplify checks + if (result.contains(Permission.TAKE_CONSOLE)) { + result.add(Permission.ACCESS_CONSOLE); + } + return result; + } + + /** + * Check if the console is accessible. Returns true if the console is + * currently unused, used by the given user or if the permissions + * allow taking over the console. + * + * @param user the user + * @param permissions the permissions + * @return true, if successful + */ + public boolean consoleAccessible(String user, Set permissions) { + return !conditionStatus("ConsoleConnected").orElse(true) + || consoleUser().map(cu -> cu.equals(user)).orElse(true) + || permissions.contains(VmDefinition.Permission.TAKE_CONSOLE); } /** @@ -456,6 +490,78 @@ public class VmDefinition { .map(Number::longValue); } + /** + * Create a connection file. + * + * @param password the password + * @param preferredIpVersion the preferred IP version + * @param deleteConnectionFile the delete connection file + * @return the string + */ + public String connectionFile(String password, + Class preferredIpVersion, boolean deleteConnectionFile) { + var addr = displayIp(preferredIpVersion); + if (addr.isEmpty()) { + logger.severe(() -> "Failed to find display IP for " + name()); + return null; + } + var port = this. fromVm("display", "spice", "port") + .map(Number::longValue); + if (port.isEmpty()) { + logger.severe(() -> "No port defined for display of " + name()); + return null; + } + StringBuffer data = new StringBuffer(100) + .append("[virt-viewer]\ntype=spice\nhost=") + .append(addr.get().getHostAddress()).append("\nport=") + .append(port.get().toString()) + .append('\n'); + if (password != null) { + data.append("password=").append(password).append('\n'); + } + this. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); + if (deleteConnectionFile) { + data.append("delete-this-file=1\n"); + } + return data.toString(); + } + + private Optional displayIp(Class preferredIpVersion) { + Optional server = fromVm("display", "spice", "server"); + if (server.isPresent()) { + var srv = server.get(); + try { + var addr = InetAddress.getByName(srv); + logger.fine(() -> "Using IP address from CRD for " + + getMetadata().getName() + ": " + addr); + return Optional.of(addr); + } catch (UnknownHostException e) { + logger.log(Level.SEVERE, e, () -> "Invalid server address " + + srv + ": " + e.getMessage()); + return Optional.empty(); + } + } + var addrs = Optional.> ofNullable( + extra("nodeAddresses")).orElse(Collections.emptyList()).stream() + .map(a -> { + try { + return InetAddress.getByName(a); + } catch (UnknownHostException e) { + logger.warning(() -> "Invalid IP address: " + a); + return null; + } + }).filter(a -> a != null).toList(); + logger.fine(() -> "Known IP addresses for " + name() + ": " + addrs); + return addrs.stream() + .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) + .findFirst().or(() -> addrs.stream().findFirst()); + } + /** * Hash code. * 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 102a6c9..2060109 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,6 +30,7 @@ import java.util.ArrayList; import java.util.Comparator; 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 static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; @@ -125,7 +126,12 @@ public class VmMonitor extends protected void handleChange(K8sClient client, Watch.Response response) { V1ObjectMeta metadata = response.object.getMetadata(); - VmChannel channel = channelManager.channelGet(metadata.getName()); + AtomicBoolean toBeAdded = new AtomicBoolean(false); + VmChannel channel = channelManager.channel(metadata.getName()) + .orElseGet(() -> { + toBeAdded.set(true); + return channelManager.createChannel(metadata.getName()); + }); // Get full definition and associate with channel as backup var vmModel = response.object; @@ -151,6 +157,9 @@ public class VmMonitor extends + response.object.getMetadata()); return; } + if (toBeAdded.get()) { + channelManager.put(vmDef.name(), channel); + } // Create and fire changed event. Remove channel from channel // manager on completion. 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 786fedf..fa00482 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 @@ -32,10 +32,7 @@ import io.kubernetes.client.util.Strings; import java.io.IOException; import java.net.Inet4Address; import java.net.Inet6Address; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.time.Duration; -import java.util.Base64; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; @@ -779,24 +776,8 @@ public class VmAccess extends FreeMarkerConlet { } break; case "openConsole": - var user = WebConsoleUtils.userFromSession(channel.session()) - .map(ConsoleUser::getName).orElse(""); - if (vmDef.conditionStatus("ConsoleConnected").orElse(false) - && vmDef.consoleUser().map(cu -> !cu.equals(user) - && !perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) - .orElse(false)) { - channel.respond(new DisplayNotification( - resourceBundle.getString("consoleTakenNotification"), - Map.of("autoClose", 5_000, "type", "Warning"))); - return; - } - if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE) - || perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) { - var pwQuery - = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> openConsole(vmDef, channel, model, - e.password().orElse(null))); - fire(pwQuery, vmChannel); + if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) { + openConsole(channel, model, vmChannel, vmDef, perms); } break; default:// ignore @@ -804,6 +785,30 @@ public class VmAccess extends FreeMarkerConlet { } } + private void openConsole(ConsoleConnection channel, ResourceModel model, + VmChannel vmChannel, VmDefinition vmDef, Set perms) { + var resourceBundle = resourceBundle(channel.locale()); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + if (!vmDef.consoleAccessible(user, perms)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleTakenNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), + e -> { + var data = vmDef.connectionFile(e.password().orElse(null), + preferredIpVersion, deleteConnectionFile); + if (data == null) { + return; + } + channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", data)); + }); + fire(pwQuery, vmChannel); + } + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.UseLocaleWithCaseConversions" }) private void selectResource(NotifyConletModel event, @@ -823,78 +828,6 @@ public class VmAccess extends FreeMarkerConlet { } } - private void openConsole(VmDefinition vmDef, ConsoleConnection connection, - ResourceModel model, String password) { - if (vmDef == null) { - return; - } - var addr = displayIp(vmDef); - if (addr.isEmpty()) { - logger - .severe(() -> "Failed to find display IP for " + vmDef.name()); - return; - } - var port = vmDef. fromVm("display", "spice", "port") - .map(Number::longValue); - if (port.isEmpty()) { - logger - .severe(() -> "No port defined for display of " + vmDef.name()); - return; - } - StringBuffer data = new StringBuffer(100) - .append("[virt-viewer]\ntype=spice\nhost=") - .append(addr.get().getHostAddress()).append("\nport=") - .append(port.get().toString()) - .append('\n'); - if (password != null) { - data.append("password=").append(password).append('\n'); - } - vmDef. fromVm("display", "spice", "proxyUrl") - .ifPresent(u -> { - if (!Strings.isNullOrEmpty(u)) { - data.append("proxy=").append(u).append('\n'); - } - }); - if (deleteConnectionFile) { - data.append("delete-this-file=1\n"); - } - connection.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", "application/x-virt-viewer", - Base64.getEncoder().encodeToString(data.toString().getBytes()))); - } - - private Optional displayIp(VmDefinition vmDef) { - Optional server = vmDef.fromVm("display", "spice", "server"); - if (server.isPresent()) { - var srv = server.get(); - try { - var addr = InetAddress.getByName(srv); - logger.fine(() -> "Using IP address from CRD for " - + vmDef.getMetadata().getName() + ": " + addr); - return Optional.of(addr); - } catch (UnknownHostException e) { - logger.log(Level.SEVERE, e, () -> "Invalid server address " - + srv + ": " + e.getMessage()); - return Optional.empty(); - } - } - var addrs = Optional.> ofNullable(vmDef - .extra("nodeAddresses")).orElse(Collections.emptyList()).stream() - .map(a -> { - try { - return InetAddress.getByName(a); - } catch (UnknownHostException e) { - logger.warning(() -> "Invalid IP address: " + a); - return null; - } - }).filter(a -> a != null).toList(); - logger.fine(() -> "Known IP addresses for " - + vmDef.name() + ": " + addrs); - return addrs.stream() - .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) - .findFirst().or(() -> addrs.stream().findFirst()); - } - private void confirmReset(NotifyConletModel event, ConsoleConnection channel, ResourceModel model, ResourceBundle resourceBundle) throws TemplateNotFoundException, diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index de65216..9d2e134 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -198,7 +198,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", }); JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", - "openConsole", function(_conletId: string, mimeType: string, data: string) { + "openConsole", function(_conletId: string, data: string) { let target = document.getElementById( "org.jdrupes.vmoperator.vmaccess.VmAccess.target"); if (!target) { @@ -208,7 +208,8 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", target.setAttribute("style", "display: none;"); document.querySelector("body")!.append(target); } - const url = "data:" + mimeType + ";base64," + data; + const url = "data:application/x-virt-viewer;base64," + + window.btoa(data); window.open(url, target.id); }); 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 a57c533..178d069 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 @@ -1,6 +1,7 @@
+ data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps" + data-conlet-resource-base="${conletResource('')}">
+ + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-off.svg b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-off.svg new file mode 100644 index 0000000..27c11ae --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-off.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer.svg b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer.svg new file mode 100644 index 0000000..f7a6b94 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + 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 0ab15fd..fb0362f 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 @@ -17,3 +17,8 @@ usedBy = Used by usedFrom = Used from vmActions = Actions vmname = Name + +confirmResetTitle = Confirm reset +confirmResetMsg = Resetting the VM may cause loss of data. \ + Please confirm to continue. +consoleTakenNotification = Console access is locked by another user. 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 c8d8c4d..2c142ae 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 @@ -24,6 +24,12 @@ vmname = Name Value\ is\ above\ maximum = Wert ist zu groß Illegal\ format = Ungültiges Format +confirmResetTitle = Zurücksetzen bestätigen +confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ + Bitte bestätigen um fortzufahren. +consoleTakenNotification = Die Konsole wird von einem anderen Benutzer verwendet. + +Open\ console = Konsole anzeigen Start\ VM = VM Starten Stop\ VM = VM Anhalten 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 3842cbc..2959f46 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 @@ -27,15 +27,22 @@ import io.kubernetes.client.custom.Quantity.Format; import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; +import java.net.Inet4Address; +import java.net.Inet6Address; import java.time.Duration; import java.time.Instant; +import java.util.Collections; import java.util.EnumSet; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.ResourceBundle; import java.util.Set; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.manager.events.ChannelTracker; +import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -44,13 +51,17 @@ import org.jgrapes.core.Channel; import org.jgrapes.core.Event; import org.jgrapes.core.Manager; import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; import org.jgrapes.webconsole.base.Conlet.RenderMode; import org.jgrapes.webconsole.base.ConletBaseModel; import org.jgrapes.webconsole.base.ConsoleConnection; -import org.jgrapes.webconsole.base.events.AddConletRequest; +import org.jgrapes.webconsole.base.ConsoleRole; +import org.jgrapes.webconsole.base.ConsoleUser; +import org.jgrapes.webconsole.base.WebConsoleUtils; import org.jgrapes.webconsole.base.events.AddConletType; import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; import org.jgrapes.webconsole.base.events.ConsoleReady; +import org.jgrapes.webconsole.base.events.DisplayNotification; import org.jgrapes.webconsole.base.events.NotifyConletModel; import org.jgrapes.webconsole.base.events.NotifyConletView; import org.jgrapes.webconsole.base.events.RenderConlet; @@ -61,10 +72,12 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; /** * The Class {@link VmMgmt}. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.CouplingBetweenObjects" }) +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects", + "PMD.ExcessiveImports" }) public class VmMgmt extends FreeMarkerConlet { + private Class preferredIpVersion = Inet4Address.class; + private boolean deleteConnectionFile = true; private static final Set MODES = RenderMode.asSet( RenderMode.Preview, RenderMode.View); private final ChannelTracker { setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update()); } + /** + * Configure the component. + * + * @param event the event + */ + @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured("/Manager/GuiHttpServer" + + "/ConsoleWeblet/WebConsole/ComponentCollector/VmAccess") + .ifPresent(c -> { + try { + var dispRes = (Map) c + .getOrDefault("displayResource", + Collections.emptyMap()); + switch ((String) dispRes.getOrDefault("preferredIpVersion", + "")) { + case "ipv6": + preferredIpVersion = Inet6Address.class; + break; + case "ipv4": + default: + preferredIpVersion = Inet4Address.class; + break; + } + + // Delete connection file + deleteConnectionFile + = Optional.ofNullable(c.get("deleteConnectionFile")) + .filter(v -> v instanceof String) + .map(v -> (String) v) + .map(Boolean::parseBoolean).orElse(true); + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } + /** * On {@link ConsoleReady}, fire the {@link AddConletType}. * @@ -117,7 +168,7 @@ public class VmMgmt extends FreeMarkerConlet { } @Override - protected Optional createNewState(AddConletRequest event, + protected Optional createStateRepresentation(Event event, ConsoleConnection connection, String conletId) throws Exception { return Optional.of(new VmsModel(conletId)); } @@ -160,17 +211,25 @@ public class VmMgmt extends FreeMarkerConlet { } if (sendVmInfos) { for (var item : channelTracker.values()) { - channel.respond(new NotifyConletView(type(), - conletId, "updateVm", - simplifiedVmDefinition(item.associated()))); + updateVm(channel, conletId, item.associated()); } } - return renderedAs; } + private void updateVm(ConsoleConnection channel, String conletId, + VmDefinition vmDef) { + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(channel.session()) + .stream().map(ConsoleRole::getName).toList(); + channel.respond(new NotifyConletView(type(), conletId, "updateVm", + simplifiedVmDefinition(vmDef, user, roles))); + } + @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private Map simplifiedVmDefinition(VmDefinition vmDef) { + private Map simplifiedVmDefinition(VmDefinition vmDef, + String user, List roles) { // Convert RAM sizes to unitless numbers var spec = DataPath.deepCopy(vmDef.spec()); var vmSpec = DataPath.> get(spec, "vm").get(); @@ -191,7 +250,9 @@ public class VmMgmt extends FreeMarkerConlet { "name", vmDef.name()), "spec", spec, "status", status, - "nodeName", vmDef.extra("nodeName")); + "nodeName", vmDef.extra("nodeName"), + "permissions", vmDef.permissionsFor(user, roles).stream() + .map(VmDefinition.Permission::toString).toList()); } /** @@ -221,8 +282,7 @@ public class VmMgmt extends FreeMarkerConlet { channelTracker.put(vmName, channel, vmDef); for (var entry : conletIdsByConsoleConnection().entrySet()) { for (String conletId : entry.getValue()) { - entry.getKey().respond(new NotifyConletView(type(), - conletId, "updateVm", simplifiedVmDefinition(vmDef))); + updateVm(entry.getKey(), conletId, vmDef); } } } @@ -337,22 +397,35 @@ public class VmMgmt extends FreeMarkerConlet { @Override @SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor") protected void doUpdateConletState(NotifyConletModel event, - ConsoleConnection channel, VmsModel conletState) - throws Exception { + ConsoleConnection channel, VmsModel model) throws Exception { event.stop(); String vmName = event.param(0); - var vmChannel = channelTracker.channel(vmName).orElse(null); - if (vmChannel == null) { + var value = channelTracker.value(vmName); + var vmChannel = value.map(v -> v.channel()).orElse(null); + var vmDef = value.map(v -> v.associated()).orElse(null); + if (vmDef == null) { return; } + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + var roles = WebConsoleUtils.rolesFromSession(channel.session()) + .stream().map(ConsoleRole::getName).toList(); + var perms = vmDef.permissionsFor(user, roles); switch (event.method()) { case "start": - fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + if (perms.contains(VmDefinition.Permission.START)) { + fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + } break; case "stop": - fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + if (perms.contains(VmDefinition.Permission.STOP)) { + fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + } break; case "openConsole": + if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) { + openConsole(channel, model, vmChannel, vmDef, user, perms); + } break; case "cpus": fire(new ModifyVm(vmName, "currentCpus", @@ -370,6 +443,29 @@ public class VmMgmt extends FreeMarkerConlet { } } + private void openConsole(ConsoleConnection channel, VmsModel model, + VmChannel vmChannel, VmDefinition vmDef, String user, + Set perms) { + ResourceBundle resourceBundle = resourceBundle(channel.locale()); + if (!vmDef.consoleAccessible(user, perms)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleTakenNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), + e -> { + var data = vmDef.connectionFile(e.password().orElse(null), + preferredIpVersion, deleteConnectionFile); + if (data == null) { + return; + } + channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", data)); + }); + fire(pwQuery, vmChannel); + } + @Override protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, String conletId) throws Exception { diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts index c7f8519..40534b1 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -104,6 +104,7 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, setup(_props: object) { const conletId: string = (viewDom.parentNode!).dataset["conletId"]!; + const resourceBase = (viewDom).dataset.conletResourceBase; const controller = reactive(new JGConsole.TableController([ ["name", "vmname"], @@ -162,9 +163,9 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, } return { - controller, vmInfos, filteredData, detailsByName, localize, - shortDateTime, formatMemory, vmAction, cic, parseMemory, - maximumCpus, + controller, vmInfos, filteredData, detailsByName, + resourceBase, localize, shortDateTime, formatMemory, + vmAction, cic, parseMemory, maximumCpus, scopedId: (id: string) => { return idScope.scopedId(id); } }; } @@ -219,3 +220,20 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", Object.assign(vmSummary, summary); }); +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", + "openConsole", function(_conletId: string, data: string) { + let target = document.getElementById( + "org.jdrupes.vmoperator.vmmgt.VmMgmt.target"); + if (!target) { + target = document.createElement("iframe"); + target.id = "org.jdrupes.vmoperator.vmmgt.VmMgmt.target"; + target.setAttribute("name", target.id); + target.setAttribute("style", "display: none;"); + document.querySelector("body")!.append(target); + } + const url = "data:application/x-virt-viewer;base64," + + window.btoa(data); + window.open(url, target.id); + }); + + diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss index 3a3f0d7..7a7af97 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss @@ -103,6 +103,10 @@ .jdrupes-vmoperator-vmmgmt-view-action-list { white-space: nowrap; + & > * + * { + margin-left: 0.5em; + } + [role=button] { padding: 0.25rem; @@ -110,4 +114,14 @@ box-shadow: var(--darkening); } } + + img { + display: inline; + height: 1.5em; + vertical-align: top; + + &[aria-disabled=''], &[aria-disabled='true'] { + opacity: 0.4; + } + } } From 8d96307bb59c92351666a5002d014f03750bb839 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 29 Jan 2025 18:42:10 +0100 Subject: [PATCH 088/274] Add reset action to VM management. --- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 28 +++++----- .../vmmgmt/VmMgmt-confirmReset.ftl.html | 13 +++++ .../vmoperator/vmmgmt/VmMgmt-view.ftl.html | 10 ++++ .../vmoperator/vmmgmt/l10n_de.properties | 1 + .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 31 ++++++++++- .../vmmgmt/browser/VmMgmt-functions.ts | 10 +++- .../vmmgmt/browser/VmMgmt-style.scss | 51 ++++++++++++++++++- 7 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html 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 fa00482..eea1eae 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 @@ -785,6 +785,20 @@ public class VmAccess extends FreeMarkerConlet { } } + private void confirmReset(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model, + ResourceBundle resourceBundle) throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException { + Template tpl = freemarkerConfig() + .getTemplate("VmAccess-confirmReset.ftl.html"); + channel.respond(new OpenModalDialog(type(), model.getConletId(), + processTemplate(event, tpl, + fmModel(event, channel, model.getConletId(), model))) + .addOption("cancelable", true).addOption("closeLabel", "") + .addOption("title", + resourceBundle.getString("confirmResetTitle"))); + } + private void openConsole(ConsoleConnection channel, ResourceModel model, VmChannel vmChannel, VmDefinition vmDef, Set perms) { var resourceBundle = resourceBundle(channel.locale()); @@ -828,20 +842,6 @@ public class VmAccess extends FreeMarkerConlet { } } - private void confirmReset(NotifyConletModel event, - ConsoleConnection channel, ResourceModel model, - ResourceBundle resourceBundle) throws TemplateNotFoundException, - MalformedTemplateNameException, ParseException, IOException { - Template tpl = freemarkerConfig() - .getTemplate("VmAccess-confirmReset.ftl.html"); - channel.respond(new OpenModalDialog(type(), model.getConletId(), - processTemplate(event, tpl, - fmModel(event, channel, model.getConletId(), model))) - .addOption("cancelable", true).addOption("closeLabel", "") - .addOption("title", - resourceBundle.getString("confirmResetTitle"))); - } - @Override protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, String conletId) throws Exception { diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html new file mode 100644 index 0000000..d174707 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html @@ -0,0 +1,13 @@ +
+

${_("confirmResetMsg")}

+

+ + + + + + +

+
\ No newline at end of file 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 178d069..4dfc8d7 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 @@ -72,6 +72,16 @@ v-on:click="vmAction(entry.name, 'stop')"> + + + + + + { } @Override - @SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor") + @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", + "PMD.NcssCount" }) protected void doUpdateConletState(NotifyConletModel event, ConsoleConnection channel, VmsModel model) throws Exception { event.stop(); @@ -422,6 +425,16 @@ public class VmMgmt extends FreeMarkerConlet { fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); } break; + case "reset": + if (perms.contains(VmDefinition.Permission.RESET)) { + confirmReset(event, channel, model, vmName); + } + break; + case "resetConfirmed": + if (perms.contains(VmDefinition.Permission.RESET)) { + fire(new ResetVm(vmName), vmChannel); + } + break; case "openConsole": if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) { openConsole(channel, model, vmChannel, vmDef, user, perms); @@ -443,6 +456,22 @@ public class VmMgmt extends FreeMarkerConlet { } } + private void confirmReset(NotifyConletModel event, + ConsoleConnection channel, VmsModel model, String vmName) + throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException { + Template tpl = freemarkerConfig() + .getTemplate("VmMgmt-confirmReset.ftl.html"); + ResourceBundle resourceBundle = resourceBundle(channel.locale()); + var fmModel = fmModel(event, channel, model.getConletId(), model); + fmModel.put("vmName", vmName); + channel.respond(new OpenModalDialog(type(), model.getConletId(), + processTemplate(event, tpl, fmModel)) + .addOption("cancelable", true).addOption("closeLabel", "") + .addOption("title", + resourceBundle.getString("confirmResetTitle"))); + } + private void openConsole(ConsoleConnection channel, VmsModel model, VmChannel vmChannel, VmDefinition vmDef, String user, Set perms) { diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts index 40534b1..f0407b7 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -34,7 +34,9 @@ declare global { interface Window { orgJDrupesVmOperatorVmMgmt: { initPreview?: (previewDom: HTMLElement, isUpdate: boolean) => void, - initView?: (viewDom: HTMLElement, isUpdate: boolean) => void + initView?: (viewDom: HTMLElement, isUpdate: boolean) => void, + confirmReset?: (conletType: string, conletId: string, + vmName: string) => void } } } @@ -236,4 +238,8 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", window.open(url, target.id); }); - +window.orgJDrupesVmOperatorVmMgmt.confirmReset = + (conletType: string, conletId: string, vmName: string) => { + JGConsole.instance.closeModalDialog(conletType, conletId); + JGConsole.notifyConletModel(conletId, "resetConfirmed", vmName); +} diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss index 7a7af97..4c11b65 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss @@ -114,7 +114,24 @@ box-shadow: var(--darkening); } } - + + span[role="button"].svg-icon { + display: inline-block; + line-height: 1; + /* Align with forkawesome */ + font-size: 14px; + fill: var(--primary); + + &[aria-disabled="true"], &[aria-disabled=""] { + fill: var(--disabled); + } + + svg { + height: 2ex; + width: 1em; + } + } + img { display: inline; height: 1.5em; @@ -125,3 +142,35 @@ } } } + +.jdrupes-vmoperator-vmmgmt.jdrupes-vmoperator-vmmgmt-confirm-reset { + + [role=button] { + padding: 0.25rem; + + &:not([aria-disabled]):hover, &[aria-disabled='false']:hover { + box-shadow: var(--darkening); + } + } + + span[role="button"].svg-icon { + display: inline-block; + line-height: 1; + /* Align with forkawesome */ + font-size: 14px; + fill: var(--danger); + + &[aria-disabled="true"], &[aria-disabled=""] { + fill: var(--disabled); + } + + svg { + width: 2.5em; + height: 2.5em; + } + } + + p { + text-align: center; + } +} From ebda41346a096c88c41e91c3b06e0ddbe45bc31a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 29 Jan 2025 21:01:49 +0100 Subject: [PATCH 089/274] Simplify permission management. --- .../org/jdrupes/vmoperator/vmaccess/VmAccess.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 eea1eae..c82ccd4 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 @@ -546,26 +546,26 @@ public class VmAccess extends FreeMarkerConlet { .map(ConsoleUser::getName).orElse(null); var roles = WebConsoleUtils.rolesFromSession(session) .stream().map(ConsoleRole::getName).toList(); - Set result = new HashSet<>(); if (model.mode() == ResourceModel.Mode.POOL) { if (pool == null) { pool = appPipeline.fire(new GetPools() .withName(model.name())).get().stream().findFirst() .orElse(null); } - if (pool != null) { - result.addAll(pool.permissionsFor(user, roles)); + if (pool == null) { + return Collections.emptySet(); } + return pool.permissionsFor(user, roles); } if (vmDef == null) { vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name()) .assignedTo(user)).get().stream().map(VmData::definition) .findFirst().orElse(null); } - if (vmDef != null) { - result.addAll(vmDef.permissionsFor(user, roles)); + if (vmDef == null) { + return Collections.emptySet(); } - return result; + return vmDef.permissionsFor(user, roles); } private void updatePreview(ConsoleConnection channel, ResourceModel model, From e447a944dce88e3eb0704549ce0e30dca3fdff09 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 30 Jan 2025 16:55:54 +0100 Subject: [PATCH 090/274] Generate metaData even if only cloudInit is specified. --- .../vmoperator/manager/runnerConfig.ftl.yaml | 6 +----- .../org/jdrupes/vmoperator/manager/Reconciler.java | 13 +++++++------ 2 files changed, 8 insertions(+), 11 deletions(-) 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 214b5b8..8a50a5e 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 @@ -58,11 +58,7 @@ data: # Forward the cloud-init data if provided <#if spec.cloudInit??> cloudInit: - <#if spec.cloudInit.metaData??> - metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData, cr.metadata())) } - <#else> - metaData: {} - + metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData!{}, cr.metadata())) } <#if spec.cloudInit.userData??> userData: ${ toJson(spec.cloudInit.userData) } <#else> 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 3fa2ffe..641247a 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 @@ -29,7 +29,9 @@ import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; import freemarker.template.TemplateHashModel; import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; +import freemarker.template.utility.DeepUnwrap; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; @@ -53,7 +55,6 @@ import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; 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.util.DataPath; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -354,8 +355,9 @@ public class Reconciler extends Component { return ""; } try { - var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH - + "/").resolve(image); + var imageUri + = new URI("file://" + Constants.IMAGE_REPO_PATH + "/") + .resolve(image); if ("file".equals(imageUri.getScheme())) { return imageUri.getPath(); } @@ -374,9 +376,8 @@ public class Reconciler extends Component { public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { @SuppressWarnings("unchecked") - var res = (Map) DataPath - .deepCopy(((AdapterTemplateModel) arguments.get(0)) - .getAdaptedObject(Object.class)); + var res = new HashMap<>((Map) DeepUnwrap + .unwrap((TemplateModel) arguments.get(0))); var metadata = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) .getAdaptedObject(Object.class); From 99c96e44c37f7eb620d809a29772751aacdfb24b Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 30 Jan 2025 22:00:10 +0100 Subject: [PATCH 091/274] Allow access to vmpools. --- deploy/vmop-role.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/vmop-role.yaml b/deploy/vmop-role.yaml index 55447e6..c96fb47 100644 --- a/deploy/vmop-role.yaml +++ b/deploy/vmop-role.yaml @@ -9,6 +9,7 @@ rules: - vmoperator.jdrupes.org resources: - vms + - vmpools verbs: - '*' - apiGroups: From 29dd6aab82f6eb8cf4c47183856f46c3e185bddf Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 30 Jan 2025 22:04:41 +0100 Subject: [PATCH 092/274] Javadoc fixes. --- .../src/org/jdrupes/vmoperator/manager/PoolMonitor.java | 1 - .../src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java | 4 ++-- .../src/org/jdrupes/vmoperator/vmaccess/VmAccess.java | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) 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 0606650..b83c0f5 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 @@ -64,7 +64,6 @@ public class PoolMonitor extends * Instantiates a new VM pool manager. * * @param componentChannel the component channel - * @param channelManager the channel manager */ public PoolMonitor(Channel componentChannel) { super(componentChannel, K8sDynamicModel.class, 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 1c260c7..9683457 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 @@ -111,12 +111,12 @@ public class VmDefUpdater extends Component { /** * Update condition. * - * @param apiClient the api client - * @param from the vM definition + * @param from the VM definition * @param status the current status * @param type the condition type * @param state the new state * @param reason the reason for the change + * @param message the message */ protected void updateCondition(VmDefinitionModel from, JsonObject status, String type, boolean state, String reason, String message) { 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 c82ccd4..0015d6c 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 @@ -685,8 +685,7 @@ public class VmAccess extends FreeMarkerConlet { * On vm pool changed. * * @param event the event - * @param channel the channel - * @throws InterruptedException + * @throws InterruptedException the interrupted exception */ @Handler(namedChannels = "manager") @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") From 150b9f29083f53fd05fde1d8a2c6d4285daa0732 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 30 Jan 2025 22:14:09 +0100 Subject: [PATCH 093/274] Fix spaces. --- dev-example/Readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-example/Readme.md b/dev-example/Readme.md index 516fb7e..3676411 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. From ecd7ba7baf7dcb8643786c9eb24c4e9c2cea49f2 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 30 Jan 2025 22:17:35 +0100 Subject: [PATCH 094/274] Fix trailing space. --- README.md | 2 +- dev-example/Readme.md | 2 +- overview.md | 2 +- webpages/vm-operator/controller.md | 46 +++++++++++++++--------------- webpages/vm-operator/index.md | 12 ++++---- webpages/vm-operator/manager.md | 46 +++++++++++++++--------------- webpages/vm-operator/runner.md | 36 +++++++++++------------ webpages/vm-operator/upgrading.md | 16 +++++------ webpages/vm-operator/user-gui.md | 12 ++++---- webpages/vm-operator/webgui.md | 20 ++++++------- 10 files changed, 97 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 9118f9d..4c989b6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # Run Qemu in Kubernetes Pods The goal of this project is to provide easy to use and flexible components -for running Qemu based VMs in Kubernetes pods. +for running Qemu based VMs in Kubernetes pods. See the [project's home page](https://jdrupes.org/vm-operator/) for details. diff --git a/dev-example/Readme.md b/dev-example/Readme.md index 3676411..ba381e1 100644 --- a/dev-example/Readme.md +++ b/dev-example/Readme.md @@ -1,7 +1,7 @@ # Example setup for development The CRD must be deployed independently. Apart from that, the -`kustomize.yaml` +`kustomize.yaml` * creates a small cdrom image repository and diff --git a/overview.md b/overview.md index 447a33c..30d595e 100644 --- a/overview.md +++ b/overview.md @@ -3,7 +3,7 @@ A Kubernetes operator for running VMs as pods. VM-Operator =========== -The VM-operator enables you to easily run Qemu based VMs as pods +The VM-operator enables you to easily run Qemu based VMs as pods in Kubernetes. It is built on the [JGrapes](https://mnlipp.github.io/jgrapes/) event driven framework. diff --git a/webpages/vm-operator/controller.md b/webpages/vm-operator/controller.md index 17ca790..cc6a274 100644 --- a/webpages/vm-operator/controller.md +++ b/webpages/vm-operator/controller.md @@ -5,12 +5,12 @@ layout: vm-operator # The Controller -The controller component (which is part of the manager) monitors -custom resources of kind `VirtualMachine`. It creates or modifies +The controller component (which is part of the manager) monitors +custom resources of kind `VirtualMachine`. It creates or modifies other resources in the cluster as required to get the VM defined -by the CR up and running. +by the CR up and running. -Here is the sample definition of a VM from the +Here is the sample definition of a VM from the ["local-path" example](https://github.com/mnlipp/VM-Operator/tree/main/example/local-path): ```yaml @@ -28,10 +28,10 @@ spec: currentCpus: 2 maximumRam: 8Gi currentRam: 4Gi - + networks: - user: {} - + disks: - volumeClaimTemplate: metadata: @@ -58,9 +58,9 @@ spec: # generateSecret: false ``` -## Pod management +## Pod management -The central resource created by the controller is a +The central resource created by the controller is a [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) with the same name as the VM (`metadata.name`). The pod is created only if `spec.vm.state` is "Running" (default is "Stopped" which deletes the @@ -72,7 +72,7 @@ and thus the VM is automatically restarted. If set to `true`, the VM's state is set to "Stopped" when the VM terminates and the pod is deleted. -[^oldSts]: Before version 3.4, the operator created a +[^oldSts]: Before version 3.4, the operator created a [stateful set](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) that in turn created the pod and the PVCs (see below). @@ -113,7 +113,7 @@ as shown in this example: ``` The disk will be available as "/dev/*name*-disk" in the VM, -using the string from `.volumeClaimTemplate.metadata.name` as *name*. +using the string from `.volumeClaimTemplate.metadata.name` as *name*. If no name is defined in the metadata, then "/dev/disk-*n*" is used instead, with *n* being the index of the volume claim template in the list of disks. @@ -140,28 +140,28 @@ the PVCs by label in a delete command. ## Choosing an image for the runner -The image used for the runner can be configured with +The image used for the runner can be configured with [`spec.image`](https://github.com/mnlipp/VM-Operator/blob/7e094e720b7b59a5e50f4a9a4ad29a6000ec76e6/deploy/crds/vms-crd.yaml#L19). This is a mapping with either a single key `source` or a detailed configuration using the keys `repository`, `path` etc. -Currently two runner images are maintained. One that is based on -Arch Linux (`ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-arch`) and a +Currently two runner images are maintained. One that is based on +Arch Linux (`ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-arch`) and a second one based on Alpine (`ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine`). -Starting with release 1.0, all versions of runner images and managers +Starting with release 1.0, all versions of runner images and managers that have the same major release number are guaranteed to be compatible. ## Generating cloud-init data -*Since: 2.2.0* +*Since: 2.2.0* The optional object `.spec.cloudInit` with sub-objects `.cloudInit.metaData`, -`.cloudInit.userData` and `.cloudInit.networkConfig` can be used to provide +`.cloudInit.userData` and `.cloudInit.networkConfig` can be used to provide data for [cloud-init](https://cloudinit.readthedocs.io/en/latest/index.html). The data from the CRD will be made available to the VM by the runner -as a vfat formatted disk (see the description of +as a vfat formatted disk (see the description of [NoCloud](https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html)). If `.metaData.instance-id` is not defined, the controller automatically @@ -180,9 +180,9 @@ generated automatically by the runner.) *Since: 2.3.0* You can define a display password using a Kubernetes secret. -When you start a VM, the controller checks if there is a secret -with labels "app.kubernetes.io/name: vm-runner, -app.kubernetes.io/component: display-secret, +When you start a VM, the controller checks if there is a secret +with labels "app.kubernetes.io/name: vm-runner, +app.kubernetes.io/component: display-secret, app.kubernetes.io/instance: *vmname*" in the namespace of the VM definition. The name of the secret can be chosen freely. @@ -204,13 +204,13 @@ data: ``` If such a secret for the VM is found, the VM is configured to use -the display password specified. The display password in the secret +the display password specified. The display password in the secret can be updated while the VM runs[^delay]. Activating/deactivating the display password while a VM runs is not supported by Qemu and therefore requires stopping the VM, adding/removing the secret and restarting the VM. -[^delay]: Be aware of the possible delay, see e.g. +[^delay]: Be aware of the possible delay, see e.g. [here](https://web.archive.org/web/20240223073838/https://ahmet.im/blog/kubernetes-secret-volumes-delay/). *Since: 3.0.0* @@ -221,7 +221,7 @@ values are those defined by qemu (`+n` seconds from now, `n` Unix timestamp, `never` and `now`). Unless `spec.vm.display.spice.generateSecret` is set to `false` in the VM -definition (CRD), the controller creates a secret for the display +definition (CRD), the controller creates a secret for the display password automatically if none is found. The secret is created with a random password that expires immediately, which makes the display effectively inaccessible until the secret is modified. diff --git a/webpages/vm-operator/index.md b/webpages/vm-operator/index.md index 9859fe2..baf8e20 100644 --- a/webpages/vm-operator/index.md +++ b/webpages/vm-operator/index.md @@ -15,9 +15,9 @@ The image used for the VM pods combines Qemu and a control program for starting and managing the Qemu process. This application is called "[the runner](runner.html)". -While you can deploy a runner manually (or with the help of some +While you can deploy a runner manually (or with the help of some helm templates), the preferred way is to deploy "[the manager](manager.html)" -application which acts as a Kubernetes operator for runners +application which acts as a Kubernetes operator for runners and thus the VMs. If you just want to try out things, you can skip the remainder of this @@ -25,11 +25,11 @@ page and proceed to "[the manager](manager.html)". ## Motivation The project was triggered by a remark in the discussion about RedHat -[dropping SPICE support](https://bugzilla.redhat.com/show_bug.cgi?id=2030592) +[dropping SPICE support](https://bugzilla.redhat.com/show_bug.cgi?id=2030592) from the RHEL packages. Which means that you have to run Qemu in a container on RHEL and derivatives if you want to continue using Spice. So KubeVirt comes to mind. But -[one comment](https://bugzilla.redhat.com/show_bug.cgi?id=2030592#c4) +[one comment](https://bugzilla.redhat.com/show_bug.cgi?id=2030592#c4) mentioned that the [KubeVirt](https://kubevirt.io/) project isn't interested in supporting SPICE either. @@ -44,7 +44,7 @@ much as possible. ## VMs and Pods VMs are not the typical workload managed by Kubernetes. You can neither -have replicas nor can the containers simply be restarted without a major +have replicas nor can the containers simply be restarted without a major impact on the "application". So there are many features for managing pods that we cannot make use of. Qemu in its container can only be deployed as a pod or using a stateful set with replica 1, which is rather @@ -57,6 +57,6 @@ A second look, however, reveals that Kubernetes has more to offer. * Its managing features *are* useful for running the component that manages the pods with the VMs. -And if you use Kubernetes anyway, well then the VMs within Kubernetes +And if you use Kubernetes anyway, well then the VMs within Kubernetes provide you with a unified view of all (or most of) your workloads, which simplifies the maintenance of your platform. diff --git a/webpages/vm-operator/manager.md b/webpages/vm-operator/manager.md index 8e6ebb4..c1965f1 100644 --- a/webpages/vm-operator/manager.md +++ b/webpages/vm-operator/manager.md @@ -7,13 +7,13 @@ layout: vm-operator The Manager is the program that provides the controller from the [operator pattern](https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper_v1-0.md#operator-components-in-kubernetes) -together with a web user interface. It should be run in a container in the cluster. +together with a web user interface. It should be run in a container in the cluster. ## Installation A manager instance manages the VMs in its own namespace. The only common (and therefore cluster scoped) resource used by all instances -is the CRD. It is available +is the CRD. It is available [here](https://github.com/mnlipp/VM-Operator/raw/main/deploy/crds/vms-crd.yaml) and must be created first. @@ -25,24 +25,24 @@ The example above uses the CRD from the main branch. This is okay if you apply it once. If you want to preserve the link for automatic upgrades, you should use a link that points to one of the release branches. -The next step is to create a namespace for the manager and the VMs, e.g. +The next step is to create a namespace for the manager and the VMs, e.g. `vmop-demo`. ```sh kubectl create namespace vmop-demo ``` -Finally you have to create an account, the role, the binding etc. The -default files for creating these resources using the default namespace -can be found in the +Finally you have to create an account, the role, the binding etc. The +default files for creating these resources using the default namespace +can be found in the [deploy](https://github.com/mnlipp/VM-Operator/tree/main/deploy) -directory. I recommend to use -[kustomize](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/) to create your own configuration. +directory. I recommend to use +[kustomize](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/) to create your own configuration. ## Initial Configuration Use one of the `kustomize.yaml` files from the -[example](https://github.com/mnlipp/VM-Operator/tree/main/example) directory +[example](https://github.com/mnlipp/VM-Operator/tree/main/example) directory as a starting point. The directory contains two examples. Here's the file from subdirectory `local-path`: @@ -91,9 +91,9 @@ patches: storageClassName: local-path ``` -The sample file adds a namespace (`vmop-demo`) to all resource +The sample file adds a namespace (`vmop-demo`) to all resource definitions and patches the PVC `vmop-image-repository`. This is a volume -that is mounted into all pods that run a VM. The volume is intended +that is mounted into all pods that run a VM. The volume is intended to be used as a common repository for CDROM images. The PVC must exist and it must be bound before any pods can run. @@ -101,13 +101,13 @@ The second patch affects the small volume that is created for each runner and contains the VM's configuration data such as the EFI vars. The manager's default configuration causes the PVC for this volume to be created with no storage class (which causes the default storage -class to be used). The patch provides a new configuration file for -the manager that makes the reconciler use local-path as storage -class for this PVC. Details about the manager configuration can be +class to be used). The patch provides a new configuration file for +the manager that makes the reconciler use local-path as storage +class for this PVC. Details about the manager configuration can be found in the next section. -Note that you need none of the patches if you are fine with using your -cluster's default storage class and this class supports ReadOnlyMany as +Note that you need none of the patches if you are fine with using your +cluster's default storage class and this class supports ReadOnlyMany as access mode. Check that the pod with the manager is running: @@ -121,30 +121,30 @@ for creating your first VM. ## Configuration Details -The [config map](https://github.com/mnlipp/VM-Operator/blob/main/deploy/vmop-config-map.yaml) -for the manager may provide a configuration file (`config.yaml`) and +The [config map](https://github.com/mnlipp/VM-Operator/blob/main/deploy/vmop-config-map.yaml) +for the manager may provide a configuration file (`config.yaml`) and a file with logging properties (`logging.properties`). Both files are mounted into the container that runs the manager and are evaluated by the manager on startup. If no files are provided, the manager uses built-in defaults. The configuration file for the Manager follows the conventions of the [JGrapes](https://jgrapes.org/) component framework. -The keys that start with a slash select the component within the +The keys that start with a slash select the component within the application's component hierarchy. The mapping associated with the selected component configures this component's properties. The available configuration options for the components can be found -in their respective JavaDocs (e.g. +in their respective JavaDocs (e.g. [here](latest-release/javadoc/org/jdrupes/vmoperator/manager/Reconciler.html) for the Reconciler). ## Development Configuration The [dev-example](https://github.com/mnlipp/VM-Operator/tree/main/dev-example) -directory contains a `kustomize.yaml` that uses the development namespace +directory contains a `kustomize.yaml` that uses the development namespace `vmop-dev` and creates a deployment for the manager with 0 replicas. -This environment can be used for running the manager in the IDE. As the +This environment can be used for running the manager in the IDE. As the namespace to manage cannot be detected from the environment, you must use - `-c ../dev-example/config.yaml` as argument when starting the manager. This + `-c ../dev-example/config.yaml` as argument when starting the manager. This configures it to use the namespace `vmop-dev`. diff --git a/webpages/vm-operator/runner.md b/webpages/vm-operator/runner.md index 319a5dc..c72793d 100644 --- a/webpages/vm-operator/runner.md +++ b/webpages/vm-operator/runner.md @@ -5,9 +5,9 @@ layout: vm-operator # The Runner -For most use cases, Qemu needs to be started and controlled by another -program that manages the Qemu process. This program is called the -runner in this context. +For most use cases, Qemu needs to be started and controlled by another +program that manages the Qemu process. This program is called the +runner in this context. The most prominent reason for this second program is that it allows a VM to be shutdown cleanly in response to a TERM signal. Qemu handles @@ -26,38 +26,38 @@ CPUs and the memory. The runner takes care of all these issues. Although it is intended to run in a container (which runs in a Kubernetes pod) it does not require a container. You can start and use it as an ordinary program on any -system, provided that you have the required commands (qemu, swtpm) +system, provided that you have the required commands (qemu, swtpm) installed. ## Stand-alone Configuration -Upon startup, the runner reads its main configuration file +Upon startup, the runner reads its main configuration file which defaults to `/etc/opt/vmrunner/config.yaml` and may be changed using the `-c` (or `--config`) command line option. A sample configuration file with annotated options can be found [here](https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml). -As the runner implementation uses the -[JGrapes](https://jgrapes.org/) framework, the file -follows the framework's +As the runner implementation uses the +[JGrapes](https://jgrapes.org/) framework, the file +follows the framework's [conventions](https://jgrapes.org/latest-release/javadoc/org/jgrapes/util/YamlConfigurationStore.html). The top level "`/Runner`" selects the component to be configured. Nested within is the information to be applied to the component. The main entries in the configuration file are the "template" and -the "vm" information. The runner processes the +the "vm" information. The runner processes the [freemarker template](https://freemarker.apache.org/), using the -"vm" information to derive the qemu command. The idea is that +"vm" information to derive the qemu command. The idea is that the "vm" section provides high level information such as the boot mode, the number of CPUs, the RAM size and the disks. The template defines a particular VM type, i.e. it contains the "nasty details" that do not need to be modified for some given set of VM instances. -The templates provided with the runner can be found -[here](https://github.com/mnlipp/VM-Operator/tree/main/org.jdrupes.vmoperator.runner.qemu/templates). When details +The templates provided with the runner can be found +[here](https://github.com/mnlipp/VM-Operator/tree/main/org.jdrupes.vmoperator.runner.qemu/templates). When details of the VM configuration need modification, a new VM type -(i.e. a new template) has to be defined. Authoring a new -template requires some knowledge about the +(i.e. a new template) has to be defined. Authoring a new +template requires some knowledge about the [qemu invocation](https://www.qemu.org/docs/master/system/invocation.html). Despite many "warnings" that you find in the web, configuring the invocation arguments of qemu is only a bit (but not much) more @@ -72,13 +72,13 @@ provided by a If additional templates are required, some ReadOnlyMany PV should be mounted in `/opt/vmrunner/templates`. The PV should contain copies -of the standard templates as well as the additional templates. Of course, +of the standard templates as well as the additional templates. Of course, a ConfigMap can be used for this purpose again. Networking options are rather limited. The assumption is that in general the VM wants full network connectivity. To achieve this, the pod must run with host networking and the host's networking must provide a -bridge that the VM can attach to. The only currently supported +bridge that the VM can attach to. The only currently supported alternative is the less performant "[user networking](https://wiki.qemu.org/Documentation/Networking#User_Networking_(SLIRP))", which may be used in a stand-alone development configuration. @@ -87,7 +87,7 @@ which may be used in a stand-alone development configuration. The runner supports adaption to changes of the RAM size (using the balloon device) and to changes of the number of CPUs. Note that -in order to get new CPUs online on Linux guests, you need a +in order to get new CPUs online on Linux guests, you need a [udev rule](https://docs.kernel.org/core-api/cpu_hotplug.html#user-space-notification) which is not installed by default[^simplest]. The runner also changes the images loaded in CDROM drives. If the @@ -103,6 +103,6 @@ Finally, `powerdownTimeout` can be changed while the qemu process runs. ## Testing with Helm -There is a +There is a [Helm Chart](https://github.com/mnlipp/VM-Operator/tree/main/org.jdrupes.vmoperator.runner.qemu/helm-test) for testing the runner. diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index a794ab6..77cacad 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -13,7 +13,7 @@ The VmViewer conlet has been renamed to VmAccess. This affects the is still accepted for backward compatibility, but should be updated. The change of name also causes conlets added to the overview page by -users to "disappear" from the GUI. They have to be re-added. +users to "disappear" from the GUI. They have to be re-added. The latter behavior also applies to the VmConlet conlet which has been renamed to VmMgmt. @@ -25,14 +25,14 @@ with replica set to 1 to (indirectly) start the pod with the VM. Rather it creates the pod directly. This implies that the PVCs must also be created by the VM-Operator, which needs additional permissions to do so (update of `deploy/vmop-role.yaml). As it would be ridiculous to keep the naming scheme -used by the stateful set when generating PVCs, the VM-Operator uses a +used by the stateful set when generating PVCs, the VM-Operator uses a [different pattern](controller.html#defining-disks) for creating new PVCs. The change is backward compatible: * Running pods created by a stateful set are left alone until stopped. Only then will the stateful set be removed. - + * The VM-Operator looks for existing PVCs generated by a stateful set in the pre 3.4 versions (naming pattern "*name*-disk-*vmName*-0") and reuses them. Only new PVCs are generated using the new pattern. @@ -40,22 +40,22 @@ The change is backward compatible: ## To version 3.0.0 All configuration files are backward compatible to version 2.3.0. -Note that in order to make use of the new viewer component, +Note that in order to make use of the new viewer component, [permissions](https://mnlipp.github.io/VM-Operator/user-gui.html#control-access-to-vms) -must be configured in the CR definition. Also note that +must be configured in the CR definition. Also note that [display secrets](https://mnlipp.github.io/VM-Operator/user-gui.html#securing-access) are automatically created unless explicitly disabled. ## To version 2.3.0 Starting with version 2.3.0, the web GUI uses a login conlet that -supports OIDC providers. This effects the configuration of the +supports OIDC providers. This effects the configuration of the web GUI components. -## To version 2.2.0 +## To version 2.2.0 Version 2.2.0 sets the stateful set's `.spec.updateStrategy.type` to -"OnDelete". This fails for no apparent reason if a definition of +"OnDelete". This fails for no apparent reason if a definition of the stateful set with the default value "RollingUpdate" already exists. In order to fix this, either the stateful set or the complete VM definition must be deleted and the manager must be restarted. diff --git a/webpages/vm-operator/user-gui.md b/webpages/vm-operator/user-gui.md index 416e243..0439db2 100644 --- a/webpages/vm-operator/user-gui.md +++ b/webpages/vm-operator/user-gui.md @@ -19,20 +19,20 @@ requirement are unexpectedly complex. ## Control access to VMs First of all, we have to define which VMs a user can access. This -is done using the optional property `spec.permissions` of the +is done using the optional property `spec.permissions` of the VM definition (CRD). ```yaml spec: permissions: - role: admin - may: + may: - "*" - user: test may: - start - stop - - accessConsole + - accessConsole ``` Permissions can be granted to individual users or to roles. There @@ -104,7 +104,7 @@ spec: ``` The value of `server` is used as value for key "host" in the -connection file, thus overriding the default value. The +connection file, thus overriding the default value. The value of `proxyUrl` is used as value for key "proxy". ## Securing access @@ -123,8 +123,8 @@ in the future or with value "never" or doesn't define a `password-expiry` at all. The automatically generated password is the base64 encoded value -of 16 (strong) random bytes (128 random bits). It is valid for -10 seconds only. This may be challenging on a slower computer +of 16 (strong) random bytes (128 random bits). It is valid for +10 seconds only. This may be challenging on a slower computer or if users may not enable automatic open for connection files in the browser. The validity can therefore be adjusted in the configuration. diff --git a/webpages/vm-operator/webgui.md b/webpages/vm-operator/webgui.md index 1dbb20f..2d6e428 100644 --- a/webpages/vm-operator/webgui.md +++ b/webpages/vm-operator/webgui.md @@ -11,7 +11,7 @@ implemented using components from the project. Configuration of the GUI therefore follows the conventions of that framework. -The structure of the configuration information should be easy to +The structure of the configuration information should be easy to understand from the examples provided. In general, configuration values are applied to the individual components that make up an application. The hierarchy of the components is reflected in the configuration @@ -22,9 +22,9 @@ for information about the complete component structure.) ## Network access -By default, the service is made available at port 8080 of the manager +By default, the service is made available at port 8080 of the manager pod. Of course, a kubernetes service and an ingress configuration must -be added as required by the environment. (See the +be added as required by the environment. (See the [definition](https://github.com/mnlipp/VM-Operator/blob/main/deploy/vmop-service.yaml) from the [sample deployment](https://github.com/mnlipp/VM-Operator/tree/main/deploy)). @@ -49,7 +49,7 @@ and role management. # configure an OIDC provider for user management and # authorization. See the text for details. oidcProviders: {} - + # Support for "local" users is provided as a fallback mechanism. # Note that up to Version 2.2.x "users" was an object with user names # as its properties. Starting with 2.3.0 it is a list as shown. @@ -60,11 +60,11 @@ and role management. - name: test fullName: Test Account password: "Generate hash with bcrypt" - + # Required for using OIDC, see the text for details. "/OidcClient": redirectUri: https://my.server.here/oauth/callback" - + # May be used for assigning roles to both local users and users from # the OIDC provider. Not needed if roles are managed by the OIDC provider. "/RoleConfigurator": @@ -79,7 +79,7 @@ and role management. "*": - other replace: false - + # Manages the permissions for the roles. "/RoleConletFilter": conletTypesByRole: @@ -98,8 +98,8 @@ and role management. ``` How local users can be configured should be obvious from the example. -The configuration of OIDC providers for user authentication (and -optionally for role assignment) is explained in the documentation of the +The configuration of OIDC providers for user authentication (and +optionally for role assignment) is explained in the documentation of the [login conlet](https://jgrapes.org/javadoc-webconsole/org/jgrapes/webconlet/oidclogin/LoginConlet.html). Details about the `RoleConfigurator` and `RoleConletFilter` can also be found in the documentation of the @@ -113,5 +113,5 @@ all users to use the login conlet to log out. ## Views -The configuration of the components that provide the manager and +The configuration of the components that provide the manager and users views is explained in the respective sections. From 4fc0d6fc637cedf06895450ccc31efd0bc0403d3 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 31 Jan 2025 11:15:43 +0100 Subject: [PATCH 095/274] Support both string and boolean for deleteConnectionFile. --- .../src/org/jdrupes/vmoperator/vmaccess/VmAccess.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 0015d6c..bd9a802 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 @@ -198,9 +198,8 @@ public class VmAccess extends FreeMarkerConlet { // Delete connection file deleteConnectionFile = Optional.ofNullable(c.get("deleteConnectionFile")) - .filter(v -> v instanceof String) - .map(v -> (String) v) - .map(Boolean::parseBoolean).orElse(true); + .map(Object::toString).map(Boolean::parseBoolean) + .orElse(true); // Users or roles for which previews should be synchronized syncUsers = ((List>) c.getOrDefault( From 6a1273e7013b8d5fdaef7e65fd6d86092b4df292 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 31 Jan 2025 12:24:21 +0100 Subject: [PATCH 096/274] Prevent concurrent modification exception. --- .../src/org/jdrupes/vmoperator/common/VmPool.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 8bf6dee..cfc29ef 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 @@ -140,7 +140,8 @@ public class VmPool { * @return the string */ @Override - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidSynchronizedStatement" }) public String toString() { StringBuilder builder = new StringBuilder(50); builder.append("VmPool [name=").append(name).append(", permissions=") @@ -148,8 +149,11 @@ public class VmPool { if (vms.size() <= 3) { builder.append(vms); } else { - builder.append('[').append(vms.stream().limit(3).map(s -> s + ",") - .collect(Collectors.joining())).append("...]"); + synchronized (vms) { + builder.append('[').append(vms.stream().limit(3) + .map(s -> s + ",").collect(Collectors.joining())) + .append("...]"); + } } builder.append(']'); return builder.toString(); From 23bc41d68d9442ae642c1e75f68599be1f0cc189 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 31 Jan 2025 15:26:25 +0100 Subject: [PATCH 097/274] Prefer running VMs for new assignments. --- .../jdrupes/vmoperator/manager/VmMonitor.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 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 2060109..c2d717a 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 @@ -293,8 +293,9 @@ public class VmMonitor extends var pool = vmPool; assignedVm = channelManager.channels().stream() .filter(c -> isAssignable(pool, c.vmDefinition())) - .sorted(Comparator.comparing(c -> c.vmDefinition() - .assignmentLastUsed().orElse(Instant.ofEpochSecond(0)))) + .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() + .assignmentLastUsed().orElse(Instant.ofEpochSecond(0))) + .thenComparing(preferRunning)) .findFirst(); // None found @@ -322,6 +323,19 @@ public class VmMonitor extends } } + 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; + } + }; + @SuppressWarnings("PMD.SimplifyBooleanReturns") private boolean isAssignable(VmPool pool, VmDefinition vmDef) { // Check if the VM is in the pool From b159bae5dabc696303de543dd09ce7e6f23a5760 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 31 Jan 2025 15:26:42 +0100 Subject: [PATCH 098/274] Allow users to start assigned VMs. --- dev-example/test-pool.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml index 82b9131..a72a623 100644 --- a/dev-example/test-pool.yaml +++ b/dev-example/test-pool.yaml @@ -9,6 +9,8 @@ spec: - user: admin may: - accessConsole + - start - role: user may: - accessConsole + - start From b5ae22a8ead79e376858e91059107d14f1a3b060 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 31 Jan 2025 22:09:17 +0100 Subject: [PATCH 099/274] Avoid duplicate assignment. --- .../jdrupes/vmoperator/manager/VmMonitor.java | 106 +++++++++--------- 1 file changed, 51 insertions(+), 55 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 c2d717a..84edafc 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 @@ -264,63 +264,59 @@ public class VmMonitor extends @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") public void onAssignVm(AssignVm event) throws ApiException, InterruptedException { - VmPool vmPool = null; - while (true) { - // Search for existing assignment. - var assignedVm = channelManager.channels().stream() - .filter(c -> c.vmDefinition().assignedFrom() - .map(p -> p.equals(event.fromPool())).orElse(false)) - .filter(c -> c.vmDefinition().assignedTo() - .map(u -> u.equals(event.toUser())).orElse(false)) - .findFirst(); - if (assignedVm.isPresent()) { - var vmDef = assignedVm.get().vmDefinition(); - event.setResult(new VmData(vmDef, assignedVm.get())); - return; - } - - // Get the pool definition for retention time calculations - if (vmPool == null) { - vmPool = newEventPipeline().fire(new GetPools() - .withName(event.fromPool())).get().stream().findFirst() - .orElse(null); - if (vmPool == null) { - return; - } - } - - // Find available VM. - var pool = vmPool; - assignedVm = channelManager.channels().stream() - .filter(c -> isAssignable(pool, c.vmDefinition())) - .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() - .assignmentLastUsed().orElse(Instant.ofEpochSecond(0))) - .thenComparing(preferRunning)) - .findFirst(); - - // None found - if (assignedVm.isEmpty()) { - return; - } - - // Assign to user + // Search for existing assignment. + var assignedVm = channelManager.channels().stream() + .filter(c -> c.vmDefinition().assignedFrom() + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignedTo() + .map(u -> u.equals(event.toUser())).orElse(false)) + .findFirst(); + if (assignedVm.isPresent()) { var vmDef = assignedVm.get().vmDefinition(); - var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), - vmDef.namespace(), vmDef.name()); - vmStub.updateStatus(from -> { - JsonObject status = from.status(); - var assignment = GsonPtr.to(status).to("assignment"); - assignment.set("pool", event.fromPool()); - assignment.set("user", event.toUser()); - assignment.set("lastUsed", Instant.now().toString()); - return status; - }); - - // Make sure that a newly assigned VM is running. - fire(new ModifyVm(vmDef.name(), "state", "Running", - assignedVm.get())); + event.setResult(new VmData(vmDef, assignedVm.get())); + return; } + + // Get the pool definition for retention time calculations + VmPool vmPool = newEventPipeline().fire(new GetPools() + .withName(event.fromPool())).get().stream().findFirst() + .orElse(null); + if (vmPool == null) { + return; + } + + // Find available VM. + var pool = vmPool; + assignedVm = channelManager.channels().stream() + .filter(c -> isAssignable(pool, c.vmDefinition())) + .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() + .assignmentLastUsed().orElse(Instant.ofEpochSecond(0))) + .thenComparing(preferRunning)) + .findFirst(); + + // None found + if (assignedVm.isEmpty()) { + return; + } + + // Assign to user + var vmDef = assignedVm.get().vmDefinition(); + var vmStub = VmDefinitionStub.get(client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + var assignment = GsonPtr.to(status).to("assignment"); + assignment.set("pool", event.fromPool()); + assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); + return status; + }); + event.setResult(new VmData(vmDef, assignedVm.get())); + + // Make sure that a newly assigned VM is running. + fire(new ModifyVm(vmDef.name(), "state", "Running", + assignedVm.get())); } private static Comparator preferRunning From 54747b25e8e7fbbe6064989a44c93d11df5386e0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Feb 2025 18:51:19 +0100 Subject: [PATCH 100/274] Use VmChannel's event pipeline to update assignment. --- .../org/jdrupes/vmoperator/common/VmPool.java | 103 ++++++++++++------ .../manager/events/UpdateAssignment.java | 60 ++++++++++ .../vmoperator/manager/Controller.java | 30 +++++ .../jdrupes/vmoperator/manager/VmMonitor.java | 60 ++-------- 4 files changed, 169 insertions(+), 84 deletions(-) create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java 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 cfc29ef..e0817d5 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 @@ -134,6 +134,78 @@ public class VmPool { return vms; } + /** + * Collect all permissions for the given user with the given roles. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + return permissions.stream() + .filter(g -> DataPath.get(g, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(g, "role").map(roles::contains).orElse(false)) + .map(g -> DataPath.> get(g, "may") + .orElse(Collections.emptySet()).stream()) + .flatMap(Function.identity()).collect(Collectors.toSet()); + } + + /** + * Checks if the given VM belongs to the pool and is not in use. + * + * @param vmDef the vm def + * @return true, if is assignable + */ + @SuppressWarnings("PMD.SimplifyBooleanReturns") + public boolean isAssignable(VmDefinition vmDef) { + // Check if the VM is in the pool + if (!vmDef.pools().contains(name)) { + return false; + } + + // Check if the VM is not in use + if (vmDef.consoleConnected()) { + return false; + } + + // If not assigned, it's usable + if (vmDef.assignedTo().isEmpty()) { + return true; + } + + // Check if it is to be retained + if (vmDef.assignmentLastUsed() + .map(this::retainUntil) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + + // Additional check in case lastUsed has not been updated + // by PoolMonitor#onVmDefChanged() yet ("race condition") + if (vmDef.condition("ConsoleConnected") + .map(cc -> cc.getLastTransitionTime().toInstant()) + .map(this::retainUntil) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + return true; + } + + /** + * Return the instant until which an assignment should be retained. + * + * @param lastUsed the last used + * @return the instant + */ + public Instant retainUntil(Instant lastUsed) { + if (retention.startsWith("P")) { + return lastUsed.plus(Duration.parse(retention)); + } + return Instant.parse(retention); + } + /** * To string. * @@ -158,35 +230,4 @@ public class VmPool { builder.append(']'); return builder.toString(); } - - /** - * Collect all permissions for the given user with the given roles. - * - * @param user the user - * @param roles the roles - * @return the sets the - */ - public Set permissionsFor(String user, - Collection roles) { - return permissions.stream() - .filter(g -> DataPath.get(g, "user").map(u -> u.equals(user)) - .orElse(false) - || DataPath.get(g, "role").map(roles::contains).orElse(false)) - .map(g -> DataPath.> get(g, "may") - .orElse(Collections.emptySet()).stream()) - .flatMap(Function.identity()).collect(Collectors.toSet()); - } - - /** - * Return the instant until which an assignment should be retained. - * - * @param lastUsed the last used - * @return the instant - */ - public Instant retainUntil(Instant lastUsed) { - if (retention.startsWith("P")) { - return lastUsed.plus(Duration.parse(retention)); - } - return Instant.parse(retention); - } } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java new file mode 100644 index 0000000..676af3d --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 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 org.jgrapes.core.Event; + +/** + * Note the assignment to a user in the VM status. + */ +@SuppressWarnings("PMD.DataClass") +public class UpdateAssignment extends Event { + + private final String usedPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param usedPool the used pool + * @param toUser the to user + */ + public UpdateAssignment(String usedPool, String toUser) { + this.usedPool = usedPool; + this.toUser = toUser; + } + + /** + * Gets the pool to assign from. + * + * @return the pool + */ + public String usedPool() { + return usedPool; + } + + /** + * Gets the user to assign to. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} 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 1ef17f7..32b3ac4 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 @@ -18,6 +18,7 @@ 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; @@ -25,16 +26,20 @@ import io.kubernetes.client.openapi.Configuration; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; +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.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; @@ -204,4 +209,29 @@ public class Controller extends Component { () -> "Cannot patch definition for Vm " + vmStub.name()); } } + + /** + * Update the assignment information in the status of the VM CR. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) + throws ApiException { + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + var assignment = GsonPtr.to(status).to("assignment"); + assignment.set("pool", event.usedPool()); + assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); + return status; + }); + event.setResult(true); + } } 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 84edafc..f0368a7 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,8 +18,6 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonObject; -import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Watch; @@ -55,9 +53,9 @@ 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.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; @@ -277,7 +275,7 @@ public class VmMonitor extends return; } - // Get the pool definition for retention time calculations + // Get the pool definition assignability check VmPool vmPool = newEventPipeline().fire(new GetPools() .withName(event.fromPool())).get().stream().findFirst() .orElse(null); @@ -286,9 +284,8 @@ public class VmMonitor extends } // Find available VM. - var pool = vmPool; assignedVm = channelManager.channels().stream() - .filter(c -> isAssignable(pool, c.vmDefinition())) + .filter(c -> vmPool.isAssignable(c.vmDefinition())) .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() .assignmentLastUsed().orElse(Instant.ofEpochSecond(0))) .thenComparing(preferRunning)) @@ -300,23 +297,14 @@ public class VmMonitor extends } // Assign to user + assignedVm.get().pipeline().fire(new UpdateAssignment(vmPool.name(), + event.toUser()), assignedVm.get()).get(); var vmDef = assignedVm.get().vmDefinition(); - var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), - vmDef.namespace(), vmDef.name()); - vmStub.updateStatus(from -> { - JsonObject status = from.status(); - var assignment = GsonPtr.to(status).to("assignment"); - assignment.set("pool", event.fromPool()); - assignment.set("user", event.toUser()); - assignment.set("lastUsed", Instant.now().toString()); - return status; - }); event.setResult(new VmData(vmDef, assignedVm.get())); // Make sure that a newly assigned VM is running. - fire(new ModifyVm(vmDef.name(), "state", "Running", - assignedVm.get())); + assignedVm.get().pipeline().fire(new ModifyVm(vmDef.name(), + "state", "Running", assignedVm.get())); } private static Comparator preferRunning @@ -332,38 +320,4 @@ public class VmMonitor extends } }; - @SuppressWarnings("PMD.SimplifyBooleanReturns") - private boolean isAssignable(VmPool pool, VmDefinition vmDef) { - // Check if the VM is in the pool - if (!vmDef.pools().contains(pool.name())) { - return false; - } - - // Check if the VM is not in use - if (vmDef.consoleConnected()) { - return false; - } - - // If not assigned, it's usable - if (vmDef.assignedTo().isEmpty()) { - return true; - } - - // Check if it is to be retained - if (vmDef.assignmentLastUsed() - .map(lu -> pool.retainUntil(lu)) - .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { - return false; - } - - // Additional check in case lastUsed has not been updated - // by PoolMonitor#onVmDefChanged() yet ("race condition") - if (vmDef.condition("ConsoleConnected") - .map(cc -> cc.getLastTransitionTime().toInstant()) - .map(t -> pool.retainUntil(t)) - .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { - return false; - } - return true; - } } From 85a4521299f6ae693546927a1af82a44c3a6cee1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Feb 2025 22:06:30 +0100 Subject: [PATCH 101/274] Combine VmDefinitionModel and VmDefinition. --- .../vmoperator/common/K8sDynamicModel.java | 2 +- .../vmoperator/common/VmDefinition.java | 201 +++++++----------- .../vmoperator/common/VmDefinitionModel.java | 39 ---- .../vmoperator/common/VmDefinitionModels.java | 4 +- .../vmoperator/common/VmDefinitionStub.java | 16 +- .../vmoperator/manager/runnerConfig.ftl.yaml | 2 +- .../manager/runnerLoadBalancer.ftl.yaml | 2 +- .../vmoperator/manager/runnerPod.ftl.yaml | 2 +- .../vmoperator/manager/Controller.java | 2 +- .../manager/DisplaySecretMonitor.java | 2 +- .../vmoperator/manager/PoolMonitor.java | 3 +- .../jdrupes/vmoperator/manager/VmMonitor.java | 24 +-- .../runner/qemu/ConsoleTracker.java | 4 +- .../vmoperator/runner/qemu/StatusUpdater.java | 18 +- .../vmoperator/runner/qemu/VmDefUpdater.java | 4 +- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 2 +- 16 files changed, 121 insertions(+), 206 deletions(-) delete mode 100644 org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java index 6a4410f..dd2bdd5 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java @@ -102,7 +102,7 @@ public class K8sDynamicModel implements KubernetesObject { * * @return the JSON object describing the status */ - public JsonObject status() { + public JsonObject statusJson() { return data.getAsJsonObject("status"); } 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 ffb1bf2..e677642 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 @@ -20,8 +20,10 @@ package org.jdrupes.vmoperator.common; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.openapi.JSON; import io.kubernetes.client.openapi.models.V1Condition; -import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Strings; import java.net.InetAddress; import java.net.UnknownHostException; @@ -46,21 +48,20 @@ import org.jdrupes.vmoperator.util.DataPath; /** * Represents a VM definition. */ -@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) -public class VmDefinition { +@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods", + "PMD.CouplingBetweenObjects" }) +public class VmDefinition extends K8sDynamicModel { @SuppressWarnings("PMD.FieldNamingConventions") private static final Logger logger = Logger.getLogger(VmDefinition.class.getName()); @SuppressWarnings("PMD.FieldNamingConventions") + private static final Gson gson = new JSON().getGson(); + @SuppressWarnings("PMD.FieldNamingConventions") private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); - private String kind; - private String apiVersion; - private V1ObjectMeta metadata; - private Map spec; - private Map status; + private final Model model; private final Map extra = new ConcurrentHashMap<>(); /** @@ -145,66 +146,34 @@ public class VmDefinition { } /** - * Gets the kind. + * Instantiates a new vm definition. * - * @return the kind + * @param delegate the delegate + * @param json the json */ - public String getKind() { - return kind; + public VmDefinition(Gson delegate, JsonObject json) { + super(delegate, json); + model = gson.fromJson(json, Model.class); } /** - * Sets the kind. + * Gets the spec. * - * @param kind the kind to set + * @return the spec */ - public void setKind(String kind) { - this.kind = kind; + public Map spec() { + return model.getSpec(); } /** - * Gets the api version. + * Get a value from the spec using {@link DataPath#get}. * - * @return the apiVersion + * @param the generic type + * @param selectors the selectors + * @return the value, if found */ - public String getApiVersion() { - return apiVersion; - } - - /** - * Sets the api version. - * - * @param apiVersion the apiVersion to set - */ - public void setApiVersion(String apiVersion) { - this.apiVersion = apiVersion; - } - - /** - * Gets the metadata. - * - * @return the metadata - */ - public V1ObjectMeta getMetadata() { - return metadata; - } - - /** - * Gets the metadata. - * - * @return the metadata - */ - public V1ObjectMeta metadata() { - return metadata; - } - - /** - * Sets the metadata. - * - * @param metadata the metadata to set - */ - public void setMetadata(V1ObjectMeta metadata) { - this.metadata = metadata; + public Optional fromSpec(Object... selectors) { + return DataPath.get(spec(), selectors); } /** @@ -217,35 +186,6 @@ public class VmDefinition { .orElse(Collections.emptyList()); } - /** - * Gets the spec. - * - * @return the spec - */ - public Map getSpec() { - return spec; - } - - /** - * Gets the spec. - * - * @return the spec - */ - public Map spec() { - return spec; - } - - /** - * Get a value from the spec using {@link DataPath#get}. - * - * @param the generic type - * @param selectors the selectors - * @return the value, if found - */ - public Optional fromSpec(Object... selectors) { - return DataPath.get(spec, selectors); - } - /** * Get a value from the `spec().get("vm")` using {@link DataPath#get}. * @@ -254,35 +194,17 @@ public class VmDefinition { * @return the value, if found */ public Optional fromVm(Object... selectors) { - return DataPath.get(spec, "vm") + return DataPath.get(spec(), "vm") .flatMap(vm -> DataPath.get(vm, selectors)); } - /** - * Sets the spec. - * - * @param spec the spec to set - */ - public void setSpec(Map spec) { - this.spec = spec; - } - - /** - * Gets the status. - * - * @return the status - */ - public Map getStatus() { - return status; - } - /** * Gets the status. * * @return the status */ public Map status() { - return status; + return model.getStatus(); } /** @@ -293,16 +215,7 @@ public class VmDefinition { * @return the value, if found */ public Optional fromStatus(Object... selectors) { - return DataPath.get(status, selectors); - } - - /** - * Sets the status. - * - * @param status the status to set - */ - public void setStatus(Map status) { - this.status = status; + return DataPath.get(status(), selectors); } /** @@ -411,7 +324,7 @@ public class VmDefinition { * @return the string */ public String name() { - return metadata.getName(); + return metadata().getName(); } /** @@ -420,7 +333,7 @@ public class VmDefinition { * @return the string */ public String namespace() { - return metadata.getNamespace(); + return metadata().getNamespace(); } /** @@ -569,7 +482,7 @@ public class VmDefinition { */ @Override public int hashCode() { - return Objects.hash(metadata.getNamespace(), metadata.getName()); + return Objects.hash(metadata().getNamespace(), metadata().getName()); } /** @@ -590,9 +503,55 @@ public class VmDefinition { return false; } VmDefinition other = (VmDefinition) obj; - return Objects.equals(metadata.getNamespace(), - other.metadata.getNamespace()) - && Objects.equals(metadata.getName(), other.metadata.getName()); + return Objects.equals(metadata().getNamespace(), + other.metadata().getNamespace()) + && Objects.equals(metadata().getName(), other.metadata().getName()); + } + + /** + * The Class Model. + */ + public static class Model { + + private Map spec; + private Map status; + + /** + * Gets the spec. + * + * @return the spec + */ + public Map getSpec() { + return spec; + } + + /** + * Sets the spec. + * + * @param spec the spec to set + */ + public void setSpec(Map spec) { + this.spec = spec; + } + + /** + * Gets the status. + * + * @return the status + */ + public Map getStatus() { + return status; + } + + /** + * Sets the status. + * + * @param status the status to set + */ + public void setStatus(Map status) { + this.status = status; + } + } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java deleted file mode 100644 index d4ae5da..0000000 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2024 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.common; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; - -/** - * Represents a VM definition. - */ -@SuppressWarnings("PMD.DataClass") -public class VmDefinitionModel extends K8sDynamicModel { - - /** - * Instantiates a new model from the JSON representation. - * - * @param delegate the gson instance to use for extracting structured data - * @param json the JSON - */ - public VmDefinitionModel(Gson delegate, JsonObject json) { - super(delegate, json); - } -} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java index 5ac412f..22e5bd7 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java @@ -25,7 +25,7 @@ import com.google.gson.JsonObject; * Represents a list of {@link VmDefinitionModel}s. */ public class VmDefinitionModels - extends K8sDynamicModelsBase { + extends K8sDynamicModelsBase { /** * Initialize the object list using the given JSON data. @@ -34,6 +34,6 @@ public class VmDefinitionModels * @param data the data */ public VmDefinitionModels(Gson delegate, JsonObject data) { - super(VmDefinitionModel.class, delegate, data); + super(VmDefinition.class, delegate, data); } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java index 49da3e0..e2e71da 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java @@ -33,9 +33,9 @@ import java.util.Collection; */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class VmDefinitionStub - extends K8sDynamicStubBase { + extends K8sDynamicStubBase { - private static DynamicTypeAdapterFactory taf = new VmDefintionModelTypeAdapterFactory(); /** @@ -48,7 +48,7 @@ public class VmDefinitionStub */ public VmDefinitionStub(K8sClient client, APIResource context, String namespace, String name) { - super(VmDefinitionModel.class, VmDefinitionModels.class, taf, client, + super(VmDefinition.class, VmDefinitionModels.class, taf, client, context, namespace, name); } @@ -101,9 +101,9 @@ public class VmDefinitionStub */ public static VmDefinitionStub createFromYaml(K8sClient client, APIResource context, Reader yaml) throws ApiException { - var model = new VmDefinitionModel(client.getJSON().getGson(), + var model = new VmDefinition(client.getJSON().getGson(), K8s.yamlToJson(client, yaml)); - return K8sGenericStub.create(VmDefinitionModel.class, + return K8sGenericStub.create(VmDefinition.class, VmDefinitionModels.class, client, context, model, (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); } @@ -121,7 +121,7 @@ public class VmDefinitionStub public static Collection list(K8sClient client, APIResource context, String namespace, ListOptions options) throws ApiException { - return K8sGenericStub.list(VmDefinitionModel.class, + return K8sGenericStub.list(VmDefinition.class, VmDefinitionModels.class, client, context, namespace, options, (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); } @@ -144,13 +144,13 @@ public class VmDefinitionStub * A factory for creating VmDefinitionModel(s) objects. */ public static class VmDefintionModelTypeAdapterFactory extends - DynamicTypeAdapterFactory { + DynamicTypeAdapterFactory { /** * Instantiates a new dynamic model type adapter factory. */ public VmDefintionModelTypeAdapterFactory() { - super(VmDefinitionModel.class, VmDefinitionModels.class); + super(VmDefinition.class, VmDefinitionModels.class); } } 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 8a50a5e..f348244 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 @@ -10,7 +10,7 @@ metadata: annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion } + - apiVersion: ${ cr.apiVersion() } kind: ${ constants.VM_OP_KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml index 9a70e19..c25d7f4 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -10,7 +10,7 @@ metadata: annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion } + - apiVersion: ${ cr.apiVersion() } kind: ${ constants.VM_OP_KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } 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 e62ac70..917d790 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 @@ -14,7 +14,7 @@ metadata: vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion }" vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion } + - apiVersion: ${ cr.apiVersion() } kind: ${ constants.VM_OP_KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } 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 32b3ac4..3e25a08 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 @@ -225,7 +225,7 @@ public class Controller extends Component { new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), vmDef.namespace(), vmDef.name()); vmStub.updateStatus(from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); var assignment = GsonPtr.to(status).to("assignment"); assignment.set("pool", event.usedPool()); assignment.set("user", event.toUser()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index 141c806..a0809e9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -185,7 +185,7 @@ public class DisplaySecretMonitor new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), event.vmDefinition().namespace(), event.vmDefinition().name()); vmStub.updateStatus(from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); status.addProperty("consoleUser", event.user()); return status; }); 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 b83c0f5..25fb10b 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 @@ -187,7 +187,8 @@ public class PoolMonitor extends new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), vmDef.namespace(), vmDef.name()); vmStub.updateStatus(from -> { - JsonObject status = from.status(); + // TODO + JsonObject status = from.statusJson(); var assignment = GsonPtr.to(status).to("assignment"); assignment.set("lastUsed", ccChange.get().toString()); return status; 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 f0368a7..b458628 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 @@ -41,7 +41,6 @@ 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.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionModels; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; @@ -65,7 +64,7 @@ import org.jgrapes.core.annotation.Handler; */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) public class VmMonitor extends - AbstractMonitor { + AbstractMonitor { private final ChannelManager channelManager; @@ -77,7 +76,7 @@ public class VmMonitor extends */ public VmMonitor(Channel componentChannel, ChannelManager channelManager) { - super(componentChannel, VmDefinitionModel.class, + super(componentChannel, VmDefinition.class, VmDefinitionModels.class); this.channelManager = channelManager; } @@ -122,7 +121,7 @@ public class VmMonitor extends @Override protected void handleChange(K8sClient client, - Watch.Response response) { + Watch.Response response) { V1ObjectMeta metadata = response.object.getMetadata(); AtomicBoolean toBeAdded = new AtomicBoolean(false); VmChannel channel = channelManager.channel(metadata.getName()) @@ -132,21 +131,17 @@ public class VmMonitor extends }); // Get full definition and associate with channel as backup - var vmModel = response.object; - if (vmModel.data() == null) { + var vmDef = response.object; + if (vmDef.data() == null) { // ADDED event does not provide data, see // https://github.com/kubernetes-client/java/issues/3215 - vmModel = getModel(client, vmModel); + vmDef = getModel(client, vmDef); } - VmDefinition vmDef = null; - if (vmModel.data() != null) { + if (vmDef.data() != null) { // New data, augment and save - vmDef = client.getJSON().getGson().fromJson(vmModel.data(), - VmDefinition.class); addDynamicData(channel.client(), vmDef, channel.vmDefinition()); channel.setVmDefinition(vmDef); - } - if (vmDef == null) { + } else { // Reuse cached (e.g. if deleted) vmDef = channel.vmDefinition(); } @@ -173,8 +168,7 @@ public class VmMonitor extends channel.pipeline().fire(chgEvt, channel); } - private VmDefinitionModel getModel(K8sClient client, - VmDefinitionModel vmDef) { + private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { try { return VmDefinitionStub.get(client, context(), namespace(), vmDef.metadata().getName()).model().orElse(null); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java index f2309df..b91b5df 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -106,7 +106,7 @@ public class ConsoleTracker extends VmDefUpdater { mainChannelClientHost = event.clientHost(); mainChannelClientPort = event.clientPort(); vmStub.updateStatus(from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); status.addProperty("consoleClient", event.clientHost()); updateCondition(from, status, "ConsoleConnected", true, "Connected", "Connection from " + event.clientHost()); @@ -141,7 +141,7 @@ public class ConsoleTracker extends VmDefUpdater { return; } vmStub.updateStatus(from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); status.addProperty("consoleClient", ""); updateCondition(from, status, "ConsoleConnected", false, "Disconnected", event.clientHost() + " has disconnected"); 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 0b18df0..d33358b 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 @@ -33,7 +33,7 @@ import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -140,12 +140,12 @@ public class StatusUpdater extends VmDefUpdater { if (vmDef.isPresent() && vmDef.get().metadata().getGeneration() == observedGeneration && (event.configuration().hasDisplayPassword - || vmDef.get().status().getAsJsonPrimitive( + || vmDef.get().statusJson().getAsJsonPrimitive( "displayPasswordSerial").getAsInt() == -1)) { return; } vmStub.updateStatus(vmDef.get(), from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { status.addProperty("displayPasswordSerial", -1); } @@ -169,14 +169,14 @@ public class StatusUpdater extends VmDefUpdater { "PMD.AvoidLiteralsInIfCondition" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { - VmDefinitionModel vmDef; + VmDefinition vmDef; if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } vmStub.updateStatus(vmDef, from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); boolean running = RUNNING_STATES.contains(event.runState()); - updateCondition(vmDef, vmDef.status(), "Running", running, + updateCondition(vmDef, vmDef.statusJson(), "Running", running, event.reason(), event.message()); if (event.runState() == RunState.STARTING) { status.addProperty("ram", GsonPtr.to(from.data()) @@ -230,7 +230,7 @@ public class StatusUpdater extends VmDefUpdater { return; } vmStub.updateStatus(from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); status.addProperty("ram", new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) .toSuffixedString()); @@ -250,7 +250,7 @@ public class StatusUpdater extends VmDefUpdater { return; } vmStub.updateStatus(from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); status.addProperty("cpus", event.usedCpus().size()); return status; }); @@ -269,7 +269,7 @@ public class StatusUpdater extends VmDefUpdater { return; } vmStub.updateStatus(from -> { - JsonObject status = from.status(); + JsonObject status = from.statusJson(); status.addProperty("displayPasswordSerial", status.get("displayPasswordSerial").getAsLong() + 1); return status; 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 9683457..f04b478 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 @@ -31,7 +31,7 @@ import java.util.Optional; import java.util.logging.Level; import java.util.stream.Collectors; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -118,7 +118,7 @@ public class VmDefUpdater extends Component { * @param reason the reason for the change * @param message the message */ - protected void updateCondition(VmDefinitionModel from, JsonObject status, + protected void updateCondition(VmDefinition from, JsonObject status, String type, boolean state, String reason, String message) { // Optimize, as we can get this several times var current = status.getAsJsonArray("conditions").asList().stream() 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 bd9a802..d6c385e 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 @@ -593,7 +593,7 @@ public class VmAccess extends FreeMarkerConlet { Map.of("namespace", vmDef.namespace(), "name", vmDef.name()), "spec", vmDef.spec(), - "status", vmDef.getStatus()); + "status", vmDef.status()); } catch (JsonSyntaxException e) { logger.log(Level.SEVERE, e, () -> "Failed to serialize VM definition"); From b7ea6860ff8a7e0496ae6e4374986edb8150c1e0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Feb 2025 22:08:57 +0100 Subject: [PATCH 102/274] Adapt name to previous change. --- .../vmoperator/common/VmDefinitionStub.java | 14 +++++++------- ...{VmDefinitionModels.java => VmDefinitions.java} | 6 +++--- .../org/jdrupes/vmoperator/manager/VmMonitor.java | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) rename org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/{VmDefinitionModels.java => VmDefinitions.java} (88%) diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java index e2e71da..72194da 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java @@ -33,10 +33,10 @@ import java.util.Collection; */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class VmDefinitionStub - extends K8sDynamicStubBase { + extends K8sDynamicStubBase { private static DynamicTypeAdapterFactory taf = new VmDefintionModelTypeAdapterFactory(); + VmDefinitions> taf = new VmDefintionModelTypeAdapterFactory(); /** * Instantiates a new stub for VM defintions. @@ -48,7 +48,7 @@ public class VmDefinitionStub */ public VmDefinitionStub(K8sClient client, APIResource context, String namespace, String name) { - super(VmDefinition.class, VmDefinitionModels.class, taf, client, + super(VmDefinition.class, VmDefinitions.class, taf, client, context, namespace, name); } @@ -104,7 +104,7 @@ public class VmDefinitionStub var model = new VmDefinition(client.getJSON().getGson(), K8s.yamlToJson(client, yaml)); return K8sGenericStub.create(VmDefinition.class, - VmDefinitionModels.class, client, context, model, + VmDefinitions.class, client, context, model, (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); } @@ -122,7 +122,7 @@ public class VmDefinitionStub APIResource context, String namespace, ListOptions options) throws ApiException { return K8sGenericStub.list(VmDefinition.class, - VmDefinitionModels.class, client, context, namespace, options, + VmDefinitions.class, client, context, namespace, options, (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); } @@ -144,13 +144,13 @@ public class VmDefinitionStub * A factory for creating VmDefinitionModel(s) objects. */ public static class VmDefintionModelTypeAdapterFactory extends - DynamicTypeAdapterFactory { + DynamicTypeAdapterFactory { /** * Instantiates a new dynamic model type adapter factory. */ public VmDefintionModelTypeAdapterFactory() { - super(VmDefinition.class, VmDefinitionModels.class); + super(VmDefinition.class, VmDefinitions.class); } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java similarity index 88% rename from org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java rename to org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java index 22e5bd7..c79654e 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java @@ -22,9 +22,9 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; /** - * Represents a list of {@link VmDefinitionModel}s. + * Represents a list of {@link VmDefinition}s. */ -public class VmDefinitionModels +public class VmDefinitions extends K8sDynamicModelsBase { /** @@ -33,7 +33,7 @@ public class VmDefinitionModels * @param delegate the gson instance to use for extracting structured data * @param data the data */ - public VmDefinitionModels(Gson delegate, JsonObject data) { + public VmDefinitions(Gson delegate, JsonObject data) { super(VmDefinition.class, delegate, data); } } 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 b458628..79ce631 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 @@ -41,7 +41,7 @@ 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.VmDefinitionModels; +import org.jdrupes.vmoperator.common.VmDefinitions; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; @@ -64,7 +64,7 @@ import org.jgrapes.core.annotation.Handler; */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) public class VmMonitor extends - AbstractMonitor { + AbstractMonitor { private final ChannelManager channelManager; @@ -77,7 +77,7 @@ public class VmMonitor extends public VmMonitor(Channel componentChannel, ChannelManager channelManager) { super(componentChannel, VmDefinition.class, - VmDefinitionModels.class); + VmDefinitions.class); this.channelManager = channelManager; } From 21108771d9ed27de392f334f7516fce3017a65f5 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 1 Feb 2025 22:10:47 +0100 Subject: [PATCH 103/274] Fix warning. --- .../src/org/jdrupes/vmoperator/manager/VmMonitor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 79ce631..e506d44 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 @@ -41,8 +41,8 @@ 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.VmDefinitions; import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmDefinitions; 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; From d5e589709fb96d106af137c57ffb59dca2b893c6 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Feb 2025 12:10:22 +0100 Subject: [PATCH 104/274] Handle conflict properly. --- .../vmoperator/common/K8sGenericStub.java | 50 +++++----- .../vmoperator/manager/Controller.java | 41 ++++++--- .../jdrupes/vmoperator/manager/VmMonitor.java | 91 ++++++++++--------- 3 files changed, 100 insertions(+), 82 deletions(-) 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 0689a97..688f43f 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 @@ -193,42 +193,41 @@ public class K8sGenericStub updateStatus(O object, - Function status, int retries) throws ApiException { - while (true) { - try { - return K8s.optional(api.updateStatus(object, status)); - } catch (ApiException e) { - if (HttpURLConnection.HTTP_CONFLICT != e.getCode() - || retries-- <= 0) { - throw e; - } - } - } + public Optional updateStatus(O object, Function status) + throws ApiException { + return K8s.optional(api.updateStatus(object, status)); } /** - * Updates the object's status, retrying up to 16 times if there - * is a conflict. + * Gets the object and updates the status. In case of conflict, retries + * up to `retries` times. * - * @param object the current state of the object (passed to `status`) - * @param status function that returns the new status - * @return the updated model or empty if not successful + * @param status the status + * @param retries the retries in case of conflict + * @return the updated model or empty if the object was not found * @throws ApiException the api exception */ - public Optional updateStatus(O object, - Function status) throws ApiException { - return updateStatus(object, status, 16); + @SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" }) + public Optional updateStatus(Function status, int retries) + throws ApiException { + try { + return updateStatus(api.get(namespace, name).throwsApiException() + .getObject(), status); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } + } + return Optional.empty(); } /** @@ -241,8 +240,7 @@ public class K8sGenericStub updateStatus(Function status) throws ApiException { - return updateStatus( - api.get(namespace, name).throwsApiException().getObject(), status); + return updateStatus(status, 16); } /** 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 3e25a08..80ff0f7 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 @@ -24,6 +24,7 @@ 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; @@ -211,7 +212,10 @@ public class Controller extends Component { } /** - * Update the assignment information in the status of the VM CR. + * 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 @@ -220,18 +224,27 @@ public class Controller extends Component { @Handler public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) throws ApiException { - var vmDef = channel.vmDefinition(); - var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), - vmDef.namespace(), vmDef.name()); - vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - var assignment = GsonPtr.to(status).to("assignment"); - assignment.set("pool", event.usedPool()); - assignment.set("user", event.toUser()); - assignment.set("lastUsed", Instant.now().toString()); - return status; - }); - event.setResult(true); + try { + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + vmDef.namespace(), vmDef.name()); + if (vmStub.updateStatus(vmDef, from -> { + JsonObject status = from.statusJson(); + var assignment = GsonPtr.to(status).to("assignment"); + assignment.set("pool", event.usedPool()); + assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); + 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); } } 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 e506d44..17e3c58 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 @@ -256,49 +256,56 @@ public class VmMonitor extends @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") public void onAssignVm(AssignVm event) throws ApiException, InterruptedException { - // Search for existing assignment. - var assignedVm = channelManager.channels().stream() - .filter(c -> c.vmDefinition().assignedFrom() - .map(p -> p.equals(event.fromPool())).orElse(false)) - .filter(c -> c.vmDefinition().assignedTo() - .map(u -> u.equals(event.toUser())).orElse(false)) - .findFirst(); - if (assignedVm.isPresent()) { - var vmDef = assignedVm.get().vmDefinition(); - event.setResult(new VmData(vmDef, assignedVm.get())); - return; + while (true) { + // Search for existing assignment. + var vmQuery = channelManager.channels().stream() + .filter(c -> c.vmDefinition().assignedFrom() + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignedTo() + .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; + } + + // 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 = channelManager.channels().stream() + .filter(c -> vmPool.isAssignable(c.vmDefinition())) + .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() + .assignmentLastUsed().orElse(Instant.ofEpochSecond(0))) + .thenComparing(preferRunning)) + .findFirst(); + + // None found + if (vmQuery.isEmpty()) { + return; + } + + // Assign to user + var chosenVm = vmQuery.get(); + var vmPipeline = chosenVm.pipeline(); + if (Optional.ofNullable(vmPipeline.fire(new UpdateAssignment( + vmPool.name(), event.toUser()), chosenVm).get()) + .orElse(false)) { + var vmDef = chosenVm.vmDefinition(); + 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)); + return; + } } - - // Get the pool definition assignability check - VmPool vmPool = newEventPipeline().fire(new GetPools() - .withName(event.fromPool())).get().stream().findFirst() - .orElse(null); - if (vmPool == null) { - return; - } - - // Find available VM. - assignedVm = channelManager.channels().stream() - .filter(c -> vmPool.isAssignable(c.vmDefinition())) - .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() - .assignmentLastUsed().orElse(Instant.ofEpochSecond(0))) - .thenComparing(preferRunning)) - .findFirst(); - - // None found - if (assignedVm.isEmpty()) { - return; - } - - // Assign to user - assignedVm.get().pipeline().fire(new UpdateAssignment(vmPool.name(), - event.toUser()), assignedVm.get()).get(); - var vmDef = assignedVm.get().vmDefinition(); - event.setResult(new VmData(vmDef, assignedVm.get())); - - // Make sure that a newly assigned VM is running. - assignedVm.get().pipeline().fire(new ModifyVm(vmDef.name(), - "state", "Running", assignedVm.get())); } private static Comparator preferRunning From d27339b1e9cdc3ee23a02e53078a808846ba2024 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Feb 2025 13:54:10 +0100 Subject: [PATCH 105/274] Make VM extra data a class. --- .../vmoperator/common/VmDefinition.java | 101 +---------- .../vmoperator/common/VmExtraData.java | 171 ++++++++++++++++++ .../vmoperator/manager/runnerConfig.ftl.yaml | 2 +- .../vmoperator/manager/Reconciler.java | 2 +- .../jdrupes/vmoperator/manager/VmMonitor.java | 27 ++- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 13 +- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 15 +- 7 files changed, 208 insertions(+), 123 deletions(-) create mode 100644 org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.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 e677642..ec79b80 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 @@ -24,9 +24,6 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import io.kubernetes.client.openapi.JSON; import io.kubernetes.client.openapi.models.V1Condition; -import io.kubernetes.client.util.Strings; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.time.Instant; import java.util.Collection; import java.util.Collections; @@ -38,9 +35,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; -import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import org.jdrupes.vmoperator.util.DataPath; @@ -52,7 +47,7 @@ import org.jdrupes.vmoperator.util.DataPath; "PMD.CouplingBetweenObjects" }) public class VmDefinition extends K8sDynamicModel { - @SuppressWarnings("PMD.FieldNamingConventions") + @SuppressWarnings({ "PMD.FieldNamingConventions", "unused" }) private static final Logger logger = Logger.getLogger(VmDefinition.class.getName()); @SuppressWarnings("PMD.FieldNamingConventions") @@ -62,7 +57,7 @@ public class VmDefinition extends K8sDynamicModel { = new ObjectMapper().registerModule(new JavaTimeModule()); private final Model model; - private final Map extra = new ConcurrentHashMap<>(); + private VmExtraData extraData; /** * The VM state from the VM definition. @@ -295,27 +290,21 @@ public class VmDefinition extends K8sDynamicModel { } /** - * Set extra data (locally used, unknown to kubernetes). - * - * @param property the property - * @param value the value + * Set extra data (unknown to kubernetes). * @return the VM definition */ - public VmDefinition extra(String property, Object value) { - extra.put(property, value); + /* default */ VmDefinition extra(VmExtraData extraData) { + this.extraData = extraData; return this; } /** - * Return extra data. + * Return the extra data. * - * @param the generic type - * @param property the property - * @return the object + * @return the data */ - @SuppressWarnings("unchecked") - public T extra(String property) { - return (T) extra.get(property); + public Optional extra() { + return Optional.ofNullable(extraData); } /** @@ -403,78 +392,6 @@ public class VmDefinition extends K8sDynamicModel { .map(Number::longValue); } - /** - * Create a connection file. - * - * @param password the password - * @param preferredIpVersion the preferred IP version - * @param deleteConnectionFile the delete connection file - * @return the string - */ - public String connectionFile(String password, - Class preferredIpVersion, boolean deleteConnectionFile) { - var addr = displayIp(preferredIpVersion); - if (addr.isEmpty()) { - logger.severe(() -> "Failed to find display IP for " + name()); - return null; - } - var port = this. fromVm("display", "spice", "port") - .map(Number::longValue); - if (port.isEmpty()) { - logger.severe(() -> "No port defined for display of " + name()); - return null; - } - StringBuffer data = new StringBuffer(100) - .append("[virt-viewer]\ntype=spice\nhost=") - .append(addr.get().getHostAddress()).append("\nport=") - .append(port.get().toString()) - .append('\n'); - if (password != null) { - data.append("password=").append(password).append('\n'); - } - this. fromVm("display", "spice", "proxyUrl") - .ifPresent(u -> { - if (!Strings.isNullOrEmpty(u)) { - data.append("proxy=").append(u).append('\n'); - } - }); - if (deleteConnectionFile) { - data.append("delete-this-file=1\n"); - } - return data.toString(); - } - - private Optional displayIp(Class preferredIpVersion) { - Optional server = fromVm("display", "spice", "server"); - if (server.isPresent()) { - var srv = server.get(); - try { - var addr = InetAddress.getByName(srv); - logger.fine(() -> "Using IP address from CRD for " - + getMetadata().getName() + ": " + addr); - return Optional.of(addr); - } catch (UnknownHostException e) { - logger.log(Level.SEVERE, e, () -> "Invalid server address " - + srv + ": " + e.getMessage()); - return Optional.empty(); - } - } - var addrs = Optional.> ofNullable( - extra("nodeAddresses")).orElse(Collections.emptyList()).stream() - .map(a -> { - try { - return InetAddress.getByName(a); - } catch (UnknownHostException e) { - logger.warning(() -> "Invalid IP address: " + a); - return null; - } - }).filter(a -> a != null).toList(); - logger.fine(() -> "Known IP addresses for " + name() + ": " + addrs); - return addrs.stream() - .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) - .findFirst().or(() -> addrs.stream().findFirst()); - } - /** * Hash code. * 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 new file mode 100644 index 0000000..85913c2 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java @@ -0,0 +1,171 @@ +/* + * 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.common; + +import io.kubernetes.client.util.Strings; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Represents internally used dynamic data associated with a + * {@link VmDefinition}. + */ +public class VmExtraData { + + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Logger logger + = Logger.getLogger(VmExtraData.class.getName()); + + private final VmDefinition vmDef; + private String nodeName = ""; + private List nodeAddresses = Collections.emptyList(); + private long resetCount; + + /** + * Initializes a new instance. + * + * @param vmDef the VM definition + */ + public VmExtraData(VmDefinition vmDef) { + this.vmDef = vmDef; + vmDef.extra(this); + } + + /** + * Sets the node info. + * + * @param name the name + * @param addresses the addresses + * @return the VM extra data + */ + public VmExtraData nodeInfo(String name, List addresses) { + nodeName = name; + nodeAddresses = addresses; + return this; + } + + /** + * Return the node name. + * + * @return the string + */ + public String nodeName() { + return nodeName; + } + + /** + * Sets the reset count. + * + * @param resetCount the reset count + * @return the vm extra data + */ + public VmExtraData resetCount(long resetCount) { + this.resetCount = resetCount; + return this; + } + + /** + * Returns the reset count. + * + * @return the long + */ + public long resetCount() { + return resetCount; + } + + /** + * Create a connection file. + * + * @param password the password + * @param preferredIpVersion the preferred IP version + * @param deleteConnectionFile the delete connection file + * @return the string + */ + public String connectionFile(String password, + Class preferredIpVersion, boolean deleteConnectionFile) { + var addr = displayIp(preferredIpVersion); + if (addr.isEmpty()) { + logger + .severe(() -> "Failed to find display IP for " + vmDef.name()); + return null; + } + 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; + } + StringBuffer data = new StringBuffer(100) + .append("[virt-viewer]\ntype=spice\nhost=") + .append(addr.get().getHostAddress()).append("\nport=") + .append(port.get().toString()) + .append('\n'); + if (password != null) { + data.append("password=").append(password).append('\n'); + } + vmDef. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); + if (deleteConnectionFile) { + data.append("delete-this-file=1\n"); + } + return data.toString(); + } + + private Optional displayIp(Class preferredIpVersion) { + Optional server = vmDef.fromVm("display", "spice", "server"); + if (server.isPresent()) { + var srv = server.get(); + try { + var addr = InetAddress.getByName(srv); + logger.fine(() -> "Using IP address from CRD for " + + vmDef.metadata().getName() + ": " + addr); + return Optional.of(addr); + } catch (UnknownHostException e) { + logger.log(Level.SEVERE, e, () -> "Invalid server address " + + srv + ": " + e.getMessage()); + return Optional.empty(); + } + } + var addrs = nodeAddresses.stream().map(a -> { + try { + return InetAddress.getByName(a); + } catch (UnknownHostException e) { + logger.warning(() -> "Invalid IP address: " + a); + return null; + } + }).filter(Objects::nonNull).toList(); + logger.fine( + () -> "Known IP addresses for " + vmDef.name() + ": " + addrs); + return addrs.stream() + .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) + .findFirst().or(() -> addrs.stream().findFirst()); + } + +} 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 f348244..f5aabc5 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("resetCount")?c } + resetCounter: ${ cr.extra().get().resetCount()?c } # Forward the cloud-init data if provided <#if spec.cloudInit??> 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 641247a..7dbb410 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 @@ -248,7 +248,7 @@ public class Reconciler extends Component { public void onResetVm(ResetVm event, VmChannel channel) throws ApiException, IOException, TemplateException { var vmDef = channel.vmDefinition(); - vmDef.extra("resetCount", vmDef. extra("resetCount") + 1); + vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1)); Map model = prepareModel(channel.client(), channel.vmDefinition()); cmReconciler.reconcile(model, channel); 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 17e3c58..5c1ae77 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 @@ -43,6 +43,7 @@ import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; import org.jdrupes.vmoperator.common.VmDefinition; 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; @@ -139,7 +140,7 @@ public class VmMonitor extends } if (vmDef.data() != null) { // New data, augment and save - addDynamicData(channel.client(), vmDef, channel.vmDefinition()); + addExtraData(channel.client(), vmDef, channel.vmDefinition()); channel.setVmDefinition(vmDef); } else { // Reuse cached (e.g. if deleted) @@ -178,17 +179,14 @@ public class VmMonitor extends } @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private void addDynamicData(K8sClient client, VmDefinition vmDef, + private void addExtraData(K8sClient client, VmDefinition vmDef, VmDefinition prevState) { - // Maintain (or initialize) the resetCount - vmDef.extra("resetCount", - Optional.ofNullable(prevState).map(d -> d.extra("resetCount")) - .orElse(0L)); + var extra = new VmExtraData(vmDef); - // Node information - // Add defaults in case the VM is not running - vmDef.extra("nodeName", ""); - vmDef.extra("nodeAddress", ""); + // Maintain (or initialize) the resetCount + extra.resetCount( + Optional.ofNullable(prevState).flatMap(VmDefinition::extra) + .map(VmExtraData::resetCount).orElse(0L)); // VM definition status changes before the pod terminates. // This results in pod information being shown for a stopped @@ -196,6 +194,8 @@ public class VmMonitor extends if (!vmDef.conditionStatus("Running").orElse(false)) { 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 @@ -205,16 +205,15 @@ public class VmMonitor extends = K8sV1PodStub.list(client, namespace(), podSearch); for (var podStub : podList) { var nodeName = podStub.model().get().getSpec().getNodeName(); - vmDef.extra("nodeName", nodeName); - logger.fine(() -> "Added node name " + nodeName + logger.fine(() -> "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); - vmDef.extra("nodeAddresses", addrs); - logger.fine(() -> "Added node addresses " + addrs + logger.fine(() -> "Adding node addresses " + addrs + " to VM info for " + vmDef.name()); + extra.nodeInfo(nodeName, addrs); } } catch (ApiException e) { logger.log(Level.WARNING, e, 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 d6c385e..e283504 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 @@ -810,13 +810,12 @@ public class VmAccess extends FreeMarkerConlet { } var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), e -> { - var data = vmDef.connectionFile(e.password().orElse(null), - preferredIpVersion, deleteConnectionFile); - if (data == null) { - return; - } - channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", data)); + vmDef.extra() + .map(xtra -> xtra.connectionFile(e.password().orElse(null), + preferredIpVersion, deleteConnectionFile)) + .ifPresent( + cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); }); fire(pwQuery, vmChannel); } 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 819a0d4..4cc63fa 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 @@ -41,6 +41,7 @@ import java.util.Set; 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.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.ModifyVm; @@ -252,7 +253,7 @@ public class VmMgmt extends FreeMarkerConlet { "name", vmDef.name()), "spec", spec, "status", status, - "nodeName", vmDef.extra("nodeName"), + "nodeName", vmDef.extra().map(VmExtraData::nodeName).orElse(""), "permissions", vmDef.permissionsFor(user, roles).stream() .map(VmDefinition.Permission::toString).toList()); } @@ -484,13 +485,11 @@ public class VmMgmt extends FreeMarkerConlet { } var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), e -> { - var data = vmDef.connectionFile(e.password().orElse(null), - preferredIpVersion, deleteConnectionFile); - if (data == null) { - return; - } - channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", data)); + vmDef.extra().map(xtra -> xtra.connectionFile( + e.password().orElse(null), preferredIpVersion, + deleteConnectionFile)).ifPresent( + cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); }); fire(pwQuery, vmChannel); } From 1fc26647b615ac94f468a45c208005f1b9066b38 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Feb 2025 16:50:52 +0100 Subject: [PATCH 106/274] Add maintenance script. --- dev-example/.gitignore | 1 + dev-example/pool-action.sh | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100755 dev-example/pool-action.sh diff --git a/dev-example/.gitignore b/dev-example/.gitignore index 16e0d04..1e31cc5 100644 --- a/dev-example/.gitignore +++ b/dev-example/.gitignore @@ -1,3 +1,4 @@ /test-vm-ci.yaml /kubeconfig.yaml /crds/ +/.vm-operator-cmd.rc diff --git a/dev-example/pool-action.sh b/dev-example/pool-action.sh new file mode 100755 index 0000000..b605479 --- /dev/null +++ b/dev-example/pool-action.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +function usage() { + cat >&2 <&2 "Unknown option: $1"; exit 1;; + *) if [ ! -v pool ]; then + pool="$1" + elif [ ! -v action ]; then + action="$1" + else + usage + fi;; + esac + shift +done + +if [ ! -v pool -o ! -v "action" -o ! -v context ]; then + echo >&2 "Missing arguments or context not set." + echo >&2 + usage +fi +case "$action" in + "start"|"stop"|"delete"|"delete-disks") ;; + *) usage;; +esac + +kubectl --context="$context" -n "$namespace" get vms -o json \ + | jq -r '.items[] | select(.spec.pools | contains(["'${pool}'"])) | .metadata.name' \ +| while read vmName; do + case "$action" in + start) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \ + --type='merge' -p '{"spec":{"vm":{"state":"Running"}}}';; + stop) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \ + --type='merge' -p '{"spec":{"vm":{"state":"Stopped"}}}';; + delete) kubectl --context="$context" -n "$namespace" delete vm/"$vmName";; + delete-disks) kubectl --context="$context" -n "$namespace" delete \ + pvc -l app.kubernetes.io/instance="$vmName" ;; + esac +done From 5078001f4bfbb0fe9b35afa05c04c9edba504d74 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 10 Feb 2025 22:24:10 +0100 Subject: [PATCH 107/274] Add guest agent client and retrieve OS info. --- deploy/crds/vms-crd.yaml | 4 + dev-example/test-vm.yaml | 9 +- .../vmoperator/runner/qemu/Configuration.java | 4 + .../runner/qemu/GuestAgentClient.java | 254 ++++++++++++++++++ .../vmoperator/runner/qemu/Runner.java | 3 + .../vmoperator/runner/qemu/StatusUpdater.java | 33 +++ .../qemu/commands/QmpGuestGetOsinfo.java | 41 +++ .../runner/qemu/commands/QmpGuestInfo.java | 41 +++ .../runner/qemu/commands/QmpGuestPing.java | 41 +++ .../runner/qemu/events/GuestAgentCommand.java | 63 +++++ .../runner/qemu/events/MonitorEvent.java | 5 +- .../runner/qemu/events/OsinfoEvent.java | 43 +++ .../qemu/events/VserportChangeEvent.java | 56 ++++ .../templates/Standard-VM-latest.ftl.yaml | 2 +- 14 files changed, 592 insertions(+), 7 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 7b46dc7..749b896 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1491,6 +1491,10 @@ spec: by the runner if password protection is not enabled. type: integer default: 0 + osinfo: + description: Copy of the OS info provided by the guest agent. + type: object + x-kubernetes-preserve-unknown-fields: true assignment: description: >- The assignment of this VM to a a particular user. diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index 7ee2793..aa75bc3 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -5,9 +5,7 @@ metadata: name: test-vm spec: image: - repository: docker-registry.lan.mnl.de - path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine - version: latest + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing pullPolicy: Always permissions: @@ -34,8 +32,9 @@ spec: currentCpus: 4 networks: - - tap: - mac: "02:16:3e:33:58:10" + # No bridge on test cluster + - user: {} + disks: - volumeClaimTemplate: metadata: diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index dd45147..086f085 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -67,6 +67,9 @@ public class Configuration implements Dto { /** The monitor socket. */ public Path monitorSocket; + /** The guest agent socket socket. */ + public Path guestAgentSocket; + /** The firmware rom. */ public Path firmwareRom; @@ -341,6 +344,7 @@ public class Configuration implements Dto { runtimeDir.toFile().mkdir(); swtpmSocket = runtimeDir.resolve("swtpm-sock"); monitorSocket = runtimeDir.resolve("monitor.sock"); + guestAgentSocket = runtimeDir.resolve("org.qemu.guest_agent.0"); } if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) { logger.severe(() -> String.format( 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 new file mode 100644 index 0000000..4d1c764 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -0,0 +1,254 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.io.Writer; +import java.lang.reflect.UndeclaredThrowableException; +import java.net.UnixDomainSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedList; +import java.util.Queue; +import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; +import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; +import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; +import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; +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.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ConnectError; +import org.jgrapes.io.events.Input; +import org.jgrapes.io.events.OpenSocketConnection; +import org.jgrapes.io.util.ByteBufferWriter; +import org.jgrapes.io.util.LineCollector; +import org.jgrapes.net.SocketIOChannel; +import org.jgrapes.net.events.ClientConnected; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * A component that handles the communication over the guest agent + * socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the monitor socket are logged. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class GuestAgentClient extends Component { + + private static ObjectMapper mapper = new ObjectMapper(); + + private EventPipeline rep; + private Path socketPath; + private SocketIOChannel gaChannel; + private final Queue executing = new LinkedList<>(); + + /** + * Instantiates a new guest agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", + "PMD.ConstructorCallsOverridableMethod" }) + public GuestAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param socketPath the socket path + * @param powerdownTimeout + */ + /* default */ void configure(Path socketPath) { + this.socketPath = socketPath; + } + + /** + * Handle the start event. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onStart(Start event) throws IOException { + rep = event.associated(EventPipeline.class).get(); + if (socketPath == null) { + return; + } + Files.deleteIfExists(socketPath); + } + + /** + * When the virtual serial port "channel0" has been opened, + * establish the connection by opening the socket. + * + * @param event the event + */ + @Handler + public void onVserportChanged(VserportChangeEvent event) { + if ("channel0".equals(event.id()) && event.isOpen()) { + fire(new OpenSocketConnection( + UnixDomainSocketAddress.of(socketPath)) + .setAssociated(GuestAgentClient.class, this)); + } + } + + /** + * Check if this is from opening the monitor socket and if true, + * save the socket in the context and associate the channel with + * the context. Then send the initial message to the socket. + * + * @param event the event + * @param channel the channel + */ + @SuppressWarnings("resource") + @Handler + public void onClientConnected(ClientConnected event, + SocketIOChannel channel) { + event.openEvent().associated(GuestAgentClient.class).ifPresent(qm -> { + gaChannel = channel; + channel.setAssociated(GuestAgentClient.class, this); + channel.setAssociated(Writer.class, new ByteBufferWriter( + channel).nativeCharset()); + channel.setAssociated(LineCollector.class, + new LineCollector() + .consumer(line -> { + try { + processGuestAgentInput(line); + } catch (IOException e) { + throw new UndeclaredThrowableException(e); + } + })); + fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); + }); + } + + /** + * Called when a connection attempt fails. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onConnectError(ConnectError event, SocketIOChannel channel) { + event.event().associated(GuestAgentClient.class).ifPresent(qm -> { + rep.fire(new Stop()); + }); + } + + /** + * Handle data from qemu monitor connection. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onInput(Input event, SocketIOChannel channel) { + if (channel.associated(GuestAgentClient.class).isEmpty()) { + return; + } + channel.associated(LineCollector.class).ifPresent(collector -> { + collector.feed(event); + }); + } + + private void processGuestAgentInput(String line) + throws IOException { + logger.fine(() -> "guest agent(in): " + line); + try { + var response = mapper.readValue(line, ObjectNode.class); + if (response.has("QMP")) { + rep.fire(new MonitorReady()); + return; + } + if (response.has("return") || response.has("error")) { + QmpCommand executed = executing.poll(); + logger.fine( + () -> String.format("(Previous \"guest agent(in)\" is " + + "result from executing %s)", executed)); + if (executed instanceof QmpGuestGetOsinfo) { + rep.fire(new OsinfoEvent(response.get("return"))); + } + } + } catch (JsonProcessingException e) { + throw new IOException(e); + } + } + + /** + * On closed. + * + * @param event the event + */ + @Handler + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) + public void onClosed(Closed event, SocketIOChannel channel) { + channel.associated(QemuMonitor.class).ifPresent(qm -> { + gaChannel = null; + }); + } + + /** + * On guest agent command. + * + * @param event the event + */ + @Handler + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidSynchronizedStatement" }) + public void onGuestAgentCommand(GuestAgentCommand event) { + var command = event.command(); + logger.fine(() -> "guest agent(out): " + command.toString()); + String asText; + try { + asText = command.asText(); + } catch (JsonProcessingException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot serialize Json: " + e.getMessage()); + return; + } + synchronized (executing) { + gaChannel.associated(Writer.class).ifPresent(writer -> { + try { + executing.add(command); + writer.append(asText).append('\n').flush(); + } catch (IOException e) { + // Cannot happen, but... + logger.log(Level.WARNING, e, e::getMessage); + } + }); + } + } +} 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 52db0ce..c8b9f44 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 @@ -218,6 +218,7 @@ public class Runner extends Component { private CommandDefinition cloudInitImgDefinition; private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; + private final GuestAgentClient guestAgentClient; private Integer resetCounter; private RunState state = RunState.INITIALIZING; @@ -275,6 +276,7 @@ public class Runner extends Component { attach(new ProcessManager(channel())); attach(new SocketConnector(channel())); attach(qemuMonitor = new QemuMonitor(channel(), configDir)); + attach(guestAgentClient = new GuestAgentClient(channel())); attach(new StatusUpdater(channel())); attach(new YamlConfigurationStore(channel(), configFile, false)); fire(new WatchFile(configFile.toPath())); @@ -349,6 +351,7 @@ public class Runner extends Component { // Forward some values to child components qemuMonitor.configure(config.monitorSocket, config.vm.powerdownTimeout); + guestAgentClient.configure(config.guestAgentSocket); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); 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 d33358b..d4548bf 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 @@ -18,12 +18,16 @@ package org.jdrupes.vmoperator.runner.qemu; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.Gson; import com.google.gson.JsonObject; import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.custom.Quantity.Format; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.JSON; import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; @@ -40,6 +44,7 @@ import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged; import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; @@ -55,6 +60,12 @@ import org.jgrapes.core.events.Start; @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class StatusUpdater extends VmDefUpdater { + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Gson gson = new JSON().getGson(); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + private static final Set RUNNING_STATES = Set.of(RunState.RUNNING, RunState.TERMINATING); @@ -286,4 +297,26 @@ public class StatusUpdater extends VmDefUpdater { public void onShutdown(ShutdownEvent event) throws ApiException { shutdownByGuest = event.byGuest(); } + + /** + * On osinfo. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onOsinfo(OsinfoEvent event) throws ApiException { + if (vmStub == null) { + return; + } + var asGson = gson.toJsonTree( + objectMapper.convertValue(event.osinfo(), Object.class)); + + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.add("osinfo", asGson); + return status; + }); + + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java new file mode 100644 index 0000000..cf4ba72 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that pings the guest agent. + */ +public class QmpGuestGetOsinfo extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-get-osinfo"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestGetOsinfo()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java new file mode 100644 index 0000000..75fdf73 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that requests the guest info. + */ +public class QmpGuestInfo extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-info"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestInfo()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java new file mode 100644 index 0000000..257c838 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that pings the guest agent. + */ +public class QmpGuestPing extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-ping"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestPing()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java new file mode 100644 index 0000000..a1b585d --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java @@ -0,0 +1,63 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * An {@link Event} that causes some component to send a QMP + * command to the guest agent process. + */ +public class GuestAgentCommand extends Event { + + private final QmpCommand command; + + /** + * Instantiates a new exec qmp command. + * + * @param command the command + */ + public GuestAgentCommand(QmpCommand command) { + this.command = command; + } + + /** + * Gets the command. + * + * @return the command + */ + public QmpCommand command() { + return command; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)) + .append(" [").append(command); + 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/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java index df981c8..e35a172 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 @@ -35,7 +35,7 @@ public class MonitorEvent extends Event { */ public enum Kind { READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, - SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED + SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED, VSERPORT_CHANGE } private final Kind kind; @@ -72,6 +72,9 @@ public class MonitorEvent extends Event { case SPICE_DISCONNECTED: return Optional.of(new SpiceDisconnectedEvent(kind, response.get(EVENT_DATA))); + case VSERPORT_CHANGE: + return Optional.of(new VserportChangeEvent(kind, + response.get(EVENT_DATA))); default: return Optional .of(new MonitorEvent(kind, response.get(EVENT_DATA))); 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 new file mode 100644 index 0000000..294ac7b --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java @@ -0,0 +1,43 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class OsinfoEvent extends Event { + + private final JsonNode osinfo; + + /** + * Instantiates a new osinfo event. + * + * @param data the data + */ + public OsinfoEvent(JsonNode data) { + osinfo = data; + } + + public JsonNode osinfo() { + return osinfo; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java new file mode 100644 index 0000000..b590cd3 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java @@ -0,0 +1,56 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a virtual serial port's open state change. + */ +public class VserportChangeEvent extends MonitorEvent { + + /** + * Initializes a new instance. + * + * @param kind the kind + * @param data the data + */ + public VserportChangeEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Return the channel's id. + * + * @return the string + */ + @SuppressWarnings("PMD.ShortMethodName") + public String id() { + return data().get("id").asText(); + } + + /** + * Returns the open state of the port. + * + * @return true, if is open + */ + public boolean isOpen() { + return Boolean.parseBoolean(data().get("open").asText()); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index 5d10b54..e2610ba 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -122,7 +122,7 @@ # Best explanation found: # https://fedoraproject.org/wiki/Features/VirtioSerial - [ "-device", "virtio-serial-pci,id=virtio-serial0" ] - # - Guest agent serial connection + # - Guest agent serial connection. MUST have id "channel0"! - [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\ chardev=guest-agent-socket" ] - [ "-chardev","socket,id=guest-agent-socket,\ From 0ded0ff9a9143eb7e41200014f62a7c0e8559014 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 16 Feb 2025 21:06:49 +0100 Subject: [PATCH 108/274] Add usage info. --- dev-example/pool-action.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dev-example/pool-action.sh b/dev-example/pool-action.sh index b605479..bc8fbce 100755 --- a/dev-example/pool-action.sh +++ b/dev-example/pool-action.sh @@ -7,6 +7,10 @@ Applys action to all VMs in the pool. --context Context to be passed to kubectl (required) -n, --namespace Namespace to be passed to kubectl + +Action is one of "start", "stop", "delete" or "delete-disks" + +Defaults for context and namespace are read from .vm-operator-cmd.rc. EOF exit 1 } From 3e713b4ff2434013d509a08aa577fc7fc1821085 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 17 Feb 2025 09:49:06 +0100 Subject: [PATCH 109/274] Extend comment. --- .../vmoperator/manager/DisplaySecretReconciler.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 0665d32..dcae3a3 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 @@ -41,13 +41,16 @@ import org.jose4j.base64url.Base64; /** * Delegee for reconciling the display secret */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) /* default */ class DisplaySecretReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); /** - * Reconcile. + * Reconcile. If the configuration prevents generating a secret + * or the secret already exists, do nothing. Else generate a new + * secret with a random password and immediate expiration, thus + * preventing access to the display. * * @param event the event * @param model the model From ec8152bd518572517f8f68cac63fdb2dd40dbb8f Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 17 Feb 2025 20:47:00 +0100 Subject: [PATCH 110/274] Add booted state. --- .../runner/qemu/GuestAgentClient.java | 5 -- .../vmoperator/runner/qemu/Runner.java | 60 ++++++++++++------- .../vmoperator/runner/qemu/StatusUpdater.java | 15 +++-- .../runner/qemu/events/RunnerStateChange.java | 23 ++++++- 4 files changed, 70 insertions(+), 33 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 4d1c764..f3928f5 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 @@ -33,7 +33,6 @@ import java.util.logging.Level; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; -import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; import org.jgrapes.core.Channel; @@ -188,10 +187,6 @@ public class GuestAgentClient extends Component { logger.fine(() -> "guest agent(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); - if (response.has("QMP")) { - rep.fire(new MonitorReady()); - return; - } if (response.has("return") || response.has("error")) { QmpCommand executed = executing.poll(); logger.fine( 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 c8b9f44..b258e1a 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 @@ -61,6 +61,7 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.QmpConfigured; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; @@ -619,8 +620,8 @@ public class Runner extends Component { } /** - * On monitor ready. - * + * When the monitor is ready, send QEMU its initial configuration. + * * @param event the event */ @Handler @@ -629,28 +630,14 @@ public class Runner extends Component { } /** - * On configure qemu. - * - * @param event the event - */ - @Handler(priority = -1000) - public void onConfigureQemuFinal(ConfigureQemu event) { - if (state == RunState.STARTING) { - fire(new MonitorCommand(new QmpCont())); - state = RunState.RUNNING; - rep.fire(new RunnerStateChange(state, "VmStarted", - "Qemu has been configured and is continuing")); - } - } - - /** - * On configure qemu. + * Whenever a new QEMU configuration is available, check if it + * is supposed to trigger a reset. * * @param event the event */ @Handler public void onConfigureQemu(ConfigureQemu event) { - if (state == RunState.RUNNING) { + if (state.vmActive()) { if (resetCounter != null && event.configuration().resetCounter != null && event.configuration().resetCounter > resetCounter) { @@ -660,6 +647,36 @@ public class Runner extends Component { } } + /** + * As last step when handling a new configuration, check if + * QEMU is suspended after startup and should be continued. + * + * @param event the event + */ + @Handler(priority = -1000) + public void onConfigureQemuFinal(ConfigureQemu event) { + if (state == RunState.STARTING) { + state = RunState.BOOTING; + fire(new MonitorCommand(new QmpCont())); + rep.fire(new RunnerStateChange(state, "VmStarted", + "Qemu has been configured and is continuing")); + } + } + + /** + * Receiving the OSinfo means that the OS has been booted. + * + * @param event the event + */ + @Handler + public void onOsinfo(OsinfoEvent event) { + if (state == RunState.BOOTING) { + state = RunState.BOOTED; + rep.fire(new RunnerStateChange(state, "VmBooted", + "The VM has started the guest agent.")); + } + } + /** * On process exited. * @@ -675,6 +692,7 @@ public class Runner extends Component { mayBeStartQemu(QemuPreps.CloudInit); return; } + // No other process(es) may exit during startup if (state == RunState.STARTING) { logger.severe(() -> "Process " + procDef.name @@ -683,7 +701,9 @@ public class Runner extends Component { rep.fire(new Stop()); return; } - if (procDef.equals(qemuDefinition) && state == RunState.RUNNING) { + + // No processes may exit while the VM is running normally + if (procDef.equals(qemuDefinition) && state.vmActive()) { rep.fire(new Exit(event.exitValue())); } logger.info(() -> "Process " + procDef.name 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 d4548bf..f9644c8 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 @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023,2024 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -31,7 +31,6 @@ import io.kubernetes.client.openapi.JSON; import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; -import java.util.Set; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; @@ -66,9 +65,6 @@ public class StatusUpdater extends VmDefUpdater { private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); - private static final Set RUNNING_STATES - = Set.of(RunState.RUNNING, RunState.TERMINATING); - private long observedGeneration; private boolean guestShutdownStops; private boolean shutdownByGuest; @@ -186,16 +182,23 @@ public class StatusUpdater extends VmDefUpdater { } vmStub.updateStatus(vmDef, from -> { JsonObject status = from.statusJson(); - boolean running = RUNNING_STATES.contains(event.runState()); + boolean running = event.runState().vmRunning(); updateCondition(vmDef, vmDef.statusJson(), "Running", running, event.reason(), event.message()); + updateCondition(vmDef, vmDef.statusJson(), "Booted", + event.runState() == RunState.BOOTED, event.reason(), + event.message()); if (event.runState() == RunState.STARTING) { status.addProperty("ram", GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); status.addProperty("cpus", 1); + + // In case we had an irregular shutdown + status.remove("osinfo"); } else if (event.runState() == RunState.STOPPED) { status.addProperty("ram", "0"); status.addProperty("cpus", 0); + status.remove("osinfo"); } // In case console connection was still present diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java index bb6ab10..829cc88 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.runner.qemu.events; +import java.util.EnumSet; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -29,10 +30,28 @@ import org.jgrapes.core.Event; public class RunnerStateChange extends Event { /** - * The state. + * The states. */ public enum RunState { - INITIALIZING, STARTING, RUNNING, TERMINATING, STOPPED + INITIALIZING, STARTING, BOOTING, BOOTED, TERMINATING, STOPPED; + + /** + * Checks if the state is one of the states in which the VM is running. + * + * @return true, if is running + */ + public boolean vmRunning() { + return EnumSet.of(BOOTING, BOOTED, TERMINATING).contains(this); + } + + /** + * Checks if the state is one of the states in which the VM is active. + * + * @return true, if is active + */ + public boolean vmActive() { + return EnumSet.of(BOOTING, BOOTED).contains(this); + } } private final RunState state; From bccc4ac2190bc3bf1f8099e69d3b0977ca648229 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 18 Feb 2025 12:15:50 +0100 Subject: [PATCH 111/274] Add pretty os name to displayed data. --- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html | 3 +++ 1 file changed, 3 insertions(+) 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 4dfc8d7..6ec6ce3 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 @@ -129,6 +129,9 @@
{{ localize("maximumCpus") }} + (conlet.element().querySelector( - ":scope .jdrupes-vmoperator-vmviewer-preview"))!; + ":scope .jdrupes-vmoperator-vmaccess-preview"))!; api.vmName = vmName; }); -JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmviewer.VmViewer", +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", "updateVmDefinition", function(conletId: string, vmDefinition: any) { const conlet = JGConsole.findConletPreview(conletId); if (!conlet) { return; } const api = getApi(conlet.element().querySelector( - ":scope .jdrupes-vmoperator-vmviewer-preview"))!; + ":scope .jdrupes-vmoperator-vmaccess-preview"))!; // Add some short-cuts for rendering vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; @@ -173,13 +173,13 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmviewer.VmViewer", api.vmDefinition = vmDefinition; }); -JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmviewer.VmViewer", +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", "openConsole", function(_conletId: string, mimeType: string, data: string) { let target = document.getElementById( - "org.jdrupes.vmoperator.vmviewer.VmViewer.target"); + "org.jdrupes.vmoperator.vmaccess.VmAccess.target"); if (!target) { target = document.createElement("iframe"); - target.id = "org.jdrupes.vmoperator.vmviewer.VmViewer.target"; + target.id = "org.jdrupes.vmoperator.vmaccess.VmAccess.target"; target.setAttribute("name", target.id); target.setAttribute("style", "display: none;"); document.querySelector("body")!.append(target); @@ -188,7 +188,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmviewer.VmViewer", window.open(url, target.id); }); -window.orgJDrupesVmOperatorVmViewer.initEdit = (dialogDom: HTMLElement, +window.orgJDrupesVmOperatorVmAccess.initEdit = (dialogDom: HTMLElement, isUpdate: boolean) => { if (isUpdate) { return; @@ -209,7 +209,7 @@ window.orgJDrupesVmOperatorVmViewer.initEdit = (dialogDom: HTMLElement, const conlet = JGConsole.findConletPreview(conletId); if (conlet) { const api = getApi(conlet.element().querySelector( - ":scope .jdrupes-vmoperator-vmviewer-preview"))!; + ":scope .jdrupes-vmoperator-vmaccess-preview"))!; vmNameInput.value = api.vmName; } @@ -222,7 +222,7 @@ window.orgJDrupesVmOperatorVmViewer.initEdit = (dialogDom: HTMLElement, app.mount(dialogDom); } -window.orgJDrupesVmOperatorVmViewer.applyEdit = +window.orgJDrupesVmOperatorVmAccess.applyEdit = (dialogDom: HTMLElement, apply: boolean) => { if (!apply) { return; @@ -233,7 +233,7 @@ window.orgJDrupesVmOperatorVmViewer.applyEdit = JGConsole.notifyConletModel(conletId, "selectedVm", vmName); } -window.orgJDrupesVmOperatorVmViewer.confirmReset = +window.orgJDrupesVmOperatorVmAccess.confirmReset = (conletType: string, conletId: string) => { JGConsole.instance.closeModalDialog(conletType, conletId); JGConsole.notifyConletModel(conletId, "resetConfirmed"); diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-style.scss b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss similarity index 87% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-style.scss rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss index 3ee432a..547dc74 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-style.scss +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss @@ -19,7 +19,7 @@ /* * Conlet specific styles. */ -.jdrupes-vmoperator-vmviewer { +.jdrupes-vmoperator-vmaccess { span[role="button"].svg-icon { display: inline-block; @@ -47,7 +47,7 @@ } } -.jdrupes-vmoperator-vmviewer.jdrupes-vmoperator-vmviewer-preview { +.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-preview { img { height: 3em; @@ -58,7 +58,7 @@ } } - .jdrupes-vmoperator-vmviewer-preview-action-list { + .jdrupes-vmoperator-vmaccess-preview-action-list { white-space: nowrap; } @@ -76,13 +76,13 @@ } } -.jdrupes-vmoperator-vmviewer.jdrupes-vmoperator-vmviewer-edit { +.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-edit { select { width: 15em; } } -.jdrupes-vmoperator-vmviewer.jdrupes-vmoperator-vmviewer-confirm-reset { +.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-confirm-reset { p { text-align: center; } diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/l10nBundles-stub.d.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub.d.ts similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/l10nBundles-stub.d.ts rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub.d.ts diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/package-info.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java similarity index 89% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/package-info.java rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java index 9a4045a..745ded7 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/package-info.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023, 2024 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 @@ -16,4 +16,4 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.vmviewer; \ No newline at end of file +package org.jdrupes.vmoperator.vmaccess; diff --git a/org.jdrupes.vmoperator.vmviewer/tsconfig.json b/org.jdrupes.vmoperator.vmaccess/tsconfig.json similarity index 92% rename from org.jdrupes.vmoperator.vmviewer/tsconfig.json rename to org.jdrupes.vmoperator.vmaccess/tsconfig.json index 6418f59..d9dbb3f 100644 --- a/org.jdrupes.vmoperator.vmviewer/tsconfig.json +++ b/org.jdrupes.vmoperator.vmaccess/tsconfig.json @@ -14,7 +14,7 @@ "aash-plugin": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/aash-vue-components/lib/AashPlugin"], "jgconsole": ["./build/unpacked/org/jgrapes/webconsole/base/JGConsole"], "jgwc": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/jgwc-vue-components/jgwc-components"], - "l10nBundles": ["./src/org/jdrupes/vmoperator/vmviewer/browser/l10nBundles-stub"], + "l10nBundles": ["./src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub"], "vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"] } }, diff --git a/org.jdrupes.vmoperator.vmviewer/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmviewer/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory deleted file mode 100644 index ebe4408..0000000 --- a/org.jdrupes.vmoperator.vmviewer/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory +++ /dev/null @@ -1 +0,0 @@ -org.jdrupes.vmoperator.vmviewer.VmViewerFactory diff --git a/settings.gradle b/settings.gradle index 64f3056..a32eaf8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,6 +12,8 @@ rootProject.name = 'VM-Operator' include 'org.jdrupes.vmoperator.manager' include 'org.jdrupes.vmoperator.manager.events' +include 'org.jdrupes.vmoperator.poolaccess' +include 'org.jdrupes.vmoperator.vmaccess' include 'org.jdrupes.vmoperator.vmconlet' include 'org.jdrupes.vmoperator.vmviewer' include 'org.jdrupes.vmoperator.runner.qemu' diff --git a/webpages/vm-operator/VmViewer-preview.png b/webpages/vm-operator/VmAccess-preview.png similarity index 100% rename from webpages/vm-operator/VmViewer-preview.png rename to webpages/vm-operator/VmAccess-preview.png diff --git a/webpages/vm-operator/user-gui.md b/webpages/vm-operator/user-gui.md index ce46e8f..416e243 100644 --- a/webpages/vm-operator/user-gui.md +++ b/webpages/vm-operator/user-gui.md @@ -11,7 +11,7 @@ The idea of the user view is to provide an intuitive widget that allows the users to access their own VMs and to optionally start and stop them. -![VM-Viewer](VmViewer-preview.png) +![VM-Viewer](VmAccess-preview.png) The configuration options resulting from this seemingly simple requirement are unexpectedly complex. @@ -62,7 +62,7 @@ objects that either specify a role or a user. "/ConsoleWeblet": "/WebConsole": "/ComponentCollector": - "/VmViewer": + "/VmAccess": syncPreviewsFor: - role: user - user: test From eabb2d9cf02a7fbdc09d166fa070e8fa0a515b4d Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 12:52:42 +0100 Subject: [PATCH 042/274] Add method. --- .../org/jdrupes/vmoperator/common/VmPool.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 aaa2746..8da0a9f 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 @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.common; +import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; @@ -25,6 +26,9 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.util.DataPath; /** * Represents a VM pool. @@ -97,6 +101,24 @@ public class VmPool { return builder.toString(); } + /** + * Collect all permissions for the given user with the given roles. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + return permissions.stream() + .filter(g -> DataPath.get(g, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(g, "role").map(roles::contains).orElse(false)) + .map(g -> DataPath.> get(g, "may") + .orElse(Collections.emptySet()).stream()) + .flatMap(Function.identity()).collect(Collectors.toSet()); + } + /** * A permission grant to a user or role. * From 5efef2a083f9366cb622e5473bcebac3e1d66190 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 12:54:29 +0100 Subject: [PATCH 043/274] Deliver events on dedicated pipeline. --- .../vmoperator/manager/PoolManager.java | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java index f47c569..fb1de27 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java @@ -43,10 +43,14 @@ import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; +import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Attached; /** - * Watches for changes of VM pools. + * Watches for changes of VM pools. Reports the changes using + * {@link VmPoolChanged} events fired on a special pipeline to + * avoid concurrent change informations. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) public class PoolManager extends @@ -55,6 +59,7 @@ public class PoolManager extends private final ReentrantLock pendingLock = new ReentrantLock(); private final Map> pending = new ConcurrentHashMap<>(); private final Map pools = new ConcurrentHashMap<>(); + private EventPipeline poolPipeline; /** * Instantiates a new VM pool manager. @@ -67,6 +72,19 @@ public class PoolManager extends K8sDynamicModels.class); } + /** + * On attached. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public void onAttached(Attached event) { + if (event.node() == this) { + poolPipeline = newEventPipeline(); + } + } + @Override protected void prepareMonitoring() throws IOException, ApiException { client(new K8sClient()); @@ -96,7 +114,7 @@ public class PoolManager extends pending.computeIfAbsent(poolName, k -> Collections .synchronizedSet(new HashSet<>())).addAll(p.vms()); pools.remove(poolName); - fire(new VmPoolChanged(p, true)); + poolPipeline.fire(new VmPoolChanged(p, true)); }); } finally { pendingLock.unlock(); @@ -138,7 +156,7 @@ public class PoolManager extends }); pending.remove(poolName); pools.put(poolName, vmPool); - fire(new VmPoolChanged(vmPool)); + poolPipeline.fire(new VmPoolChanged(vmPool)); } finally { pendingLock.unlock(); } @@ -164,7 +182,7 @@ public class PoolManager extends pending.computeIfAbsent(p, k -> Collections .synchronizedSet(new HashSet<>())).add(vmName); } - fire(new VmPoolChanged(pools.get(p))); + poolPipeline.fire(new VmPoolChanged(pools.get(p))); }); } finally { pendingLock.unlock(); @@ -175,7 +193,7 @@ public class PoolManager extends pendingLock.lock(); pools.values().stream().forEach(p -> { if (p.vms().remove(vmName)) { - fire(new VmPoolChanged(p)); + poolPipeline.fire(new VmPoolChanged(p)); } }); // Should not be necessary, but just in case From 22446c361873885a823452b5888cdc601de11902 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 12:54:57 +0100 Subject: [PATCH 044/274] Clarify documentation. --- .../src/org/jdrupes/vmoperator/manager/AbstractMonitor.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 43f4287..2deb9ab 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 @@ -246,7 +246,9 @@ public abstract class AbstractMonitor Date: Sat, 23 Nov 2024 12:55:48 +0100 Subject: [PATCH 045/274] Remove poolaccess conlet. --- settings.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index a32eaf8..50b3277 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,7 +12,6 @@ rootProject.name = 'VM-Operator' include 'org.jdrupes.vmoperator.manager' include 'org.jdrupes.vmoperator.manager.events' -include 'org.jdrupes.vmoperator.poolaccess' include 'org.jdrupes.vmoperator.vmaccess' include 'org.jdrupes.vmoperator.vmconlet' include 'org.jdrupes.vmoperator.vmviewer' From dc21dc8a7bcaaa9f89d8701e18bfcd655c6e6560 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 12:56:32 +0100 Subject: [PATCH 046/274] Test pools. --- dev-example/test-pool.yaml | 10 ++++++++++ dev-example/test-vm.tpl.yaml | 3 +++ 2 files changed, 13 insertions(+) create mode 100644 dev-example/test-pool.yaml diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml new file mode 100644 index 0000000..73bd6ab --- /dev/null +++ b/dev-example/test-pool.yaml @@ -0,0 +1,10 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VmPool +metadata: + namespace: vmop-dev + name: test-vms +spec: + permissions: + - user: admin + may: + - accessConsole diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index 6d8a89c..1ce8d95 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -30,6 +30,9 @@ spec: cloudInit: metaData: {} + pools: + - test-vms + vm: # state: Running bootMenu: true From c361f9296ddd1d311e3cbfefdae1499fcf6be695 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 13:03:04 +0100 Subject: [PATCH 047/274] Remove conlet. --- org.jdrupes.vmoperator.manager/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index f5ffe2c..cefac0b 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -33,7 +33,6 @@ dependencies { runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') runtimeOnly project(':org.jdrupes.vmoperator.vmconlet') - runtimeOnly project(':org.jdrupes.vmoperator.poolaccess') } application { From 2be88d0f34facddbccfa77c8b329cb9a404497fd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 13:06:51 +0100 Subject: [PATCH 048/274] Remove viewer. --- settings.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index 50b3277..be8546d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,7 +14,6 @@ include 'org.jdrupes.vmoperator.manager' include 'org.jdrupes.vmoperator.manager.events' include 'org.jdrupes.vmoperator.vmaccess' include 'org.jdrupes.vmoperator.vmconlet' -include 'org.jdrupes.vmoperator.vmviewer' include 'org.jdrupes.vmoperator.runner.qemu' include 'org.jdrupes.vmoperator.common' include 'org.jdrupes.vmoperator.util' From 4ceaaa9fa2371360c59c97e6f4e4a56ec0e77c68 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 14:08:01 +0100 Subject: [PATCH 049/274] Provide backward compatibility for configuration. --- dev-example/config.yaml | 2 +- dev-example/kustomization.yaml | 2 +- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 83 +++++++++++-------- webpages/vm-operator/upgrading.md | 13 +++ 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 1c80ab8..f2e0563 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -65,7 +65,7 @@ other: - org.jgrapes.webconlet.oidclogin.LoginConlet "/ComponentCollector": - "/VmViewer": + "/VmAccess": displayResource: preferredIpVersion: ipv4 syncPreviewsFor: diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml index f6e51b8..7dc4a15 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -79,7 +79,7 @@ patches: other: - org.jgrapes.webconlet.locallogin.LoginConlet "/ComponentCollector": - "/VmViewer": + "/VmAccess": displayResource: preferredIpVersion: ipv4 syncPreviewsFor: 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 b323084..e1a41ef 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 @@ -152,44 +152,57 @@ public class VmAccess extends FreeMarkerConlet { @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured(componentPath()).ifPresent(c -> { - try { - var dispRes = (Map) c - .getOrDefault("displayResource", Collections.emptyMap()); - switch ((String) dispRes.getOrDefault("preferredIpVersion", - "")) { - case "ipv6": - preferredIpVersion = Inet6Address.class; - break; - case "ipv4": - default: - preferredIpVersion = Inet4Address.class; - break; + event.structured(componentPath()) + .or(() -> { + var oldConfig = event.structured("/Manager/GuiHttpServer" + + "/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "component name \"VmViewer\", please update to " + + "\"VmAccess\""); } + return oldConfig; + }) + .ifPresent(c -> { + try { + var dispRes = (Map) c + .getOrDefault("displayResource", + Collections.emptyMap()); + switch ((String) dispRes.getOrDefault("preferredIpVersion", + "")) { + case "ipv6": + preferredIpVersion = Inet6Address.class; + break; + case "ipv4": + default: + preferredIpVersion = Inet4Address.class; + break; + } - // Delete connection file - deleteConnectionFile - = Optional.ofNullable(c.get("deleteConnectionFile")) - .filter(v -> v instanceof String).map(v -> (String) v) - .map(Boolean::parseBoolean).orElse(true); + // Delete connection file + deleteConnectionFile + = Optional.ofNullable(c.get("deleteConnectionFile")) + .filter(v -> v instanceof String) + .map(v -> (String) v) + .map(Boolean::parseBoolean).orElse(true); - // Users or roles for which previews should be synchronized - syncUsers = ((List>) c.getOrDefault( - "syncPreviewsFor", Collections.emptyList())).stream() - .map(m -> m.get("user")) - .filter(s -> s != null).collect(Collectors.toSet()); - logger.finest(() -> "Syncing previews for users: " - + syncUsers.toString()); - syncRoles = ((List>) c.getOrDefault( - "syncPreviewsFor", Collections.emptyList())).stream() - .map(m -> m.get("role")) - .filter(s -> s != null).collect(Collectors.toSet()); - logger.finest(() -> "Syncing previews for roles: " - + syncRoles.toString()); - } catch (ClassCastException e) { - logger.config("Malformed configuration: " + e.getMessage()); - } - }); + // Users or roles for which previews should be synchronized + syncUsers = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("user")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for users: " + + syncUsers.toString()); + syncRoles = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("role")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for roles: " + + syncRoles.toString()); + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); } private boolean syncPreviews(Session session) { diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index ec5da7e..a794ab6 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -5,6 +5,19 @@ layout: vm-operator # Upgrading +## To version 4.0.0 + +The VmViewer conlet has been renamed to VmAccess. This affects the +[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration information using the old path +"/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer" +is still accepted for backward compatibility, but should be updated. + +The change of name also causes conlets added to the overview page by +users to "disappear" from the GUI. They have to be re-added. + +The latter behavior also applies to the VmConlet conlet which has been +renamed to VmMgmt. + ## To version 3.4.0 Starting with this version, the VM-Operator no longer uses a stateful set From e839f7b3b2dd34a658ab776cc4142a20bfc66e59 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 23 Nov 2024 14:08:45 +0100 Subject: [PATCH 050/274] Rename conlet. --- org.jdrupes.vmoperator.manager/build.gradle | 2 +- ...apes.webconsole.base.ConletComponentFactory | 1 - .../.checkstyle | 0 .../.eclipse-pmd | 0 .../.eslintignore | 0 .../.eslintrc.json | 0 .../.gitignore | 0 .../.settings/org.eclipse.buildship.core.prefs | 0 .../.settings/org.eclipse.core.resources.prefs | 0 .../.settings/org.eclipse.core.runtime.prefs | 0 .../.settings/org.eclipse.jdt.ui.prefs | 0 .../build.gradle | 0 .../package.json | 0 ...apes.webconsole.base.ConletComponentFactory | 1 + .../vmmgmt/VmMgmt-l10nBundles.ftl.js | 0 .../vmoperator/vmmgmt/VmMgmt-preview.ftl.html | 4 ++-- .../vmoperator/vmmgmt/VmMgmt-view.ftl.html | 10 +++++----- .../jdrupes/vmoperator/vmmgmt}/l10n.properties | 2 +- .../vmoperator/vmmgmt}/l10n_de.properties | 2 +- .../vmoperator/vmmgmt}/l10n_en.properties | 0 .../rollup.config.mjs | 4 ++-- .../jdrupes/vmoperator/vmmgmt}/TimeSeries.java | 2 +- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 14 +++++++------- .../vmoperator/vmmgmt/VmMgmtFactory.java | 10 +++++----- .../browser/ConditionalInputController.ts | 0 .../vmoperator/vmmgmt}/browser/CpuRamChart.ts | 0 .../vmoperator/vmmgmt}/browser/MemorySize.ts | 0 .../vmoperator/vmmgmt}/browser/TimeSeries.ts | 0 .../vmmgmt/browser/VmMgmt-functions.ts | 18 +++++++++--------- .../vmmgmt/browser/VmMgmt-style.scss | 8 ++++---- .../vmmgmt}/browser/l10nBundles-stub.d.ts | 0 .../vmoperator/vmmgmt}/package-info.java | 2 +- .../tsconfig.json | 2 +- settings.gradle | 2 +- 34 files changed, 42 insertions(+), 42 deletions(-) delete mode 100644 org.jdrupes.vmoperator.vmconlet/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.checkstyle (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.eclipse-pmd (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.eslintignore (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.eslintrc.json (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.gitignore (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.settings/org.eclipse.buildship.core.prefs (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.settings/org.eclipse.core.resources.prefs (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.settings/org.eclipse.core.runtime.prefs (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/.settings/org.eclipse.jdt.ui.prefs (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/build.gradle (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/package.json (100%) create mode 100644 org.jdrupes.vmoperator.vmmgmt/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory rename org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-l10nBundles.ftl.js => org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js (100%) rename org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-preview.ftl.html => org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html (88%) rename org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html => org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html (93%) rename {org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt}/l10n.properties (92%) rename {org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt}/l10n_de.properties (94%) rename {org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt}/l10n_en.properties (100%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/rollup.config.mjs (93%) rename {org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt}/TimeSeries.java (98%) rename org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java (97%) rename org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConletFactory.java => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmtFactory.java (85%) rename {org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt}/browser/ConditionalInputController.ts (100%) rename {org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt}/browser/CpuRamChart.ts (100%) rename {org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt}/browser/MemorySize.ts (100%) rename {org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt}/browser/TimeSeries.ts (100%) rename org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts (93%) rename org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss (91%) rename {org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt}/browser/l10nBundles-stub.d.ts (100%) rename {org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet => org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt}/package-info.java (94%) rename {org.jdrupes.vmoperator.vmconlet => org.jdrupes.vmoperator.vmmgmt}/tsconfig.json (91%) diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index cefac0b..b581971 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -31,8 +31,8 @@ dependencies { runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' runtimeOnly 'org.apache.logging.log4j:log4j-to-jul:2.20.0' + runtimeOnly project(':org.jdrupes.vmoperator.vmmgmt') runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') - runtimeOnly project(':org.jdrupes.vmoperator.vmconlet') } application { diff --git a/org.jdrupes.vmoperator.vmconlet/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmconlet/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory deleted file mode 100644 index 5a22dc7..0000000 --- a/org.jdrupes.vmoperator.vmconlet/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory +++ /dev/null @@ -1 +0,0 @@ -org.jdrupes.vmoperator.vmconlet.VmConletFactory diff --git a/org.jdrupes.vmoperator.vmconlet/.checkstyle b/org.jdrupes.vmoperator.vmmgmt/.checkstyle similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.checkstyle rename to org.jdrupes.vmoperator.vmmgmt/.checkstyle diff --git a/org.jdrupes.vmoperator.vmconlet/.eclipse-pmd b/org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.eclipse-pmd rename to org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd diff --git a/org.jdrupes.vmoperator.vmconlet/.eslintignore b/org.jdrupes.vmoperator.vmmgmt/.eslintignore similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.eslintignore rename to org.jdrupes.vmoperator.vmmgmt/.eslintignore diff --git a/org.jdrupes.vmoperator.vmconlet/.eslintrc.json b/org.jdrupes.vmoperator.vmmgmt/.eslintrc.json similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.eslintrc.json rename to org.jdrupes.vmoperator.vmmgmt/.eslintrc.json diff --git a/org.jdrupes.vmoperator.vmconlet/.gitignore b/org.jdrupes.vmoperator.vmmgmt/.gitignore similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.gitignore rename to org.jdrupes.vmoperator.vmmgmt/.gitignore diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.buildship.core.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.buildship.core.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.buildship.core.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.resources.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.resources.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.resources.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.runtime.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.runtime.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.runtime.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.jdt.ui.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.jdt.ui.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.jdt.ui.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/build.gradle b/org.jdrupes.vmoperator.vmmgmt/build.gradle similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/build.gradle rename to org.jdrupes.vmoperator.vmmgmt/build.gradle diff --git a/org.jdrupes.vmoperator.vmconlet/package.json b/org.jdrupes.vmoperator.vmmgmt/package.json similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/package.json rename to org.jdrupes.vmoperator.vmmgmt/package.json diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmmgmt/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory new file mode 100644 index 0000000..d7d7c8d --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory @@ -0,0 +1 @@ +org.jdrupes.vmoperator.vmmgmt.VmMgmtFactory diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-l10nBundles.ftl.js b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-l10nBundles.ftl.js rename to org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-preview.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html similarity index 88% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-preview.ftl.html rename to org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html index 0c6aa37..8c9970a 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-preview.ftl.html +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html @@ -1,6 +1,6 @@ -
diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html similarity index 93% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html rename to org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html index 0af656b..6b46faa 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html @@ -1,7 +1,7 @@ -
-
@@ -48,6 +48,11 @@ >{{ shortDateTime(entry[key].toString()) }} {{ formatMemory(entry[key]) }} + +
+ + + From 777ae73c745ecb769b3196dd468fc03b9d63e4fe Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Tue, 18 Feb 2025 16:50:43 +0100 Subject: [PATCH 112/274] Add OS icons. --- .../vmoperator/vmaccess/osicons/Licenses.txt | 16 + .../vmoperator/vmaccess/osicons/almalinux.svg | 16 + .../vmoperator/vmaccess/osicons/arch.svg | 20 + .../vmoperator/vmaccess/osicons/debian.svg | 9 + .../vmoperator/vmaccess/osicons/fedora.svg | Bin 0 -> 41315 bytes .../vmoperator/vmaccess/osicons/tux.svg | 438 ++++++++++++++++++ .../vmoperator/vmaccess/osicons/ubuntu.svg | 8 + .../vmoperator/vmaccess/osicons/unknown.svg | 84 ++++ .../vmoperator/vmaccess/osicons/windows.svg | 1 + .../vmaccess/browser/VmAccess-functions.ts | 33 +- .../vmaccess/browser/VmAccess-style.scss | 14 + 11 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/tux.svg create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg create mode 100644 org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt new file mode 100644 index 0000000..f67978f --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt @@ -0,0 +1,16 @@ +almalinux.svg: + Source: https://commons.wikimedia.org/wiki/File:AlmaLinux_Icon_Logo.svg + License: https://github.com/AlmaLinux/wiki/blob/master/LICENSE + +archlinux.svg: + Source: https://commons.wikimedia.org/wiki/File:Arch_Linux_%22Crystal%22_icon.svghttps://commons.wikimedia.org/wiki/File:Arch_Linux_%22Crystal%22_icon.svg + License: GPL v2 or later + +debian.svg: + Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg + License : LGPL + +tux.svg: + Source: https://commons.wikimedia.org/wiki/File:Tux.svghttps://commons.wikimedia.org/wiki/File:Tux.svg + License: Creative Commons CC0 1.0 Universal Public Domain Dedication. Creative Commons CC0 1.0 Universal Public Domain Dedication. + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg new file mode 100644 index 0000000..b2e050a --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg new file mode 100644 index 0000000..ca8204c --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg new file mode 100644 index 0000000..685f632 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg new file mode 100644 index 0000000000000000000000000000000000000000..78717917f660f45dd4c2e9d11c8af3b32c901589 GIT binary patch literal 41315 zcmXV21yoc|7oVji6cj{4SOr8vQc_|ufF(q_MWsZfq@-6&q`M@PkmgT`G!`M<9ZE=p zbS~e#^*efw@!p%cbMNo=9Rlv$R-&O~rUU@cV3lub0YC!(O1w-#2LGVFcU}|zL1L|- zt^hz$DAnP8QuuGQxw4iz0G^isz`X!qAO06^4ggmn02b~6ARPw)qvN|uO&R!r?18G% zO>m6-O0D@A3IB)O1*?9Ge2$!mf`{(psb*LBF~Htb(0Mw%H0BX!qWcX0cW2bNPq#*J zzI}Y?8r6vt2eq7!nQ4{%11LH9Lxj>!wr5R=EU2+II`#gQcq=NMdO89;x`+j+Tj;#v z8$>VolU^=)d79-eBu}uTKUgdm%#0gPbZqQqadP%KjOBQBcy&k(Pyk>tHa*kA5pBF5 zYs``zruNdd`e#(y6T|4AS@lnOV}%Ry^25wOGz_2SI}`i(eGpGkzap3R1;YX58S*_o z-c#!xzauEQxHyk=^vyqYkm7^BvnI4SL3DkIdC8(kYp= zFW64(O{^Y%m zUPgaXHnPjpYZ_#TFk}iC$Tya0Nmu^&8AVcjpm-Gbwa7+e^V@EDAten#v1K2X--e%D zzf+#RrMJz%wDGoRJ6hbeb{hfIO=1FLNE#UG473;MKXbFpyEG@;=ie6Zrj0sJN1d528CoOt?Gt0WQ1Y15CPhQA0jNu0W+N&mQZHN3&hM1uq zOP5+#myo>n2%YO9n7MR|(yvCc%LWHgVk;%8e}aZ@@Dol-kzL`OztOO^6)Dqr_hG(( zNO#49Be}4`{&O}J(QM0Z<}L9T2++jg5{uUIQT>V?!o`e+^CH;i%B1yXe=Rl>WMmhV zLt{Lm`hf`oKfjP_`%r@=AoPaHh(W{{>zv+FSs>IxWr;P{R#Zg7i1*y!$8znuZJ@_k`fwQlwrxuOIs~imPQ$2_kzD~ z{w>O3B`_$&X;ZDyG@Qs8jHUxkbh+n6_Jf0J%;%Sc`A!htO5J&mzs2%1(zKZ`U#uk9 z<-$ZFn5@bj($6auni;P0h#;J#UO4{RfHa>{-nEvd))<%I=(ku>;JupEMr?Vu*X8P! zs-s!$)BnWnct^j@_*%bn>y}8RUN`zQ3RgeOlUrr^ZVzgOXQU(nW>*nYUaiF*y`)|& z9eNX>UG%F8&uPf3UaIk0Ii4OoON2t~n8$2Rl|-*cb1%20OIkWJT&r_2Y7=MfdQdYE z>>DMCpd0fBGC2o5WayX?T@yfa1ki;wT;^w8u923@KW*zI`%S>rg)d{ip#Fjo9{{8< zv>^CkU1Kthl7@p)i%T0mpA zokkQPj03UgWps;zqQ&L^WwQhHbH>)id|~t?y{i5XpRrbB?25bb@c8jl>d4*Wm zhhM!Bp?VPjcGKH+_k)j8-debPNs^#mH<3(s`ewj!ce?t1ID zyysfNdzxv&b|Lo`^0$hfX6y(XZs=mou_S<2=t{(_2lh|hN|jTu-apRz&C~=R$@pLu ziIEo^$Mm)b6jyd)+L^X45tvh7@Au`^g%cUIbIKA%*>N`kxM0pDYZ1IxXj!tDj6R7_ zv)*3nk^7oy;7AE85pezp1@?YPY0O^RX=B?mPG&`pg_$#T-KnG#&k!`8SH#ktMuF+~ z1C~ptF4>*)85pf-;2^B?Rcx)U#Mk@h-`d9(1!qIoi2){JJgR6S^t<9j&C?hx9XVpc zzp(9gn28EowlXs95<|UZ$?tM!A$O>o3gFR>AaEL!s+MBrml<@FkgmE{fC7_QkA)iW zA6Zld3}kG*B?#x5v=u$upE7NDaAl*to*e*I4n8}lJbYSZ$zI5agB+IvL72NgH%S*8 zu=|hX+-WN@w*ACKLvx6x zVZk_JKLQ4-_y-wEri(tm#upoEZ!;sv+?;&$75dZxKb_)<@T(CR0M^7hevU8STGn4V z{yun~z`J_Qc)2>fntAncv50jzjOU+q3*@pZQI4(hCyNya5U7^S%AL(WzgRIl!P&-1 z0|1d-UC^Gd^>g3dwJWZsgk^A?^KZ=xn9gA(j~`{QaNUARh3hm~DU957Wjjv+5z*uv z%Wq(4VnJE=IIk9+{RRSyVo|rwLoXR`Gw0Fy#8d|u_@2R|~L6_+oZB)p-PQs-IvuIZHJvJQ0h zpfROW}A7ziHGK#0XrS1hfxMU!S1R7A+}HD92EHju?FHmJ6GbGVBNn#EHH zIn3&227bYLNAJ@gA&17#p*9VEI^nnRLuvhI5Fw_<7VOR>JPUtuP^ag{40i(T3qt+A zg_R{UBjpp`D*gm9HU2abT`t(yV9H%p<>jD-d~oJF@8RdX`mRXu3@Kb@^0H}>o%B++dr z!l@6Vp#t2FXce?@6+sS7`=8>z;cIjJ{4d+ZgaBg5|Ml_KTgFt8rspT3`s7vKhCx-L zh_@@=uY!ERU(tks!Hg$}`z_lv#O*~iL_iIpsHmzM8=N4xD`XQ~wNsCspwiCSXurGt zzN%VL5OkR2LA#&|o93m;1A>&VJM8;!o#Aw-O^;M224Fh-$C&++l@)?V%;g$~*T)Vj zZ^Pl%O%84)77o)`6qE-oZ_i2|yA7+?$8=}pqsfjdxNzV8oRAKA5o;|LC*)h4hE2a( zRW4pTr0=*yOZXY&6Kk*H)ch;1fl%MKm!U$S9W&dEWm|8K?m^cL;R8Z0bLAc{JQg|L z|EIkWKhK2Bzf|VtZ=S)NFzp>DE?KmRFqWk1nu4p8_nRqBp176*suVDM%ob^AbNf zj_EcmxM6h(8!+UuE}HtRx|*ADBQ!RBjt|s}PM1!T|J1!jc)|62gj}6V``j@H;dvb2 zs*z9iR7D_XFc-98xOX8IF~b&{4VDtr4xH4JbCNd!YZ3%YyOZpW6xlIkl-xZrQ~L)q zn|oW528CLWn1RE1LZlMb?x~DxMWpEzGTYei>nY5#pJaFSQ8< zyU-y@`@(b5xr#;O@Ce4eRajyu!Q}X&|L~l)%QX?N2q2IP@@*|q42V~?VlR&r3JYbu zh`DvxOb)n~bqc z8(SGAi?yossn9e(oINai9wAZ}-o=C*9AMqKuc_F3PR>4CzKMch<}5Jzylv8$@FQX$ zp#hBl(M?n_En9l!pu}{D=nVR{NWnq(Ch6860;aLv2AP{@`N2Q1-m~Ix|Mq?MpR-Wku5^*<6OL4l{Ty$>h_{Y zY#=MwDs0^QL$8L^m~}fLRN~lnZ2=J z&bW{jjSZTAkc9TVu-&i9L{?ii^EFsLz>ODoU+xg|tA2#93{)n*|AiWmjQ^`Tkso2Y zuMoDls@fb^cqPen$MgdPK-i6*S-Tve+rK45D#H*Eg()2C_jGwvB@+8XU3eD1F>q3a zc_TF)OZYgb#FJlZ?rz!B{-#S`k9EQ^m{6v{xS! zy{V6Pvp(WVc1pG)P{eif70RjVgJ4KBBV&ehHUp>6cJoB9VXt`#;TdX9$93uXOEhsPubx zZQv=gMCxlRytl5d$XQd~+zrI|0Xa6qqf4jx-jzJnvm?I`r68#oD`+4p^B`%WB`XBs z0WGepPdmKe`zE2bMR-L;f6P#R+<9r0i{>Jtt|U#51n0fyYFU)*xF~mN62Vt@t5IFQ zSAPb%32;H<8l(z#4x`=k<&qJtU~Pz|S`aKx_A$|7N8pM;&*J7RUz*AuoL2 zd};efvRIXq0dWn0sNiSWI7bg-!Lg7gr15Er4b_0p?fy|7p6dwMD2VwOT0D2ujX#Gp z8a^iiVP#hPUtK3e_2lUjwP0+3_NEN#8xuX7cSNL#9ysTC!P-io*uJ;~?}n8itbe^@ zp-Q!o<%q!_igEq%T3wk?*U(=U4pS9m`KC0~;Ie)7N>8A5JUxO`520N%-H4?iIkXMp z{s4N_peie~WM-HUp`1$vn1||#<=J092fXG?!V;DkIf?PS%q(~~_uH`Sd^!}xi*`$Q zhXQ_O*Q49rHUwGC1t`ln?Gf@}B6>g(%(pT$*rA?@M-NS55m7zu5KRiHSR%MsZ zDsgJ;aBrGO4hJHIN#e9({fL62HDdkHAti*0dyopxIH$2jnD`Rj2pd=j<3N)Nn2q8y6^5)Tk<0O`-?1?R*0&K9uWI-3e`t7ON;ufE8`2Vrv0O&70x zB*9evM*`+q5A_Mk73MaQXML$e80{Iz1nv=5*d4hyH8_K~cPun}nA;{|er-1iP6wmc zt3{MrZa{=%JB62>l4(E;DQL>c?-$iJIEhQXM+QQM5PXh{o^;0}#%C#4EM#YJhN_>M zo065!3QWeCxbD1?7L4{@7;B7xAA_dg@?i6J(5ibk=oprR<_bP#7F`u ziPp@eDbYW6zlT}rfpXzIRsW9b#c_^*h91gG(G@GP*VLL)m~)Z(4nSYYY#Y(JFm-gd z+W^rDaQK}qh4+n|%MZc+s0h-_pp#E~HAhg?{x_Xr5(YsP?i3%GcPX#;*^MCO3q-uh zTzGuor?U;RUUli(1kkKGQ{C$j5PzwP)K)m)E|cfTk8>$cC!MbgcabB=MBy^Z1zX*X zf3m(lLxxav25=0Ep?WfjXja!SBDPxURq;?Mpyi&p-x z2&F_Gn(}|Pel7alJedmW??XkzA4DYGI1L|RaD2dA{+c;{xPtT9j_6ICe7v0Bn0)h` z7boJ0UNroHS+WnGFhtX8e9^tJK0Tw$Rxgo7@Be19mHRYZSvf%q51(q|o?p}XboK(4 zlmOKix6934hX@sr%a=h|qt$5Hw$5UFBUdy5skBCIA!3oMH3chTA0QOiDj)pX*DTmd zj_!>`j2f_!$yj`P&_ ziBEK4i@F*_Sm5w}hURUNtq~X>6_Ladf%oi(6GrTTUfaI^7Y+t5T>Oz!I<2l^{!?sP zx&jLKUvd9;-s%*rbczr{g0}!_^L@?V;t^%&TrP;2-#@BjiXS3y1*0gTX7=BTElIeB zgVg%hQqsi&iEbGJ>el4?n@q|rZjW6@$f9Wd>^$bt-^Bq-j|e*_3td# zgc5b`>i??TJ?ii;_=LGbP*}BBVM;mAZF*>b-oTn}mbahr=8S>L7*q@UrqmA_$m zMYM?rgsF<2?e#P5Zp1==0Wb{)J}wQWi?u4WUlgC8xz}^HAjvAPLZ4m=`BbDsv_Z{G zq-w<%5y4qg5B(#j5bKmk9YsVbL2-JYvzGOk`|@XrH;|2nLjN*nst84bufg$qU5mK- z@k(uxy`Uxum3AYs6{2`d*22#T2bU7zXX+|MQm_l~;1+jgViYFATj>eH54*PQ@7{2U z;&cr3xsYG_KL<0a9gs~AJBbX;XF6?SDC-UWP==t+I)uhES zOta9u1T#_f`zkcFUOa(3!&?@-^VL6MZ2IzN^)%h?Ks41r%8bG439KeTM3c@uOzNi6 zUYqO)9~$q?R;wRH-u}(NM1zz&VCq@aC^P#FKFI3*%??O zpJb=Ixb}KLsn<`tn-qc=^OWJUA{S|a%7c&(1)8m{^iC7;VN9XtZoPBIyPI39m|`^o zqIC7URU%IFx=!+)MZrCF{Rf``S2zuFpW1r3ij4iDNPtY&sS|OsChnGUhf@F+TZYV|Jd6- zlbtP^LA;Dzw$*r~VS7FJJhqDwG+nR_ivMKcHgJghxD64N>UDSi7BS7}+1@kl~tSxv=3 zjUgEfg2^vF(J+y*o<0kq)uamRx7IUn_9w8FNTRhyK5(v2kN%-fN}S?WfUSY9+Vh{3 zIwrL3`H^|i+ofEkHQC0-%o98$~x^Fv;Iq#>^?_0)JhKHB$Q%ZTLbjsB%f*2|uQ77BwD)RIBXAS3!r171?2nm?Qux6)x2Ty!TwpBjsTj zqO3B$4E;~#wtEd$HAN*!@hFUMsIpnMjf33AcA^HBR&ln7921krT7*|ctoK}emaU7T zU4TFV-@q>Z<>(iR&k=FC8%j|NhjbakV+ox-iAI~7nP=_n7A~=(Ck&FR3;$a(R}Q8^MQ<%#FF^=x$d;Z#I5NDt1@mG zBGme8?s%%0_H3^R7)lT=uaCIV-=Z`&3%?oz0WMkm#@uDL^R%g!e!m^y) z4Yyo2!-4dzgKH;9nnIt=XP#_)N9dEMhktSAr?Zfp{Myehv`Y>qo8Ep15jba<$rqBV z<0>3`d5@pX$)zhx#Pn*8nsH*~*7sLdNAGK~`D3k=;OkG7Z|2E6@)5IPinDietUFjZA7r5`l@i@^OXAKp?E<<^^Tea|%-P%rEp#tH))Xog#imz(fhE-GxFx5j{#AbeZvtu7Qv85Vb0|bhG%ji zub)T-9fp%W|4FsE=X_v0`n@&l)F={cbQNJix{>dX%^YXJbwPqc5_LiXl{h6bmCAOVSHWYmsc+U{} zs}Zy6*dwSzVX=%1iqd{hJ9m7-s&iM;U3o0!PHc6a=vn3psKF-3Cbo6&&4KS86{ z>1!H?q)ZhqwP_$(!+M|75#?5P&!elQ7ZuN~C29Kn-rs&rwm7eTrE;;%XLulnlO8Sk z^Nh;T_5QnGwQ%F+WEf@`D{fCk>~TQ@u&$16?JMKn?)u?6W1@qmt&Gq+m2cPUFfN%I z@wufyKYwm%R^HXrzh=8oaJnkyhBCxo+g=goWTI8-OG=!%h7&Q_~)grq1e zq}e)pJhPXoN>SXppD(|zp>3^lymQD_^IuuC5I8Oh>EqhDE3vPMxXG-4y@QjUUCP`r z?nCFv$yAz1ywgg{YRMp4ADLoFaBWoe>TBm9M%B?+NR;JF?JCWxJ>m#XgW;LEPKE0l zBT_s0nsa$!ZsX7PfcnQB%e7O=mG>;-@dxV{5;_aRgaCU{_u7LWb+=D9z7jnU{_OoF z;g_bA!@mmXebHhKH8V+f+%_3{xRVjsu6Ovh|*I>8dG zWADU%|K}(6)-a8xVBwfwcVgIYe{Bm1c)j<|MEukBiA1B^FEgYhM7uY>i*A3459IeM zInU)Ut@qY+DfKGgS$((;z2a3%i+C6Xlw}kpVsN7@ z7LhbGxv}_Nj}MX}?VC#XXFK$Dxu`zaa`wTj4b4v$rMMN{Oss4CWlSxV8=~$*id>!X z82<_IB)?Bpx^c{XDl7k{(^GzwBxSSzTseV5(=VRg+F!}I0oA9*5&uRjrXAq#{JvSu zPWvrfn0S&RbF2byG4)jN%?u~ng7Ns4rhqJTF3S?NQKr~4Cs+!ia^@?W5)!^*b1 zEeC=<;y5n@Pm2|k#cH_^_)tCRBBYz|bn+ z*X5{MUG3X@AX&}8G2mm=oecwMiX2tN*hNw6?I~eD+^KWefh3cBjbx*5y~gM&Me&Me zLAr0`b&+cY|9ZIzo>&HG{3$+UoAYi;d=%~khjRhJZTl1QE1EyoQ!!}OLE5!KA>v7& z57-R{#NaE4`FLl@{g`B5%WvlRxtY;StTVaJLt30J^x)kp-M5s#%Nrj9Gm%ELr>WKp zgvG3f9dZG$2Y|xb?8?cbKkMfoX*`9eHt$@VKO}T|IO9-jY-@cFLH4wEBHXKR8%#{AUKeO8KuaA?D4 ze>~Sc?RMM((XZjOPk{xRDd@WQzg7@W{}(Mt%WS*jwlKjqQgBk7j6@{6CsS(* zse~+h)1V7;8y@B?CKCAhWMT4w4}KI6B^<`2eP&=l;9S5lYD8C6ZTn*_(DyKqxCY^ufiYwJ+G3^e;+XYX7U>MDQT2R3YGU7 zzpIdLvOx^9JY4FW@#(&SOlqhqFGl|!1#S+N><3`wFl6Lk`VY}&@GP|~ zx({&Z%h1RyM4@snPdnYm$Vs{zWEaS0C!leyQm#MNw)C~ZG_h@P-iBR>2Nc=okgnKE zr#d^|p1Th`ED)P-VM3|^m>vWj_4gVtZmdivxRcfgkuF*_&rzTBfJH~a+nhezt-c9^ zb#H`lGA`em#iVT?o=^bs!u)zR$1z0c@Aj3;Gg;AlXB(wqjUzL-uy>C`fjwu3ztpRr_0 zV}WsP9TuGJIrUD0CFpMPJxiye--}#5DO0Rq(pu~XozHRc1>V4-d3K!pWBa*oeZO+` zBy6Y*R)4-uk#MjA^tp3Xi1d8*Cz*Zb521RZ4qv{H7WP?r%yx<* zJu&+2neY{d>6VT}M$-D3oxJ>*B}U0=_`ZA3ypLG%scU%R-PE4fQFA#{xfDdoqULSt z$S4y$Ip;;}1`XDdbr=BoDi^hv0_U3NDeK?+JkVlK68Rj5#$7$dB&#%$4k(`4Z0Mbe zbP;X53hFyv={2~FH9y`bpIi$H$NzeVXV%ffoWpp^n}}ME>ay=Q{&-yrgy{YlN5rX~ zfED8`+s#vdHnn>#T?KI*d{EjSH^{SatJLWH8%l}A$G71q91IWHuGq?bDWV2zC3R9| ziZ}S?a!{-_7Z^wrzU5s&w=v)Z-XU2wB@S17Y26`WP>~5t8e3EIAYl;+YBJ7LI`jI+ zff!nnuy)~e^SWCoS`4A5(bPUoO9pmzVG@pC392PQr8der>&f@gD?wfu6W{Adu5}#y zK@SwXCRh*DXPUK7!buu)P9)%oZivewOZ}rl$?CK64*z8EBv78ZCsM6JLNNn8kT!MR z)g%r^FsaWt5WTpIzXjJG2w61Fia&Dz$&j;0C?;1Dloc%WWWQhFM8S1Vu;ahq93jTUsUOyxNr(Z?m= zx-L+waz1K~5SUUX1-qJ-_iqlQXx*R%BC|OsqM41DI4IDvw-ajb9Wl@YIZKm+D^5G? zU{V`~Hu)WqDSXI@NAlQ>L_J#kP8 zMr$Of^k+r;iU!+66t_^0Gp1XO&2t>A2@sYE(wQbV_zWVc&|XlzXqKdi(p%DPsQP{4 zi0Zk6tm`sq<+@-;%X`L2pFOenH5$&LXFm`0SfB_i5dJ7UkqXwH<*wY7_^MnY2_2U2 z)r0g$3?+ZrajbjO*--s)8FZP00!WFb zUd@3X3!lgRl)iWM%qu|-8Da&(rxGk&wQTrB%lNP0q* z)}wiq?2Hy|{q9IkS58@vWSw)a)8}~3I?=lZe+bc3_}(O@2&raxS&&1?!5dm7vrhB6 zFL`Zq$kHLVNc7e@jG$qMl;zM?+T9Zii>Ee|HeeY|qZly+c+pz33(t>b272SWM`%nv ztKO?7vQ(DkXB&GBpMKO{kIs`>$$`I%g+46&enS?`koD;U29}C?%O!bl$cGKU^tC{$ z$msZB)cz~*Y?d64WtilednS9n^>8mI$3nrSB>nWs8_>4hE|`cn%I)fN>D{yN(k_Qp zkoRR?*GgeQ#I20e`)?(sNt(h8Df6K{k5J{M0$Ahu!Lg(su8|jw|687j1c*Yo@xL;S ztbP+^t`4Mse~`P8VT#e~gsA~}X*=Z;@DQCz#h!LviFZf33I^OtZ5)id%L|=*o(b4V zcW#Z#I|p~I<-|2?2QPlEeF+N>cNm;vCB(i5g+s?0TDvoS8y=w1NozYW!k&8CU4hnY zlZ#64EMu-?31@OH30ND@eHIRb>-RZV1kpiZC-=>U`!oNDm(TSrFE4nzkNKXX=I84S zAP#i#vcG2*8pp|)I&j;Fj4SU{ntdZ2rL5Z zc6fYifu+jM{i7t$$37)6>;16(g7IgHlah0}+g10)FJi&G!4=Z3jzzcGwLV~p-|Ezx zPhbC{T)F>3ZhxoIotCWgnx-Jbo`1Tr|E?_U<;YmUP@Tu;OTQ@JkX!+vRyvz`{`Kh5 zso#M+!U$qZ-b^r`RC>RH$M>T|GEvE7^K*bPSC#`&Q@vL z>Nd{$J?k5c?=IG*@w712&g$Q`ULacEMcvwO8l+5;$%<>FChM#_yq^dwanK;FE(vJd zdOhaxZLMA{`$R7_Zk}qPBbFbAW4KkdCA067@~Oj>vR6DdaBL<$Su0UoVQKfcYHCYN z3s)Zx;JUMra>q1X2LCCFu-r>J^KGWBM8GrKEVAcF7`yR&-Jf_H7_72OHNbh2t5drw z*D2!Ct&wtykLS>cQDx4vFMg+%A7kgZJC^&cV`}mz^6j+zS&8re#$BYz527RKG;Y}Q z&!bU0V2We(>>FcQsMdKDOr&oFz*l>nl+U&9Hdho#nij8(Y?UzXJo0juxSwta%a{dn z??c;}?()tO?~bg~wC(?8cRxeYsoGz}c%{m%)m-6e{xc>uFP>Frf*(iHsp}MPpq0Q+ z^i*Bl=~|}7i(0tbUOzr+74VsVf5p>Q!nXBj3CcK!H;9&`bMZ^gp7))*kI-+2T2{tQ z48s* zkYzd01e@K-Pxl%*X=cOVN^LyP19i1oxhU9GJoKo{#y&d8elk^08r|9ngoVd>cDBI} zf67ZwzY6t!s3vs*)G#_nW+(Ch&jX_1BYs$xuMf=($wrf~KUz_lhoSNNj9ljL#Pf$A zGx#!AWh=CiG@na${DOSe6~WHA1xW=^GkY}FdMlR_=86v+zV1inxk!`4WgK9UTFePU zr?A`l>d18e_jBXTh61;*YZXchiJQSnpXPxok@v5!n*7ATvtv`kP0{KO*bIxVdHI|I zOe+2P8{|CU0qd-_+Xwl>0}VKl%l#|My|L`WIqKw<6daAvXa2OpVLVlE8rop99=XTU zv|!GP0l@4mfPv^2B{rjxtX26k?J=IrDDpP^b~;?j?s0FN`7KQ)Ynqw&#u(;zUVVpR zXLvwJxY!CeSJAl>VDg%4pA!u)ADSZNGb}Uh?*)4Oc(OhT`wsgnXRd92S7c*|Q)T>* z8YokBIC)>As*feDhg}n=^tzc&6xi+B`&MzVO0L&^UU0cDLV|^YgPO5bZ~t#e)z*ku z@QZMc#;q&8sZV~YqiMLaY+;{nvZc{>w-^{mRpYhzX#O<8HG&tcxE)=h#mu}c+l5`#ANYc>vab~0O3=vq5G*6EtXFPNBkM|;Dpi-BomWD*_t1m&Eap@ zJT~$0zER?Pw2$&{;s}W9b59w3?_-Dk+GK^Q7XfI~f;cBwjHkriPAt1Sg}? zdOjbdh$2Q!BW2_8v_=6FB;vQ9}}*&-VoTaX{-SkWd$U_{J5 zIiBTCsRZlHe@efYQ2+U=kvx)iC3uVkh3!6;tuyvAIztjpYX0aq@7rQQi7>Wg2dm11 z-`cY9R?M5AS;u>j+(+HGi`ScqmHNfzRrLmgxc&L^{M2r}odDCJ8&K zr{7wgR{M%*(lpwb%6NLh6xl;$ujQoi!uz;L+(uI!XWOfB#hFSio1}&3#`o+w*pH*ow zoR!HZ|7iVt*jmdq_?}geuys6C!5@bIQ!%EJKKPqPY1VLlWnhr|V$UU* z|0uJY%lje&BC~cVq2+?DKX7V`1J3a3o7++{I2jzL(6mU1+b)ma_OI9Ua;u8id{EvVXSGRec3m)UZgAK@!Ci#*RxqaQuSH8}tO5mv6R_U2jT7S(5S!IKqII zC9=m7NF7`?gZXJuczio-vaglU=?Y(O^7opMNqrTQRw~xrJsWv_im$3+JC9Z* zHB&MttEKcZdQ4_0Pk^||2<3&HL5rQ+K9b%hLGY?_u>!E;1LB=-&1$ea`sod|^F zlYQplmf~|vx3OmvFgFX%O>YW5~+_io#Zt+EtjWmvK2EN?TFw?JCK&_6t7i6`bjO@-iOG|`|+W2{C1t;?88rs~Xm`hRgcL0cw9^%@J=YueeAROS`Wm3#AgtypV191yY z(MDsdTJ|A$*ixR@v6>1IN7DLeIQbfR6}NK=)RPLFEOnMRJJgp{41*4ma4R}WE%X$7 z2~puecRd3Lagka*571%OcPVkDQ!DaMo*9Dq#?w6Ljl8x|1L@~pAdc_pB65W^EM1Z8 zLDRE>bgmay%^~p~ire2r8S{B5)KG+bMk?D+$ED#V`OjhL+xBq2#;iLHC?D)_kn)7l z%ml17RXpPXk9oh&!W5(a0U2hWON;t@*z>6nBJ>d{fG;szvJ@$%MvIbyMd(IMCjFnC zd*U;yDU)%05bjUkq(yx1Bk2cZyyiwlc>C0OVnDL}PL;%_QRngW^|w<3{}##54Sx^s zr=(0C5TS?S-|=SSB#@z`is^G?x$}w{L>3zxF)9bGp4>-*-9ks~00_}OKNWIKMERgh zjs}E@T_QGLEIkX5EbyESuLA=B_t3B2vAeTA1^&yM@f+e>J9el$MRsda5xgF-{9At@ zOnpP(KNHy_cmv!bI?^Q~cK;I)NWu}@Fl8_tjs0Z;1HZ&3-XsiP)`HuGU$6kOm3=U!iEf!-h+IVVQIfai+& zN+~^1%sYl;zHYEzYWmcD4uaX`Qallueo@4}4XaDY1>1=B>GN=x*VU8v4nz6*tjREn zrQDDjRwfyrFO^PGf`C%j-RiGkHg_XvACQ%J)TU%9gE4fu+(bV@no3~L2I6Iw~wtb~d4#)}sgJU?y7xK%Ww8?&Vh=k9< zw!N&>SZ~Y6Kq`$c!-}REix*r#v$*QNrSv=o0u7+i|F3*;e)e-Bbdq!=8}j`3IZ-KF zQXJs~#g1Q4o4qla_Scoumeo&KDWUM)X~G^I?Trn|>f?}rEJ4XZQd+}pw;PNX(o4X@ z%sVLWAHyZqaEiQv3a5HDk3Xi5f zq8_eWO)QY%TcqTP0NvtN&e@whsfn9k?@vB?9e6hw?6d|kf(YkJns5rooDVdCAxos3 zk%aC}6`i+1qBwBqW?%MG5ru|#@_=LOb7Azz-Lc!Ku#>qvF1+)9dC0t<+P68v7&6>N z8m@BP=X#mRTqesI5SB=k8obU!6Ng3Z!dox!R!UM45-5qeM&vX@y=SE&yWjMZ_&nBb zw8IKlv%h3CUvVX$1hpH<1Aq8M&_6y-{}_s*S;Tc3)V`0W_52lVx;)&IfTpPbnAa*C z+!dM+9I4^$JRrGEMV|?;FC-5R=CyNb5YJ^dUcLj&Hqu2!^nrmmAOX|g?nQS!sua#9 zs_2GQ%%u^v`>O%{_8NlF+O6&E=}P;|UR=zq0iS;}+Ny8`b3cHG&70E+Q>`-|Xp*Cs zU0X{~bp5D&a;>6!WkswpqXsypN$WB4`e(HoN=9{UynkPiUOVv*DybgCN&X z&rE!mAWCQ@`z95fFMU<@rYYY`Z^``9PvNKcT+@uIv`A1S@4;G#FFP(x5ah|mKZH|% zrvx?L04Z3WG}f?tbm2`}=WuklgT4eU3GF-WCG5N)fC>+VWwCR?;$w6YUsJZi-xaVh0~BhG&YkPXw1{Omy!& z;@5F(QvyVXUsWoXU&`{6O1|+BTSA3^{g7UCSs;az?uY>~lBK{Y--nm`* zZ*tT^DV!U=`gMAsaCIQicw}ArIUGmy`4ez{;kNL3B8tzu_@~XEp;61;p2JFiY9xg# zAChJoeJgC7Irj4Wcy}T?C6P$nHFL8~sod7gIN~{q;#t}d#o8NUuf=l+Lf{2H*N=I% zCEl;cn@Ucn=8yE$!GaDPn3AHvg$YToE8>)DOB`hS;qd{w6ppz}7}jb#EUz>Lrj*0% z*zf3@I4lbPNIdmIb=4X)Nk=w8Pq#_N2PWN@>_e>E@CYh2fpL>E(G03H*S1wm&>|R|e5p|$f4U3D%hp;x&lQ7vv_BDGmn$Gq$ z+ls`^lRJS2<}+zr#+VSum?p+L)1IsvJFL%aYb|d;#j|4%89t;=>07go(^_ge@xti4 z^c0uff+i~LiP-oW;$Lv^M|bq=<&TZLuaiwnxt)9E5+QvtN_{r}N)9q?4Y-~V%MB_o8aD`ljz60$BOD-kZ` z+dt{BC2Sz`n{IQFLYwwe?GHypu-a_4Lr@TB0&FUQ8AucqBV8@vJ-Gi-^HQHLVK>^O z!n*N^iCD!gmcY}xhP0B0iu672dcA*AnR-1>e1#I*SXps_CbEbSbqIemgENh{10Ifb z{YgAl_k#(8hV@{to#*l@e6EV&NxP(+SU$^@# z%7XCSX?ol23*ARvy;C)mt4TBrx^JU@z30d4=WpB~yRCWrTFUC99V?0=e%apT`5r(H zc{;jeJuKy;;;yT2UR_=w{j~7&5R&zm2AnL0Q(bOAaT0f}ue1w8i|zr4osy>=i|hW@#lC+}5N?oZj&dUs|4W zj}jzApwkHEdQq)h1}wf1{ahriJr*Q&+W(?@3SrN$#@D(x-0~gWhsd_X!^oe`j~H#Z zd~)7{akeR#Vp{`r`3R?4@k6iW{a5u0`15S&exv0&3$iSW!Ch)Sg4Eq+o}~`Ifk9eA zZ%#!JcmpdCKZBnPp|_l2;@Gkg8A0<5Cs?g};GU+aiznbH>iUrd@w=>(-_`eE({o zLr}&THd~Ppa7orp{5s*1VC9hgQ{fGPHxZ_A+jq3Z0Lef;sGti-OX>|V={Y}g+}A1U z|8EN`-qgdH4^C?$96|hHJg7Fyw_3yzO9A*dE=E`h?Pkkqi?)!%h>%QdI(ivG1#98r z^)r}H$JK9uCdGmOi{Sb?sxM8q7q^2%?E`3n5Ll1?QHTAoqYyg<7ZqfWZG>d)au&eq z6Zw8e{q)KI#^~AiwV}aF#}Ec#QC~l3rQ!jR8(dPCNg8QO%$VUNR8n1L$ibWKujN%; z0)ZAfAXI?zb;5xCD|kYXnC{d-2s&!{Kd z0>}uF4ujnO^k)5~dM5PineZ43ldCu+A=2*{(_)`?EBuUk<(ry z7F>Pl(-~1f--erRBBEy|bQ#k~y>bk3IL!k~)l^+Pd>1i|HMHz^0YL8&(S!=NlSi{4qSzAGj*kh%cZAo>Q1V*p>4n$v}G472Zl@uqJM8-;k1N? zwb*ngZ*HPzqhW7>uD-eK_#{97v!OJGu#?P4sgoXG7Ch$^wD;!=VYNfe15Iet=&xOt z9T#x+y_8Ys#^grlV{-ICz<5Xlk*lAzG5Hno8wW45a?sLG`L1vs^PUik!0|usL4=sB z9GO%0AK!(;f>D$vks)`FTEab53e1CCSzW#|gAk+jHXS1O=XmmXRPuOw@;He>BYB|u zA;MlQaecID&lXw#bKk@wW&Ak}&QmOE>F8GBe3CB!oJ+crA zu+f#qM~!N0HNZ)?0WbdE{UttDr0zExv$DUQFIYGyr|bRc5JJm5VbH659ndB{X#z>| zjSTR&UijNM2ki&UWWu8_KLBWe;%R=Ti>c|jSgQ-U7tcpSy7!(+WhhtSO$6btDhg;3 zP@+XvqeE`!9_<}lCFzY|vFWTN$^kH;piBdYJkC3_gd+Pd0(8o#QckY+qn@^dO`kEq zk|~juv@BD}6BZ{Nz8}V#-8gpTe5UqQs29mR8)OT}SBh{I<{&2)Zk*IRCaDUW8_@t3 zPK6wQ@#J?>PY+{u8Dn5|D0KXCll`|Zg7pXRH1F=08wQ*PCJAM8nG>1kthyNSd!ng) zxp`V_>5v@Q>HWIz`9wn~5y26TAt8SJ6UQ%_BRnGM**J*y+qr7qa_bfCjc5)m6Ou$G zp98*Ur|Tc-`rPJ){e{MACf77L_P%QveKK-#j zRrqPobkW6+e1xzz^v+bo@$ymQTcj&FfRWJ3JNsj{_|!qYhR7r(-c>5sEV0JNHis}j zYhu*UO@d&#%a+@aG0)@pAewW+opA9*3)Otc)2uN=L`6rB=||}~t`wq&V<~vxN?3&6 zF~m=F9HxRZ1cq_eN6z*dUedT>Y>LA|QF$)lX$JQ7nA|p22CtA{@WEoC?0-*2R@x)Z zi4{^ws53NP;k+RnaFhy}TuQp;<6;zWOJ!%#P92s%ULoZZFJWmZNCzA-U;typlCDnd zFt2Wqj>hvmeS8MvDEslsz2W$X`!;;^2o<;FiR9zXNbrxh>bwCtf+)7lZDd8Kk(V~H zj>yY{)%M=;uNnLm)A_z~fR!HkY=Ee?RgwoYFh4Me^gq|HocvYKq>WL90o#V)l3u4IvA|Y*YRAypt`H$->L9Y zAUq){U>}b$G|v)WT%LU&NOHJ3?8G6sVk1M@dF(*vbWP1~pA#H%jnnvc;T z|0XB+*MD^1FNLfGSZk_t-(Wp>wUws%0kni+;tA5Ct@nZf-r?ht_ZngjHQ8)dun{YZ z8TSJifYF}-0cNplzX>l#Z%w`Z^^s!M@9ON#?;gfzu~kaJvmxzhXwh3(u( zC$G0g(s5Jf?j}+bXt2IZ5x6j31LkM=v8UOQyf+LKPGmy^WVFlg>LGWC(4TJ-Aw{Sv z0V)l+ES1Q>j9_z9aRhs$sgLO>2|{*n@BUd}Zn3ha6Y5RV%DQgY21T5>LI2#>-&kA4 zOeheN21Yoq4knr~-BaVRN{O`iC8a0xFpEOK8ypU@7v|Imy?k2lvHANQGih(6-1jj^ zw0Ko0h>GOJDJbq^Qs(?FU2YWF6V6D1U?)(}JO)ldq);GMx zKkY$W51%$1g-CWgt4>l!?0|M7IW6d@bCSSUX{bigLtt3x1+Mqow5#=2I5?1_=Q4xQ zUN(L$0(HlTf*lu*m&7$>MXdrsnq0pqfUjRORa!yHf^}X3JPV8y?9KLv*}ThtkyuhU zumK5+1L$r&q_}>mLgxG5rPH>3De^l%znuPz6zmOw)JxB5(q3{NUiYaDoh>qCsDJ@|yBcujDz7l;3)ypFthkcf8|#K~(0J z@HUg`=-W&sDIULe9M!9_1zXMZkTh^VZa;E9E(n2MR?uY0bF3+El(^O?w;2n8#wW z+t!0{<|?7ATu&21!1xcgRVb+_NG+9u=zUN|@yC5Wh6;l`v))P|P{Jc-x%Re%deFDN z>1zv7+LfE7dAd;VMGyI(fPXHSxEC9|V2^b}A_*PyuLG zdplVD(DqFX@_7nESe_OJGKvTeVY7$Wx$Eh-tt%-m;5VPxLw!DME7Qu8uKu z#3D#ks!YKfjt5dHqso=VX3OQ zaQDL$$EyY zKNMoS&1k&N?%D56cxlDQv|#+GXYmyIBRnK@`FLqvV%?!3-X3eW7%&HQ+lG ziec%I7He8q`Wj;7YjpT&ETO&SGQ&3?Y@pUPXPDDyW61}CHH`2E!}DyQ(HvjF_m-D` z?FJhjqH0QZg^vEcVZlX5H5&sBB^l$RgS|1GeNB#wHdb|Xh?G?S+8h{;=iX8r$;Rz- zX;g=aA-pDPS-J1h0)4kwh*dIod*ft#_HD9C9G8QAtW>&m=jdx=SFdiAs9JS&8oLiH;bX8?J znh=arLAT&{xAWqHZb@bYfFT#+&AhWSRRnq>R0Q0)Z+RMGbtqWwO$|lLR3Y7`BQVA0RMQ&c1qx~6Rp<&CU`~G-EQQlcK z5mNLDtG%31zf2M@Aw zEf-Xw%FM*=R(F&52DeXN-=b|};Dze^tV{P7?E1Ch6CntJN5u4*kh%mY7O`$$;O`gN zP#6Vwb>?HvJx`eHBZI?#S!8;6gC20ewOF&SQfU6jXEh|B`MAz?9KDB~wT4oMgWV7D z_r^uVA=WmehUN5$x)=m&TxIDcNDl0|;V&4V#8H{vBNgV+pOPZb!Lvq%Y0rCGq-3oq zKh`2x67bOPc9TldIl!`Wa`=&5z?&pm#mNSIgF4z#-VIGpb9?rnDKx6Wb&JftH}T19 z;32vTNK`1p^~EYV0{S2*=YAi6vmRaL_Ixy+at7M0xAX_|e7znMDj4u7|D@P8p13-D zuEudE#3f9`SyNfrTgf$Xn~)hu0A7{eT6=e!~WJ>Jw!83pV|q`!{X7 zF%$$C@jA)@NxH2pARDP3;NM`_B0Rt6G zT83gkw0H+gl8FMd@?<{%oJ_EB3YjjvS{ih9V0ZQpuA!x_t1Cj8GLQ z3PoFFs=!F_UXm6qhCU_t(hT?W6AO6h(RV-U!4k2Y3j5+{C^-SAn2doQN@6z}tlje~ z6?Xp%jf!M zG)My_UsYE?#4guOqQ8!xQ`{b^DM@*PC&_&pd)G~PDjO*#fX^}j|p|6JDQVV~NA za;)816T1b=S3cW}7QI(3(a@gdwao8DwOY`a^Y0chJrl!4kbl==vHvVoRxIdO*g0Ac4bz3uOVt+X`A?jIOlD2hUchg=|N@d2Rga4e-T`dBe6G z3`XMoa)03F7Ib~(01cd=>Uog-`ET7L6d+D5(5I^+1;U`V!ta_*;v``5J;p-Og9i3xqq)=Hf6<6h!D%A)2BC z&~iVHpTIIg7eDwhr8!*oKeMvEppL<>$oO1l*Ty#5!p5yb#v&#b$f!|I_Y<2 zPTgp~U$^p|>Vw~bRqQ!nOt;IB&M@>bYcC_;v=Rp-04oYkg(g%{IVFCUl!A0h=+%=t zJ}D3(MfHNdVCF?mj%P~EYb=tI_Ftgd^Jev9B7ZYLxv)h$5tk*h#jemBq8ac)md&GN ztB;D*$-wI6^$Tvkjwhh+F4oM=@*YOMIGF!26FqgY5E1p}w}0{FU^$_8nrj zRA+pTch+$#5}Z}%1n^((lHfL5NA`As?5&uj-=?eX#Iu%XklWU^?t*$Kwt8}~&(#qN z&T-aWYm`p%FY`1xuBANkZ8+Zz1K6Yc-UwQ1`UDpt)s`Ep4gGx^cBPJ2^jC@*DV(+x z*r%%tv?DY-5X&L}sX%Al&V}!L-zU-lCDh>GeL+NQ5#3qXyHhGc3y6G#^N7JqS@@8=-Vk{{~|3GqhPIl)f zqi*U+^gQDZ%TcA5XFcO!J5u2K`!gThz8+=0u6mIG<+|I)=859=-{Wav5B91qK8j5M z@dHic(lA_xGz(QuW|AM2vQwXU&ULd}l?r*@yKq-M9UABvq@4C2z6>)jre&Du4{Q!S zZ;b~*0-J}B69^=~vb)6h6G)Jn#vNiC6!9eMM5wjbnQIlPx?>J9Djn^K5kM9unJY|= zOLaY!!(}oGN?vg5AfeMqIt--EjA#cSkg{(`Ob~@qodb|8Vp)3NWO&xeSE9V`FmL-> zoMIvHS#X zu0s46lwO`0?K~4S{2iH0I%qs_W2l@!7_jIE``L2^K#3OdKU)ByC|%)ajwTtJzl4f{w*iW*a5IL~8lR^n2XqTK>zMCK@>~N2l43VB- zfc`a)<1Q_hSi|Tnk>F>EY-{6v`bv=Hxe85>v9j)V{%`E;8nS*OB8)&}w7vM#CIkg;R6L}nLxW9?lK3IhmQGj-or;dCU*SrG>8s3Oee5pi^$oeX)J_Ye*f-&M}uQ1%NTJ@E|MA%16*;miC+KmetO(AHDeA2W^H|cdQO# z6CdD4EYd)f1o|A~xY=3=n613{s+3B!{`@Oo*cYkeKFGy6&HS$YzHlIvaQVhIK$=bG zzB@qN)Un8>LK{x1eU?x56+ktT3xY|Y>lgT`FnFJH4yfE4fEpFW@z80D8(*996UG?7HShmr=oAz30m0=- zxI@sdy57|doj_t<`R2=Jvi6psjH|R7NLOCiED@K-%BG|$j`|xbHTFlE%Vk)6gn5N0 zImF?-P-EN$gzypn}GmSuNXn8CT{{T#*LhI8gLLA{s{^^ z`UoO&z?ZndDaL2buMFbhaDUO&F%@=XUETMN80qK+EIRnA$O5&k+*>usOX`+#GL{#! z+*MLEruY%>mo4b%!Ax5nY1kT;vV&O0>RuA65lL`g0#Ab4U-%OjU)V%A(VzE}1SuCr zZhnd<)F1ASd}<23G!!-Sf?n|LgnRCzn-P>i?6-B31gzV3E^-?NzxKbAZ8r7jS6o2A zD1XjQI8&J~vaZAs#IITDSThfIx7o#Wn^G7^wRmIdXz)csZ_!aud>!!weYweeYg8R$ zNDz9ER^s{gG4h#SYrmssVK*HFLYw3l+W#z^_kskGFB3>XREcAa3_?*)<6l=?$)`&A z;A%k)b4I)P%Ear@YY4JzH$Rp-nu8S8@fR7i0wzIloAz=u4iQ+5xR+$i#L9_pKbu* zoGsg>6r{!X$tk$YQy(_ctz;K4fO=rJ|E|5a8H3*6hP3$CpaCef@Wqk@Mj7$2GJC=D zFJRP^i5wDBa=!&zlacORGuYC0f|{?M;e9kIgkWobCX7OXrWQm>us^9p_9-G+=t(-m zP>}5`_g-4|8899=!b)gC>F(bb*TJ?J{iMV$?nGW@4q9QL4D9_N_O;Lz7{*}AVehlf z*8jIcA{@{Kv6V)P6dq<8OT5K(6OQW-FlkM$zkXc-5w~#nNh$oDx1TsoS=0Fw=*~SMUJ#k_6=39S3KB-un+IgOPFAWT+!nuocvT{0+3qhjnG9bQ$Yn` zSoRuxI(mI}_my=(7?6GrUwx$N$_D{+RjRt{tR{?+7A-uB2A4uNMWKuIgtg&@EfeFd zV-Trf(&@7^lX3SG`(0K8BN^DlYW2)4K%(E_c*XNOAN-hZ|_{{!9pJxe+P} zp?zEv*_{e@kc|ZkIj$*IDm{uG-@vNNX~3Y}*5Y?o6P?S~ewl>4A)FQvj>zKNZn9cB zdbdpwnm3P=O827|(?eEa(iC0F$VB@IfvkwVg@PvIVlC)EFthf~YJ8y=-Df&z7fsDFmPJ*TlN^4n9Z( z4DpHIhXlVyxx5UgCvl?ZKXN)kbTXJ4guRIKA#F%jzh?~W8$-rOuyqe1xmqm7l`7ncNN$-%fd`ZX*Ejqx9Vwq{`Q6*6A#s7wIJ_TrUy}k zg#6{z>c^2lAk}<)ms|14KI)G2R?1NEJO@Grpr3n&8>z&1V9-!Ov(yb~^M)BzbI*I) z)4^aI?D`T~>>&xCSyF|^ihVJ%-3Px9;E?V2rxm8#CAa|Eeg)jaLBRj|(E##oW{*Mk zDE+^;h9c3$#Tmv>Du@Lnk9mq_z@?J?(=!UN|9Wh@!L`J3cQCwJ?~OFlq5?_dKQzh6 zx0GV9=vH!silrlJSwZx!dMM-*RWWsq7O6)dc`o?e<0N*qqbHyFO+}Ac^!m=Zng$G# zSRix!?I_zzZt!+$zQ`j3qTWi(JlH&_+L4!yQ8Bj)E$(8k9E2nY{qZZCyRd0~c?YkA zLCnxF@PShO{25cG#nIx@8KQ8GRWG$GN`s!QC?iqh`(q!#O*bs5yS^iwKcjF23K0H@ zGJZn}rsfzk2M!}1^=|&>xPh*Mt;CrhHV?>e^U23sPv27l%LNR`s8Rm&w=E8=1N^Dt zFM(i0Y58-=-8qA!r~Iz6AdlT)KrGVOgGP^~asyDOW@EMFg5jN(H}+aHy!|3{$ZzHc z{#IAhPx{elq-6K4fH#0sWzAaly}O9IM$0%A9pG-DI&qjHf_WzCV*J~{m7UHR=Z&c) zpQaqB_F7`6~ZDcU<;IJnanggh|MAD5BLW|q}HTyt| zm3ZszRp`uG_WE%db?fEB^{SF_`n;Q@mv0U^sR!I)pq+7e^u%5Wrj1gaV_?%!_m`WV z36iW&wodD+7uq?DDrs`+D$-L`P3*GFw(-c0iy6oNf(8+)hV9XRrHzG6uO6zSc51np zb5Y%6;dGp4fBer^e?IbdLS-nVjCuUd3;;dcFWF+q6F;&t(X{UVEJE~!Sy3I4%Os;= zZ74fF1Kd=MjA;xv3id51J<~{IpLh6^F zimV+-CsG5fCu$u$!65;95#h7Z;&UUPh{3QGYiYdu9!d@rEe8V9dTa!=$#M9yCiF>R z-?SnOUBJXapqmVGndj$!=6#Lo%#(p&Ar_bxste+vs_&Rv0C46!c~e1?N>2k6jJB7V zKk8kOt1Q=B=psR=wpWPm-*P$EZp!baz{C8lZ&Cy^qPPXZq}?<8GMW~I=>8jQfy2L- zR!R_^^<2O2zgGPCpE+!!So0k3>l#abL!8a$q7HGTQJ4uqQf^Ore@evgR2GnA-r0hs zyK}e@IAtLgf$un}qc7}mH#y_hzum%JTUGMP%myF-6zzloZ6N*o+)~h7jtXm=zQJ?a zY;BejUgU#p_3OJx`OzS$oz=;wrv=CCNeRwV+v-42dX?TVV*Si$E2M9CXVFg3?^hR6gyI7mKtFyhFvN6mE{WX1=q0f^(Qxie$;fy3{iM$z+ zCB+Pv15JFK1KN*&4Z{R`14c#T58bu1?{%iz7}*8yV34Pd2*KlT_Fs?kN-5~tY=~zB zW->;!3(cl6?y@jY6u1wm?W{+8OV}dKCb^IFcZQ8>o?{RU=Z;36=HPix^BTe)jAcr= z%wTchDMfneV-Ngm&U@eiLFM~2yKHIFPLu2U6?*A)*#9m%PVKTF^qnMiKRe({Flhah zD)%k5AhE-BEI`~+^WZo30Bs>=2jo{K4{n`Oocwvoo}C&4_lWxb+c;q4>R^L#)F&?E z(d^4_B+{Q;(;1r?J;#;J@D6DS4)V|HTnKwKWR{DjaWYLfQB_US`WR}~x$d&*z1uv3>Y7| z<-el}iM(1Ln@=dXhbE=*RPr~sh71X@%ONf~CW3PN55*M$#$w~1W>KR;1M(>Qe#FG6 zzA)YUV_Dsg+FMfBAZZu*aJ}81I=tfFdJPT1dz^0meQ$V*GdV&Q{KJ5|rX=v#dlJO_ zlWQ+VX(bws=WdUEHUn4Q^GlZ4xZ6L2 zHCC}Z^vzFUM%a5OwHVDl?F}INZ2?pRb28kyy(_1=82xHpQ69JDE~6u-g|RdVkmFm| zL{=N=`h_`CoGx@uKY!`V)~2x zP7qzc9nSV0PRZ`7<}HOX2R~Tlc`CSwUO?ASa=u^_3eC3aMebRyxvY>*nwW?|ZV> zVF4596Q91H5GD44@h1apWPa~a1iNn857i&a1g5wB=2vzbrD){2SNZMAO1P7;n$XTMa;C)Ae@?RYo$vkN32LJ6-8vBRVX!jM|LJ6^dr11Z$h%-74TiIJ_u*B;{lS1 zC!6D@sF?is=R)mO?(@QTG{#6FNs;|_{;S8C+f-0TGRxhun8$um2V@b(Wz?Mxo=JVm z519BSQkhp-f==sFYu}}c+a0m%gnT;M*2@r6r-@`@&`HnWVJILkyZf^0ngM6O&yzC! zm;0?sIYPD@J*if5B{hanr*l-5t~~GuV7g*1Rw#iVRq*eQ>f$|(nTTD2KTD&m57WJ?ZbpmyNw!ND5{90@wj(nJ@~FpH;&a*Xr~=HXLZ~qp7n<52XunW zG49wjwm+0BE!PuvwAU5VAR~P4d=u5qYLo;ymV?Q-`bFs1uk~^L_*7VTjuz&gVEaes z`~mj9>cGA0UR9-C8vFev{@)ije=V*X*Bv-bHs*D)ko}`T=s1=uPW+Px)!!vOx;S((WZZoyfEg$}Bk>6XDL(a^#|BLTnnFuCt!Ab9}qZy)*r56B07U zyq=F#AQ+6yrsSLb@Hs_Fij0-H(wuZ%e!IA{N||<)^!`n$1RtZCc7?NMp_ZYv>og$x z)au`gi$}P3-0jV7Wqi`EQ@dD>O&8ZXp6nif{))90M>3o4>iK))tmI|GG^kfyWSRZ{o|jfg7bI8IWMl8?c@}$# z$Q79^uJ!f0X|Tx|DbKC@j{S-yuq1Z*weH`sHT*074n!9_CdoLv$ztR-&um@b_gPf9 z?Qq&;rjz=Txq_95?OedAUKWkhzcu!szdCoyZ+CO>UVm)ZA=7G_#Dc}Wy~i{ei#ZHAKiT+TKNQz9Jvo)Xk$^!&&KvEH8&&0BGJ8xE{8ATuoY5&pY_l_K zv(D!2sjH>=8{9~+>EqU#YZC58zD$>1MaqS2aOE7aUwevCLRxOVl{~n@D%SCLj}Krl zgT>WqZ*^zqsy<^y#M+xupX6B1!w#>yhr(s;J{AV-_Dxrnuvq^i*a_ccwf!xZv*X6> z2sYpo^rhJA6vU6Q#%h-pZy+Lj7r0qFyB3Go*>P4PVUFIZhQDn>0odreL%pe6LV(cV zl`s46FW&fd>=YTkXsc5rZg8;eA0L7*DiDo|ygjb~&$2xE-F3e3`*FYBFGz4nN$Hgv z#l_EIPPmGVom#BOEdO&P^cjfYj?vC|#l%0zK&fhYKd*80E-{p6K?mYnX~Y?U0W}JE zF+bzNH0_{XcSaJNk*M0?{_|PY36(KWGBwBlGdZy!NoKYVL0Bc+bL_h}^=Y}ugXOsf zyAoNpwt%oHSZnZEtLWiL4zGSz1WD$feg9;0UD1tZMg2Iwr_|A`mdi6E4DHq(G4EWv z`gJQE5qLWjsn8yuT=@_6CrQ+xp~)DsTJHy~H=;Q>cQZ_&2pjEj@aA z34=*Vtx+^1=I)h#Fd-Yu!xLA>&FDd#!K9;l}n(5Ll zu!?{uiplS-uS!bjL-DE+vHZ7zixawl7fM@zNngwU*n$ImW=`;gTZSGqGoC75gvrbAB~fmtkoB zZn1%AlUsUfzrcFr-h#QABFz;hpuZXc@>KuB_s$l*kp(Zma^$x;DRwqxD9ts8$ClcB?)S--uk)_LR85T&CFg$?Iml1~(6ei+ zIa<8*_H0$sVhZ#R_iBmO>6&UBZKGbS>T3SbeF*t8Zm{03vR<7m0>`4`h zu$J@hy339x5$aQNW!;Y|;?G2^Xe#)m8VWDmwWdd~IR?b6%68cm(u**7EBd1+PMsa> z%zavx*I_F40*gEL=8O06UXS8Ext0Mes8osep~us$-_B_SgIeQx(+3ALYNGNwYL}Tj z0=aM{GaRZjslAj%mEu=G%`U!`>d4KFmrM=^(IXr$!i&u%wrJ;!Bja=Pr8$~0mKAcp zqfmt#-1SH#166E`mFJEzD2Qj8eWgb!^5z03Yv&gCaK|&G#mSgPou>?I;1S`MMtsC+0uW&mGZJZWlf$r#`vcuj4<-jFWPl9^`XQD%^bJ9h@LdYkpX5; z>XPFAWX5y5Ij)nS`iv(}5@YV(ZLCzShwPom&Ed{ugNi#ln;+BWi~dTJVY9)0VftG<(=f?dr8QgLsDUIyE?mmttLByJM>-V-5f_Vy^ zsFC3Dt6i&c=G9$*p%<1jVO%4*jM2G#N#zD{!?h5WK(GOpp`6d34s~@7-JvaN6H#jJZqq+SqI)z!LVP_zm|Mgjf772cRqTPi%Bi>-0L{N+|5`a2!({(aLcT^4 zdE@)p1wQ46WALm7+1{U7M|O85mWTQ0@4wMHxfUNC3kq*j!f&%9 zbBs9ff)g0FWZdm)2*2}E=i>Muq#2dm6F`VBGq7W{JLE-?|@6PMT0g zr=9hGVS1i~ljL%nq{#g~MvXawCa00!=yy&DAP&Ng%Pg;cyel5htGxLe?N4J^DrJf5 zOluRD3DH1T0zMdi8FMmPO?!E%S5CMjgTEY}<41{!OUP?-nu>lP-I}nokfC)XmNxS3wJ$=+o~?at&O-9q-suh)Y z3aHA+{7S00Z&3;Ul^<02Tg`_)s0?jX09QxqOrZiD$vJ8Js@!Nf!)4jmoETN_Bb=i9 z4_zujF|lAOzLiH>zScgoyO(WhGP@b>8Hxqe#z!t~f=cAOj>jQPu>9TrGD#LIf8P7qIpLTO<6&!5&n8yW z?h%4|UKUT)A(Xoq#OV=fdw+A*9~K48L$Fea*tN&P_j5a>)FR`>O&Og@$?AW>K4IhX zr68`HMSTtKUwatj5&Lti3u&oImZt>kV2M?y^3+=PY`*C|6HC2Q7fpUhs{K%1!au%y z_FSD|0o9(4+IbXVd#WVJn9VVwn55*5>qSs}oBC(^Bq9=fw8nOy@FZt+G%xJ%vmKk? zWN0fF?>(k{P;e7=cG?n~bI}H#z2AyqK5@L2dh5d>3{qreexd8^_SbM?qh7pOl&0nA zz5NGr)s+U?7dA69yn;ax9L?jSvEOq9g)(@^L77xFy7>ZsG+GDrpRx|>W@BukKl!|9 zy>Vll@INAA6fl&j4cfP6^SgyoAONY@d(mWb1BGHt2ZH}uDRjV8X znw>mt`>oBaj8_Ib7=)EXt#$gvSB)nssQiLMw9Zk@C3PQO&vp0jUFdGkyzQ^@j*02@ z?qJ{1AE~7K@TE61PEzV55Vh}HAD7cvf4cWd~{7G=@Y7`~+R^1GFP*GZ5%C+O%6QxnrYV;wxW7uga#k{=AyrtSL#Jx8915n;mAE#>s=nJ4JoNR?Kdbex=r zzg1jl-e*CNi_1Na<8IZ(X$7gkE&|-=;}@RyiKeG+5tVrGZwQQREQh;?c zBBs=J6=tP`>lah6U$YY~H>6H!TWcOq7U!Ry%YJQf-i@P_<8gJojgD8t^TAKg=o^8X1DkY-Ti5u!~FG+t` zqCC2?xyPZcd)WEB+pTN5%~LjWWgKxjD|0?9WD@5ZDarPq*9`7X*ezT3R&8(uz2qz`g`0L?_ei|<4HnL=;k`BCky6JYn-J?=*rzw+nrP@?&>lSlY8 zpR2W-1ML2rTg*VS{pmOTAtc~AVxMpGY4re(F0a69bdC6Mw#rcW2YLd2SjI>~*9Z># z$YZABv!@+8@bI(5@-J?ZR6K5VdlAr6r@E?oaFo|d0~_t~H+24u;hx18Fm>qf%Cqk7 z`k}hC%xSGwN3!H#QIWt8XJr>wE|m4{?SDf0??~_|1%}KhJ(WW#r)e={a176A?#UD7 z#BKRE=mt0{=ub*Z%jjrJhj3Pm%vq%)DHp+@z_0GFp;TqQ&AGQeDTn`eBwtN^a|7Z~ z_jru{UtC=L8}Dbu6W@eR9q0nw*K(E8d$cmiir{}F1$S}$KVd)eGJbYwj~UuR7Z|j4 zhNyMbHkSkw>AX*`j;mV3iU>b^gYl#57C)GU{xIins|;Ge$iZ*#V^plS;0=A9RyE@7 zgCj_E#&`FJ8(jsV)(3_O4hBb;l;Os|e(aB{e8tpqKK4Jcg~k@8n1$5lTK%O1`-NT> zay9imxcA@pE&jdOpTwmeQeS05<$|>6R8JRuNUeHA{c4}5+ar`V6m0_6C)%qq(kOAa zQN!=40~_+C1-%yg?Tn=M@ajk(E|%MUP^i4=4EfSHcH>8)5i+ zpNY+favUg&WsIWQC~Y{4@JVtkd0bCB19~+g)+Rsdkzi)ciWRk<`zHFl$Z<4ir5~p zE7sPIF$>pre5ppzPNdM4?2$INcx~^v)2Q|-p>RiJwyw4J(-gPV!}?2RIJ@(+=bh1` zs^$zXJEmZNBU6yTIDR$0()R#E0!#Dr!l=fR52@KvjS5b{W7OIkLIQ_zhs2_v9BX@9 zYmLt55K^fpifUqi?z=Uy;ScUoe;a)^Jww1y?a!ogh9>Tk3_|N|{y+}pw88%8Q9neOv^j-LKOWDov z#}yrb%NC@J8 z{C&xZv@fXr_hL#lYPE-^8-;KGrSm_xH7)s0zi%70^n1S6Z3=s78k&<-&5#2{_UNcZy&yN2OsTN4b{VxAl`ey>yA38n4&vU^f05| z7Oi_pkr^1vh_UOB+wx8gE1pK%Og12Fgw`19UjM(ku00;=td0MqL}YeTD7jQKS-DN) zI^>pU#%)U%m54PiqY`7*m>6VN$)(1~wV}(-Yg`)kGg9lCt%$+2ScDmDjj!yO#Wj+Lk%V%u(zx8#|fInv-Wrd8~ccEuP0AFkHi(x?Nk}Ry;}Q#uwzm;eb@F=Y^WTV${Cd;Jh8!ne=_Ps=O&OD%b`KHI;lAYroV`J{yHKHP{Cb=*LPT zvj6@dCmOsSN`h=J{P{s%!e6TvT|!Ba7&!J`na#2ic3|a*0$Q9{0YJo|-)?^s_qo-L z--~@thfS_+-NHhN8|MbP%_9xArkvajCB}c5o?y|!uQ14tM!-HPk1nlrF`SgobXOh# z09^>@D|z4WB1dwU1GuagE}tob4bZ7za8(Inkk5n)=e!$=p8$Gm@b=;vm+>uf+WFzE01<@tO?Ca+x#Q0X zzfHGa-$IsnT>zko?fV%?x|VCiGj4bdERS37Rs;ayyl0wGZ_mKq zNb9P0V5jW`PalJmv3{nN>v`C zB@SmzlK9G-48YAB49oD7vLjPh4udNnz)`>V3_UI@>1^%8cnd5<_yjpK#HR;kFY)6B zV9f%;;0nB6s5G?JRPt1%3`J)`(4G14OiT3?6ZRZuUt<;k-8Z$V5ZhmHV7Sg;JEP@Ef3Ohk9d#Q-;l>`AJdj zjwC1uLaT{Qs+)U?)-VV#8j$x6of7b#ry6uKx5&7;My_5|JrxWv zt;!J$Ny|f3LExFdKMM9jT<_?=<%Fr3mpY%i{I`AFMu^fwYyr5NE*E{eP;=F)I@{Je zs$>v%xLp4=rN>n>{rE@Y60#`*4$Ye#aag}1wav2uai6fJA}D1i_V&6brKPYx-|3$u z{Lp7Q08QA|6)*Xt>dM1h=EF!}CRc}8uJ#H$dIKo9Wj8%d0NMK=7QU)P5WqgxaCx>t z-WRx@q(T)5ENvxsx0Vc0O-&S(`$Pqw~7itU% z!)6t*QfD~GGQbL$8icHP$^|b)HG2E1BQ$`S@-ez-_ARefVc)|0y3GnHYI`AEA9F3z zX*yAwfY+dw13_H(g3kQ=O_j66H9tq{aHtB<<|fcaGQFmg@s0K3BKIdfGvLHoRtCg0 z^XKtiV-xz7;!+#j4yc0*;yPe{bR_o|6)gr?1p&$_=k|ZCiD|j{%zNEb(b$&$-;fF^ zCiBfEZq1Rj7GOn+7R~IjAkg*xkhSVymj6XVw?+Z=Yl+V!sl~rzy z|B*Mm5EKgNg$R-OM&;doR>^@bhcy^nsYH;=Z=J zNsKfWe=0r#rXRb{dQ&1eaN}1!&g#XJI(R5W!P#R)Buh9rc`(H3uq5;S6`)Z{$gac0 z?AWM0l&~^0&`3c3kj*z#h0qZ0yr0mYQbBDv)#s%b27s?Tc*NR7&I0Sq2N^SbE&%P| zp$UeGepWnQYhPneU%8V@d&AnK7X_r^eL2_!!D1ax`sPXXd9;HUJfQUN9lx~lDVslx zZ9hm0^i8>?3(DjocS8;){Bo*hBDbAQ%L;KjK7*39$ zmit=cQO0>)P%{xG_fcat2a*J_vr4&+t(QQL>Eczwh0CQ0?Tfbp$5>&T&H?^I_F&73~2^=+r6cXOMmXMsq*$vFiDUQ4!&?VqZgL z2IJrcQ0+J4xiKr#@0MOf_GzKKAA{z#kG6PiCyiX;dSk9GH;Kg4+*kwyY}7(Q_qtz% zQvz+Z&GRI&KC9`-yP$NL&_3oh8)|r!RMvxkdsQ!2XOw511pC8I>#0)R@PtyEai-XF z)+7TMXh@Rt^i)|v50PzgRhl}IGR3vkz12P`N9vPQ?CvdaYdfSMvm^M`a9 zakj)pQQS()v-@*#CNe=54HT7VsN-{RYhLEZ@wp;LQ?@ZO?>le_*wY~-Tj^^g{nRgn z?fg@&2E`HOt1qb~A{gIVRiLujF|5?_VE=Js$+2A^>H6Gd(MVA>UfGO2%u~O3Rt7h1 z(V2x^WPh&i24duhAY&Nq(|OxXW6cwDj*Yx~Bk5q(5eiAgw$jfA+m!bU}16 zvuK1FZdy9`e1%(fqV0(~sN`BfHAI_!p3N4&*3c@9o$5Lr!RO~Mv6I_RDnoRa29&C& znTe6f`E&fVN#hwpFxGsg>m^BZnK2l~A7Q9aqKBu#ml@zQUJ2UGA@|-fDoqXN5i2{( zIxO3y0z#2@j%6Dj%g8=lzX`v + + Tux + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg new file mode 100644 index 0000000..f217bc8 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg new file mode 100644 index 0000000..51f3016 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + OS + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg new file mode 100644 index 0000000..2c7392e --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index 9d2e134..ec21fb5 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -87,6 +87,26 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, const running = computed(() => previewApi.vmDefinition.running); const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.permissions); + const osicon = computed(() => { + if (!previewApi.vmDefinition.status?.osinfo?.id) { + return null; + } + switch(previewApi.vmDefinition.status.osinfo.id) { + case "almalinux": return "almalinux.svg"; + case "arch": return "arch.svg"; + case "debian": return "debian.svg"; + case "fedora": return "fedora.svg"; + case "mswindows": return "windows.svg"; + case "ubuntu": return "ubuntu.svg"; + default: { + if ((previewApi.vmDefinition.status.osinfo.name || "") + .toLowerCase().includes("linux")) { + return "tux.svg"; + } + return "unknown.svg"; + } + } + }); watch(previewApi, (api: Api) => { JGConsole.instance.updateConletTitle(conletId, @@ -101,7 +121,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, return { localize, resourceBase, vmAction, poolName, vmName, configured, busy, startable, stoppable, running, inUse, - permissions }; + permissions, osicon }; }, template: `
{{ entry.status?.osinfo?.["pretty-name"] || "" }}
{{ localize("usedFrom") }} {{ entry.usedFrom }}
@@ -111,13 +131,16 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, style="position: absolute;" :class="{ busy: busy }" > + :src="resourceBase + 'computer.svg'"> diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss index 86b4014..63ae299 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss @@ -79,6 +79,20 @@ z-index: 100; pointer-events: none; } + + span.osicon { + width: 4.25em; + height: 3em; + padding: 0.25rem; + pointer-events: none; + + img { + display: block; + height: 1.75em; + margin: 0.2em auto 0; + pointer-events: none; + } + } } .jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-edit { From 5ad052ffe479341ade5833af0d499620eb08c61a Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 19 Feb 2025 21:04:08 +0100 Subject: [PATCH 113/274] Delay console opening for pool VMs. --- .../manager/events/GetDisplayPassword.java | 74 ----------- .../manager/events/PrepareConsole.java | 119 ++++++++++++++++++ .../manager/DisplaySecretMonitor.java | 48 ++++--- .../jdrupes/vmoperator/vmaccess/VmAccess.java | 25 ++-- .../vmaccess/browser/VmAccess-functions.ts | 21 ++-- .../org/jdrupes/vmoperator/vmmgmt/VmMgmt.java | 23 ++-- 6 files changed, 191 insertions(+), 119 deletions(-) delete mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java deleted file mode 100644 index f6fa555..0000000 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2024 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 java.util.Optional; -import org.jdrupes.vmoperator.common.VmDefinition; -import org.jgrapes.core.Event; - -/** - * Gets the current display secret and optionally updates it. - */ -@SuppressWarnings("PMD.DataClass") -public class GetDisplayPassword extends Event { - - private final VmDefinition vmDef; - private final String user; - - /** - * Instantiates a new request for the display secret. - * - * @param vmDef the vm name - * @param user the requesting user - */ - public GetDisplayPassword(VmDefinition vmDef, String user) { - this.vmDef = vmDef; - this.user = user; - } - - /** - * Gets the vm definition. - * - * @return the vm definition - */ - public VmDefinition vmDefinition() { - return vmDef; - } - - /** - * Return the id of the user who has requested the password. - * - * @return the string - */ - public String user() { - return user; - } - - /** - * Return the password. May only be called when the event is completed. - * - * @return the optional - */ - public Optional password() { - if (!isDone()) { - throw new IllegalStateException("Event is not done."); - } - return currentResults().stream().findFirst(); - } -} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java new file mode 100644 index 0000000..ad8f9ce --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java @@ -0,0 +1,119 @@ +/* + * VM-Operator + * Copyright (C) 2024 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 org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the current display secret and optionally updates it. + */ +@SuppressWarnings("PMD.DataClass") +public class PrepareConsole extends Event { + + private final VmDefinition vmDef; + private final String user; + private final boolean loginUser; + + /** + * Instantiates a new request for the display secret. + * After handling the event, a result of `null` means that + * no password is needed. No result means that the console + * is not accessible. + * + * @param vmDef the vm name + * @param user the requesting user + * @param loginUser login the user + */ + public PrepareConsole(VmDefinition vmDef, String user, + boolean loginUser) { + this.vmDef = vmDef; + this.user = user; + this.loginUser = loginUser; + } + + /** + * Instantiates a new request for the display secret. + * After handling the event, a result of `null` means that + * no password is needed. No result means that the console + * is not accessible. + * + * @param vmDef the vm name + * @param user the requesting user + */ + public PrepareConsole(VmDefinition vmDef, String user) { + this(vmDef, user, false); + } + + /** + * Gets the vm definition. + * + * @return the vm definition + */ + public VmDefinition vmDefinition() { + return vmDef; + } + + /** + * Return the id of the user who has requested the password. + * + * @return the string + */ + public String user() { + return user; + } + + /** + * Checks if the user should be logged in before allowing access. + * + * @return the loginUser + */ + public boolean loginUser() { + return loginUser; + } + + /** + * Returns `true` if a password is available. May only be called + * when the event is completed. Note that the password returned + * by {@link #password()} may be `null`, indicating that no password + * is needed. + * + * @return true, if successful + */ + public boolean passwordAvailable() { + if (!isDone()) { + throw new IllegalStateException("Event is not done."); + } + return !currentResults().isEmpty(); + } + + /** + * Return the password. May only be called when the event has been + * completed with a valid result (see {@link #passwordAvailable()}). + * + * @return the password. A value of `null` means that no password + * is required. + */ + public String password() { + if (!isDone() || currentResults().isEmpty()) { + throw new IllegalStateException("Event is not done."); + } + return currentResults().get(0); + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index a0809e9..152f91e 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -50,7 +50,7 @@ import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jgrapes.core.Channel; @@ -72,7 +72,7 @@ public class DisplaySecretMonitor extends AbstractMonitor { private int passwordValidity = 10; - private final List pendingGets + private final List pendingPrepares = Collections.synchronizedList(new LinkedList<>()); private final ChannelDictionary channelDictionary; @@ -178,49 +178,59 @@ public class DisplaySecretMonitor */ @Handler @SuppressWarnings("PMD.StringInstantiation") - public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) + public void onPrepareConsole(PrepareConsole event, VmChannel channel) throws ApiException { // Update console user in status var vmStub = VmDefinitionStub.get(client(), new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), event.vmDefinition().namespace(), event.vmDefinition().name()); - vmStub.updateStatus(from -> { + var optVmDef = vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); status.addProperty("consoleUser", event.user()); return status; }); + if (optVmDef.isEmpty()) { + return; + } + var vmDef = optVmDef.get(); + + // Check if access is possible + if (event.loginUser() + ? !vmDef.conditionStatus("Booted").orElse(false) + : !vmDef.conditionStatus("Running").orElse(false)) { + return; + } // Look for secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" - + event.vmDefinition().metadata().getName()); - var stubs = K8sV1SecretStub.list(client(), - event.vmDefinition().namespace(), options); + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(client(), vmDef.namespace(), options); if (stubs.isEmpty()) { // No secret means no password for this VM wanted + event.setResult(null); return; } var stub = stubs.iterator().next(); // Check validity - var model = stub.model().get(); + var secret = stub.model().get(); @SuppressWarnings("PMD.StringInstantiation") - var expiry = Optional.ofNullable(model.getData() + var expiry = Optional.ofNullable(secret.getData() .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (model.getData().get(DATA_DISPLAY_PASSWORD) != null + if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null && stillValid(expiry)) { // Fixed secret, don't touch event.setResult( - new String(model.getData().get(DATA_DISPLAY_PASSWORD))); + new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); return; } updatePassword(stub, event); } @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event) + private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) throws ApiException { SecureRandom random = null; try { @@ -242,9 +252,9 @@ public class DisplaySecretMonitor var pending = new PendingGet(event, event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, new CompletionLock(event, 1500)); - pendingGets.add(pending); + pendingPrepares.add(pending); Event.onCompletion(event, e -> { - pendingGets.remove(pending); + pendingPrepares.remove(pending); }); // Update, will (eventually) trigger confirmation @@ -273,9 +283,9 @@ public class DisplaySecretMonitor @Handler @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onVmDefChanged(VmDefChanged event, Channel channel) { - synchronized (pendingGets) { + synchronized (pendingPrepares) { String vmName = event.vmDefinition().name(); - for (var pending : pendingGets) { + for (var pending : pendingPrepares) { if (pending.event.vmDefinition().name().equals(vmName) && event.vmDefinition().displayPasswordSerial() .map(s -> s >= pending.expectedSerial).orElse(false)) { @@ -293,7 +303,7 @@ public class DisplaySecretMonitor */ @SuppressWarnings("PMD.DataClass") private static class PendingGet { - public final GetDisplayPassword event; + public final PrepareConsole event; public final long expectedSerial; public final CompletionLock lock; @@ -303,7 +313,7 @@ public class DisplaySecretMonitor * @param event the event * @param expectedSerial the expected serial */ - public PendingGet(GetDisplayPassword event, long expectedSerial, + public PendingGet(PrepareConsole event, long expectedSerial, CompletionLock lock) { super(); this.event = event; 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 e283504..3b28d1c 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 @@ -49,11 +49,11 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.AssignVm; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; 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.PrepareConsole; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -808,18 +808,23 @@ public class VmAccess extends FreeMarkerConlet { Map.of("autoClose", 5_000, "type", "Warning"))); return; } - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> { - vmDef.extra() - .map(xtra -> xtra.connectionFile(e.password().orElse(null), - preferredIpVersion, deleteConnectionFile)) - .ifPresent( - cf -> channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", cf))); - }); + var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user, + model.mode() == ResourceModel.Mode.POOL), + e -> gotPassword(channel, model, vmDef, e)); fire(pwQuery, vmChannel); } + private void gotPassword(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef, PrepareConsole event) { + if (!event.passwordAvailable()) { + return; + } + vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), + preferredIpVersion, deleteConnectionFile)) + .ifPresent(cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.UseLocaleWithCaseConversions" }) private void selectResource(NotifyConletModel event, diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index ec21fb5..31408cb 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -73,7 +73,9 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, const configured = computed(() => previewApi.vmDefinition.spec); const busy = computed(() => previewApi.vmDefinition.spec && (previewApi.vmDefinition.spec.vm.state === 'Running' - && !previewApi.vmDefinition.running + && (previewApi.poolName + ? !previewApi.vmDefinition.booted + : !previewApi.vmDefinition.running) || previewApi.vmDefinition.spec.vm.state === 'Stopped' && previewApi.vmDefinition.running)); const startable = computed(() => previewApi.vmDefinition.spec @@ -85,6 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); + const booted = computed(() => previewApi.vmDefinition.booted); const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.permissions); const osicon = computed(() => { @@ -120,8 +123,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, }; return { localize, resourceBase, vmAction, poolName, vmName, - configured, busy, startable, stoppable, running, inUse, - permissions, osicon }; + configured, busy, startable, stoppable, running, booted, + inUse, permissions, osicon }; }, template: `
{{ vmName }}
@@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, -
{ if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; vmDefinition.runningConditionSince = new Date(condition.lastTransitionTime); - break; + } else if (condition.type === "Booted") { + vmDefinition.booted = condition.status === "True"; + vmDefinition.bootedConditionSince + = new Date(condition.lastTransitionTime); } - } + }) } else { vmDefinition = {}; } 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 4cc63fa..10b4f48 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 @@ -43,8 +43,8 @@ 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.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -483,17 +483,22 @@ public class VmMgmt extends FreeMarkerConlet { Map.of("autoClose", 5_000, "type", "Warning"))); return; } - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> { - vmDef.extra().map(xtra -> xtra.connectionFile( - e.password().orElse(null), preferredIpVersion, - deleteConnectionFile)).ifPresent( - cf -> channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", cf))); - }); + var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user), + e -> gotPassword(channel, model, vmDef, e)); fire(pwQuery, vmChannel); } + private void gotPassword(ConsoleConnection channel, VmsModel model, + VmDefinition vmDef, PrepareConsole event) { + if (!event.passwordAvailable()) { + return; + } + vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), + preferredIpVersion, deleteConnectionFile)).ifPresent( + cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + @Override protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, String conletId) throws Exception { From e29135282848de191f1075d35e5cefe64bb25fb0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 21 Feb 2025 20:54:27 +0100 Subject: [PATCH 114/274] Prepare usage of guest os command. --- .../runner/qemu/GuestAgentClient.java | 39 ++++++++++++++++++- .../vmoperator/runner/qemu/Runner.java | 9 ++++- .../templates/Standard-VM-latest.ftl.yaml | 4 ++ 3 files changed, 49 insertions(+), 3 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 f3928f5..afe3d26 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 @@ -20,6 +20,7 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.io.Writer; @@ -28,6 +29,8 @@ import java.net.UnixDomainSocketAddress; import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedList; +import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.logging.Level; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; @@ -65,6 +68,8 @@ public class GuestAgentClient extends Component { private EventPipeline rep; private Path socketPath; + private List> guestAgentCmds; + private String guestAgentCmd; private SocketIOChannel gaChannel; private final Queue executing = new LinkedList<>(); @@ -72,6 +77,7 @@ public class GuestAgentClient extends Component { * Instantiates a new guest agent client. * * @param componentChannel the component channel + * @param guestAgentCmds * @throws IOException Signals that an I/O exception has occurred. */ @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", @@ -87,10 +93,20 @@ public class GuestAgentClient extends Component { * forwarded from the {@link Runner} instead. * * @param socketPath the socket path + * @param guestAgentCmds * @param powerdownTimeout */ - /* default */ void configure(Path socketPath) { + @SuppressWarnings("PMD.EmptyCatchBlock") + /* default */ void configure(Path socketPath, ArrayNode guestAgentCmds) { this.socketPath = socketPath; + try { + this.guestAgentCmds = mapper.convertValue(guestAgentCmds, + mapper.constructType(getClass() + .getDeclaredField("guestAgentCmds").getGenericType())); + } catch (IllegalArgumentException | NoSuchFieldException + | SecurityException e) { + // Cannot happen + } } /** @@ -193,7 +209,7 @@ public class GuestAgentClient extends Component { () -> String.format("(Previous \"guest agent(in)\" is " + "result from executing %s)", executed)); if (executed instanceof QmpGuestGetOsinfo) { - rep.fire(new OsinfoEvent(response.get("return"))); + processOsInfo(response); } } } catch (JsonProcessingException e) { @@ -201,6 +217,25 @@ public class GuestAgentClient extends Component { } } + private void processOsInfo(ObjectNode response) { + var osInfo = new OsinfoEvent(response.get("return")); + var osId = osInfo.osinfo().get("id").asText(); + for (var cmdDef : guestAgentCmds) { + if (osId.equals(cmdDef.get("osId")) + || "*".equals(cmdDef.get("osId"))) { + guestAgentCmd = cmdDef.get("executable"); + break; + } + } + if (guestAgentCmd == null) { + logger.warning(() -> "No guest agent command for OS " + osId); + } else { + logger.fine(() -> "Guest agent command for OS " + osId + + " is " + guestAgentCmd); + } + rep.fire(osInfo); + } + /** * On closed. * 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 b258e1a..e0cd837 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 @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import freemarker.core.ParseException; @@ -197,6 +198,7 @@ public class Runner extends Component { private static final String QEMU = "qemu"; private static final String SWTPM = "swtpm"; private static final String CLOUD_INIT_IMG = "cloudInitImg"; + private static final String GUEST_AGENT_CMDS = "guestAgentCmds"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -348,11 +350,16 @@ public class Runner extends Component { .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) .orElse(null); logger.finest(() -> cloudInitImgDefinition.toString()); + var guestAgentCmds = (ArrayNode) tplData.get(GUEST_AGENT_CMDS); + if (guestAgentCmds != null) { + logger.finest( + () -> "GuestAgentCmds: " + guestAgentCmds.toString()); + } // Forward some values to child components qemuMonitor.configure(config.monitorSocket, config.vm.powerdownTimeout); - guestAgentClient.configure(config.guestAgentSocket); + guestAgentClient.configure(config.guestAgentSocket, guestAgentCmds); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index e2610ba..3eacfa3 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -233,3 +233,7 @@ + +"guestAgentCmds": + - "osId": "*" + "executable": "/usr/local/libexec/vm-operator-cmd" From 81b128e4a3461039abfde053ed7a32e5a391b811 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 22 Feb 2025 21:24:58 +0100 Subject: [PATCH 115/274] Clarify responsibilities of display secret monitor and reconciler. --- .../manager/DisplaySecretMonitor.java | 210 +--------------- .../manager/DisplaySecretReconciler.java | 224 +++++++++++++++++- .../vmoperator/manager/Reconciler.java | 5 +- webpages/vm-operator/upgrading.md | 24 +- webpages/vm-operator/user-gui.md | 12 +- 5 files changed, 252 insertions(+), 223 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index 152f91e..99c8a11 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2024 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 Affero General Public License as @@ -18,8 +18,6 @@ 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.V1Secret; @@ -28,52 +26,26 @@ import io.kubernetes.client.util.Watch.Response; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.time.Instant; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Scanner; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub; -import org.jdrupes.vmoperator.common.VmDefinitionStub; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; -import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jgrapes.core.Channel; -import org.jgrapes.core.CompletionLock; -import org.jgrapes.core.Event; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jose4j.base64url.Base64; /** - * Watches for changes of display secrets. The component supports the - * following configuration properties: - * - * * `passwordValidity`: the validity of the random password in seconds. - * Used to calculate the password expiry time in the generated secret. + * Watches for changes of display secrets. Updates an artifical attribute + * of the pod running the VM in response to force an update of the files + * in the pod that reflect the information from the secret. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) public class DisplaySecretMonitor extends AbstractMonitor { - private int passwordValidity = 10; - private final List pendingPrepares - = Collections.synchronizedList(new LinkedList<>()); private final ChannelDictionary channelDictionary; /** @@ -93,27 +65,6 @@ public class DisplaySecretMonitor options(options); } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @Override - public void onConfigurationUpdate(ConfigurationUpdate event) { - super.onConfigurationUpdate(event); - event.structured(componentPath()).ifPresent(c -> { - try { - if (c.containsKey("passwordValidity")) { - passwordValidity = Integer - .parseInt((String) c.get("passwordValidity")); - } - } catch (ClassCastException e) { - logger.config("Malformed configuration: " + e.getMessage()); - } - }); - } - @Override protected void prepareMonitoring() throws IOException, ApiException { client(new K8sClient()); @@ -168,157 +119,4 @@ public class DisplaySecretMonitor + "\"}]"), patchOpts); } - - /** - * On get display secrets. - * - * @param event the event - * @param channel the channel - * @throws ApiException the api exception - */ - @Handler - @SuppressWarnings("PMD.StringInstantiation") - public void onPrepareConsole(PrepareConsole event, VmChannel channel) - throws ApiException { - // Update console user in status - var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), - event.vmDefinition().namespace(), event.vmDefinition().name()); - var optVmDef = vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.addProperty("consoleUser", event.user()); - return status; - }); - if (optVmDef.isEmpty()) { - return; - } - var vmDef = optVmDef.get(); - - // Check if access is possible - if (event.loginUser() - ? !vmDef.conditionStatus("Booted").orElse(false) - : !vmDef.conditionStatus("Running").orElse(false)) { - return; - } - - // Look for secret - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + vmDef.name()); - var stubs = K8sV1SecretStub.list(client(), vmDef.namespace(), options); - if (stubs.isEmpty()) { - // No secret means no password for this VM wanted - event.setResult(null); - return; - } - var stub = stubs.iterator().next(); - - // Check validity - var secret = stub.model().get(); - @SuppressWarnings("PMD.StringInstantiation") - var expiry = Optional.ofNullable(secret.getData() - .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null - && stillValid(expiry)) { - // Fixed secret, don't touch - event.setResult( - new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); - return; - } - updatePassword(stub, event); - } - - @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) - throws ApiException { - SecureRandom random = null; - try { - random = SecureRandom.getInstanceStrong(); - } catch (NoSuchAlgorithmException e) { // NOPMD - // "Every implementation of the Java platform is required - // to support at least one strong SecureRandom implementation." - } - byte[] bytes = new byte[16]; - random.nextBytes(bytes); - var password = Base64.encode(bytes); - var model = stub.model().get(); - model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, - Long.toString(Instant.now().getEpochSecond() + passwordValidity))); - event.setResult(password); - - // Prepare wait for confirmation (by VM status change) - var pending = new PendingGet(event, - event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, - new CompletionLock(event, 1500)); - pendingPrepares.add(pending); - Event.onCompletion(event, e -> { - pendingPrepares.remove(pending); - }); - - // Update, will (eventually) trigger confirmation - stub.update(model).getObject(); - } - - private boolean stillValid(String expiry) { - if (expiry == null || "never".equals(expiry)) { - return true; - } - @SuppressWarnings({ "PMD.CloseResource", "resource" }) - var scanner = new Scanner(expiry); - if (!scanner.hasNextLong()) { - return false; - } - long expTime = scanner.nextLong(); - return expTime > Instant.now().getEpochSecond() + passwordValidity; - } - - /** - * On vm def changed. - * - * @param event the event - * @param channel the channel - */ - @Handler - @SuppressWarnings("PMD.AvoidSynchronizedStatement") - public void onVmDefChanged(VmDefChanged event, Channel channel) { - synchronized (pendingPrepares) { - String vmName = event.vmDefinition().name(); - for (var pending : pendingPrepares) { - if (pending.event.vmDefinition().name().equals(vmName) - && event.vmDefinition().displayPasswordSerial() - .map(s -> s >= pending.expectedSerial).orElse(false)) { - pending.lock.remove(); - // pending will be removed from pendingGest by - // waiting thread, see updatePassword - continue; - } - } - } - } - - /** - * The Class PendingGet. - */ - @SuppressWarnings("PMD.DataClass") - private static class PendingGet { - public final PrepareConsole event; - public final long expectedSerial; - public final CompletionLock lock; - - /** - * Instantiates a new pending get. - * - * @param event the event - * @param expectedSerial the expected serial - */ - public PendingGet(PrepareConsole event, long expectedSerial, - CompletionLock lock) { - super(); - this.event = event; - this.expectedSerial = expectedSerial; - this.lock = lock; - } - } } 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 dcae3a3..a281b8e 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 @@ -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 Affero General Public License as @@ -18,7 +18,9 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; import freemarker.template.TemplateException; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1Secret; @@ -26,25 +28,83 @@ import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Scanner; import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8sV1SecretStub; -import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.VmDefinitionStub; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.DataPath; +import org.jgrapes.core.Channel; +import org.jgrapes.core.CompletionLock; +import org.jgrapes.core.Component; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; import org.jose4j.base64url.Base64; /** - * Delegee for reconciling the display secret + * The properties of the display secret do not only depend on the + * VM definition, but also on events that occur during runtime. + * The reconciler for the display secret is therefore a separate + * component. + * + * The reconciler supports the following configuration properties: + * + * * `passwordValidity`: the validity of the random password in seconds. + * Used to calculate the password expiry time in the generated secret. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) -/* default */ class DisplaySecretReconciler { +public class DisplaySecretReconciler extends Component { protected final Logger logger = Logger.getLogger(getClass().getName()); + private int passwordValidity = 10; + private final List pendingPrepares + = Collections.synchronizedList(new LinkedList<>()); + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()) + // for backward compatibility + .or(() -> { + var oldConfig = event + .structured("/Manager/Controller/DisplaySecretMonitor"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "path '/Manager/Controller/DisplaySecretMonitor' " + + "for `passwordValidity`, please update " + + "the configuration."); + } + return oldConfig; + }).ifPresent(c -> { + try { + if (c.containsKey("passwordValidity")) { + passwordValidity = Integer + .parseInt((String) c.get("passwordValidity")); + } + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } /** * Reconcile. If the configuration prevents generating a secret @@ -104,4 +164,160 @@ import org.jose4j.base64url.Base64; K8sV1SecretStub.create(channel.client(), secret); } + /** + * Prepares access to the console for the user from the event. + * Generates a new password and sends it to the runner. + * Requests the VM (via the runner) to login the user if specified + * in the event. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings("PMD.StringInstantiation") + public void onPrepareConsole(PrepareConsole event, VmChannel channel) + throws ApiException { + // Update console user in status + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + event.vmDefinition().namespace(), event.vmDefinition().name()); + var optVmDef = vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty("consoleUser", event.user()); + return status; + }); + if (optVmDef.isEmpty()) { + return; + } + var vmDef = optVmDef.get(); + + // Check if access is possible + if (event.loginUser() + ? !vmDef.conditionStatus("Booted").orElse(false) + : !vmDef.conditionStatus("Running").orElse(false)) { + return; + } + + // Look for secret + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (stubs.isEmpty()) { + // No secret means no password for this VM wanted + event.setResult(null); + return; + } + var stub = stubs.iterator().next(); + + // Check validity + var secret = stub.model().get(); + @SuppressWarnings("PMD.StringInstantiation") + var expiry = Optional.ofNullable(secret.getData() + .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null + && stillValid(expiry)) { + // Fixed secret, don't touch + event.setResult( + new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + return; + } + updatePassword(stub, event); + } + + @SuppressWarnings("PMD.StringInstantiation") + private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) + throws ApiException { + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + var model = stub.model().get(); + model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + + // Prepare wait for confirmation (by VM status change) + var pending = new PendingGet(event, + event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, + new CompletionLock(event, 1500)); + pendingPrepares.add(pending); + Event.onCompletion(event, e -> { + pendingPrepares.remove(pending); + }); + + // Update, will (eventually) trigger confirmation + stub.update(model).getObject(); + } + + private boolean stillValid(String expiry) { + if (expiry == null || "never".equals(expiry)) { + return true; + } + @SuppressWarnings({ "PMD.CloseResource", "resource" }) + var scanner = new Scanner(expiry); + if (!scanner.hasNextLong()) { + return false; + } + long expTime = scanner.nextLong(); + return expTime > Instant.now().getEpochSecond() + passwordValidity; + } + + /** + * On vm def changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onVmDefChanged(VmDefChanged event, Channel channel) { + synchronized (pendingPrepares) { + String vmName = event.vmDefinition().name(); + for (var pending : pendingPrepares) { + if (pending.event.vmDefinition().name().equals(vmName) + && event.vmDefinition().displayPasswordSerial() + .map(s -> s >= pending.expectedSerial).orElse(false)) { + pending.lock.remove(); + // pending will be removed from pendingGest by + // waiting thread, see updatePassword + continue; + } + } + } + } + + /** + * The Class PendingGet. + */ + @SuppressWarnings("PMD.DataClass") + private static class PendingGet { + public final PrepareConsole event; + public final long expectedSerial; + public final CompletionLock lock; + + /** + * Instantiates a new pending get. + * + * @param event the event + * @param expectedSerial the expected serial + */ + public PendingGet(PrepareConsole event, long expectedSerial, + CompletionLock lock) { + super(); + this.event = event; + this.expectedSerial = expectedSerial; + this.lock = lock; + } + } } 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 7dbb410..7969d46 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 @@ -138,6 +138,8 @@ import org.jgrapes.util.events.ConfigurationUpdate; * properties to be used by the runners managed by the controller. * This property is a string that holds the content of * a logging.properties file. + * + * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.AvoidDuplicateLiterals" }) @@ -163,6 +165,7 @@ public class Reconciler extends Component { * * @param componentChannel the component channel */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Reconciler(Channel componentChannel) { super(componentChannel); @@ -177,7 +180,7 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - dsReconciler = new DisplaySecretReconciler(); + dsReconciler = attach(new DisplaySecretReconciler()); stsReconciler = new StatefulSetReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig); diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index 77cacad..2c4253e 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -7,16 +7,24 @@ layout: vm-operator ## To version 4.0.0 -The VmViewer conlet has been renamed to VmAccess. This affects the -[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration information using the old path -"/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer" -is still accepted for backward compatibility, but should be updated. + * The VmViewer conlet has been renamed to VmAccess. This affects the + [configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration + information using the old path + `/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer` + is still accepted for backward compatibility until the next major version, + but should be updated. -The change of name also causes conlets added to the overview page by -users to "disappear" from the GUI. They have to be re-added. + The change of name also causes conlets added to the overview page by + users to "disappear" from the GUI. They have to be re-added. -The latter behavior also applies to the VmConlet conlet which has been -renamed to VmMgmt. + The latter behavior also applies to the VmConlet conlet which has been + renamed to VmMgmt. + + * The configuration property `passwordValidity` has been moved from component + `/Manager/Controller/DisplaySecretMonitor` to + `/Manager/Controller/Reconciler/DisplaySecretReconciler`. The old path is + still accepted for backward compatibility until the next major version, + but should be updated. ## To version 3.4.0 diff --git a/webpages/vm-operator/user-gui.md b/webpages/vm-operator/user-gui.md index 0439db2..bc0b93e 100644 --- a/webpages/vm-operator/user-gui.md +++ b/webpages/vm-operator/user-gui.md @@ -127,16 +127,20 @@ of 16 (strong) random bytes (128 random bits). It is valid for 10 seconds only. This may be challenging on a slower computer or if users may not enable automatic open for connection files in the browser. The validity can therefore be adjusted in the -configuration. +configuration.[^oldPath] ```yaml "/Manager": "/Controller": - "/DisplaySecretMonitor": - # Validity of generated password in seconds - passwordValidity: 10 + "/Reconciler": + "/DisplaySecretReconciler": + # Validity of generated password in seconds + passwordValidity: 10 ``` +[^oldPath]: Before version 4.0, the path for `passwordValidity` was + `/Manager/Controller/DisplaySecretMonitor`. + Taking into account that the controller generates a display secret automatically by default, this approach to securing console access should be sufficient in all cases. (Any feedback From 0828d0383520b88a019a433e813010e2bee4fc46 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 22 Feb 2025 21:27:39 +0100 Subject: [PATCH 116/274] Javadoc fixes. --- .../org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java | 4 +--- 1 file changed, 1 insertion(+), 3 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 afe3d26..fba975e 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 @@ -77,7 +77,6 @@ public class GuestAgentClient extends Component { * Instantiates a new guest agent client. * * @param componentChannel the component channel - * @param guestAgentCmds * @throws IOException Signals that an I/O exception has occurred. */ @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", @@ -93,8 +92,7 @@ public class GuestAgentClient extends Component { * forwarded from the {@link Runner} instead. * * @param socketPath the socket path - * @param guestAgentCmds - * @param powerdownTimeout + * @param guestAgentCmds the guest agent cmds */ @SuppressWarnings("PMD.EmptyCatchBlock") /* default */ void configure(Path socketPath, ArrayNode guestAgentCmds) { From 3012da3e876e1156db5f854a21ac4ebce8d00f8c Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 11:14:46 +0100 Subject: [PATCH 117/274] Add login information to display secret. --- .../jdrupes/vmoperator/manager/Constants.java | 7 ++ .../manager/DisplaySecretReconciler.java | 96 ++++++++++++------- .../vmoperator/manager/Reconciler.java | 2 +- 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java index 7de839b..f12b512 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.manager; +// TODO: Auto-generated Javadoc /** * Some constants. */ @@ -33,6 +34,12 @@ public class Constants extends org.jdrupes.vmoperator.common.Constants { /** The Constant DATA_PASSWORD_EXPIRY. */ public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; + /** The Constant DATA_DISPLAY_USER. */ + public static final String DATA_DISPLAY_USER = "display-user"; + + /** The Constant DATA_DISPLAY_LOGIN. */ + public static final String DATA_DISPLAY_LOGIN = "login-user"; + /** The Constant STATE_RUNNING. */ public static final String STATE_RUNNING = "Running"; 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 a281b8e..66bb021 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 @@ -26,6 +26,7 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import static java.nio.charset.StandardCharsets.UTF_8; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Instant; @@ -33,16 +34,19 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Scanner; import java.util.logging.Logger; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinitionStub; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; +import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_LOGIN; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_USER; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; @@ -75,6 +79,15 @@ public class DisplaySecretReconciler extends Component { private final List pendingPrepares = Collections.synchronizedList(new LinkedList<>()); + /** + * Instantiates a new display secret reconciler. + * + * @param componentChannel the component channel + */ + public DisplaySecretReconciler(Channel componentChannel) { + super(componentChannel); + } + /** * On configuration update. * @@ -213,39 +226,13 @@ public class DisplaySecretReconciler extends Component { } var stub = stubs.iterator().next(); - // Check validity + // Get secret and update var secret = stub.model().get(); - @SuppressWarnings("PMD.StringInstantiation") - var expiry = Optional.ofNullable(secret.getData() - .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null - && stillValid(expiry)) { - // Fixed secret, don't touch - event.setResult( - new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + var updPw = updatePassword(secret, event); + var updUsr = updateUser(secret, event); + if (!updPw && !updUsr) { return; } - updatePassword(stub, event); - } - - @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, PrepareConsole event) - throws ApiException { - SecureRandom random = null; - try { - random = SecureRandom.getInstanceStrong(); - } catch (NoSuchAlgorithmException e) { // NOPMD - // "Every implementation of the Java platform is required - // to support at least one strong SecureRandom implementation." - } - byte[] bytes = new byte[16]; - random.nextBytes(bytes); - var password = Base64.encode(bytes); - var model = stub.model().get(); - model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, - Long.toString(Instant.now().getEpochSecond() + passwordValidity))); - event.setResult(password); // Prepare wait for confirmation (by VM status change) var pending = new PendingGet(event, @@ -257,7 +244,52 @@ public class DisplaySecretReconciler extends Component { }); // Update, will (eventually) trigger confirmation - stub.update(model).getObject(); + stub.update(secret).getObject(); + } + + private boolean updateUser(V1Secret secret, PrepareConsole event) { + var curUser = DataPath. get(secret, "data", DATA_DISPLAY_USER) + .map(b -> new String(b, UTF_8)).orElse(null); + var curLogin = DataPath. get(secret, "data", DATA_DISPLAY_LOGIN) + .map(b -> new String(b, UTF_8)).map(Boolean::parseBoolean) + .orElse(null); + if (Objects.equals(curUser, event.user()) && Objects.equals( + curLogin, event.loginUser())) { + return false; + } + secret.getData().put(DATA_DISPLAY_USER, event.user().getBytes(UTF_8)); + secret.getData().put(DATA_DISPLAY_LOGIN, + Boolean.toString(event.loginUser()).getBytes(UTF_8)); + return true; + } + + private boolean updatePassword(V1Secret secret, PrepareConsole event) { + var expiry = Optional.ofNullable(secret.getData() + .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null + && stillValid(expiry)) { + // Fixed secret, don't touch + event.setResult( + new String(secret.getData().get(DATA_DISPLAY_PASSWORD))); + return false; + } + + // Generate password and set expiry + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + return true; } private boolean stillValid(String expiry) { 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 7969d46..8011e2c 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 @@ -180,7 +180,7 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - dsReconciler = attach(new DisplaySecretReconciler()); + dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); stsReconciler = new StatefulSetReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig); From 5b8b47f95cb80bc469db18b2be7f166bfc95a5b8 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 11:47:13 +0100 Subject: [PATCH 118/274] Add some metadata to make bing happy. --- dev-example/test-vm.tpl.yaml | 2 +- webpages/vm-operator/admin-gui.md | 4 ++++ webpages/vm-operator/controller.md | 3 +++ webpages/vm-operator/index.md | 7 +++++-- webpages/vm-operator/manager.md | 3 +++ webpages/vm-operator/runner.md | 4 ++++ webpages/vm-operator/upgrading.md | 2 ++ webpages/vm-operator/user-gui.md | 4 ++++ 8 files changed, 26 insertions(+), 3 deletions(-) diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index 50031bb..260341e 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -14,7 +14,7 @@ spec: # repository: ghcr.io # path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine # version: "3.0.0" - source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:feature-auto-login pullPolicy: Always permissions: diff --git a/webpages/vm-operator/admin-gui.md b/webpages/vm-operator/admin-gui.md index 4bfa3d3..4b7f5b2 100644 --- a/webpages/vm-operator/admin-gui.md +++ b/webpages/vm-operator/admin-gui.md @@ -1,5 +1,9 @@ --- title: "VM-Operator: Administrator View — Provides an overview of running VMs" +description: >- + Information about the administrator view of the VM-Operator, which provides + an overview of the defined VMs, their state and resource consumptions and + actions for starting, stopping and accessing the VMs. layout: vm-operator --- diff --git a/webpages/vm-operator/controller.md b/webpages/vm-operator/controller.md index cc6a274..e20263f 100644 --- a/webpages/vm-operator/controller.md +++ b/webpages/vm-operator/controller.md @@ -1,5 +1,8 @@ --- title: "VM-Operator: Controller — Reconciles the VM CRs" +description: >- + Information about the VM Operator's controller component its + configuration options and the CRD used to define VMs. layout: vm-operator --- diff --git a/webpages/vm-operator/index.md b/webpages/vm-operator/index.md index baf8e20..5cd2d58 100644 --- a/webpages/vm-operator/index.md +++ b/webpages/vm-operator/index.md @@ -1,6 +1,9 @@ --- -title: Run VMs on Kubernetes using Qemu/KVM and SPICE -description: A solution for running VMs on Kubernetes with a web interface for admins and users. Focuses on running Qemu/KVM virtual machines and using SPICE as display protocol. +title: "Run VMs on Kubernetes using QEMU/KVM and SPICE" +description: >- + A solution for running VMs on Kubernetes with a web interface for + admins and users. Focuses on running QEMU/KVM virtual machines and + using SPICE as display protocol. layout: vm-operator --- diff --git a/webpages/vm-operator/manager.md b/webpages/vm-operator/manager.md index c1965f1..ee971c1 100644 --- a/webpages/vm-operator/manager.md +++ b/webpages/vm-operator/manager.md @@ -1,5 +1,8 @@ --- title: "VM-Operator: The Manager — Provides the controller and a web user interface" +description: >- + Information about the installation and configuration of the + VM Operator. layout: vm-operator --- diff --git a/webpages/vm-operator/runner.md b/webpages/vm-operator/runner.md index c72793d..a6a744d 100644 --- a/webpages/vm-operator/runner.md +++ b/webpages/vm-operator/runner.md @@ -1,5 +1,9 @@ --- title: "VM-Operator: The Runner — Starts and monitors a VM" +description: >- + Description of the VM Operator's runner component which starts + QEMU and thus the VM, optionally together with a TPM, in a + kubenernetes pod and monitors everything. layout: vm-operator --- diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index 77cacad..b987298 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -1,5 +1,7 @@ --- title: "VM-Operator: Upgrading — Issues to watch out for" +description: >- + Information about issues to watch out for when upgrading the VM-Operator. layout: vm-operator --- diff --git a/webpages/vm-operator/user-gui.md b/webpages/vm-operator/user-gui.md index 0439db2..0d16113 100644 --- a/webpages/vm-operator/user-gui.md +++ b/webpages/vm-operator/user-gui.md @@ -1,5 +1,9 @@ --- title: "VM-Operator: User View — Allows users to manage their own VMs" +description: >- + Information about the user view of the VM-Operator, which allows users + to access and optionally manage the VMs for which they have the + respective permissions. layout: vm-operator --- From 558f4d96c9aee5186d85130d85345712cdaef5f1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 12:00:27 +0100 Subject: [PATCH 119/274] Add pagefind. --- webpages/_layouts/vm-operator.html | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 30e6407..e779711 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -1,5 +1,5 @@ - + @@ -11,10 +11,31 @@ - {% seo %} + + + + {% seo %}
+
@@ -68,4 +89,4 @@ {% include matomo.html %} - + From f236b376ae27d4c6c8f8a40ad9d8681ef0730c90 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 23 Feb 2025 12:05:55 +0100 Subject: [PATCH 120/274] Back to testing. --- dev-example/test-vm.tpl.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index 260341e..50031bb 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -14,7 +14,7 @@ spec: # repository: ghcr.io # path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine # version: "3.0.0" - source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:feature-auto-login + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing pullPolicy: Always permissions: From e3b5f5a04dcd92cfbf1b34af605cf6f77534c085 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 11:58:13 +0100 Subject: [PATCH 121/274] Refactor QEMU socket connection handling and start vmop agent. --- dev-example/test-vm.tpl.yaml | 2 +- .../runner/qemu/AgentConnector.java | 86 +++++++ .../vmoperator/runner/qemu/Configuration.java | 6 +- .../runner/qemu/GuestAgentClient.java | 200 ++------------- .../vmoperator/runner/qemu/QemuConnector.java | 234 ++++++++++++++++++ .../vmoperator/runner/qemu/QemuMonitor.java | 134 ++-------- .../vmoperator/runner/qemu/Runner.java | 42 +++- .../runner/qemu/VmopAgentClient.java | 48 ++++ .../templates/Standard-VM-latest.ftl.yaml | 11 +- webpages/vm-operator/upgrading.md | 7 + 10 files changed, 451 insertions(+), 319 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index 50031bb..260341e 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -14,7 +14,7 @@ spec: # repository: ghcr.io # path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine # version: "3.0.0" - source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:feature-auto-login pullPolicy: Always permissions: diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java new file mode 100644 index 0000000..40db84a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java @@ -0,0 +1,86 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.nio.file.Path; +import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * A component that handles the communication with an agent + * running in the VM. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public abstract class AgentConnector extends QemuConnector { + + protected String channelId; + + /** + * Instantiates a new agent connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public AgentConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param channelId the channel id + * @param socketPath the socket path + */ + /* default */ void configure(String channelId, Path socketPath) { + super.configure(socketPath); + this.channelId = channelId; + logger.fine(() -> getClass().getSimpleName() + " configured with" + + " channelId=" + channelId); + } + + /** + * When the virtual serial port with the configured channel id has + * been opened call {@link #agentConnected()}. + * + * @param event the event + */ + @Handler + public void onVserportChanged(VserportChangeEvent event) { + if (event.id().equals(channelId) && event.isOpen()) { + agentConnected(); + } + } + + /** + * Called when the agent in the VM opens the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentConnected() { + // Default is to do nothing. + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index 086f085..20d4c66 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -39,7 +39,7 @@ import org.jdrupes.vmoperator.util.FsdUtils; /** * The configuration information from the configuration file. */ -@SuppressWarnings("PMD.ExcessivePublicCount") +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyFields" }) public class Configuration implements Dto { private static final String CI_INSTANCE_ID = "instance-id"; @@ -67,9 +67,6 @@ public class Configuration implements Dto { /** The monitor socket. */ public Path monitorSocket; - /** The guest agent socket socket. */ - public Path guestAgentSocket; - /** The firmware rom. */ public Path firmwareRom; @@ -344,7 +341,6 @@ public class Configuration implements Dto { runtimeDir.toFile().mkdir(); swtpmSocket = runtimeDir.resolve("swtpm-sock"); monitorSocket = runtimeDir.resolve("monitor.sock"); - guestAgentSocket = runtimeDir.resolve("org.qemu.guest_agent.0"); } if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) { logger.severe(() -> String.format( 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 fba975e..2e5e059 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 @@ -19,58 +19,26 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.LinkedList; -import java.util.List; -import java.util.Map; import java.util.Queue; import java.util.logging.Level; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; -import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; 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.Start; -import org.jgrapes.core.events.Stop; -import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; -import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; -import org.jgrapes.util.events.ConfigurationUpdate; /** - * A component that handles the communication over the guest agent - * socket. + * A component that handles the communication with the guest agent. * * If the log level for this class is set to fine, the messages * exchanged on the monitor socket are logged. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class GuestAgentClient extends Component { +public class GuestAgentClient extends AgentConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; - private List> guestAgentCmds; - private String guestAgentCmd; - private SocketIOChannel gaChannel; private final Queue executing = new LinkedList<>(); /** @@ -79,135 +47,36 @@ public class GuestAgentClient extends Component { * @param componentChannel the component channel * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", - "PMD.ConstructorCallsOverridableMethod" }) public GuestAgentClient(Channel componentChannel) throws IOException { super(componentChannel); } /** - * As the initial configuration of this component depends on the - * configuration of the {@link Runner}, it doesn't have a handler - * for the {@link ConfigurationUpdate} event. The values are - * forwarded from the {@link Runner} instead. - * - * @param socketPath the socket path - * @param guestAgentCmds the guest agent cmds + * When the agent has connected, request the OS information. */ - @SuppressWarnings("PMD.EmptyCatchBlock") - /* default */ void configure(Path socketPath, ArrayNode guestAgentCmds) { - this.socketPath = socketPath; - try { - this.guestAgentCmds = mapper.convertValue(guestAgentCmds, - mapper.constructType(getClass() - .getDeclaredField("guestAgentCmds").getGenericType())); - } catch (IllegalArgumentException | NoSuchFieldException - | SecurityException e) { - // Cannot happen - } + @Override + protected void agentConnected() { + fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); } /** - * Handle the start event. + * Process agent input. * - * @param event the event + * @param line the line * @throws IOException Signals that an I/O exception has occurred. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - } - - /** - * When the virtual serial port "channel0" has been opened, - * establish the connection by opening the socket. - * - * @param event the event - */ - @Handler - public void onVserportChanged(VserportChangeEvent event) { - if ("channel0".equals(event.id()) && event.isOpen()) { - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(GuestAgentClient.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @SuppressWarnings("resource") - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(GuestAgentClient.class).ifPresent(qm -> { - gaChannel = channel; - channel.setAssociated(GuestAgentClient.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processGuestAgentInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(GuestAgentClient.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(GuestAgentClient.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processGuestAgentInput(String line) - throws IOException { + @Override + protected void processInput(String line) throws IOException { logger.fine(() -> "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)\" is " - + "result from executing %s)", executed)); + logger.fine(() -> String.format("(Previous \"guest agent(in)\"" + + " is result from executing %s)", executed)); if (executed instanceof QmpGuestGetOsinfo) { - processOsInfo(response); + var osInfo = new OsinfoEvent(response.get("return")); + rep().fire(osInfo); } } } catch (JsonProcessingException e) { @@ -215,48 +84,17 @@ public class GuestAgentClient extends Component { } } - private void processOsInfo(ObjectNode response) { - var osInfo = new OsinfoEvent(response.get("return")); - var osId = osInfo.osinfo().get("id").asText(); - for (var cmdDef : guestAgentCmds) { - if (osId.equals(cmdDef.get("osId")) - || "*".equals(cmdDef.get("osId"))) { - guestAgentCmd = cmdDef.get("executable"); - break; - } - } - if (guestAgentCmd == null) { - logger.warning(() -> "No guest agent command for OS " + osId); - } else { - logger.fine(() -> "Guest agent command for OS " + osId - + " is " + guestAgentCmd); - } - rep.fire(osInfo); - } - - /** - * On closed. - * - * @param event the event - */ - @Handler - @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", - "PMD.AvoidDuplicateLiterals" }) - public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(QemuMonitor.class).ifPresent(qm -> { - gaChannel = null; - }); - } - /** * On guest agent command. * * @param event the event */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidSynchronizedStatement" }) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onGuestAgentCommand(GuestAgentCommand event) { + if (qemuChannel() == null) { + return; + } var command = event.command(); logger.fine(() -> "guest agent(out): " + command.toString()); String asText; @@ -268,7 +106,7 @@ public class GuestAgentClient extends Component { return; } synchronized (executing) { - gaChannel.associated(Writer.class).ifPresent(writer -> { + writer().ifPresent(writer -> { try { executing.add(command); writer.append(asText).append('\n').flush(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java new file mode 100644 index 0000000..143cfc2 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -0,0 +1,234 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.Writer; +import java.lang.reflect.UndeclaredThrowableException; +import java.net.UnixDomainSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +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.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ConnectError; +import org.jgrapes.io.events.Input; +import org.jgrapes.io.events.OpenSocketConnection; +import org.jgrapes.io.util.ByteBufferWriter; +import org.jgrapes.io.util.LineCollector; +import org.jgrapes.net.SocketIOChannel; +import org.jgrapes.net.events.ClientConnected; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * A component that handles the communication with QEMU over a socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public abstract class QemuConnector extends Component { + + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + + private EventPipeline rep; + private Path socketPath; + private SocketIOChannel qemuChannel; + + /** + * Instantiates a new QEMU connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public QemuConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param socketPath the socket path + */ + /* default */ void configure(Path socketPath) { + this.socketPath = socketPath; + logger.fine(() -> getClass().getSimpleName() + + " configured with socketPath=" + socketPath); + } + + /** + * Note the runner's event processor and delete the socket. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onStart(Start event) throws IOException { + rep = event.associated(EventPipeline.class).get(); + if (socketPath == null) { + return; + } + Files.deleteIfExists(socketPath); + fire(new WatchFile(socketPath)); + } + + /** + * Return the runner's event pipeline. + * + * @return the event pipeline + */ + protected EventPipeline rep() { + return rep; + } + + /** + * Watch for the creation of the swtpm socket and start the + * qemu process if it has been created. + * + * @param event the event + */ + @Handler + public void onFileChanged(FileChanged event) { + if (event.change() == FileChanged.Kind.CREATED + && event.path().equals(socketPath)) { + // qemu running, open socket + fire(new OpenSocketConnection( + UnixDomainSocketAddress.of(socketPath)) + .setAssociated(getClass(), this)); + } + } + + /** + * Check if this is from opening the agent socket and if true, + * save the socket in the context and associate the channel with + * the context. + * + * @param event the event + * @param channel the channel + */ + @SuppressWarnings("resource") + @Handler + public void onClientConnected(ClientConnected event, + SocketIOChannel channel) { + event.openEvent().associated(getClass()).ifPresent(qm -> { + qemuChannel = channel; + channel.setAssociated(getClass(), this); + channel.setAssociated(Writer.class, new ByteBufferWriter( + channel).nativeCharset()); + channel.setAssociated(LineCollector.class, + new LineCollector() + .consumer(line -> { + try { + processInput(line); + } catch (IOException e) { + throw new UndeclaredThrowableException(e); + } + })); + socketConnected(); + }); + } + + /** + * Return the QEMU channel if the connection has been established. + * + * @return the socket IO channel + */ + protected Optional qemuChannel() { + return Optional.ofNullable(qemuChannel); + } + + /** + * Return the {@link Writer} for the connection if the connection + * has been established. + * + * @return the optional + */ + protected Optional writer() { + return qemuChannel().flatMap(c -> c.associated(Writer.class)); + } + + /** + * Called when the connector has been connected to the socket. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void socketConnected() { + // Default is to do nothing. + } + + /** + * Called when a connection attempt fails. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onConnectError(ConnectError event, SocketIOChannel channel) { + event.event().associated(getClass()).ifPresent(qm -> { + rep.fire(new Stop()); + }); + } + + /** + * Handle data from the socket connection. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onInput(Input event, SocketIOChannel channel) { + if (channel.associated(getClass()).isEmpty()) { + return; + } + channel.associated(LineCollector.class).ifPresent(collector -> { + collector.feed(event); + }); + } + + /** + * Process agent input. + * + * @param line the line + * @throws IOException Signals that an I/O exception has occurred. + */ + protected abstract void processInput(String line) throws IOException; + + /** + * On closed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onClosed(Closed event, SocketIOChannel channel) { + channel.associated(getClass()).ifPresent(qm -> { + qemuChannel = null; + }); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 7cac734..000a3bf 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 @@ -19,13 +19,8 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; @@ -42,24 +37,13 @@ import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult; import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; import org.jgrapes.core.Components; import org.jgrapes.core.Components.Timer; -import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; import org.jgrapes.core.events.Stop; import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.FileChanged; -import org.jgrapes.util.events.WatchFile; /** * A component that handles the communication over the Qemu monitor @@ -69,14 +53,9 @@ import org.jgrapes.util.events.WatchFile; * exchanged on the monitor socket are logged. */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class QemuMonitor extends Component { +public class QemuMonitor extends QemuConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; private int powerdownTimeout; - private SocketIOChannel monitorChannel; private final Queue executing = new LinkedList<>(); private Instant powerdownStartedAt; private Stop suspendedStop; @@ -84,7 +63,7 @@ public class QemuMonitor extends Component { private boolean powerdownConfirmed; /** - * Instantiates a new qemu monitor. + * Instantiates a new QEMU monitor. * * @param componentChannel the component channel * @param configDir the config dir @@ -111,109 +90,26 @@ public class QemuMonitor extends Component { * @param powerdownTimeout */ /* default */ void configure(Path socketPath, int powerdownTimeout) { - this.socketPath = socketPath; + super.configure(socketPath); this.powerdownTimeout = powerdownTimeout; } /** - * Handle the start event. - * - * @param event the event - * @throws IOException Signals that an I/O exception has occurred. + * When the socket is connected, send the capabilities command. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - fire(new WatchFile(socketPath)); + @Override + protected void socketConnected() { + fire(new MonitorCommand(new QmpCapabilities())); } - /** - * Watch for the creation of the swtpm socket and start the - * qemu process if it has been created. - * - * @param event the event - */ - @Handler - public void onFileChanged(FileChanged event) { - if (event.change() == FileChanged.Kind.CREATED - && event.path().equals(socketPath)) { - // qemu running, open socket - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(QemuMonitor.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @SuppressWarnings("resource") - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = channel; - channel.setAssociated(QemuMonitor.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processMonitorInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new MonitorCommand(new QmpCapabilities())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(QemuMonitor.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(QemuMonitor.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processMonitorInput(String line) + @Override + protected void processInput(String line) throws IOException { logger.fine(() -> "monitor(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("QMP")) { - rep.fire(new MonitorReady()); + rep().fire(new MonitorReady()); return; } if (response.has("return") || response.has("error")) { @@ -221,11 +117,11 @@ public class QemuMonitor extends Component { logger.fine( () -> String.format("(Previous \"monitor(in)\" is result " + "from executing %s)", executed)); - rep.fire(MonitorResult.from(executed, response)); + rep().fire(MonitorResult.from(executed, response)); return; } if (response.has("event")) { - MonitorEvent.from(response).ifPresent(rep::fire); + MonitorEvent.from(response).ifPresent(rep()::fire); } } catch (JsonProcessingException e) { throw new IOException(e); @@ -241,8 +137,8 @@ public class QemuMonitor extends Component { @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", "PMD.AvoidDuplicateLiterals" }) public void onClosed(Closed event, SocketIOChannel channel) { + super.onClosed(event, channel); channel.associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = null; synchronized (this) { if (powerdownTimer != null) { powerdownTimer.cancel(); @@ -275,7 +171,7 @@ public class QemuMonitor extends Component { return; } synchronized (executing) { - monitorChannel.associated(Writer.class).ifPresent(writer -> { + writer().ifPresent(writer -> { try { executing.add(command); writer.append(asText).append('\n').flush(); @@ -295,7 +191,7 @@ public class QemuMonitor extends Component { @Handler(priority = 100) @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onStop(Stop event) { - if (monitorChannel != null) { + if (qemuChannel() != null) { // We have a connection to Qemu, attempt ACPI shutdown. event.suspendHandling(); suspendedStop = event; 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 e0cd837..0eaabe9 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 @@ -23,7 +23,6 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import freemarker.core.ParseException; @@ -198,7 +197,6 @@ public class Runner extends Component { private static final String QEMU = "qemu"; private static final String SWTPM = "swtpm"; private static final String CLOUD_INIT_IMG = "cloudInitImg"; - private static final String GUEST_AGENT_CMDS = "guestAgentCmds"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -222,6 +220,7 @@ public class Runner extends Component { private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; private final GuestAgentClient guestAgentClient; + private final VmopAgentClient vmopAgentClient; private Integer resetCounter; private RunState state = RunState.INITIALIZING; @@ -280,6 +279,7 @@ public class Runner extends Component { attach(new SocketConnector(channel())); attach(qemuMonitor = new QemuMonitor(channel(), configDir)); attach(guestAgentClient = new GuestAgentClient(channel())); + attach(vmopAgentClient = new VmopAgentClient(channel())); attach(new StatusUpdater(channel())); attach(new YamlConfigurationStore(channel(), configFile, false)); fire(new WatchFile(configFile.toPath())); @@ -350,16 +350,12 @@ public class Runner extends Component { .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) .orElse(null); logger.finest(() -> cloudInitImgDefinition.toString()); - var guestAgentCmds = (ArrayNode) tplData.get(GUEST_AGENT_CMDS); - if (guestAgentCmds != null) { - logger.finest( - () -> "GuestAgentCmds: " + guestAgentCmds.toString()); - } // Forward some values to child components qemuMonitor.configure(config.monitorSocket, config.vm.powerdownTimeout); - guestAgentClient.configure(config.guestAgentSocket, guestAgentCmds); + configureAgentClient(guestAgentClient, "guest-agent-socket"); + configureAgentClient(vmopAgentClient, "vmop-agent-socket"); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); @@ -484,6 +480,36 @@ public class Runner extends Component { } } + @SuppressWarnings("PMD.CognitiveComplexity") + private void configureAgentClient(AgentConnector client, String chardev) { + String id = null; + Path path = null; + for (var arg : qemuDefinition.command) { + if (arg.startsWith("virtserialport,") + && arg.contains("chardev=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("id=")) { + id = prop.substring(3); + } + } + } + if (arg.startsWith("socket,") + && arg.contains("id=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("path=")) { + path = Path.of(prop.substring(5)); + } + } + } + } + if (id == null || path == null) { + logger.warning(() -> "Definition of chardev " + chardev + + " missing in runner template."); + return; + } + client.configure(id, path); + } + /** * Handle the started event. * 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 new file mode 100644 index 0000000..a74432b --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -0,0 +1,48 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import org.jgrapes.core.Channel; + +/** + * A component that handles the communication over the vmop agent + * socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public class VmopAgentClient extends AgentConnector { + + /** + * Instantiates a new VM operator agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public VmopAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + @Override + protected void processInput(String line) throws IOException { + // TODO Auto-generated method stub + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index 3eacfa3..c5c0252 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -122,11 +122,16 @@ # Best explanation found: # https://fedoraproject.org/wiki/Features/VirtioSerial - [ "-device", "virtio-serial-pci,id=virtio-serial0" ] - # - Guest agent serial connection. MUST have id "channel0"! + # - Guest agent serial connection. - [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\ chardev=guest-agent-socket" ] - [ "-chardev","socket,id=guest-agent-socket,\ path=${ runtimeDir }/org.qemu.guest_agent.0,server=on,wait=off" ] + # - VM operator agent serial connection. + - [ "-device", "virtserialport,id=channel1,name=org.jdrupes.vmop_agent.0,\ + chardev=vmop-agent-socket" ] + - [ "-chardev","socket,id=vmop-agent-socket,\ + path=${ runtimeDir }/org.jdrupes.vmop_agent.0,server=on,wait=off" ] # * USB Hub and devices (more in SPICE configuration below) # https://qemu-project.gitlab.io/qemu/system/devices/usb.html # https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c @@ -233,7 +238,3 @@ - -"guestAgentCmds": - - "osId": "*" - "executable": "/usr/local/libexec/vm-operator-cmd" diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index 2c4253e..422c32d 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -26,6 +26,13 @@ layout: vm-operator still accepted for backward compatibility until the next major version, but should be updated. + * The standard [template](./runner.html#stand-alone-configuration) used + to generate the QEMU command has been updated. Unless you have enabled + automatic updates of the template in the VM definition, you have to + 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). + ## To version 3.4.0 Starting with this version, the VM-Operator no longer uses a stateful set From c45c452c83a09cf6c00ab6ab3cfe8ad0ab7e6529 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 13:20:28 +0100 Subject: [PATCH 122/274] Adjust class name. --- .../vmoperator/manager/DisplaySecretReconciler.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 66bb021..e1955b4 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 @@ -76,7 +76,7 @@ public class DisplaySecretReconciler extends Component { protected final Logger logger = Logger.getLogger(getClass().getName()); private int passwordValidity = 10; - private final List pendingPrepares + private final List pendingPrepares = Collections.synchronizedList(new LinkedList<>()); /** @@ -234,8 +234,8 @@ public class DisplaySecretReconciler extends Component { return; } - // Prepare wait for confirmation (by VM status change) - var pending = new PendingGet(event, + // Register wait for confirmation (by VM status change) + var pending = new PendingPrepare(event, event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, new CompletionLock(event, 1500)); pendingPrepares.add(pending); @@ -333,7 +333,7 @@ public class DisplaySecretReconciler extends Component { * The Class PendingGet. */ @SuppressWarnings("PMD.DataClass") - private static class PendingGet { + private static class PendingPrepare { public final PrepareConsole event; public final long expectedSerial; public final CompletionLock lock; @@ -344,7 +344,7 @@ public class DisplaySecretReconciler extends Component { * @param event the event * @param expectedSerial the expected serial */ - public PendingGet(PrepareConsole event, long expectedSerial, + public PendingPrepare(PrepareConsole event, long expectedSerial, CompletionLock lock) { super(); this.event = event; From ddab466fd05a8d168f5b38dd3716a4ccdb898985 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 24 Feb 2025 14:03:49 +0100 Subject: [PATCH 123/274] Restrict pagefind search to project. --- webpages/_layouts/vm-operator.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index e779711..4456d20 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -11,11 +11,12 @@ - - + + + + +

Hosted on GitHub Pages — TermsPrivacy — Theme derived from minimal

From d7af1f5d068ab4f8283e109220b16ace7f88c322 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:23:02 +0100 Subject: [PATCH 151/274] Fix footer. --- webpages/_layouts/vm-operator.html | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 5d780ef..1c3bad2 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -82,15 +82,17 @@
- - - - - -

Hosted on GitHub Pages — Terms - — Privacy - — Theme derived from minimal

From 05d53c58b16f5ac60842012d0fc0c662bdd03cbd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:28:08 +0100 Subject: [PATCH 152/274] Fix footer. --- webpages/_layouts/vm-operator.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 1c3bad2..966b268 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -88,7 +88,7 @@ — Privacy — Theme derived from minimal

-
- -
From 687a050ec446441ba6e28e1e56f19d2473b9641c Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:55:40 +0100 Subject: [PATCH 154/274] Generate sitemap. --- webpages/Gemfile | 1 + webpages/_config.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/webpages/Gemfile b/webpages/Gemfile index ecbbb7d..3e965ca 100644 --- a/webpages/Gemfile +++ b/webpages/Gemfile @@ -2,4 +2,5 @@ source 'https://rubygems.org' # gem 'github-pages', group: :jekyll_plugins gem "jekyll", "~> 4.0" gem "jekyll-seo-tag" +gem 'jekyll-sitemap' gem 'webrick', '~> 1.3', '>= 1.3.1' diff --git a/webpages/_config.yml b/webpages/_config.yml index bc830a3..a2ee1a3 100644 --- a/webpages/_config.yml +++ b/webpages/_config.yml @@ -1,5 +1,6 @@ plugins: - jekyll-seo-tag + - jekyll-sitemap url: "https://jdrupes.org" From 07fb07a6a4c4c334cad516282a0ffeac331dc17d Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 12:57:26 +0100 Subject: [PATCH 155/274] Javadoc is hosted on main site only. --- webpages/_layouts/vm-operator.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 8be6f81..820aeb6 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -68,7 +68,7 @@
  • For Users

  • Upgrading

    -

    Javadoc

    +

    Javadoc

    From 3a94602a0d825cda0045401e270f34e1ab183b61 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:02:46 +0100 Subject: [PATCH 156/274] Add sitemap. --- webpages/robots.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 webpages/robots.txt diff --git a/webpages/robots.txt b/webpages/robots.txt new file mode 100644 index 0000000..457a45d --- /dev/null +++ b/webpages/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: sitemap.xml From f6338758d8b5abfaa125cc3e07698eb6eacf8b29 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:13:03 +0100 Subject: [PATCH 157/274] Sitemap property must be an absolute url. --- webpages/.readthedocs.yaml | 1 + webpages/robots-readthedocs.txt | 3 +++ webpages/robots.txt | 3 --- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 webpages/robots-readthedocs.txt delete mode 100644 webpages/robots.txt diff --git a/webpages/.readthedocs.yaml b/webpages/.readthedocs.yaml index 15aece6..2eac996 100644 --- a/webpages/.readthedocs.yaml +++ b/webpages/.readthedocs.yaml @@ -14,4 +14,5 @@ build: - cd webpages && bundle install # Build the site and save generated files into Read the Docs directory - cd webpages && jekyll build --destination $READTHEDOCS_OUTPUT/html + - cp webpages/robots-readthedocs.txt $READTHEDOCS_OUTPUT/html \ No newline at end of file diff --git a/webpages/robots-readthedocs.txt b/webpages/robots-readthedocs.txt new file mode 100644 index 0000000..90e0f33 --- /dev/null +++ b/webpages/robots-readthedocs.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://kubernetes-vm-operator.readthedocs.io/sitemap.xml diff --git a/webpages/robots.txt b/webpages/robots.txt deleted file mode 100644 index 457a45d..0000000 --- a/webpages/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -User-agent: * -Allow: / -Sitemap: sitemap.xml From 199cd8185ea66681ba2617f1ef553dc3eae54cc8 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:15:38 +0100 Subject: [PATCH 158/274] Fix robots.txt "generation". --- webpages/.readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/.readthedocs.yaml b/webpages/.readthedocs.yaml index 2eac996..546c09f 100644 --- a/webpages/.readthedocs.yaml +++ b/webpages/.readthedocs.yaml @@ -14,5 +14,5 @@ build: - cd webpages && bundle install # Build the site and save generated files into Read the Docs directory - cd webpages && jekyll build --destination $READTHEDOCS_OUTPUT/html - - cp webpages/robots-readthedocs.txt $READTHEDOCS_OUTPUT/html + - cp webpages/robots-readthedocs.txt $READTHEDOCS_OUTPUT/html/robots.txt \ No newline at end of file From 60349bca7881b4c08fc034aa6934d5eff078b882 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 13:45:38 +0100 Subject: [PATCH 159/274] Shorten title to make bing happy. --- webpages/vm-operator/manager.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/vm-operator/manager.md b/webpages/vm-operator/manager.md index ee971c1..d283484 100644 --- a/webpages/vm-operator/manager.md +++ b/webpages/vm-operator/manager.md @@ -1,5 +1,5 @@ --- -title: "VM-Operator: The Manager — Provides the controller and a web user interface" +title: "VM-Operator: The Manager — Provides the controller and a Web UI" description: >- Information about the installation and configuration of the VM Operator. From 0e28bcd038bc529a5ee8be6f228449bfd33426bb Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 18:48:13 +0100 Subject: [PATCH 160/274] Change (main) site. --- webpages/_config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpages/_config.yml b/webpages/_config.yml index a2ee1a3..1ebd835 100644 --- a/webpages/_config.yml +++ b/webpages/_config.yml @@ -2,7 +2,7 @@ plugins: - jekyll-seo-tag - jekyll-sitemap -url: "https://jdrupes.org" +url: "https://vm-operator.jdrupes.org" author: Michael N. Lipp From fd0bcc93074418fec8ba33f962210a856edd7f53 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 20:10:42 +0100 Subject: [PATCH 161/274] Move main site to vm-operator.jdrupes.org. --- misc/javadoc.bottom.txt | 7 ++++--- webpages/_includes/matomo.html | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index abf54f3..631d63f 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -16,18 +16,19 @@ var _paq = _paq || []; /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ _paq.push(["setDocumentTitle", document.domain + "/" + document.title]); - _paq.push(["setCookieDomain", "*.jdrupes.org"]); + _paq.push(["setCookieDomain", "*.mnlipp.github.io"]); + _paq.push(["setDomains", ["*.mnlipp.github.io", "*.jdrupes.org", "kubernetes-vm-operator.readthedocs.io"]]); _paq.push(['disableCookies']); _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { var u="//jdrupes.org/"; _paq.push(['setTrackerUrl', u+'piwik.php']); - _paq.push(['setSiteId', '15']); + _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); })(); - +
    \ No newline at end of file diff --git a/webpages/_includes/matomo.html b/webpages/_includes/matomo.html index 3a93186..75978bc 100644 --- a/webpages/_includes/matomo.html +++ b/webpages/_includes/matomo.html @@ -4,20 +4,20 @@ /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ _paq.push(["setDocumentTitle", document.domain + "/" + document.title]); _paq.push(["setCookieDomain", "*.mnlipp.github.io"]); - _paq.push(["setDomains", ["*.mnlipp.github.io"]]); + _paq.push(["setDomains", ["*.mnlipp.github.io", "*.jdrupes.org", "kubernetes-vm-operator.readthedocs.io"]]); _paq.push(['disableCookies']); _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { var u="//piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); - _paq.push(['setSiteId', '14']); + _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); })(); From a0dfd2519226e6ac479d0cbe462a9ac5bc9a65fd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 20:29:56 +0100 Subject: [PATCH 162/274] Add robots.txt. --- webpages/robots.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 webpages/robots.txt diff --git a/webpages/robots.txt b/webpages/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/webpages/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / From 4965845f3da5170ce813aa8430c6364d3c85bf2b Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 20:44:39 +0100 Subject: [PATCH 163/274] Move robots.txt. --- webpages/vm-operator/robots.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 webpages/vm-operator/robots.txt diff --git a/webpages/vm-operator/robots.txt b/webpages/vm-operator/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/webpages/vm-operator/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / From d8cff8b9425e449255dc15db3e5496ffb0a8fef4 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 21:06:26 +0100 Subject: [PATCH 164/274] Track vm-operator separately. --- buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle | 1 + .../{matomo.html => matomo-vm-operator.jdrupes.org.html} | 0 webpages/_layouts/vm-operator.html | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename webpages/_includes/{matomo.html => matomo-vm-operator.jdrupes.org.html} (100%) diff --git a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle index 5eed550..0b6e68f 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle @@ -124,6 +124,7 @@ gitPublish { branch = 'main' contents { from("${rootProject.projectDir}/webpages") { + include '_includes/matomo-vm-operator.jdrupes.org.html' include '_layouts/vm-operator.html' include 'vm-operator/**' } diff --git a/webpages/_includes/matomo.html b/webpages/_includes/matomo-vm-operator.jdrupes.org.html similarity index 100% rename from webpages/_includes/matomo.html rename to webpages/_includes/matomo-vm-operator.jdrupes.org.html diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 820aeb6..2e2e444 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -104,7 +104,7 @@ - {% include matomo.html %} + {% include matomo-vm-operator.jdrupes.org.html %} From d6e2a92fe8a3f5578e606d8eb9956e07f967c67d Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 21:20:25 +0100 Subject: [PATCH 165/274] Fix url. --- misc/javadoc.bottom.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index 631d63f..c858345 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -22,7 +22,7 @@ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { - var u="//jdrupes.org/"; + var u="//piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; From 7670857d0a48bf8fd4c6faa89e36dba0bfa46665 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 21:50:24 +0100 Subject: [PATCH 166/274] Separate sites. --- .github/workflows/jekyll.yml | 68 +++++++++++++++++++ ...-operator.jdrupes.org.html => matomo.html} | 0 webpages/_layouts/vm-operator.html | 4 +- webpages/robots.txt | 2 - 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/jekyll.yml rename webpages/_includes/{matomo-vm-operator.jdrupes.org.html => matomo.html} (100%) delete mode 100644 webpages/robots.txt diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml new file mode 100644 index 0000000..57d3802 --- /dev/null +++ b/.github/workflows/jekyll.yml @@ -0,0 +1,68 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# Sample workflow for building and deploying a Jekyll site to GitHub Pages +name: Deploy Jekyll site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between +# the run in-progress and latest queued. However, do NOT cancel +# in-progress runs as we want to allow these production deployments +# to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' # Not needed with a .ruby-version file + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + cache-version: 0 # Increment this number if you need to re-download cached gems + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Build with Jekyll + # Outputs to the './_site' directory by default + run: bundle exec jekyll build + env: + JEKYLL_ENV: production + - name: Index pagefind + run: npx pagefind --source "_site/vm-operator" + - name: Upload artifact + # Automatically uploads an artifact from the './_site' directory by default + uses: actions/upload-pages-artifact@v3 + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/webpages/_includes/matomo-vm-operator.jdrupes.org.html b/webpages/_includes/matomo.html similarity index 100% rename from webpages/_includes/matomo-vm-operator.jdrupes.org.html rename to webpages/_includes/matomo.html diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 2e2e444..6d0df26 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -83,7 +83,7 @@
    - {% include matomo-vm-operator.jdrupes.org.html %} + {% include matomo.html %} diff --git a/webpages/robots.txt b/webpages/robots.txt deleted file mode 100644 index c2a49f4..0000000 --- a/webpages/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Allow: / From 083c6db2da1224786963e0cfe791fcf24a65f692 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 22:12:43 +0100 Subject: [PATCH 167/274] Build own site. --- .github/workflows/jekyll.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index 57d3802..b273259 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -35,25 +35,38 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 0 # Increment this number if you need to re-download cached gems + - name: Install graphviz + run: sudo apt-get install graphviz + - name: Build apidocs + run: ./gradlew apidocs - name: Setup Pages id: pages uses: actions/configure-pages@v5 - name: Build with Jekyll # Outputs to the './_site' directory by default - run: bundle exec jekyll build + run: cd webpages && bundle exec jekyll build env: JEKYLL_ENV: production + - name: Copy javadoc + run: cp -a build/javadoc webpages/_site/vm-operator/ - name: Index pagefind - run: npx pagefind --source "_site/vm-operator" + run: cd webpages && npx pagefind --source "_site/vm-operator" - name: Upload artifact # Automatically uploads an artifact from the './_site' directory by default uses: actions/upload-pages-artifact@v3 + with: + path: './webpages/_site' # Deployment job deploy: From 987f634f40cc1ab3abbade24ab5cb36b2634a8ce Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 22:33:22 +0100 Subject: [PATCH 168/274] Update. --- .github/workflows/jekyll.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index b273259..c7df16e 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -35,21 +35,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - java-version: '21' - distribution: 'temurin' - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 0 # Increment this number if you need to re-download cached gems - - name: Install graphviz - run: sudo apt-get install graphviz - - name: Build apidocs - run: ./gradlew apidocs - name: Setup Pages id: pages uses: actions/configure-pages@v5 @@ -58,6 +49,15 @@ jobs: run: cd webpages && bundle exec jekyll build env: JEKYLL_ENV: production + - name: Install graphviz + run: sudo apt-get install graphviz + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + - name: Build apidocs + run: ./gradlew apidocs - name: Copy javadoc run: cp -a build/javadoc webpages/_site/vm-operator/ - name: Index pagefind From 30bc11917880229e14bcbd46e80935019484bfbd Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 22:57:22 +0100 Subject: [PATCH 169/274] Update. --- .github/workflows/jekyll.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index c7df16e..58609d6 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -41,6 +41,7 @@ jobs: ruby-version: '3.3' # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 0 # Increment this number if you need to re-download cached gems + working-directory: webpages - name: Setup Pages id: pages uses: actions/configure-pages@v5 From 214085119c180a990835e05d01902bd54396fdd0 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 2 Mar 2025 23:15:19 +0100 Subject: [PATCH 170/274] Add style for searching. --- webpages/stylesheets/styles.css | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/webpages/stylesheets/styles.css b/webpages/stylesheets/styles.css index 8d6b803..41fb0d0 100644 --- a/webpages/stylesheets/styles.css +++ b/webpages/stylesheets/styles.css @@ -189,6 +189,56 @@ footer { margin-bottom:5px; } +#search { + + --pagefind-ui-font: inherit; + --pagefind-ui-border-radius: 4px; + + position: absolute; + right: 1em; + top: 1em; + + .pagefind-ui__form { + width: 20em; + margin-left: auto; + + &::before { + top: calc(17px * var(--pagefind-ui-scale)); + } + } + + .pagefind-ui__search-input { + font-weight: inherit; + height: calc(48px * var(--pagefind-ui-scale)); + } + + .pagefind-ui__search-clear { + font-weight: inherit; + height: calc(42px * var(--pagefind-ui-scale)); + } + + .pagefind-ui__drawer { + position: absolute; + right: 0; + width: 40em; + background-color: white; + border: solid var(--pagefind-ui-border-width) var(--pagefind-ui-border); + padding: 0 1em 1em 1em; + } + + .pagefind-ui__message { + padding-top: 0; + } + + .pagefind-ui__result { + padding: 0; + } + + .pagefind-ui__result-title { + font-weight: inherit; + } +} + @media print, screen and (max-width: 960px) { div.wrapper { From 03fdabe85a777618b9e9cd69158acafde87922e8 Mon Sep 17 00:00:00 2001 From: Michael Lipp Date: Mon, 3 Mar 2025 06:50:03 +0000 Subject: [PATCH 171/274] Edit README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4c989b6..e6292ec 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ # Run Qemu in Kubernetes Pods -The goal of this project is to provide easy to use and flexible components +The goal of this project is to provide orgy to use and flexible components for running Qemu based VMs in Kubernetes pods. - -See the [project's home page](https://jdrupes.org/vm-operator/) +vm-ovm +See the [project's home page](https://vm-operator.jdrupes.org/) for details. From c004265f5e8165c4a88bba79b96e132169972c46 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 3 Mar 2025 09:25:58 +0100 Subject: [PATCH 172/274] Don't merge into jdrupes.org any more. --- build.gradle | 9 +----- ...pes.vmoperator.java-doc-conventions.gradle | 31 ------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/build.gradle b/build.gradle index 8a7b571..eb8e59a 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ allprojects { } task stage { - description = 'To be executed by CI, build and update JavaDoc.' + description = 'To be executed by CI.' group = 'build' // Build everything first @@ -27,13 +27,6 @@ task stage { dependsOn subprojects.tasks.collect { tc -> tc.findByName("build") }.flatten() } - - def gitBranch = grgit.branch.current.name.replace('/', '-') - if (JavaVersion.current() == JavaVersion.VERSION_21 - && gitBranch == "main") { - // Publish JavaDoc - dependsOn gitPublishPush - } } eclipse { diff --git a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle index 0b6e68f..6af8fa7 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle @@ -118,34 +118,3 @@ if (System.properties['org.ajoberstar.grgit.auth.username'] == null) { System.setProperty('org.ajoberstar.grgit.auth.username', project.rootProject.properties['website.push.token'] ?: "nouser") } - -gitPublish { - repoUri = 'https://github.com/mnlipp/jdrupes.org.git' - branch = 'main' - contents { - from("${rootProject.projectDir}/webpages") { - include '_includes/matomo-vm-operator.jdrupes.org.html' - include '_layouts/vm-operator.html' - include 'vm-operator/**' - } - from("${rootProject.buildDir}/javadoc") { - into 'vm-operator/javadoc' - } - if (!findProject(':org.jdrupes.vmoperator.runner.qemu').isSnapshot - && !findProject(':org.jdrupes.vmoperator.manager').isSnapshot) { - from("${rootProject.buildDir}/javadoc") { - into 'vm-operator/latest-release/javadoc' - } - } - } - preserve { include '**/*' } - commitMessage = "Updated." -} - -gradle.projectsEvaluated { - tasks.gitPublishReset.mustRunAfter subprojects.tasks - .collect { tc -> tc.findByName("build") }.flatten() - tasks.gitPublishReset.mustRunAfter subprojects.tasks - .collect { tc -> tc.findByName("test") }.flatten() - tasks.gitPublishCopy.dependsOn apidocs -} From cc78c38efe650301f9bfba4d4632d781acd8a7eb Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Mon, 3 Mar 2025 10:16:05 +0100 Subject: [PATCH 173/274] Remove no longer needed sub-directory. --- .github/workflows/jekyll.yml | 4 ++-- misc/javadoc.bottom.txt | 5 +++-- webpages/{vm-operator => }/02_2_operator.png | Bin .../VM-Operator-GUI-preview.png | Bin .../{vm-operator => }/VM-Operator-GUI-view.png | Bin .../{vm-operator => }/VM-Operator-with-font.svg | 0 webpages/{vm-operator => }/VM-Operator.svg | 0 webpages/{vm-operator => }/VmAccess-preview.png | Bin webpages/_includes/matomo.html | 4 ++-- webpages/_layouts/vm-operator.html | 15 ++++++++------- webpages/{vm-operator => }/admin-gui.md | 0 webpages/{vm-operator => }/controller.md | 0 webpages/{vm-operator => }/favicon.svg | 0 webpages/{vm-operator => }/index-pic.svg | 0 webpages/index.html | 11 ----------- webpages/{vm-operator => }/index.md | 0 webpages/{vm-operator => }/manager.md | 0 webpages/{vm-operator => }/pools.md | 0 webpages/{vm-operator => }/robots.txt | 0 webpages/{vm-operator => }/runner.md | 0 webpages/{vm-operator => }/upgrading.md | 0 webpages/{vm-operator => }/user-gui.md | 0 webpages/{vm-operator => }/webgui.md | 0 23 files changed, 15 insertions(+), 24 deletions(-) rename webpages/{vm-operator => }/02_2_operator.png (100%) rename webpages/{vm-operator => }/VM-Operator-GUI-preview.png (100%) rename webpages/{vm-operator => }/VM-Operator-GUI-view.png (100%) rename webpages/{vm-operator => }/VM-Operator-with-font.svg (100%) rename webpages/{vm-operator => }/VM-Operator.svg (100%) rename webpages/{vm-operator => }/VmAccess-preview.png (100%) rename webpages/{vm-operator => }/admin-gui.md (100%) rename webpages/{vm-operator => }/controller.md (100%) rename webpages/{vm-operator => }/favicon.svg (100%) rename webpages/{vm-operator => }/index-pic.svg (100%) delete mode 100644 webpages/index.html rename webpages/{vm-operator => }/index.md (100%) rename webpages/{vm-operator => }/manager.md (100%) rename webpages/{vm-operator => }/pools.md (100%) rename webpages/{vm-operator => }/robots.txt (100%) rename webpages/{vm-operator => }/runner.md (100%) rename webpages/{vm-operator => }/upgrading.md (100%) rename webpages/{vm-operator => }/user-gui.md (100%) rename webpages/{vm-operator => }/webgui.md (100%) diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index 58609d6..b5acc1f 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -60,9 +60,9 @@ jobs: - name: Build apidocs run: ./gradlew apidocs - name: Copy javadoc - run: cp -a build/javadoc webpages/_site/vm-operator/ + run: cp -a build/javadoc webpages/_site/ - name: Index pagefind - run: cd webpages && npx pagefind --source "_site/vm-operator" + run: cd webpages && npx pagefind --source "_site" - name: Upload artifact # Automatically uploads an artifact from the './_site' directory by default uses: actions/upload-pages-artifact@v3 diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index c858345..dfc3373 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -22,13 +22,14 @@ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { - var u="//piwik.mnl.de/"; + var u="https://piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); })(); - + \ No newline at end of file diff --git a/webpages/vm-operator/02_2_operator.png b/webpages/02_2_operator.png similarity index 100% rename from webpages/vm-operator/02_2_operator.png rename to webpages/02_2_operator.png diff --git a/webpages/vm-operator/VM-Operator-GUI-preview.png b/webpages/VM-Operator-GUI-preview.png similarity index 100% rename from webpages/vm-operator/VM-Operator-GUI-preview.png rename to webpages/VM-Operator-GUI-preview.png diff --git a/webpages/vm-operator/VM-Operator-GUI-view.png b/webpages/VM-Operator-GUI-view.png similarity index 100% rename from webpages/vm-operator/VM-Operator-GUI-view.png rename to webpages/VM-Operator-GUI-view.png diff --git a/webpages/vm-operator/VM-Operator-with-font.svg b/webpages/VM-Operator-with-font.svg similarity index 100% rename from webpages/vm-operator/VM-Operator-with-font.svg rename to webpages/VM-Operator-with-font.svg diff --git a/webpages/vm-operator/VM-Operator.svg b/webpages/VM-Operator.svg similarity index 100% rename from webpages/vm-operator/VM-Operator.svg rename to webpages/VM-Operator.svg diff --git a/webpages/vm-operator/VmAccess-preview.png b/webpages/VmAccess-preview.png similarity index 100% rename from webpages/vm-operator/VmAccess-preview.png rename to webpages/VmAccess-preview.png diff --git a/webpages/_includes/matomo.html b/webpages/_includes/matomo.html index 75978bc..adb7c30 100644 --- a/webpages/_includes/matomo.html +++ b/webpages/_includes/matomo.html @@ -9,7 +9,7 @@ _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); (function() { - var u="//piwik.mnl.de/"; + var u="https://piwik.mnl.de/"; _paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setSiteId', '17']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; @@ -17,7 +17,7 @@ })(); diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html index 6d0df26..590412a 100644 --- a/webpages/_layouts/vm-operator.html +++ b/webpages/_layouts/vm-operator.html @@ -5,18 +5,19 @@ - - + + - - + + 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 262/274] 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 263/274] 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 264/274] 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 265/274] 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 266/274] 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 267/274] 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 268/274] 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 @@