From 3103452170ccddd8ff67bd9a60f50a8caae1366c Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" <1446020+mnlipp@users.noreply.github.com> Date: Wed, 20 Mar 2024 11:03:09 +0100 Subject: [PATCH] Support for display secrets (#21) --- dev-example/test-vm-display-secret.yaml | 12 + .../jdrupes/vmoperator/common/Constants.java | 3 + .../org/jdrupes/vmoperator/common/K8s.java | 35 +- .../vmoperator/common/K8sDynamicStub.java | 58 ++- .../vmoperator/common/K8sGenericStub.java | 367 +++++++++--------- .../vmoperator/common/K8sObserver.java | 230 +++++++++++ .../vmoperator/common/K8sV1ConfigMapStub.java | 7 +- .../common/K8sV1DeploymentStub.java | 34 +- .../vmoperator/common/K8sV1PodStub.java | 13 +- .../vmoperator/common/K8sV1SecretStub.java | 60 +++ .../common/K8sV1StatefulSetStub.java | 9 +- .../manager/events/ChannelCache.java | 207 ++++++++++ .../manager/events/ChannelManager.java | 303 +++++++++++++++ .../manager/events/DisplaySecretChanged.java | 77 ++++ .../manager/events/VmDefChanged.java | 27 +- .../vmoperator/manager/runnerSts.ftl.yaml | 10 +- .../vmoperator/manager/AbstractMonitor.java | 287 ++++++++++++++ .../jdrupes/vmoperator/manager/Constants.java | 3 + .../vmoperator/manager/Controller.java | 18 +- .../manager/DisplaySecretsMonitor.java | 77 ++++ .../vmoperator/manager/Reconciler.java | 4 +- .../manager/StatefulSetReconciler.java | 8 + .../jdrupes/vmoperator/manager/VmMonitor.java | 183 +++++++++ .../jdrupes/vmoperator/manager/VmWatcher.java | 360 ----------------- .../display-password | 1 + .../runner/qemu/CdMediaController.java | 4 +- .../vmoperator/runner/qemu/Configuration.java | 13 +- .../vmoperator/runner/qemu/CpuController.java | 6 +- .../runner/qemu/DisplayController.java | 117 ++++++ .../vmoperator/runner/qemu/QemuMonitor.java | 14 +- .../vmoperator/runner/qemu/RamController.java | 4 +- .../vmoperator/runner/qemu/Runner.java | 49 ++- .../vmoperator/runner/qemu/StatusUpdater.java | 4 +- .../runner/qemu/commands/QmpCommand.java | 27 ++ .../qemu/commands/QmpSetDisplayPassword.java | 68 ++++ ...gurationUpdate.java => ConfigureQemu.java} | 4 +- .../templates/Standard-VM-latest.ftl.yaml | 6 +- .../jdrupes/vmoperator/vmconlet/VmConlet.java | 30 +- 38 files changed, 2081 insertions(+), 658 deletions(-) create mode 100644 dev-example/test-vm-display-secret.yaml create mode 100644 org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java create mode 100644 org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelCache.java create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java create mode 100644 org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/DisplaySecretChanged.java create mode 100644 org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java create mode 100644 org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretsMonitor.java create mode 100644 org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java delete mode 100644 org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/display-password create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java rename org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/{RunnerConfigurationUpdate.java => ConfigureQemu.java} (93%) diff --git a/dev-example/test-vm-display-secret.yaml b/dev-example/test-vm-display-secret.yaml new file mode 100644 index 0000000..f3b5ccb --- /dev/null +++ b/dev-example/test-vm-display-secret.yaml @@ -0,0 +1,12 @@ +kind: Secret +apiVersion: v1 +metadata: + name: test-vm-display-secret + namespace: vmop-dev + labels: + app.kubernetes.io/name: vm-runner + app.kubernetes.io/instance: test-vm + app.kubernetes.io/component: display-secret +type: Opaque +data: + display-password: dGVzdC12bQ== 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 3ebe29d..d5d457c 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 @@ -26,6 +26,9 @@ public class Constants { /** The Constant APP_NAME. */ public static final String APP_NAME = "vm-runner"; + /** The Constant COMP_DISPLAY_SECRETS. */ + public static final String COMP_DISPLAY_SECRET = "display-secret"; + /** The Constant VM_OP_NAME. */ public static final String VM_OP_NAME = "vm-operator"; 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 e350cf1..2c29fab 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 @@ -44,6 +44,7 @@ import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; +// TODO: Auto-generated Javadoc /** * Helpers for K8s API. */ @@ -74,6 +75,35 @@ public class K8s { return Optional.empty(); } + /** + * Returns a new context with the given version as preferred version. + * + * @param context the context + * @param version the version + * @return the API resource + */ + public static APIResource preferred(APIResource context, String version) { + assert context.getVersions().contains(version); + return new APIResource(context.getGroup(), + context.getVersions(), version, context.getKind(), + context.getNamespaced(), context.getResourcePlural(), + context.getResourceSingular()); + } + + /** + * Return a string representation of the context (API resource). + * + * @param context the context + * @return the string + */ + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public static String toString(APIResource context) { + return (Strings.isNullOrEmpty(context.getGroup()) ? "" + : context.getGroup() + "/") + + context.getPreferredVersion().toUpperCase() + + context.getKind(); + } + /** * Convert Yaml to Json. * @@ -156,6 +186,7 @@ public class K8s { * @param api the api * @param existing the existing * @param update the update + * @return the t * @throws ApiException the api exception */ public static @@ -199,8 +230,10 @@ public class K8s { * * If `type` is not set, set it to "Normal" * * If `regarding` is not set, set it to the given object. * + * @param client the client + * @param object the object * @param event the event - * @throws ApiException + * @throws ApiException the api exception */ @SuppressWarnings("PMD.NPathComplexity") public static void createEvent(ApiClient client, 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 1ab33ca..e6d36c5 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 @@ -18,10 +18,14 @@ package org.jdrupes.vmoperator.common; +import com.google.gson.Gson; import io.kubernetes.client.Discovery.APIResource; import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; import java.io.Reader; +import java.util.Collection; /** * A stub for namespaced custom objects. It uses a dynamic model @@ -47,6 +51,24 @@ public class K8sDynamicStub Class objectListClass, K8sClient client, APIResource context, String namespace, String name) { super(objectClass, objectListClass, client, context, namespace, name); + + // Make sure that we have an adapter for our type + Gson gson = client.getJSON().getGson(); + if (!checkAdapters(client)) { + client.getJSON().setGson(gson.newBuilder() + .registerTypeAdapterFactory( + new K8sDynamicModelTypeAdapterFactory()) + .create()); + } + } + + private boolean checkAdapters(ApiClient client) { + return K8sDynamicModelTypeAdapterFactory.K8sDynamicModelCreator.class + .equals(client.getJSON().getGson().getAdapter(K8sDynamicModel.class) + .getClass()) + && K8sDynamicModelTypeAdapterFactory.K8sDynamicModelsCreator.class + .equals(client.getJSON().getGson() + .getAdapter(K8sDynamicModels.class).getClass()); } /** @@ -83,8 +105,7 @@ public class K8sDynamicStub @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static K8sDynamicStub get(K8sClient client, - APIResource context, String namespace, String name) - throws ApiException { + APIResource context, String namespace, String name) { return K8sGenericStub.get(K8sDynamicModel.class, K8sDynamicModels.class, client, context, namespace, name, K8sDynamicStub::new); } @@ -106,4 +127,37 @@ public class K8sDynamicStub K8sDynamicModels.class, client, context, model, K8sDynamicStub::new); } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace, ListOptions options) + throws ApiException { + return K8sGenericStub.list(K8sDynamicModel.class, + K8sDynamicModels.class, client, context, namespace, options, + K8sDynamicStub::new); + } + + /** + * Get the stubs for the objects in the given namespace. + * + * @param client the client + * @param namespace the namespace + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace) + throws ApiException { + return list(client, context, namespace, new ListOptions()); + } + } \ No newline at end of file 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 30c6699..db68a38 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 @@ -18,21 +18,22 @@ package org.jdrupes.vmoperator.common; -import com.google.gson.Gson; import io.kubernetes.client.Discovery.APIResource; import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.common.KubernetesListObject; import io.kubernetes.client.common.KubernetesObject; import io.kubernetes.client.custom.V1Patch; -import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.Strings; import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.options.GetOptions; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.net.HttpURLConnection; import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedList; +import java.util.List; import java.util.Optional; import java.util.function.Function; @@ -49,145 +50,16 @@ public class K8sGenericStub { protected final K8sClient client; private final GenericKubernetesApi api; - protected final String group; - protected final String version; - protected final String kind; - protected final String plural; + protected final APIResource context; protected final String namespace; protected final String name; /** - * Get a namespaced object stub. If the version in parameter - * `gvk` is an empty string, the stub refers to the first object - * found with matching group and kind. - * - * @param the object type - * @param the object list type - * @param the stub type - * @param objectClass the object class - * @param objectListClass the object list class - * @param client the client - * @param gvk the group, version and kind - * @param namespace the namespace - * @param name the name - * @param provider the provider - * @return the stub if the object exists - * @throws ApiException the api exception - */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops" }) - public static > - R get(Class objectClass, Class objectListClass, - K8sClient client, GroupVersionKind gvk, String namespace, - String name, GenericSupplier provider) - throws ApiException { - var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(), - gvk.getKind()); - if (context.isEmpty()) { - throw new ApiException("No known API for " + gvk.getGroup() - + "/" + gvk.getVersion() + " " + gvk.getKind()); - } - return provider.get(objectClass, objectListClass, client, context.get(), - namespace, name); - } - - /** - * Get a namespaced object stub. - * - * @param the object type - * @param the object list type - * @param the stub type - * @param objectClass the object class - * @param objectListClass the object list class - * @param client the client - * @param context the context - * @param namespace the namespace - * @param name the name - * @param provider the provider - * @return the stub if the object exists - * @throws ApiException the api exception - */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) - public static > - R get(Class objectClass, Class objectListClass, - K8sClient client, APIResource context, String namespace, - String name, GenericSupplier provider) - throws ApiException { - return provider.get(objectClass, objectListClass, client, - context, namespace, name); - } - - /** - * Get a namespaced object stub for a newly created object. - * - * @param the object type - * @param the object list type - * @param the stub type - * @param objectClass the object class - * @param objectListClass the object list class - * @param client the client - * @param context the context - * @param model the model - * @param provider the provider - * @return the stub if the object exists - * @throws ApiException the api exception - */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) - public static > - R create(Class objectClass, Class objectListClass, - K8sClient client, APIResource context, O model, - GenericSupplier provider) throws ApiException { - var api = new GenericKubernetesApi<>(objectClass, objectListClass, - context.getGroup(), context.getPreferredVersion(), - context.getResourcePlural(), client); - api.create(model).throwsApiException(); - return provider.get(objectClass, objectListClass, client, - context, model.getMetadata().getNamespace(), - model.getMetadata().getName()); - } - - /** - * Get the stubs for the objects in the given namespace that match - * the criteria from the given options. - * - * @param the object type - * @param the object list type - * @param the stub type - * @param objectClass the object class - * @param objectListClass the object list class - * @param client the client - * @param context the context - * @param namespace the namespace - * @param options the options - * @param provider the provider - * @return the collection - * @throws ApiException the api exception - */ - public static > - Collection list(Class objectClass, Class objectListClass, - K8sClient client, APIResource context, String namespace, - ListOptions options, SpecificSupplier provider) - throws ApiException { - var api = new GenericKubernetesApi<>(objectClass, objectListClass, - context.getGroup(), context.getPreferredVersion(), - context.getResourcePlural(), client); - var objs = api.list(namespace, options).throwsApiException(); - var result = new ArrayList(); - for (var item : objs.getObject().getItems()) { - result.add( - provider.get(client, namespace, item.getMetadata().getName())); - } - return result; - } - - /** - * Instantiates a new namespaced custom object stub. + * Instantiates a new stub for the object specified. If the object + * exists in the context specified, the version (see + * {@link #version()} is bound to the existing object's version. + * Else the stub is dangling with the version set to the context's + * preferred version. * * @param objectClass the object class * @param objectListClass the object list class @@ -196,35 +68,47 @@ public class K8sGenericStub objectClass, Class objectListClass, K8sClient client, APIResource context, String namespace, String name) { this.client = client; - group = context.getGroup(); - version = context.getPreferredVersion(); - kind = context.getKind(); - plural = context.getResourcePlural(); this.namespace = namespace; this.name = name; - Gson gson = client.getJSON().getGson(); - if (!checkAdapters(client)) { - client.getJSON().setGson(gson.newBuilder() - .registerTypeAdapterFactory( - new K8sDynamicModelTypeAdapterFactory()) - .create()); + // Bind version + var foundVersion = context.getPreferredVersion(); + GenericKubernetesApi testApi = null; + GetOptions mdOpts + = new GetOptions().isPartialObjectMetadataRequest(true); + for (var version : candidateVersions(context)) { + testApi = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + if (testApi.get(namespace, name, mdOpts) + .isSuccess()) { + foundVersion = version; + break; + } } - api = new GenericKubernetesApi<>(objectClass, - objectListClass, group, version, plural, client); + if (foundVersion.equals(context.getPreferredVersion())) { + this.context = context; + } else { + this.context = K8s.preferred(context, foundVersion); + } + + api = Optional.ofNullable(testApi) + .orElseGet(() -> new GenericKubernetesApi<>(objectClass, + objectListClass, group(), version(), plural(), client)); } - private boolean checkAdapters(ApiClient client) { - return K8sDynamicModelTypeAdapterFactory.K8sDynamicModelCreator.class - .equals(client.getJSON().getGson().getAdapter(K8sDynamicModel.class) - .getClass()) - && K8sDynamicModelTypeAdapterFactory.K8sDynamicModelsCreator.class - .equals(client.getJSON().getGson() - .getAdapter(K8sDynamicModels.class).getClass()); + /** + * Gets the context. + * + * @return the context + */ + public APIResource context() { + return context; } /** @@ -233,7 +117,7 @@ public class K8sGenericStub the object type - * @param the object list type - * @param the result type - */ - public interface SpecificSupplier> { - - /** - * Gets a new stub. - * - * @param client the client - * @param namespace the namespace - * @param name the name - * @return the result - */ - R get(K8sClient client, String namespace, String name); - } - @Override @SuppressWarnings("PMD.UseLocaleWithCaseConversions") public String toString() { - return (Strings.isNullOrEmpty(group) ? "" : group + "/") - + version.toUpperCase() + kind + " " + namespace + ":" + name; + return (Strings.isNullOrEmpty(group()) ? "" : group() + "/") + + version().toUpperCase() + kind() + " " + namespace + ":" + name; + } + + /** + * Get a namespaced object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object + * found with matching group and kind. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param gvk the group, version and kind + * @param namespace the namespace + * @param name the name + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) + public static > + R get(Class objectClass, Class objectListClass, + K8sClient client, GroupVersionKind gvk, String namespace, + String name, GenericSupplier provider) + throws ApiException { + var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(), + gvk.getKind()); + if (context.isEmpty()) { + throw new ApiException("No known API for " + gvk.getGroup() + + "/" + gvk.getVersion() + " " + gvk.getKind()); + } + return provider.get(objectClass, objectListClass, client, context.get(), + namespace, name); + } + + /** + * Get a namespaced object stub. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", + "PMD.UseObjectForClearerAPI" }) + public static > + R get(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + String name, GenericSupplier provider) { + return provider.get(objectClass, objectListClass, client, + context, namespace, name); + } + + /** + * Get a namespaced object stub for a newly created object. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param model the model + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", + "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) + public static > + R create(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, O model, + GenericSupplier provider) throws ApiException { + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + api.create(model).throwsApiException(); + return provider.get(objectClass, objectListClass, client, + context, model.getMetadata().getNamespace(), + model.getMetadata().getName()); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param options the options + * @param provider the provider + * @return the collection + * @throws ApiException the api exception + */ + public static > + Collection list(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + ListOptions options, GenericSupplier provider) + throws ApiException { + var result = new ArrayList(); + for (var version : candidateVersions(context)) { + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + var objs = api.list(namespace, options).throwsApiException(); + for (var item : objs.getObject().getItems()) { + result.add(provider.get(objectClass, objectListClass, client, + context, namespace, item.getMetadata().getName())); + } + } + return result; + } + + private static List candidateVersions(APIResource context) { + var result = new LinkedList<>(context.getVersions()); + result.remove(context.getPreferredVersion()); + result.add(0, context.getPreferredVersion()); + return result; } } 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 new file mode 100644 index 0000000..148ce83 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java @@ -0,0 +1,230 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch.Response; +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.function.BiConsumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * An observer that watches namespaced resources in a given context and + * invokes a handler on changes. + * + * @param the object type for the context + * @param the object list type for the context + */ +public class K8sObserver { + + /** + * The type of change reported by {@link Response} as enum. + */ + public enum ResponseType { + ADDED, MODIFIED, DELETED + } + + @SuppressWarnings("PMD.FieldNamingConventions") + protected final Logger logger = Logger.getLogger(getClass().getName()); + + protected final K8sClient client; + protected final GenericKubernetesApi api; + protected final APIResource context; + protected final String namespace; + protected final ListOptions options; + protected final Thread thread; + protected BiConsumer> handler; + protected BiConsumer, Throwable> onTerminated; + + /** + * Create and start a new observer for objects in the given context + * (using preferred version) and namespace with the given options. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param options the options + */ + @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", + "PMD.UseObjectForClearerAPI", "PMD.AvoidCatchingThrowable", + "PMD.CognitiveComplexity" }) + public K8sObserver(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + ListOptions options) { + this.client = client; + this.context = context; + this.namespace = namespace; + this.options = options; + + api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + thread = new Thread(() -> { + try { + logger.info(() -> "Watching " + context.getResourcePlural() + + " (" + context.getPreferredVersion() + ")" + + " 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()); + } + } catch (ApiException e) { + logger.log(Level.FINE, e, () -> "Problem watching" + + " (will retry): " + e.getMessage()); + delayRestart(startedAt); + } + } + if (onTerminated != null) { + onTerminated.accept(this, null); + } + } catch (Throwable e) { + logger.log(Level.SEVERE, e, () -> "Probem watching: " + + e.getMessage()); + if (onTerminated != null) { + onTerminated.accept(this, e); + } + } + }); + thread.setDaemon(true); + } + + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + private void delayRestart(Instant started) { + var runningFor = Duration + .between(started, Instant.now()).toMillis(); + if (runningFor < 5000) { + logger.log(Level.FINE, () -> "Waiting... "); + try { + Thread.sleep(5000 - runningFor); + } catch (InterruptedException e1) { // NOPMD + // Retry + } + logger.log(Level.FINE, () -> "Retrying"); + } + } + + /** + * Sets the handler. + * + * @param handler the handler + * @return the observer + */ + public K8sObserver + handler(BiConsumer> handler) { + this.handler = handler; + return this; + } + + /** + * Sets a function to invoke if the observer terminates. First argument + * is this observer, the second is the throwable that caused the + * abnormal termination or `null` if the observer was terminated + * by {@link #stop()}. + * + * @param onTerminated the on terminated + * @return the observer + */ + public K8sObserver onTerminated( + BiConsumer, Throwable> onTerminated) { + this.onTerminated = onTerminated; + return this; + } + + /** + * Start the observer. + * + * @return the observer + */ + public K8sObserver start() { + if (handler == null) { + throw new IllegalStateException("No handler defined"); + } + thread.start(); + return this; + } + + /** + * Stops the observer. + * + * @return the observer + */ + public K8sObserver stop() { + thread.interrupt(); + return this; + } + + /** + * Returns the client. + * + * @return the client + */ + public K8sClient client() { + return client; + } + + /** + * Returns the context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Returns the observed namespace. + * + * @return the namespace + */ + public String getNamespace() { + return namespace; + } + + /** + * Returns the options for object selection. + * + * @return the list options + */ + public ListOptions options() { + return options; + } + + @Override + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public String toString() { + return "Observer for " + K8s.toString(context) + " " + namespace; + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java index 58a9516..c85726e 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java @@ -30,6 +30,9 @@ import java.util.List; public class K8sV1ConfigMapStub extends K8sGenericStub { + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "ConfigMap", true, "configmaps", "configmap"); + /** * Instantiates a new stub. * @@ -40,9 +43,7 @@ public class K8sV1ConfigMapStub protected K8sV1ConfigMapStub(K8sClient client, String namespace, String name) { super(V1ConfigMap.class, V1ConfigMapList.class, client, - new APIResource("", List.of("v1"), "v1", "ConfigMap", true, - "configmaps", "configmap"), - namespace, name); + CONTEXT, namespace, name); } /** 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 049363d..16e5c82 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 @@ -33,6 +33,10 @@ import java.util.Optional; public class K8sV1DeploymentStub extends K8sGenericStub { + /** The deployment's context. */ + public static final APIResource CONTEXT = new APIResource("apps", + List.of("v1"), "v1", "Pod", true, "deployments", "deployment"); + /** * Instantiates a new stub. * @@ -43,22 +47,7 @@ public class K8sV1DeploymentStub protected K8sV1DeploymentStub(K8sClient client, String namespace, String name) { super(V1Deployment.class, V1DeploymentList.class, client, - new APIResource("apps", List.of("v1"), "v1", "Pod", true, - "deployments", "deployment"), - namespace, name); - } - - /** - * Gets the stub for the given namespace and name. - * - * @param client the client - * @param namespace the namespace - * @param name the name - * @return the deployment stub - */ - public static K8sV1DeploymentStub get(K8sClient client, String namespace, - String name) { - return new K8sV1DeploymentStub(client, namespace, name); + CONTEXT, namespace, name); } /** @@ -74,4 +63,17 @@ public class K8sV1DeploymentStub + "\", \"value\": " + replicas + "}]"), client.defaultPatchOptions()); } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the deployment stub + */ + public static K8sV1DeploymentStub get(K8sClient client, String namespace, + String name) { + return new K8sV1DeploymentStub(client, namespace, name); + } } \ No newline at end of file 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 fe47a0f..b3da776 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 @@ -32,6 +32,7 @@ import java.util.List; @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1PodStub extends K8sGenericStub { + /** The pods' context. */ public static final APIResource CONTEXT = new APIResource("", List.of("v1"), "v1", "Pod", true, "pods", "pod"); @@ -72,7 +73,17 @@ public class K8sV1PodStub extends K8sGenericStub { public static Collection list(K8sClient client, String namespace, ListOptions options) throws ApiException { return K8sGenericStub.list(V1Pod.class, V1PodList.class, client, - CONTEXT, namespace, options, K8sV1PodStub::new); + CONTEXT, namespace, options, K8sV1PodStub::getGeneric); + } + + /** + * Provide {@link GenericSupplier}. + */ + @SuppressWarnings("PMD.UnusedFormalParameter") + private static K8sV1PodStub getGeneric(Class objectClass, + Class objectListClass, K8sClient client, + APIResource context, String namespace, String name) { + return new K8sV1PodStub(client, namespace, name); } } \ No newline at end of file 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 new file mode 100644 index 0000000..d2fcaf3 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretList; +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"), + "v1", "Secret", true, "secrets", "secret"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1SecretStub(K8sClient client, String namespace, + String name) { + super(V1Secret.class, V1SecretList.class, client, + CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the config map stub + */ + public static K8sV1SecretStub get(K8sClient client, String namespace, + String name) { + return new K8sV1SecretStub(client, namespace, name); + } +} \ No newline at end of file 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 13462b9..b918725 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 @@ -30,6 +30,11 @@ import java.util.List; public class K8sV1StatefulSetStub extends K8sGenericStub { + /** The stateful sets' context */ + public static final APIResource CONTEXT + = new APIResource("apps", List.of("v1"), "v1", "StatefulSet", true, + "statefulsets", "statefulset"); + /** * Instantiates a new stub. * @@ -39,9 +44,7 @@ public class K8sV1StatefulSetStub */ protected K8sV1StatefulSetStub(K8sClient client, String namespace, String name) { - super(V1StatefulSet.class, V1StatefulSetList.class, client, - new APIResource("apps", List.of("v1"), "v1", "StatefulSet", true, - "statefulsets", "statefulset"), + super(V1StatefulSet.class, V1StatefulSetList.class, client, CONTEXT, namespace, name); } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelCache.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelCache.java new file mode 100644 index 0000000..fc8df7a --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelCache.java @@ -0,0 +1,207 @@ +/* + * 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 java.lang.ref.WeakReference; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.jgrapes.core.Channel; + +/** + * A channel manager that tracks mappings from a key to a channel using + * "add/remove" (or "open/close") events and the channels on which they + * are delivered. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelCache { + + private final Map> channels = new ConcurrentHashMap<>(); + + /** + * Helper + */ + @SuppressWarnings("PMD.ShortClassName") + private static class Data { + public WeakReference channel; + public A associated; + + /** + * Instantiates a new value. + * + * @param channel the channel + */ + public Data(C channel) { + this.channel = new WeakReference<>(channel); + } + } + + /** + * Combines the channel and the associated data. + * + * @param the generic type + * @param the generic type + */ + @SuppressWarnings("PMD.ShortClassName") + public static class Both { + + /** The channel. */ + public C channel; + + /** The associated. */ + public A associated; + + /** + * Instantiates a new both. + * + * @param channel the channel + * @param associated the associated + */ + public Both(C channel, A associated) { + super(); + this.channel = channel; + this.associated = associated; + } + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> both(K key) { + synchronized (channels) { + var value = channels.get(key); + if (value == null) { + return Optional.empty(); + } + var channel = value.channel.get(); + if (channel == null) { + // Cleanup old reference + channels.remove(key); + return Optional.empty(); + } + return Optional.of(new Both<>(channel, value.associated)); + } + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelCache put(K key, C channel, A associated) { + Data data = new Data<>(channel); + data.associated = associated; + channels.put(key, data); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelCache put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Returns the channel registered for the key or an empty optional + * if no mapping exists. + * + * @param key the key + * @return the optional + */ + public Optional channel(K key) { + return both(key).map(b -> b.channel); + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelCache associate(K key, A data) { + synchronized (channels) { + Optional.ofNullable(channels.get(key)) + .ifPresent(v -> v.associated = data); + } + return this; + } + + /** + * Return the data associated with the entry for the channel. + * + * @param key the key + * @return the data + */ + public Optional associated(K key) { + return both(key).map(b -> b.associated); + } + + /** + * Returns all associated data. + * + * @return the collection + */ + public Collection associated() { + synchronized (channels) { + return channels.values().stream() + .filter(v -> v.channel.get() != null && v.associated != null) + .map(v -> v.associated).toList(); + } + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + synchronized (channels) { + channels.remove(name); + } + } + + /** + * Returns all known keys. + * + * @return the sets the + */ + public Set keys() { + return channels.keySet(); + } +} 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 new file mode 100644 index 0000000..eb27ea0 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java @@ -0,0 +1,303 @@ +/* + * 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 java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.jgrapes.core.Channel; + +/** + * A channel manager that maintains mappings from a key to a channel. + * As a convenience, it is possible to additionally associate arbitrary + * data with the entry (and thus with the channel). + * + * The manager should be used by a component that defines channels for + * housekeeping. It can be shared between this component and another + * component, preferably using the {@link #fixed()} view for the + * second component. Alternatively, the second component can use a + * {@link ChannelCache} to track the mappings using events. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelManager { + + private final Map> channels = new ConcurrentHashMap<>(); + private final Function supplier; + private ChannelManager readOnly; + + /** + * Combines the channel and the associated data. + * + * @param the generic type + * @param the generic type + */ + @SuppressWarnings("PMD.ShortClassName") + public static class Both { + + /** The channel. */ + public C channel; + + /** The associated. */ + public A associated; + + /** + * Instantiates a new both. + * + * @param channel the channel + * @param associated the associated + */ + public Both(C channel, A associated) { + super(); + this.channel = channel; + this.associated = associated; + } + } + + /** + * Instantiates a new channel manager. + * + * @param supplier the supplier that creates new channels + */ + public ChannelManager(Function supplier) { + this.supplier = supplier; + } + + /** + * Instantiates a new channel manager without a default supplier. + */ + public ChannelManager() { + this(k -> null); + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> both(K key) { + synchronized (channels) { + return Optional.ofNullable(channels.get(key)); + } + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelManager put(K key, C channel, A associated) { + channels.put(key, new Both<>(channel, associated)); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelManager put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Returns the channel registered for the key or an empty optional + * if no mapping exists. + * + * @param key the key + * @return the optional + */ + public Optional channel(K key) { + return both(key).map(b -> b.channel); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the supplier passed to the constructor if it doesn't exist yet. + * + * @param key the key + * @return the channel + */ + public Optional getChannel(K key) { + return getChannel(key, supplier); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the given supplier if it doesn't exist yet. + * + * @param key the key + * @param supplier the supplier + * @return the channel + */ + @SuppressWarnings({ "PMD.AssignmentInOperand", + "PMD.DataflowAnomalyAnalysis" }) + public Optional getChannel(K key, Function supplier) { + synchronized (channels) { + return Optional + .of(Optional.ofNullable(channels.get(key)) + .map(v -> v.channel) + .orElseGet(() -> { + var channel = supplier.apply(key); + channels.put(key, new Both<>(channel, null)); + return channel; + })); + } + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelManager associate(K key, A data) { + synchronized (channels) { + Optional.ofNullable(channels.get(key)) + .ifPresent(v -> v.associated = data); + } + return this; + } + + /** + * Return the data associated with the entry for the channel. + * + * @param key the key + * @return the data + */ + public Optional associated(K key) { + return both(key).map(b -> b.associated); + } + + /** + * Returns all associated data. + * + * @return the collection + */ + public Collection associated() { + synchronized (channels) { + return channels.values().stream() + .filter(v -> v.associated != null) + .map(v -> v.associated).toList(); + } + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + synchronized (channels) { + channels.remove(name); + } + } + + /** + * Returns all known keys. + * + * @return the sets the + */ + public Set keys() { + return channels.keySet(); + } + + /** + * Returns a read only view of this channel manager. The methods + * that usually create a new entry refrain from doing so. The + * methods that change the value of channel and {@link #remove(String)} + * do nothing. The associated data, however, can still be changed. + * + * @return the channel manager + */ + public ChannelManager fixed() { + if (readOnly == null) { + readOnly = new ChannelManager<>(supplier) { + + @Override + public Optional> both(K key) { + return ChannelManager.this.both(key); + } + + @Override + public ChannelManager put(K key, C channel, + A associated) { + return associate(key, associated); + } + + @Override + public Optional getChannel(K key) { + return ChannelManager.this.channel(key); + } + + @Override + public Optional getChannel(K key, Function supplier) { + return ChannelManager.this.channel(key); + } + + @Override + public ChannelManager associate(K key, A data) { + return ChannelManager.this.associate(key, data); + } + + @Override + public Optional associated(K key) { + return ChannelManager.this.associated(key); + } + + @Override + public Collection associated() { + return ChannelManager.this.associated(); + } + + @Override + public void remove(String name) { + // Do nothing + } + + @Override + public Set keys() { + return ChannelManager.this.keys(); + } + + @Override + public ChannelManager fixed() { + return ChannelManager.this.fixed(); + } + + }; + } + return readOnly; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/DisplaySecretChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/DisplaySecretChanged.java new file mode 100644 index 0000000..69dabe6 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/DisplaySecretChanged.java @@ -0,0 +1,77 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import io.kubernetes.client.openapi.models.V1Secret; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates that a display secret has changed. + */ +@SuppressWarnings("PMD.DataClass") +public class DisplaySecretChanged extends Event { + + private final ResponseType type; + private final V1Secret secret; + + /** + * Initializes a new display secret changed event. + * + * @param type the type + * @param secret the secret + */ + public DisplaySecretChanged(ResponseType type, V1Secret secret) { + this.type = type; + this.secret = secret; + } + + /** + * Returns the type. + * + * @return the type + */ + public ResponseType type() { + return type; + } + + /** + * Gets the secret. + * + * @return the secret + */ + public V1Secret secret() { + return secret; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(secret.getMetadata().getName()).append(' ').append(type); + if (channels() != null) { + builder.append(", channels="); + builder.append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java index e9c9ca1..d4a1aa9 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java @@ -18,8 +18,8 @@ package org.jdrupes.vmoperator.manager.events; -import io.kubernetes.client.openapi.models.V1APIResource; import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sObserver; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -34,16 +34,8 @@ import org.jgrapes.core.Event; @SuppressWarnings("PMD.DataClass") public class VmDefChanged extends Event { - /** - * The type of change. - */ - public enum Type { - ADDED, MODIFIED, DELETED - } - - private final Type type; + private final K8sObserver.ResponseType type; private final boolean specChanged; - private final V1APIResource crd; private final K8sDynamicModel vmDef; /** @@ -51,14 +43,12 @@ public class VmDefChanged extends Event { * * @param type the type * @param specChanged the spec part changed - * @param crd the crd * @param vmDefinition the VM definition */ - public VmDefChanged(Type type, boolean specChanged, V1APIResource crd, + public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged, K8sDynamicModel vmDefinition) { this.type = type; this.specChanged = specChanged; - this.crd = crd; this.vmDef = vmDefinition; } @@ -67,7 +57,7 @@ public class VmDefChanged extends Event { * * @return the type */ - public Type type() { + public K8sObserver.ResponseType type() { return type; } @@ -78,15 +68,6 @@ public class VmDefChanged extends Event { return specChanged; } - /** - * Returns the Crd. - * - * @return the v 1 API resource - */ - public V1APIResource crd() { - return crd; - } - /** * Returns the object. * diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml index 2e5712b..3d4a316 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml @@ -126,8 +126,14 @@ spec: # hostPath: # path: /sys/fs/cgroup - name: config - configMap: - name: ${ cr.metadata.name.asString } + projected: + sources: + - configMap: + name: ${ cr.metadata.name.asString } + <#if displaySecret??> + - secret: + name: ${ displaySecret } + - name: vmop-image-repository persistentVolumeClaim: claimName: vmop-image-repository 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 new file mode 100644 index 0000000..e78a5e0 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java @@ -0,0 +1,287 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.manager.events.ChannelManager; +import org.jdrupes.vmoperator.manager.events.Exit; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.Components; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * A base class for monitoring VM related resources. + * + * @param the object type for the context + * @param the object list type for the context + */ +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" }) +public abstract class AbstractMonitor extends Component { + + private final Class objectClass; + private final Class objectListClass; + private K8sClient client; + private APIResource context; + private String namespace; + private ListOptions options = new ListOptions(); + private final AtomicInteger observerCounter = new AtomicInteger(0); + private ChannelManager channelManager; + private boolean channelManagerMaster; + + /** + * Initializes the instance. + * + * @param componentChannel the component channel + */ + protected AbstractMonitor(Channel componentChannel, Class objectClass, + Class objectListClass) { + super(componentChannel); + this.objectClass = objectClass; + this.objectListClass = objectListClass; + } + + /** + * Return the client. + * + * @return the client + */ + public K8sClient client() { + return client; + } + + /** + * Sets the client to be used. + * + * @param client the client + * @return the abstract monitor + */ + public AbstractMonitor client(K8sClient client) { + this.client = client; + return this; + } + + /** + * Return the observed namespace. + * + * @return the namespace + */ + public String namespace() { + return namespace; + } + + /** + * Sets the namespace to be observed. + * + * @param namespace the namespaceToWatch to set + * @return the abstract monitor + */ + public AbstractMonitor namespace(String namespace) { + this.namespace = namespace; + return this; + } + + /** + * Returns the options for selecting the objects to observe. + * + * @return the options + */ + public ListOptions options() { + return options; + } + + /** + * Sets the options for selecting the objects to observe. + * + * @param options the options to set + * @return the abstract monitor + */ + public AbstractMonitor options(ListOptions options) { + this.options = options; + return this; + } + + /** + * Returns the observed context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Sets the context to observe. + * + * @param context the context + * @return the abstract monitor + */ + public AbstractMonitor context(APIResource context) { + this.context = context; + return this; + } + + /** + * Returns the channel manager. + * + * @return the context + */ + public ChannelManager channelManager() { + return channelManager; + } + + /** + * Sets the channel manager. + * + * @param channelManager the channel manager + * @return the abstract monitor + */ + public AbstractMonitor + channelManager(ChannelManager channelManager) { + this.channelManager = channelManager; + return this; + } + + /** + * Looks for a key "namespace" in the configuration and, if found, + * sets the namespace to its value. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(Components.manager(parent()).componentPath()) + .ifPresent(c -> { + if (c.containsKey("namespace")) { + namespace = (String) c.get("namespace"); + } + }); + } + + /** + * Handle the start event. Configures the namespace invokes + * {@link #prepareMonitoring()} and starts the observers. + * + * @param event the event + */ + @Handler(priority = 10) + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onStart(Start event) { + try { + // Get namespace + if (namespace == null) { + var path = Path + .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); + if (Files.isReadable(path)) { + namespace + = Files.lines(path).findFirst().orElse(null); + } + } + + // Additional preparations by derived class + prepareMonitoring(); + assert client != null; + assert context != null; + assert namespace != null; + logger.fine(() -> "Observing " + K8s.toString(context) + + " objects in " + namespace); + + // Monitor all versions + for (var version : context.getVersions()) { + createObserver(version); + } + registerAsGenerator(); + } catch (IOException | ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot watch VMs, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + private void createObserver(String version) { + observerCounter.incrementAndGet(); + new K8sObserver<>(objectClass, objectListClass, client, + K8s.preferred(context, version), namespace, options) + .handler((c, r) -> { + handleChange(c, r); + if (ResponseType.valueOf(r.type) == ResponseType.DELETED + && channelManagerMaster) { + channelManager.remove(r.object.getMetadata().getName()); + } + }).onTerminated((o, t) -> { + if (observerCounter.decrementAndGet() == 0) { + unregisterAsGenerator(); + } + // Exception has been logged already + if (t != null) { + fire(new Stop()); + } + }).start(); + } + + /** + * Invoked by {@link #onStart(Start)} after the namespace has + * been configured and before starting the observer. + * + * @throws IOException Signals that an I/O exception has occurred. + * @throws ApiException the api exception + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void prepareMonitoring() throws IOException, ApiException { + // To be overridden by derived class. + } + + /** + * Handle an observed change. + * + * @param client the client + * @param change the change + */ + protected abstract void handleChange(K8sClient client, Response change); + + /** + * Returns the {@link Channel} for the given name. + * + * @param name the name + * @return the channel used for events related to the specified object + */ + protected Optional channel(String name) { + return channelManager.getChannel(name); + } +} 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 2ef4199..17e74bf 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 @@ -23,6 +23,9 @@ package org.jdrupes.vmoperator.manager; */ public class Constants extends org.jdrupes.vmoperator.common.Constants { + /** The Constant COMP_DISPLAY_SECRET. */ + public static final String COMP_DISPLAY_SECRET = "display-secret"; + /** The Constant STATE_RUNNING. */ public static final String STATE_RUNNING = "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 ee693c2..4b8e5aa 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 @@ -30,6 +30,7 @@ import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.Exit; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.VmChannel; @@ -46,7 +47,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; * [Operator Whitepaper](https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper_v1-0.md#operator-components-in-kubernetes). * * The implementation splits the controller in two components. The - * {@link VmWatcher} and the {@link Reconciler}. The former watches + * {@link VmMonitor} and the {@link Reconciler}. The former watches * the VM definitions (CRs) and generates {@link VmDefChanged} events * when they change. The latter handles the changes and reconciles the * resources in the cluster. @@ -87,7 +88,20 @@ public class Controller extends Component { public Controller(Channel componentChannel) { super(componentChannel); // Prepare component tree - attach(new VmWatcher(channel())); + 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; + } + }); + attach(new VmMonitor(channel()).channelManager(chanMgr)); + attach(new DisplaySecretsMonitor(channel()) + .channelManager(chanMgr.fixed())); attach(new Reconciler(channel())); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretsMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretsMonitor.java new file mode 100644 index 0000000..de51080 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretsMonitor.java @@ -0,0 +1,77 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretList; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; +import org.jdrupes.vmoperator.manager.events.DisplaySecretChanged; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jgrapes.core.Channel; + +/** + * Watches for changes of display secrets. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class DisplaySecretsMonitor + extends AbstractMonitor { + + /** + * Instantiates a new display secrets monitor. + * + * @param componentChannel the component channel + */ + public DisplaySecretsMonitor(Channel componentChannel) { + super(componentChannel, V1Secret.class, V1SecretList.class); + context(K8sV1SecretStub.CONTEXT); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + 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 = channel(vmName).orElse(null); + if (channel == null || channel.vmDefinition() == null) { + return; + } + channel.pipeline().fire(new DisplaySecretChanged( + ResponseType.valueOf(change.type), change.object), channel); + } + +} 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 0683c76..07944cf 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 @@ -45,9 +45,9 @@ import java.util.Map; import java.util.Optional; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; @@ -194,7 +194,7 @@ public class Reconciler extends Component { // Ownership relationships takes care of deletions var defMeta = event.vmDefinition().getMetadata(); - if (event.type() == Type.DELETED) { + if (event.type() == K8sObserver.ResponseType.DELETED) { logger.fine(() -> "VM \"" + defMeta.getName() + "\" deleted"); return; } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java index 8812a93..e642502 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java @@ -28,6 +28,7 @@ import java.io.IOException; import java.io.StringWriter; import java.util.Map; import java.util.logging.Logger; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -69,6 +70,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; throws IOException, TemplateException, ApiException { var metadata = event.vmDefinition().getMetadata(); + // Check if we have a display secret + var dsStub = K8sV1SecretStub.get(channel.client(), + metadata.getNamespace(), metadata.getName() + "-display-secret"); + dsStub.model().ifPresent(m -> { + model.put("displaySecret", m.getMetadata().getName()); + }); + // Combine template and data and parse result var fmTemplate = fmConfig.getTemplate("runnerSts.ftl.yaml"); StringWriter out = new StringWriter(); 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 new file mode 100644 index 0000000..b12dc48 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -0,0 +1,183 @@ +/* + * VM-Operator + * Copyright (C) 2023,2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.util.Watch; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.util.Set; +import java.util.logging.Level; +import java.util.stream.Collectors; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sDynamicModels; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; +import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.jgrapes.core.Channel; + +/** + * Watches for changes of VM definitions. + */ +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) +public class VmMonitor + extends AbstractMonitor { + + /** + * Instantiates a new VM definition watcher. + * + * @param componentChannel the component channel + */ + public VmMonitor(Channel componentChannel) { + super(componentChannel, K8sDynamicModel.class, K8sDynamicModels.class); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + + // Get all our API versions + var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM); + if (ctx.isEmpty()) { + logger.severe(() -> "Cannot get CRD context."); + return; + } + context(ctx.get()); + + // Remove left over resources + purge(); + } + + @SuppressWarnings("PMD.CognitiveComplexity") + private void purge() throws ApiException { + // Get existing CRs (VMs) + var known = K8sDynamicStub.list(client(), context(), namespace()) + .stream().map(stub -> stub.name()).collect(Collectors.toSet()); + ListOptions opts = new ListOptions(); + opts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME); + for (var context : Set.of(K8sV1StatefulSetStub.CONTEXT, + K8sV1ConfigMapStub.CONTEXT)) { + for (var resStub : K8sDynamicStub.list(client(), context, + namespace(), opts)) { + String instance = resStub.model() + .map(m -> m.metadata().getName()).orElse("(unknown)"); + if (!known.contains(instance)) { + resStub.delete(); + } + } + } + } + + @Override + protected void handleChange(K8sClient client, + Watch.Response response) { + V1ObjectMeta metadata = response.object.getMetadata(); + VmChannel channel = channel(metadata.getName()).orElse(null); + if (channel == null) { + return; + } + + // Get full definition and associate with channel as backup + var vmDef = response.object; + if (vmDef.data() == null) { + // ADDED event does not provide data, see + // https://github.com/kubernetes-client/java/issues/3215 + vmDef = getModel(client, vmDef); + } + if (vmDef.data() != null) { + // New data, augment and save + addDynamicData(channel.client(), vmDef); + channel.setVmDefinition(vmDef); + } else { + // Reuse cached + vmDef = channel.vmDefinition(); + } + if (vmDef == null) { + logger.warning( + () -> "Cannot get model for " + response.object.getMetadata()); + return; + } + + // Create and fire event + channel.pipeline() + .fire(new VmDefChanged(ResponseType.valueOf(response.type), + channel.setGeneration( + response.object.getMetadata().getGeneration()), + vmDef), channel); + } + + private K8sDynamicModel getModel(K8sClient client, K8sDynamicModel vmDef) { + try { + return K8sDynamicStub.get(client, context(), namespace(), + vmDef.metadata().getName()).model().orElse(null); + } catch (ApiException e) { + return null; + } + } + + private void addDynamicData(K8sClient client, K8sDynamicModel vmState) { + var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class); + rootNode.addProperty("nodeName", ""); + + // VM definition status changes before the pod terminates. + // This results in pod information being shown for a stopped + // VM which is irritating. So check condition first. + var isRunning = GsonPtr.to(rootNode).to("status", "conditions") + .get(JsonArray.class) + .asList().stream().filter(el -> "Running" + .equals(((JsonObject) el).get("type").getAsString())) + .findFirst().map(el -> "True" + .equals(((JsonObject) el).get("status").getAsString())) + .orElse(false); + if (!isRunning) { + return; + } + var podSearch = new ListOptions(); + podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + + ",app.kubernetes.io/component=" + APP_NAME + + ",app.kubernetes.io/instance=" + vmState.getMetadata().getName()); + try { + var podList + = K8sV1PodStub.list(client, namespace(), podSearch); + for (var podStub : podList) { + rootNode.addProperty("nodeName", + podStub.model().get().getSpec().getNodeName()); + } + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Cannot access node information: " + e.getMessage()); + } + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java deleted file mode 100644 index c074ac2..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java +++ /dev/null @@ -1,360 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2023,2024 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; -import io.kubernetes.client.apimachinery.GroupVersion; -import io.kubernetes.client.apimachinery.GroupVersionKind; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.apis.ApisApi; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIGroup; -import io.kubernetes.client.openapi.models.V1APIResource; -import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery; -import io.kubernetes.client.openapi.models.V1Namespace; -import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.Config; -import io.kubernetes.client.util.Watch; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.options.ListOptions; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; -import org.jdrupes.vmoperator.common.K8sDynamicStub; -import org.jdrupes.vmoperator.common.K8sV1PodStub; -import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; -import org.jdrupes.vmoperator.manager.events.Exit; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; -import org.jdrupes.vmoperator.util.GsonPtr; -import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; -import org.jgrapes.core.Components; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; -import org.jgrapes.core.events.Stop; -import org.jgrapes.util.events.ConfigurationUpdate; - -/** - * Watches for changes of VM definitions. - */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) -public class VmWatcher extends Component { - - private String namespaceToWatch; - private final Map channels = new ConcurrentHashMap<>(); - - /** - * Instantiates a new VM definition watcher. - * - * @param componentChannel the component channel - */ - public VmWatcher(Channel componentChannel) { - super(componentChannel); - } - - /** - * Configure the component. - * - * @param event the event - */ - @Handler - public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured(Components.manager(parent()).componentPath()) - .ifPresent(c -> { - if (c.containsKey("namespace")) { - namespaceToWatch = (String) c.get("namespace"); - } - }); - } - - /** - * Handle the start event. - * - * @param event the event - * @throws IOException - * @throws ApiException - */ - @Handler(priority = 10) - public void onStart(Start event) { - try { - startWatching(); - } catch (IOException | ApiException e) { - logger.log(Level.SEVERE, e, - () -> "Cannot watch VMs, terminating."); - event.cancel(true); - fire(new Exit(1)); - } - } - - private void startWatching() throws IOException, ApiException { - // Get namespace - if (namespaceToWatch == null) { - var path = Path - .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); - if (Files.isReadable(path)) { - namespaceToWatch = Files.lines(path).findFirst().orElse(null); - } - } - // Availability already checked by Controller.onStart - logger.fine(() -> "Watching namespace \"" + namespaceToWatch + "\"."); - - // Get all our API versions - var client = Config.defaultClient(); - var apis = new ApisApi(client).getAPIVersions(); - var vmOpApiVersions = apis.getGroups().stream() - .filter(g -> g.getName().equals(VM_OP_GROUP)).findFirst() - .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream()) - .map(V1GroupVersionForDiscovery::getVersion).toList(); - - // Remove left overs - var coa = new CustomObjectsApi(client); - purge(client, coa, vmOpApiVersions); - - // Start a watcher thread for each existing CRD version. - // The watcher will send us an "ADDED" for each existing VM. - for (var version : vmOpApiVersions) { - coa.getAPIResources(VM_OP_GROUP, version) - .getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())) - .findFirst() - .ifPresent(crd -> watchVmDefs(crd, version)); - } - } - - @SuppressWarnings("PMD.CognitiveComplexity") - private void purge(ApiClient client, CustomObjectsApi coa, - List vmOpApiVersions) throws ApiException { - // Get existing CRs (VMs) - Set known = new HashSet<>(); - for (var version : vmOpApiVersions) { - // Get all known CR instances. - coa.getAPIResources(VM_OP_GROUP, version) - .getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())) - .findFirst() - .ifPresent(crd -> known.addAll(getKnown(client, crd, version))); - } - - ListOptions opts = new ListOptions(); - opts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME); - for (String resource : List.of("apps/v1/statefulsets", - "v1/configmaps", "v1/secrets")) { - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", - "PMD.AvoidDuplicateLiterals" }) - var resParts = new LinkedList<>(List.of(resource.split("/"))); - var group = resParts.size() == 3 ? resParts.poll() : ""; - var version = resParts.poll(); - var plural = resParts.poll(); - // Get resources, selected by label - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var api = new DynamicKubernetesApi(group, version, plural, client); - var listObj = api.list(namespaceToWatch, opts).getObject(); - if (listObj == null) { - continue; - } - for (var obj : listObj.getItems()) { - String instance = obj.getMetadata().getLabels() - .get("app.kubernetes.io/instance"); - if (!known.contains(instance)) { - var resName = obj.getMetadata().getName(); - var result = api.delete(namespaceToWatch, resName); - if (!result.isSuccess()) { - logger.warning(() -> "Cannot cleanup resource \"" - + resName + "\": " + result.toString()); - } - } - } - } - } - - private Set getKnown(ApiClient client, V1APIResource crd, - String version) { - Set result = new HashSet<>(); - var api = new DynamicKubernetesApi(VM_OP_GROUP, version, - crd.getName(), client); - for (var item : api.list(namespaceToWatch).getObject().getItems()) { - if (!VM_OP_KIND_VM.equals(item.getKind())) { - continue; - } - result.add(item.getMetadata().getName()); - } - return result; - } - - private void watchVmDefs(V1APIResource crd, String version) { - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", - "PMD.AvoidCatchingThrowable", "PMD.AvoidCatchingGenericException" }) - var watcher = new Thread(() -> { - try { - logger.info(() -> "Watching objects created from " - + crd.getName() + "." + VM_OP_GROUP + "/" + version); - // Watch sometimes terminates without apparent reason. - while (true) { - Instant startedAt = Instant.now(); - var client = Config.defaultClient(); - var coa = new CustomObjectsApi(client); - var call = coa.listNamespacedCustomObjectCall(VM_OP_GROUP, - version, namespaceToWatch, crd.getName(), null, false, - null, null, null, null, null, null, null, true, null); - try (Watch watch - = Watch.createWatch(client, call, - new TypeToken>() { - }.getType())) { - for (Watch.Response item : watch) { - handleVmDefinitionChange(crd, item); - } - } catch (IOException | ApiException | RuntimeException e) { - logger.log(Level.FINE, e, () -> "Problem watching \"" - + crd.getName() + "\" (will retry): " - + e.getMessage()); - delayRestart(startedAt); - } - } - } catch (Throwable e) { - logger.log(Level.SEVERE, e, () -> "Probem watching: " - + e.getMessage()); - } - fire(new Stop()); - }); - watcher.setDaemon(true); - watcher.start(); - } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private void delayRestart(Instant started) { - var runningFor = Duration - .between(started, Instant.now()).toMillis(); - if (runningFor < 5000) { - logger.log(Level.FINE, () -> "Waiting... "); - try { - Thread.sleep(5000 - runningFor); - } catch (InterruptedException e1) { // NOPMD - // Retry - } - logger.log(Level.FINE, () -> "Retrying"); - } - } - - private void handleVmDefinitionChange(V1APIResource vmsCrd, - Watch.Response vmDefRef) throws ApiException { - V1ObjectMeta metadata = vmDefRef.object.getMetadata(); - VmChannel channel = channels.computeIfAbsent(metadata.getName(), - k -> { - 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; - } - }); - if (channel == null) { - return; - } - - // Get full definition and associate with channel as backup - @SuppressWarnings("PMD.ShortVariable") - var gv = GroupVersion.parse(vmDefRef.object.getApiVersion()); - var vmStub = K8sDynamicStub.get(channel.client(), - new GroupVersionKind(gv.getGroup(), gv.getVersion(), VM_OP_KIND_VM), - metadata.getNamespace(), metadata.getName()); - vmStub.model().ifPresent(vmDef -> { - addDynamicData(channel.client(), vmDef); - channel.setVmDefinition(vmDef); - - // Create and fire event - channel.pipeline().fire(new VmDefChanged(VmDefChanged.Type - .valueOf(vmDefRef.type), - channel - .setGeneration( - vmDefRef.object.getMetadata().getGeneration()), - vmsCrd, vmDef), channel); - }); - } - - private void addDynamicData(K8sClient client, K8sDynamicModel vmState) { - var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class); - rootNode.addProperty("nodeName", ""); - - // VM definition status changes before the pod terminates. - // This results in pod information being shown for a stopped - // VM which is irritating. So check condition first. - var isRunning = GsonPtr.to(rootNode).to("status", "conditions") - .get(JsonArray.class) - .asList().stream().filter(el -> "Running" - .equals(((JsonObject) el).get("type").getAsString())) - .findFirst().map(el -> "True" - .equals(((JsonObject) el).get("status").getAsString())) - .orElse(false); - if (!isRunning) { - return; - } - var podSearch = new ListOptions(); - podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME - + ",app.kubernetes.io/component=" + APP_NAME - + ",app.kubernetes.io/instance=" + vmState.getMetadata().getName()); - try { - var podList - = K8sV1PodStub.list(client, namespaceToWatch, podSearch); - for (var podStub : podList) { - rootNode.addProperty("nodeName", - podStub.model().get().getSpec().getNodeName()); - } - } catch (ApiException e) { - logger.log(Level.WARNING, e, - () -> "Cannot access node information: " + e.getMessage()); - } - } - - /** - * Remove VM channel when VM is deleted. - * - * @param event the event - * @param channel the channel - */ - @Handler(priority = -10_000) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) { - if (event.type() == Type.DELETED) { - channels.remove(event.vmDefinition().getMetadata().getName()); - } - } - -} diff --git a/org.jdrupes.vmoperator.runner.qemu/display-password b/org.jdrupes.vmoperator.runner.qemu/display-password new file mode 100644 index 0000000..97c1abb --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/display-password @@ -0,0 +1 @@ +test-vm \ No newline at end of file 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 b7c960a..53922f2 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 @@ -25,8 +25,8 @@ import java.util.concurrent.ConcurrentHashMap; import org.jdrupes.vmoperator.runner.qemu.commands.QmpChangeMedium; import org.jdrupes.vmoperator.runner.qemu.commands.QmpOpenTray; import org.jdrupes.vmoperator.runner.qemu.commands.QmpRemoveMedium; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; import org.jdrupes.vmoperator.runner.qemu.events.TrayMovedEvent; import org.jgrapes.core.Channel; @@ -68,7 +68,7 @@ public class CdMediaController extends Component { @Handler @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.AvoidInstantiatingObjectsInLoops" }) - public void onConfigureQemu(RunnerConfigurationUpdate event) { + public void onConfigureQemu(ConfigureQemu event) { if (event.state() == State.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 7fc3f95..b37d2c5 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 @@ -46,7 +46,7 @@ public class Configuration implements Dto { @SuppressWarnings("PMD.FieldNamingConventions") protected final Logger logger = Logger.getLogger(getClass().getName()); - /** Configuration timestamp */ + /** Configuration timestamp. */ public Instant asOf; /** The data dir. */ @@ -73,6 +73,9 @@ public class Configuration implements Dto { /** The firmware vars. */ public Path firmwareVars; + /** The display password. */ + public boolean hasDisplayPassword; + /** Optional cloud-init data. */ public CloudInit cloudInit; @@ -87,10 +90,16 @@ public class Configuration implements Dto { * Subsection "cloud-init". */ 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; } @@ -230,6 +239,8 @@ public class Configuration implements Dto { * The Class Display. */ public static class Display implements Dto { + + /** The spice. */ public Spice spice; } 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 f0face4..3071e42 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 @@ -27,11 +27,11 @@ import java.util.Set; import org.jdrupes.vmoperator.runner.qemu.commands.QmpAddCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpDelCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpQueryHotpluggableCpus; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.CpuAdded; import org.jdrupes.vmoperator.runner.qemu.events.CpuDeleted; import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -45,7 +45,7 @@ public class CpuController extends Component { private Integer currentCpus; private Integer desiredCpus; - private RunnerConfigurationUpdate suspendedConfigure; + private ConfigureQemu suspendedConfigure; /** * Instantiates a new CPU controller. @@ -62,7 +62,7 @@ public class CpuController extends Component { * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { + public void onConfigureQemu(ConfigureQemu event) { if (event.state() == State.TERMINATING) { return; } 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 new file mode 100644 index 0000000..882b85b --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -0,0 +1,117 @@ +/* + * 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; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * The Class DisplayController. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class DisplayController extends Component { + + public static final String DISPLAY_PASSWORD_FILE = "display-password"; + private String currentPassword; + private String protocol; + private final Path configDir; + + /** + * Instantiates a new Display controller. + * + * @param componentChannel the component channel + * @param configDir + */ + @SuppressWarnings("PMD.AssignmentToNonFinalStatic") + public DisplayController(Channel componentChannel, Path configDir) { + super(componentChannel); + this.configDir = configDir; + fire(new WatchFile(configDir.resolve(DISPLAY_PASSWORD_FILE))); + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + public void onConfigureQemu(ConfigureQemu event) { + if (event.state() == State.TERMINATING) { + return; + } + protocol + = event.configuration().vm.display.spice != null ? "spice" : null; + updatePassword(); + } + + /** + * Watch for changes of the password file. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.EmptyCatchBlock") + public void onFileChanged(FileChanged event) { + if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) { + updatePassword(); + } + } + + @SuppressWarnings("PMD.DataflowAnomalyAnalysis") + private void updatePassword() { + if (protocol == null) { + return; + } + + String password; + Path dpPath = configDir.resolve(DISPLAY_PASSWORD_FILE); + if (dpPath.toFile().canRead()) { + logger.finer(() -> "Found display password"); + try { + password = Files.readString(dpPath); + } catch (IOException e) { + logger.log(Level.WARNING, e, () -> "Cannot read display" + + " password: " + e.getMessage()); + return; + } + } else { + logger.finer(() -> "No display password"); + return; + } + + if (Objects.equals(this.currentPassword, password)) { + return; + } + logger.fine(() -> "Updating display password"); + fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password))); + } + +} 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 3d22b26..84a99a4 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 @@ -35,12 +35,12 @@ import java.util.logging.Level; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpPowerdown; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.MonitorEvent; import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult; import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.Components; @@ -87,13 +87,16 @@ public class QemuMonitor extends Component { * Instantiates a new qemu monitor. * * @param componentChannel the component channel + * @param configDir the config dir * @throws IOException Signals that an I/O exception has occurred. */ @SuppressWarnings("PMD.AssignmentToNonFinalStatic") - public QemuMonitor(Channel componentChannel) throws IOException { + public QemuMonitor(Channel componentChannel, Path configDir) + throws IOException { super(componentChannel); attach(new RamController(channel())); attach(new CpuController(channel())); + attach(new DisplayController(channel(), configDir)); attach(new CdMediaController(channel())); } @@ -254,17 +257,18 @@ public class QemuMonitor extends Component { * @param event the event */ @Handler + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") public void onExecQmpCommand(MonitorCommand event) { var command = event.command(); + logger.fine(() -> "monitor(out): " + command.toString()); String asText; try { - asText = mapper.writeValueAsString(command.toJson()); + asText = command.asText(); } catch (JsonProcessingException e) { logger.log(Level.SEVERE, e, () -> "Cannot serialize Json: " + e.getMessage()); return; } - logger.fine(() -> "monitor(out): " + asText); synchronized (executing) { monitorChannel.associated(Writer.class).ifPresent(writer -> { try { @@ -343,7 +347,7 @@ public class QemuMonitor extends Component { * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { + public void onConfigureQemu(ConfigureQemu event) { int newTimeout = event.configuration().vm.powerdownTimeout; if (powerdownTimeout != newTimeout) { powerdownTimeout = newTimeout; 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 05fdde6..9cdc2b5 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 @@ -21,8 +21,8 @@ package org.jdrupes.vmoperator.runner.qemu; import java.math.BigInteger; import java.util.Optional; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetBalloon; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -50,7 +50,7 @@ public class RamController extends Component { * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { + public void onConfigureQemu(ConfigureQemu event) { Optional.ofNullable(event.configuration().vm.currentRam) .ifPresent(cr -> { if (currentRam != null && currentRam.equals(cr)) { 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 922f2af..624d949 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 Michael N. Lipp + * Copyright (C) 2023,2024 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -55,10 +55,10 @@ 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.runner.qemu.commands.QmpCont; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.QmpConfigured; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; @@ -143,8 +143,8 @@ import org.jgrapes.util.events.WatchFile; * waitForConfigured: entry/fire QmpCapabilities * waitForConfigured --> configure: QmpConfigured * - * configure: entry/fire RunnerConfigurationUpdate - * configure --> success: RunnerConfigurationUpdate (last handler)/fire cont command + * configure: entry/fire ConfigureQemu + * configure --> success: ConfigureQemu (last handler)/fire cont command * } * * Initializing --> prepFork: Started @@ -207,6 +207,7 @@ public class Runner extends Component { private final JsonNode defaults; @SuppressWarnings("PMD.UseConcurrentHashMap") private final File configFile; + private final Path configDir; private Configuration config = new Configuration(); private final freemarker.template.Configuration fmConfig; private CommandDefinition swtpmDefinition; @@ -240,6 +241,17 @@ public class Runner extends Component { defaults = yamlMapper.readValue( Runner.class.getResourceAsStream("defaults.yaml"), JsonNode.class); + // Get the config + configFile = new File(cmdLine.getOptionValue('c', + "/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml")); + // Don't rely on night config to produce a good exception + // for this simple case + if (!Files.isReadable(configFile.toPath())) { + throw new IOException( + "Cannot read configuration file " + configFile); + } + configDir = configFile.getParentFile().toPath().toRealPath(); + // Configure freemarker library fmConfig = new freemarker.template.Configuration( freemarker.template.Configuration.VERSION_2_3_32); @@ -256,17 +268,8 @@ public class Runner extends Component { attach(new FileSystemWatcher(channel())); attach(new ProcessManager(channel())); attach(new SocketConnector(channel())); - attach(qemuMonitor = new QemuMonitor(channel())); + attach(qemuMonitor = new QemuMonitor(channel(), configDir)); attach(new StatusUpdater(channel())); - - configFile = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml")); - // Don't rely on night config to produce a good exception - // for this simple case - if (!Files.isReadable(configFile.toPath())) { - throw new IOException( - "Cannot read configuration file " + configFile); - } attach(new YamlConfigurationStore(channel(), configFile, false)); fire(new WatchFile(configFile.toPath())); } @@ -294,13 +297,20 @@ public class Runner extends Component { public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { var newConf = yamlMapper.convertValue(c, Configuration.class); + + // Add some values from other sources to configuration newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); + Path dsPath + = configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE); + newConf.hasDisplayPassword = dsPath.toFile().canRead(); + + // Special actions for initial configuration (startup) if (event instanceof InitialConfiguration) { processInitialConfiguration(newConf); return; } logger.fine(() -> "Updating configuration"); - rep.fire(new RunnerConfigurationUpdate(newConf, state)); + rep.fire(new ConfigureQemu(newConf, state)); }); } @@ -388,12 +398,9 @@ public class Runner extends Component { .map(Object::toString).orElse(null)); model.put("firmwareVars", Optional.ofNullable(config.firmwareVars) .map(Object::toString).orElse(null)); + model.put("hasDisplayPassword", config.hasDisplayPassword); model.put("cloudInit", config.cloudInit); model.put("vm", config.vm); - if (Optional.ofNullable(config.vm.display) - .map(d -> d.spice).map(s -> s.ticket).isPresent()) { - model.put("ticketPath", config.runtimeDir.resolve("ticket.txt")); - } // Combine template and data and parse result // (tempting, but no need to use a pipe here) @@ -598,7 +605,7 @@ public class Runner extends Component { */ @Handler public void onQmpConfigured(QmpConfigured event) { - rep.fire(new RunnerConfigurationUpdate(config, state)); + rep.fire(new ConfigureQemu(config, state)); } /** @@ -607,7 +614,7 @@ public class Runner extends Component { * @param event the event */ @Handler(priority = -1000) - public void onConfigureQemu(RunnerConfigurationUpdate event) { + public void onConfigureQemu(ConfigureQemu event) { if (state == State.STARTING) { fire(new MonitorCommand(new QmpCont())); state = State.RUNNING; 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 d55e027..d416fa1 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 @@ -42,9 +42,9 @@ import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; @@ -178,7 +178,7 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler - public void onRunnerConfigurationUpdate(RunnerConfigurationUpdate event) + public void onConfigureQemu(ConfigureQemu event) throws ApiException { guestShutdownStops = event.configuration().guestShutdownStops; 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 8a03ab0..f91d702 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 @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.runner.qemu.commands; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -55,4 +56,30 @@ public abstract class QmpCommand { * @return the json node */ public abstract JsonNode toJson(); + + /** + * Returns the string representation. + * + * @return the string + * @throws JsonProcessingException the JSON processing exception + */ + public String asText() throws JsonProcessingException { + return mapper.writeValueAsString(toJson()); + } + + /** + * Calls {@link #asText()} but suppresses the + * {@link JsonProcessingException}. + * + * @return the string + */ + @Override + public String toString() { + try { + return asText(); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java new file mode 100644 index 0000000..0048b9a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java @@ -0,0 +1,68 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; + +/** + * A {@link QmpCommand} that sets the display password. + */ +public class QmpSetDisplayPassword extends QmpCommand { + + private final String password; + private final String protocol; + + /** + * Instantiates a new command. + * + * @param protocol the protocol + * @param password the password + */ + public QmpSetDisplayPassword(String protocol, String password) { + this.protocol = protocol; + this.password = password; + } + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "set_password"); + ObjectNode args = mapper.createObjectNode(); + cmd.set("arguments", args); + args.set("protocol", new TextNode(protocol)); + args.set("password", new TextNode(password)); + return cmd; + } + + @Override + public String toString() { + try { + var json = toJson(); + ((ObjectNode) json.get("arguments")).set("password", + new TextNode("********")); + return mapper.writeValueAsString(json); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java similarity index 93% rename from org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java rename to org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java index bdb0c73..a8b1e45 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java @@ -31,7 +31,7 @@ import org.jgrapes.core.Event; * on the event and only {@link Event#resumeHandling() resume handling} * when the adaption has completed. */ -public class RunnerConfigurationUpdate extends Event { +public class ConfigureQemu extends Event { private final Configuration configuration; private final State state; @@ -41,7 +41,7 @@ public class RunnerConfigurationUpdate extends Event { * * @param channels the channels */ - public RunnerConfigurationUpdate(Configuration configuration, State state, + public ConfigureQemu(Configuration configuration, State state, Channel... channels) { super(channels); this.state = state; diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index aa7f49e..d100554 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -215,12 +215,8 @@ <#assign spice = vm.display.spice/> # SPICE (display, channels ...) # https://www.linux-kvm.org/page/SPICE - <#if ticketPath??> - - [ "-object", "secret,id=spiceTicket,file=${ ticketPath }" ] - - [ "-spice", "port=${ spice.port?c }\ - <#if spice.ticket??>,password-secret=spiceTicket\ - <#else>,disable-ticketing=on\ + ,disable-ticketing=<#if hasDisplayPassword!false>off<#else>on\ <#if spice.streamingVideo??>,streaming-video=${ spice.streamingVideo }\ ,seamless-migration=on" ] - [ "-chardev", "spicevmc,id=vdagentdev,name=vdagent" ] diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java index 993a844..025ce3c 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java @@ -31,17 +31,16 @@ import java.math.BigInteger; import java.time.Duration; import java.time.Instant; import java.util.HashSet; -import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import org.jdrupes.json.JsonBeanDecoder; import org.jdrupes.json.JsonDecodeException; import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.manager.events.ChannelCache; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; @@ -69,10 +68,8 @@ public class VmConlet extends FreeMarkerConlet { private static final Set MODES = RenderMode.asSet( RenderMode.Preview, RenderMode.View); - private final Map vmInfos - = new ConcurrentHashMap<>(); - private final Map vmChannels - = new ConcurrentHashMap<>(); + private final ChannelCache channelManager = new ChannelCache<>(); private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); private Summary cachedSummary; @@ -162,9 +159,10 @@ public class VmConlet extends FreeMarkerConlet { sendVmInfos = true; } if (sendVmInfos) { - for (var vmInfo : vmInfos.values()) { - var def = JsonBeanDecoder.create(vmInfo.data().toString()) - .readObject(); + for (var vmDef : channelManager.associated()) { + var def + = JsonBeanDecoder.create(vmDef.data().toString()) + .readObject(); channel.respond(new NotifyConletView(type(), conletId, "updateVm", def)); } @@ -187,9 +185,8 @@ public class VmConlet extends FreeMarkerConlet { public void onVmDefChanged(VmDefChanged event, VmChannel channel) throws JsonDecodeException, IOException { var vmName = event.vmDefinition().getMetadata().getName(); - if (event.type() == Type.DELETED) { - vmInfos.remove(vmName); - vmChannels.remove(vmName); + if (event.type() == K8sObserver.ResponseType.DELETED) { + channelManager.remove(vmName); for (var entry : conletIdsByConsoleConnection().entrySet()) { for (String conletId : entry.getValue()) { entry.getKey().respond(new NotifyConletView(type(), @@ -199,8 +196,7 @@ public class VmConlet extends FreeMarkerConlet { } else { var vmDef = new K8sDynamicModel(channel.client().getJSON() .getGson(), convertQuantities(event.vmDefinition().data())); - vmInfos.put(vmName, vmDef); - vmChannels.put(vmName, channel); + channelManager.put(vmName, channel, vmDef); var def = JsonBeanDecoder.create(vmDef.data().toString()) .readObject(); for (var entry : conletIdsByConsoleConnection().entrySet()) { @@ -323,7 +319,7 @@ public class VmConlet extends FreeMarkerConlet { return cachedSummary; } Summary summary = new Summary(); - for (var vmDef : vmInfos.values()) { + for (var vmDef : channelManager.associated()) { summary.totalVms += 1; var status = GsonPtr.to(vmDef.data()).to("status"); summary.usedCpus += status.getAsInt("cpus").orElse(0); @@ -349,7 +345,7 @@ public class VmConlet extends FreeMarkerConlet { throws Exception { event.stop(); var vmName = event.params().asString(0); - var vmChannel = vmChannels.get(vmName); + var vmChannel = channelManager.channel(vmName).orElse(null); if (vmChannel == null) { return; }