diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 579103d79..3f973e40e 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -9,6 +9,14 @@ "/Reconciler": runnerData: storageClassName: null + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common "/GuiSocketServer": port: 8888 "/GuiHttpServer": @@ -17,12 +25,12 @@ "/WebConsole": "/LoginConlet": users: - - name: admin - fullName: Administrator - password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - - name: test - fullName: Test Account - password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml index 19b629583..f6e51b8ff 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -35,6 +35,14 @@ patches: "/Reconciler": runnerData: storageClassName: null + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common "/GuiSocketServer": port: 8888 "/GuiHttpServer": @@ -43,12 +51,12 @@ patches: "/WebConsole": "/LoginConlet": users: - admin: - fullName: Administrator - password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - test: - fullName: Test Account - password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index e874ef812..6f71b6a43 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -11,12 +11,12 @@ spec: pullPolicy: Always permissions: - - user: admin - may: - - "*" - - user: test - may: - - "accessConsole" + - user: admin + may: + - "*" + - user: test + may: + - "accessConsole" resources: requests: @@ -62,3 +62,5 @@ spec: spice: port: 5810 generateSecret: true + + loadBalancerService: {} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java index 481f72429..7a2818508 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java @@ -157,27 +157,6 @@ public static Optional context(ApiClient client, return Optional.of(apiRes); } - /** - * Get an object from its metadata. - * - * @param the generic type - * @param the generic type - * @param api the api - * @param meta the meta - * @return the object - */ - @Deprecated - @SuppressWarnings("PMD.GenericsNaming") - public static - Optional - get(GenericKubernetesApi api, V1ObjectMeta meta) { - var response = api.get(meta.getNamespace(), meta.getName()); - if (response.isSuccess()) { - return Optional.of(response.getObject()); - } - return Optional.empty(); - } - /** * Apply the given patch data. * diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java index f118a1709..09516a04b 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java @@ -27,6 +27,7 @@ import io.kubernetes.client.util.Strings; import io.kubernetes.client.util.generic.GenericKubernetesApi; import io.kubernetes.client.util.generic.KubernetesApiResponse; +import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.options.GetOptions; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; @@ -47,7 +48,7 @@ * @param the generic type * @param the generic type */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" }) public class K8sGenericStub { protected final K8sClient client; @@ -224,7 +225,7 @@ public Optional updateStatus(Function status) * @param patchType the patch type * @param patch the patch * @param options the options - * @return the kubernetes api response + * @return the kubernetes api response if successful * @throws ApiException the api exception */ public Optional patch(String patchType, V1Patch patch, @@ -239,7 +240,7 @@ public Optional patch(String patchType, V1Patch patch, * * @param patchType the patch type * @param patch the patch - * @return the kubernetes api response + * @return the kubernetes api response if successful * @throws ApiException the api exception */ public Optional @@ -248,6 +249,21 @@ public Optional patch(String patchType, V1Patch patch, return patch(patchType, patch, opts); } + /** + * Apply the given definition. + * + * @param def the def + * @return the kubernetes api response if successful + * @throws ApiException the api exception + */ + public Optional apply(DynamicKubernetesObject def) throws ApiException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + return patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(client.getJSON().serialize(def)), opts); + } + /** * Update the object. * diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java new file mode 100644 index 000000000..71ea7f10a --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -0,0 +1,332 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.util.DataPath; + +/** + * Represents a VM definition. + */ +@SuppressWarnings({ "PMD.DataClass" }) +public class VmDefinition { + + private String kind; + private String apiVersion; + private V1ObjectMeta metadata; + private Map spec; + private Map status; + private final Map extra = new ConcurrentHashMap<>(); + + /** + * The VM state from the VM definition. + */ + public enum RequestedVmState { + STOPPED, RUNNING + } + + /** + * Permissions for accessing and manipulating the VM. + */ + public enum Permission { + START("start"), STOP("stop"), RESET("reset"), + ACCESS_CONSOLE("accessConsole"); + + @SuppressWarnings("PMD.UseConcurrentHashMap") + private static Map reprs = new HashMap<>(); + + static { + for (var value : EnumSet.allOf(Permission.class)) { + reprs.put(value.repr, value); + } + } + + private final String repr; + + Permission(String repr) { + this.repr = repr; + } + + /** + * Create permission from representation in CRD. + * + * @param value the value + * @return the permission + */ + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public static Set parse(String value) { + if ("*".equals(value)) { + return EnumSet.allOf(Permission.class); + } + return Set.of(reprs.get(value)); + } + + @Override + public String toString() { + return repr; + } + } + + /** + * Gets the kind. + * + * @return the kind + */ + public String getKind() { + return kind; + } + + /** + * Sets the kind. + * + * @param kind the kind to set + */ + public void setKind(String kind) { + this.kind = kind; + } + + /** + * Gets the api version. + * + * @return the apiVersion + */ + public String getApiVersion() { + return apiVersion; + } + + /** + * Sets the api version. + * + * @param apiVersion the apiVersion to set + */ + public void setApiVersion(String apiVersion) { + this.apiVersion = apiVersion; + } + + /** + * Gets the metadata. + * + * @return the metadata + */ + public V1ObjectMeta getMetadata() { + return metadata; + } + + /** + * Gets the metadata. + * + * @return the metadata + */ + public V1ObjectMeta metadata() { + return metadata; + } + + /** + * Sets the metadata. + * + * @param metadata the metadata to set + */ + public void setMetadata(V1ObjectMeta metadata) { + this.metadata = metadata; + } + + /** + * Gets the spec. + * + * @return the spec + */ + public Map getSpec() { + return spec; + } + + /** + * Gets the spec. + * + * @return the spec + */ + public Map spec() { + return spec; + } + + /** + * Get a value from the spec using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromSpec(Object... selectors) { + return DataPath.get(spec, selectors); + } + + /** + * Get a value from the `spec().get("vm")` using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromVm(Object... selectors) { + return DataPath.get(spec, "vm") + .flatMap(vm -> DataPath.get(vm, selectors)); + } + + /** + * Sets the spec. + * + * @param spec the spec to set + */ + public void setSpec(Map spec) { + this.spec = spec; + } + + /** + * Gets the status. + * + * @return the status + */ + public Map getStatus() { + return status; + } + + /** + * Gets the status. + * + * @return the status + */ + public Map status() { + return status; + } + + /** + * Get a value from the status using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromStatus(Object... selectors) { + return DataPath.get(status, selectors); + } + + /** + * Sets the status. + * + * @param status the status to set + */ + public void setStatus(Map status) { + this.status = status; + } + + /** + * Set extra data (locally used, unknown to kubernetes). + * + * @param property the property + * @param value the value + * @return the VM definition + */ + public VmDefinition extra(String property, Object value) { + extra.put(property, value); + return this; + } + + /** + * Return extra data. + * + * @param property the property + * @return the object + */ + @SuppressWarnings("unchecked") + public T extra(String property) { + return (T) extra.get(property); + } + + /** + * Returns the definition's name. + * + * @return the string + */ + public String name() { + return metadata.getName(); + } + + /** + * Returns the definition's namespace. + * + * @return the string + */ + public String namespace() { + return metadata.getNamespace(); + } + + /** + * Return the requested VM state + * + * @return the string + */ + public RequestedVmState vmState() { + // TODO + return fromVm("state") + .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING + : RequestedVmState.STOPPED) + .orElse(RequestedVmState.STOPPED); + } + + /** + * Collect all permissions for the given user with the given roles. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + return this.>> fromSpec("permissions") + .orElse(Collections.emptyList()).stream() + .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(p, "role").map(roles::contains).orElse(false)) + .map(p -> DataPath.> get(p, "may") + .orElse(Collections.emptyList()).stream()) + .flatMap(Function.identity()) + .map(Permission::parse).map(Set::stream) + .flatMap(Function.identity()).collect(Collectors.toSet()); + } + + /** + * Get the display password serial. + * + * @return the optional + */ + public Optional displayPasswordSerial() { + return this. fromStatus("displayPasswordSerial") + .map(Number::longValue); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java index 987b4c857..d4ae5da92 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java @@ -20,16 +20,6 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; -import java.util.Collection; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; -import org.jdrupes.vmoperator.util.GsonPtr; /** * Represents a VM definition. @@ -37,55 +27,6 @@ @SuppressWarnings("PMD.DataClass") public class VmDefinitionModel extends K8sDynamicModel { - /** - * The VM state from the VM definition. - */ - public enum RequestedVmState { - STOPPED, RUNNING - } - - /** - * Permissions for accessing and manipulating the VM. - */ - public enum Permission { - START("start"), STOP("stop"), RESET("reset"), - ACCESS_CONSOLE("accessConsole"); - - @SuppressWarnings("PMD.UseConcurrentHashMap") - private static Map reprs = new HashMap<>(); - - static { - for (var value : EnumSet.allOf(Permission.class)) { - reprs.put(value.repr, value); - } - } - - private final String repr; - - Permission(String repr) { - this.repr = repr; - } - - /** - * Create permission from representation in CRD. - * - * @param value the value - * @return the permission - */ - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - public static Set parse(String value) { - if ("*".equals(value)) { - return EnumSet.allOf(Permission.class); - } - return Set.of(reprs.get(value)); - } - - @Override - public String toString() { - return repr; - } - } - /** * Instantiates a new model from the JSON representation. * @@ -95,49 +36,4 @@ public String toString() { public VmDefinitionModel(Gson delegate, JsonObject json) { super(delegate, json); } - - /** - * Collect all permissions for the given user with the given roles. - * - * @param user the user - * @param roles the roles - * @return the sets the - */ - public Set permissionsFor(String user, - Collection roles) { - return GsonPtr.to(data()) - .getAsListOf(JsonObject.class, "spec", "permissions") - .stream().filter(p -> GsonPtr.to(p).getAsString("user") - .map(u -> u.equals(user)).orElse(false) - || GsonPtr.to(p).getAsString("role").map(roles::contains) - .orElse(false)) - .map(p -> GsonPtr.to(p).getAsListOf(JsonPrimitive.class, "may") - .stream()) - .flatMap(Function.identity()).map(p -> p.getAsString()) - .map(Permission::parse).map(Set::stream) - .flatMap(Function.identity()).collect(Collectors.toSet()); - } - - /** - * Return the requested VM state - * - * @return the string - */ - public RequestedVmState vmState() { - return GsonPtr.to(data()).getAsString("spec", "vm", "state") - .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING - : RequestedVmState.STOPPED) - .orElse(RequestedVmState.STOPPED); - } - - /** - * Get the display password serial. - * - * @return the optional - */ - public Optional displayPasswordSerial() { - return GsonPtr.to(status()) - .get(JsonPrimitive.class, "displayPasswordSerial") - .map(JsonPrimitive::getAsLong); - } } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java index 37eddec36..3322f1a4b 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.manager.events; import java.util.Optional; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Event; /** @@ -28,14 +28,14 @@ @SuppressWarnings("PMD.DataClass") public class GetDisplayPassword extends Event { - private final VmDefinitionModel vmDef; + private final VmDefinition vmDef; /** * Instantiates a new returns the display secret. * * @param vmDef the vm name */ - public GetDisplayPassword(VmDefinitionModel vmDef) { + public GetDisplayPassword(VmDefinition vmDef) { this.vmDef = vmDef; } @@ -44,7 +44,7 @@ public GetDisplayPassword(VmDefinitionModel vmDef) { * * @return the vm definition */ - public VmDefinitionModel vmDefinition() { + public VmDefinition vmDefinition() { return vmDef; } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java index 46861cebd..5ea282a46 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Subchannel.DefaultSubchannel; @@ -32,7 +32,7 @@ public class VmChannel extends DefaultSubchannel { private final EventPipeline pipeline; private final K8sClient client; - private VmDefinitionModel vmDefinition; + private VmDefinition definition; private long generation = -1; /** @@ -56,18 +56,18 @@ public VmChannel(Channel mainChannel, EventPipeline pipeline, * @return the watch channel */ @SuppressWarnings("PMD.LinguisticNaming") - public VmChannel setVmDefinition(VmDefinitionModel definition) { - this.vmDefinition = definition; + public VmChannel setVmDefinition(VmDefinition definition) { + this.definition = definition; return this; } /** * Returns the last known definition of the VM. * - * @return the json object + * @return the defintion */ - public VmDefinitionModel vmDefinition() { - return vmDefinition; + public VmDefinition vmDefinition() { + return definition; } /** diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java index a2bafb716..a8873cf16 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -36,7 +36,7 @@ public class VmDefChanged extends Event { private final K8sObserver.ResponseType type; private final boolean specChanged; - private final VmDefinitionModel vmDef; + private final VmDefinition vmDefinition; /** * Instantiates a new VM changed event. @@ -46,10 +46,10 @@ public class VmDefChanged extends Event { * @param vmDefinition the VM definition */ public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged, - VmDefinitionModel vmDefinition) { + VmDefinition vmDefinition) { this.type = type; this.specChanged = specChanged; - this.vmDef = vmDefinition; + this.vmDefinition = vmDefinition; } /** @@ -69,19 +69,19 @@ public boolean specChanged() { } /** - * Returns the object. + * Return the VM definition. * - * @return the object. + * @return the VM definition */ - public VmDefinitionModel vmDefinition() { - return vmDef; + public VmDefinition vmDefinition() { + return vmDefinition; } @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append(Components.objectName(this)).append(" [") - .append(vmDef.getMetadata().getName()).append(' ').append(type); + .append(vmDefinition.name()).append(' ').append(type); if (channels() != null) { builder.append(", channels=").append(Channel.toString(channels())); } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml index 9e3d5ef51..214b5b8d0 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml @@ -1,141 +1,142 @@ apiVersion: v1 kind: ConfigMap metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } + - apiVersion: ${ cr.apiVersion } kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false - + data: config.yaml: | "/Runner": # The directory used to store data files. Defaults to (depending on # values available): - # * $XDG_DATA_HOME/vmrunner/${ cr.metadata.name.asString } - # * $HOME/.local/share/vmrunner/${ cr.metadata.name.asString } - # * ./${ cr.metadata.name.asString } + # * $XDG_DATA_HOME/vmrunner/${ cr.name() } + # * $HOME/.local/share/vmrunner/${ cr.name() } + # * ./${ cr.name() } dataDir: /var/local/vm-data # The directory used to store runtime files. Defaults to (depending on # values available): - # * $XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString } - # * /tmp/$USER/vmrunner/${ cr.metadata.name.asString } - # * /tmp/vmrunner/${ cr.metadata.name.asString } - # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString }" + # * $XDG_RUNTIME_DIR/vmrunner/${ cr.name() } + # * /tmp/$USER/vmrunner/${ cr.name() } + # * /tmp/vmrunner/${ cr.name() } + # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.name() }" + <#assign spec = cr.spec() /> # The template to use. Resolved relative to /usr/share/vmrunner/templates. # template: "Standard-VM-latest.ftl.yaml" - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.source?? > - template: ${ cr.spec.runnerTemplate.source.asString } + <#if spec.runnerTemplate?? && spec.runnerTemplate.source?? > + template: ${ cm.spec().runnerTemplate.source } # The template is copied to the data diretory when the VM starts for # the first time. Subsequent starts use the copy unless this option is set. - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.update?? > - updateTemplate: ${ cr.spec.runnerTemplate.update.asBoolean?c } + <#if spec.runnerTemplate?? && spec.runnerTemplate.update?? > + updateTemplate: ${ spec.runnerTemplate.update?c } # Whether a shutdown initiated by the guest stops the pod deployment - guestShutdownStops: ${ cr.spec.guestShutdownStops!false?c } + guestShutdownStops: ${ (spec.guestShutdownStops!false)?c } # When incremented, the VM is reset. The value has no default value, # i.e. if you start the VM without a value for this property, and # decide to trigger a reset later, you have to first set the value # and then inrement it. - resetCounter: ${ cr.resetCount } + resetCounter: ${ cr.extra("resetCount")?c } # Forward the cloud-init data if provided - <#if cr.spec.cloudInit??> + <#if spec.cloudInit??> cloudInit: - <#if cr.spec.cloudInit.metaData??> - metaData: ${ cr.spec.cloudInit.metaData.toString() } + <#if spec.cloudInit.metaData??> + metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData, cr.metadata())) } <#else> metaData: {} - <#if cr.spec.cloudInit.userData??> - userData: ${ cr.spec.cloudInit.userData.toString() } + <#if spec.cloudInit.userData??> + userData: ${ toJson(spec.cloudInit.userData) } <#else> userData: {} - <#if cr.spec.cloudInit.networkConfig??> - networkConfig: ${ cr.spec.cloudInit.networkConfig.toString() } + <#if spec.cloudInit.networkConfig??> + networkConfig: ${ toJson(spec.cloudInit.networkConfig) } # Define the VM (required) vm: # The VM's name (required) - name: ${ cr.metadata.name.asString } + name: ${ cr.name() } # The machine's uuid. If none is specified, a uuid is generated # and stored in the data directory. If the uuid is important # (e.g. because licenses depend on it) it is recommaned to specify # it here explicitly or to carefully backup the data directory. # uuid: "generated uuid" - <#if cr.spec.vm.machineUuid??> - uuid: "${ cr.spec.vm.machineUuid.asString }" + <#if spec.vm.machineUuid??> + uuid: "${ spec.vm.machineUuid }" # Whether to provide a software TPM (defaults to false) # useTpm: false - useTpm: ${ cr.spec.vm.useTpm.asBoolean?c } + useTpm: ${ spec.vm.useTpm?c } # How to boot (see https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml): # * bios # * uefi[-4m] # * secure[-4m] - firmware: ${ cr.spec.vm.firmware.asString } + firmware: ${ spec.vm.firmware } # Whether to show a boot menu. # bootMenu: false - bootMenu: ${ cr.spec.vm.bootMenu.asBoolean?c } + bootMenu: ${ spec.vm.bootMenu?c } # When terminating, a graceful powerdown is attempted. If it # doesn't succeed within the given timeout (seconds) SIGTERM # is sent to Qemu. # powerdownTimeout: 900 - powerdownTimeout: ${ cr.spec.vm.powerdownTimeout.asLong?c } + powerdownTimeout: ${ spec.vm.powerdownTimeout?c } # CPU settings - cpuModel: ${ cr.spec.vm.cpuModel.asString } + cpuModel: ${ spec.vm.cpuModel } # Setting maximumCpus to 1 omits the "-smp" options. The defaults (0) # cause the corresponding property to be omitted from the "-smp" option. # If currentCpus is greater than maximumCpus, the latter is adjusted. - <#if cr.spec.vm.maximumCpus?? > - maximumCpus: ${ parseQuantity(cr.spec.vm.maximumCpus.asString)?c } + <#if spec.vm.maximumCpus?? > + maximumCpus: ${ parseQuantity(spec.vm.maximumCpus)?c } - <#if cr.spec.vm.cpuTopology?? > - sockets: ${ cr.spec.vm.cpuTopology.sockets.asInt?c } - diesPerSocket: ${ cr.spec.vm.cpuTopology.diesPerSocket.asInt?c } - coresPerDie: ${ cr.spec.vm.cpuTopology.coresPerDie.asInt?c } - threadsPerCore: ${ cr.spec.vm.cpuTopology.threadsPerCore.asInt?c } + <#if spec.vm.cpuTopology?? > + sockets: ${ spec.vm.cpuTopology.sockets?c } + diesPerSocket: ${ spec.vm.cpuTopology.diesPerSocket?c } + coresPerDie: ${ spec.vm.cpuTopology.coresPerDie?c } + threadsPerCore: ${ spec.vm.cpuTopology.threadsPerCore?c } - <#if cr.spec.vm.currentCpus?? > - currentCpus: ${ parseQuantity(cr.spec.vm.currentCpus.asString)?c } + <#if spec.vm.currentCpus?? > + currentCpus: ${ parseQuantity(spec.vm.currentCpus)?c } # RAM settings # Maximum defaults to 1G - maximumRam: "${ formatMemory(parseQuantity(cr.spec.vm.maximumRam.asString)) }" - <#if cr.spec.vm.currentRam?? > - currentRam: "${ formatMemory(parseQuantity(cr.spec.vm.currentRam.asString)) }" + maximumRam: "${ formatMemory(parseQuantity(spec.vm.maximumRam)) }" + <#if spec.vm.currentRam?? > + currentRam: "${ formatMemory(parseQuantity(spec.vm.currentRam)) }" # RTC settings. # rtcBase: utc # rtcClock: rt - rtcBase: ${ cr.spec.vm.rtcBase.asString } - rtcClock: ${ cr.spec.vm.rtcClock.asString } + rtcBase: ${ spec.vm.rtcBase } + rtcClock: ${ spec.vm.rtcClock } # Network settings # Supported types are "tap" and "user" (for debugging). Type "user" @@ -147,19 +148,19 @@ data: # mac: (undefined) network: <#assign nwCounter = 0/> - <#list cr.spec.vm.networks.asList() as itf> + <#list spec.vm.networks as itf> <#if itf.tap??> - type: tap - device: ${ itf.tap.device.asString } - bridge: ${ itf.tap.bridge.asString } + device: ${ itf.tap.device } + bridge: ${ itf.tap.bridge } <#if itf.tap.mac??> - mac: "${ itf.tap.mac.asString }" + mac: "${ itf.tap.mac }" <#elseif itf.user??> - type: user - device: ${ itf.user.device.asString } + device: ${ itf.user.device } <#if itf.user.net??> - net: "${ itf.user.net.asString }" + net: "${ itf.user.net }" <#assign nwCounter += 1/> @@ -175,11 +176,11 @@ data: # file: (undefined) drives: <#assign drvCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate?? && disk.volumeClaimTemplate.metadata?? && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> + <#assign diskName = disk.volumeClaimTemplate.metadata.name + "-disk"> <#else> <#assign diskName = "disk-" + drvCounter> @@ -187,33 +188,33 @@ data: - type: raw resource: /dev/${ diskName } <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } <#assign drvCounter = drvCounter + 1/> <#if disk.cdrom??> - type: ide-cd - file: "${ disk.cdrom.image.asString }" + file: "${ imageLocation(disk.cdrom.image) }" <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } display: - <#if cr.spec.vm.display.outputs?? > - outputs: ${ cr.spec.vm.display.outputs.asInt?c } + <#if spec.vm.display.outputs?? > + outputs: ${ spec.vm.display.outputs?c } - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.spice??> spice: - port: ${ cr.spec.vm.display.spice.port.asInt?c } - <#if cr.spec.vm.display.spice.ticket??> - ticket: "${ cr.spec.vm.display.spice.ticket.asString }" + port: ${ spec.vm.display.spice.port?c } + <#if spec.vm.display.spice.ticket??> + ticket: "${ spec.vm.display.spice.ticket }" - <#if cr.spec.vm.display.spice.streamingVideo??> - streaming-video: "${ cr.spec.vm.display.spice.streamingVideo.asString }" + <#if spec.vm.display.spice.streamingVideo??> + streaming-video: "${ spec.vm.display.spice.streamingVideo }" - usbRedirects: ${ cr.spec.vm.display.spice.usbRedirects.asInt?c } + usbRedirects: ${ spec.vm.display.spice.usbRedirects?c } logging.properties: | diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml index a1a94e1bf..ddb638c66 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml @@ -1,11 +1,11 @@ kind: PersistentVolumeClaim apiVersion: v1 metadata: - namespace: ${ cr.metadata.namespace.asString } + namespace: ${ cr.namespace() } name: ${ runnerDataPvcName } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } spec: accessModes: diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml index 732f592ff..8258d55ae 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml @@ -1,16 +1,16 @@ kind: PersistentVolumeClaim apiVersion: v1 metadata: - namespace: ${ cr.metadata.namespace.asString } + namespace: ${ cr.namespace() } name: ${ disk.generatedPvcName } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } <#if disk.volumeClaimTemplate.metadata?? && disk.volumeClaimTemplate.metadata.annotations??> annotations: - ${ disk.volumeClaimTemplate.metadata.annotations.toString() } + ${ toJson(disk.volumeClaimTemplate.metadata.annotations) } spec: - ${ disk.volumeClaimTemplate.spec.toString() } + ${ toJson(disk.volumeClaimTemplate.spec) } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml index 2c32aa64b..9a70e196e 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -1,26 +1,26 @@ apiVersion: v1 kind: Service metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } + - apiVersion: ${ cr.apiVersion } kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false spec: type: LoadBalancer ports: - name: spice - port: ${ cr.spec.vm.display.spice.port.asInt?c } + port: ${ cr.spec().vm.display.spice.port?c } selector: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml index ad652e4c5..e62ac7039 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml @@ -1,42 +1,43 @@ kind: Pod apiVersion: v1 metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/component: ${ constants.APP_NAME } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: # Triggers update of config map mounted in pod # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }" + vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion }" vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } + - apiVersion: ${ cr.apiVersion } kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } blockOwnerDeletion: true controller: false +<#assign spec = cr.spec() /> spec: containers: - - name: ${ cr.metadata.name.asString } - <#assign image = cr.spec.image> + - name: ${ cr.name() } + <#assign image = spec.image> <#if image.source??> - image: ${ image.source.asString } + image: ${ image.source } <#else> - image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString } + image: ${ image.repository }/${ image.path }<#if image.version??>:${ image.version } <#if image.pullPolicy??> - imagePullPolicy: ${ image.pullPolicy.asString } + imagePullPolicy: ${ image.pullPolicy } - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.spice??> ports: - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.spice??> - name: spice - containerPort: ${ cr.spec.vm.display.spice.port.asInt?c } + containerPort: ${ spec.vm.display.spice.port?c } protocol: TCP @@ -55,33 +56,33 @@ spec: - name: vmop-image-repository mountPath: ${ constants.IMAGE_REPO_PATH } volumeDevices: - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate??> - - name: ${ disk.generatedDiskName.asString } - devicePath: /dev/${ disk.generatedDiskName.asString } + - name: ${ disk.generatedDiskName } + devicePath: /dev/${ disk.generatedDiskName } securityContext: privileged: true - <#if cr.spec.resources??> - resources: ${ cr.spec.resources.toString() } + <#if spec.resources??> + resources: ${ toJson(spec.resources) } <#else> - <#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? > + <#if spec.vm.currentCpus?? || spec.vm.currentRam?? > resources: requests: - <#if cr.spec.vm.currentCpus?? > + <#if spec.vm.currentCpus?? > <#assign factor = 2.0 /> <#if reconciler.cpuOvercommit??> <#assign factor = reconciler.cpuOvercommit * 1.0 /> - cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c } + cpu: ${ (parseQuantity(spec.vm.currentCpus) / factor)?c } - <#if cr.spec.vm.currentRam?? > + <#if spec.vm.currentRam?? > <#assign factor = 1.25 /> <#if reconciler.ramOvercommit??> <#assign factor = reconciler.ramOvercommit * 1.0 /> - memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c } + memory: ${ (parseQuantity(spec.vm.currentRam) / factor)?floor?c } @@ -102,7 +103,7 @@ spec: projected: sources: - configMap: - name: ${ cr.metadata.name.asString } + name: ${ cr.name() } <#if displaySecret??> - secret: name: ${ displaySecret } @@ -113,22 +114,22 @@ spec: - name: runner-data persistentVolumeClaim: claimName: ${ runnerDataPvcName } - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate??> - - name: ${ disk.generatedDiskName.asString } + - name: ${ disk.generatedDiskName } persistentVolumeClaim: - claimName: ${ disk.generatedPvcName.asString } + claimName: ${ disk.generatedPvcName } hostNetwork: true - terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c } - <#if cr.spec.nodeName??> - nodeName: ${ cr.spec.nodeName.asString } + terminationGracePeriodSeconds: ${ (spec.vm.powerdownTimeout + 5)?c } + <#if spec.nodeName??> + nodeName: ${ spec.nodeName } - <#if cr.spec.nodeSelector??> - nodeSelector: ${ cr.spec.nodeSelector.toString() } + <#if spec.nodeSelector??> + nodeSelector: ${ toJson(spec.nodeSelector) } - <#if cr.spec.affinity??> - affinity: ${ cr.spec.affinity.toString() } + <#if spec.affinity??> + affinity: ${ toJson(spec.affinity) } serviceAccountName: vm-runner diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml deleted file mode 100644 index 3d4a31673..000000000 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml +++ /dev/null @@ -1,194 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - vmoperator.jdrupes.org/version: ${ managerVersion } - ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } - blockOwnerDeletion: true - controller: false - -spec: - selector: - matchLabels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - replicas: ${ (cr.spec.vm.state.asString == "Running")?then(1, 0) } - updateStrategy: - type: OnDelete - template: - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/component: ${ constants.APP_NAME } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - # Triggers update of config map mounted in pod - # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }" - vmoperator.jdrupes.org/version: ${ managerVersion } - spec: - containers: - - name: ${ cr.metadata.name.asString } - <#assign image = cr.spec.image> - <#if image.source??> - image: ${ image.source.asString } - <#else> - image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString } - - <#if image.pullPolicy??> - imagePullPolicy: ${ image.pullPolicy.asString } - - <#if cr.spec.vm.display.spice??> - ports: - <#if cr.spec.vm.display.spice??> - - name: spice - containerPort: ${ cr.spec.vm.display.spice.port.asInt?c } - protocol: TCP - - - volumeMounts: - # Not needed because pod is priviledged: - # - mountPath: /dev/kvm - # name: dev-kvm - # - mountPath: /dev/net/tun - # name: dev-tun - # - mountPath: /sys/fs/cgroup - # name: cgroup - - name: config - mountPath: /etc/opt/vmrunner - - name: runner-data - mountPath: /var/local/vm-data - - name: vmop-image-repository - mountPath: ${ constants.IMAGE_REPO_PATH } - volumeDevices: - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - name: ${ diskName } - devicePath: /dev/${ diskName } - <#assign diskCounter = diskCounter + 1/> - - - securityContext: - privileged: true - <#if cr.spec.resources??> - resources: ${ cr.spec.resources.toString() } - <#else> - <#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? > - resources: - requests: - <#if cr.spec.vm.currentCpus?? > - <#assign factor = 2.0 /> - <#if reconciler.cpuOvercommit??> - <#assign factor = reconciler.cpuOvercommit * 1.0 /> - - cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c } - - <#if cr.spec.vm.currentRam?? > - <#assign factor = 1.25 /> - <#if reconciler.ramOvercommit??> - <#assign factor = reconciler.ramOvercommit * 1.0 /> - - memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c } - - - - volumes: - # Not needed because pod is priviledged: - # - name: dev-kvm - # hostPath: - # path: /dev/kvm - # type: CharDevice - # - hostPath: - # path: /dev/net/tun - # type: CharDevice - # name: dev-tun - # - name: cgroup - # hostPath: - # path: /sys/fs/cgroup - - name: config - projected: - sources: - - configMap: - name: ${ cr.metadata.name.asString } - <#if displaySecret??> - - secret: - name: ${ displaySecret } - - - name: vmop-image-repository - persistentVolumeClaim: - claimName: vmop-image-repository - hostNetwork: true - terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c } - <#if cr.spec.nodeName??> - nodeName: ${ cr.spec.nodeName.asString } - - <#if cr.spec.nodeSelector??> - nodeSelector: ${ cr.spec.nodeSelector.toString() } - - <#if cr.spec.affinity??> - affinity: ${ cr.spec.affinity.toString() } - - serviceAccountName: vm-runner - volumeClaimTemplates: - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: runner-data - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - spec: - accessModes: - - ReadWriteOnce - <#if reconciler.runnerDataPvc?? && reconciler.runnerDataPvc.storageClassName??> - storageClassName: ${ reconciler.runnerDataPvc.storageClassName } - - resources: - requests: - storage: 1Mi - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ diskName } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.annotations??> - annotations: - ${ disk.volumeClaimTemplate.metadata.annotations.toString() } - - spec: - ${ disk.volumeClaimTemplate.spec.toString() } - <#assign diskCounter = diskCounter + 1/> - - diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 4219e53e7..129a54de5 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -68,7 +68,7 @@ public ConfigMapReconciler(Configuration fmConfig) { * @throws TemplateException the template exception * @throws ApiException the api exception */ - public DynamicKubernetesObject reconcile(Map model, + public Map reconcile(Map model, VmChannel channel) throws IOException, TemplateException, ApiException { // Get API @@ -87,7 +87,10 @@ public DynamicKubernetesObject reconcile(Map model, // Apply and maybe force pod update var newState = K8s.apply(cmApi, mapDef, out.toString()); maybeForceUpdate(channel.client(), newState); - return newState; + @SuppressWarnings("unchecked") + var res = (Map) channel.client().getJSON().getGson() + .fromJson(newState.getRaw(), Map.class); + return res; } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index fa0bbf0aa..69d40586e 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -256,10 +256,9 @@ private boolean stillValid(String expiry) { @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onVmDefChanged(VmDefChanged event, Channel channel) { synchronized (pendingGets) { - String vmName = event.vmDefinition().metadata().getName(); + String vmName = event.vmDefinition().name(); for (var pending : pendingGets) { - if (pending.event.vmDefinition().metadata().getName() - .equals(vmName) + if (pending.event.vmDefinition().name().equals(vmName) && event.vmDefinition().displayPasswordSerial() .map(s -> s >= pending.expectedSerial).orElse(false)) { pending.lock.remove(); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index 17456aa7c..0665d3247 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -18,7 +18,6 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonPrimitive; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; @@ -36,7 +35,7 @@ import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jose4j.base64url.Base64; /** @@ -61,32 +60,31 @@ 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(true)) { + var display = event.vmDefinition().fromVm("display").get(); + if (!DataPath. get(display, "spice", "generateSecret") + .orElse(true)) { return; } // Check if exists - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + metadata.getName()); - var stubs = K8sV1SecretStub.list(channel.client(), - metadata.getNamespace(), options); + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); if (!stubs.isEmpty()) { return; } // Create secret var secret = new V1Secret(); - secret.setMetadata(new V1ObjectMeta().namespace(metadata.getNamespace()) - .name(metadata.getName() + "-" + COMP_DISPLAY_SECRET) + secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) + .name(vmDef.name() + "-" + COMP_DISPLAY_SECRET) .putLabelsItem("app.kubernetes.io/name", APP_NAME) .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) - .putLabelsItem("app.kubernetes.io/instance", metadata.getName())); + .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); secret.setType("Opaque"); SecureRandom random = null; try { diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java index 85158c71f..2d632b972 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -18,22 +18,23 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonObject; +import com.google.gson.Gson; import freemarker.template.Configuration; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1APIService; import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; import java.io.IOException; import java.io.StringWriter; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sV1ServiceStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.GsonPtr; @@ -92,11 +93,13 @@ public void reconcile(VmDefChanged event, if (lbsDef instanceof Boolean isOn && !isOn) { return; } - JsonObject cfgMeta = new JsonObject(); - if (lbsDef instanceof Map) { - var json = channel.client().getJSON(); - cfgMeta - = json.deserialize(json.serialize(lbsDef), JsonObject.class); + + // Load balancer can also be turned off for VM + var vmDef = event.vmDefinition(); + if (vmDef + .>> fromSpec(LOAD_BALANCER_SERVICE) + .map(m -> m.isEmpty()).orElse(false)) { + return; } // Combine template and data and parse result @@ -107,53 +110,78 @@ public void reconcile(VmDefChanged event, // https://github.com/kubernetes-client/java/issues/2741 var svcDef = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - mergeMetadata(svcDef, cfgMeta, event.vmDefinition()); + @SuppressWarnings("unchecked") + var defaults = lbsDef instanceof Map + ? (Map>) lbsDef + : null; + var client = channel.client(); + mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef); // Apply - DynamicKubernetesApi svcApi = new DynamicKubernetesApi("", "v1", - "services", channel.client()); - K8s.apply(svcApi, svcDef, svcDef.getRaw().toString()); + var svcStub = K8sV1ServiceStub + .get(client, vmDef.namespace(), vmDef.name()); + if (svcStub.apply(svcDef).isEmpty()) { + logger.warning( + () -> "Could not patch service for " + svcStub.name()); + } } - private void mergeMetadata(DynamicKubernetesObject svcDef, - JsonObject cfgMeta, K8sDynamicModel vmDefinition) { - // Get metadata from VM definition - var vmMeta = GsonPtr.to(vmDefinition.data()).to("spec") - .get(JsonObject.class, LOAD_BALANCER_SERVICE) - .map(JsonObject::deepCopy).orElseGet(() -> new JsonObject()); - - // Merge Data from VM definition into config data - mergeReplace(GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(LABELS).get(JsonObject.class)); - mergeReplace( - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(ANNOTATIONS).get(JsonObject.class)); + private void mergeMetadata(Gson gson, DynamicKubernetesObject svcDef, + Map> defaults, + VmDefinition vmDefinition) { + // Get specific load balancer metadata from VM definition + var vmLbMeta = vmDefinition + .>> fromSpec(LOAD_BALANCER_SERVICE) + .orElse(Collections.emptyMap()); - // Merge additional data into service definition - var svcMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); - mergeIfAbsent(svcMeta.to(LABELS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class)); - mergeIfAbsent(svcMeta.to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class)); + // Merge + var svcMeta = svcDef.getMetadata(); + var svcJsonMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); + Optional.ofNullable(mergeIfAbsent(svcMeta.getLabels(), + mergeReplace(defaults.get(LABELS), vmLbMeta.get(LABELS)))) + .ifPresent(lbls -> svcJsonMeta.set(LABELS, gson.toJsonTree(lbls))); + Optional.ofNullable(mergeIfAbsent(svcMeta.getAnnotations(), + mergeReplace(defaults.get(ANNOTATIONS), vmLbMeta.get(ANNOTATIONS)))) + .ifPresent(as -> svcJsonMeta.set(ANNOTATIONS, gson.toJsonTree(as))); } - private void mergeReplace(JsonObject dest, JsonObject src) { + private Map mergeReplace(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (e.getValue().isJsonNull()) { + if (e.getValue() == null) { dest.remove(e.getKey()); continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } - private void mergeIfAbsent(JsonObject dest, JsonObject src) { + private Map mergeIfAbsent(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (dest.has(e.getKey())) { + if (dest.containsKey(e.getKey())) { continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java index 33c22218d..4ee96d866 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java @@ -20,7 +20,6 @@ import freemarker.template.Configuration; import freemarker.template.TemplateException; -import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.generic.dynamic.Dynamics; import io.kubernetes.client.util.generic.options.PatchOptions; @@ -29,7 +28,7 @@ import java.util.Map; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8sV1PodStub; -import org.jdrupes.vmoperator.common.VmDefinitionModel.RequestedVmState; +import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.yaml.snakeyaml.LoaderOptions; @@ -73,18 +72,18 @@ public void reconcile(VmDefChanged event, Map model, } // Get pod stub. - var metadata = event.vmDefinition().getMetadata(); - var podStub = K8sV1PodStub.get(channel.client(), - metadata.getNamespace(), metadata.getName()); + var vmDef = event.vmDefinition(); + var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), + vmDef.name()); // Nothing to do if exists and should be running - if (event.vmDefinition().vmState() == RequestedVmState.RUNNING + if (vmDef.vmState() == RequestedVmState.RUNNING && podStub.model().isPresent()) { return; } // Delete if running but should be stopped - if (event.vmDefinition().vmState() == RequestedVmState.STOPPED) { + if (vmDef.vmState() == RequestedVmState.STOPPED) { if (podStub.model().isPresent()) { podStub.delete(); } @@ -104,9 +103,7 @@ public void reconcile(VmDefChanged event, Map model, PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (podStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, - new V1Patch(channel.client().getJSON().serialize(podDef)), opts) - .isEmpty()) { + if (podStub.apply(podDef).isEmpty()) { logger.warning( () -> "Could not patch pod for " + podStub.name()); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java index 39fcfe9d0..d044199c4 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -18,8 +18,6 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import freemarker.core.ParseException; import freemarker.template.Configuration; import freemarker.template.MalformedTemplateNameException; @@ -32,6 +30,7 @@ import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.io.StringWriter; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Logger; @@ -41,7 +40,7 @@ import org.jdrupes.vmoperator.common.K8sV1PvcStub; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -78,16 +77,16 @@ public PvcReconciler(Configuration fmConfig) { public void reconcile(VmDefChanged event, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); // Existing disks ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/instance=" + metadata.getName()); + + "app.kubernetes.io/instance=" + vmDef.name()); var knownDisks = K8sV1PvcStub.list(channel.client(), - metadata.getNamespace(), listOpts); + vmDef.namespace(), listOpts); var knownPvcs = knownDisks.stream().map(K8sV1PvcStub::name) .collect(Collectors.toSet()); @@ -95,23 +94,23 @@ public void reconcile(VmDefChanged event, Map model, reconcileRunnerDataPvc(event, model, channel, knownPvcs); // Reconcile pvcs for defined disks - var diskDefs = GsonPtr.to((JsonObject) model.get("cr")) - .getAsListOf(JsonObject.class, "spec", "vm", "disks"); + var diskDefs = vmDef.>> fromVm("disks") + .orElse(List.of()); var diskCounter = 0; for (var diskDef : diskDefs) { - if (!diskDef.has("volumeClaimTemplate")) { + if (!diskDef.containsKey("volumeClaimTemplate")) { continue; } - var diskName = GsonPtr.to(diskDef) - .getAsString("volumeClaimTemplate", "metadata", "name") - .map(name -> name + "-disk").orElse("disk-" + diskCounter); + var diskName = DataPath.get(diskDef, "volumeClaimTemplate", + "metadata", "name").map(name -> name + "-disk") + .orElse("disk-" + diskCounter); diskCounter += 1; - diskDef.addProperty("generatedDiskName", diskName); + diskDef.put("generatedDiskName", diskName); // Don't do anything if pvc with old (sts generated) name exists. - var stsDiskPvcName = diskName + "-" + metadata.getName() + "-0"; + var stsDiskPvcName = diskName + "-" + vmDef.name() + "-0"; if (knownPvcs.contains(stsDiskPvcName)) { - diskDef.addProperty("generatedPvcName", stsDiskPvcName); + diskDef.put("generatedPvcName", stsDiskPvcName); continue; } @@ -127,18 +126,18 @@ private void reconcileRunnerDataPvc(VmDefChanged event, Set knownPvcs) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); // Look for old (sts generated) name. var stsRunnerDataPvcName - = "runner-data" + "-" + metadata.getName() + "-0"; + = "runner-data" + "-" + vmDef.name() + "-0"; if (knownPvcs.contains(stsRunnerDataPvcName)) { model.put("runnerDataPvcName", stsRunnerDataPvcName); return; } // Generate PVC - model.put("runnerDataPvcName", metadata.getName() + "-runner-data"); + model.put("runnerDataPvcName", vmDef.name() + "-runner-data"); var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -149,7 +148,7 @@ private void reconcileRunnerDataPvc(VmDefChanged event, // Do apply changes var pvcStub = K8sV1PvcStub.get(channel.client(), - metadata.getNamespace(), (String) model.get("runnerDataPvcName")); + vmDef.namespace(), (String) model.get("runnerDataPvcName")); PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); @@ -165,13 +164,13 @@ private void reconcileRunnerDiskPvc(VmDefChanged event, Map model, VmChannel channel) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); + var vmDef = event.vmDefinition(); // Generate PVC - var diskDef = GsonPtr.to((JsonElement) model.get("disk")); - var pvcName = metadata.getName() + "-" - + diskDef.getAsString("generatedDiskName").get(); - diskDef.set("generatedPvcName", pvcName); + @SuppressWarnings("unchecked") + var diskDef = (Map) model.get("disk"); + var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName"); + diskDef.put("generatedPvcName", pvcName); var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -181,9 +180,8 @@ private void reconcileRunnerDiskPvc(VmDefChanged event, new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); // Do apply changes - var pvcStub = K8sV1PvcStub.get(channel.client(), - metadata.getNamespace(), GsonPtr.to((JsonElement) model.get("disk")) - .getAsString("generatedPvcName").get()); + var pvcStub + = K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 17ba60d65..32753ac55 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -18,11 +18,13 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.SimpleNumber; +import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; import freemarker.template.TemplateHashModel; @@ -30,7 +32,7 @@ import freemarker.template.TemplateModelException; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.math.BigDecimal; @@ -44,15 +46,15 @@ import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; -import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -135,6 +137,10 @@ "PMD.AvoidDuplicateLiterals" }) public class Reconciler extends Component { + /** The Constant mapper. */ + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + @SuppressWarnings("PMD.SingularField") private final Configuration fmConfig; private final ConfigMapReconciler cmReconciler; @@ -203,17 +209,17 @@ public void onVmDefChanged(VmDefChanged event, VmChannel channel) } // Ownership relationships takes care of deletions - var defMeta = event.vmDefinition().getMetadata(); if (event.type() == K8sObserver.ResponseType.DELETED) { - logger.fine(() -> "VM \"" + defMeta.getName() + "\" deleted"); + logger.fine( + () -> "VM \"" + event.vmDefinition().name() + "\" deleted"); return; } - // Reconcile, use "augmented" vm definition for model + // Create model for processing templates Map model - = prepareModel(channel.client(), patchCr(event.vmDefinition())); + = prepareModel(channel.client(), event.vmDefinition()); var configMap = cmReconciler.reconcile(model, channel); - model.put("cm", configMap.getRaw()); + model.put("cm", configMap); dsReconciler.reconcile(event, model, channel); // Manage (eventual) removal of stateful set. stsReconciler.reconcile(event, model, channel); @@ -235,81 +241,22 @@ public void onVmDefChanged(VmDefChanged event, VmChannel channel) @Handler public void onResetVm(ResetVm event, VmChannel channel) throws ApiException, IOException, TemplateException { - var defRoot - = GsonPtr.to(channel.vmDefinition().data()).get(JsonObject.class); - defRoot.addProperty("resetCount", - defRoot.get("resetCount").getAsLong() + 1); + var vmDef = channel.vmDefinition(); + vmDef.extra("resetCount", vmDef. extra("resetCount") + 1); Map model - = prepareModel(channel.client(), patchCr(channel.vmDefinition())); + = prepareModel(channel.client(), channel.vmDefinition()); cmReconciler.reconcile(model, channel); } - private DynamicKubernetesObject patchCr(K8sDynamicModel vmDef) { - var json = vmDef.data().deepCopy(); - // Adjust cdromImage path - adjustCdRomPaths(json); - - // Adjust cloud-init data - adjustCloudInitData(json); - - return new DynamicKubernetesObject(json); - } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private void adjustCdRomPaths(JsonObject json) { - var disks - = GsonPtr.to(json).to("spec", "vm", "disks").get(JsonArray.class); - for (var disk : disks) { - var cdrom = (JsonObject) ((JsonObject) disk).get("cdrom"); - if (cdrom == null) { - continue; - } - String image = cdrom.get("image").getAsString(); - if (image.isEmpty()) { - continue; - } - try { - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH - + "/").resolve(image); - if ("file".equals(imageUri.getScheme())) { - cdrom.addProperty("image", imageUri.getPath()); - } else { - cdrom.addProperty("image", imageUri.toString()); - } - } catch (URISyntaxException e) { - logger.warning(() -> "Invalid CDROM image: " + image); - } - } - } - - private void adjustCloudInitData(JsonObject json) { - var spec = GsonPtr.to(json).to("spec").get(JsonObject.class); - if (!spec.has("cloudInit")) { - return; - } - var metaData = GsonPtr.to(spec).to("cloudInit", "metaData"); - if (metaData.getAsString("instance-id").isEmpty()) { - metaData.set("instance-id", - GsonPtr.to(json).getAsString("metadata", "resourceVersion") - .map(s -> "v" + s).orElse("v1")); - } - if (metaData.getAsString("local-hostname").isEmpty()) { - metaData.set("local-hostname", - GsonPtr.to(json).getAsString("metadata", "name").get()); - } - } - - @SuppressWarnings("PMD.CognitiveComplexity") + @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity" }) private Map prepareModel(K8sClient client, - DynamicKubernetesObject vmDef) - throws TemplateModelException, ApiException { + VmDefinition vmDef) throws TemplateModelException, ApiException { @SuppressWarnings("PMD.UseConcurrentHashMap") Map model = new HashMap<>(); model.put("managerVersion", Optional.ofNullable(Reconciler.class.getPackage() .getImplementationVersion()).orElse("(Unknown)")); - model.put("cr", vmDef.getRaw()); + model.put("cr", vmDef); model.put("constants", (TemplateHashModel) new DefaultObjectWrapperBuilder( Configuration.VERSION_2_3_32) @@ -321,9 +268,10 @@ private Map prepareModel(K8sClient client, ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + vmDef.getMetadata().getName()); + + "app.kubernetes.io/instance=" + vmDef.name()); var dsStub = K8sV1SecretStub - .list(client, vmDef.getMetadata().getNamespace(), options).stream() + .list(client, vmDef.namespace(), options) + .stream() .findFirst(); if (dsStub.isPresent()) { dsStub.get().model().ifPresent(m -> { @@ -332,14 +280,23 @@ private Map prepareModel(K8sClient client, } // Methods - model.put("parseQuantity", new TemplateMethodModelEx() { + model.put("parseQuantity", parseQuantityModel); + model.put("formatMemory", formatMemoryModel); + model.put("imageLocation", imgageLocationModel); + model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); + model.put("toJson", toJsonModel); + return model; + } + + private final TemplateMethodModelEx parseQuantityModel + = new TemplateMethodModelEx() { @Override @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var arg = arguments.get(0); - if (arg instanceof Number number) { - return number; + if (arg instanceof SimpleNumber number) { + return number.getAsNumber(); } try { return Quantity.fromString(arg.toString()).getNumber(); @@ -348,8 +305,10 @@ public Object exec(@SuppressWarnings("rawtypes") List arguments) + "specified as \"" + arg + "\": " + e.getMessage()); } } - }); - model.put("formatMemory", new TemplateMethodModelEx() { + }; + + private final TemplateMethodModelEx formatMemoryModel + = new TemplateMethodModelEx() { @Override @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) @@ -376,7 +335,71 @@ public Object exec(@SuppressWarnings("rawtypes") List arguments) } return Convertions.formatMemory(bigInt); } - }); - return model; - } + }; + + private final TemplateMethodModelEx imgageLocationModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings({ "PMD.PreserveStackTrace", + "PMD.AvoidLiteralsInIfCondition" }) + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + var image = ((SimpleScalar) arguments.get(0)).getAsString(); + if (image.isEmpty()) { + return ""; + } + try { + var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH + + "/").resolve(image); + if ("file".equals(imageUri.getScheme())) { + return imageUri.getPath(); + } + return imageUri.toString(); + } catch (URISyntaxException e) { + logger.warning(() -> "Invalid CDROM image: " + image); + } + return image; + } + }; + + private final TemplateMethodModelEx adjustCloudInitMetaModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings("PMD.PreserveStackTrace") + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + @SuppressWarnings("unchecked") + var res = (Map) DataPath + .deepCopy(((AdapterTemplateModel) arguments.get(0)) + .getAdaptedObject(Object.class)); + var metadata + = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) + .getAdaptedObject(Object.class); + if (!res.containsKey("instance-id")) { + res.put("instance-id", + Optional.ofNullable(metadata.getResourceVersion()) + .map(s -> "v" + s).orElse("v1")); + } + if (!res.containsKey("local-hostname")) { + res.put("local-hostname", metadata.getName()); + } + return res; + } + }; + + private final TemplateMethodModelEx toJsonModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings("PMD.PreserveStackTrace") + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + try { + return mapper.writeValueAsString( + ((AdapterTemplateModel) arguments.get(0)) + .getAdaptedObject(Object.class)); + } catch (JsonProcessingException e) { + return "{}"; + } + } + }; } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java index a58d16977..8803e619c 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java @@ -22,19 +22,14 @@ import freemarker.template.TemplateException; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.dynamic.Dynamics; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; -import java.io.StringWriter; import java.util.Map; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; +import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; -import org.yaml.snakeyaml.LoaderOptions; -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Before version 3.4, the pod running the VM was created by a stateful set. @@ -45,15 +40,15 @@ /* default */ class StatefulSetReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); - private final Configuration fmConfig; /** * Instantiates a new stateful set reconciler. * * @param fmConfig the fm config */ + @SuppressWarnings("PMD.UnusedFormalParameter") public StatefulSetReconciler(Configuration fmConfig) { - this.fmConfig = fmConfig; + // Nothing to do } /** @@ -70,12 +65,11 @@ public StatefulSetReconciler(Configuration fmConfig) { public void reconcile(VmDefChanged event, Map model, VmChannel channel) throws IOException, TemplateException, ApiException { - var metadata = event.vmDefinition().getMetadata(); model.put("usingSts", false); // If exists, delete when not running or supposed to be not running. var stsStub = K8sV1StatefulSetStub.get(channel.client(), - metadata.getNamespace(), metadata.getName()); + event.vmDefinition().namespace(), event.vmDefinition().name()); if (stsStub.model().isEmpty()) { return; } @@ -94,16 +88,7 @@ public void reconcile(VmDefChanged event, Map model, // Check if VM is supposed to be stopped. If so, // set replicas to 0. This is the first step of the transition, // the stateful set will be deleted when the VM is restarted. - var fmTemplate = fmConfig.getTemplate("runnerSts.ftl.yaml"); - StringWriter out = new StringWriter(); - fmTemplate.process(model, out); - // Avoid Yaml.load due to - // https://github.com/kubernetes-client/java/issues/2741 - var stsDef = Dynamics.newFromYaml( - new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - var desired = GsonPtr.to(stsDef.getRaw()) - .to("spec").getAsInt("replicas").orElse(1); - if (desired == 1) { + if (event.vmDefinition().vmState() == RequestedVmState.RUNNING) { return; } @@ -111,12 +96,12 @@ public void reconcile(VmDefChanged event, Map model, PatchOptions opts = new PatchOptions(); opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (stsStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, - new V1Patch(channel.client().getJSON().serialize(stsDef)), opts) - .isEmpty()) { + if (stsStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/replicas" + + "\", \"value\": 0}]"), + channel.client().defaultPatchOptions()).isEmpty()) { logger.warning( () -> "Could not patch stateful set for " + stsStub.name()); } } - } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 0ad501752..cc8ae7be0 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -18,13 +18,15 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.logging.Level; @@ -37,6 +39,7 @@ import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionModels; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -46,7 +49,7 @@ import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; @@ -116,42 +119,47 @@ protected void handleChange(K8sClient client, V1ObjectMeta metadata = response.object.getMetadata(); VmChannel channel = channelManager.channelGet(metadata.getName()); + // Remove from channel manager if deleted + if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { + channelManager.remove(metadata.getName()); + } + // Get full definition and associate with channel as backup - var vmDef = response.object; - if (vmDef.data() == null) { + var vmModel = response.object; + if (vmModel.data() == null) { // ADDED event does not provide data, see // https://github.com/kubernetes-client/java/issues/3215 - vmDef = getModel(client, vmDef); + vmModel = getModel(client, vmModel); } - if (vmDef.data() != null) { + VmDefinition vmDef = null; + if (vmModel.data() != null) { // New data, augment and save + vmDef = client.getJSON().getGson().fromJson(vmModel.data(), + VmDefinition.class); addDynamicData(channel.client(), vmDef, channel.vmDefinition()); channel.setVmDefinition(vmDef); - } else { - // Reuse cached + } + if (vmDef == null) { + // Reuse cached (e.g. if deleted) vmDef = channel.vmDefinition(); } if (vmDef == null) { - logger.warning( - () -> "Cannot get model for " + response.object.getMetadata()); + logger.warning(() -> "Cannot get defintion for " + + response.object.getMetadata()); return; } - if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { - channelManager.remove(metadata.getName()); - } // Create and fire changed event. Remove channel from channel // manager on completion. channel.pipeline() .fire(Event.onCompletion( new VmDefChanged(ResponseType.valueOf(response.type), - channel.setGeneration( - response.object.getMetadata().getGeneration()), + channel.setGeneration(response.object.getMetadata() + .getGeneration()), vmDef), e -> { if (e.type() == ResponseType.DELETED) { - channelManager - .remove(e.vmDefinition().metadata().getName()); + channelManager.remove(e.vmDefinition().name()); } }), channel); } @@ -166,51 +174,53 @@ private VmDefinitionModel getModel(K8sClient client, } } - private void addDynamicData(K8sClient client, VmDefinitionModel vmState, - VmDefinitionModel prevState) { - var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class); - + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + private void addDynamicData(K8sClient client, VmDefinition vmDef, + VmDefinition prevState) { // Maintain (or initialize) the resetCount - rootNode.addProperty("resetCount", Optional.ofNullable(prevState) - .map(ps -> GsonPtr.to(ps.data())) - .flatMap(d -> d.getAsLong("resetCount")).orElse(0L)); + vmDef.extra("resetCount", + Optional.ofNullable(prevState).map(d -> d.extra("resetCount")) + .orElse(0L)); + // Node information // Add defaults in case the VM is not running - rootNode.addProperty("nodeName", ""); - rootNode.addProperty("nodeAddress", ""); + vmDef.extra("nodeName", ""); + vmDef.extra("nodeAddress", ""); // VM definition status changes before the pod terminates. // This results in pod information being shown for a stopped // VM which is irritating. So check condition first. - var isRunning = GsonPtr.to(rootNode).to("status", "conditions") - .get(JsonArray.class) - .asList().stream().filter(el -> "Running" - .equals(((JsonObject) el).get("type").getAsString())) - .findFirst().map(el -> "True" - .equals(((JsonObject) el).get("status").getAsString())) - .orElse(false); + @SuppressWarnings("PMD.LambdaCanBeMethodReference") + var isRunning + = vmDef.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(t -> "Running".equals(t)).orElse(false)) + .findFirst().map(cond -> DataPath.get(cond, "status") + .map(s -> "True".equals(s)).orElse(false)) + .orElse(false); if (!isRunning) { return; } var podSearch = new ListOptions(); podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ",app.kubernetes.io/component=" + APP_NAME - + ",app.kubernetes.io/instance=" + vmState.getMetadata().getName()); + + ",app.kubernetes.io/instance=" + vmDef.name()); try { var podList = K8sV1PodStub.list(client, namespace(), podSearch); for (var podStub : podList) { var nodeName = podStub.model().get().getSpec().getNodeName(); - rootNode.addProperty("nodeName", nodeName); + vmDef.extra("nodeName", nodeName); logger.fine(() -> "Added node name " + nodeName - + " to VM info for " + vmState.getMetadata().getName()); + + " to VM info for " + vmDef.name()); @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var addrs = new JsonArray(); + var addrs = new ArrayList(); podStub.model().get().getStatus().getPodIPs().stream() .map(ip -> ip.getIp()).forEach(addrs::add); - rootNode.add("nodeAddresses", addrs); + vmDef.extra("nodeAddresses", addrs); logger.fine(() -> "Added node addresses " + addrs - + " to VM info for " + vmState.getMetadata().getName()); + + " to VM info for " + vmDef.name()); } } catch (ApiException e) { logger.log(Level.WARNING, e, diff --git a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml new file mode 100644 index 000000000..54ea11048 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml @@ -0,0 +1,64 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine +metadata: + namespace: vmop-dev + name: unittest-vm +spec: + image: + repository: docker-registry.lan.mnl.de + path: vmoperator/this.will.never.start + version: 0.0.0 + + cloudInit: + metaData: {} + + vm: + # state: Running + maximumRam: 4Gi + currentRam: 2Gi + maximumCpus: 4 + currentCpus: 2 + powerdownTimeout: 1 + + networks: + - user: {} + disks: + - cdrom: + image: https://test.com/test.iso + bootindex: 0 + - cdrom: + image: "image.iso" + - volumeClaimTemplate: + metadata: + name: system + annotations: + use_as: system-disk + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + - volumeClaimTemplate: + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + + display: + outputs: 2 + spice: + port: 5812 + usbRedirects: 2 + + resources: + requests: + cpu: 1 + memory: 2Gi + + loadBalancerService: + labels: + label2: replaced + label3: added + annotations: + anno1: added diff --git a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml deleted file mode 100644 index 0d395bd2e..000000000 --- a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: "vmoperator.jdrupes.org/v1" -kind: VirtualMachine -metadata: - namespace: vmop-dev - name: unittest-vm -spec: - resources: - requests: - cpu: 1 - memory: 2Gi - - loadBalancerService: - labels: - test2: null - test3: added - - vm: - # state: Running - maximumRam: 4Gi - currentRam: 2Gi - maximumCpus: 4 - currentCpus: 2 - powerdownTimeout: 1 - - networks: - - user: {} - disks: - - cdrom: - # image: "" - image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso - # image: "Fedora-Workstation-Live-x86_64-38-1.6.iso" - - display: - spice: - port: 5812 diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index bd479d04b..4f5d7a3c4 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -1,12 +1,19 @@ package org.jdrupes.vmoperator.manager; import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.FileReader; import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; @@ -15,7 +22,11 @@ import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; import org.jdrupes.vmoperator.common.K8sV1DeploymentStub; +import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.K8sV1ServiceStub; +import org.jdrupes.vmoperator.util.DataPath; import org.junit.jupiter.api.AfterAll; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.BeforeAll; @@ -29,6 +40,9 @@ class BasicTests { private static K8sClient client; private static APIResource vmsContext; private static K8sV1DeploymentStub mgrDeployment; + private static K8sDynamicStub vmStub; + private static final String VM_NAME = "unittest-vm"; + private static final Object EXISTS = new Object(); @BeforeAll static void setUpBeforeClass() throws Exception { @@ -38,23 +52,40 @@ static void setUpBeforeClass() throws Exception { // Get client client = new K8sClient(); + // Update manager pod by scaling deployment + mgrDeployment + = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator"); + mgrDeployment.scale(0); + mgrDeployment.scale(1); + waitForManager(); + // Context for working with our CR var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM); assertTrue(apiRes.isPresent()); vmsContext = apiRes.get(); // Cleanup existing VM - K8sDynamicStub.get(client, vmsContext, "vmop-dev", "unittest-vm") + K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME) .delete(); + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + for (var secret : secrets) { + secret.delete(); + } + deletePvcs(); - // Update manager pod by scaling deployment - mgrDeployment - = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator"); - mgrDeployment.scale(0); - mgrDeployment.scale(1); + // Load from Yaml + var rdr = new FileReader("test-resources/basic-vm.yaml"); + vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr); + assertTrue(vmStub.model().isPresent()); + } + private static void waitForManager() + throws ApiException, InterruptedException { // Wait until available - for (int i = 0; i < 10; i++) { if (mgrDeployment.model().get().getStatus().getConditions() .stream().filter(c -> "Available".equals(c.getType())).findAny() @@ -66,70 +97,250 @@ static void setUpBeforeClass() throws Exception { fail("vm-operator not deployed."); } + private static void deletePvcs() throws ApiException { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME); + var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts); + for (var pvc : knownPvcs) { + pvc.delete(); + } + } + @AfterAll static void tearDownAfterClass() throws Exception { + // Cleanup + K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME) + .delete(); + deletePvcs(); + // Bring down manager mgrDeployment.scale(0); } @Test - void test() throws IOException, InterruptedException, ApiException { - // Load from Yaml - var rdr = new FileReader("test-resources/unittest-vm.yaml"); - var vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr); - assertTrue(vmStub.model().isPresent()); - - // Wait for created resources - assertTrue(waitForConfigMap(client)); - assertTrue(waitForPvc(client)); - + void testConfigMap() + throws IOException, InterruptedException, ApiException { + K8sV1ConfigMapStub stub + = K8sV1ConfigMapStub.get(client, "vmop-dev", VM_NAME); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } // Check config map - var config = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm") - .model().get(); - var yaml = new Yaml(new SafeConstructor(new LoaderOptions())) + var config = stub.model().get(); + Map, Object> toCheck = Map.of( + List.of("namespace"), "vmop-dev", + List.of("name"), VM_NAME, + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS); + checkProps(config.getMetadata(), toCheck); + + toCheck = new LinkedHashMap<>(); + toCheck.put(List.of("/Runner", "guestShutdownStops"), false); + toCheck.put(List.of("/Runner", "cloudInit", "metaData", "instance-id"), + EXISTS); + toCheck.put( + List.of("/Runner", "cloudInit", "metaData", "local-hostname"), + VM_NAME); + toCheck.put(List.of("/Runner", "cloudInit", "userData"), Map.of()); + toCheck.put(List.of("/Runner", "vm", "maximumRam"), "4 GiB"); + toCheck.put(List.of("/Runner", "vm", "currentRam"), "2 GiB"); + toCheck.put(List.of("/Runner", "vm", "maximumCpus"), 4); + toCheck.put(List.of("/Runner", "vm", "currentCpus"), 2); + toCheck.put(List.of("/Runner", "vm", "powerdownTimeout"), 1); + toCheck.put(List.of("/Runner", "vm", "network", 0, "type"), "user"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "file"), + "https://test.com/test.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "bootindex"), 0); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "file"), + "/var/local/vmop-image-repository/image.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "resource"), + "/dev/system-disk"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "resource"), + "/dev/disk-1"); + toCheck.put(List.of("/Runner", "vm", "display", "outputs"), 2); + toCheck.put(List.of("/Runner", "vm", "display", "spice", "port"), 5812); + toCheck.put( + List.of("/Runner", "vm", "display", "spice", "usbRedirects"), 2); + var cm = new Yaml(new SafeConstructor(new LoaderOptions())) .load(config.getData().get("config.yaml")); - @SuppressWarnings("unchecked") - var maximumRam = ((Map>>) yaml) - .get("/Runner").get("vm").get("maximumRam"); - assertEquals("4 GiB", maximumRam); + checkProps(cm, toCheck); + } - // Cleanup - K8sDynamicStub.get(client, vmsContext, "vmop-dev", "unittest-vm") - .delete(); + @Test + void testDisplaySecret() throws ApiException, InterruptedException { ListOptions listOpts = new ListOptions(); - listOpts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/instance=unittest-vm"); - var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts); - for (var pvc : knownPvcs) { - pvc.delete(); + listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME + "," + + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + Collection secrets = null; + for (int i = 0; i < 10; i++) { + secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); + if (secrets.size() > 0) { + break; + } + Thread.sleep(1000); } + assertEquals(1, secrets.size()); + var secretData = secrets.iterator().next().model().get().getData(); + checkProps(secretData, Map.of( + List.of("display-password"), EXISTS)); + assertEquals("now", new String(secretData.get("password-expiry"))); } - private boolean waitForConfigMap(K8sClient client) - throws InterruptedException, ApiException { - var stub = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm"); + @Test + void testRunnerPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-runner-data"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Mi"))); + } + + @Test + void testSystemDiskPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-system-disk"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { - return true; + break; } Thread.sleep(1000); } - return false; + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME, + List.of("annotations", "use_as"), "system-disk")); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); } - private boolean waitForPvc(K8sClient client) - throws InterruptedException, ApiException { + @Test + void testDisk1Pvc() throws ApiException, InterruptedException { var stub - = K8sV1PvcStub.get(client, "vmop-dev", "unittest-vm-runner-data"); + = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-disk-1"); for (int i = 0; i < 10; i++) { if (stub.model().isPresent()) { - return true; + break; } Thread.sleep(1000); } - return false; + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); + } + + @Test + void testPod() throws ApiException, InterruptedException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + assertTrue(vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + + "\", \"value\": \"Running\"}]"), + client.defaultPatchOptions()).isPresent()); + var stub = K8sV1PodStub.get(client, "vmop-dev", VM_NAME); + for (int i = 0; i < 20; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pod = stub.model().get(); + checkProps(pod.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/component"), APP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), + Constants.VM_OP_NAME, + List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS)); + checkProps(pod.getSpec(), Map.of( + List.of("containers", 0, "image"), EXISTS, + List.of("containers", 0, "name"), VM_NAME, + List.of("containers", 0, "resources", "requests", "cpu"), + Quantity.fromString("1"))); + } + + @Test + public void testLoadBalancer() throws ApiException, InterruptedException { + var stub = K8sV1ServiceStub.get(client, "vmop-dev", VM_NAME); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var svc = stub.model().get(); + checkProps(svc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("labels", "label1"), "label1", + List.of("labels", "label2"), "replaced", + List.of("labels", "label3"), "added", + List.of("annotations", "metallb.universe.tf/loadBalancerIPs"), + "192.168.168.1", + List.of("annotations", "anno1"), "added")); + } + + private void checkProps(Object obj, + Map, Object> toCheck) { + for (var entry : toCheck.entrySet()) { + var prop = DataPath.get(obj, entry.getKey().toArray()); + assertTrue(prop.isPresent(), () -> "Property " + entry.getKey() + + " not found in " + obj); + + // Check for existance only + if (entry.getValue() == EXISTS) { + continue; + } + assertEquals(entry.getValue(), prop.get()); + } } } diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java new file mode 100644 index 000000000..be5b53012 --- /dev/null +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java @@ -0,0 +1,176 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.util; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Utility class that supports navigation through arbitrary data structures. + */ +public final class DataPath { + + private static final Logger logger + = Logger.getLogger(DataPath.class.getName()); + + private DataPath() { + } + + /** + * Apply the given selectors on the given object and return the + * value reached. + * + * Selectors can be if type {@link String} or {@link Number}. The + * former are used to access a property of an object, the latter to + * access an element in an array or a {@link List}. + * + * Depending on the object currently visited, a {@link String} can + * be the key of a {@link Map}, the property part of a getter method + * or the name of a method that has an empty parameter list. + * + * @param the generic type + * @param from the from + * @param selectors the selectors + * @return the result + */ + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public static Optional get(Object from, Object... selectors) { + Object cur = from; + for (var selector : selectors) { + if (cur == null) { + return Optional.empty(); + } + if (selector instanceof String && cur instanceof Map map) { + cur = map.get(selector); + continue; + } + if (selector instanceof Number index && cur instanceof List list) { + cur = list.get(index.intValue()); + continue; + } + if (selector instanceof String property) { + var retrieved = tryAccess(cur, property); + if (retrieved.isEmpty()) { + return Optional.empty(); + } + cur = retrieved.get(); + } + } + @SuppressWarnings("unchecked") + var result = Optional.ofNullable((T) cur); + return result; + } + + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + private static Optional tryAccess(Object obj, String property) { + Method acc = null; + try { + // Try getter + acc = obj.getClass().getMethod("get" + property.substring(0, 1) + .toUpperCase() + property.substring(1)); + } catch (SecurityException e) { + return Optional.empty(); + } catch (NoSuchMethodException e) { // NOPMD + // Can happen... + } + if (acc == null) { + try { + // Try method + acc = obj.getClass().getMethod(property); + } catch (SecurityException | NoSuchMethodException e) { + return Optional.empty(); + } + } + if (acc != null) { + try { + return Optional.ofNullable(acc.invoke(obj)); + } catch (IllegalAccessException + | InvocationTargetException e) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + /** + * Attempts to make a as-deep-as-possible copy of the given + * container. New containers will be created for Maps, Lists and + * Arrays. The method is invoked recursively for the entries/items. + * + * If invoked with an object that is neither a map, list or array, + * the methods checks if the object implements {@link Cloneable} + * and if it does, invokes its {@link Object#clone()} method. + * Else the method return the object. + * + * @param the generic type + * @param object the container + * @return the t + */ + @SuppressWarnings({ "PMD.CognitiveComplexity", "unchecked" }) + public static T deepCopy(T object) { + if (object instanceof Map map) { + @SuppressWarnings("PMD.UseConcurrentHashMap") + Map copy; + try { + copy = (Map) object.getClass().getConstructor() + .newInstance(); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + logger.severe( + () -> "Cannot create new instance of " + object.getClass()); + return null; + } + for (var entry : ((Map) map).entrySet()) { + copy.put(entry.getKey(), + deepCopy(entry.getValue())); + } + return (T) copy; + } + if (object instanceof List list) { + List copy = new ArrayList<>(); + for (var item : list) { + copy.add(deepCopy(item)); + } + return (T) copy; + } + if (object.getClass().isArray()) { + var copy = new ArrayList<>(); + for (var item : (Object[]) object) { + copy.add(deepCopy(item)); + } + return (T) copy.toArray(); + } + if (object instanceof Cloneable) { + try { + return (T) object.getClass().getMethod("clone") + .invoke(object); + } catch (IllegalAccessException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return object; + } + } + return object; + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/build.gradle b/org.jdrupes.vmoperator.vmconlet/build.gradle index 405625654..606c6cd44 100644 --- a/org.jdrupes.vmoperator.vmconlet/build.gradle +++ b/org.jdrupes.vmoperator.vmconlet/build.gradle @@ -5,7 +5,7 @@ plugins { dependencies { implementation project(':org.jdrupes.vmoperator.manager.events') - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.0.0.3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java index ea5976758..69418af53 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java @@ -18,8 +18,6 @@ package org.jdrupes.vmoperator.vmconlet; -import com.google.gson.Gson; -import com.google.gson.JsonObject; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; import freemarker.template.Template; @@ -31,16 +29,19 @@ import java.math.BigInteger; import java.time.Duration; import java.time.Instant; +import java.util.Collections; import java.util.EnumSet; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; import org.jgrapes.core.Manager; @@ -62,13 +63,14 @@ /** * The Class VmConlet. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", + "PMD.CouplingBetweenObjects" }) public class VmConlet extends FreeMarkerConlet { private static final Set MODES = RenderMode.asSet( RenderMode.Preview, RenderMode.View); private final ChannelTracker channelTracker = new ChannelTracker<>(); + VmDefinition> channelTracker = new ChannelTracker<>(); private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); private Summary cachedSummary; @@ -160,22 +162,45 @@ protected Set doRenderConlet(RenderConletRequestBase event, } if (sendVmInfos) { for (var item : channelTracker.values()) { - Gson gson = item.channel().client().getJSON().getGson(); - var def = gson.fromJson(item.associated().data(), Object.class); channel.respond(new NotifyConletView(type(), - conletId, "updateVm", def)); + conletId, "updateVm", + simplifiedVmDefinition(item.associated()))); } } return renderedAs; } + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + private Map simplifiedVmDefinition(VmDefinition vmDef) { + // Convert RAM sizes to unitless numbers + var spec = DataPath.deepCopy(vmDef.spec()); + var vmSpec = DataPath.> get(spec, "vm").get(); + vmSpec.put("maximumRam", Quantity.fromString( + DataPath. get(vmSpec, "maximumRam").orElse("0")).getNumber() + .toBigInteger()); + vmSpec.put("currentRam", Quantity.fromString( + DataPath. get(vmSpec, "currentRam").orElse("0")).getNumber() + .toBigInteger()); + var status = DataPath.deepCopy(vmDef.status()); + status.put("ram", Quantity.fromString( + DataPath. get(status, "ram").orElse("0")).getNumber() + .toBigInteger()); + + // Build result + return Map.of("metadata", + Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", spec, + "status", status, + "nodeName", vmDef.extra("nodeName")); + } + /** * Track the VM definitions. * * @param event the event * @param channel the channel - * @throws JsonDecodeException the json decode exception * @throws IOException */ @Handler(namedChannels = "manager") @@ -184,7 +209,7 @@ protected Set doRenderConlet(RenderConletRequestBase event, "PMD.ConfusingArgumentToVarargsMethod" }) public void onVmDefChanged(VmDefChanged event, VmChannel channel) throws IOException { - var vmName = event.vmDefinition().getMetadata().getName(); + var vmName = event.vmDefinition().name(); if (event.type() == K8sObserver.ResponseType.DELETED) { channelTracker.remove(vmName); for (var entry : conletIdsByConsoleConnection().entrySet()) { @@ -194,15 +219,12 @@ public void onVmDefChanged(VmDefChanged event, VmChannel channel) } } } else { - var gson = channel.client().getJSON().getGson(); - var vmDef = new VmDefinitionModel(gson, - cleanup(event.vmDefinition().data())); + var vmDef = event.vmDefinition(); channelTracker.put(vmName, channel, vmDef); - var def = gson.fromJson(vmDef.data(), Object.class); for (var entry : conletIdsByConsoleConnection().entrySet()) { for (String conletId : entry.getValue()) { entry.getKey().respond(new NotifyConletView(type(), - conletId, "updateVm", def)); + conletId, "updateVm", simplifiedVmDefinition(vmDef))); } } } @@ -217,28 +239,6 @@ public void onVmDefChanged(VmDefChanged event, VmChannel channel) } } - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private JsonObject cleanup(JsonObject vmDef) { - // Clone and remove managed fields - var json = vmDef.deepCopy(); - GsonPtr.to(json).to("metadata").get(JsonObject.class) - .remove("managedFields"); - - // Convert RAM sizes to unitless numbers - var vmSpec = GsonPtr.to(json).to("spec", "vm"); - vmSpec.set("maximumRam", Quantity.fromString( - vmSpec.getAsString("maximumRam").orElse("0")).getNumber() - .toBigInteger()); - vmSpec.set("currentRam", Quantity.fromString( - vmSpec.getAsString("currentRam").orElse("0")).getNumber() - .toBigInteger()); - var status = GsonPtr.to(json).to("status"); - status.set("ram", Quantity.fromString( - status.getAsString("ram").orElse("0")).getNumber() - .toBigInteger()); - return json; - } - /** * Handle the periodic update event by sending {@link NotifyConletView} * events. @@ -267,10 +267,10 @@ public static class Summary { public int totalVms; /** The running vms. */ - public int runningVms; + public long runningVms; /** The used cpus. */ - public int usedCpus; + public long usedCpus; /** The used ram. */ public BigInteger usedRam = BigInteger.ZERO; @@ -289,7 +289,7 @@ public int getTotalVms() { * * @return the runningVms */ - public int getRunningVms() { + public long getRunningVms() { return runningVms; } @@ -298,7 +298,7 @@ public int getRunningVms() { * * @return the usedCpus */ - public int getUsedCpus() { + public long getUsedCpus() { return usedCpus; } @@ -313,7 +313,8 @@ public String getUsedRam() { } - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.LambdaCanBeMethodReference" }) private Summary evaluateSummary(boolean force) { if (!force && cachedSummary != null) { return cachedSummary; @@ -321,18 +322,20 @@ private Summary evaluateSummary(boolean force) { Summary summary = new Summary(); for (var vmDef : channelTracker.associated()) { summary.totalVms += 1; - var status = GsonPtr.to(vmDef.data()).to("status"); - summary.usedCpus += status.getAsInt("cpus").orElse(0); - summary.usedRam = summary.usedRam.add(status.getAsString("ram") - .map(BigInteger::new).orElse(BigInteger.ZERO)); - for (var c : status.getAsListOf(JsonObject.class, "conditions")) { - if ("Running".equals(GsonPtr.to(c).getAsString("type") - .orElse(null)) - && "True".equals(GsonPtr.to(c).getAsString("status") - .orElse(null))) { - summary.runningVms += 1; - } - } + summary.usedCpus += vmDef. fromStatus("cpus") + .map(Number::intValue).orElse(0); + summary.usedRam = summary.usedRam + .add(vmDef. fromStatus("ram") + .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) + .orElse(BigInteger.ZERO)); + summary.runningVms + = vmDef.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(t -> "Running".equals(t)).orElse(false) + && DataPath.get(cond, "status") + .map(s -> "True".equals(s)).orElse(false)) + .count(); } cachedSummary = summary; return summary; diff --git a/org.jdrupes.vmoperator.vmviewer/build.gradle b/org.jdrupes.vmoperator.vmviewer/build.gradle index b5faf7cb7..606c6cd44 100644 --- a/org.jdrupes.vmoperator.vmviewer/build.gradle +++ b/org.jdrupes.vmoperator.vmviewer/build.gradle @@ -5,7 +5,7 @@ plugins { dependencies { implementation project(':org.jdrupes.vmoperator.manager.events') - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.0.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index 59cc98f29..8920e4cfd 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java @@ -23,8 +23,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; import com.google.gson.JsonSyntaxException; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; @@ -48,17 +46,16 @@ import java.util.Set; import java.util.logging.Level; import org.bouncycastle.util.Objects; -import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; -import org.jdrupes.vmoperator.common.VmDefinitionModel.Permission; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +import org.jdrupes.vmoperator.util.DataPath; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -122,7 +119,7 @@ public class VmViewer extends FreeMarkerConlet { private static final Set MODES_FOR_GENERATED = RenderMode.asSet( RenderMode.Preview, RenderMode.StickyPreview); private final ChannelTracker channelTracker = new ChannelTracker<>(); + VmDefinition> channelTracker = new ChannelTracker<>(); private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private Class preferredIpVersion = Inet4Address.class; @@ -399,8 +396,7 @@ private List accessibleVms(ConsoleConnection channel) { .map(d -> d.getMetadata().getName()).sorted().toList(); } - private Set permissions(VmDefinitionModel vmDef, - Session session) { + private Set permissions(VmDefinition vmDef, Session session) { var user = WebConsoleUtils.userFromSession(session) .map(ConsoleUser::getName).orElse(null); var roles = WebConsoleUtils.rolesFromSession(session) @@ -421,15 +417,16 @@ private void updateVmDef(ConsoleConnection channel, ViewerModel model) { channelTracker.value(model.vmName()).ifPresent(item -> { try { var vmDef = item.associated(); - @SuppressWarnings("unchecked") - var def = (Map) item.channel().client() - .getJSON().getGson() - .fromJson(vmDef.data().toString(), Map.class); - def.put("userPermissions", + var data = Map.of("metadata", + Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", vmDef.spec(), + "status", vmDef.getStatus(), + "userPermissions", permissions(vmDef, channel.session()).stream() .map(Permission::toString).toList()); channel.respond(new NotifyConletView(type(), - model.getConletId(), "updateVmDefinition", def)); + model.getConletId(), "updateVmDefinition", data)); } catch (JsonSyntaxException e) { logger.log(Level.SEVERE, e, () -> "Failed to serialize VM definition"); @@ -452,7 +449,6 @@ protected void doConletDeleted(ConletDeleted event, * * @param event the event * @param channel the channel - * @throws JsonDecodeException the json decode exception * @throws IOException */ @Handler(namedChannels = "manager") @@ -461,11 +457,8 @@ protected void doConletDeleted(ConletDeleted event, "PMD.ConfusingArgumentToVarargsMethod" }) public void onVmDefChanged(VmDefChanged event, VmChannel channel) throws IOException { - var vmDef = new VmDefinitionModel(channel.client().getJSON() - .getGson(), event.vmDefinition().data()); - GsonPtr.to(vmDef.data()).to("metadata").get(JsonObject.class) - .remove("managedFields"); - var vmName = vmDef.getMetadata().getName(); + var vmDef = event.vmDefinition(); + var vmName = vmDef.name(); if (event.type() == K8sObserver.ResponseType.DELETED) { channelTracker.remove(vmName); } else { @@ -567,27 +560,26 @@ private void openConsole(String vmName, ConsoleConnection connection, logger.severe(() -> "Failed to find display IP for " + vmName); return; } - var port = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec", - "vm", "display", "spice", "port"); + var port = vmDef. fromVm("display", "spice", "port") + .map(Number::longValue); if (port.isEmpty()) { logger.severe(() -> "No port defined for display of " + vmName); return; } - var proxyUrl = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec", - "vm", "display", "spice", "proxyUrl"); StringBuffer data = new StringBuffer(100) .append("[virt-viewer]\ntype=spice\nhost=") .append(addr.get().getHostAddress()).append("\nport=") - .append(Integer.toString(port.get().getAsInt())) + .append(port.get().toString()) .append('\n'); if (password != null) { data.append("password=").append(password).append('\n'); } - proxyUrl.map(JsonPrimitive::getAsString).ifPresent(u -> { - if (!Strings.isNullOrEmpty(u)) { - data.append("proxy=").append(u).append('\n'); - } - }); + vmDef. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); if (deleteConnectionFile) { data.append("delete-this-file=1\n"); } @@ -596,11 +588,10 @@ private void openConsole(String vmName, ConsoleConnection connection, Base64.getEncoder().encodeToString(data.toString().getBytes()))); } - private Optional displayIp(K8sDynamicModel vmDef) { - var server = GsonPtr.to(vmDef.data()).get(JsonPrimitive.class, "spec", - "vm", "display", "spice", "server"); + private Optional displayIp(VmDefinition vmDef) { + Optional server = vmDef.fromVm("display", "spice", "server"); if (server.isPresent()) { - var srv = server.get().getAsString(); + var srv = server.get(); try { var addr = InetAddress.getByName(srv); logger.fine(() -> "Using IP address from CRD for " @@ -612,8 +603,8 @@ private Optional displayIp(K8sDynamicModel vmDef) { return Optional.empty(); } } - var addrs = GsonPtr.to(vmDef.data()).getAsListOf(JsonPrimitive.class, - "nodeAddresses").stream().map(JsonPrimitive::getAsString) + var addrs = Optional.> ofNullable(vmDef + .extra("nodeAddresses")).orElse(Collections.emptyList()).stream() .map(a -> { try { return InetAddress.getByName(a); @@ -623,7 +614,7 @@ private Optional displayIp(K8sDynamicModel vmDef) { } }).filter(a -> a != null).toList(); logger.fine(() -> "Known IP addresses for " - + vmDef.getMetadata().getName() + ": " + addrs); + + vmDef.name() + ": " + addrs); return addrs.stream() .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) .findFirst().or(() -> addrs.stream().findFirst());