diff --git a/deploy/crds/vmpools-crd.yaml b/deploy/crds/vmpools-crd.yaml index 5f8414f..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: >- @@ -44,7 +53,7 @@ spec: - reset - accessConsole - "*" - default: [] + default: ["accessConsole"] required: - permissions # either Namespaced or Cluster diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 5f67b4c..7b46dc7 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -994,6 +994,10 @@ spec: type: array description: >- Defines permissions for accessing and manipulating the VM. + The meaning of most permissions should be obvious. The + difference between "accessConsole" and "takeConsole" is + that "takeConsole" allows the user to take control of + the console even if it is already in use by another user. items: type: object description: >- @@ -1017,12 +1021,13 @@ spec: - stop - reset - accessConsole + - takeConsole - "*" default: [] pools: type: array description: >- - List of pools to which this VM belongs. + List of pools this VM belongs to. items: type: string default: [] @@ -1486,6 +1491,24 @@ spec: by the runner if password protection is not enabled. type: integer default: 0 + assignment: + description: >- + The assignment of this VM to a a particular user. + type: object + properties: + pool: + description: >- + The pool this VM is taken from. + type: string + user: + description: >- + The user this VM is assigned to. + type: string + lastUsed: + description: >- + The last time this VM was used by the user. + type: string + default: {} conditions: description: >- List of component conditions observed diff --git a/dev-example/config.yaml b/dev-example/config.yaml index f2e0563..586a16e 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -40,15 +40,30 @@ - name: admin fullName: Administrator password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - - name: test - fullName: Test Account + - name: operator + fullName: Operator + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test1 + fullName: Test Account 1 + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account 2 + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account 3 password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin admin: - admin - test: + operator: + - operator + test1: + - user + test2: + - user + test3: - user # All users have role other "*": @@ -59,8 +74,10 @@ # Admins can use all conlets admin: - "*" + operator: + - org.jdrupes.vmoperator.vmaccess.VmAccess user: - - org.jdrupes.vmoperator.vmviewer.VmViewer + - org.jdrupes.vmoperator.vmaccess.VmAccess # Others cannot use any conlet (except login conlet to log out) other: - org.jgrapes.webconlet.oidclogin.LoginConlet diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml index 7dc4a15..975d95f 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -54,7 +54,13 @@ patches: - name: admin fullName: Administrator password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - - name: test + - name: test1 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 fullName: Test Account password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": @@ -62,7 +68,11 @@ patches: # User admin has role admin admin: - admin - test: + test1: + - user + test2: + - user + test3: - user # All users have role other "*": diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml index 73bd6ab..82b9131 100644 --- a/dev-example/test-pool.yaml +++ b/dev-example/test-pool.yaml @@ -4,7 +4,11 @@ metadata: namespace: vmop-dev name: test-vms spec: + retention: "PT1m" permissions: - user: admin may: - accessConsole + - role: user + may: + - accessConsole diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml index 1ce8d95..50031bb 100644 --- a/dev-example/test-vm.tpl.yaml +++ b/dev-example/test-vm.tpl.yaml @@ -21,9 +21,6 @@ spec: - role: admin may: - "*" - - user: test - may: - - accessConsole guestShutdownStops: true diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index 6f71b6a..7ee2793 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -14,9 +14,6 @@ spec: - user: admin may: - "*" - - user: test - may: - - "accessConsole" resources: requests: diff --git a/org.jdrupes.vmoperator.common/build.gradle b/org.jdrupes.vmoperator.common/build.gradle index 07877fe..e72cb14 100644 --- a/org.jdrupes.vmoperator.common/build.gradle +++ b/org.jdrupes.vmoperator.common/build.gradle @@ -13,4 +13,5 @@ dependencies { api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' api 'io.kubernetes:client-java:[19.0.0,20.0.0)' api 'org.yaml:snakeyaml' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java index 81a4eab..af87af2 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java @@ -45,7 +45,8 @@ import java.util.function.Function; * @param the generic type * @param the generic type */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", + "PMD.CouplingBetweenObjects" }) public class K8sClusterGenericStub { protected final K8sClient client; @@ -373,7 +374,7 @@ public class K8sClusterGenericStub> Collection list(Class objectClass, Class objectListClass, - K8sClient client, APIResource context, + K8sClient client, APIResource context, ListOptions options, GenericSupplier provider) throws ApiException { var result = new ArrayList(); 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 71ea7f1..f577d28 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,13 +18,18 @@ 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; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -35,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; @@ -57,7 +65,7 @@ public class VmDefinition { */ public enum Permission { START("start"), STOP("stop"), RESET("reset"), - ACCESS_CONSOLE("accessConsole"); + ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole"); @SuppressWarnings("PMD.UseConcurrentHashMap") private static Map reprs = new HashMap<>(); @@ -88,12 +96,44 @@ public class VmDefinition { return Set.of(reprs.get(value)); } + /** + * To string. + * + * @return the string + */ @Override public String toString() { return repr; } } + /** + * Permissions granted to a user or role. + * + * @param user the user + * @param role the role + * @param may the may + */ + public record Grant(String user, String role, Set may) { + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (user != null) { + builder.append("User ").append(user); + } else { + builder.append("Role ").append(role); + } + builder.append(" may=").append(may).append(']'); + return builder.toString(); + } + } + /** * Gets the kind. * @@ -157,6 +197,16 @@ public class VmDefinition { this.metadata = metadata; } + /** + * The pools that this VM belongs to. + * + * @return the list + */ + public List pools() { + return this.> fromSpec("pools") + .orElse(Collections.emptyList()); + } + /** * Gets the spec. * @@ -245,6 +295,82 @@ public class VmDefinition { this.status = status; } + /** + * The pool that the VM was taken from. + * + * @return the optional + */ + public Optional assignedFrom() { + return fromStatus("assignment", "pool"); + } + + /** + * The user that the VM was assigned to. + * + * @return the optional + */ + public Optional assignedTo() { + 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. + * + * @param name the condition's name + * @return the status, if the condition is defined + */ + public Optional conditionStatus(String name) { + return this.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(name::equals).orElse(false)) + .findFirst().map(cond -> DataPath.get(cond, "status") + .map("True"::equals).orElse(false)); + } + + /** + * Return true if the console is in use. + * + * @return true, if successful + */ + public boolean consoleConnected() { + return conditionStatus("ConsoleConnected").orElse(false); + } + + /** + * Return the last known console user. + * + * @return the optional + */ + public Optional consoleUser() { + return this. fromStatus("consoleUser"); + } + /** * Set extra data (locally used, unknown to kubernetes). * @@ -260,6 +386,7 @@ public class VmDefinition { /** * Return extra data. * + * @param the generic type * @param property the property * @return the object */ @@ -287,12 +414,11 @@ public class VmDefinition { } /** - * Return the requested VM state + * Return the requested VM state. * * @return the string */ public RequestedVmState vmState() { - // TODO return fromVm("state") .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING : RequestedVmState.STOPPED) @@ -329,4 +455,38 @@ public class VmDefinition { return this. fromStatus("displayPasswordSerial") .map(Number::longValue); } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + return Objects.hash(metadata.getNamespace(), metadata.getName()); + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + VmDefinition other = (VmDefinition) obj; + return Objects.equals(metadata.getNamespace(), + other.metadata.getNamespace()) + && Objects.equals(metadata.getName(), other.metadata.getName()); + } + } 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 8da0a9f..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,16 +18,17 @@ package org.jdrupes.vmoperator.common; +import java.time.Duration; +import java.time.Instant; import java.util.Collection; import java.util.Collections; -import java.util.EnumSet; -import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.VmDefinition.Grant; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.util.DataPath; /** @@ -37,10 +38,21 @@ 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 = Collections.synchronizedSet(new HashSet<>()); + /** + * Instantiates a new vm pool. + * + * @param name the name + */ + public VmPool(String name) { + this.name = name; + } + /** * Returns the name. * @@ -60,6 +72,44 @@ public class VmPool { } /** + * Checks if is defined. + * + * @return the result + */ + public boolean isDefined() { + return defined; + } + + /** + * Sets if is. + * + * @param defined the defined to set + */ + public void setDefined(boolean defined) { + 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. + * * @return the permissions */ public List permissions() { @@ -84,6 +134,11 @@ public class VmPool { return vms; } + /** + * To string. + * + * @return the string + */ @Override @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") public String toString() { @@ -93,9 +148,8 @@ public class VmPool { if (vms.size() <= 3) { builder.append(vms); } else { - builder.append('['); - vms.stream().limit(3).map(s -> s + ",").forEach(builder::append); - builder.append("...]"); + builder.append('[').append(vms.stream().limit(3).map(s -> s + ",") + .collect(Collectors.joining())).append("...]"); } builder.append(']'); return builder.toString(); @@ -120,67 +174,15 @@ public class VmPool { } /** - * A permission grant to a user or role. + * Return the instant until which an assignment should be retained. * - * @param user the user - * @param role the role - * @param may the may + * @param lastUsed the last used + * @return the instant */ - public record Grant(String user, String role, Set may) { - - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - if (user != null) { - builder.append("User ").append(user); - } else { - builder.append("Role ").append(role); - } - builder.append(" may=").append(may).append(']'); - return builder.toString(); + public Instant retainUntil(Instant lastUsed) { + if (retention.startsWith("P")) { + return lastUsed.plus(Duration.parse(retention)); } + return Instant.parse(retention); } - - /** - * Permissions for accessing and manipulating the pool. - */ - 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; - } - } - } diff --git a/org.jdrupes.vmoperator.manager.events/build.gradle b/org.jdrupes.vmoperator.manager.events/build.gradle index cfdd79e..bb4b8d8 100644 --- a/org.jdrupes.vmoperator.manager.events/build.gradle +++ b/org.jdrupes.vmoperator.manager.events/build.gradle @@ -10,5 +10,4 @@ plugins { dependencies { api project(':org.jdrupes.vmoperator.common') - api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java new file mode 100644 index 0000000..21e6031 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java @@ -0,0 +1,61 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jgrapes.core.Event; + +/** + * Assign a VM from a pool to a user. + */ +@SuppressWarnings("PMD.DataClass") +public class AssignVm extends Event { + + private final String fromPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param fromPool the from pool + * @param toUser the to user + */ + public AssignVm(String fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; + } + + /** + * Gets the pool to assign from. + * + * @return the pool + */ + public String fromPool() { + return fromPool; + } + + /** + * Gets the user to assign to. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java new file mode 100644 index 0000000..40fa6ad --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java @@ -0,0 +1,88 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Event; + +/** + * Gets the known pools' definitions. + */ +@SuppressWarnings("PMD.DataClass") +public class GetPools extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + + /** + * Return only the pool with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetPools withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmPool}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetPools accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional forUser() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List forRoles() { + return roles; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java new file mode 100644 index 0000000..8b00698 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java @@ -0,0 +1,139 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the known VMs' definitions and channels. + */ +@SuppressWarnings("PMD.DataClass") +public class GetVms extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + private String fromPool; + private String toUser; + + /** + * Return only the VMs with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetVms withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmDefinition}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetVms accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned from the given pool. + * + * @param pool the pool + * @return the returns the vms + */ + public GetVms assignedFrom(String pool) { + this.fromPool = pool; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned to the given user. + * + * @param user the user + * @return the returns the vms + */ + public GetVms assignedTo(String user) { + this.toUser = user; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional user() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List roles() { + return roles; + } + + /** + * Returns the pool filter criterion, if set. + * + * @return the optional + */ + public Optional fromPool() { + return Optional.ofNullable(fromPool); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional toUser() { + return Optional.ofNullable(toUser); + } + + /** + * Return tuple. + * + * @param definition the definition + * @param channel the channel + */ + public record VmData(VmDefinition definition, VmChannel channel) { + } +} diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index b581971..eda5ce0 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -17,7 +17,7 @@ dependencies { implementation 'org.jgrapes:org.jgrapes.io:[2.12.1,3)' implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.2.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.8.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)' implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)' diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 68ca7fa..9c6dc3e 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 @@ -86,14 +86,14 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // Maybe override logging.properties from reconciler configuration. DataPath. get(model, "reconciler", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") + GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); // Maybe override logging.properties from VM definition. DataPath. get(model, "cr", "spec", "loggingProperties") .ifPresent(props -> { - GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") + GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data") .get().addProperty("logging.properties", props); }); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index d847785..1ef17f7 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -106,7 +106,7 @@ public class Controller extends Component { // to access the VM's console. Might change in the future. // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); attach(new Reconciler(channel())); - attach(new PoolManager(channel())); + attach(new PoolMonitor(channel())); } /** diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java similarity index 57% rename from org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java rename to org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index fb1de27..0606650 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolManager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -18,27 +18,28 @@ 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.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.Watch; import java.io.IOException; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; 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; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; import org.jdrupes.vmoperator.util.GsonPtr; @@ -53,11 +54,9 @@ import org.jgrapes.core.events.Attached; * avoid concurrent change informations. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) -public class PoolManager extends +public class PoolMonitor extends AbstractMonitor { - private final ReentrantLock pendingLock = new ReentrantLock(); - private final Map> pending = new ConcurrentHashMap<>(); private final Map pools = new ConcurrentHashMap<>(); private EventPipeline poolPipeline; @@ -67,7 +66,7 @@ public class PoolManager extends * @param componentChannel the component channel * @param channelManager the channel manager */ - public PoolManager(Channel componentChannel) { + public PoolMonitor(Channel componentChannel) { super(componentChannel, K8sDynamicModel.class, K8sDynamicModels.class); } @@ -107,18 +106,13 @@ public class PoolManager extends // When pool is deleted, save VMs in pending if (type == ResponseType.DELETED) { - try { - pendingLock.lock(); - Optional.ofNullable(pools.get(poolName)).ifPresent( - p -> { - pending.computeIfAbsent(poolName, k -> Collections - .synchronizedSet(new HashSet<>())).addAll(p.vms()); - pools.remove(poolName); - poolPipeline.fire(new VmPoolChanged(p, true)); - }); - } finally { - pendingLock.unlock(); - } + Optional.ofNullable(pools.get(poolName)).ifPresent(pool -> { + pool.setDefined(false); + if (pool.vms().isEmpty()) { + pools.remove(poolName); + } + poolPipeline.fire(new VmPoolChanged(pool, true)); + }); return; } @@ -135,75 +129,85 @@ public class PoolManager extends } } - // Convert to VM pool - var vmPool = client().getJSON().getGson().fromJson( - GsonPtr.to(poolModel.data()).to("spec").get(), - VmPool.class); - V1ObjectMeta metadata = response.object.getMetadata(); - vmPool.setName(metadata.getName()); - - // If modified, merge changes - if (type == ResponseType.MODIFIED && pools.containsKey(poolName)) { - pools.get(poolName).setPermissions(vmPool.permissions()); - return; - } - - // Add new pool - try { - pendingLock.lock(); - Optional.ofNullable(pending.get(poolName)).ifPresent(s -> { - vmPool.vms().addAll(s); - }); - pending.remove(poolName); - pools.put(poolName, vmPool); - poolPipeline.fire(new VmPoolChanged(vmPool)); - } finally { - pendingLock.unlock(); - } + // Get pool and merge changes + 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)); } /** * 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: - try { - pendingLock.lock(); - event.vmDefinition().> fromSpec("pools") - .orElse(Collections.emptyList()).stream().forEach(p -> { - if (pools.containsKey(p)) { - pools.get(p).vms().add(vmName); - } else { - pending.computeIfAbsent(p, k -> Collections - .synchronizedSet(new HashSet<>())).add(vmName); - } - poolPipeline.fire(new VmPoolChanged(pools.get(p))); - }); - } finally { - pendingLock.unlock(); - } + vmDef.> fromSpec("pools") + .orElse(Collections.emptyList()).stream().forEach(p -> { + pools.computeIfAbsent(p, k -> new VmPool(p)) + .vms().add(vmName); + poolPipeline.fire(new VmPoolChanged(pools.get(p))); + }); break; case DELETED: - try { - pendingLock.lock(); - pools.values().stream().forEach(p -> { - if (p.vms().remove(vmName)) { - poolPipeline.fire(new VmPoolChanged(p)); - } - }); - // Should not be necessary, but just in case - pending.values().stream().forEach(s -> s.remove(vmName)); - } finally { - pendingLock.unlock(); - } - break; + pools.values().stream().forEach(p -> { + if (p.vms().remove(vmName)) { + poolPipeline.fire(new VmPoolChanged(p)); + } + }); + return; default: break; } + + // Sync last usage to console state change if user matches + if (vmDef.assignedTo() + .map(at -> at.equals(vmDef.consoleUser().orElse(null))) + .orElse(true)) { + return; + } + + var ccChange = vmDef.condition("ConsoleConnected") + .map(cc -> cc.getLastTransitionTime().toInstant()); + if (ccChange + .map(tt -> vmDef.assignmentLastUsed().map(alu -> alu.isAfter(tt)) + .orElse(true)) + .orElse(true)) { + 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", ccChange.get().toString()); + return status; + }); + } + + /** + * Return the requested pools. + * + * @param event the event + */ + @Handler + public void onGetPools(GetPools event) { + event.setResult(pools.values().stream().filter(VmPool::isDefined) + .filter(p -> event.name().isEmpty() + || p.name().equals(event.name().get())) + .filter(p -> event.forUser().isEmpty() && event.forRoles().isEmpty() + || !p.permissionsFor(event.forUser().orElse(null), + event.forRoles()).isEmpty()) + .toList()); } } 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 d044199..34085f0 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 @@ -41,6 +41,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.DataPath; +import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -179,13 +180,32 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; var pvcDef = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - // Do apply changes + // Apply changes var pvcStub = K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); + var pvc = pvcStub.model(); + if (pvc.isEmpty() + || !"Bound".equals(pvc.get().getStatus().getPhase())) { + // Does not exist or isn't bound, use apply + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + return; + } + + // If bound, use json merge, omitting immutable fields + var spec = GsonPtr.to(pvcDef.getRaw()).to("spec"); + spec.removeExcept("volumeAttributesClassName", "resources"); + spec.get("resources").ifPresent(p -> p.removeExcept("requests")); PatchOptions opts = new PatchOptions(); - opts.setForce(true); opts.setFieldManager("kubernetes-java-kubectl-apply"); - if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) .isEmpty()) { logger.warning( 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 cc8ae7b..102a6c9 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 @@ -18,20 +18,22 @@ 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.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.Collections; -import java.util.List; -import java.util.Map; +import java.util.Comparator; import java.util.Optional; import java.util.Set; import java.util.logging.Level; import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import 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.K8sDynamicStub; @@ -43,15 +45,21 @@ 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_KIND_VM; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.ChannelManager; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.DataPath; +import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; /** * Watches for changes of VM definitions. @@ -119,11 +127,6 @@ public class VmMonitor extends 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 vmModel = response.object; if (vmModel.data() == null) { @@ -151,17 +154,16 @@ public class VmMonitor extends // 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()), - vmDef), - e -> { - if (e.type() == ResponseType.DELETED) { - channelManager.remove(e.vmDefinition().name()); - } - }), channel); + VmDefChanged chgEvt + = new VmDefChanged(ResponseType.valueOf(response.type), + channel.setGeneration(response.object.getMetadata() + .getGeneration()), + vmDef); + if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { + chgEvt = Event.onCompletion(chgEvt, + e -> channelManager.remove(e.vmDefinition().name())); + } + channel.pipeline().fire(chgEvt, channel); } private VmDefinitionModel getModel(K8sClient client, @@ -190,16 +192,7 @@ public class VmMonitor extends // 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. - @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) { + if (!vmDef.conditionStatus("Running").orElse(false)) { return; } var podSearch = new ListOptions(); @@ -227,4 +220,131 @@ public class VmMonitor extends () -> "Cannot access node information: " + e.getMessage()); } } + + /** + * Returns the VM data. + * + * @param event the event + */ + @Handler + public void onGetVms(GetVms event) { + event.setResult(channelManager.channels().stream() + .filter(c -> event.name().isEmpty() + || c.vmDefinition().name().equals(event.name().get())) + .filter(c -> event.user().isEmpty() && event.roles().isEmpty() + || !c.vmDefinition().permissionsFor(event.user().orElse(null), + event.roles()).isEmpty()) + .filter(c -> event.fromPool().isEmpty() + || c.vmDefinition().assignedFrom() + .map(p -> p.equals(event.fromPool().get())).orElse(false)) + .filter(c -> event.toUser().isEmpty() + || c.vmDefinition().assignedTo() + .map(u -> u.equals(event.toUser().get())).orElse(false)) + .map(c -> new VmData(c.vmDefinition(), c)) + .toList()); + } + + /** + * Assign a VM if not already assigned. + * + * @param event the event + * @throws ApiException the api exception + * @throws InterruptedException + */ + @Handler + @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; + } + + // 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 -> isAssignable(pool, c.vmDefinition())) + .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), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + 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; + }); + + // Make sure that a newly assigned VM is running. + fire(new ModifyVm(vmDef.name(), "state", "Running", + assignedVm.get())); + } + } + + @SuppressWarnings("PMD.SimplifyBooleanReturns") + private boolean isAssignable(VmPool pool, VmDefinition vmDef) { + // Check if the VM is in the pool + if (!vmDef.pools().contains(pool.name())) { + return false; + } + + // Check if the VM is not in use + if (vmDef.consoleConnected()) { + return false; + } + + // If not assigned, it's usable + if (vmDef.assignedTo().isEmpty()) { + return true; + } + + // Check if it is to be retained + if (vmDef.assignmentLastUsed() + .map(lu -> pool.retainUntil(lu)) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + + // Additional check in case lastUsed has not been updated + // by PoolMonitor#onVmDefChanged() yet ("race condition") + if (vmDef.condition("ConsoleConnected") + .map(cc -> cc.getLastTransitionTime().toInstant()) + .map(t -> pool.retainUntil(t)) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + return true; + } } diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java index 8b84ed3..36c3444 100644 --- a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java @@ -23,6 +23,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import java.math.BigInteger; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -62,7 +63,8 @@ public class GsonPtr { * @param selectors the selectors * @return the Gson pointer */ - @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) + @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace", + "PMD.AvoidDuplicateLiterals" }) public GsonPtr to(Object... selectors) { JsonElement element = position; for (Object sel : selectors) { @@ -91,6 +93,42 @@ public class GsonPtr { return new GsonPtr(element); } + /** + * Create a new instance pointing to the {@link JsonElement} + * selected by the given selectors. If a selector of type + * {@link String} denotes a non-existant member of a + * {@link JsonObject} the result is empty. + * + * @param selectors the selectors + * @return the Gson pointer + */ + @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) + public Optional get(Object... selectors) { + JsonElement element = position; + for (Object sel : selectors) { + if (element instanceof JsonObject obj + && sel instanceof String member) { + element = obj.get(member); + if (element == null) { + return Optional.empty(); + } + continue; + } + if (element instanceof JsonArray arr + && sel instanceof Integer index) { + try { + element = arr.get(index); + } catch (IndexOutOfBoundsException e) { + throw new IllegalStateException("Selected array index" + + " may not be empty."); + } + continue; + } + throw new IllegalStateException("Invalid selection"); + } + return Optional.of(new GsonPtr(element)); + } + /** * Returns {@link JsonElement} that the pointer points to. * @@ -109,7 +147,7 @@ public class GsonPtr { * @return the result */ @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) - public T get(Class cls) { + public T getAs(Class cls) { if (cls.isAssignableFrom(position.getClass())) { return cls.cast(position); } @@ -128,7 +166,7 @@ public class GsonPtr { */ @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) public Optional - get(Class cls, Object... selectors) { + getAs(Class cls, Object... selectors) { JsonElement element = position; for (Object sel : selectors) { if (element instanceof JsonObject obj @@ -163,7 +201,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsString(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsString); } @@ -174,7 +212,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsInt(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsInt); } @@ -185,7 +223,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsBigInteger(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBigInteger); } @@ -196,7 +234,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsLong(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsLong); } @@ -207,7 +245,7 @@ public class GsonPtr { * @return the boolean */ public Optional getAsBoolean(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBoolean); } @@ -222,7 +260,7 @@ public class GsonPtr { @SuppressWarnings("unchecked") public List getAsListOf(Class cls, Object... selectors) { - return get(JsonArray.class, selectors).map(a -> (List) a.asList()) + return getAs(JsonArray.class, selectors).map(a -> (List) a.asList()) .orElse(Collections.emptyList()); } @@ -336,4 +374,22 @@ public class GsonPtr { return this; } + /** + * Removes all properties except the specified ones. + * + * @param properties the properties + */ + public void removeExcept(String... properties) { + if (!position.isJsonObject()) { + return; + } + for (var itr = ((JsonObject) position).entrySet().iterator(); + itr.hasNext();) { + var entry = itr.next(); + if (Arrays.asList(properties).contains(entry.getKey())) { + continue; + } + itr.remove(); + } + } } diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html index ba61399..a34f725 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html @@ -5,17 +5,35 @@ data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">
- {{ localize("Select VM") }} -

