diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 7dff899..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,78 +0,0 @@ -stages: - - build - - test - - publish - - deploy - -.any-job: - rules: - - if: $CI_SERVER_HOST == "gitlab.mnl.de" - -.gradle-job: - extends: .any-job - image: registry.mnl.de/org/jgrapes/jdk21-builder:v2 - cache: - - key: dependencies-${CI_COMMIT_BRANCH} - policy: pull-push - paths: - - .gradle - - node_modules - - key: "$CI_COMMIT_SHA" - policy: pull-push - paths: - - build - - "*/build" - before_script: - - echo -n $CI_REGISTRY_PASSWORD | podman login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY - - git switch $(git branch -r --sort="authordate" --contains $CI_COMMIT_SHA | head -1 | sed -e 's#[^/]*/##') - - git pull - - git reset --hard $CI_COMMIT_SHA - -build-jars: - stage: build - extends: .gradle-job - script: - - ./gradlew -Pdocker.registry=$CI_REGISTRY_IMAGE build apidocs - -publish-images: - stage: publish - extends: .gradle-job - dependencies: - - build-jars - script: - - ./gradlew -Pdocker.registry=$CI_REGISTRY_IMAGE publishImage - -.pages-job: - extends: .any-job - image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/ruby:3.2 - variables: - JEKYLL_ENV: production - LC_ALL: C.UTF-8 - before_script: - - git fetch origin gh-pages - - git checkout gh-pages - - gem install bundler - - bundle install - -test-pages: - stage: test - extends: .pages-job - rules: - - if: $CI_COMMIT_BRANCH == "gh-pages" - script: - - bundle exec jekyll build -d test - artifacts: - paths: - - test - -#publish-pages: -# stage: publish -# extends: .pages-job -# rules: -# - if: $CI_COMMIT_BRANCH == "gh-pages" -# script: -# - bundle exec jekyll build -d public -# artifacts: -# paths: -# - public -# environment: production diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..56a575c --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,38 @@ +when: +- event: push + evaluate: 'CI_SYSTEM_HOST == "woodpecker.mnl.de"' + +clone: +- name: git + image: woodpeckerci/plugin-git + settings: + partial: false + tags: true + depth: 0 + +steps: +- name: prepare + image: alpine + commands: + # Because we run the next step as user 1000 to make podman work: + - mkdir /woodpecker/workflow + - chown 1000:1000 /woodpecker/workflow + - chown -R 1000:1000 $CI_WORKSPACE + +- name: build-jars + image: registry.mnl.de/mnl/jdk21-builder:v4 + environment: + HOME: /woodpecker/workflow + REGISTRY: registry.mnl.de + REGISTRY_USER: mnl + REGISTRY_TOKEN: + from_secret: REGISTRY_TOKEN + commands: + - echo $REGISTRY_TOKEN | podman login -u $REGISTRY_USER --password-stdin $REGISTRY + - ./gradlew -Pdocker.registry=$REGISTRY/$REGISTRY_USER build apidocs publishImage + backend_options: + kubernetes: + securityContext: + privileged: true + runAsUser: 1000 + runAsGroup: 1000 diff --git a/README.md b/README.md index e6292ec..09fcd25 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,23 @@ ![Latest Manager](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=manager*&label=latest) ![Latest Runner](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=runner-qemu*&label=latest) -# Run Qemu in Kubernetes Pods +# Run QEMU/KVM in Kubernetes Pods + +![Overview picture](webpages/index-pic.svg) + +This project provides an easy to use and flexible solution for running +QEMU/KVM based VMs in Kubernetes pods. + +The central component of this solution is the kubernetes operator that +manages "runners". These run in pods and are used to start and manage +the QEMU/KVM process for the VMs (optionally together with a SW-TPM). + +A web GUI for administrators provides an overview of the VMs together +with some basic control over the VMs. A web GUI for users provides an +interface to access and optionally start, stop and reset the VMs. + +Advanced features of the operator include pooling of VMs and automatic +login. -The goal of this project is to provide orgy to use and flexible components -for running Qemu based VMs in Kubernetes pods. -vm-ovm See the [project's home page](https://vm-operator.jdrupes.org/) for details. diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 101784f..c2a7a66 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1470,6 +1470,10 @@ spec: type: object default: {} properties: + runnerVersion: + description: >- + The version string of the runner. + type: string cpus: description: >- Number of CPUs currently in use. diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml index 648cc39..08316f6 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -21,22 +21,31 @@ spec: - name: vm-operator image: >- ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + imagePullPolicy: Always + env: + - name: JAVA_OPTS + # The VM operator needs about 25 MB of memory, plus 1 MB for + # each VM. The reason is that for the sake of effeciency, we + # have to keep a parsed representation of the CRD in memory, + # which requires about 512 KB per VM. While handling updates, + # we temporarily have the old and the new version of the CRD + # in memory, so we need another 512 KB per VM. + value: "-Xmx128m" + resources: + requests: + cpu: 100m + memory: 128Mi volumeMounts: - name: config mountPath: /etc/opt/vmoperator - name: vmop-image-repository mountPath: /var/local/vmop-image-repository - imagePullPolicy: Always securityContext: capabilities: drop: - ALL readOnlyRootFilesystem: true allowPrivilegeEscalation: false - resources: - requests: - cpu: 100m - memory: 128Mi volumes: - name: config configMap: diff --git a/deploy/vmop-role.yaml b/deploy/vmop-role.yaml index c96fb47..e1ae7bc 100644 --- a/deploy/vmop-role.yaml +++ b/deploy/vmop-role.yaml @@ -38,6 +38,7 @@ rules: - persistentvolumeclaims - pods verbs: + - watch - list - get - create diff --git a/dev-example/Readme.md b/dev-example/Readme.md index ba381e1..d794b24 100644 --- a/dev-example/Readme.md +++ b/dev-example/Readme.md @@ -3,9 +3,9 @@ The CRD must be deployed independently. Apart from that, the `kustomize.yaml` -* creates a small cdrom image repository and + * creates a small cdrom image repository and -* deploys the operator in namespace `vmop-dev` with a replica of 0. + * deploys the operator in namespace `vmop-dev` with a replica of 0. This allows you to run the manager in your IDE. diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index e582c1b..76adfba 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -2,17 +2,20 @@ apiVersion: "vmoperator.jdrupes.org/v1" kind: VirtualMachine metadata: namespace: vmop-dev - name: test-vm<%= ${number} %> + name: test-vm<%= $(printf "%02d" ${number}) %> annotations: argocd.argoproj.io/sync-wave: "20" spec: image: - repository: ghcr.io - path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine - version: latest + source: ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-arch:latest +# source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing +# source: docker-registry.lan.mnl.de/vmoperator/org.jdrupes.vmoperator.runner.qemu-arch:latest pullPolicy: Always + runnerTemplate: + update: true + permissions: - role: admin may: @@ -31,8 +34,8 @@ spec: bootMenu: true maximumCpus: 4 currentCpus: 2 - maximumRam: 4Gi - currentRam: 3Gi + maximumRam: 6Gi + currentRam: 4Gi networks: # No bridge on TC1 diff --git a/dev-example/vmop-agent/gdm/PostLogin/Default b/dev-example/vmop-agent/gdm/PostLogin/Default new file mode 100755 index 0000000..8a70890 --- /dev/null +++ b/dev-example/vmop-agent/gdm/PostLogin/Default @@ -0,0 +1,3 @@ +#!/bin/sh + +sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent index 1c74c61..9f4d9e7 100755 --- a/dev-example/vmop-agent/vmop-agent +++ b/dev-example/vmop-agent/vmop-agent @@ -1,5 +1,8 @@ #!/usr/bin/bash +# Note that this script requires "jq" to be installed and a version +# of loginctl that accepts the "-j" option. + while [ "$#" -gt 0 ]; do case "$1" in --path) shift; ttyPath="$1";; @@ -69,8 +72,8 @@ doLogin() { return fi - # Check if this user is already logged in on tty1 - curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty1") | .user') + # Check if this user is already logged in on tty2 + curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty2") | .user') if [ "$curUser" = "$user" ]; then echo >&${con} "201 User already logged in" return @@ -93,17 +96,14 @@ doLogin() { return fi fi - - # Start the desktop for the user - systemd-run 2>$temperr \ - --unit vmop-user-desktop --uid=$uid --gid=$uid \ - --working-directory="/home/$user" -p TTYPath=/dev/tty1 \ - -p PAMName=login -p StandardInput=tty -p StandardOutput=journal \ - -p Conflicts="gdm.service getty@tty1.service" \ - -E XDG_RUNTIME_DIR="/run/user/$uid" \ - -E XDG_CURRENT_DESKTOP=GNOME \ - -p ExecStartPre="/usr/bin/chvt 1" \ - dbus-run-session -- gnome-shell --display-server --wayland + + # Configure user as auto login user + sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf + sed -i '/\[daemon\]/a AutomaticLoginEnable=true\nAutomaticLogin='$user \ + /etc/gdm/custom.conf + + # Activate user + systemctl restart gdm if [ $? -eq 0 ]; then echo >&${con} "201 User logged in successfully" else @@ -114,14 +114,8 @@ doLogin() { # Attempt to log out a user currently using tty1. This is an intermediate # operation that can be invoked from other operations attemptLogout() { - systemctl status vmop-user-desktop > /dev/null 2>&1 - if [ $? = 0 ]; then - systemctl stop vmop-user-desktop - fi - loginctl -j | jq -r '.[] | select(.tty=="tty1") | .session' \ - | while read sid; do - loginctl kill-session $sid - done + sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf + systemctl stop gdm echo >&${con} "102 Desktop stopped" } @@ -130,15 +124,7 @@ attemptLogout() { # Also try to restart gdm, if it is not running. doLogout() { attemptLogout - systemctl status gdm >/dev/null 2>&1 - if [ $? != 0 ]; then - systemctl restart gdm 2>$temperr - if [ $? -eq 0 ]; then - echo >&${con} "102 gdm restarted" - else - echo >&${con} "102 Restarting gdm failed: $(tr '\n' ' ' <${temperr})" - fi - fi + systemctl restart gdm echo >&${con} "202 User logged out" } @@ -150,7 +136,7 @@ while read line <&${con}; do done onExit() { - attemptLogout + doLogout if [ -n "$temperr" ]; then rm -f $temperr fi 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 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 83b261e..b9de69f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -18,7 +18,6 @@ package org.jdrupes.vmoperator.common; -// TODO: Auto-generated Javadoc /** * Some constants. */ @@ -50,6 +49,9 @@ public class Constants { * Status related constants. */ public static class Status { + /** The Constant RUNNER_VERSION. */ + public static final String RUNNER_VERSION = "runnerVersion"; + /** The Constant CPUS. */ public static final String CPUS = "cpus"; diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java index 47b7208..68f52eb 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java @@ -32,13 +32,11 @@ import java.util.regex.Pattern; public class Convertions { @SuppressWarnings({ "PMD.UseConcurrentHashMap", - "PMD.FieldNamingConventions", "PMD.VariableNamingConventions" }) + "PMD.FieldNamingConventions" }) private static final Map unitMap = new HashMap<>(); - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final List> unitMappings; - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final Pattern memorySize = Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*"); @@ -69,7 +67,6 @@ public class Convertions { * @param amount the amount * @return the big integer */ - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public static BigInteger parseMemory(Object amount) { if (amount == null) { return (BigInteger) amount; 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 7a28185..3870337 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 @@ -47,8 +47,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Helpers for K8s API. */ -@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass", - "PMD.DataflowAnomalyAnalysis" }) +@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass" }) public class K8s { /** @@ -113,7 +112,6 @@ public class K8s { public static JsonObject yamlToJson(ApiClient client, Reader yaml) { // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 - @SuppressWarnings("PMD.UseConcurrentHashMap") Map yamlData = new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml); diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java index 37b0b97..272da2b 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java @@ -48,8 +48,7 @@ import okhttp3.Response; * A client with some additional properties. */ @SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods", - "PMD.LinguisticNaming", "checkstyle:LineLength", - "PMD.CouplingBetweenObjects", "PMD.GodClass" }) + "checkstyle:LineLength", "PMD.CouplingBetweenObjects", "PMD.GodClass" }) public class K8sClient extends ApiClient { private ApiClient apiClient; @@ -231,7 +230,6 @@ public class K8sClient extends ApiClient { * @return the api client * @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[]) */ - @SuppressWarnings("PMD.UseVarargs") @Override public ApiClient setKeyManagers(KeyManager[] managers) { return apiClient().setKeyManagers(managers); @@ -638,7 +636,6 @@ public class K8sClient extends ApiClient { * @return the string * @see ApiClient#selectHeaderAccept(java.lang.String[]) */ - @SuppressWarnings("PMD.UseVarargs") @Override public String selectHeaderAccept(String[] accepts) { return apiClient().selectHeaderAccept(accepts); @@ -651,7 +648,6 @@ public class K8sClient extends ApiClient { * @return the string * @see ApiClient#selectHeaderContentType(java.lang.String[]) */ - @SuppressWarnings("PMD.UseVarargs") @Override public String selectHeaderContentType(String[] contentTypes) { return apiClient().selectHeaderContentType(contentTypes); @@ -818,7 +814,7 @@ public class K8sClient extends ApiClient { * @throws ApiException the api exception * @see ApiClient#buildCall(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback) */ - @SuppressWarnings({ "rawtypes", "PMD.ExcessiveParameterList" }) + @SuppressWarnings({ "rawtypes" }) @Override public Call buildCall(String path, String method, List queryParams, List collectionQueryParams, Object body, @@ -847,7 +843,7 @@ public class K8sClient extends ApiClient { * @throws ApiException the api exception * @see ApiClient#buildRequest(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback) */ - @SuppressWarnings({ "rawtypes", "PMD.ExcessiveParameterList" }) + @SuppressWarnings({ "rawtypes" }) @Override public Request buildRequest(String path, String method, List queryParams, List collectionQueryParams, 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..59b4d12 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,8 +45,7 @@ import java.util.function.Function; * @param the generic type * @param the generic type */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.CouplingBetweenObjects" }) +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) public class K8sClusterGenericStub { protected final K8sClient client; @@ -240,6 +239,7 @@ public class K8sClusterGenericStub the object list type * @param the result type */ + @FunctionalInterface public interface GenericSupplier> { @@ -254,7 +254,6 @@ public class K8sClusterGenericStub objectClass, Class objectListClass, K8sClient client, APIResource context, String name); } @@ -283,7 +282,6 @@ public class K8sClusterGenericStub> R get(Class objectClass, Class objectListClass, @@ -314,8 +312,6 @@ public class K8sClusterGenericStub> R get(Class objectClass, Class objectListClass, @@ -340,8 +336,6 @@ public class K8sClusterGenericStub> R create(Class objectClass, Class objectListClass, 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 dd2bdd5..2392d3e 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 @@ -29,7 +29,6 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta; * notably the metadata, is made available through the methods * defined by {@link KubernetesObject}. */ -@SuppressWarnings("PMD.DataClass") public class K8sDynamicModel implements KubernetesObject { private final V1ObjectMeta metadata; diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java index 1813621..4e21c0e 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java @@ -62,7 +62,7 @@ public class K8sDynamicModelsBase } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException exc) { - throw new IllegalArgumentException(exc); // NOPMD + throw new IllegalArgumentException(exc); } } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java index afed802..c0303c2 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java @@ -31,7 +31,6 @@ import java.util.Collection; * state and can therefore be used for any kind of object, especially * custom objects. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sDynamicStub extends K8sDynamicStubBase { @@ -64,8 +63,6 @@ public class K8sDynamicStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static K8sDynamicStub get(K8sClient client, GroupVersionKind gvk, String namespace, String name) throws ApiException { @@ -83,8 +80,6 @@ public class K8sDynamicStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static K8sDynamicStub get(K8sClient client, APIResource context, String namespace, String name) { return new K8sDynamicStub(client, context, namespace, name); diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java index 44f419c..ae3f012 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java @@ -26,7 +26,6 @@ import io.kubernetes.client.Discovery.APIResource; * state and can therefore be used for any kind of object, especially * custom objects. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public abstract class K8sDynamicStubBase> extends K8sGenericStub { @@ -40,7 +39,6 @@ public abstract class K8sDynamicStubBase objectClass, Class objectListClass, DynamicTypeAdapterFactory taf, K8sClient client, APIResource context, String namespace, 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..9ba376f 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 @@ -48,7 +48,7 @@ import java.util.function.Function; * @param the generic type * @param the generic type */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" }) +@SuppressWarnings({ "PMD.TooManyMethods" }) public class K8sGenericStub { protected final K8sClient client; @@ -200,7 +200,6 @@ public class K8sGenericStub updateStatus(O object, Function updater) throws ApiException { return K8s.optional(api.updateStatus(object, updater)); @@ -218,7 +217,7 @@ public class K8sGenericStub updateStatus(Function updater, O current, int retries) throws ApiException { while (true) { @@ -248,7 +247,6 @@ public class K8sGenericStub updateStatus(Function updater, int retries) throws ApiException { return updateStatus(updater, null, retries); @@ -359,6 +357,7 @@ public class K8sGenericStub the object list type * @param the result type */ + @FunctionalInterface public interface GenericSupplier> { @@ -370,7 +369,6 @@ public class K8sGenericStub> R create(Class objectClass, Class objectListClass, diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java index 80e3863..9e22382 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java @@ -27,6 +27,7 @@ import io.kubernetes.client.util.generic.GenericKubernetesApi; import io.kubernetes.client.util.generic.options.ListOptions; import java.time.Duration; import java.time.Instant; +import java.util.Optional; import java.util.function.BiConsumer; import java.util.logging.Level; import java.util.logging.Logger; @@ -49,7 +50,6 @@ public class K8sObserver objectClass, Class objectListClass, K8sClient client, APIResource context, String namespace, @@ -89,23 +88,29 @@ public class K8sObserver { try { - logger - .config(() -> "Watching " + context.getResourcePlural() - + " (" + context.getPreferredVersion() + ")" - + " in " + namespace); + logger.fine(() -> "Observing " + context.getResourcePlural() + + " (" + context.getPreferredVersion() + ")" + + Optional.ofNullable(options.getLabelSelector()) + .map(ls -> " with labels " + ls).orElse("") + + " in " + namespace); // Watch sometimes terminates without apparent reason. while (!Thread.currentThread().isInterrupted()) { Instant startedAt = Instant.now(); try { - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") var changed = api.watch(namespace, options).iterator(); while (changed.hasNext()) { - handler.accept(client, changed.next()); + var response = changed.next(); + logger.fine(() -> "Resource " + + context.getKind() + "/" + + response.object.getMetadata().getName() + + " " + response.type); + handler.accept(client, response); } } catch (ApiException | RuntimeException e) { logger.log(Level.FINE, e, () -> "Problem watching" + + " resource " + context.getKind() + " (will retry): " + e.getMessage()); delayRestart(startedAt); } @@ -225,7 +230,6 @@ public class K8sObserver { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java index 16e5c82..9075a84 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java @@ -29,7 +29,6 @@ import java.util.Optional; /** * A stub for pods (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1DeploymentStub extends K8sGenericStub { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java index 050c593..ea1237d 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for nodes (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1NodeStub extends K8sClusterGenericStub { public static final APIResource CONTEXT = new APIResource("", List.of("v1"), @@ -74,8 +73,7 @@ public class K8sV1NodeStub extends K8sClusterGenericStub { /** * Provide {@link GenericSupplier}. */ - @SuppressWarnings({ "PMD.UnusedFormalParameter", - "PMD.UnusedPrivateMethod" }) + @SuppressWarnings({ "PMD.UnusedFormalParameter" }) private static K8sV1NodeStub getGeneric(Class objectClass, Class objectListClass, K8sClient client, APIResource context, String name) { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java index 21ac931..f21bb47 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for pods (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1PodStub extends K8sGenericStub { /** The pods' context. */ diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java index 876e648..c46a60f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for pods (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1PvcStub extends K8sGenericStub { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java index a847d36..9c1c086 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for secrets (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1SecretStub extends K8sGenericStub { public static final APIResource CONTEXT = new APIResource("", List.of("v1"), diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java index 2157a1d..863f86f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for secrets (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1ServiceStub extends K8sGenericStub { public static final APIResource CONTEXT = new APIResource("", List.of("v1"), diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java index b918725..be30b00 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java @@ -26,7 +26,6 @@ import java.util.List; /** * A stub for stateful sets (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1StatefulSetStub extends K8sGenericStub { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java index f763c47..a0b66bf 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 @@ -46,11 +46,10 @@ import org.jdrupes.vmoperator.util.DataPath; /** * Represents a VM definition. */ -@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods", - "PMD.CouplingBetweenObjects" }) +@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) public class VmDefinition extends K8sDynamicModel { - @SuppressWarnings({ "PMD.FieldNamingConventions", "unused" }) + @SuppressWarnings({ "unused" }) private static final Logger logger = Logger.getLogger(VmDefinition.class.getName()); @SuppressWarnings("PMD.FieldNamingConventions") @@ -300,8 +299,8 @@ public class VmDefinition extends K8sDynamicModel { * * @return the data */ - public Optional extra() { - return Optional.ofNullable(extraData); + public VmExtraData extra() { + return extraData; } /** diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java index 72194da..377220a 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 @@ -31,7 +31,6 @@ import java.util.Collection; * state and can therefore be used for any kind of object, especially * custom objects. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class VmDefinitionStub extends K8sDynamicStubBase { @@ -64,8 +63,6 @@ public class VmDefinitionStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static VmDefinitionStub get(K8sClient client, GroupVersionKind gvk, String namespace, String name) throws ApiException { @@ -83,8 +80,6 @@ public class VmDefinitionStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static VmDefinitionStub get(K8sClient client, APIResource context, String namespace, String name) { return new VmDefinitionStub(client, context, namespace, name); diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java index 85913c2..e1565c5 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 @@ -34,7 +34,6 @@ import java.util.logging.Logger; */ public class VmExtraData { - @SuppressWarnings("PMD.FieldNamingConventions") private static final Logger logger = Logger.getLogger(VmExtraData.class.getName()); @@ -75,6 +74,15 @@ public class VmExtraData { return nodeName; } + /** + * Gets the node addresses. + * + * @return the nodeAddresses + */ + public List nodeAddresses() { + return nodeAddresses; + } + /** * Sets the reset count. * @@ -103,20 +111,20 @@ public class VmExtraData { * @param deleteConnectionFile the delete connection file * @return the string */ - public String connectionFile(String password, + public Optional connectionFile(String password, Class preferredIpVersion, boolean deleteConnectionFile) { var addr = displayIp(preferredIpVersion); if (addr.isEmpty()) { 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=") @@ -135,7 +143,7 @@ public class VmExtraData { if (deleteConnectionFile) { data.append("delete-this-file=1\n"); } - return data.toString(); + return Optional.of(data.toString()); } private Optional displayIp(Class preferredIpVersion) { diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java index 7c13ddb..f7aaa67 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 @@ -35,7 +35,6 @@ import org.jdrupes.vmoperator.util.DataPath; /** * Represents a VM pool. */ -@SuppressWarnings({ "PMD.DataClass" }) public class VmPool { private final String name; @@ -177,7 +176,7 @@ public class VmPool { } // Additional check in case lastUsed has not been updated - // by PoolMonitor#onVmDefChanged() yet ("race condition") + // by PoolMonitor#onVmResourceChanged() yet ("race condition") if (vmDef.condition("ConsoleConnected") .map(cc -> cc.getLastTransitionTime().toInstant()) .map(this::retainUntil) diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java index 21e6031..7252c6a 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java @@ -24,7 +24,6 @@ import org.jgrapes.core.Event; /** * Assign a VM from a pool to a user. */ -@SuppressWarnings("PMD.DataClass") public class AssignVm extends Event { private final String fromPool; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java index 05a079e..2b23532 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java @@ -43,7 +43,6 @@ public interface ChannelDictionary { * @param channel the channel * @param associated the associated */ - @SuppressWarnings("PMD.ShortClassName") public record Value(C channel, A associated) { } 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 ce0e4f0..da36123 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 @@ -149,8 +149,6 @@ public class ChannelManager * @param supplier the supplier * @return the channel */ - @SuppressWarnings({ "PMD.AssignmentInOperand", - "PMD.DataflowAnomalyAnalysis" }) public C computeIfAbsent(K key, Function supplier) { return entries.computeIfAbsent(key, k -> new Value<>(supplier.apply(k), null)).channel(); diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java index 2f7dbd6..dc47b4a 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java @@ -24,7 +24,6 @@ import org.jgrapes.core.Event; /** * Gets the current display secret and optionally updates it. */ -@SuppressWarnings("PMD.DataClass") public class GetDisplaySecret extends Event { private final VmDefinition vmDef; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java index 40fa6ad..b563c9c 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java @@ -27,7 +27,6 @@ import org.jgrapes.core.Event; /** * Gets the known pools' definitions. */ -@SuppressWarnings("PMD.DataClass") public class GetPools extends Event> { private String name; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java index 8b00698..0e24013 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java @@ -27,7 +27,6 @@ import org.jgrapes.core.Event; /** * Gets the known VMs' definitions and channels. */ -@SuppressWarnings("PMD.DataClass") public class GetVms extends Event> { private String name; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java index 8f735da..9e19255 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java @@ -24,7 +24,6 @@ import org.jgrapes.core.Event; /** * Modifies a VM. */ -@SuppressWarnings("PMD.DataClass") public class ModifyVm extends Event { private final String name; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java new file mode 100644 index 0000000..8bbcfe8 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java @@ -0,0 +1,75 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import io.kubernetes.client.openapi.models.V1Pod; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates a change in a pod that runs a VM. + */ +public class PodChanged extends Event { + + private final V1Pod pod; + private final K8sObserver.ResponseType type; + + /** + * Instantiates a new VM changed event. + * + * @param pod the pod + * @param type the type + */ + public PodChanged(V1Pod pod, K8sObserver.ResponseType type) { + this.pod = pod; + this.type = type; + } + + /** + * Gets the pod. + * + * @return the pod + */ + public V1Pod pod() { + return pod; + } + + /** + * Returns the type. + * + * @return the type + */ + public K8sObserver.ResponseType type() { + return type; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(pod.getMetadata().getName()).append(' ').append(type); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java index f3320c8..778820e 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java @@ -23,7 +23,6 @@ import org.jgrapes.core.Event; /** * Triggers a reset of the VM. */ -@SuppressWarnings("PMD.DataClass") public class ResetVm extends Event { private final String vmName; 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 index af9dde0..b4fcf56 100644 --- 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 @@ -24,7 +24,6 @@ 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 VmPool fromPool; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java index 5ea282a..73507ae 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java @@ -21,13 +21,13 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Subchannel.DefaultSubchannel; /** * A subchannel used to send the events related to a specific VM. */ -@SuppressWarnings("PMD.DataClass") public class VmChannel extends DefaultSubchannel { private final EventPipeline pipeline; @@ -55,7 +55,6 @@ public class VmChannel extends DefaultSubchannel { * @param definition the definition * @return the watch channel */ - @SuppressWarnings("PMD.LinguisticNaming") public VmChannel setVmDefinition(VmDefinition definition) { this.definition = definition; return this; @@ -86,7 +85,6 @@ public class VmChannel extends DefaultSubchannel { * @param generation the generation to set * @return true if value has changed */ - @SuppressWarnings("PMD.LinguisticNaming") public boolean setGeneration(long generation) { if (this.generation == generation) { return false; @@ -104,6 +102,19 @@ public class VmChannel extends DefaultSubchannel { return pipeline; } + /** + * Fire the given event on this channel, using the associated + * {@link #pipeline()}. + * + * @param the generic type + * @param event the event + * @return the t + */ + public > T fire(T event) { + pipeline.fire(event, this); + return event; + } + /** * Returns the API client. * diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java index 0c506a1..0c3f3a1 100644 --- 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 @@ -26,7 +26,6 @@ import org.jgrapes.core.Event; /** * Indicates a change in a pool configuration. */ -@SuppressWarnings("PMD.DataClass") public class VmPoolChanged extends Event { private final VmPool vmPool; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java similarity index 75% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java index a8873cf..eac30fb 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java @@ -25,31 +25,35 @@ import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** - * Indicates a change in a VM definition. Note that the definition - * consists of the metadata (mostly immutable), the "spec" and the - * "status" parts. Consumers that are only interested in "spec" - * changes should check {@link #specChanged()} before processing - * the event any further. + * Indicates a change in a VM "resource". Note that the resource + * combines the VM CR's metadata (mostly immutable), the VM CR's + * "spec" part, the VM CR's "status" subresource and state information + * from the pod. Consumers that are only interested in "spec" changes + * should check {@link #specChanged()} before processing the event any + * further. */ @SuppressWarnings("PMD.DataClass") -public class VmDefChanged extends Event { +public class VmResourceChanged extends Event { private final K8sObserver.ResponseType type; - private final boolean specChanged; private final VmDefinition vmDefinition; + private final boolean specChanged; + private final boolean podChanged; /** * Instantiates a new VM changed event. * * @param type the type - * @param specChanged the spec part changed * @param vmDefinition the VM definition + * @param specChanged the spec part changed */ - public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged, - VmDefinition vmDefinition) { + public VmResourceChanged(K8sObserver.ResponseType type, + VmDefinition vmDefinition, boolean specChanged, + boolean podChanged) { this.type = type; - this.specChanged = specChanged; this.vmDefinition = vmDefinition; + this.specChanged = specChanged; + this.podChanged = podChanged; } /** @@ -61,6 +65,15 @@ public class VmDefChanged extends Event { return type; } + /** + * Return the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDefinition; + } + /** * Indicates if the "spec" part changed. */ @@ -69,12 +82,10 @@ public class VmDefChanged extends Event { } /** - * Return the VM definition. - * - * @return the VM definition + * Indicates if the pod status changed. */ - public VmDefinition vmDefinition() { - return vmDefinition; + public boolean podChanged() { + return podChanged; } @Override diff --git a/org.jdrupes.vmoperator.manager/.gitignore b/org.jdrupes.vmoperator.manager/.gitignore new file mode 100644 index 0000000..50a6b62 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/.gitignore @@ -0,0 +1 @@ +/logging.properties diff --git a/org.jdrupes.vmoperator.manager/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)' diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html index 8147dca..72596d5 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html @@ -1,3 +1,3 @@
-Copyright © Michael N. Lipp 2023 +Copyright © Michael N. Lipp 2023, 2025
diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties index 9e6d0f5..2a16af6 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties @@ -1,6 +1,6 @@ # # VM-Operator -# Copyright (C) 2023 Michael N. Lipp +# Copyright (C) 2025 Michael N. Lipp # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by @@ -19,10 +19,7 @@ handlers=java.util.logging.ConsoleHandler, \ org.jgrapes.webconlet.logviewer.LogViewerHandler -org.jgrapes.level=FINE -org.jgrapes.core.handlerTracking.level=FINER - -org.jdrupes.vmoperator.manager.level=FINE +org.jdrupes.vmoperator.level=FINE java.util.logging.ConsoleHandler.level=ALL java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml index 2a59a2c..0200021 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 @@ -37,7 +37,7 @@ data: # The template to use. Resolved relative to /usr/share/vmrunner/templates. # template: "Standard-VM-latest.ftl.yaml" <#if spec.runnerTemplate?? && spec.runnerTemplate.source?? > - template: ${ cm.spec().runnerTemplate.source } + template: ${ spec.runnerTemplate.source } # The template is copied to the data diretory when the VM starts for @@ -53,7 +53,7 @@ data: # i.e. if you start the VM without a value for this property, and # decide to trigger a reset later, you have to first set the value # and then inrement it. - resetCounter: ${ cr.extra().get().resetCount()?c } + resetCounter: ${ cr.extra().resetCount()?c } # Forward the cloud-init data if provided <#if spec.cloudInit??> diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml index f000c70..7518ad3 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml @@ -11,7 +11,7 @@ metadata: annotations: # Triggers update of config map mounted in pod # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion }" + vmrunner.jdrupes.org/cmVersion: "${ configMapResourceVersion }" vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java index 2deb9ab..c10752e 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 @@ -51,7 +51,6 @@ import org.jgrapes.util.events.ConfigurationUpdate; * @param the object type for the context * @param the object list type for the context */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" }) public abstract class AbstractMonitor extends Component { @@ -181,7 +180,6 @@ public abstract class AbstractMonitor "Observing " + K8s.toString(context) - + " objects in " + namespace); // Monitor all versions for (var version : context.getVersions()) { @@ -219,9 +215,7 @@ public abstract class AbstractMonitor(objectClass, objectListClass, client, K8s.preferred(context, version), namespace, options) - .handler((c, r) -> { - handleChange(c, r); - }).onTerminated((o, t) -> { + .handler(this::handleChange).onTerminated((o, t) -> { if (observerCounter.decrementAndGet() == 0) { unregisterAsGenerator(); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 9c6dc3e..0ca6312 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -19,11 +19,17 @@ package org.jdrupes.vmoperator.manager; import com.google.gson.JsonObject; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; import freemarker.template.TemplateException; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.utility.DeepUnwrap; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; @@ -31,7 +37,11 @@ import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.io.StringWriter; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8s; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; @@ -46,7 +56,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the config map */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class ConfigMapReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); @@ -66,48 +75,70 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * * @param model the model * @param channel the channel - * @return the dynamic kubernetes object + * @param modelChanged the model has changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception - * @throws ApiException the api exception + * @throws ApiException the API exception */ - public Map reconcile(Map model, - VmChannel channel) + public void reconcile(Map model, VmChannel channel, + boolean modelChanged) throws IOException, TemplateException, ApiException { + // Check if an update is needed + var prevData = channel.associated(PrevData.class) + .orElseGet(() -> new PrevData(null, new HashMap<>())); + Object newInputs = model.get("loginRequestedFor"); + if (!modelChanged && Objects.equals(prevData.inputs, newInputs)) { + // Make added data available in new model + model.putAll(prevData.added); + return; + } + prevData = new PrevData(newInputs, prevData.added); + channel.setAssociated(PrevData.class, prevData); + // Combine template and data and parse result + logger.fine(() -> "Create/update configmap " + + DataPath. get(model, "cr", "name").orElse("unknown")); + model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); + prevData.added.put("adjustCloudInitMeta", adjustCloudInitMetaModel); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 - var mapDef = Dynamics.newFromYaml( + var newCm = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); // Maybe override logging.properties from reconciler configuration. DataPath. get(model, "reconciler", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); // Maybe override logging.properties from VM definition. DataPath. get(model, "cr", "spec", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); - // Get API + // Get API and update DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", "configmaps", channel.client()); // Apply and maybe force pod update - var newState = K8s.apply(cmApi, mapDef, mapDef.getRaw().toString()); - maybeForceUpdate(channel.client(), newState); - @SuppressWarnings("unchecked") - var res = (Map) channel.client().getJSON().getGson() - .fromJson(newState.getRaw(), Map.class); - return res; + var updatedCm = K8s.apply(cmApi, newCm, newCm.getRaw().toString()); + maybeForceUpdate(channel.client(), updatedCm); + model.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); + prevData.added.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); + } + + /** + * Key for association. + */ + private record PrevData(Object inputs, Map added) { } /** @@ -153,4 +184,27 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } + private final TemplateMethodModelEx adjustCloudInitMetaModel + = new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + @SuppressWarnings("unchecked") + var res = new HashMap<>((Map) DeepUnwrap + .unwrap((TemplateModel) arguments.get(0))); + var metadata + = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) + .getAdaptedObject(Object.class); + if (!res.containsKey("instance-id")) { + res.put("instance-id", + Optional.ofNullable(metadata.getGeneration()) + .map(s -> "v" + s).orElse("v1")); + } + if (!res.containsKey("local-hostname")) { + res.put("local-hostname", metadata.getName()); + } + return res; + } + }; + } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java index c5c8528..2ef4199 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 @@ -21,7 +21,6 @@ package org.jdrupes.vmoperator.manager; /** * Some constants. */ -@SuppressWarnings("PMD.DataClass") public class Constants extends org.jdrupes.vmoperator.common.Constants { /** The Constant STATE_RUNNING. */ diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 5d5c592..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 @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023, 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -20,29 +20,37 @@ package org.jdrupes.vmoperator.manager; import com.google.gson.JsonObject; import io.kubernetes.client.apimachinery.GroupVersionKind; -import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.Configuration; import java.io.IOException; -import java.net.HttpURLConnection; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; +import java.util.Comparator; +import java.util.Optional; import java.util.logging.Level; import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.Exit; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.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; @@ -54,7 +62,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; * * The implementation splits the controller in two components. The * {@link VmMonitor} and the {@link Reconciler}. The former watches - * the VM definitions (CRs) and generates {@link VmDefChanged} events + * the VM definitions (CRs) and generates {@link VmResourceChanged} events * when they change. The latter handles the changes and reconciles the * resources in the cluster. * @@ -87,6 +95,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; public class Controller extends Component { private String namespace; + private final ChannelManager chanMgr; /** * Creates a new instance. @@ -95,17 +104,16 @@ public class Controller extends Component { public Controller(Channel componentChannel) { super(componentChannel); // Prepare component tree - ChannelManager chanMgr - = new ChannelManager<>(name -> { - try { - return new VmChannel(channel(), newEventPipeline(), - new K8sClient()); - } catch (IOException e) { - logger.log(Level.SEVERE, e, () -> "Failed to create client" - + " for handling changes: " + e.getMessage()); - return null; - } - }); + chanMgr = new ChannelManager<>(name -> { + try { + return new VmChannel(channel(), newEventPipeline(), + new K8sClient()); + } catch (IOException e) { + logger.log(Level.SEVERE, e, () -> "Failed to create client" + + " for handling changes: " + e.getMessage()); + return null; + } + }); attach(new VmMonitor(channel(), chanMgr)); attach(new DisplaySecretMonitor(channel(), chanMgr)); // Currently, we don't use the IP assigned by the load balancer @@ -113,6 +121,7 @@ public class Controller extends Component { // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); attach(new Reconciler(channel())); attach(new PoolMonitor(channel())); + attach(new PodMonitor(channel(), chanMgr)); } /** @@ -174,77 +183,146 @@ public class Controller extends Component { fire(new Exit(2)); return; } - logger.fine(() -> "Controlling namespace \"" + namespace + "\"."); + logger.config(() -> "Controlling namespace \"" + namespace + "\"."); } /** - * On modify vm. + * Returns the VM data. + * + * @param event the event + */ + @Handler + public void onGetVms(GetVms event) { + event.setResult(chanMgr.channels().stream() + .filter(c -> event.name().isEmpty() + || c.vmDefinition().name().equals(event.name().get())) + .filter(c -> event.user().isEmpty() && event.roles().isEmpty() + || !c.vmDefinition().permissionsFor(event.user().orElse(null), + event.roles()).isEmpty()) + .filter(c -> event.fromPool().isEmpty() + || c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool().get())).orElse(false)) + .filter(c -> event.toUser().isEmpty() + || c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser().get())).orElse(false)) + .map(c -> new VmData(c.vmDefinition(), c)) + .toList()); + } + + /** + * Assign a VM if not already assigned. * * @param event the event * @throws ApiException the api exception - * @throws IOException Signals that an I/O exception has occurred. + * @throws InterruptedException */ @Handler - public void onModifyVm(ModifyVm event, VmChannel channel) - throws ApiException, IOException { - patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), - event.value()); + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onAssignVm(AssignVm event) + throws ApiException, InterruptedException { + while (true) { + // Search for existing assignment. + var vmQuery = chanMgr.channels().stream() + .filter(c -> c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser())).orElse(false)) + .findFirst(); + if (vmQuery.isPresent()) { + var vmDef = vmQuery.get().vmDefinition(); + event.setResult(new VmData(vmDef, vmQuery.get())); + return; + } + + // Get the pool definition for checking possible assignment + VmPool vmPool = newEventPipeline().fire(new GetPools() + .withName(event.fromPool())).get().stream().findFirst() + .orElse(null); + if (vmPool == null) { + return; + } + + // Find available VM. + vmQuery = chanMgr.channels().stream() + .filter(c -> vmPool.isAssignable(c.vmDefinition())) + .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() + .assignment().map(Assignment::lastUsed) + .orElse(Instant.ofEpochSecond(0))) + .thenComparing(preferRunning)) + .findFirst(); + + // None found + if (vmQuery.isEmpty()) { + return; + } + + // Assign to user + var chosenVm = vmQuery.get(); + if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment( + vmPool, event.toUser())).get()).orElse(false)) { + var vmDef = chosenVm.vmDefinition(); + event.setResult(new VmData(vmDef, chosenVm)); + + // Make sure that a newly assigned VM is running. + chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running")); + return; + } + } } - private void patchVmDef(K8sClient client, String name, String path, - Object value) throws ApiException, IOException { - var vmStub = K8sDynamicStub.get(client, - new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, - name); + 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; + } + }; - // Patch running - String valueAsText = value instanceof String - ? "\"" + value + "\"" - : value.toString(); - var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, - new V1Patch("[{\"op\": \"replace\", \"path\": \"/" - + path + "\", \"value\": " + valueAsText + "}]"), - client.defaultPatchOptions()); - if (!res.isPresent()) { - logger.warning( - () -> "Cannot patch definition for Vm " + vmStub.name()); + /** + * When s pool is deleted, remove all related assignments. + * + * @param event the event + * @throws InterruptedException + */ + @Handler + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onPoolChanged(VmPoolChanged event) throws InterruptedException { + if (!event.deleted()) { + return; + } + var vms = newEventPipeline() + .fire(new GetVms().assignedFrom(event.vmPool().name())).get(); + for (var vm : vms) { + vm.channel().fire(new UpdateAssignment(event.vmPool(), null)); } } /** - * 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. + * Remove runner version from status when pod is deleted * * @param event the event * @param channel the channel * @throws ApiException the api exception */ @Handler - public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) + public void onPodChange(PodChanged event, VmChannel channel) throws ApiException { - try { + if (event.type() == ResponseType.DELETED) { + // Remove runner info from status var vmDef = channel.vmDefinition(); var vmStub = VmDefinitionStub.get(channel.client(), new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), vmDef.namespace(), vmDef.name()); - if (vmStub.updateStatus(vmDef, from -> { + vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); - assignment.set("pool", event.fromPool().name()); - assignment.set("user", event.toUser()); - assignment.set("lastUsed", Instant.now().toString()); + status.remove(Status.RUNNER_VERSION); 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/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index a254c0e..b094b79 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 @@ -42,7 +42,6 @@ import org.jgrapes.core.Channel; * 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 { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index dabffb6..1e3eb0f 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -45,7 +45,7 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.CompletionLock; @@ -66,7 +66,6 @@ import org.jose4j.base64url.Base64; * * `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" }) public class DisplaySecretReconciler extends Component { protected final Logger logger = Logger.getLogger(getClass().getName()); @@ -104,12 +103,15 @@ public class DisplaySecretReconciler extends Component { 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()); + Optional.ofNullable(c.get("passwordValidity")) + .map(p -> p instanceof Integer ? (Integer) p + : Integer.valueOf((String) p)) + .ifPresent(p -> { + passwordValidity = p; + }); + } catch (NumberFormatException e) { + logger.warning( + () -> "Malformed configuration: " + e.getMessage()); } }); } @@ -120,25 +122,30 @@ public class DisplaySecretReconciler extends Component { * secret with a random password and immediate expiration, thus * preventing access to the display. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, - Map model, VmChannel channel) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { + // Nothing to do unless spec changed + if (!specChanged) { + return; + } + // Secret needed at all? - var display = event.vmDefinition().fromVm("display").get(); + var display = vmDef.fromVm("display").get(); if (!DataPath. get(display, "spice", "generateSecret") .orElse(true)) { return; } // Check if exists - var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," @@ -150,9 +157,11 @@ public class DisplaySecretReconciler extends Component { } // Create secret + var secretName = vmDef.name() + "-" + DisplaySecret.NAME; + logger.fine(() -> "Create/update secret " + secretName); var secret = new V1Secret(); secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) - .name(vmDef.name() + "-" + DisplaySecret.NAME) + .name(secretName) .putLabelsItem("app.kubernetes.io/name", APP_NAME) .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); @@ -183,7 +192,6 @@ public class DisplaySecretReconciler extends Component { * @throws ApiException the api exception */ @Handler - @SuppressWarnings("PMD.StringInstantiation") public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel) throws ApiException { // Get VM definition and check if running @@ -293,7 +301,7 @@ public class DisplaySecretReconciler extends Component { */ @Handler @SuppressWarnings("PMD.AvoidSynchronizedStatement") - public void onVmDefChanged(VmDefChanged event, Channel channel) { + public void onVmResourceChanged(VmResourceChanged event, Channel channel) { synchronized (pendingPrepares) { String vmName = event.vmDefinition().name(); for (var pending : pendingPrepares) { @@ -312,7 +320,6 @@ public class DisplaySecretReconciler extends Component { /** * The Class PendingGet. */ - @SuppressWarnings("PMD.DataClass") private static class PendingRequest { public final GetDisplaySecret event; public final long expectedSerial; diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java index 2d632b9..a66b432 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -36,7 +36,7 @@ import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8sV1ServiceStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.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; @@ -45,7 +45,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the service */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class LoadBalancerReconciler { private static final String LOAD_BALANCER_SERVICE = "loadBalancerService"; @@ -69,18 +68,24 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, - Map model, VmChannel channel) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { + // Nothing to do unless spec changed + if (!specChanged) { + return; + } + // Check if to be generated - @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" }) + @SuppressWarnings({ "unchecked" }) var lbsDef = Optional.of(model) .map(m -> (Map) m.get("reconciler")) .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE); @@ -95,7 +100,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Load balancer can also be turned off for VM - var vmDef = event.vmDefinition(); if (vmDef .>> fromSpec(LOAD_BALANCER_SERVICE) .map(m -> m.isEmpty()).orElse(false)) { @@ -103,6 +107,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Combine template and data and parse result + logger.fine(() -> "Create/update load balancer service for " + + DataPath. get(model, "cr", "name").orElse("unknown")); var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java index 9d291cf..f431c9d 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java @@ -81,7 +81,7 @@ import org.jgrapes.webconsole.vuejs.VueJsConsoleWeblet; /** * The application class. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) +@SuppressWarnings({ "PMD.ExcessiveImports" }) public class Manager extends Component { private static String version; @@ -97,8 +97,8 @@ public class Manager extends Component { * @throws IOException Signals that an I/O exception has occurred. * @throws URISyntaxException */ - @SuppressWarnings({ "PMD.TooFewBranchesForASwitchStatement", - "PMD.NcssCount", "PMD.ConstructorCallsOverridableMethod" }) + @SuppressWarnings({ "PMD.NcssCount", + "PMD.ConstructorCallsOverridableMethod" }) public Manager(CommandLine cmdLine) throws IOException, URISyntaxException { super(new NamedChannel("manager")); // Prepare component tree @@ -217,7 +217,6 @@ public class Manager extends Component { * @param event the event */ @Handler - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { if (c.containsKey("clusterName")) { @@ -264,7 +263,7 @@ public class Manager extends Component { */ @Handler(priority = -1000) public void onStop(Stop event) { - logger.fine(() -> "Application stopped."); + logger.info(() -> "Application stopped."); } static { @@ -291,7 +290,6 @@ public class Manager extends Component { * @param args the arguments * @throws Exception the exception */ - @SuppressWarnings("PMD.SignatureDeclareThrowsException") public static void main(String[] args) { try { // Instance logger is not available yet. diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java new file mode 100644 index 0000000..cfb49e5 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java @@ -0,0 +1,139 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.manager.events.ChannelDictionary; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; + +/** + * Watches for changes of pods that run VMs. + */ +public class PodMonitor extends AbstractMonitor { + + private final ChannelDictionary channelDictionary; + + private final Map pendingChanges + = new ConcurrentHashMap<>(); + + /** + * Instantiates a new pod monitor. + * + * @param componentChannel the component channel + * @param channelDictionary the channel dictionary + */ + public PodMonitor(Channel componentChannel, + ChannelDictionary channelDictionary) { + super(componentChannel, V1Pod.class, V1PodList.class); + this.channelDictionary = channelDictionary; + context(K8sV1PodStub.CONTEXT); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + APP_NAME + "," + + "app.kubernetes.io/managed-by=" + VM_OP_NAME); + options(options); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + } + + @Override + protected void handleChange(K8sClient client, Response change) { + String vmName = change.object.getMetadata().getLabels() + .get("app.kubernetes.io/instance"); + if (vmName == null) { + return; + } + var channel = channelDictionary.channel(vmName).orElse(null); + var responseType = ResponseType.valueOf(change.type); + if (channel != null && channel.vmDefinition() != null) { + pendingChanges.remove(vmName); + channel.fire(new PodChanged(change.object, responseType)); + return; + } + + // VM definition not available yet, may happen during startup + if (responseType == ResponseType.DELETED) { + return; + } + purgePendingChanges(); + logger.finer(() -> "Add pending pod change for " + vmName); + pendingChanges.put(vmName, new PendingChange(Instant.now(), change)); + } + + private void purgePendingChanges() { + Instant tooOld = Instant.now().minus(Duration.ofMinutes(15)); + for (var itr = pendingChanges.entrySet().iterator(); itr.hasNext();) { + var change = itr.next(); + if (change.getValue().from().isBefore(tooOld)) { + itr.remove(); + logger.finer( + () -> "Cleaned pending pod change for " + change.getKey()); + } + } + } + + /** + * Check for pending changes. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onVmResourceChanged(VmResourceChanged event, + VmChannel channel) { + Optional.ofNullable(pendingChanges.remove(event.vmDefinition().name())) + .map(PendingChange::change).ifPresent(change -> { + logger.finer(() -> "Firing pending pod change for " + + event.vmDefinition().name()); + channel.fire(new PodChanged(change.object, + ResponseType.valueOf(change.type))); + if (logger.isLoggable(Level.FINER) + && pendingChanges.isEmpty()) { + logger.finer("No pending pod changes left."); + } + }); + } + + private record PendingChange(Instant from, Response change) { + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java index 4ee96d8..4733e73 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java @@ -22,15 +22,20 @@ import freemarker.template.Configuration; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.generic.dynamic.Dynamics; +import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.io.StringWriter; import java.util.Map; import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -38,7 +43,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the pod. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class PodReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); @@ -56,23 +60,18 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile the pod. * - * @param event the event + * @param vmDef the vm def * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, Map model, - VmChannel channel) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { - // Don't do anything if stateful set is still in use (pre v3.4) - if ((Boolean) model.get("usingSts")) { - return; - } - // Get pod stub. - var vmDef = event.vmDefinition(); var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), vmDef.name()); @@ -91,6 +90,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Create pod. First combine template and data and parse result + logger.fine(() -> "Create/update pod " + podStub.name()); + addDisplaySecret(channel.client(), model, vmDef); var fmTemplate = fmConfig.getTemplate("runnerPod.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -109,4 +110,19 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } + private void addDisplaySecret(K8sClient client, Map model, + VmDefinition vmDef) throws ApiException { + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var dsStub = K8sV1SecretStub + .list(client, vmDef.namespace(), options).stream().findFirst(); + if (dsStub.isPresent()) { + dsStub.get().model().ifPresent(m -> { + model.put("displaySecret", m.getMetadata().getName()); + }); + } + } + } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index 5d85280..e554d5a 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -40,8 +40,8 @@ import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.GetPools; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.EventPipeline; @@ -53,7 +53,6 @@ import org.jgrapes.core.events.Attached; * {@link VmPoolChanged} events fired on a special pipeline to * avoid concurrent change informations. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) public class PoolMonitor extends AbstractMonitor { @@ -142,7 +141,8 @@ public class PoolMonitor extends * @throws ApiException */ @Handler - public void onVmDefChanged(VmDefChanged event) throws ApiException { + public void onVmResourceChanged(VmResourceChanged event) + throws ApiException { final var vmDef = event.vmDefinition(); final String vmName = vmDef.name(); switch (event.type()) { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java index 34085f0..515bfc9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -38,8 +38,8 @@ import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; @@ -49,7 +49,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the stateful set (effectively the pod). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class PvcReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); @@ -67,32 +66,35 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile the PVCs. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - public void reconcile(VmDefChanged event, Map model, - VmChannel channel) + @SuppressWarnings({ "unchecked" }) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { - 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=" + vmDef.name()); - var knownDisks = K8sV1PvcStub.list(channel.client(), - vmDef.namespace(), listOpts); - var knownPvcs = knownDisks.stream().map(K8sV1PvcStub::name) - .collect(Collectors.toSet()); + Set knownPvcs; + if (!specChanged && channel.associated(this, Set.class).isPresent()) { + knownPvcs = (Set) channel.associated(this, Set.class).get(); + } else { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + knownPvcs = K8sV1PvcStub.list(channel.client(), + vmDef.namespace(), listOpts).stream().map(K8sV1PvcStub::name) + .collect(Collectors.toSet()); + channel.setAssociated(this, knownPvcs); + } // Reconcile runner data pvc - reconcileRunnerDataPvc(event, model, channel, knownPvcs); + reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs, specChanged); // Reconcile pvcs for defined disks var diskDefs = vmDef.>> fromVm("disks") @@ -116,18 +118,15 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Update PVC - model.put("disk", diskDef); - reconcileRunnerDiskPvc(event, model, channel); + reconcileRunnerDiskPvc(vmDef, model, channel, specChanged, diskDef); } - model.remove("disk"); } - private void reconcileRunnerDataPvc(VmDefChanged event, + private void reconcileRunnerDataPvc(VmDefinition vmDef, Map model, VmChannel channel, - Set knownPvcs) + Set knownPvcs, boolean specChanged) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var vmDef = event.vmDefinition(); // Look for old (sts generated) name. var stsRunnerDataPvcName @@ -138,7 +137,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } // Generate PVC - model.put("runnerDataPvcName", vmDef.name() + "-runner-data"); + var runnerDataPvcName = vmDef.name() + "-runner-data"; + logger.fine(() -> "Create/update pvc " + runnerDataPvcName); + model.put("runnerDataPvcName", runnerDataPvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -161,20 +166,26 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } - private void reconcileRunnerDiskPvc(VmDefChanged event, - Map model, VmChannel channel) + private void reconcileRunnerDiskPvc(VmDefinition vmDef, + Map model, VmChannel channel, boolean specChanged, + Map diskDef) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var vmDef = event.vmDefinition(); - // Generate PVC - @SuppressWarnings("unchecked") - var diskDef = (Map) model.get("disk"); var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName"); diskDef.put("generatedPvcName", pvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } + + // Generate PVC + logger.fine(() -> "Create/update pvc " + pvcName); + model.put("disk", diskDef); var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); + model.remove("disk"); // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 var pvcDef = Dynamics.newFromYaml( diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 8df5c88..e580c48 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -27,13 +27,9 @@ import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; import freemarker.template.TemplateMethodModelEx; -import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; -import freemarker.template.utility.DeepUnwrap; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.lang.reflect.Modifier; import java.math.BigDecimal; @@ -46,19 +42,15 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.Convertions; -import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -145,19 +137,16 @@ import org.jgrapes.util.events.ConfigurationUpdate; * * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.AvoidDuplicateLiterals" }) +@SuppressWarnings({ "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; private final DisplaySecretReconciler dsReconciler; - private final StatefulSetReconciler stsReconciler; private final PvcReconciler pvcReconciler; private final PodReconciler podReconciler; private final LoadBalancerReconciler lbReconciler; @@ -185,7 +174,6 @@ public class Reconciler extends Component { cmReconciler = new ConfigMapReconciler(fmConfig); dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); - stsReconciler = new StatefulSetReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig); lbReconciler = new LoadBalancerReconciler(fmConfig); @@ -213,32 +201,27 @@ public class Reconciler extends Component { * @throws IOException Signals that an I/O exception has occurred. */ @Handler - @SuppressWarnings("PMD.ConfusingTernary") - public void onVmDefChanged(VmDefChanged event, VmChannel channel) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) throws ApiException, TemplateException, IOException { // Ownership relationships takes care of deletions if (event.type() == K8sObserver.ResponseType.DELETED) { - logger.fine( - () -> "VM \"" + event.vmDefinition().name() + "\" deleted"); return; } // Create model for processing templates - Map model - = prepareModel(channel.client(), event.vmDefinition()); - var configMap = cmReconciler.reconcile(model, channel); + var vmDef = event.vmDefinition(); + Map model = prepareModel(vmDef); + cmReconciler.reconcile(model, channel, event.specChanged()); - // The remaining reconcilers depend only on changes of the spec part. - if (!event.specChanged()) { + // The remaining reconcilers depend only on changes of the spec part + // or the pod state. + if (!event.specChanged() && !event.podChanged()) { return; } - model.put("cm", configMap); - dsReconciler.reconcile(event, model, channel); - // Manage (eventual) removal of stateful set. - stsReconciler.reconcile(event, model, channel); - pvcReconciler.reconcile(event, model, channel); - podReconciler.reconcile(event, model, channel); - lbReconciler.reconcile(event, model, channel); + dsReconciler.reconcile(vmDef, model, channel, event.specChanged()); + pvcReconciler.reconcile(vmDef, model, channel, event.specChanged()); + podReconciler.reconcile(vmDef, model, channel, event.specChanged()); + lbReconciler.reconcile(vmDef, model, channel, event.specChanged()); } /** @@ -255,15 +238,15 @@ public class Reconciler extends Component { public void onResetVm(ResetVm event, VmChannel channel) throws ApiException, IOException, TemplateException { var vmDef = channel.vmDefinition(); - vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1)); + var extra = vmDef.extra(); + extra.resetCount(extra.resetCount() + 1); Map model - = prepareModel(channel.client(), channel.vmDefinition()); - cmReconciler.reconcile(model, channel); + = prepareModel(channel.vmDefinition()); + cmReconciler.reconcile(model, channel, true); } - @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity" }) - private Map prepareModel(K8sClient client, - VmDefinition vmDef) throws TemplateModelException, ApiException { + private Map prepareModel(VmDefinition vmDef) + throws TemplateModelException, ApiException { @SuppressWarnings("PMD.UseConcurrentHashMap") Map model = new HashMap<>(); model.put("managerVersion", @@ -273,13 +256,11 @@ public class Reconciler extends Component { model.put("reconciler", config); model.put("constants", constantsMap(Constants.class)); addLoginRequestedFor(model, vmDef); - addDisplaySecret(client, model, vmDef); // Methods model.put("parseQuantity", parseQuantityModel); model.put("formatMemory", formatMemoryModel); model.put("imageLocation", imgageLocationModel); - model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); model.put("toJson", toJsonModel); return model; } @@ -332,21 +313,6 @@ public class Reconciler extends Component { .ifPresent(u -> model.put("loginRequestedFor", u)); } - private void addDisplaySecret(K8sClient client, Map model, - VmDefinition vmDef) throws ApiException { - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," - + "app.kubernetes.io/instance=" + vmDef.name()); - var dsStub = K8sV1SecretStub - .list(client, vmDef.namespace(), options).stream().findFirst(); - if (dsStub.isPresent()) { - dsStub.get().model().ifPresent(m -> { - model.put("displaySecret", m.getMetadata().getName()); - }); - } - } - private final TemplateMethodModelEx parseQuantityModel = new TemplateMethodModelEx() { @Override @@ -369,7 +335,6 @@ public class Reconciler extends Component { private final TemplateMethodModelEx formatMemoryModel = new TemplateMethodModelEx() { @Override - @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var arg = arguments.get(0); @@ -399,8 +364,7 @@ public class Reconciler extends Component { private final TemplateMethodModelEx imgageLocationModel = new TemplateMethodModelEx() { @Override - @SuppressWarnings({ "PMD.PreserveStackTrace", - "PMD.AvoidLiteralsInIfCondition" }) + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var image = ((SimpleScalar) arguments.get(0)).getAsString(); @@ -422,34 +386,9 @@ public class Reconciler extends Component { } }; - private final TemplateMethodModelEx adjustCloudInitMetaModel - = new TemplateMethodModelEx() { - @Override - @SuppressWarnings("PMD.PreserveStackTrace") - public Object exec(@SuppressWarnings("rawtypes") List arguments) - throws TemplateModelException { - @SuppressWarnings("unchecked") - var res = new HashMap<>((Map) DeepUnwrap - .unwrap((TemplateModel) arguments.get(0))); - var metadata - = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) - .getAdaptedObject(Object.class); - if (!res.containsKey("instance-id")) { - res.put("instance-id", - Optional.ofNullable(metadata.getResourceVersion()) - .map(s -> "v" + s).orElse("v1")); - } - if (!res.containsKey("local-hostname")) { - res.put("local-hostname", metadata.getName()); - } - return res; - } - }; - private final TemplateMethodModelEx toJsonModel = new TemplateMethodModelEx() { @Override - @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { try { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java deleted file mode 100644 index 8803e61..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2023 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager; - -import freemarker.template.Configuration; -import freemarker.template.TemplateException; -import io.kubernetes.client.custom.V1Patch; -import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.options.PatchOptions; -import java.io.IOException; -import java.util.Map; -import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; -import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; - -/** - * Before version 3.4, the pod running the VM was created by a stateful set. - * Starting with version 3.4, this reconciler simply deletes the stateful - * set, provided that the VM is not running. - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -/* default */ class StatefulSetReconciler { - - protected final Logger logger = Logger.getLogger(getClass().getName()); - - /** - * Instantiates a new stateful set reconciler. - * - * @param fmConfig the fm config - */ - @SuppressWarnings("PMD.UnusedFormalParameter") - public StatefulSetReconciler(Configuration fmConfig) { - // Nothing to do - } - - /** - * Reconcile stateful set. - * - * @param event the event - * @param model the model - * @param channel the channel - * @throws IOException Signals that an I/O exception has occurred. - * @throws TemplateException the template exception - * @throws ApiException the api exception - */ - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - public void reconcile(VmDefChanged event, Map model, - VmChannel channel) - throws IOException, TemplateException, ApiException { - model.put("usingSts", false); - - // If exists, delete when not running or supposed to be not running. - var stsStub = K8sV1StatefulSetStub.get(channel.client(), - event.vmDefinition().namespace(), event.vmDefinition().name()); - if (stsStub.model().isEmpty()) { - return; - } - - // Stateful set still exists, check if replicas is 0 so we can - // delete it. - var stsModel = stsStub.model().get(); - if (stsModel.getSpec().getReplicas() == 0) { - stsStub.delete(); - return; - } - - // Cannot yet delete the stateful set. - model.put("usingSts", true); - - // Check if VM is supposed to be stopped. If so, - // set replicas to 0. This is the first step of the transition, - // the stateful set will be deleted when the VM is restarted. - if (event.vmDefinition().vmState() == RequestedVmState.RUNNING) { - return; - } - - // Do apply changes (set replicas to 0) - PatchOptions opts = new PatchOptions(); - opts.setForce(true); - opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (stsStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, - new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/replicas" - + "\", \"value\": 0}]"), - channel.client().defaultPatchOptions()).isEmpty()) { - logger.warning( - () -> "Could not patch stateful set for " + stsStub.name()); - } - } -} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 1a559b3..22f083c 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,56 +18,64 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import java.net.HttpURLConnection; import java.time.Instant; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Collections; import java.util.Optional; import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.logging.Level; import java.util.stream.Collectors; import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; -import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; import org.jdrupes.vmoperator.common.VmDefinition; -import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitions; import org.jdrupes.vmoperator.common.VmExtraData; -import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; -import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; -import org.jdrupes.vmoperator.manager.events.GetPools; -import org.jdrupes.vmoperator.manager.events.GetVms; -import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PodChanged; import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.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. @@ -76,7 +84,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; @@ -98,7 +106,6 @@ public class VmMonitor extends purge(); } - @SuppressWarnings("PMD.CognitiveComplexity") private void purge() throws ApiException { // Get existing CRs (VMs) var known = K8sDynamicStub.list(client(), context(), namespace()) @@ -123,14 +130,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) { @@ -138,9 +149,12 @@ 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(channel.client(), vmDef, channel.vmDefinition()); + addExtraData(vmDef, channel.vmDefinition()); channel.setVmDefinition(vmDef); } else { // Reuse cached (e.g. if deleted) @@ -151,22 +165,20 @@ 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. - VmDefChanged chgEvt - = new VmDefChanged(ResponseType.valueOf(response.type), + VmResourceChanged chgEvt + = new VmResourceChanged(ResponseType.valueOf(response.type), vmDef, channel.setGeneration(response.object.getMetadata() .getGeneration()), - vmDef); + false); if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { chgEvt = Event.onCompletion(chgEvt, e -> channelManager.remove(e.vmDefinition().name())); } - channel.pipeline().fire(chgEvt, channel); + channel.fire(chgEvt); } private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { @@ -178,147 +190,137 @@ public class VmMonitor extends } } - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private void addExtraData(K8sClient client, VmDefinition vmDef, - VmDefinition prevState) { + private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { var extra = new VmExtraData(vmDef); + var prevExtra = Optional.ofNullable(prevState).map(VmDefinition::extra); // Maintain (or initialize) the resetCount - extra.resetCount( - Optional.ofNullable(prevState).flatMap(VmDefinition::extra) - .map(VmExtraData::resetCount).orElse(0L)); + extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L)); - // VM definition status changes before the pod terminates. - // This results in pod information being shown for a stopped - // VM which is irritating. So check condition first. - if (!vmDef.conditionStatus("Running").orElse(false)) { + // Maintain node info + prevExtra + .ifPresent(e -> extra.nodeInfo(e.nodeName(), e.nodeAddresses())); + } + + /** + * On pod changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onPodChanged(PodChanged event, VmChannel channel) { + var vmDef = channel.vmDefinition(); + + // 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) { + var extra = vmDef.extra(); + if (event.type() == ResponseType.DELETED) { + // The status of a deleted pod is the status before deletion, + // i.e. the node info is still cached and must be removed. + extra.nodeInfo("", Collections.emptyList()); return; } - // Get pod and extract node information. - var podSearch = new ListOptions(); - podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME - + ",app.kubernetes.io/component=" + APP_NAME - + ",app.kubernetes.io/instance=" + vmDef.name()); - try { - var podList - = K8sV1PodStub.list(client, namespace(), podSearch); - for (var podStub : podList) { - var nodeName = podStub.model().get().getSpec().getNodeName(); - logger.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); - logger.fine(() -> "Adding node addresses " + addrs - + " to VM info for " + vmDef.name()); - extra.nodeInfo(nodeName, addrs); - } - } catch (ApiException e) { - logger.log(Level.WARNING, e, - () -> "Cannot access node information: " + e.getMessage()); - } + // Get current node info from pod + var pod = event.pod(); + var nodeName = Optional + .ofNullable(pod.getSpec().getNodeName()).orElse(""); + logger.finer(() -> "Adding node name " + nodeName + + " to VM info for " + vmDef.name()); + var addrs = new ArrayList(); + Optional.ofNullable(pod.getStatus().getPodIPs()) + .orElse(Collections.emptyList()).stream() + .map(ip -> ip.getIp()).forEach(addrs::add); + logger.finer(() -> "Adding node addresses " + addrs + + " to VM info for " + vmDef.name()); + extra.nodeInfo(nodeName, addrs); } /** - * Returns the VM data. - * - * @param event the event - */ - @Handler - public void onGetVms(GetVms event) { - event.setResult(channelManager.channels().stream() - .filter(c -> event.name().isEmpty() - || c.vmDefinition().name().equals(event.name().get())) - .filter(c -> event.user().isEmpty() && event.roles().isEmpty() - || !c.vmDefinition().permissionsFor(event.user().orElse(null), - event.roles()).isEmpty()) - .filter(c -> event.fromPool().isEmpty() - || c.vmDefinition().assignment().map(Assignment::pool) - .map(p -> p.equals(event.fromPool().get())).orElse(false)) - .filter(c -> event.toUser().isEmpty() - || c.vmDefinition().assignment().map(Assignment::user) - .map(u -> u.equals(event.toUser().get())).orElse(false)) - .map(c -> new VmData(c.vmDefinition(), c)) - .toList()); - } - - /** - * Assign a VM if not already assigned. + * On modify vm. * * @param event the event * @throws ApiException the api exception - * @throws InterruptedException + * @throws IOException Signals that an I/O exception has occurred. */ @Handler - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - public void onAssignVm(AssignVm event) - throws ApiException, InterruptedException { - while (true) { - // Search for existing assignment. - var vmQuery = channelManager.channels().stream() - .filter(c -> c.vmDefinition().assignment().map(Assignment::pool) - .map(p -> p.equals(event.fromPool())).orElse(false)) - .filter(c -> c.vmDefinition().assignment().map(Assignment::user) - .map(u -> u.equals(event.toUser())).orElse(false)) - .findFirst(); - if (vmQuery.isPresent()) { - var vmDef = vmQuery.get().vmDefinition(); - event.setResult(new VmData(vmDef, vmQuery.get())); - return; - } + public void onModifyVm(ModifyVm event, VmChannel channel) + throws ApiException, IOException { + patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), + event.value()); + } - // Get the pool definition for checking possible assignment - VmPool vmPool = newEventPipeline().fire(new GetPools() - .withName(event.fromPool())).get().stream().findFirst() - .orElse(null); - if (vmPool == null) { - return; - } + private void patchVmDef(K8sClient client, String name, String path, + Object value) throws ApiException, IOException { + var vmStub = K8sDynamicStub.get(client, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace(), + name); - // Find available VM. - vmQuery = channelManager.channels().stream() - .filter(c -> vmPool.isAssignable(c.vmDefinition())) - .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() - .assignment().map(Assignment::lastUsed) - .orElse(Instant.ofEpochSecond(0))) - .thenComparing(preferRunning)) - .findFirst(); - - // None found - if (vmQuery.isEmpty()) { - return; - } - - // Assign to user - var chosenVm = vmQuery.get(); - var vmPipeline = chosenVm.pipeline(); - if (Optional.ofNullable(vmPipeline.fire(new UpdateAssignment( - vmPool, 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; - } + // Patch running + String valueAsText = value instanceof String + ? "\"" + value + "\"" + : value.toString(); + var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/" + + path + "\", \"value\": " + valueAsText + "}]"), + client.defaultPatchOptions()); + if (!res.isPresent()) { + logger.warning( + () -> "Cannot patch definition for Vm " + vmStub.name()); } } - private static Comparator preferRunning - = new Comparator<>() { - @Override - public int compare(VmChannel ch1, VmChannel ch2) { - if (ch1.vmDefinition().conditionStatus("Running").orElse(false) - && !ch2.vmDefinition().conditionStatus("Running") - .orElse(false)) { - return -1; + /** + * Attempt to Update the assignment information in the status of the + * VM CR. Returns true if successful. The handler does not attempt + * retries, because in case of failure it will be necessary to + * re-evaluate the chosen VM. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) + throws ApiException { + try { + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + if (vmStub.updateStatus(vmDef, from -> { + JsonObject status = from.statusJson(); + if (event.toUser() == null) { + ((JsonObject) GsonPtr.to(status).get()) + .remove(Status.ASSIGNMENT); + } else { + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); + assignment.set("pool", event.fromPool().name()); + assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); } - return 0; + return status; + }).isPresent()) { + event.setResult(true); } - }; + } catch (ApiException e) { + // Log exceptions except for conflict, which can be expected + if (HttpURLConnection.HTTP_CONFLICT != e.getCode()) { + throw e; + } + } + event.setResult(false); + } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java index 337b5e3..1d05ec9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -83,8 +83,18 @@ * [YamlConfigurationStore] *-right[hidden]- [Controller] * * [Manager] *-- [Controller] - * [Controller] *-- [VmWatcher] - * [Controller] *-- [Reconciler] + * Component VmMonitor as VmMonitor <> + * [Controller] *-- [VmMonitor] + * [VmMonitor] -right[hidden]- [PoolMonitor] + * Component PoolMonitor as PoolMonitor <> + * [Controller] *-- [PoolMonitor] + * Component PodMonitor as PodMonitor <> + * [Controller] *-- [PodMonitor] + * [PodMonitor] -up[hidden]- VmMonitor + * Component DisplaySecretMonitor as DisplaySecretMonitor <> + * [Controller] *-- [DisplaySecretMonitor] + * [DisplaySecretMonitor] -up[hidden]- VmMonitor + * [Controller] *-left- [Reconciler] * [Controller] -right[hidden]- [GuiHttpServer] * * [Manager] *-down- [GuiSocketServer:8080] diff --git a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml index 54ea110..36054a2 100644 --- a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml +++ b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml @@ -1,8 +1,8 @@ apiVersion: "vmoperator.jdrupes.org/v1" kind: VirtualMachine metadata: - namespace: vmop-dev - name: unittest-vm + namespace: vmop-test + name: test-vm spec: image: repository: docker-registry.lan.mnl.de diff --git a/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml new file mode 100644 index 0000000..3a8451e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml @@ -0,0 +1,111 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../../deploy + +namespace: vmop-test + +patches: +- patch: |- + kind: PersistentVolumeClaim + apiVersion: v1 + metadata: + name: vmop-image-repository + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: local-path + +- patch: |- + kind: ConfigMap + apiVersion: v1 + metadata: + name: vm-operator + data: + # Keep in sync with config.yaml + config.yaml: | + "/Manager": + # clusterName: "test" + "/Controller": + "/Reconciler": + runnerData: + storageClassName: null + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common + "/GuiSocketServer": + port: 8888 + "/GuiHttpServer": + # This configures the GUI + "/ConsoleWeblet": + "/WebConsole": + "/LoginConlet": + users: + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test1 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + "/RoleConfigurator": + rolesByUser: + # User admin has role admin + admin: + - admin + test1: + - user + test2: + - user + test3: + - user + # All users have role other + "*": + - other + replace: false + "/RoleConletFilter": + conletTypesByRole: + # Admins can use all conlets + admin: + - "*" + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer + # Others cannot use any conlet (except login conlet to log out) + other: + - org.jgrapes.webconlet.locallogin.LoginConlet + "/ComponentCollector": + "/VmAccess": + displayResource: + preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user +- target: + group: apps + version: v1 + kind: Deployment + name: vm-operator + patch: |- + - op: replace + path: /spec/template/spec/containers/0/image + value: docker-registry.lan.mnl.de/vmoperator/org.jdrupes.vmoperator.manager:test + - op: replace + path: /spec/template/spec/containers/0/imagePullPolicy + value: Always + - op: replace + path: /spec/replicas + value: 0 + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index 03db0d2..d600d3c 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -41,7 +41,7 @@ class BasicTests { private static APIResource vmsContext; private static K8sV1DeploymentStub mgrDeployment; private static K8sDynamicStub vmStub; - private static final String VM_NAME = "unittest-vm"; + private static final String VM_NAME = "test-vm"; private static final Object EXISTS = new Object(); @BeforeAll @@ -54,7 +54,7 @@ class BasicTests { // Update manager pod by scaling deployment mgrDeployment - = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator"); + = K8sV1DeploymentStub.get(client, "vmop-test", "vm-operator"); mgrDeployment.scale(0); mgrDeployment.scale(1); waitForManager(); @@ -65,13 +65,13 @@ class BasicTests { vmsContext = apiRes.get(); // Cleanup existing VM - K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME) + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) .delete(); ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," + "app.kubernetes.io/component=" + DisplaySecret.NAME); - var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + var secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); for (var secret : secrets) { secret.delete(); } @@ -103,7 +103,7 @@ class BasicTests { "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME); - var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts); + var knownPvcs = K8sV1PvcStub.list(client, "vmop-test", listOpts); for (var pvc : knownPvcs) { pvc.delete(); } @@ -112,7 +112,7 @@ class BasicTests { @AfterAll static void tearDownAfterClass() throws Exception { // Cleanup - K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME) + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) .delete(); deletePvcs(); @@ -124,7 +124,7 @@ class BasicTests { void testConfigMap() throws IOException, InterruptedException, ApiException { K8sV1ConfigMapStub stub - = K8sV1ConfigMapStub.get(client, "vmop-dev", VM_NAME); + = K8sV1ConfigMapStub.get(client, "vmop-test", VM_NAME); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -134,7 +134,7 @@ class BasicTests { // Check config map var config = stub.model().get(); Map, Object> toCheck = Map.of( - List.of("namespace"), "vmop-dev", + List.of("namespace"), "vmop-test", List.of("name"), VM_NAME, List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, @@ -191,7 +191,7 @@ class BasicTests { + "app.kubernetes.io/component=" + DisplaySecret.NAME); Collection secrets = null; for (int i = 0; i < 10; i++) { - secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); if (secrets.size() > 0) { break; } @@ -207,7 +207,7 @@ class BasicTests { @Test void testRunnerPvc() throws ApiException, InterruptedException { var stub - = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-runner-data"); + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-runner-data"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -227,7 +227,7 @@ class BasicTests { @Test void testSystemDiskPvc() throws ApiException, InterruptedException { var stub - = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-system-disk"); + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-system-disk"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -248,7 +248,7 @@ class BasicTests { @Test void testDisk1Pvc() throws ApiException, InterruptedException { var stub - = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-disk-1"); + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-disk-1"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; @@ -274,7 +274,7 @@ class BasicTests { new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + "\", \"value\": \"Running\"}]"), client.defaultPatchOptions()).isPresent()); - var stub = K8sV1PodStub.get(client, "vmop-dev", VM_NAME); + var stub = K8sV1PodStub.get(client, "vmop-test", VM_NAME); for (int i = 0; i < 20; i++) { if (stub.model().isPresent()) { break; @@ -303,7 +303,7 @@ class BasicTests { @Test public void testLoadBalancer() throws ApiException, InterruptedException { - var stub = K8sV1ServiceStub.get(client, "vmop-dev", VM_NAME); + var stub = K8sV1ServiceStub.get(client, "vmop-test", VM_NAME); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { break; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java index 40db84a..6303794 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java @@ -20,10 +20,10 @@ package org.jdrupes.vmoperator.runner.qemu; import java.io.IOException; import java.nio.file.Path; +import java.util.List; 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 @@ -47,19 +47,41 @@ public abstract class AgentConnector extends QemuConnector { } /** - * 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. + * Extracts the channel id and the socket path from the QEMU + * command line. * - * @param channelId the channel id - * @param socketPath the socket path + * @param command the command + * @param chardev the chardev */ - /* default */ void configure(String channelId, Path socketPath) { - super.configure(socketPath); - this.channelId = channelId; + @SuppressWarnings("PMD.CognitiveComplexity") + protected void configureConnection(List command, String chardev) { + Path socketPath = null; + for (var arg : command) { + if (arg.startsWith("virtserialport,") + && arg.contains("chardev=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("id=")) { + channelId = prop.substring(3); + } + } + } + if (arg.startsWith("socket,") + && arg.contains("id=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("path=")) { + socketPath = Path.of(prop.substring(5)); + } + } + } + } + if (channelId == null || socketPath == null) { + logger.warning(() -> "Definition of chardev " + chardev + + " missing in runner template."); + return; + } logger.fine(() -> getClass().getSimpleName() + " configured with" + " channelId=" + channelId); + super.configure(socketPath); } /** @@ -70,8 +92,12 @@ public abstract class AgentConnector extends QemuConnector { */ @Handler public void onVserportChanged(VserportChangeEvent event) { - if (event.id().equals(channelId) && event.isOpen()) { - agentConnected(); + if (event.id().equals(channelId)) { + if (event.isOpen()) { + agentConnected(); + } else { + agentDisconnected(); + } } } @@ -83,4 +109,14 @@ public abstract class AgentConnector extends QemuConnector { protected void agentConnected() { // Default is to do nothing. } + + /** + * Called when the agent in the VM closes the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentDisconnected() { + // Default is to do nothing. + } + } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java index 0a8971c..c4ac871 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java @@ -36,7 +36,6 @@ import org.jgrapes.core.annotation.Handler; /** * The Class CdMediaController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class CdMediaController extends Component { /** @@ -55,7 +54,6 @@ public class CdMediaController extends Component { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") public CdMediaController(Channel componentChannel) { super(componentChannel); } @@ -66,8 +64,7 @@ public class CdMediaController extends Component { * @param event the event */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidInstantiatingObjectsInLoops" }) + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) public void onConfigureQemu(ConfigureQemu event) { if (event.runState() == RunState.TERMINATING) { return; 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 50635b5..87e8c76 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,11 +39,9 @@ import org.jdrupes.vmoperator.util.FsdUtils; /** * The configuration information from the configuration file. */ -@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyFields" }) public class Configuration implements Dto { private static final String CI_INSTANCE_ID = "instance-id"; - @SuppressWarnings("PMD.FieldNamingConventions") protected final Logger logger = Logger.getLogger(getClass().getName()); /** Configuration timestamp. */ @@ -95,15 +93,12 @@ public class Configuration implements Dto { public static class CloudInit implements Dto { /** The meta data. */ - @SuppressWarnings("PMD.UseConcurrentHashMap") public Map metaData; /** The user data. */ - @SuppressWarnings("PMD.UseConcurrentHashMap") public Map userData; /** The network config. */ - @SuppressWarnings("PMD.UseConcurrentHashMap") public Map networkConfig; } @@ -299,7 +294,6 @@ public class Configuration implements Dto { return true; } - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") private void checkDrives() { for (Drive drive : vm.drives) { if (drive.file != null || drive.device != null @@ -319,7 +313,6 @@ public class Configuration implements Dto { } } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkRuntimeDir() { // Runtime directory (sockets etc.) if (runtimeDir == null) { @@ -355,7 +348,6 @@ public class Configuration implements Dto { return true; } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkDataDir() { // Data directory if (dataDir == 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 ddfc702..b50b481 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 @@ -41,7 +41,6 @@ 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 VmDefinitionStub vmStub; @@ -53,7 +52,6 @@ public class ConsoleTracker extends VmDefUpdater { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public ConsoleTracker(Channel componentChannel) { super(componentChannel); apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration @@ -91,8 +89,7 @@ public class ConsoleTracker extends VmDefUpdater { * @throws ApiException the api exception */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidDuplicateLiterals" }) + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) public void onSpiceInitialized(SpiceInitializedEvent event) throws ApiException { if (vmStub == null) { @@ -127,7 +124,6 @@ public class ConsoleTracker extends VmDefUpdater { * @throws ApiException the api exception */ @Handler - @SuppressWarnings("PMD.AvoidDuplicateLiterals") public void onSpiceDisconnected(SpiceDisconnectedEvent event) throws ApiException { if (vmStub == null) { diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java new file mode 100644 index 0000000..eac05fa --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.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; + +/** + * Some constants. + */ +public class Constants extends org.jdrupes.vmoperator.common.Constants { + + /** + * Process names. + */ + public static class ProcessName { + + /** The Constant QEMU. */ + public static final String QEMU = "qemu"; + + /** The Constant SWTPM. */ + public static final String SWTPM = "swtpm"; + + /** The Constant CLOUD_INIT_IMG. */ + public static final String CLOUD_INIT_IMG = "cloudInitImg"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java index b0abfd4..440da91 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java @@ -41,7 +41,6 @@ import org.jgrapes.core.annotation.Handler; /** * The Class CpuController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class CpuController extends Component { private Integer currentCpus; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index d301aac..c3bec93 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -43,12 +43,12 @@ import org.jgrapes.util.events.WatchFile; /** * The Class DisplayController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class DisplayController extends Component { private String currentPassword; private String protocol; private final Path configDir; + private boolean canBeUpdated; private boolean vmopAgentConnected; private String loggedInUser; @@ -58,8 +58,7 @@ public class DisplayController extends Component { * @param componentChannel the component channel * @param configDir */ - @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", - "PMD.ConstructorCallsOverridableMethod" }) + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) public DisplayController(Channel componentChannel, Path configDir) { super(componentChannel); this.configDir = configDir; @@ -83,6 +82,7 @@ public class DisplayController extends Component { if (event.runState() == RunState.STARTING) { configurePassword(); } + canBeUpdated = true; } /** @@ -112,10 +112,12 @@ public class DisplayController extends Component { * @param event the event */ @Handler - @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { - configurePassword(); + logger.fine(() -> "Display password updated"); + if (canBeUpdated) { + configurePassword(); + } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java index 880ca58..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 @@ -21,15 +21,23 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; +import java.time.Instant; import java.util.LinkedList; import java.util.Queue; import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestPowerdown; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Components.Timer; import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.ProcessExited; /** * A component that handles the communication with the guest agent. @@ -39,7 +47,12 @@ import org.jgrapes.core.annotation.Handler; */ public class GuestAgentClient extends AgentConnector { + private boolean connected; + private Instant powerdownStartedAt; + private int powerdownTimeout; + private Timer powerdownTimer; private final Queue executing = new LinkedList<>(); + private Stop suspendedStop; /** * Instantiates a new guest agent client. @@ -56,7 +69,15 @@ public class GuestAgentClient extends AgentConnector { */ @Override protected void agentConnected() { - fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); + logger.fine(() -> "Guest agent connected"); + connected = true; + rep().fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); + } + + @Override + protected void agentDisconnected() { + logger.fine(() -> "Guest agent disconnected"); + connected = false; } /** @@ -67,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); } } @@ -88,20 +110,22 @@ public class GuestAgentClient extends AgentConnector { * On guest agent command. * * @param event the event - * @throws IOException + * @throws IOException Signals that an I/O exception has occurred. */ @Handler - @SuppressWarnings("PMD.AvoidSynchronizedStatement") + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) public void onGuestAgentCommand(GuestAgentCommand event) throws IOException { if (qemuChannel() == null) { 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()); @@ -114,4 +138,89 @@ public class GuestAgentClient extends AgentConnector { } } } + + /** + * Shutdown the VM. + * + * @param event the event + */ + @Handler(priority = 200) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onStop(Stop event) { + if (!connected) { + logger.fine(() -> "No guest agent connection," + + " cannot send shutdown command"); + return; + } + + // We have a connection to the guest agent attempt shutdown. + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); + if (waitUntil.isBefore(Instant.now())) { + return; + } + event.suspendHandling(); + suspendedStop = event; + logger.fine(() -> "Attempting shutdown through guest agent," + + " waiting for termination until " + waitUntil); + powerdownTimer = Components.schedule(t -> { + logger.fine(() -> "Powerdown timeout reached."); + synchronized (this) { + powerdownTimer = null; + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + }, waitUntil); + rep().fire(new GuestAgentCommand(new QmpGuestPowerdown())); + } + + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onConfigureQemu(ConfigureQemu event) { + int newTimeout = event.configuration().vm.powerdownTimeout; + if (powerdownTimeout != newTimeout) { + powerdownTimeout = newTimeout; + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer + .reschedule(powerdownStartedAt.plusSeconds(newTimeout)); + } + + } + } + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java index 2e94c14..777478e 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -121,7 +121,7 @@ public abstract class QemuConnector extends Component { // qemu running, open socket fire(new OpenSocketConnection( UnixDomainSocketAddress.of(socketPath)) - .setAssociated(getClass(), this)); + .setAssociated(this, this)); } } @@ -137,21 +137,21 @@ public abstract class QemuConnector extends Component { @Handler public void onClientConnected(ClientConnected event, SocketIOChannel channel) { - event.openEvent().associated(getClass()).ifPresent(qm -> { + event.openEvent().associated(this, getClass()).ifPresent(qc -> { qemuChannel = channel; - channel.setAssociated(getClass(), this); + channel.setAssociated(this, this); channel.setAssociated(Writer.class, new ByteBufferWriter( channel).nativeCharset()); channel.setAssociated(LineCollector.class, new LineCollector() .consumer(line -> { try { - processInput(line); + qc.processInput(line); } catch (IOException e) { throw new UndeclaredThrowableException(e); } })); - socketConnected(); + qc.socketConnected(); }); } @@ -202,11 +202,10 @@ public abstract class QemuConnector extends Component { * 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 -> { + public void onConnectError(ConnectError event) { + event.event().associated(this, getClass()).ifPresent(qc -> { rep.fire(new Stop()); }); } @@ -219,7 +218,7 @@ public abstract class QemuConnector extends Component { */ @Handler public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(getClass()).isEmpty()) { + if (channel.associated(this, getClass()).isEmpty()) { return; } channel.associated(LineCollector.class).ifPresent(collector -> { @@ -243,7 +242,7 @@ public abstract class QemuConnector extends Component { */ @Handler public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(getClass()).ifPresent(qm -> { + channel.associated(this, getClass()).ifPresent(qc -> { qemuChannel = null; }); } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 1de8f60..feeb76a 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java @@ -27,6 +27,7 @@ import java.time.Instant; import java.util.LinkedList; import java.util.Queue; import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpPowerdown; @@ -42,6 +43,7 @@ import org.jgrapes.core.Components.Timer; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.Stop; import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ProcessExited; import org.jgrapes.net.SocketIOChannel; import org.jgrapes.util.events.ConfigurationUpdate; @@ -52,7 +54,6 @@ import org.jgrapes.util.events.ConfigurationUpdate; * 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 QemuMonitor extends QemuConnector { private int powerdownTimeout; @@ -61,6 +62,7 @@ public class QemuMonitor extends QemuConnector { private Stop suspendedStop; private Timer powerdownTimer; private boolean powerdownConfirmed; + private boolean monitorReady; /** * Instantiates a new QEMU monitor. @@ -69,8 +71,6 @@ public class QemuMonitor extends QemuConnector { * @param configDir the config dir * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", - "PMD.ConstructorCallsOverridableMethod" }) public QemuMonitor(Channel componentChannel, Path configDir) throws IOException { super(componentChannel); @@ -99,29 +99,36 @@ public class QemuMonitor extends QemuConnector { */ @Override protected void socketConnected() { - fire(new MonitorCommand(new QmpCapabilities())); + rep().fire(new MonitorCommand(new QmpCapabilities())); } @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); @@ -134,20 +141,11 @@ public class QemuMonitor extends QemuConnector { * @param event the event */ @Handler - @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", - "PMD.AvoidDuplicateLiterals" }) public void onClosed(Closed event, SocketIOChannel channel) { - super.onClosed(event, channel); - channel.associated(QemuMonitor.class).ifPresent(qm -> { - synchronized (this) { - if (powerdownTimer != null) { - powerdownTimer.cancel(); - } - if (suspendedStop != null) { - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } + channel.associated(this, getClass()).ifPresent(qm -> { + super.onClosed(event, channel); + logger.fine(() -> "QMP connection closed."); + monitorReady = false; }); } @@ -158,14 +156,24 @@ public class QemuMonitor extends QemuConnector { * @throws IOException */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidSynchronizedStatement" }) - public void onExecQmpCommand(MonitorCommand event) throws IOException { + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) + public void onMonitorCommand(MonitorCommand event) throws IOException { + // Check prerequisites + if (!monitorReady && !(event.command() instanceof QmpCapabilities)) { + logger.severe(() -> "Premature QMP command (not ready): " + + event.command()); + rep().fire(new Stop()); + return; + } + + // 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()); @@ -187,32 +195,44 @@ public class QemuMonitor extends QemuConnector { @Handler(priority = 100) @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onStop(Stop event) { - if (qemuChannel() != null) { - // We have a connection to Qemu, attempt ACPI shutdown. - event.suspendHandling(); - suspendedStop = event; - - // Attempt powerdown command. If not confirmed, assume - // "hanging" qemu process. - powerdownTimer = Components.schedule(t -> { - // Powerdown not confirmed - logger.fine(() -> "QMP powerdown command has not effect."); - synchronized (this) { - powerdownTimer = null; - if (suspendedStop != null) { - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } - }, Duration.ofSeconds(1)); - logger.fine(() -> "Attempting QMP powerdown."); - powerdownStartedAt = Instant.now(); - fire(new MonitorCommand(new QmpPowerdown())); + if (!monitorReady) { + logger.fine(() -> "Not sending QMP powerdown command" + + " because QMP connection is closed"); + return; } + + // We have a connection to Qemu, attempt ACPI shutdown if time left + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + if (powerdownStartedAt.plusSeconds(powerdownTimeout) + .isBefore(Instant.now())) { + return; + } + event.suspendHandling(); + suspendedStop = event; + + // Send command. If not confirmed, assume "hanging" qemu process. + powerdownTimer = Components.schedule(t -> { + logger.fine(() -> "QMP powerdown command not confirmed"); + synchronized (this) { + powerdownTimer = null; + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + }, Duration.ofSeconds(5)); + logger.fine(() -> "Attempting QMP (ACPI) powerdown."); + rep().fire(new MonitorCommand(new QmpPowerdown())); } /** - * On powerdown event. + * When the powerdown event is confirmed, wait for termination + * or timeout. Termination is detected by the qemu process exiting + * (see {@link #onProcessExited(ProcessExited)}). * * @param event the event */ @@ -226,20 +246,46 @@ public class QemuMonitor extends QemuConnector { } // (Re-)schedule timer as fallback - logger.fine(() -> "QMP powerdown confirmed, waiting..."); + var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); + logger.fine(() -> "QMP powerdown confirmed, waiting for" + + " termination until " + waitUntil); powerdownTimer = Components.schedule(t -> { logger.fine(() -> "Powerdown timeout reached."); synchronized (this) { + powerdownTimer = null; if (suspendedStop != null) { suspendedStop.resumeHandling(); suspendedStop = null; } } - }, powerdownStartedAt.plusSeconds(powerdownTimeout)); + }, waitUntil); powerdownConfirmed = true; } } + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + /** * On configure qemu. * diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java index 9cdc2b5..81a10f9 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java @@ -39,7 +39,6 @@ public class RamController extends Component { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") public RamController(Channel componentChannel) { super(componentChannel); } 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 01c4127..4819dcd 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023,2024 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -57,6 +57,7 @@ import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -156,6 +157,15 @@ import org.jgrapes.util.events.WatchFile; * * success --> Running * + * state Running { + * state Booting + * state Booted + * + * [*] -right-> Booting + * Booting -down-> Booting: VserportChanged[guest agent connected]/fire GetOsinfo + * Booting --> Booted: Osinfo + * } + * * state Terminating { * state terminate <> * state qemuRunning <> @@ -191,13 +201,9 @@ import org.jgrapes.util.events.WatchFile; * */ @SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", - "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods", - "PMD.CouplingBetweenObjects", "PMD.TooManyFields" }) + "PMD.TooManyMethods", "PMD.CouplingBetweenObjects" }) public class Runner extends Component { - private static final String QEMU = "qemu"; - private static final String SWTPM = "swtpm"; - private static final String CLOUD_INIT_IMG = "cloudInitImg"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -211,15 +217,16 @@ public class Runner extends Component { .builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) .build()); private final JsonNode defaults; - @SuppressWarnings("PMD.UseConcurrentHashMap") private final File configFile; private final Path configDir; - private Configuration config = new Configuration(); + private Configuration initialConfig; + private Configuration pendingConfig; private final freemarker.template.Configuration fmConfig; private CommandDefinition swtpmDefinition; private CommandDefinition cloudInitImgDefinition; private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; + private boolean qmpConfigured; private final GuestAgentClient guestAgentClient; private final VmopAgentClient vmopAgentClient; private Integer resetCounter; @@ -241,8 +248,7 @@ public class Runner extends Component { * @param cmdLine the cmd line * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings({ "PMD.SystemPrintln", - "PMD.ConstructorCallsOverridableMethod" }) + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) public Runner(CommandLine cmdLine) throws IOException { yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -301,13 +307,17 @@ public class Runner extends Component { } /** - * On configuration update. + * Process the initial configuration. The initial configuration + * and any subsequent updates will be forwarded to other components + * only when the QMP connection is ready + * (see @link #onQmpConfigured(QmpConfigured)). * * @param event the event */ @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { + logger.fine(() -> "Runner configuratation updated"); var newConf = yamlMapper.convertValue(c, Configuration.class); // Add some values from other sources to configuration @@ -318,55 +328,63 @@ public class Runner extends Component { // Special actions for initial configuration (startup) if (event instanceof InitialConfiguration) { processInitialConfiguration(newConf); - return; } - logger.fine(() -> "Updating configuration"); - rep.fire(new ConfigureQemu(newConf, state)); + + // Check if to be sent immediately or later + if (qmpConfigured) { + rep.fire(new ConfigureQemu(newConf, state)); + } else { + pendingConfig = newConf; + } }); } @SuppressWarnings("PMD.LambdaCanBeMethodReference") private void processInitialConfiguration(Configuration newConfig) { try { - config = newConfig; - if (!config.check()) { + if (!newConfig.check()) { // Invalid configuration, not used, problems already logged. - config = null; + return; } // Prepare firmware files and add to config - setFirmwarePaths(); + setFirmwarePaths(newConfig); // Obtain more context data from template - var tplData = dataFromTemplate(); - swtpmDefinition = Optional.ofNullable(tplData.get(SWTPM)) - .map(d -> new CommandDefinition(SWTPM, d)).orElse(null); + var tplData = dataFromTemplate(newConfig); + initialConfig = newConfig; + + // Configure + swtpmDefinition + = Optional.ofNullable(tplData.get(ProcessName.SWTPM)) + .map(d -> new CommandDefinition(ProcessName.SWTPM, d)) + .orElse(null); logger.finest(() -> swtpmDefinition.toString()); - qemuDefinition = Optional.ofNullable(tplData.get(QEMU)) - .map(d -> new CommandDefinition(QEMU, d)).orElse(null); + qemuDefinition = Optional.ofNullable(tplData.get(ProcessName.QEMU)) + .map(d -> new CommandDefinition(ProcessName.QEMU, d)) + .orElse(null); logger.finest(() -> qemuDefinition.toString()); cloudInitImgDefinition - = Optional.ofNullable(tplData.get(CLOUD_INIT_IMG)) - .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) + = Optional.ofNullable(tplData.get(ProcessName.CLOUD_INIT_IMG)) + .map(d -> new CommandDefinition(ProcessName.CLOUD_INIT_IMG, + d)) .orElse(null); logger.finest(() -> cloudInitImgDefinition.toString()); // Forward some values to child components - qemuMonitor.configure(config.monitorSocket, - config.vm.powerdownTimeout); - configureAgentClient(guestAgentClient, "guest-agent-socket"); - configureAgentClient(vmopAgentClient, "vmop-agent-socket"); + qemuMonitor.configure(initialConfig.monitorSocket, + initialConfig.vm.powerdownTimeout); + guestAgentClient.configureConnection(qemuDefinition.command, + "guest-agent-socket"); + vmopAgentClient.configureConnection(qemuDefinition.command, + "vmop-agent-socket"); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); - // Don't use default configuration - config = null; } } - @SuppressWarnings({ "PMD.CognitiveComplexity", - "PMD.DataflowAnomalyAnalysis" }) - private void setFirmwarePaths() throws IOException { + private void setFirmwarePaths(Configuration config) throws IOException { JsonNode firmware = defaults.path("firmware").path(config.vm.firmware); // Get file for firmware ROM JsonNode codePaths = firmware.path("rom"); @@ -396,7 +414,7 @@ public class Runner extends Component { } } - private JsonNode dataFromTemplate() + private JsonNode dataFromTemplate(Configuration config) throws IOException, TemplateNotFoundException, MalformedTemplateNameException, ParseException, TemplateException, JsonProcessingException, JsonMappingException { @@ -435,6 +453,21 @@ public class Runner extends Component { return yamlMapper.readValue(out.toString(), JsonNode.class); } + /** + * Note ready state and send a {@link ConfigureQemu} event for + * any pending configuration (initial or change). + * + * @param event the event + */ + @Handler + public void onQmpConfigured(QmpConfigured event) { + qmpConfigured = true; + if (pendingConfig != null) { + rep.fire(new ConfigureQemu(pendingConfig, state)); + pendingConfig = null; + } + } + /** * Handle the start event. * @@ -442,7 +475,7 @@ public class Runner extends Component { */ @Handler(priority = 100) public void onStart(Start event) { - if (config == null) { + if (initialConfig == null) { // Missing configuration, fail event.cancel(true); fire(new Stop()); @@ -458,19 +491,19 @@ public class Runner extends Component { try { // Store process id try (var pidFile = Files.newBufferedWriter( - config.runtimeDir.resolve("runner.pid"))) { + initialConfig.runtimeDir.resolve("runner.pid"))) { pidFile.write(ProcessHandle.current().pid() + "\n"); } // Files to watch for - Files.deleteIfExists(config.swtpmSocket); - fire(new WatchFile(config.swtpmSocket)); + Files.deleteIfExists(initialConfig.swtpmSocket); + fire(new WatchFile(initialConfig.swtpmSocket)); // Helper files - var ticket = Optional.ofNullable(config.vm.display) + var ticket = Optional.ofNullable(initialConfig.vm.display) .map(d -> d.spice).map(s -> s.ticket); if (ticket.isPresent()) { - Files.write(config.runtimeDir.resolve("ticket.txt"), + Files.write(initialConfig.runtimeDir.resolve("ticket.txt"), ticket.get().getBytes()); } } catch (IOException e) { @@ -480,36 +513,6 @@ 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. * @@ -522,12 +525,12 @@ public class Runner extends Component { "Runner has been started")); // Start first process(es) qemuLatch.add(QemuPreps.Config); - if (config.vm.useTpm && swtpmDefinition != null) { + if (initialConfig.vm.useTpm && swtpmDefinition != null) { startProcess(swtpmDefinition); qemuLatch.add(QemuPreps.Tpm); } - if (config.cloudInit != null) { - generateCloudInitImg(); + if (initialConfig.cloudInit != null) { + generateCloudInitImg(initialConfig); qemuLatch.add(QemuPreps.CloudInit); } mayBeStartQemu(QemuPreps.Config); @@ -546,7 +549,7 @@ public class Runner extends Component { } } - private void generateCloudInitImg() { + private void generateCloudInitImg(Configuration config) { try { var cloudInitDir = config.dataDir.resolve("cloud-init"); cloudInitDir.toFile().mkdir(); @@ -583,7 +586,7 @@ public class Runner extends Component { private boolean startProcess(CommandDefinition toStart) { logger.info( () -> "Starting process: " + String.join(" ", toStart.command)); - fire(new StartProcess(toStart.command) + rep.fire(new StartProcess(toStart.command) .setAssociated(CommandDefinition.class, toStart)); return true; } @@ -597,7 +600,7 @@ public class Runner extends Component { @Handler public void onFileChanged(FileChanged event) { if (event.change() == Kind.CREATED - && event.path().equals(config.swtpmSocket)) { + && event.path().equals(initialConfig.swtpmSocket)) { // swtpm running, maybe start qemu mayBeStartQemu(QemuPreps.Tpm); } @@ -612,15 +615,13 @@ public class Runner extends Component { * @throws InterruptedException the interrupted exception */ @Handler - @SuppressWarnings({ "PMD.SwitchStmtsShouldHaveDefault", - "PMD.TooFewBranchesForASwitchStatement" }) public void onProcessStarted(ProcessStarted event, ProcessChannel channel) throws InterruptedException { event.startEvent().associated(CommandDefinition.class) .ifPresent(procDef -> { channel.setAssociated(CommandDefinition.class, procDef); try (var pidFile = Files.newBufferedWriter( - config.runtimeDir.resolve(procDef.name + ".pid"))) { + initialConfig.runtimeDir.resolve(procDef.name + ".pid"))) { pidFile.write(channel.process().toHandle().pid() + "\n"); } catch (IOException e) { throw new UndeclaredThrowableException(e); @@ -652,16 +653,6 @@ public class Runner extends Component { .ifPresent(lc -> lc.feed(event))); } - /** - * When the monitor is ready, send QEMU its initial configuration. - * - * @param event the event - */ - @Handler - public void onQmpConfigured(QmpConfigured event) { - rep.fire(new ConfigureQemu(config, state)); - } - /** * Whenever a new QEMU configuration is available, check if it * is supposed to trigger a reset. @@ -780,7 +771,6 @@ public class Runner extends Component { "The VM has been shut down")); } - @SuppressWarnings("PMD.ConfusingArgumentToVarargsMethod") private void shutdown() { if (!Set.of(RunState.TERMINATING, RunState.STOPPED).contains(state)) { fire(new Stop()); @@ -791,7 +781,7 @@ public class Runner extends Component { logger.log(Level.WARNING, e, () -> "Proper shutdown failed."); } - Optional.ofNullable(config).map(c -> c.runtimeDir) + Optional.ofNullable(initialConfig).map(c -> c.runtimeDir) .ifPresent(runtimeDir -> { try { Files.walk(runtimeDir).sorted(Comparator.reverseOrder()) 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 b1580ae..127c070 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -31,6 +31,9 @@ import io.kubernetes.client.openapi.JSON; import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.Optional; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import org.jdrupes.vmoperator.common.Constants.Crd; @@ -54,6 +57,8 @@ import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Components.Timer; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; @@ -61,7 +66,7 @@ import org.jgrapes.core.events.Start; /** * Updates the CR status. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) public class StatusUpdater extends VmDefUpdater { @SuppressWarnings("PMD.FieldNamingConventions") @@ -70,18 +75,20 @@ public class StatusUpdater extends VmDefUpdater { private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); - private long observedGeneration; private boolean guestShutdownStops; private boolean shutdownByGuest; private VmDefinitionStub vmStub; private String loggedInUser; + private BigInteger lastRamValue; + private Instant lastRamChange; + private Timer balloonTimer; + private BigInteger targetRamValue; /** * Instantiates a new status updater. * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public StatusUpdater(Channel componentChannel) { super(componentChannel); attach(new ConsoleTracker(componentChannel)); @@ -121,9 +128,11 @@ public class StatusUpdater extends VmDefUpdater { if (vmDef == null) { return; } - observedGeneration = vmDef.getMetadata().getGeneration(); vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); + status.addProperty(Status.RUNNER_VERSION, Optional.ofNullable( + Runner.class.getPackage().getImplementationVersion()) + .orElse("(unknown)")); status.remove(Status.LOGGED_IN_USER); return status; }); @@ -142,31 +151,16 @@ public class StatusUpdater extends VmDefUpdater { * @throws ApiException */ @Handler - @SuppressWarnings("PMD.AvoidDuplicateLiterals") public void onConfigureQemu(ConfigureQemu event) throws ApiException { guestShutdownStops = event.configuration().guestShutdownStops; loggedInUser = event.configuration().vm.display.loggedInUser; + targetRamValue = event.configuration().vm.currentRam; // Remainder applies only if we have a connection to k8s. if (vmStub == null) { return; } - - // A change of the runner configuration is typically caused - // by a new version of the CR. So we update only if we have - // a new version of the CR. There's one exception: the display - // password is configured by a file, not by the CR. - var vmDef = vmStub.model().orElse(null); - if (vmDef == null) { - return; - } - if (vmDef.metadata().getGeneration() == observedGeneration - && (event.configuration().hasDisplayPassword - || vmDef.statusJson().getAsJsonPrimitive( - Status.DISPLAY_PASSWORD_SERIAL).getAsInt() == -1)) { - return; - } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { @@ -180,7 +174,7 @@ public class StatusUpdater extends VmDefUpdater { from.getMetadata().getGeneration())); updateUserLoggedIn(from); return status; - }, vmDef); + }); } /** @@ -190,8 +184,7 @@ public class StatusUpdater extends VmDefUpdater { * @throws ApiException */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AssignmentInOperand", "PMD.AvoidDuplicateLiterals" }) + @SuppressWarnings({ "PMD.AssignmentInOperand" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { VmDefinition vmDef; @@ -275,7 +268,11 @@ public class StatusUpdater extends VmDefUpdater { } /** - * On ballon change. + * Update the current RAM size in the status. Balloon changes happen + * more than once every second during changes. While this is nice + * to watch, this puts a heavy load on the system. Therefore we + * only update the status once every 15 seconds or when the target + * value is reached. * * @param event the event * @throws ApiException @@ -285,10 +282,45 @@ public class StatusUpdater extends VmDefUpdater { if (vmStub == null) { return; } + Instant now = Instant.now(); + if (lastRamChange == null + || lastRamChange.isBefore(now.minusSeconds(15)) + || event.size().equals(targetRamValue)) { + if (balloonTimer != null) { + balloonTimer.cancel(); + balloonTimer = null; + } + lastRamChange = now; + lastRamValue = event.size(); + updateRam(); + return; + } + + // Save for later processing and maybe start timer + lastRamChange = now; + lastRamValue = event.size(); + if (balloonTimer != null) { + return; + } + final var pipeline = activeEventPipeline(); + balloonTimer = Components.schedule(t -> { + pipeline.submit("Update RAM size", () -> { + try { + updateRam(); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Failed to update ram size: " + e.getMessage()); + } + balloonTimer = null; + }); + }, now.plusSeconds(15)); + } + + private void updateRam() throws ApiException { vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); status.addProperty(Status.RAM, - new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) + new Quantity(new BigDecimal(lastRamValue), Format.BINARY_SI) .toSuffixedString()); return status; }); @@ -389,7 +421,6 @@ public class StatusUpdater extends VmDefUpdater { * @throws ApiException */ @Handler - @SuppressWarnings("PMD.AssignmentInOperand") public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) throws ApiException { vmStub.updateStatus(from -> { @@ -406,7 +437,6 @@ public class StatusUpdater extends VmDefUpdater { * @throws ApiException */ @Handler - @SuppressWarnings("PMD.AssignmentInOperand") public void onVmopAgentLoggedOut(VmopAgentLoggedOut event) throws ApiException { vmStub.updateStatus(from -> { diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java index 4c64ff1..406a0bc 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 @@ -43,7 +43,6 @@ import org.jgrapes.util.events.InitialConfiguration; /** * Updates the CR status. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class VmDefUpdater extends Component { protected String namespace; @@ -125,16 +124,19 @@ public class VmDefUpdater extends Component { protected JsonObject updateCondition(VmDefinition from, String type, boolean state, String reason, String message) { JsonObject status = from.statusJson(); - // Optimize, as we can get this several times + // Avoid redundant updates, as this may be called several times var current = status.getAsJsonArray("conditions").asList().stream() .map(cond -> (JsonObject) cond) .filter(cond -> type.equals(cond.get("type").getAsString())) .findFirst(); - if (current.isPresent() - && current.map(c -> c.get("status").getAsString()) - .map("True"::equals).map(s -> s == state).orElse(false) + var stateUnchanged = current.map(c -> c.get("status").getAsString()) + .map("True"::equals).map(s -> s == state).orElse(false); + if (stateUnchanged && current.map(c -> c.get("reason").getAsString()) - .map(reason::equals).orElse(false)) { + .map(reason::equals).orElse(false) + && current.map(c -> c.get("observedGeneration").getAsLong()) + .map(from.getMetadata().getGeneration()::equals) + .orElse(false)) { return status; } @@ -143,7 +145,9 @@ public class VmDefUpdater extends Component { "status", state ? "True" : "False", "observedGeneration", from.getMetadata().getGeneration(), "reason", reason, - "lastTransitionTime", Instant.now().toString())); + "lastTransitionTime", stateUnchanged + ? current.get().get("lastTransitionTime").getAsString() + : Instant.now().toString())); if (message != null) { condition.put("message", message); } 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..a940d73 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,34 +78,38 @@ 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"); } } @Override - @SuppressWarnings({ "PMD.UnnecessaryReturn", - "PMD.AvoidLiteralsInIfCondition" }) + @SuppressWarnings({ "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 +119,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 +135,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/commands/QmpCapabilities.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java index ffd6ca6..918b7d5 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpCapabilities extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"qmp_capabilities\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java index 158a318..b60b619 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpChangeMedium extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-change-medium\",\"arguments\": {" + "\"id\": \"\",\"filename\": \"\",\"format\": \"raw\"," diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java index f91d702..0db58e2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java @@ -30,8 +30,7 @@ import java.util.logging.Logger; */ public abstract class QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) protected static final ObjectMapper mapper = new ObjectMapper(); /** diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java index 7b1abbd..0e06e34 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpCont extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"cont\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java index 46fba32..a97e6c6 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpDelCpu extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"device_del\", " + "\"arguments\": " + "{ \"id\": 0 } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java new file mode 100644 index 0000000..04110a5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that powers down the guest. + */ +public class QmpGuestPowerdown extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-shutdown"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestPowerdown()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java index 2f9ad55..88a392c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpOpenTray extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-open-tray\",\"arguments\": {" + "\"id\": \"\" } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java index 108a355..dfb7d96 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpPowerdown extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"system_powerdown\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java index 6f87d10..d4fb5cc 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpQueryHotpluggableCpus extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson( "{\"execute\":\"query-hotpluggable-cpus\",\"arguments\":{}}"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java index cc74555..71360cf 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpRemoveMedium extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-remove-medium\",\"arguments\": {" + "\"id\": \"\" } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java index 0bcffc4..5364811 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpReset extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"system_reset\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java index c7f6bed..f9d4c5d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java @@ -28,8 +28,7 @@ import java.math.BigInteger; */ public class QmpSetBalloon extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"balloon\", " + "\"arguments\": " + "{ \"value\": 0 } }"); 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..93e7785 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; /** @@ -47,7 +49,6 @@ public class MonitorEvent extends Event { * @param response the response * @return the optional */ - @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") public static Optional from(JsonNode response) { try { var kind = Kind.valueOf(response.get("event").asText()); @@ -112,4 +113,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(); + } } 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 829cc88..261eebf 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 @@ -26,7 +26,6 @@ import org.jgrapes.core.Event; /** * The Class RunnerStateChange. */ -@SuppressWarnings("PMD.DataClass") public class RunnerStateChange extends Event { /** 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 445d383..e83cf27 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 @@ -32,7 +32,6 @@ import java.util.logging.Logger; */ public final class DataPath { - @SuppressWarnings("PMD.FieldNamingConventions") private static final Logger logger = Logger.getLogger(DataPath.class.getName()); @@ -56,7 +55,6 @@ public final class DataPath { * @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) { @@ -132,7 +130,6 @@ public final class DataPath { @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() 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 f78374c..c6fb101 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 @@ -32,8 +32,7 @@ import java.util.function.Supplier; /** * Utility class for pointing to elements on a Gson (Json) tree. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal", "PMD.GodClass" }) +@SuppressWarnings({ "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal" }) public class GsonPtr { private final JsonElement position; @@ -102,7 +101,7 @@ public class GsonPtr { * @param selectors the selectors * @return the Gson pointer */ - @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) + @SuppressWarnings({ "PMD.PreserveStackTrace" }) public Optional get(Object... selectors) { JsonElement element = position; for (Object sel : selectors) { @@ -146,7 +145,6 @@ public class GsonPtr { * @param cls the cls * @return the result */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) public T getAs(Class cls) { if (cls.isAssignableFrom(position.getClass())) { return cls.cast(position); diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index 91642f1..f30b771 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -57,8 +57,8 @@ import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -111,9 +111,8 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; * users and roles. * */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", - "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods", - "PMD.CyclomaticComplexity" }) +@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.CouplingBetweenObjects", + "PMD.GodClass", "PMD.TooManyMethods", "PMD.CyclomaticComplexity" }) public class VmAccess extends FreeMarkerConlet { private static final String VM_NAME_PROPERTY = "vmName"; @@ -129,6 +128,7 @@ public class VmAccess extends FreeMarkerConlet { private EventPipeline appPipeline; private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private Class preferredIpVersion = Inet4Address.class; private Set syncUsers = Collections.emptySet(); private Set syncRoles = Collections.emptySet(); @@ -166,7 +166,7 @@ public class VmAccess extends FreeMarkerConlet { * * @param event the event */ - @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) + @SuppressWarnings({ "unchecked" }) @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()) @@ -266,7 +266,7 @@ public class VmAccess extends FreeMarkerConlet { public void onConsoleConfigured(ConsoleConfigured event, ConsoleConnection connection) throws InterruptedException, IOException { - @SuppressWarnings({ "unchecked", "PMD.PrematureDeclaration" }) + @SuppressWarnings({ "unchecked" }) final var rendered = (Set) connection.session().get(RENDERED); connection.session().remove(RENDERED); @@ -276,8 +276,7 @@ public class VmAccess extends FreeMarkerConlet { addMissingConlets(event, connection, rendered); } - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", - "PMD.AvoidDuplicateLiterals" }) + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) private void addMissingConlets(ConsoleConfigured event, ConsoleConnection connection, final Set rendered) throws InterruptedException { @@ -405,7 +404,6 @@ public class VmAccess extends FreeMarkerConlet { } @Override - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) protected Set doRenderConlet(RenderConletRequestBase event, ConsoleConnection channel, String conletId, ResourceModel model) throws Exception { @@ -654,10 +652,9 @@ public class VmAccess extends FreeMarkerConlet { * @throws InterruptedException */ @Handler(namedChannels = "manager") - @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", - "PMD.ConfusingArgumentToVarargsMethod" }) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) + @SuppressWarnings({ "PMD.CognitiveComplexity", + "PMD.AvoidInstantiatingObjectsInLoops" }) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) throws IOException, InterruptedException { var vmDef = event.vmDefinition(); @@ -785,12 +782,12 @@ public class VmAccess extends FreeMarkerConlet { switch (event.method()) { case "start": if (perms.contains(VmDefinition.Permission.START)) { - fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + vmChannel.fire(new ModifyVm(vmName, "state", "Running")); } break; case "stop": if (perms.contains(VmDefinition.Permission.STOP)) { - fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); } break; case "reset": @@ -800,7 +797,7 @@ public class VmAccess extends FreeMarkerConlet { break; case "resetConfirmed": if (perms.contains(VmDefinition.Permission.RESET)) { - fire(new ResetVm(vmName), vmChannel); + vmChannel.fire(new ResetVm(vmName)); } break; case "openConsole": @@ -838,7 +835,7 @@ public class VmAccess extends FreeMarkerConlet { } var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), e -> gotPassword(channel, model, vmDef, e)); - fire(pwQuery, vmChannel); + vmChannel.fire(pwQuery); } private void gotPassword(ConsoleConnection channel, ResourceModel model, @@ -846,14 +843,13 @@ public class VmAccess extends FreeMarkerConlet { if (!event.secretAvailable()) { return; } - vmDef.extra().map(xtra -> xtra.connectionFile(event.secret(), - preferredIpVersion, deleteConnectionFile)) + vmDef.extra().connectionFile(event.secret(), + preferredIpVersion, deleteConnectionFile) .ifPresent(cf -> channel.respond(new NotifyConletView(type(), model.getConletId(), "openConsole", cf))); } - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.UseLocaleWithCaseConversions" }) + @SuppressWarnings({ "PMD.UseLocaleWithCaseConversions" }) private void selectResource(NotifyConletModel event, ConsoleConnection channel, ResourceModel model) throws JsonProcessingException, InterruptedException { @@ -880,7 +876,6 @@ public class VmAccess extends FreeMarkerConlet { /** * The Class AccessModel. */ - @SuppressWarnings("PMD.DataClass") public static class ResourceModel extends ConletBaseModel { /** 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 533b2f4..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 @@ -