diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index de65f8a..f441cbc 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -990,6 +990,30 @@ spec: description: Copied to cloud-init's network-config file. type: object x-kubernetes-preserve-unknown-fields: true + permissions: + type: array + description: >- + Defines permissions for accessing and manipulating the VM. + items: + type: object + description: >- + Permissions can be granted to a user or to a role. + oneOf: + - required: + - user + - required: + - role + properties: + user: + type: string + role: + type: string + may: + type: array + items: + type: string + enum: ["start", "stop", "accessConsole", "*"] + default: [] vm: type: object description: Defines the VM. @@ -1395,6 +1419,9 @@ spec: to the spice server. Defaults to the address of the node that the VM is running on. type: string + generateSecret: + type: boolean + default: true proxyUrl: description: >- If specified, is copied to the generated diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 3c94254..579103d 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -28,6 +28,8 @@ # User admin has role admin admin: - admin + test: + - user # All users have role other "*": - other @@ -37,11 +39,14 @@ # Admins can use all conlets admin: - "*" + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer # Others cannot use any conlet (except login conlet to log out) other: - - --org.jdrupes.vmoperator.vmconlet.VmConlet - org.jgrapes.webconlet.oidclogin.LoginConlet "/ComponentCollector": "/VmViewer": displayResource: preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml index 9829bf6..19b6295 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -54,6 +54,8 @@ patches: # User admin has role admin admin: - admin + test: + - user # All users have role other "*": - other @@ -63,6 +65,8 @@ patches: # Admins can use all conlets admin: - "*" + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer # Others cannot use any conlet (except login conlet to log out) other: - org.jgrapes.webconlet.locallogin.LoginConlet @@ -70,6 +74,8 @@ patches: "/VmViewer": displayResource: preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user - target: group: apps version: v1 diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index 19144d5..e874ef8 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -7,8 +7,17 @@ spec: image: repository: docker-registry.lan.mnl.de path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine + version: latest pullPolicy: Always + permissions: + - user: admin + may: + - "*" + - user: test + may: + - "accessConsole" + resources: requests: cpu: 1 @@ -52,3 +61,4 @@ spec: display: spice: port: 5810 + generateSecret: true diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java new file mode 100644 index 0000000..d21eed4 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java @@ -0,0 +1,197 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.InstanceCreator; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import io.kubernetes.client.openapi.ApiClient; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Type; + +/** + * A factory for creating objects. + * + * @param the generic type + * @param the generic type + */ +public class DynamicTypeAdapterFactory> implements TypeAdapterFactory { + + private final Class objectClass; + private final Class objectListClass; + + /** + * Make sure that this adapter is registered. + * + * @param client the client + */ + public void register(ApiClient client) { + if (!ModelCreator.class + .equals(client.getJSON().getGson().getAdapter(objectClass) + .getClass()) + || !ModelsCreator.class.equals(client.getJSON().getGson() + .getAdapter(objectListClass).getClass())) { + Gson gson = client.getJSON().getGson(); + client.getJSON().setGson(gson.newBuilder() + .registerTypeAdapterFactory(this).create()); + } + } + + /** + * Instantiates a new generic type adapter factory. + * + * @param objectClass the object class + * @param objectListClass the object list class + */ + public DynamicTypeAdapterFactory(Class objectClass, + Class objectListClass) { + this.objectClass = objectClass; + this.objectListClass = objectListClass; + } + + /** + * Creates a type adapter for the given type. + * + * @param the generic type + * @param gson the gson + * @param typeToken the type token + * @return the type adapter or null if the type is not handles by + * this factory + */ + @SuppressWarnings("unchecked") + @Override + public TypeAdapter create(Gson gson, TypeToken typeToken) { + if (TypeToken.get(objectClass).equals(typeToken)) { + return (TypeAdapter) new ModelCreator(gson); + } + if (TypeToken.get(objectListClass).equals(typeToken)) { + return (TypeAdapter) new ModelsCreator(gson); + } + return null; + } + + /** + * The Class ModelCreator. + */ + private class ModelCreator extends TypeAdapter + implements InstanceCreator { + private final Gson delegate; + + /** + * Instantiates a new object state creator. + * + * @param delegate the delegate + */ + public ModelCreator(Gson delegate) { + this.delegate = delegate; + } + + @Override + public O createInstance(Type type) { + try { + return objectClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, null); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + + @Override + public void write(JsonWriter jsonWriter, O state) + throws IOException { + jsonWriter.jsonValue(delegate.toJson(state.data())); + } + + @Override + public O read(JsonReader jsonReader) + throws IOException { + try { + return objectClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, + delegate.fromJson(jsonReader, JsonObject.class)); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + } + + /** + * The Class ModelsCreator. + */ + private class ModelsCreator extends TypeAdapter + implements InstanceCreator { + + private final Gson delegate; + + /** + * Instantiates a new object states creator. + * + * @param delegate the delegate + */ + public ModelsCreator(Gson delegate) { + this.delegate = delegate; + } + + @Override + public L createInstance(Type type) { + try { + return objectListClass + .getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, null); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + + @Override + public void write(JsonWriter jsonWriter, L states) + throws IOException { + jsonWriter.jsonValue(delegate.toJson(states.data())); + } + + @Override + public L read(JsonReader jsonReader) + throws IOException { + try { + return objectListClass + .getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, + delegate.fromJson(jsonReader, JsonObject.class)); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelTypeAdapterFactory.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelTypeAdapterFactory.java deleted file mode 100644 index 33a8e18..0000000 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelTypeAdapterFactory.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2024 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.common; - -import com.google.gson.Gson; -import com.google.gson.InstanceCreator; -import com.google.gson.JsonObject; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; -import java.io.IOException; -import java.lang.reflect.Type; - -/** - * A factory for creating K8sDynamicModel(s) objects. - */ -public class K8sDynamicModelTypeAdapterFactory implements TypeAdapterFactory { - - /** - * Creates a type adapter for the given type. - * - * @param the generic type - * @param gson the gson - * @param typeToken the type token - * @return the type adapter or null if the type is not handles by - * this factory - */ - @SuppressWarnings("unchecked") - @Override - public TypeAdapter create(Gson gson, TypeToken typeToken) { - if (TypeToken.get(K8sDynamicModel.class).equals(typeToken)) { - return (TypeAdapter) new K8sDynamicModelCreator(gson); - } - if (TypeToken.get(K8sDynamicModels.class).equals(typeToken)) { - return (TypeAdapter) new K8sDynamicModelsCreator(gson); - } - return null; - } - - /** - * The Class K8sDynamicModelCreator. - */ - /* default */ class K8sDynamicModelCreator - extends TypeAdapter - implements InstanceCreator { - private final Gson delegate; - - /** - * Instantiates a new object state creator. - * - * @param delegate the delegate - */ - public K8sDynamicModelCreator(Gson delegate) { - this.delegate = delegate; - } - - @Override - public K8sDynamicModel createInstance(Type type) { - return new K8sDynamicModel(delegate, null); - } - - @Override - public void write(JsonWriter jsonWriter, K8sDynamicModel state) - throws IOException { - jsonWriter.jsonValue(delegate.toJson(state.data())); - } - - @Override - public K8sDynamicModel read(JsonReader jsonReader) - throws IOException { - return new K8sDynamicModel(delegate, - delegate.fromJson(jsonReader, JsonObject.class)); - } - } - - /** - * The Class K8sDynamicModelsCreator. - */ - /* default */class K8sDynamicModelsCreator - extends TypeAdapter - implements InstanceCreator { - - private final Gson delegate; - - /** - * Instantiates a new object states creator. - * - * @param delegate the delegate - */ - public K8sDynamicModelsCreator(Gson delegate) { - this.delegate = delegate; - } - - @Override - public K8sDynamicModels createInstance(Type type) { - return new K8sDynamicModels(delegate, null); - } - - @Override - public void write(JsonWriter jsonWriter, K8sDynamicModels states) - throws IOException { - jsonWriter.jsonValue(delegate.toJson(states.data())); - } - - @Override - public K8sDynamicModels read(JsonReader jsonReader) - throws IOException { - return new K8sDynamicModels(delegate, - delegate.fromJson(jsonReader, JsonObject.class)); - } - } - -} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java index 165b10e..d165c10 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java @@ -19,14 +19,8 @@ package org.jdrupes.vmoperator.common; import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; import io.kubernetes.client.common.KubernetesListObject; -import io.kubernetes.client.openapi.Configuration; -import io.kubernetes.client.openapi.models.V1ListMeta; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; /** * Represents a list of Kubernetes objects each of which is @@ -35,11 +29,7 @@ import java.util.Objects; * notably the metadata, is made available through the methods * defined by {@link KubernetesListObject}. */ -public class K8sDynamicModels implements KubernetesListObject { - - private final JsonObject data; - private final V1ListMeta metadata; - private final List items; +public class K8sDynamicModels extends K8sDynamicModelsBase { /** * Initialize the object list using the given JSON data. @@ -48,116 +38,7 @@ public class K8sDynamicModels implements KubernetesListObject { * @param data the data */ public K8sDynamicModels(Gson delegate, JsonObject data) { - this.data = data; - metadata = delegate.fromJson(data.get("metadata"), V1ListMeta.class); - items = new ArrayList<>(); - for (JsonElement e : data.get("items").getAsJsonArray()) { - items.add(new K8sDynamicModel(delegate, e.getAsJsonObject())); - } + super(K8sDynamicModel.class, delegate, data); } - @Override - public String getApiVersion() { - return apiVersion(); - } - - /** - * Gets the API version. (Abbreviated method name for convenience.) - * - * @return the API version - */ - public String apiVersion() { - return data.get("apiVersion").getAsString(); - } - - @Override - public String getKind() { - return kind(); - } - - /** - * Gets the kind. (Abbreviated method name for convenience.) - * - * @return the kind - */ - public String kind() { - return data.get("kind").getAsString(); - } - - @Override - public V1ListMeta getMetadata() { - return metadata; - } - - /** - * Gets the metadata. (Abbreviated method name for convenience.) - * - * @return the metadata - */ - public V1ListMeta metadata() { - return metadata; - } - - /** - * Returns the JSON representation of this object. - * - * @return the JOSN representation - */ - public JsonObject data() { - return data; - } - - @Override - public List getItems() { - return items; - } - - /** - * Sets the api version. - * - * @param apiVersion the new api version - */ - public void setApiVersion(String apiVersion) { - data.addProperty("apiVersion", apiVersion); - } - - /** - * Sets the kind. - * - * @param kind the new kind - */ - public void setKind(String kind) { - data.addProperty("kind", kind); - } - - /** - * Sets the metadata. - * - * @param objectMeta the new metadata - */ - public void setMetadata(V1ListMeta objectMeta) { - data.add("metadata", - Configuration.getDefaultApiClient().getJSON().getGson() - .toJsonTree(objectMeta)); - } - - @Override - public int hashCode() { - return Objects.hash(data); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - K8sDynamicModels other = (K8sDynamicModels) obj; - return Objects.equals(data, other.data); - } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java new file mode 100644 index 0000000..4e21c0e --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java @@ -0,0 +1,174 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.models.V1ListMeta; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Represents a list of Kubernetes objects each of which is + * represented using a JSON data structure. + * Some information that is common to all Kubernetes objects, + * notably the metadata, is made available through the methods + * defined by {@link KubernetesListObject}. + */ +public class K8sDynamicModelsBase + implements KubernetesListObject { + + private final JsonObject data; + private final V1ListMeta metadata; + private final List items; + + /** + * Initialize the object list using the given JSON data. + * + * @param itemClass the item class + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public K8sDynamicModelsBase(Class itemClass, Gson delegate, + JsonObject data) { + this.data = data; + metadata = delegate.fromJson(data.get("metadata"), V1ListMeta.class); + items = new ArrayList<>(); + for (JsonElement e : data.get("items").getAsJsonArray()) { + try { + items.add(itemClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, e.getAsJsonObject())); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException exc) { + throw new IllegalArgumentException(exc); + } + } + } + + @Override + public String getApiVersion() { + return apiVersion(); + } + + /** + * Gets the API version. (Abbreviated method name for convenience.) + * + * @return the API version + */ + public String apiVersion() { + return data.get("apiVersion").getAsString(); + } + + @Override + public String getKind() { + return kind(); + } + + /** + * Gets the kind. (Abbreviated method name for convenience.) + * + * @return the kind + */ + public String kind() { + return data.get("kind").getAsString(); + } + + @Override + public V1ListMeta getMetadata() { + return metadata; + } + + /** + * Gets the metadata. (Abbreviated method name for convenience.) + * + * @return the metadata + */ + public V1ListMeta metadata() { + return metadata; + } + + /** + * Returns the JSON representation of this object. + * + * @return the JOSN representation + */ + public JsonObject data() { + return data; + } + + @Override + public List getItems() { + return items; + } + + /** + * Sets the api version. + * + * @param apiVersion the new api version + */ + public void setApiVersion(String apiVersion) { + data.addProperty("apiVersion", apiVersion); + } + + /** + * Sets the kind. + * + * @param kind the new kind + */ + public void setKind(String kind) { + data.addProperty("kind", kind); + } + + /** + * Sets the metadata. + * + * @param objectMeta the new metadata + */ + public void setMetadata(V1ListMeta objectMeta) { + data.add("metadata", + Configuration.getDefaultApiClient().getJSON().getGson() + .toJsonTree(objectMeta)); + } + + @Override + public int hashCode() { + return Objects.hash(data); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + K8sDynamicModelsBase other = (K8sDynamicModelsBase) obj; + return Objects.equals(data, other.data); + } +} 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 e6d36c5..afed802 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,8 @@ 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; @@ -35,40 +33,23 @@ import java.util.Collection; */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sDynamicStub - extends K8sGenericStub { + extends K8sDynamicStubBase { + + private static DynamicTypeAdapterFactory taf = new K8sDynamicModelTypeAdapterFactory(); /** * Instantiates a new dynamic stub. * - * @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 */ - public K8sDynamicStub(Class objectClass, - Class objectListClass, K8sClient client, + public K8sDynamicStub(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()); + super(K8sDynamicModel.class, K8sDynamicModels.class, taf, client, + context, namespace, name); } /** @@ -88,8 +69,8 @@ public class K8sDynamicStub public static K8sDynamicStub get(K8sClient client, GroupVersionKind gvk, String namespace, String name) throws ApiException { - return K8sGenericStub.get(K8sDynamicModel.class, K8sDynamicModels.class, - client, gvk, namespace, name, K8sDynamicStub::new); + return new K8sDynamicStub(client, apiResource(client, gvk), namespace, + name); } /** @@ -106,8 +87,7 @@ public class K8sDynamicStub "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static K8sDynamicStub get(K8sClient client, APIResource context, String namespace, String name) { - return K8sGenericStub.get(K8sDynamicModel.class, K8sDynamicModels.class, - client, context, namespace, name, K8sDynamicStub::new); + return new K8sDynamicStub(client, context, namespace, name); } /** @@ -125,7 +105,7 @@ public class K8sDynamicStub K8s.yamlToJson(client, yaml)); return K8sGenericStub.create(K8sDynamicModel.class, K8sDynamicModels.class, client, context, model, - K8sDynamicStub::new); + (c, ns, n) -> new K8sDynamicStub(c, context, ns, n)); } /** @@ -143,7 +123,7 @@ public class K8sDynamicStub throws ApiException { return K8sGenericStub.list(K8sDynamicModel.class, K8sDynamicModels.class, client, context, namespace, options, - K8sDynamicStub::new); + (c, ns, n) -> new K8sDynamicStub(c, context, ns, n)); } /** @@ -160,4 +140,18 @@ public class K8sDynamicStub return list(client, context, namespace, new ListOptions()); } + /** + * A factory for creating K8sDynamicModel(s) objects. + */ + public static class K8sDynamicModelTypeAdapterFactory extends + DynamicTypeAdapterFactory { + + /** + * Instantiates a new dynamic model type adapter factory. + */ + public K8sDynamicModelTypeAdapterFactory() { + super(K8sDynamicModel.class, K8sDynamicModels.class); + } + } + } \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java new file mode 100644 index 0000000..44f419c --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java @@ -0,0 +1,51 @@ +/* + * 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; + +/** + * A stub for namespaced custom objects. It uses a dynamic model + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public abstract class K8sDynamicStubBase> extends K8sGenericStub { + + /** + * Instantiates a new dynamic stub. + * + * @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 + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public K8sDynamicStubBase(Class objectClass, + Class objectListClass, DynamicTypeAdapterFactory taf, + K8sClient client, APIResource context, String namespace, + String name) { + super(objectClass, objectListClass, client, context, namespace, name); + taf.register(client); + } +} \ 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 db68a38..f118a17 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 @@ -26,9 +26,11 @@ import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.Strings; import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.KubernetesApiResponse; import io.kubernetes.client.util.generic.options.GetOptions; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; +import io.kubernetes.client.util.generic.options.UpdateOptions; import java.net.HttpURLConnection; import java.util.ArrayList; import java.util.Collection; @@ -228,7 +230,8 @@ public class K8sGenericStub patch(String patchType, V1Patch patch, PatchOptions options) throws ApiException { return K8s - .optional(api.patch(namespace, name, patchType, patch, options)); + .optional(api.patch(namespace, name, patchType, patch, options) + .throwsApiException()); } /** @@ -245,6 +248,30 @@ public class K8sGenericStub update(O object) throws ApiException { + return api.update(object).throwsApiException(); + } + + /** + * Update the object. + * + * @param object the object + * @param options the options + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public KubernetesApiResponse update(O object, UpdateOptions options) + throws ApiException { + return api.update(object, options).throwsApiException(); + } + /** * A supplier for generic stubs. * @@ -258,17 +285,13 @@ public class K8sGenericStub objectClass, Class objectListClass, K8sClient client, - APIResource context, String namespace, String name); + R get(K8sClient client, String namespace, String name); } @Override @@ -278,68 +301,6 @@ public class K8sGenericStub 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. * @@ -366,8 +327,7 @@ public class 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::getGeneric); + CONTEXT, namespace, options, (clnt, nscp, + name) -> new K8sV1PodStub(clnt, nscp, name)); } - - /** - * Provide {@link GenericSupplier}. - */ - @SuppressWarnings({ "PMD.UnusedFormalParameter", - "PMD.UnusedPrivateMethod" }) - 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 index 9a43883..a847d36 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java @@ -25,7 +25,6 @@ import io.kubernetes.client.openapi.models.V1SecretList; import io.kubernetes.client.util.generic.options.ListOptions; import java.util.Collection; import java.util.List; -import org.jdrupes.vmoperator.common.K8sGenericStub.GenericSupplier; /** * A stub for secrets (v1). @@ -62,6 +61,20 @@ public class K8sV1SecretStub extends K8sGenericStub { return new K8sV1SecretStub(client, namespace, name); } + /** + * Creates an object stub from a model. + * + * @param client the client + * @param model the model + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static K8sV1SecretStub create(K8sClient client, V1Secret model) + throws ApiException { + return K8sGenericStub.create(V1Secret.class, + V1SecretList.class, client, CONTEXT, model, K8sV1SecretStub::new); + } + /** * Get the stubs for the objects in the given namespace that match * the criteria from the given options. @@ -75,18 +88,6 @@ public class K8sV1SecretStub extends K8sGenericStub { public static Collection list(K8sClient client, String namespace, ListOptions options) throws ApiException { return K8sGenericStub.list(V1Secret.class, V1SecretList.class, client, - CONTEXT, namespace, options, K8sV1SecretStub::getGeneric); + CONTEXT, namespace, options, K8sV1SecretStub::new); } - - /** - * Provide {@link GenericSupplier}. - */ - @SuppressWarnings({ "PMD.UnusedFormalParameter", - "PMD.UnusedPrivateMethod" }) - private static K8sV1SecretStub getGeneric(Class objectClass, - Class objectListClass, K8sClient client, - APIResource context, 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/K8sV1ServiceStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java index 74f7f61..2157a1d 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java @@ -25,7 +25,6 @@ import io.kubernetes.client.openapi.models.V1ServiceList; import io.kubernetes.client.util.generic.options.ListOptions; import java.util.Collection; import java.util.List; -import org.jdrupes.vmoperator.common.K8sGenericStub.GenericSupplier; /** * A stub for secrets (v1). @@ -75,18 +74,7 @@ public class K8sV1ServiceStub extends K8sGenericStub { public static Collection list(K8sClient client, String namespace, ListOptions options) throws ApiException { return K8sGenericStub.list(V1Service.class, V1ServiceList.class, client, - CONTEXT, namespace, options, K8sV1ServiceStub::getGeneric); + CONTEXT, namespace, options, + (clnt, nscp, name) -> new K8sV1ServiceStub(clnt, nscp, name)); } - - /** - * Provide {@link GenericSupplier}. - */ - @SuppressWarnings({ "PMD.UnusedFormalParameter", - "PMD.UnusedPrivateMethod" }) - private static K8sV1ServiceStub getGeneric(Class objectClass, - Class objectListClass, K8sClient client, - APIResource context, String namespace, String name) { - return new K8sV1ServiceStub(client, namespace, name); - } - } \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java new file mode 100644 index 0000000..fa59c82 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java @@ -0,0 +1,123 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.util.GsonPtr; + +/** + * Represents a VM definition. + */ +@SuppressWarnings("PMD.DataClass") +public class VmDefinitionModel extends K8sDynamicModel { + + /** + * Permissions for accessing and manipulating the VM. + */ + public enum Permission { + START("start"), STOP("stop"), ACCESS_CONSOLE("accessConsole"); + + @SuppressWarnings("PMD.UseConcurrentHashMap") + private static Map reprs = new HashMap<>(); + + static { + for (var value : EnumSet.allOf(Permission.class)) { + reprs.put(value.repr, value); + } + } + + private final String repr; + + Permission(String repr) { + this.repr = repr; + } + + /** + * Create permission from representation in CRD. + * + * @param value the value + * @return the permission + */ + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public static Set parse(String value) { + if ("*".equals(value)) { + return EnumSet.allOf(Permission.class); + } + return Set.of(reprs.get(value)); + } + + @Override + public String toString() { + return repr; + } + } + + /** + * Instantiates a new model from the JSON representation. + * + * @param delegate the gson instance to use for extracting structured data + * @param json the JSON + */ + public VmDefinitionModel(Gson delegate, JsonObject json) { + super(delegate, json); + } + + /** + * Collect all permissions for the given user with the given roles. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + return GsonPtr.to(data()) + .getAsListOf(JsonObject.class, "spec", "permissions") + .stream().filter(p -> GsonPtr.to(p).getAsString("user") + .map(u -> u.equals(user)).orElse(false) + || GsonPtr.to(p).getAsString("role").map(roles::contains) + .orElse(false)) + .map(p -> GsonPtr.to(p).getAsListOf(JsonPrimitive.class, "may") + .stream()) + .flatMap(Function.identity()).map(p -> p.getAsString()) + .map(Permission::parse).map(Set::stream) + .flatMap(Function.identity()).collect(Collectors.toSet()); + } + + /** + * Get the display password serial. + * + * @return the optional + */ + public Optional displayPasswordSerial() { + return GsonPtr.to(status()) + .get(JsonPrimitive.class, "displayPasswordSerial") + .map(JsonPrimitive::getAsLong); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java new file mode 100644 index 0000000..5ac412f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java @@ -0,0 +1,39 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Represents a list of {@link VmDefinitionModel}s. + */ +public class VmDefinitionModels + extends K8sDynamicModelsBase { + + /** + * Initialize the object list using the given JSON data. + * + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public VmDefinitionModels(Gson delegate, JsonObject data) { + super(VmDefinitionModel.class, delegate, data); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java new file mode 100644 index 0000000..49da3e0 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java @@ -0,0 +1,157 @@ +/* + * 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.apimachinery.GroupVersionKind; +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 + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class VmDefinitionStub + extends K8sDynamicStubBase { + + private static DynamicTypeAdapterFactory taf = new VmDefintionModelTypeAdapterFactory(); + + /** + * Instantiates a new stub for VM defintions. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + public VmDefinitionStub(K8sClient client, APIResource context, + String namespace, String name) { + super(VmDefinitionModel.class, VmDefinitionModels.class, taf, client, + context, namespace, name); + } + + /** + * Get a dynamic object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object with + * matching group and kind. + * + * @param client the client + * @param gvk the group, version and kind + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", + "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) + public static VmDefinitionStub get(K8sClient client, + GroupVersionKind gvk, String namespace, String name) + throws ApiException { + return new VmDefinitionStub(client, apiResource(client, gvk), namespace, + name); + } + + /** + * Get a dynamic object stub. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", + "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) + public static VmDefinitionStub get(K8sClient client, + APIResource context, String namespace, String name) { + return new VmDefinitionStub(client, context, namespace, name); + } + + /** + * Creates a stub from yaml. + * + * @param client the client + * @param context the context + * @param yaml the yaml + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static VmDefinitionStub createFromYaml(K8sClient client, + APIResource context, Reader yaml) throws ApiException { + var model = new VmDefinitionModel(client.getJSON().getGson(), + K8s.yamlToJson(client, yaml)); + return K8sGenericStub.create(VmDefinitionModel.class, + VmDefinitionModels.class, client, context, model, + (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); + } + + /** + * 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(VmDefinitionModel.class, + VmDefinitionModels.class, client, context, namespace, options, + (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); + } + + /** + * 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()); + } + + /** + * A factory for creating VmDefinitionModel(s) objects. + */ + public static class VmDefintionModelTypeAdapterFactory extends + DynamicTypeAdapterFactory { + + /** + * Instantiates a new dynamic model type adapter factory. + */ + public VmDefintionModelTypeAdapterFactory() { + super(VmDefinitionModel.class, VmDefinitionModels.class); + } + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/DisplayPasswordChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/DisplayPasswordChanged.java deleted file mode 100644 index 9185bbc..0000000 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/DisplayPasswordChanged.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2024 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager.events; - -import 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 DisplayPasswordChanged 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 DisplayPasswordChanged(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=").append(Channel.toString(channels())); - } - builder.append(']'); - return builder.toString(); - } -} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java index 77dc298..37eddec 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java @@ -19,40 +19,44 @@ package org.jdrupes.vmoperator.manager.events; import java.util.Optional; +import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jgrapes.core.Event; /** - * Gets the current display secret. + * Gets the current display secret and optionally updates it. */ @SuppressWarnings("PMD.DataClass") public class GetDisplayPassword extends Event { - private final String vmName; + private final VmDefinitionModel vmDef; /** * Instantiates a new returns the display secret. * - * @param vmName the vm name + * @param vmDef the vm name */ - public GetDisplayPassword(String vmName) { - this.vmName = vmName; + public GetDisplayPassword(VmDefinitionModel vmDef) { + this.vmDef = vmDef; } /** - * Gets the vm name. + * Gets the vm definition. * - * @return the vm name + * @return the vm definition */ - public String vmName() { - return vmName; + public VmDefinitionModel vmDefinition() { + return vmDef; } /** - * Return the password. Should only be called when the event is completed. + * Return the password. May only be called when the event is completed. * * @return the optional */ public Optional password() { + if (!isDone()) { + throw new IllegalStateException("Event is not done."); + } return currentResults().stream().findFirst(); } } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java index 972693a..46861ce 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jgrapes.core.Channel; import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Subchannel.DefaultSubchannel; @@ -32,7 +32,7 @@ public class VmChannel extends DefaultSubchannel { private final EventPipeline pipeline; private final K8sClient client; - private K8sDynamicModel vmDefinition; + private VmDefinitionModel vmDefinition; private long generation = -1; /** @@ -56,7 +56,7 @@ public class VmChannel extends DefaultSubchannel { * @return the watch channel */ @SuppressWarnings("PMD.LinguisticNaming") - public VmChannel setVmDefinition(K8sDynamicModel definition) { + public VmChannel setVmDefinition(VmDefinitionModel definition) { this.vmDefinition = definition; return this; } @@ -66,7 +66,7 @@ public class VmChannel extends DefaultSubchannel { * * @return the json object */ - public K8sDynamicModel vmDefinition() { + public VmDefinitionModel vmDefinition() { return vmDefinition; } 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 5e93790..a2bafb7 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 org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -36,7 +36,7 @@ public class VmDefChanged extends Event { private final K8sObserver.ResponseType type; private final boolean specChanged; - private final K8sDynamicModel vmDef; + private final VmDefinitionModel vmDef; /** * Instantiates a new VM changed event. @@ -46,7 +46,7 @@ public class VmDefChanged extends Event { * @param vmDefinition the VM definition */ public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged, - K8sDynamicModel vmDefinition) { + VmDefinitionModel vmDefinition) { this.type = type; this.specChanged = specChanged; this.vmDef = vmDefinition; @@ -73,7 +73,7 @@ public class VmDefChanged extends Event { * * @return the object. */ - public K8sDynamicModel vmDefinition() { + public VmDefinitionModel vmDefinition() { return vmDef; } diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index 7dc07a2..a8b67a0 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.5.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.3.0,2)' implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.3.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)' runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.sysinfo:[1.4.0,2)' runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.logviewer:[0.2.0,2)' diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md new file mode 100644 index 0000000..50a3024 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md @@ -0,0 +1,5 @@ +You can use the "puzzle piece" icon on the top right corner of the +page to add display widgets (conlets) to the overview tab. + +Use the "full screen" icon on the top right corner of any +conlet (if available) to get a detailed view. \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md new file mode 100644 index 0000000..e5e4d68 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md @@ -0,0 +1,6 @@ +Verwenden Sie das "Puzzle"-Icon auf der rechten oberen Ecke +der Seite, um Anzeige-Widgets (Conlets) hinzuzufügen. + +Wenn sich in der rechten oberen Ecke eines Conlets ein Vollbild-Icon +befindet, können Sie es verwenden, um eine Detailansicht in einem neuen +Register anzufordern. diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties index ec22a06..6bcc3a2 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties @@ -17,3 +17,4 @@ # consoleTitle = VM-Operator +introTitle = Usage \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties new file mode 100644 index 0000000..dcbba93 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties @@ -0,0 +1,19 @@ +# +# 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 . +# + +introTitle = Benutzung diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java index 000a21e..912b623 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java @@ -18,11 +18,17 @@ package org.jdrupes.vmoperator.manager; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Collections; +import java.util.ResourceBundle; +import java.util.stream.Collectors; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.webconsole.base.Conlet; +import org.jgrapes.webconlet.markdowndisplay.MarkdownDisplayConlet; +import org.jgrapes.webconsole.base.Conlet.RenderMode; import org.jgrapes.webconsole.base.ConsoleConnection; import org.jgrapes.webconsole.base.events.AddConletRequest; import org.jgrapes.webconsole.base.events.ConsoleConfigured; @@ -63,10 +69,13 @@ public class AvoidEmptyPolicy extends Component { * @param event the event * @param connection the connection */ - @Handler + @Handler(priority = 100) public void onRenderConlet(RenderConlet event, ConsoleConnection connection) { - connection.session().put(renderedFlagName, true); + if (event.renderAs().contains(RenderMode.Preview) + || event.renderAs().contains(RenderMode.View)) { + connection.session().put(renderedFlagName, true); + } } /** @@ -76,18 +85,42 @@ public class AvoidEmptyPolicy extends Component { * @param connection the console connection * @throws InterruptedException the interrupted exception */ - @Handler + @Handler(priority = -100) public void onConsoleConfigured(ConsoleConfigured event, ConsoleConnection connection) throws InterruptedException, IOException { - if ((Boolean) connection.session().getOrDefault( - renderedFlagName, false)) { + if ((Boolean) connection.session().getOrDefault(renderedFlagName, + false)) { return; } + var resourceBundle = ResourceBundle.getBundle( + getClass().getPackage().getName() + ".l10n", connection.locale(), + getClass().getClassLoader(), + ResourceBundle.Control.getNoFallbackControl( + ResourceBundle.Control.FORMAT_DEFAULT)); + var locale = resourceBundle.getLocale().toString(); + String shortDesc; + try (BufferedReader shortDescReader + = new BufferedReader(new InputStreamReader( + AvoidEmptyPolicy.class.getResourceAsStream( + "ManagerIntro-Preview" + (locale.isEmpty() ? "" + : "_" + locale) + ".md"), + "utf-8"))) { + shortDesc + = shortDescReader.lines().collect(Collectors.joining("\n")); + } fire(new AddConletRequest(event.event().event().renderSupport(), - "org.jdrupes.vmoperator.vmconlet.VmConlet", - Conlet.RenderMode - .asSet(Conlet.RenderMode.Preview, Conlet.RenderMode.View)), + MarkdownDisplayConlet.class.getName(), + RenderMode.asSet(RenderMode.Preview)) + .addProperty(MarkdownDisplayConlet.CONLET_ID, + getClass().getName()) + .addProperty(MarkdownDisplayConlet.TITLE, + resourceBundle.getString("consoleTitle")) + .addProperty(MarkdownDisplayConlet.PREVIEW_SOURCE, + shortDesc) + .addProperty(MarkdownDisplayConlet.DELETABLE, true) + .addProperty(MarkdownDisplayConlet.EDITABLE_BY, + Collections.EMPTY_SET), connection); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 0c5f0cd..a882a79 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -104,7 +104,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME); + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + newCm.getMetadata() + .getLabels().get("app.kubernetes.io/instance")); // Get pod, selected by label var podApi = new DynamicKubernetesApi("", "v1", "pods", client); var pods = podApi 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 a7b84a3..7de839b 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 @@ -27,6 +27,12 @@ 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 DATA_DISPLAY_PASSWORD. */ + public static final String DATA_DISPLAY_PASSWORD = "display-password"; + + /** The Constant DATA_PASSWORD_EXPIRY. */ + public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; + /** 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 89b5eac..66c11a7 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 @@ -101,7 +101,7 @@ public class Controller extends Component { } }); attach(new VmMonitor(channel()).channelManager(chanMgr)); - attach(new DisplayPasswordMonitor(channel()) + attach(new DisplaySecretMonitor(channel()) .channelManager(chanMgr.fixed())); // Currently, we don't use the IP assigned by the load balancer // to access the VM's console. Might change in the future. diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplayPasswordMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplayPasswordMonitor.java deleted file mode 100644 index 9959aec..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplayPasswordMonitor.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2024 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager; - -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.DisplayPasswordChanged; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jgrapes.core.Channel; -import org.jgrapes.core.annotation.Handler; - -/** - * Watches for changes of display secrets. - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class DisplayPasswordMonitor - extends AbstractMonitor { - - /** - * Instantiates a new display secrets monitor. - * - * @param componentChannel the component channel - */ - public DisplayPasswordMonitor(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 DisplayPasswordChanged( - ResponseType.valueOf(change.type), change.object), channel); - } - - /** - * On get display secrets. - * - * @param event the event - * @param channel the channel - * @throws ApiException the api exception - */ - @Handler - @SuppressWarnings("PMD.StringInstantiation") - public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) - throws ApiException { - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + event.vmName()); - var stubs = K8sV1SecretStub.list(client(), namespace(), options); - if (stubs.isEmpty()) { - return; - } - stubs.iterator().next().model().map(m -> m.getData()) - .map(m -> m.get("display-password")) - .ifPresent(p -> event.setResult(new String(p))); - } -} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java new file mode 100644 index 0000000..8bc1db0 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -0,0 +1,285 @@ +/* + * 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.custom.V1Patch; +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 io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; +import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; +import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.CompletionLock; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jose4j.base64url.Base64; + +/** + * Watches for changes of display secrets. + */ +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) +public class DisplaySecretMonitor + extends AbstractMonitor { + + private int passwordValidity = 10; + private final List pendingGets + = Collections.synchronizedList(new LinkedList<>()); + + /** + * Instantiates a new display secrets monitor. + * + * @param componentChannel the component channel + */ + public DisplaySecretMonitor(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); + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + @Override + public void onConfigurationUpdate(ConfigurationUpdate event) { + super.onConfigurationUpdate(event); + event.structured(componentPath()).ifPresent(c -> { + try { + if (c.containsKey("passwordValidity")) { + passwordValidity = Integer + .parseInt((String) c.get("passwordValidity")); + } + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + } + + @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; + } + + try { + patchPod(client, change); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Cannot patch pod annotations: " + e.getMessage()); + } + } + + private void patchPod(K8sClient client, Response change) + throws ApiException { + // Force update for pod + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + change.object.getMetadata() + .getLabels().get("app.kubernetes.io/instance")); + // Get pod, selected by label + var pods = K8sV1PodStub.list(client, namespace(), listOpts); + + // If the VM is being created, the pod may not exist yet. + if (pods.isEmpty()) { + return; + } + var pod = pods.iterator().next(); + + // Patch pod annotation + PatchOptions patchOpts = new PatchOptions(); + patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); + pod.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": " + + "\"/metadata/annotations/vmrunner.jdrupes.org~1dpVersion\", " + + "\"value\": \"" + + change.object.getMetadata().getResourceVersion() + + "\"}]"), + patchOpts); + } + + /** + * On get display secrets. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings("PMD.StringInstantiation") + public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) + throws ApiException { + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/instance=" + + event.vmDefinition().metadata().getName()); + var stubs = K8sV1SecretStub.list(client(), + event.vmDefinition().metadata().getNamespace(), options); + if (stubs.isEmpty()) { + return; + } + var stub = stubs.iterator().next(); + + // Check validity + var model = stub.model().get(); + @SuppressWarnings("PMD.StringInstantiation") + var expiry = new String(model.getData().get(DATA_PASSWORD_EXPIRY)); + if (model.getData().get(DATA_DISPLAY_PASSWORD) != null + && stillValid(expiry)) { + event.setResult( + new String(model.getData().get(DATA_DISPLAY_PASSWORD))); + return; + } + updatePassword(stub, event); + } + + @SuppressWarnings("PMD.StringInstantiation") + private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event) + throws ApiException { + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + var model = stub.model().get(); + model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + + // Prepare wait for confirmation (by VM status change) + var pending = new PendingGet(event, + event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, + new CompletionLock(event, 1500)); + pendingGets.add(pending); + Event.onCompletion(event, e -> { + pendingGets.remove(pending); + }); + + // Update, will (eventually) trigger confirmation + stub.update(model).getObject(); + } + + private boolean stillValid(String expiry) { + if (expiry == null || "never".equals(expiry)) { + return true; + } + @SuppressWarnings({ "PMD.CloseResource", "resource" }) + var scanner = new Scanner(expiry); + if (!scanner.hasNextLong()) { + return false; + } + long expTime = scanner.nextLong(); + return expTime > Instant.now().getEpochSecond() + passwordValidity; + } + + /** + * On vm def changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onVmDefChanged(VmDefChanged event, Channel channel) { + synchronized (pendingGets) { + String vmName = event.vmDefinition().metadata().getName(); + for (var pending : pendingGets) { + if (pending.event.vmDefinition().metadata().getName() + .equals(vmName) + && event.vmDefinition().displayPasswordSerial() + .map(s -> s >= pending.expectedSerial).orElse(false)) { + pending.lock.remove(); + // pending will be removed from pendingGest by + // waiting thread, see updatePassword + continue; + } + } + } + } + + /** + * The Class PendingGet. + */ + @SuppressWarnings("PMD.DataClass") + private static class PendingGet { + public final GetDisplayPassword event; + public final long expectedSerial; + public final CompletionLock lock; + + /** + * Instantiates a new pending get. + * + * @param event the event + * @param expectedSerial the expected serial + */ + public PendingGet(GetDisplayPassword event, long expectedSerial, + CompletionLock lock) { + super(); + this.event = event; + this.expectedSerial = expectedSerial; + this.lock = lock; + } + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java new file mode 100644 index 0000000..14b8890 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -0,0 +1,106 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonPrimitive; +import freemarker.template.TemplateException; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Map; +import java.util.logging.Logger; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; +import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; +import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; +import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.jose4j.base64url.Base64; + +/** + * Delegee for reconciling the display secret + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +/* default */ class DisplaySecretReconciler { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + + /** + * Reconcile. + * + * @param event the event + * @param model the model + * @param channel the channel + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + * @throws ApiException the api exception + */ + public void reconcile(VmDefChanged event, + Map model, VmChannel channel) + throws IOException, TemplateException, ApiException { + // Secret needed at all? + var display = GsonPtr.to(event.vmDefinition().data()).to("spec", "vm", + "display"); + if (!display.get(JsonPrimitive.class, "spice", "generateSecret") + .map(JsonPrimitive::getAsBoolean).orElse(false)) { + return; + } + + // Check if exists + var metadata = event.vmDefinition().getMetadata(); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/instance=" + metadata.getName()); + var stubs = K8sV1SecretStub.list(channel.client(), + metadata.getNamespace(), options); + if (!stubs.isEmpty()) { + return; + } + + // Create secret + var secret = new V1Secret(); + secret.setMetadata(new V1ObjectMeta().namespace(metadata.getNamespace()) + .name(metadata.getName() + "-" + COMP_DISPLAY_SECRET) + .putLabelsItem("app.kubernetes.io/name", APP_NAME) + .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) + .putLabelsItem("app.kubernetes.io/instance", metadata.getName())); + secret.setType("Opaque"); + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, + DATA_PASSWORD_EXPIRY, "now")); + K8sV1SecretStub.create(channel.client(), secret); + } + +} 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 5ba9dc5..5bbfe38 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 @@ -135,6 +135,7 @@ public class Reconciler extends Component { @SuppressWarnings("PMD.SingularField") private final Configuration fmConfig; private final ConfigMapReconciler cmReconciler; + private final DisplaySecretReconciler dsReconciler; private final StatefulSetReconciler stsReconciler; private final LoadBalancerReconciler lbReconciler; @SuppressWarnings("PMD.UseConcurrentHashMap") @@ -159,6 +160,7 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); + dsReconciler = new DisplaySecretReconciler(); stsReconciler = new StatefulSetReconciler(fmConfig); lbReconciler = new LoadBalancerReconciler(fmConfig); } @@ -209,6 +211,7 @@ public class Reconciler extends Component { = prepareModel(channel.client(), patchCr(event.vmDefinition())); var configMap = cmReconciler.reconcile(event, model, channel); model.put("cm", configMap.getRaw()); + dsReconciler.reconcile(event, model, channel); stsReconciler.reconcile(event, model, channel); lbReconciler.reconcile(event, model, channel); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 7027808..41f08ce 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -32,12 +32,14 @@ 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 org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinitionModels; +import org.jdrupes.vmoperator.common.VmDefinitionStub; 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; @@ -50,8 +52,8 @@ import org.jgrapes.core.Channel; * Watches for changes of VM definitions. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) -public class VmMonitor - extends AbstractMonitor { +public class VmMonitor extends + AbstractMonitor { /** * Instantiates a new VM definition watcher. @@ -59,7 +61,8 @@ public class VmMonitor * @param componentChannel the component channel */ public VmMonitor(Channel componentChannel) { - super(componentChannel, K8sDynamicModel.class, K8sDynamicModels.class); + super(componentChannel, VmDefinitionModel.class, + VmDefinitionModels.class); } @Override @@ -102,7 +105,7 @@ public class VmMonitor @Override protected void handleChange(K8sClient client, - Watch.Response response) { + Watch.Response response) { V1ObjectMeta metadata = response.object.getMetadata(); VmChannel channel = channel(metadata.getName()).orElse(null); if (channel == null) { @@ -138,9 +141,10 @@ public class VmMonitor vmDef), channel); } - private K8sDynamicModel getModel(K8sClient client, K8sDynamicModel vmDef) { + private VmDefinitionModel getModel(K8sClient client, + VmDefinitionModel vmDef) { try { - return K8sDynamicStub.get(client, context(), namespace(), + return VmDefinitionStub.get(client, context(), namespace(), vmDef.metadata().getName()).model().orElse(null); } catch (ApiException e) { return null; 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 06ed64c..bbcba5e 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 @@ -40,7 +40,8 @@ import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; -import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged; @@ -73,7 +74,7 @@ public class StatusUpdater extends Component { private long observedGeneration; private boolean guestShutdownStops; private boolean shutdownByGuest; - private K8sDynamicStub vmStub; + private VmDefinitionStub vmStub; /** * Instantiates a new status updater. @@ -158,7 +159,7 @@ public class StatusUpdater extends Component { return; } try { - vmStub = K8sDynamicStub.get(apiClient, + vmStub = VmDefinitionStub.get(apiClient, new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, vmName); vmStub.model().ifPresent(model -> { @@ -226,7 +227,7 @@ public class StatusUpdater extends Component { "PMD.AvoidLiteralsInIfCondition" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { - K8sDynamicModel vmDef; + VmDefinitionModel vmDef; if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } 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 1882173..a8bb1ae 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 @@ -35,8 +35,8 @@ import java.util.Optional; import java.util.Set; 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.common.VmDefinitionModel; import org.jdrupes.vmoperator.manager.events.ChannelCache; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.VmChannel; @@ -69,7 +69,7 @@ public class VmConlet extends FreeMarkerConlet { private static final Set MODES = RenderMode.asSet( RenderMode.Preview, RenderMode.View); private final ChannelCache channelManager = new ChannelCache<>(); + VmDefinitionModel> channelManager = new ChannelCache<>(); private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); private Summary cachedSummary; @@ -196,8 +196,8 @@ public class VmConlet extends FreeMarkerConlet { } } } else { - var vmDef = new K8sDynamicModel(channel.client().getJSON() - .getGson(), convertQuantities(event.vmDefinition().data())); + var vmDef = new VmDefinitionModel(channel.client().getJSON() + .getGson(), cleanup(event.vmDefinition().data())); channelManager.put(vmName, channel, vmDef); var def = JsonBeanDecoder.create(vmDef.data().toString()) .readObject(); @@ -220,7 +220,7 @@ public class VmConlet extends FreeMarkerConlet { } @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private JsonObject convertQuantities(JsonObject vmDef) { + private JsonObject cleanup(JsonObject vmDef) { // Clone and remove managed fields var json = vmDef.deepCopy(); GsonPtr.to(json).to("metadata").get(JsonObject.class) diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss index 3649bff..a5658e9 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss @@ -100,7 +100,7 @@ [role=button] { padding: 0.25rem; - &:not([aria-disabled]):hover { + &:not([aria-disabled]):hover, &[aria-disabled='false']:hover { box-shadow: var(--darkening); } } diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_de.properties b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_de.properties index e81a0fe..f05dfff 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/l10n_de.properties @@ -3,5 +3,6 @@ conletName = VM-Konsole okayLabel = Anwenden und Schließen Select\ VM = VM auswählen -Start\ VM = VM Starten -Stop\ VM = VM Anhalten +Start\ VM = VM starten +Stop\ VM = VM anhalten +Open\ console = Konsole anzeigen diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index b97ff6f..c06cc1a 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java @@ -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 @@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonGetter; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; @@ -33,18 +34,23 @@ import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; import java.net.UnknownHostException; +import java.time.Duration; import java.util.Base64; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; import java.util.logging.Level; +import org.bouncycastle.util.Objects; 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.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinitionModel.Permission; import org.jdrupes.vmoperator.manager.events.ChannelCache; import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.ModifyVm; @@ -52,6 +58,7 @@ import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; import org.jgrapes.core.Event; import org.jgrapes.core.Manager; import org.jgrapes.core.annotation.Handler; @@ -62,11 +69,15 @@ import org.jgrapes.util.events.KeyValueStoreUpdate; import org.jgrapes.webconsole.base.Conlet.RenderMode; import org.jgrapes.webconsole.base.ConletBaseModel; import org.jgrapes.webconsole.base.ConsoleConnection; +import org.jgrapes.webconsole.base.ConsoleRole; import org.jgrapes.webconsole.base.ConsoleUser; import org.jgrapes.webconsole.base.WebConsoleUtils; +import org.jgrapes.webconsole.base.events.AddConletRequest; import org.jgrapes.webconsole.base.events.AddConletType; import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; import org.jgrapes.webconsole.base.events.ConletDeleted; +import org.jgrapes.webconsole.base.events.ConsoleConfigured; +import org.jgrapes.webconsole.base.events.ConsolePrepared; import org.jgrapes.webconsole.base.events.ConsoleReady; import org.jgrapes.webconsole.base.events.DeleteConlet; import org.jgrapes.webconsole.base.events.NotifyConletModel; @@ -75,22 +86,32 @@ import org.jgrapes.webconsole.base.events.OpenModalDialog; import org.jgrapes.webconsole.base.events.RenderConlet; import org.jgrapes.webconsole.base.events.RenderConletRequestBase; import org.jgrapes.webconsole.base.events.SetLocale; +import org.jgrapes.webconsole.base.events.UpdateConletType; import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; /** * The Class VmConlet. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", - "PMD.CouplingBetweenObjects" }) + "PMD.CouplingBetweenObjects", "PMD.GodClass" }) public class VmViewer extends FreeMarkerConlet { + private static final String VM_NAME_PROPERTY = "vmName"; + private static final String RENDERED + = VmViewer.class.getName() + ".rendered"; + private static final String PENDING + = VmViewer.class.getName() + ".pending"; private static final Set MODES = RenderMode.asSet( RenderMode.Preview, RenderMode.Edit); + private static final Set MODES_FOR_GENERATED = RenderMode.asSet( + RenderMode.Preview, RenderMode.StickyPreview); private final ChannelCache channelManager = new ChannelCache<>(); + VmDefinitionModel> channelManager = new ChannelCache<>(); private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private Class preferredIpVersion = Inet4Address.class; + private final Set syncUsers = new HashSet<>(); + private final Set syncRoles = new HashSet<>(); /** * The periodically generated update event. @@ -114,24 +135,47 @@ public class VmViewer extends FreeMarkerConlet { * * @param event the event */ + @SuppressWarnings("unchecked") @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { - @SuppressWarnings("unchecked") - var dispRes = (Map) c - .getOrDefault("displayResource", Collections.emptyMap()); - switch ((String) dispRes.getOrDefault("preferredIpVersion", "")) { - case "ipv6": - preferredIpVersion = Inet6Address.class; - break; - case "ipv4": - default: - preferredIpVersion = Inet4Address.class; - break; + try { + var dispRes = (Map) c + .getOrDefault("displayResource", Collections.emptyMap()); + switch ((String) dispRes.getOrDefault("preferredIpVersion", + "")) { + case "ipv6": + preferredIpVersion = Inet6Address.class; + break; + case "ipv4": + default: + preferredIpVersion = Inet4Address.class; + break; + } + + // Sync + for (var entry : (List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())) { + if (entry.containsKey("user")) { + syncUsers.add(entry.get("user")); + } else if (entry.containsKey("role")) { + syncRoles.add(entry.get("role")); + } + } + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); } }); } + private boolean syncPreviews(Session session) { + return WebConsoleUtils.userFromSession(session) + .filter(u -> syncUsers.contains(u.getName())).isPresent() + || WebConsoleUtils.rolesFromSession(session).stream() + .filter(cr -> syncRoles.contains(cr.getName())).findAny() + .isPresent(); + } + /** * On {@link ConsoleReady}, fire the {@link AddConletType}. * @@ -155,6 +199,61 @@ public class VmViewer extends FreeMarkerConlet { .addScript(new ScriptResource().setScriptType("module") .setScriptUri(event.renderSupport().conletResource( type(), "VmViewer-functions.js")))); + channel.session().put(RENDERED, new HashSet<>()); + } + + /** + * On console configured. + * + * @param event the event + * @param connection the console connection + * @throws InterruptedException the interrupted exception + */ + @Handler + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onConsoleConfigured(ConsoleConfigured event, + ConsoleConnection connection) throws InterruptedException, + IOException { + @SuppressWarnings("unchecked") + final var rendered = (Set) connection.session().get(RENDERED); + connection.session().remove(RENDERED); + if (!syncPreviews(connection.session())) { + return; + } + + boolean foundMissing = false; + for (var vmName : accessibleVms(connection)) { + if (rendered.contains(vmName)) { + continue; + } + if (!foundMissing) { + // Suspending to allow rendering of conlets to be noticed + var failSafe = Components.schedule(t -> event.resumeHandling(), + Duration.ofSeconds(1)); + event.suspendHandling(failSafe::cancel); + connection.setAssociated(PENDING, event); + foundMissing = true; + } + fire(new AddConletRequest(event.event().event().renderSupport(), + VmViewer.class.getName(), + RenderMode.asSet(RenderMode.Preview)) + .addProperty(VM_NAME_PROPERTY, vmName), + connection); + } + } + + /** + * On console prepared. + * + * @param event the event + * @param connection the connection + */ + @Handler + public void onConsolePrepared(ConsolePrepared event, + ConsoleConnection connection) { + if (syncPreviews(connection.session())) { + connection.respond(new UpdateConletType(type())); + } } private String storagePath(Session session, String conletId) { @@ -163,6 +262,20 @@ public class VmViewer extends FreeMarkerConlet { + "/" + VmViewer.class.getName() + "/" + conletId; } + @Override + protected Optional createNewState(AddConletRequest event, + ConsoleConnection connection, String conletId) throws Exception { + var model = new ViewerModel(conletId); + model.vmName = (String) event.properties().get(VM_NAME_PROPERTY); + if (model.vmName != null) { + model.setGenerated(true); + } + String jsonState = objectMapper.writeValueAsString(model); + connection.respond(new KeyValueStoreUpdate().update( + storagePath(connection.session(), model.getConletId()), jsonState)); + return Optional.of(model); + } + @Override protected Optional createStateRepresentation(Event event, ConsoleConnection connection, String conletId) throws Exception { @@ -197,32 +310,57 @@ public class VmViewer extends FreeMarkerConlet { } @Override - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "unchecked" }) protected Set doRenderConlet(RenderConletRequestBase event, - ConsoleConnection channel, String conletId, ViewerModel conletState) + ConsoleConnection channel, String conletId, ViewerModel model) throws Exception { ResourceBundle resourceBundle = resourceBundle(channel.locale()); Set renderedAs = new HashSet<>(); if (event.renderAs().contains(RenderMode.Preview)) { + channel.associated(PENDING, Event.class) + .ifPresent(e -> { + e.resumeHandling(); + channel.setAssociated(PENDING, null); + }); + + // Remove conlet if definition has been removed + if (model.vmName() != null + && !channelManager.associated(model.vmName()).isPresent()) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + + // Don't render if user has not at least one permission + if (model.vmName() != null + && channelManager.associated(model.vmName()) + .map(d -> permissions(d, channel.session()).isEmpty()) + .orElse(true)) { + return Collections.emptySet(); + } + + // Render Template tpl = freemarkerConfig().getTemplate("VmViewer-preview.ftl.html"); channel.respond(new RenderConlet(type(), conletId, processTemplate(event, tpl, - fmModel(event, channel, conletId, conletState))) + fmModel(event, channel, conletId, model))) .setRenderAs( RenderMode.Preview.addModifiers(event.renderAs())) - .setSupportedModes(MODES)); + .setSupportedModes( + model.isGenerated() ? MODES_FOR_GENERATED : MODES)); renderedAs.add(RenderMode.Preview); - if (!Strings.isNullOrEmpty(conletState.vmName())) { - updateConfig(channel, conletState); + if (!Strings.isNullOrEmpty(model.vmName())) { + Optional.ofNullable(channel.session().get(RENDERED)) + .ifPresent(s -> ((Set) s).add(model.vmName())); + updateConfig(channel, model); } } if (event.renderAs().contains(RenderMode.Edit)) { Template tpl = freemarkerConfig() .getTemplate("VmViewer-edit.ftl.html"); - var fmModel = fmModel(event, channel, conletId, conletState); - fmModel.put("vmNames", - channelManager.keys().stream().sorted().toList()); + var fmModel = fmModel(event, channel, conletId, model); + fmModel.put("vmNames", accessibleVms(channel)); channel.respond(new OpenModalDialog(type(), conletId, processTemplate(event, tpl, fmModel)) .addOption("cancelable", true) @@ -232,6 +370,21 @@ public class VmViewer extends FreeMarkerConlet { return renderedAs; } + private List accessibleVms(ConsoleConnection channel) { + return channelManager.associated().stream() + .filter(d -> !permissions(d, channel.session()).isEmpty()) + .map(d -> d.getMetadata().getName()).sorted().toList(); + } + + private Set permissions(VmDefinitionModel vmDef, + Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return vmDef.permissionsFor(user, roles); + } + private void updateConfig(ConsoleConnection channel, ViewerModel model) { channel.respond(new NotifyConletView(type(), model.getConletId(), "updateConfig", model.vmName())); @@ -246,6 +399,9 @@ public class VmViewer extends FreeMarkerConlet { try { var def = JsonBeanDecoder.create(vmDef.data().toString()) .readObject(); + def.setField("userPermissions", + permissions(vmDef, channel.session()).stream() + .map(Permission::toString).toList()); channel.respond(new NotifyConletView(type(), model.getConletId(), "updateVmDefinition", def)); } catch (JsonDecodeException e) { @@ -279,8 +435,10 @@ public class VmViewer extends FreeMarkerConlet { "PMD.ConfusingArgumentToVarargsMethod" }) public void onVmDefChanged(VmDefChanged event, VmChannel channel) throws JsonDecodeException, IOException { - var vmDef = new K8sDynamicModel(channel.client().getJSON() + var vmDef = new VmDefinitionModel(channel.client().getJSON() .getGson(), event.vmDefinition().data()); + GsonPtr.to(vmDef.data()).to("metadata").get(JsonObject.class) + .remove("managedFields"); var vmName = vmDef.getMetadata().getName(); if (event.type() == K8sObserver.ResponseType.DELETED) { channelManager.remove(vmName); @@ -291,7 +449,8 @@ public class VmViewer extends FreeMarkerConlet { var connection = entry.getKey(); for (var conletId : entry.getValue()) { var model = stateFromSession(connection.session(), conletId); - if (model.isEmpty() || !model.get().vmName().equals(vmName)) { + if (model.isEmpty() + || !Objects.areEqual(model.get().vmName(), vmName)) { continue; } if (event.type() == K8sObserver.ResponseType.DELETED) { @@ -311,11 +470,15 @@ public class VmViewer extends FreeMarkerConlet { ConsoleConnection channel, ViewerModel model) throws Exception { event.stop(); - var vmName = event.params().asString(0); - var vmChannel = channelManager.channel(vmName).orElse(null); - if (vmChannel == null) { + var both = Optional.ofNullable(event.params().asString(0)) + .flatMap(vm -> channelManager.both(vm)); + if (both.isEmpty()) { return; } + var vmChannel = both.get().channel; + var vmDef = both.get().associated; + var vmName = vmDef.metadata().getName(); + var perms = permissions(vmDef, channel.session()); switch (event.method()) { case "selectedVm": model.setVmName(event.params().asString(0)); @@ -325,15 +488,22 @@ public class VmViewer extends FreeMarkerConlet { updateConfig(channel, model); break; case "start": - fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + if (perms.contains(Permission.START)) { + fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + } break; case "stop": - fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + if (perms.contains(Permission.STOP)) { + fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + } break; case "openConsole": - channelManager.channel(vmName).ifPresent( - vc -> fire(Event.onCompletion(new GetDisplayPassword(vmName), - ds -> openConsole(vmName, channel, model, ds)), vc)); + if (perms.contains(Permission.ACCESS_CONSOLE)) { + var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef), + e -> e.password().ifPresent( + pw -> openConsole(vmName, channel, model, pw))); + fire(pwQuery, vmChannel); + } break; default:// ignore break; @@ -341,7 +511,7 @@ public class VmViewer extends FreeMarkerConlet { } private void openConsole(String vmName, ConsoleConnection connection, - ViewerModel model, GetDisplayPassword pwQuery) { + ViewerModel model, String password) { var vmDef = channelManager.associated(vmName).orElse(null); if (vmDef == null) { return; @@ -362,10 +532,8 @@ public class VmViewer extends FreeMarkerConlet { StringBuffer data = new StringBuffer(100) .append("[virt-viewer]\ntype=spice\nhost=") .append(addr.get().getHostAddress()).append("\nport=") - .append(Integer.toString(port.get().getAsInt())).append('\n'); - pwQuery.password().ifPresent(p -> { - data.append("password=").append(p).append('\n'); - }); + .append(Integer.toString(port.get().getAsInt())) + .append("\npassword=").append(password).append('\n'); proxyUrl.map(JsonPrimitive::getAsString).ifPresent(u -> { if (!Strings.isNullOrEmpty(u)) { data.append("proxy=").append(u).append('\n'); @@ -418,9 +586,11 @@ public class VmViewer extends FreeMarkerConlet { /** * The Class VmsModel. */ + @SuppressWarnings("PMD.DataClass") public static class ViewerModel extends ConletBaseModel { private String vmName; + private boolean generated; /** * Instantiates a new vms model. @@ -450,5 +620,23 @@ public class VmViewer extends FreeMarkerConlet { this.vmName = vmName; } + /** + * Checks if is generated. + * + * @return the generated + */ + public boolean isGenerated() { + return generated; + } + + /** + * Sets the generated. + * + * @param generated the generated to set + */ + public void setGenerated(boolean generated) { + this.generated = generated; + } + } } diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts index 2c66aeb..ca3b246 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts @@ -84,17 +84,22 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, + ? 'computer.svg' : 'computer-off.svg')" + :title="localize('Open console')"> -