- -

+
+ {{ localize("Select VM or pool") }} +
    +
  • + +
  • +
  • + +
  • +
+
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..8f4051e 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,5 @@ okayLabel = Apply and Close confirmResetTitle = Confirm reset confirmResetMsg = Resetting the VM may cause loss of data. \ Please confirm to continue. +consoleTakenNotification = Console access is locked by another user. +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 bcdc332..e51eb5e 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 @@ -1,7 +1,7 @@ conletName = VM-Zugriff okayLabel = Anwenden und Schließen -Select\ VM = VM auswählen +Select\ VM\ or\ pool = VM oder Pool auswählen Start\ VM = VM starten Stop\ VM = VM anhalten @@ -11,3 +11,7 @@ Open\ console = Konsole anzeigen confirmResetTitle = Zurücksetzen bestätigen confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ Bitte bestätigen um fortzufahren. +consoleTakenNotification = Die Konsole wird von einem anderen Benutzer verwendet. +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 e1a41ef..786fedf 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 @@ -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 @@ -50,17 +50,24 @@ import org.bouncycastle.util.Objects; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; -import org.jdrupes.vmoperator.manager.events.ChannelTracker; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; +import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Manager; import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; import org.jgrapes.http.Session; import org.jgrapes.util.events.ConfigurationUpdate; import org.jgrapes.util.events.KeyValueStoreQuery; @@ -79,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; @@ -106,10 +114,12 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; * */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", - "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods" }) -public class VmAccess extends FreeMarkerConlet { + "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods", + "PMD.CyclomaticComplexity" }) +public class VmAccess extends FreeMarkerConlet { private static final String VM_NAME_PROPERTY = "vmName"; + private static final String POOL_NAME_PROPERTY = "poolName"; private static final String RENDERED = VmAccess.class.getName() + ".rendered"; private static final String PENDING @@ -118,8 +128,7 @@ public class VmAccess extends FreeMarkerConlet { RenderMode.Preview, RenderMode.Edit); private static final Set MODES_FOR_GENERATED = RenderMode.asSet( RenderMode.Preview, RenderMode.StickyPreview); - private final ChannelTracker channelTracker = new ChannelTracker<>(); + private EventPipeline appPipeline; private static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private Class preferredIpVersion = Inet4Address.class; @@ -144,6 +153,16 @@ public class VmAccess extends FreeMarkerConlet { super(componentChannel); } + /** + * On start. + * + * @param event the event + */ + @Handler + public void onStart(Start event) { + appPipeline = event.processedBy().get(); + } + /** * Configure the component. * @@ -247,36 +266,74 @@ public class VmAccess extends FreeMarkerConlet { * @throws InterruptedException the interrupted exception */ @Handler - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") public void onConsoleConfigured(ConsoleConfigured event, ConsoleConnection connection) throws InterruptedException, IOException { @SuppressWarnings("unchecked") - final var rendered = (Set) connection.session().get(RENDERED); + final var rendered + = (Set) connection.session().get(RENDERED); connection.session().remove(RENDERED); if (!syncPreviews(connection.session())) { return; } + addMissingConlets(event, connection, rendered); + } - boolean foundMissing = false; - for (var vmName : accessibleVms(connection)) { - if (rendered.contains(vmName)) { - continue; - } - if (!foundMissing) { - // Suspending to allow rendering of conlets to be noticed - var failSafe = Components.schedule(t -> event.resumeHandling(), - Duration.ofSeconds(1)); - event.suspendHandling(failSafe::cancel); - connection.setAssociated(PENDING, event); - foundMissing = true; - } + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", + "PMD.AvoidDuplicateLiterals" }) + private void addMissingConlets(ConsoleConfigured event, + ConsoleConnection connection, final Set rendered) + throws InterruptedException { + var session = connection.session(); + + // Evaluate missing VMs + var missingVms = appPipeline.fire(new GetVms().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(d -> d.definition().name()) + .collect(Collectors.toCollection(HashSet::new)); + missingVms.removeAll(rendered.stream() + .filter(r -> r.mode() == ResourceModel.Mode.VM) + .map(ResourceModel::name).toList()); + + // Evaluate missing pools + var missingPools = appPipeline.fire(new GetPools().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(VmPool::name) + .collect(Collectors.toCollection(HashSet::new)); + missingPools.removeAll(rendered.stream() + .filter(r -> r.mode() == ResourceModel.Mode.POOL) + .map(ResourceModel::name).toList()); + + // Nothing to do + if (missingVms.isEmpty() && missingPools.isEmpty()) { + return; + } + + // Suspending to allow rendering of conlets to be noticed + var failSafe = Components.schedule(t -> event.resumeHandling(), + Duration.ofSeconds(1)); + event.suspendHandling(failSafe::cancel); + connection.setAssociated(PENDING, event); + + // Create conlets for VMs and pools that haven't been rendered + for (var vmName : missingVms) { fire(new AddConletRequest(event.event().event().renderSupport(), - VmAccess.class.getName(), - RenderMode.asSet(RenderMode.Preview)) + VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview)) .addProperty(VM_NAME_PROPERTY, vmName), connection); } + for (var poolName : missingPools) { + fire(new AddConletRequest(event.event().event().renderSupport(), + VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview)) + .addProperty(POOL_NAME_PROPERTY, poolName), + connection); + } } /** @@ -300,12 +357,16 @@ public class VmAccess extends FreeMarkerConlet { } @Override - protected Optional createNewState(AddConletRequest event, + protected Optional createNewState(AddConletRequest event, ConsoleConnection connection, String conletId) throws Exception { - var model = new ViewerModel(conletId); - model.vmName = (String) event.properties().get(VM_NAME_PROPERTY); - if (model.vmName != null) { - model.setGenerated(true); + var model = new ResourceModel(conletId); + var poolName = (String) event.properties().get(POOL_NAME_PROPERTY); + if (poolName != null) { + model.setMode(ResourceModel.Mode.POOL); + model.setName(poolName); + } else { + model.setMode(ResourceModel.Mode.VM); + model.setName((String) event.properties().get(VM_NAME_PROPERTY)); } String jsonState = objectMapper.writeValueAsString(model); connection.respond(new KeyValueStoreUpdate().update( @@ -314,9 +375,9 @@ public class VmAccess extends FreeMarkerConlet { } @Override - protected Optional createStateRepresentation(Event event, + protected Optional createStateRepresentation(Event event, ConsoleConnection connection, String conletId) throws Exception { - var model = new ViewerModel(conletId); + var model = new ResourceModel(conletId); String jsonState = objectMapper.writeValueAsString(model); connection.respond(new KeyValueStoreUpdate().update( storagePath(connection.session(), model.getConletId()), jsonState)); @@ -325,7 +386,7 @@ public class VmAccess extends FreeMarkerConlet { @Override @SuppressWarnings("PMD.EmptyCatchBlock") - protected Optional recreateState(Event event, + protected Optional recreateState(Event event, ConsoleConnection channel, String conletId) throws Exception { KeyValueStoreQuery query = new KeyValueStoreQuery( storagePath(channel.session(), conletId), channel); @@ -334,8 +395,8 @@ public class VmAccess extends FreeMarkerConlet { if (!query.results().isEmpty()) { var json = query.results().get(0).values().stream().findFirst() .get(); - ViewerModel model - = objectMapper.readValue(json, ViewerModel.class); + ResourceModel model + = objectMapper.readValue(json, ResourceModel.class); return Optional.of(model); } } catch (InterruptedException e) { @@ -347,58 +408,37 @@ public class VmAccess extends FreeMarkerConlet { } @Override - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "unchecked" }) + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) protected Set doRenderConlet(RenderConletRequestBase event, - ConsoleConnection channel, String conletId, ViewerModel model) + ConsoleConnection channel, String conletId, ResourceModel model) throws Exception { + if (event.renderAs().contains(RenderMode.Preview)) { + return renderPreview(event, channel, conletId, model); + } + + // Render edit ResourceBundle resourceBundle = resourceBundle(channel.locale()); Set renderedAs = EnumSet.noneOf(RenderMode.class); - if (event.renderAs().contains(RenderMode.Preview)) { - channel.associated(PENDING, Event.class) - .ifPresent(e -> { - e.resumeHandling(); - channel.setAssociated(PENDING, null); - }); - - // Remove conlet if definition has been removed - if (model.vmName() != null - && !channelTracker.associated(model.vmName()).isPresent()) { - channel.respond( - new DeleteConlet(conletId, Collections.emptySet())); - return Collections.emptySet(); - } - - // Don't render if user has not at least one permission - if (model.vmName() != null - && channelTracker.associated(model.vmName()) - .map(d -> permissions(d, channel.session()).isEmpty()) - .orElse(true)) { - return Collections.emptySet(); - } - - // Render - Template tpl - = freemarkerConfig().getTemplate("VmAccess-preview.ftl.html"); - channel.respond(new RenderConlet(type(), conletId, - processTemplate(event, tpl, - fmModel(event, channel, conletId, model))) - .setRenderAs( - RenderMode.Preview.addModifiers(event.renderAs())) - .setSupportedModes(syncPreviews(channel.session()) - ? MODES_FOR_GENERATED - : MODES)); - renderedAs.add(RenderMode.Preview); - if (!Strings.isNullOrEmpty(model.vmName())) { - Optional.ofNullable(channel.session().get(RENDERED)) - .ifPresent(s -> ((Set) s).add(model.vmName())); - updateConfig(channel, model); - } - } if (event.renderAs().contains(RenderMode.Edit)) { - Template tpl = freemarkerConfig() - .getTemplate("VmAccess-edit.ftl.html"); + var session = channel.session(); + var vmNames = appPipeline.fire(new GetVms().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(d -> d.definition().name()).sorted() + .toList(); + var poolNames = appPipeline.fire(new GetPools().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(VmPool::name).sorted().toList(); + Template tpl + = freemarkerConfig().getTemplate("VmAccess-edit.ftl.html"); var fmModel = fmModel(event, channel, conletId, model); - fmModel.put("vmNames", accessibleVms(channel)); + fmModel.put("vmNames", vmNames); + fmModel.put("poolNames", poolNames); channel.respond(new OpenModalDialog(type(), conletId, processTemplate(event, tpl, fmModel)) .addOption("cancelable", true) @@ -408,10 +448,83 @@ public class VmAccess extends FreeMarkerConlet { return renderedAs; } - private List accessibleVms(ConsoleConnection channel) { - return channelTracker.associated().stream() - .filter(d -> !permissions(d, channel.session()).isEmpty()) - .map(d -> d.getMetadata().getName()).sorted().toList(); + @SuppressWarnings("unchecked") + private Set renderPreview(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, ResourceModel model) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException, InterruptedException { + channel.associated(PENDING, Event.class) + .ifPresent(e -> { + e.resumeHandling(); + channel.setAssociated(PENDING, null); + }); + + VmDefinition vmDef = null; + if (model.mode() == ResourceModel.Mode.VM && model.name() != null) { + // Remove conlet if VM definition has been removed + // or user has not at least one permission + vmDef = getVmData(model, channel).map(VmData::definition) + .orElse(null); + if (vmDef == null) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + } + + if (model.mode() == ResourceModel.Mode.POOL && model.name() != null) { + // Remove conlet if pool definition has been removed + // or user has not at least one permission + VmPool pool = appPipeline + .fire(new GetPools().withName(model.name())).get() + .stream().findFirst().orElse(null); + if (pool == null + || permissions(pool, channel.session()).isEmpty()) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + vmDef = getVmData(model, channel).map(VmData::definition) + .orElse(null); + } + + // Render + Template tpl + = freemarkerConfig().getTemplate("VmAccess-preview.ftl.html"); + channel.respond(new RenderConlet(type(), conletId, + processTemplate(event, tpl, + fmModel(event, channel, conletId, model))) + .setRenderAs( + RenderMode.Preview.addModifiers(event.renderAs())) + .setSupportedModes(syncPreviews(channel.session()) + ? MODES_FOR_GENERATED + : MODES)); + if (!Strings.isNullOrEmpty(model.name())) { + Optional.ofNullable(channel.session().get(RENDERED)) + .ifPresent(s -> ((Set) s).add(model)); + updatePreview(channel, model, vmDef); + } + return EnumSet.of(RenderMode.Preview); + } + + private Optional getVmData(ResourceModel model, + ConsoleConnection channel) throws InterruptedException { + if (model.mode() == ResourceModel.Mode.VM) { + // Get the VM data by name. + var session = channel.session(); + return appPipeline.fire(new GetVms().withName(model.name()) + .accessibleFor(WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().findFirst(); + } + + // Look for an (already) assigned VM + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + return appPipeline.fire(new GetVms().assignedFrom(model.name()) + .assignedTo(user)).get().stream().findFirst(); } private Set permissions(VmDefinition vmDef, Session session) { @@ -422,39 +535,83 @@ public class VmAccess extends FreeMarkerConlet { return vmDef.permissionsFor(user, roles); } - private void updateConfig(ConsoleConnection channel, ViewerModel model) { - channel.respond(new NotifyConletView(type(), - model.getConletId(), "updateConfig", model.vmName())); - updateVmDef(channel, model); + private Set permissions(VmPool pool, Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return pool.permissionsFor(user, roles); } - private void updateVmDef(ConsoleConnection channel, ViewerModel model) { - if (Strings.isNullOrEmpty(model.vmName())) { - return; + private Set permissions(ResourceModel model, Session session, + VmPool pool, VmDefinition vmDef) throws InterruptedException { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + Set result = new HashSet<>(); + if (model.mode() == ResourceModel.Mode.POOL) { + if (pool == null) { + pool = appPipeline.fire(new GetPools() + .withName(model.name())).get().stream().findFirst() + .orElse(null); + } + if (pool != null) { + result.addAll(pool.permissionsFor(user, roles)); + } } - channelTracker.value(model.vmName()).ifPresent(item -> { + if (vmDef == null) { + vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name()) + .assignedTo(user)).get().stream().map(VmData::definition) + .findFirst().orElse(null); + } + if (vmDef != null) { + result.addAll(vmDef.permissionsFor(user, roles)); + } + return result; + } + + private void updatePreview(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + updateConfig(channel, model, vmDef); + updateVmDef(channel, model, vmDef); + } + + private void updateConfig(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateConfig", model.mode(), model.name(), + permissions(model, channel.session(), null, vmDef).stream() + .map(VmDefinition.Permission::toString).toList())); + } + + private void updateVmDef(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + Map data = null; + if (vmDef == null) { + model.setAssignedVm(null); + } else { + model.setAssignedVm(vmDef.name()); try { - var vmDef = item.associated(); - var data = Map.of("metadata", + 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", data)); + "status", vmDef.getStatus()); } catch (JsonSyntaxException e) { logger.log(Level.SEVERE, e, () -> "Failed to serialize VM definition"); + return; } - }); + } + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateVmDefinition", data)); } @Override protected void doConletDeleted(ConletDeleted event, - ConsoleConnection channel, String conletId, ViewerModel conletState) + ConsoleConnection channel, String conletId, + ResourceModel conletState) throws Exception { if (event.renderModes().isEmpty()) { channel.respond(new KeyValueStoreUpdate().delete( @@ -463,95 +620,181 @@ public class VmAccess extends FreeMarkerConlet { } /** - * Track the VM definitions. + * Track the VM definitions and update conlets. * * @param event the event * @param channel the channel * @throws IOException + * @throws InterruptedException */ @Handler(namedChannels = "manager") @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", "PMD.ConfusingArgumentToVarargsMethod" }) public void onVmDefChanged(VmDefChanged event, VmChannel channel) - throws IOException { + throws IOException, InterruptedException { var vmDef = event.vmDefinition(); - var vmName = vmDef.name(); - if (event.type() == K8sObserver.ResponseType.DELETED) { - channelTracker.remove(vmName); - } else { - channelTracker.put(vmName, channel, vmDef); - } + + // Update known conlets for (var entry : conletIdsByConsoleConnection().entrySet()) { var connection = entry.getKey(); for (var conletId : entry.getValue()) { var model = stateFromSession(connection.session(), conletId); if (model.isEmpty() - || !Objects.areEqual(model.get().vmName(), vmName)) { + || Strings.isNullOrEmpty(model.get().name())) { continue; } - if (event.type() == K8sObserver.ResponseType.DELETED) { - connection.respond( - new DeleteConlet(conletId, Collections.emptySet())); + if (model.get().mode() == ResourceModel.Mode.VM) { + // Check if this VM is used by conlet + if (!Objects.areEqual(model.get().name(), vmDef.name())) { + continue; + } + if (event.type() == K8sObserver.ResponseType.DELETED + || permissions(vmDef, connection.session()).isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + continue; + } } else { - updateVmDef(connection, model.get()); + // Check if VM is used by pool conlet or to be assigned to + // it + var user + = WebConsoleUtils.userFromSession(connection.session()) + .map(ConsoleUser::getName).orElse(null); + var toBeUsedByConlet = vmDef.assignedFrom() + .map(p -> p.equals(model.get().name())).orElse(false) + && vmDef.assignedTo().map(u -> u.equals(user)) + .orElse(false); + if (!Objects.areEqual(model.get().assignedVm(), + vmDef.name()) && !toBeUsedByConlet) { + continue; + } + + // Now unassigned if VM is deleted or no longer to be used + if (event.type() == K8sObserver.ResponseType.DELETED + || !toBeUsedByConlet) { + updateVmDef(connection, model.get(), null); + continue; + } } + + // Full update because permissions may have changed + updatePreview(connection, model.get(), vmDef); } } } - @Override - @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", - "PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount", + /** + * On vm pool changed. + * + * @param event the event + * @param channel the channel + * @throws InterruptedException + */ + @Handler(namedChannels = "manager") + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onVmPoolChanged(VmPoolChanged event) + throws InterruptedException { + var poolName = event.vmPool().name(); + // Update known conlets + for (var entry : conletIdsByConsoleConnection().entrySet()) { + var connection = entry.getKey(); + for (var conletId : entry.getValue()) { + var model = stateFromSession(connection.session(), conletId); + if (model.isEmpty() + || model.get().mode() != ResourceModel.Mode.POOL + || !Objects.areEqual(model.get().name(), poolName)) { + continue; + } + if (event.deleted() + || permissions(event.vmPool(), connection.session()) + .isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + continue; + } + updateConfig(connection, model.get(), null); + } + } + } + + @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity", "PMD.AvoidLiteralsInIfCondition" }) + @Override protected void doUpdateConletState(NotifyConletModel event, - ConsoleConnection channel, ViewerModel model) - throws Exception { + ConsoleConnection channel, ResourceModel model) throws Exception { event.stop(); - if ("selectedVm".equals(event.method())) { - selectVm(event, channel, model); + if ("selectedResource".equals(event.method())) { + selectResource(event, channel, model); return; } - // Handle command for selected VM - var both = Optional.ofNullable(model.vmName()) - .flatMap(vm -> channelTracker.value(vm)); - if (both.isEmpty()) { - return; + Optional vmData = getVmData(model, channel); + if (vmData.isEmpty()) { + if (model.mode() == ResourceModel.Mode.VM) { + return; + } + if ("start".equals(event.method())) { + // Assign a VM. + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + vmData = Optional.ofNullable(appPipeline + .fire(new AssignVm(model.name(), user)).get()); + if (vmData.isEmpty()) { + ResourceBundle resourceBundle + = resourceBundle(channel.locale()); + channel.respond(new DisplayNotification( + resourceBundle.getString("poolEmptyNotification"), + Map.of("autoClose", 10_000, "type", "Error"))); + return; + } + } } - var vmChannel = both.get().channel(); - var vmDef = both.get().associated(); + + // Handle command for selected VM + var vmChannel = vmData.get().channel(); + var vmDef = vmData.get().definition(); var vmName = vmDef.metadata().getName(); - var perms = permissions(vmDef, channel.session()); + var perms = permissions(model, channel.session(), null, vmDef); var resourceBundle = resourceBundle(channel.locale()); switch (event.method()) { case "start": - if (perms.contains(Permission.START)) { + if (perms.contains(VmDefinition.Permission.START)) { fire(new ModifyVm(vmName, "state", "Running", vmChannel)); } break; case "stop": - if (perms.contains(Permission.STOP)) { + if (perms.contains(VmDefinition.Permission.STOP)) { fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); } break; case "reset": - if (perms.contains(Permission.RESET)) { + if (perms.contains(VmDefinition.Permission.RESET)) { confirmReset(event, channel, model, resourceBundle); } break; case "resetConfirmed": - if (perms.contains(Permission.RESET)) { + if (perms.contains(VmDefinition.Permission.RESET)) { fire(new ResetVm(vmName), vmChannel); } break; case "openConsole": - if (perms.contains(Permission.ACCESS_CONSOLE)) { - var user = WebConsoleUtils.userFromSession(channel.session()) - .map(ConsoleUser::getName).orElse(""); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + if (vmDef.conditionStatus("ConsoleConnected").orElse(false) + && vmDef.consoleUser().map(cu -> !cu.equals(user) + && !perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) + .orElse(false)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleTakenNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE) + || perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) { var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> openConsole(vmName, channel, model, + e -> openConsole(vmDef, channel, model, e.password().orElse(null))); fire(pwQuery, vmChannel); } @@ -561,30 +804,41 @@ public class VmAccess extends FreeMarkerConlet { } } - private void selectVm(NotifyConletModel event, ConsoleConnection channel, - ViewerModel model) throws JsonProcessingException { - model.setVmName(event.param(0)); - String jsonState = objectMapper.writeValueAsString(model); - channel.respond(new KeyValueStoreUpdate().update(storagePath( - channel.session(), model.getConletId()), jsonState)); - updateConfig(channel, model); + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.UseLocaleWithCaseConversions" }) + private void selectResource(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model) + throws JsonProcessingException, InterruptedException { + try { + model.setMode(ResourceModel.Mode + .valueOf(event. param(0).toUpperCase())); + model.setName(event.param(1)); + String jsonState = objectMapper.writeValueAsString(model); + channel.respond(new KeyValueStoreUpdate().update(storagePath( + channel.session(), model.getConletId()), jsonState)); + updatePreview(channel, model, + getVmData(model, channel).map(VmData::definition).orElse(null)); + } catch (IllegalArgumentException e) { + logger.warning(() -> "Invalid resource type: " + e.getMessage()); + } } - private void openConsole(String vmName, ConsoleConnection connection, - ViewerModel model, String password) { - var vmDef = channelTracker.associated(vmName).orElse(null); + private void openConsole(VmDefinition vmDef, ConsoleConnection connection, + ResourceModel model, String password) { if (vmDef == null) { return; } var addr = displayIp(vmDef); if (addr.isEmpty()) { - logger.severe(() -> "Failed to find display IP for " + vmName); + logger + .severe(() -> "Failed to find display IP for " + vmDef.name()); return; } var port = vmDef. fromVm("display", "spice", "port") .map(Number::longValue); if (port.isEmpty()) { - logger.severe(() -> "No port defined for display of " + vmName); + logger + .severe(() -> "No port defined for display of " + vmDef.name()); return; } StringBuffer data = new StringBuffer(100) @@ -642,7 +896,7 @@ public class VmAccess extends FreeMarkerConlet { } private void confirmReset(NotifyConletModel event, - ConsoleConnection channel, ViewerModel model, + ConsoleConnection channel, ResourceModel model, ResourceBundle resourceBundle) throws TemplateNotFoundException, MalformedTemplateNameException, ParseException, IOException { Template tpl = freemarkerConfig() @@ -662,59 +916,119 @@ public class VmAccess extends FreeMarkerConlet { } /** - * The Class VmsModel. + * The Class AccessModel. */ @SuppressWarnings("PMD.DataClass") - public static class ViewerModel extends ConletBaseModel { - - private String vmName; - private boolean generated; + public static class ResourceModel extends ConletBaseModel { /** - * Instantiates a new vms model. + * The Enum ResourceType. + */ + @SuppressWarnings("PMD.ShortVariable") + public enum Mode { + VM, POOL + } + + private Mode mode; + private String name; + private String assignedVm; + + /** + * Instantiates a new resource model. * * @param conletId the conlet id */ - public ViewerModel(@JsonProperty("conletId") String conletId) { + public ResourceModel(@JsonProperty("conletId") String conletId) { super(conletId); } /** - * Gets the vm name. + * Returns the mode. * - * @return the vmName + * @return the resourceType */ - @JsonGetter("vmName") - public String vmName() { - return vmName; + @JsonGetter("mode") + public Mode mode() { + return mode; } /** - * Sets the vm name. + * Sets the mode. * - * @param vmName the vmName to set + * @param mode the resource mode to set */ - public void setVmName(String vmName) { - this.vmName = vmName; + public void setMode(Mode mode) { + this.mode = mode; } /** - * Checks if is generated. + * Gets the resource name. * - * @return the generated + * @return the string */ - public boolean isGenerated() { - return generated; + @JsonGetter("name") + public String name() { + return name; } /** - * Sets the generated. + * Sets the name. * - * @param generated the generated to set + * @param name the resource name to set */ - public void setGenerated(boolean generated) { - this.generated = generated; + public void setName(String name) { + this.name = name; } + /** + * Gets the assigned vm. + * + * @return the string + */ + @JsonGetter("assignedVm") + public String assignedVm() { + return assignedVm; + } + + /** + * Sets the assigned vm. + * + * @param name the assigned vm + */ + public void setAssignedVm(String name) { + this.assignedVm = name; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = super.hashCode(); + result = prime * result + java.util.Objects.hash(mode, name); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj)) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ResourceModel other = (ResourceModel) obj; + return mode == other.mode + && java.util.Objects.equals(name, other.name); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(50); + builder.append("AccessModel [mode=").append(mode) + .append(", name=").append(name).append(']'); + return builder.toString(); + } } } diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index fb52353..de65216 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -44,6 +44,8 @@ interface Api { /* eslint-disable @typescript-eslint/no-explicit-any */ vmName: string; vmDefinition: any; + poolName: string | null; + permissions: string[]; } const localize = (key: string) => { @@ -62,24 +64,33 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, const previewApi: Api = reactive({ vmName: "", - vmDefinition: {} + vmDefinition: {}, + poolName: null, + permissions: [] }); + const poolName = computed(() => previewApi.poolName); + const vmName = computed(() => previewApi.vmDefinition.name); const configured = computed(() => previewApi.vmDefinition.spec); - const startable = computed(() => previewApi.vmDefinition.spec && - previewApi.vmDefinition.spec.vm.state !== 'Running' - && !previewApi.vmDefinition.running); + const busy = computed(() => previewApi.vmDefinition.spec + && (previewApi.vmDefinition.spec.vm.state === 'Running' + && !previewApi.vmDefinition.running + || previewApi.vmDefinition.spec.vm.state === 'Stopped' + && previewApi.vmDefinition.running)); + const startable = computed(() => previewApi.vmDefinition.spec + && previewApi.vmDefinition.spec.vm.state !== 'Running' + && !previewApi.vmDefinition.running + && previewApi.permissions.includes('start') + || previewApi.poolName !== null && !previewApi.vmDefinition.name); const stoppable = computed(() => previewApi.vmDefinition.spec && previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); - const permissions = computed(() => previewApi.vmDefinition.spec - ? previewApi.vmDefinition.userPermissions : []); + const permissions = computed(() => previewApi.permissions); - watch(() => previewApi.vmName, (name: string) => { - if (name !== "") { - JGConsole.instance.updateConletTitle(conletId, name); - } + watch(previewApi, (api: Api) => { + JGConsole.instance.updateConletTitle(conletId, + api.poolName || api.vmDefinition.name || ""); }); provideApi(previewDom, previewApi); @@ -88,16 +99,16 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, JGConsole.notifyConletModel(conletId, action); }; - return { localize, resourceBase, vmAction, configured, - startable, stoppable, running, inUse, permissions }; + return { localize, resourceBase, vmAction, poolName, vmName, + configured, busy, startable, stoppable, running, inUse, + permissions }; }, template: ` + + + + - - -
{{ vmName }}
-
` }); @@ -139,36 +150,49 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, }; JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", - "updateConfig", function(conletId: string, vmName: string) { + "updateConfig", + function(conletId: string, type: string, resource: string, + permissions: []) { const conlet = JGConsole.findConletPreview(conletId); if (!conlet) { return; } const api = getApi(conlet.element().querySelector( ":scope .jdrupes-vmoperator-vmaccess-preview"))!; - api.vmName = vmName; + if (type === "VM") { + api.vmName = resource; + api.poolName = ""; + } else { + api.poolName = resource; + api.vmName = ""; + } + api.permissions = permissions; }); JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", - "updateVmDefinition", function(conletId: string, vmDefinition: any) { + "updateVmDefinition", function(conletId: string, vmDefinition: any | null) { const conlet = JGConsole.findConletPreview(conletId); if (!conlet) { return; } const api = getApi(conlet.element().querySelector( ":scope .jdrupes-vmoperator-vmaccess-preview"))!; - // Add some short-cuts for rendering - vmDefinition.name = vmDefinition.metadata.name; - vmDefinition.currentCpus = vmDefinition.status.cpus; - vmDefinition.currentRam = Number(vmDefinition.status.ram); - vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; - for (const condition of vmDefinition.status.conditions) { - if (condition.type === "Running") { - vmDefinition.running = condition.status === "True"; - vmDefinition.runningConditionSince - = new Date(condition.lastTransitionTime); - break; + if (vmDefinition) { + // Add some short-cuts for rendering + vmDefinition.name = vmDefinition.metadata.name; + vmDefinition.currentCpus = vmDefinition.status.cpus; + vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; + for (const condition of vmDefinition.status.conditions) { + if (condition.type === "Running") { + vmDefinition.running = condition.status === "True"; + vmDefinition.runningConditionSince + = new Date(condition.lastTransitionTime); + break; + } } + } else { + vmDefinition = {}; } api.vmDefinition = vmDefinition; }); @@ -203,19 +227,36 @@ window.orgJDrupesVmOperatorVmAccess.initEdit = (dialogDom: HTMLElement, l10nBundles, JGWC.lang()!, key); }; + const resource = ref("vm"); const vmNameInput = ref(""); + const poolNameInput = ref(""); + + watch(resource, (resource: string) => { + if (resource === "vm") { + poolNameInput.value = ""; + } + if (resource === "pool") + vmNameInput.value = ""; + }); + const conletId = (dialogDom.closest( "[data-conlet-id]")!).dataset["conletId"]!; const conlet = JGConsole.findConletPreview(conletId); if (conlet) { const api = getApi(conlet.element().querySelector( ":scope .jdrupes-vmoperator-vmaccess-preview"))!; + if (api.poolName) { + resource.value = "pool"; + } vmNameInput.value = api.vmName; + poolNameInput.value = api.poolName; } - provideApi(dialogDom, vmNameInput); + provideApi(dialogDom, { resource: () => resource.value, + name: () => resource.value === "vm" + ? vmNameInput.value : poolNameInput.value }); - return { formId, localize, vmNameInput }; + return { formId, localize, resource, vmNameInput, poolNameInput }; } }); app.use(JgwcPlugin); @@ -229,8 +270,9 @@ window.orgJDrupesVmOperatorVmAccess.applyEdit = } const conletId = (dialogDom.closest("[data-conlet-id]")!) .dataset["conletId"]!; - const vmName = getApi>(dialogDom!)!.value; - JGConsole.notifyConletModel(conletId, "selectedVm", vmName); + const editApi = getApi>(dialogDom!)!; + JGConsole.notifyConletModel(conletId, "selectedResource", editApi.resource(), + editApi.name()); } window.orgJDrupesVmOperatorVmAccess.confirmReset = diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss index 547dc74..86b4014 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss @@ -49,7 +49,12 @@ .jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-preview { + table { + border-spacing: 0; + } + img { + display: block; height: 3em; padding: 0.25rem; @@ -77,6 +82,11 @@ } .jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-edit { + + fieldset ul li { + margin-top: 0.5em; + } + select { width: 15em; } diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html index 6b46faa..a57c533 100644 --- a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html @@ -34,7 +34,7 @@ :aria-expanded="(entry.name in detailsByName) ? 'true' : 'false'"> @@ -48,6 +48,11 @@ >{{ shortDateTime(entry[key].toString()) }} {{ formatMemory(entry[key]) }} + @@ -103,6 +108,12 @@ >{{ cic.error }} + + + + + +
{{ localize("usedFrom") }}{{ entry.usedFrom }}
diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties index 88f1403..0ab15fd 100644 --- a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties @@ -2,15 +2,17 @@ conletName = VM Management VMsSummary = VMs (running/total) -since = Since +assignedTo = Assigned to currentCpus = Current CPUs currentRam = Current RAM maximumCpus = Maximum CPUs maximumRam = Maximum RAM +notInUse = Currently closed nodeName = Node requestedCpus = Requested CPUs requestedRam = Requested RAM running = Running +since = Since usedBy = Used by usedFrom = Used from vmActions = Actions diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties index 80e36d2..c8d8c4d 100644 --- a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties @@ -6,15 +6,17 @@ Period = Zeitraum Last\ hour = Letzte Stunde Last\ day = Letzter Tag -running = Gestartet -since = Seit +assignedTo = Zugewiesen an currentCpus = Aktuelle CPUs currentRam = Akuelles RAM maximumCpus = Maximale CPUs maximumRam = Maximales RAM nodeName = Knoten +notInUse = Derzeit geschlossen requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM +running = Gestartet +since = Seit usedBy = Benutzt durch usedFrom = Benutzt von vmActions = Aktionen diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java index 8395b4c..dbe17ca 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -29,9 +29,7 @@ import java.math.BigDecimal; 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; @@ -328,14 +326,9 @@ public class VmMgmt extends FreeMarkerConlet { .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(); + if (vmDef.conditionStatus("Running").orElse(false)) { + summary.runningVms += 1; + } } cachedSummary = summary; return summary; diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts index 78f5851..c7f8519 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -75,17 +75,17 @@ window.orgJDrupesVmOperatorVmMgmt.initPreview = (previewDom: HTMLElement, chart = new CpuRamChart(canvas, chartData); }) - watch(chartDateUpdate, (_) => { + watch(chartDateUpdate, (_: never) => { chart?.update(); }) - watch(JGWC.langRef(), (_) => { + watch(JGWC.langRef(), (_: never) => { chart?.localizeChart(); }) const period: Ref = ref("day"); - watch(period, (_) => { + watch(period, (_: never) => { const hours = (period.value === "day") ? 24 : 1; chart?.setPeriod(hours * 3600 * 1000); }); @@ -112,8 +112,8 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], ["nodeName", "nodeName"], - ["usedFrom", "usedFrom"], - ["usedBy", "usedBy"] + ["usedBy", "usedBy"], + ["assignedTo", "assignedTo"] ], { sortKey: "name", sortOrder: "up" @@ -164,7 +164,7 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, return { controller, vmInfos, filteredData, detailsByName, localize, shortDateTime, formatMemory, vmAction, cic, parseMemory, - maximumCpus, + maximumCpus, scopedId: (id: string) => { return idScope.scopedId(id); } }; } @@ -183,6 +183,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", vmDefinition.currentRam = Number(vmDefinition.status.ram); vmDefinition.usedFrom = vmDefinition.status.consoleClient || ""; vmDefinition.usedBy = vmDefinition.status.consoleUser || ""; + vmDefinition.assignedTo = vmDefinition.status.assignment?.user || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss index e02fe24..3a3f0d7 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss @@ -72,12 +72,18 @@ } } } + + .console-conection-closed { + color: var(--disabled); + } } td.details { - padding-left: 1em; + padding-left: 0; table { + display: inline; + td:nth-child(2) { min-width: 7em;