Merge branch 'feature/pools' into testing

This commit is contained in:
Michael Lipp 2025-01-27 11:11:25 +01:00
commit 50ad911265
34 changed files with 1566 additions and 453 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
"*":

View file

@ -4,7 +4,11 @@ metadata:
namespace: vmop-dev
name: test-vms
spec:
retention: "PT1m"
permissions:
- user: admin
may:
- accessConsole
- role: user
may:
- accessConsole

View file

@ -21,9 +21,6 @@ spec:
- role: admin
may:
- "*"
- user: test
may:
- accessConsole
guestShutdownStops: true

View file

@ -14,9 +14,6 @@ spec:
- user: admin
may:
- "*"
- user: test
may:
- "accessConsole"
resources:
requests:

View file

@ -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]'
}

View file

@ -45,7 +45,8 @@ import java.util.function.Function;
* @param <O> the generic type
* @param <L> the generic type
*/
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
"PMD.CouplingBetweenObjects" })
public class K8sClusterGenericStub<O extends KubernetesObject,
L extends KubernetesListObject> {
protected final K8sClient client;

View file

@ -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<String, Permission> 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<Permission> 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<String> pools() {
return this.<List<String>> 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<String> assignedFrom() {
return fromStatus("assignment", "pool");
}
/**
* The user that the VM was assigned to.
*
* @return the optional
*/
public Optional<String> assignedTo() {
return fromStatus("assignment", "user");
}
/**
* Last usage of assigned VM.
*
* @return the optional
*/
public Optional<Instant> assignmentLastUsed() {
return this.<String> 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<V1Condition> condition(String name) {
return this.<List<Map<String, Object>>> 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<Boolean> conditionStatus(String name) {
return this.<List<Map<String, Object>>> 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<String> consoleUser() {
return this.<String> fromStatus("consoleUser");
}
/**
* Set extra data (locally used, unknown to kubernetes).
*
@ -260,6 +386,7 @@ public class VmDefinition {
/**
* Return extra data.
*
* @param <T> 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.<Number> 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());
}
}

View file

@ -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<Grant> permissions = Collections.emptyList();
private final Set<String> 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<Grant> 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<Permission> 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<String, Permission> 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<Permission> parse(String value) {
if ("*".equals(value)) {
return EnumSet.allOf(Permission.class);
}
return Set.of(reprs.get(value));
}
@Override
public String toString() {
return repr;
}
}
}

View file

@ -10,5 +10,4 @@ plugins {
dependencies {
api project(':org.jdrupes.vmoperator.common')
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]'
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<VmData> {
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;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<VmPool>> {
private String name;
private String user;
private List<String> 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<String> roles) {
this.user = user;
this.roles = roles;
return this;
}
/**
* Returns the name filter criterion, if set.
*
* @return the optional
*/
public Optional<String> name() {
return Optional.ofNullable(name);
}
/**
* Returns the user filter criterion, if set.
*
* @return the optional
*/
public Optional<String> forUser() {
return Optional.ofNullable(user);
}
/**
* Returns the roles criterion.
*
* @return the list
*/
public List<String> forRoles() {
return roles;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<GetVms.VmData>> {
private String name;
private String user;
private List<String> 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<String> 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<String> name() {
return Optional.ofNullable(name);
}
/**
* Returns the user filter criterion, if set.
*
* @return the optional
*/
public Optional<String> user() {
return Optional.ofNullable(user);
}
/**
* Returns the roles criterion.
*
* @return the list
*/
public List<String> roles() {
return roles;
}
/**
* Returns the pool filter criterion, if set.
*
* @return the optional
*/
public Optional<String> fromPool() {
return Optional.ofNullable(fromPool);
}
/**
* Returns the user filter criterion, if set.
*
* @return the optional
*/
public Optional<String> toUser() {
return Optional.ofNullable(toUser);
}
/**
* Return tuple.
*
* @param definition the definition
* @param channel the channel
*/
public record VmData(VmDefinition definition, VmChannel channel) {
}
}

View file

@ -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)'

View file

@ -86,14 +86,14 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
// Maybe override logging.properties from reconciler configuration.
DataPath.<String> 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.<String> 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);
});

View file

@ -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()));
}
/**

View file

@ -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<K8sDynamicModel, K8sDynamicModels, Channel> {
private final ReentrantLock pendingLock = new ReentrantLock();
private final Map<String, Set<String>> pending = new ConcurrentHashMap<>();
private final Map<String, VmPool> 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().<List<String>> 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.<List<String>> 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());
}
}

View file

@ -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(

View file

@ -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.<List<Map<String, Object>>> 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;
}
}

View file

@ -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<GsonPtr> 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 extends JsonElement> T get(Class<T> cls) {
public <T extends JsonElement> T getAs(Class<T> cls) {
if (cls.isAssignableFrom(position.getClass())) {
return cls.cast(position);
}
@ -128,7 +166,7 @@ public class GsonPtr {
*/
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" })
public <T extends JsonElement> Optional<T>
get(Class<T> cls, Object... selectors) {
getAs(Class<T> 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<String> 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<Integer> 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<BigInteger> 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<Long> 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<Boolean> 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 <T extends JsonElement> List<T> getAsListOf(Class<T> cls,
Object... selectors) {
return get(JsonArray.class, selectors).map(a -> (List<T>) a.asList())
return getAs(JsonArray.class, selectors).map(a -> (List<T>) 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();
}
}
}

View file

@ -5,17 +5,35 @@
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">
<form :id="formId" ref="formDom" onsubmit="return false;">
<section>
<span>{{ localize("Select VM") }}</span>
<p>
<label>
<span>{{ localize("VM") }}</span>
<select v-model="vmNameInput">
<#list vmNames as name>
<option value="${name}">${name}</option>
</#list>
</select>
</label>
</p>
<fieldset>
<legend>{{ localize("Select VM or pool") }}</legend>
<ul>
<li>
<label>
<input v-model="resource" type="radio" value="vm"
<#if vmNames?size == 0>:disabled="true"</#if>label>
<span>{{ localize("VM") }}</span>
<select v-model="vmNameInput" :disabled="resource !== 'vm'">
<#list vmNames as name>
<option value="${name}">${name}</option>
</#list>
</select>
</label>
</li>
<li>
<label>
<input v-model="resource" type="radio" value="pool"
<#if poolNames?size == 0>:disabled="true"</#if>>
<span>{{ localize("Pool") }}</span>
<select v-model="poolNameInput" :disabled="resource !== 'pool'">
<#list poolNames as name>
<option value="${name}">${name}</option>
</#list>
</select>
</label>
</li>
</ul>
</fieldset>
</section>
</form>
</div>

View file

@ -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.

View file

@ -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.

View file

@ -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<VmAccess.ViewerModel> {
"PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods",
"PMD.CyclomaticComplexity" })
public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
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<VmAccess.ViewerModel> {
RenderMode.Preview, RenderMode.Edit);
private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet(
RenderMode.Preview, RenderMode.StickyPreview);
private final ChannelTracker<String, VmChannel,
VmDefinition> 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<VmAccess.ViewerModel> {
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<VmAccess.ViewerModel> {
* @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<String>) connection.session().get(RENDERED);
final var rendered
= (Set<ResourceModel>) 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<ResourceModel> 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<VmAccess.ViewerModel> {
}
@Override
protected Optional<ViewerModel> createNewState(AddConletRequest event,
protected Optional<ResourceModel> 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<VmAccess.ViewerModel> {
}
@Override
protected Optional<ViewerModel> createStateRepresentation(Event<?> event,
protected Optional<ResourceModel> 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<VmAccess.ViewerModel> {
@Override
@SuppressWarnings("PMD.EmptyCatchBlock")
protected Optional<ViewerModel> recreateState(Event<?> event,
protected Optional<ResourceModel> 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<VmAccess.ViewerModel> {
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<VmAccess.ViewerModel> {
}
@Override
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "unchecked" })
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" })
protected Set<RenderMode> 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<RenderMode> 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<String>) 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<VmAccess.ViewerModel> {
return renderedAs;
}
private List<String> 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<RenderMode> 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<ResourceModel>) s).add(model));
updatePreview(channel, model, vmDef);
}
return EnumSet.of(RenderMode.Preview);
}
private Optional<VmData> 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<Permission> permissions(VmDefinition vmDef, Session session) {
@ -422,39 +535,83 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
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<Permission> 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<Permission> 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<Permission> 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<String, Object> 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<VmAccess.ViewerModel> {
}
/**
* 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> 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<VmAccess.ViewerModel> {
}
}
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.<String> 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.<Number> 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<VmAccess.ViewerModel> {
}
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<VmAccess.ViewerModel> {
}
/**
* 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();
}
}
}

View file

@ -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: `
<table>
<tbody>
<tr>
<td rowspan="2" style="position: relative"><span
style="position: absolute;"
:class="{ busy: configured && !startable && !stoppable }"
style="position: absolute;" :class="{ busy: busy }"
><img role=button :aria-disabled="!running
|| !permissions.includes('accessConsole')"
v-on:click="vmAction('openConsole')"
@ -107,9 +118,12 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
:title="localize('Open console')"></span><span
style="visibility: hidden;"><img
:src="resourceBase + 'computer.svg'"></span></td>
<td v-if="!poolName" style="padding: 0;"></td>
<td v-else>{{ vmName }}</td>
</tr>
<tr>
<td class="jdrupes-vmoperator-vmaccess-preview-action-list">
<span role="button"
:aria-disabled="!startable || !permissions.includes('start')"
<span role="button" :aria-disabled="!startable"
tabindex="0" class="fa fa-play" :title="localize('Start VM')"
v-on:click="vmAction('start')"></span>
<span role="button"
@ -127,9 +141,6 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
</span>
</td>
</tr>
<tr>
<td></td>
</tr>
</tbody>
</table>`
});
@ -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<Api>(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<Api>(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<string>("vm");
const vmNameInput = ref<string>("");
const poolNameInput = ref<string>("");
watch(resource, (resource: string) => {
if (resource === "vm") {
poolNameInput.value = "";
}
if (resource === "pool")
vmNameInput.value = "";
});
const conletId = (<HTMLElement>dialogDom.closest(
"[data-conlet-id]")!).dataset["conletId"]!;
const conlet = JGConsole.findConletPreview(conletId);
if (conlet) {
const api = getApi<Api>(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 = (<HTMLElement>dialogDom.closest("[data-conlet-id]")!)
.dataset["conletId"]!;
const vmName = getApi<ref<string>>(dialogDom!)!.value;
JGConsole.notifyConletModel(conletId, "selectedVm", vmName);
const editApi = getApi<ref<string>>(dialogDom!)!;
JGConsole.notifyConletModel(conletId, "selectedResource", editApi.resource(),
editApi.name());
}
window.orgJDrupesVmOperatorVmAccess.confirmReset =

View file

@ -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;
}

View file

@ -34,7 +34,7 @@
:aria-expanded="(entry.name in detailsByName) ? 'true' : 'false'">
<td v-for="key in controller.keys"
v-bind:class="'column-' + key"
v-bind:title="key == 'name' ? entry['name']: false"
v-bind:title="key == 'name' ? entry['name'] : null"
v-bind:rowspan="(key == 'name') && $aash.isDisclosed(scopedId(rowIndex)) ? 2 : false">
<aash-disclosure-button v-if="key === 'name'" :type="'div'"
:id-ref="scopedId(rowIndex)">
@ -48,6 +48,11 @@
>{{ shortDateTime(entry[key].toString()) }}</span>
<span v-else-if="key === 'currentRam'"
>{{ formatMemory(entry[key]) }}</span>
<span v-else-if="key === 'usedBy'"
:class="{ 'console-conection-closed' : !entry.usedFrom }"
:title="entry.usedFrom ? localize('usedFrom')
+ ' ' + entry.usedFrom : localize('notInUse')"
v-html="controller.breakBeforeDots(entry[key])"></span>
<span v-else
v-html="controller.breakBeforeDots(entry[key])"></span>
</td>
@ -103,6 +108,12 @@
><span>{{ cic.error }}</span></form></td>
</tr>
</table>
<table class="table--basic table--basic--autoStriped">
<tr>
<td>{{ localize("usedFrom") }}</td>
<td>{{ entry.usedFrom }}</td>
</tr>
</table>
</td>
</tr>
</template>

View file

@ -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

View file

@ -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

View file

@ -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<VmMgmt.VmsModel> {
.add(vmDef.<String> fromStatus("ram")
.map(r -> Quantity.fromString(r).getNumber().toBigInteger())
.orElse(BigInteger.ZERO));
summary.runningVms
+= vmDef.<List<Map<String, Object>>> 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;

View file

@ -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<string> = ref<string>("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"
@ -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";

View file

@ -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;