From 5d722abd2e95cc052124d3fe41ce27568c8be596 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sat, 25 Jan 2025 13:35:51 +0100 Subject: [PATCH] Add assignment based on last usage. --- deploy/crds/vmpools-crd.yaml | 9 +++ dev-example/test-pool.yaml | 1 + .../vmoperator/common/VmDefinition.java | 34 +++++++- .../org/jdrupes/vmoperator/common/VmPool.java | 38 +++++++++ .../vmoperator/manager/PoolMonitor.java | 40 +++++++++- .../jdrupes/vmoperator/manager/VmMonitor.java | 80 +++++++++++++------ .../vmoperator/vmaccess/l10n.properties | 1 + .../vmoperator/vmaccess/l10n_de.properties | 3 + .../jdrupes/vmoperator/vmaccess/VmAccess.java | 12 ++- 9 files changed, 186 insertions(+), 32 deletions(-) diff --git a/deploy/crds/vmpools-crd.yaml b/deploy/crds/vmpools-crd.yaml index 193d547..b34d096 100644 --- a/deploy/crds/vmpools-crd.yaml +++ b/deploy/crds/vmpools-crd.yaml @@ -16,6 +16,15 @@ spec: spec: type: object properties: + retention: + description: >- + Defines the timeout for assignments. The time may be + specified as ISO 8601 time or duration. When specifying + a duration, it will be added to the last time the VM's + console was used to obtain the timeout. + type: string + pattern: '^(?:\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3])(?:|:?[0-5]\d))|P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+[Hh])?(?:\d+[Mm])?(?:\d+(?:\.\d{1,9})?[Ss])?)?)$' + default: "PT1h" permissions: type: array description: >- diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml index 43c36b8..96289e3 100644 --- a/dev-example/test-pool.yaml +++ b/dev-example/test-pool.yaml @@ -4,6 +4,7 @@ metadata: namespace: vmop-dev name: test-vms spec: + retention: "PT1m" permissions: - user: admin may: diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java index 375612b..2742b88 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -18,7 +18,11 @@ package org.jdrupes.vmoperator.common; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.kubernetes.client.openapi.models.V1Condition; import io.kubernetes.client.openapi.models.V1ObjectMeta; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; @@ -36,9 +40,12 @@ import org.jdrupes.vmoperator.util.DataPath; /** * Represents a VM definition. */ -@SuppressWarnings({ "PMD.DataClass" }) +@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) public class VmDefinition { + private static ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + private String kind; private String apiVersion; private V1ObjectMeta metadata; @@ -306,6 +313,31 @@ public class VmDefinition { return fromStatus("assignment", "user"); } + /** + * Last usage of assigned VM. + * + * @return the optional + */ + public Optional assignmentLastUsed() { + return this. fromStatus("assignment", "lastUsed") + .map(Instant::parse); + } + + /** + * Return a condition from the status. + * + * @param name the condition's name + * @return the status, if the condition is defined + */ + public Optional condition(String name) { + return this.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(name::equals).orElse(false)) + .findFirst() + .map(cond -> objectMapper.convertValue(cond, V1Condition.class)); + } + /** * Return a condition's status. * diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java index 426a69c..8bf6dee 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java @@ -18,6 +18,8 @@ package org.jdrupes.vmoperator.common; +import java.time.Duration; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -36,6 +38,7 @@ import org.jdrupes.vmoperator.util.DataPath; public class VmPool { private String name; + private String retention; private boolean defined; private List permissions = Collections.emptyList(); private final Set vms @@ -86,6 +89,24 @@ public class VmPool { this.defined = defined; } + /** + * Gets the retention. + * + * @return the retention + */ + public String retention() { + return retention; + } + + /** + * Sets the retention. + * + * @param retention the retention to set + */ + public void setRetention(String retention) { + this.retention = retention; + } + /** * Permissions granted for a VM from the pool. * @@ -113,6 +134,11 @@ public class VmPool { return vms; } + /** + * To string. + * + * @return the string + */ @Override @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") public String toString() { @@ -147,4 +173,16 @@ public class VmPool { .flatMap(Function.identity()).collect(Collectors.toSet()); } + /** + * Return the instant until which an assignment should be retained. + * + * @param lastUsed the last used + * @return the instant + */ + public Instant retainUntil(Instant lastUsed) { + if (retention.startsWith("P")) { + return lastUsed.plus(Duration.parse(retention)); + } + return Instant.parse(retention); + } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index e11f667..4a7a1cb 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -18,21 +18,26 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.Watch; import java.io.IOException; +import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sDynamicModels; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; import org.jdrupes.vmoperator.manager.events.GetPools; @@ -129,6 +134,7 @@ public class PoolMonitor extends var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName)); var newData = client().getJSON().getGson().fromJson( GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class); + vmPool.setRetention(newData.retention()); vmPool.setPermissions(newData.permissions()); vmPool.setDefined(true); poolPipeline.fire(new VmPoolChanged(vmPool)); @@ -138,13 +144,15 @@ public class PoolMonitor extends * Track VM definition changes. * * @param event the event + * @throws ApiException */ @Handler - public void onVmDefChanged(VmDefChanged event) { - String vmName = event.vmDefinition().name(); + public void onVmDefChanged(VmDefChanged event) throws ApiException { + final var vmDef = event.vmDefinition(); + final String vmName = vmDef.name(); switch (event.type()) { case ADDED: - event.vmDefinition().> fromSpec("pools") + vmDef.> fromSpec("pools") .orElse(Collections.emptyList()).stream().forEach(p -> { pools.computeIfAbsent(p, k -> new VmPool(p)) .vms().add(vmName); @@ -157,10 +165,34 @@ public class PoolMonitor extends poolPipeline.fire(new VmPoolChanged(p)); } }); - break; + return; default: break; } + + // Sync last usage to console state change if user matches + var assignedTo = vmDef.assignedTo().orElse(null); + if (assignedTo == null || !assignedTo + .equals(vmDef. fromStatus("consoleUser").orElse(null))) { + return; + } + var lastUsed + = vmDef.assignmentLastUsed().orElse(Instant.ofEpochSecond(0)); + var conChange = vmDef.condition("ConsoleConnected") + .map(c -> c.getLastTransitionTime().toInstant()) + .orElse(Instant.ofEpochSecond(0)); + if (!conChange.isAfter(lastUsed)) { + return; + } + var vmStub = VmDefinitionStub.get(client(), + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + var assignment = GsonPtr.to(status).to("assignment"); + assignment.set("lastUsed", conChange.toString()); + return status; + }); } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index b8ba467..9b6f75f 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023,2024 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -25,7 +25,9 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import java.time.Instant; import java.util.ArrayList; +import java.util.Comparator; import java.util.Optional; import java.util.Set; import java.util.logging.Level; @@ -43,10 +45,12 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionModels; import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmPool; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; +import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.GetVms; import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; @@ -245,28 +249,59 @@ public class VmMonitor extends * * @param event the event * @throws ApiException the api exception + * @throws InterruptedException */ @Handler - public void onAssignVm(AssignVm event) throws ApiException { - // Search for existing assignment. - var assignedVm = channelManager.channels().stream() - .filter(c -> c.vmDefinition().assignedFrom() - .map(p -> p.equals(event.fromPool())).orElse(false)) - .filter(c -> c.vmDefinition().assignedTo() - .map(u -> u.equals(event.toUser())).orElse(false)) - .findFirst(); - if (assignedVm.isPresent()) { - event.setResult(new VmData(assignedVm.get().vmDefinition(), - assignedVm.get())); - return; - } + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onAssignVm(AssignVm event) + throws ApiException, InterruptedException { + VmPool vmPool = null; + while (true) { + // Search for existing assignment. + var assignedVm = channelManager.channels().stream() + .filter(c -> c.vmDefinition().assignedFrom() + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignedTo() + .map(u -> u.equals(event.toUser())).orElse(false)) + .findFirst(); + if (assignedVm.isPresent()) { + var vmDef = assignedVm.get().vmDefinition(); + event.setResult(new VmData(vmDef, assignedVm.get())); + return; + } - // Find available VM. - assignedVm = channelManager.channels().stream() - .filter(c -> c.vmDefinition().pools().contains(event.fromPool())) - .filter(c -> c.vmDefinition().assignedTo().isEmpty()) - .findFirst(); - if (assignedVm.isPresent()) { + // Get the pool definition for retention time calculations + if (vmPool == null) { + vmPool = newEventPipeline().fire(new GetPools() + .withName(event.fromPool())).get().stream().findFirst() + .orElse(null); + if (vmPool == null) { + return; + } + } + + // Find available VM. + var pool = vmPool; + assignedVm = channelManager.channels().stream() + .filter(c -> c.vmDefinition().pools() + .contains(event.fromPool())) + .filter(c -> !c.vmDefinition() + .conditionStatus("ConsoleConnected").orElse(false)) + .filter(c -> c.vmDefinition().assignedTo().isEmpty() + || pool.retainUntil(c.vmDefinition() + . fromStatus("assignment", "lastUsed") + .map(Instant::parse).orElse(Instant.ofEpochSecond(0))) + .isBefore(Instant.now())) + .sorted(Comparator.comparing(c -> c.vmDefinition() + .assignmentLastUsed().orElse(Instant.ofEpochSecond(0)))) + .findFirst(); + + // None found + if (assignedVm.isEmpty()) { + return; + } + + // Assign to user var vmDef = assignedVm.get().vmDefinition(); var vmStub = VmDefinitionStub.get(client(), new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), @@ -276,14 +311,13 @@ public class VmMonitor extends var assignment = GsonPtr.to(status).to("assignment"); assignment.set("pool", event.fromPool()); assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); return status; }); - // Always start a newly assigned VM. + // Make sure that a newly assigned VM is running. fire(new ModifyVm(vmDef.name(), "state", "Running", assignedVm.get())); - event.setResult(new VmData(vmDef, assignedVm.get())); } } - } diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties index d755e7a..6305a4b 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties @@ -5,3 +5,4 @@ okayLabel = Apply and Close confirmResetTitle = Confirm reset confirmResetMsg = Resetting the VM may cause loss of data. \ Please confirm to continue. +poolEmptyNotification = No VM available. Please consult your administrator. diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties index 7d4ff23..dbd3b11 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties @@ -11,3 +11,6 @@ Open\ console = Konsole anzeigen confirmResetTitle = Zurücksetzen bestätigen confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ Bitte bestätigen um fortzufahren. +poolEmptyNotification = Keine VM verfügbar. Wenden Sie sich bitte an den \ + Systemadministrator. + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index e6d4e58..5c72309 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -86,6 +86,7 @@ import org.jgrapes.webconsole.base.events.ConsoleConfigured; import org.jgrapes.webconsole.base.events.ConsolePrepared; import org.jgrapes.webconsole.base.events.ConsoleReady; import org.jgrapes.webconsole.base.events.DeleteConlet; +import org.jgrapes.webconsole.base.events.DisplayNotification; import org.jgrapes.webconsole.base.events.NotifyConletModel; import org.jgrapes.webconsole.base.events.NotifyConletView; import org.jgrapes.webconsole.base.events.OpenModalDialog; @@ -717,10 +718,9 @@ public class VmAccess extends FreeMarkerConlet { } } - @Override - @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", - "PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount", + @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity", "PMD.AvoidLiteralsInIfCondition" }) + @Override protected void doUpdateConletState(NotifyConletModel event, ConsoleConnection channel, ResourceModel model) throws Exception { event.stop(); @@ -741,7 +741,11 @@ public class VmAccess extends FreeMarkerConlet { vmData = Optional.ofNullable(appPipeline .fire(new AssignVm(model.name(), user)).get()); if (vmData.isEmpty()) { - // TODO message + ResourceBundle resourceBundle + = resourceBundle(channel.locale()); + channel.respond(new DisplayNotification( + resourceBundle.getString("poolEmptyNotification"), + Map.of("autoClose", 15_000, "type", "Error"))); return; } }