Merge branch 'feature/pools' into 'main'

Add VM pools

See merge request org/jdrupes/vm-operator!11
This commit is contained in:
Michael Lipp 2025-01-27 11:50:53 +00:00
commit 85be5b9cbf
34 changed files with 1566 additions and 453 deletions

View file

@ -16,6 +16,15 @@ spec:
spec: spec:
type: object type: object
properties: 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: permissions:
type: array type: array
description: >- description: >-
@ -44,7 +53,7 @@ spec:
- reset - reset
- accessConsole - accessConsole
- "*" - "*"
default: [] default: ["accessConsole"]
required: required:
- permissions - permissions
# either Namespaced or Cluster # either Namespaced or Cluster

View file

@ -994,6 +994,10 @@ spec:
type: array type: array
description: >- description: >-
Defines permissions for accessing and manipulating the VM. 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: items:
type: object type: object
description: >- description: >-
@ -1017,12 +1021,13 @@ spec:
- stop - stop
- reset - reset
- accessConsole - accessConsole
- takeConsole
- "*" - "*"
default: [] default: []
pools: pools:
type: array type: array
description: >- description: >-
List of pools to which this VM belongs. List of pools this VM belongs to.
items: items:
type: string type: string
default: [] default: []
@ -1486,6 +1491,24 @@ spec:
by the runner if password protection is not enabled. by the runner if password protection is not enabled.
type: integer type: integer
default: 0 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: conditions:
description: >- description: >-
List of component conditions observed List of component conditions observed

View file

@ -40,15 +40,30 @@
- name: admin - name: admin
fullName: Administrator fullName: Administrator
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
- name: test - name: operator
fullName: Test Account 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" password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
"/RoleConfigurator": "/RoleConfigurator":
rolesByUser: rolesByUser:
# User admin has role admin # User admin has role admin
admin: admin:
- admin - admin
test: operator:
- operator
test1:
- user
test2:
- user
test3:
- user - user
# All users have role other # All users have role other
"*": "*":
@ -59,8 +74,10 @@
# Admins can use all conlets # Admins can use all conlets
admin: admin:
- "*" - "*"
operator:
- org.jdrupes.vmoperator.vmaccess.VmAccess
user: user:
- org.jdrupes.vmoperator.vmviewer.VmViewer - org.jdrupes.vmoperator.vmaccess.VmAccess
# Others cannot use any conlet (except login conlet to log out) # Others cannot use any conlet (except login conlet to log out)
other: other:
- org.jgrapes.webconlet.oidclogin.LoginConlet - org.jgrapes.webconlet.oidclogin.LoginConlet

View file

@ -54,7 +54,13 @@ patches:
- name: admin - name: admin
fullName: Administrator fullName: Administrator
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." 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 fullName: Test Account
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
"/RoleConfigurator": "/RoleConfigurator":
@ -62,7 +68,11 @@ patches:
# User admin has role admin # User admin has role admin
admin: admin:
- admin - admin
test: test1:
- user
test2:
- user
test3:
- user - user
# All users have role other # All users have role other
"*": "*":

View file

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

View file

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

View file

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

View file

@ -13,4 +13,5 @@ dependencies {
api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)'
api 'io.kubernetes:client-java:[19.0.0,20.0.0)' api 'io.kubernetes:client-java:[19.0.0,20.0.0)'
api 'org.yaml:snakeyaml' 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 <O> the generic type
* @param <L> the generic type * @param <L> the generic type
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis") @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
"PMD.CouplingBetweenObjects" })
public class K8sClusterGenericStub<O extends KubernetesObject, public class K8sClusterGenericStub<O extends KubernetesObject,
L extends KubernetesListObject> { L extends KubernetesListObject> {
protected final K8sClient client; protected final K8sClient client;

View file

@ -18,13 +18,18 @@
package org.jdrupes.vmoperator.common; 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 io.kubernetes.client.openapi.models.V1ObjectMeta;
import java.time.Instant;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@ -35,9 +40,12 @@ import org.jdrupes.vmoperator.util.DataPath;
/** /**
* Represents a VM definition. * Represents a VM definition.
*/ */
@SuppressWarnings({ "PMD.DataClass" }) @SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" })
public class VmDefinition { public class VmDefinition {
private static ObjectMapper objectMapper
= new ObjectMapper().registerModule(new JavaTimeModule());
private String kind; private String kind;
private String apiVersion; private String apiVersion;
private V1ObjectMeta metadata; private V1ObjectMeta metadata;
@ -57,7 +65,7 @@ public class VmDefinition {
*/ */
public enum Permission { public enum Permission {
START("start"), STOP("stop"), RESET("reset"), START("start"), STOP("stop"), RESET("reset"),
ACCESS_CONSOLE("accessConsole"); ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole");
@SuppressWarnings("PMD.UseConcurrentHashMap") @SuppressWarnings("PMD.UseConcurrentHashMap")
private static Map<String, Permission> reprs = new HashMap<>(); private static Map<String, Permission> reprs = new HashMap<>();
@ -88,12 +96,44 @@ public class VmDefinition {
return Set.of(reprs.get(value)); return Set.of(reprs.get(value));
} }
/**
* To string.
*
* @return the string
*/
@Override @Override
public String toString() { public String toString() {
return repr; 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. * Gets the kind.
* *
@ -157,6 +197,16 @@ public class VmDefinition {
this.metadata = metadata; 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. * Gets the spec.
* *
@ -245,6 +295,82 @@ public class VmDefinition {
this.status = status; 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). * Set extra data (locally used, unknown to kubernetes).
* *
@ -260,6 +386,7 @@ public class VmDefinition {
/** /**
* Return extra data. * Return extra data.
* *
* @param <T> the generic type
* @param property the property * @param property the property
* @return the object * @return the object
*/ */
@ -287,12 +414,11 @@ public class VmDefinition {
} }
/** /**
* Return the requested VM state * Return the requested VM state.
* *
* @return the string * @return the string
*/ */
public RequestedVmState vmState() { public RequestedVmState vmState() {
// TODO
return fromVm("state") return fromVm("state")
.map(s -> "Running".equals(s) ? RequestedVmState.RUNNING .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING
: RequestedVmState.STOPPED) : RequestedVmState.STOPPED)
@ -329,4 +455,38 @@ public class VmDefinition {
return this.<Number> fromStatus("displayPasswordSerial") return this.<Number> fromStatus("displayPasswordSerial")
.map(Number::longValue); .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; package org.jdrupes.vmoperator.common;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; 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; import org.jdrupes.vmoperator.util.DataPath;
/** /**
@ -37,10 +38,21 @@ import org.jdrupes.vmoperator.util.DataPath;
public class VmPool { public class VmPool {
private String name; private String name;
private String retention;
private boolean defined;
private List<Grant> permissions = Collections.emptyList(); private List<Grant> permissions = Collections.emptyList();
private final Set<String> vms private final Set<String> vms
= Collections.synchronizedSet(new HashSet<>()); = Collections.synchronizedSet(new HashSet<>());
/**
* Instantiates a new vm pool.
*
* @param name the name
*/
public VmPool(String name) {
this.name = name;
}
/** /**
* Returns the 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 * @return the permissions
*/ */
public List<Grant> permissions() { public List<Grant> permissions() {
@ -84,6 +134,11 @@ public class VmPool {
return vms; return vms;
} }
/**
* To string.
*
* @return the string
*/
@Override @Override
@SuppressWarnings("PMD.AvoidLiteralsInIfCondition") @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
public String toString() { public String toString() {
@ -93,9 +148,8 @@ public class VmPool {
if (vms.size() <= 3) { if (vms.size() <= 3) {
builder.append(vms); builder.append(vms);
} else { } else {
builder.append('['); builder.append('[').append(vms.stream().limit(3).map(s -> s + ",")
vms.stream().limit(3).map(s -> s + ",").forEach(builder::append); .collect(Collectors.joining())).append("...]");
builder.append("...]");
} }
builder.append(']'); builder.append(']');
return builder.toString(); 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 lastUsed the last used
* @param role the role * @return the instant
* @param may the may
*/ */
public record Grant(String user, String role, Set<Permission> may) { public Instant retainUntil(Instant lastUsed) {
if (retention.startsWith("P")) {
@Override return lastUsed.plus(Duration.parse(retention));
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();
} }
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 { dependencies {
api project(':org.jdrupes.vmoperator.common') 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.io:[2.12.1,3)'
implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' 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.vuejs:[1.8.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.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. // Maybe override logging.properties from reconciler configuration.
DataPath.<String> get(model, "reconciler", "loggingProperties") DataPath.<String> get(model, "reconciler", "loggingProperties")
.ifPresent(props -> { .ifPresent(props -> {
GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data")
.get().addProperty("logging.properties", props); .get().addProperty("logging.properties", props);
}); });
// Maybe override logging.properties from VM definition. // Maybe override logging.properties from VM definition.
DataPath.<String> get(model, "cr", "spec", "loggingProperties") DataPath.<String> get(model, "cr", "spec", "loggingProperties")
.ifPresent(props -> { .ifPresent(props -> {
GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") GsonPtr.to(mapDef.getRaw()).getAs(JsonObject.class, "data")
.get().addProperty("logging.properties", props); .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. // to access the VM's console. Might change in the future.
// attach(new ServiceMonitor(channel()).channelManager(chanMgr)); // attach(new ServiceMonitor(channel()).channelManager(chanMgr));
attach(new Reconciler(channel())); attach(new Reconciler(channel()));
attach(new PoolManager(channel())); attach(new PoolMonitor(channel()));
} }
/** /**

View file

@ -18,27 +18,28 @@
package org.jdrupes.vmoperator.manager; 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.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.Watch;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; 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_GROUP;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sDynamicModel;
import org.jdrupes.vmoperator.common.K8sDynamicModels; import org.jdrupes.vmoperator.common.K8sDynamicModels;
import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sDynamicStub;
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.common.VmPool;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; 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.VmDefChanged;
import org.jdrupes.vmoperator.manager.events.VmPoolChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged;
import org.jdrupes.vmoperator.util.GsonPtr; import org.jdrupes.vmoperator.util.GsonPtr;
@ -53,11 +54,9 @@ import org.jgrapes.core.events.Attached;
* avoid concurrent change informations. * avoid concurrent change informations.
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" })
public class PoolManager extends public class PoolMonitor extends
AbstractMonitor<K8sDynamicModel, K8sDynamicModels, Channel> { 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 final Map<String, VmPool> pools = new ConcurrentHashMap<>();
private EventPipeline poolPipeline; private EventPipeline poolPipeline;
@ -67,7 +66,7 @@ public class PoolManager extends
* @param componentChannel the component channel * @param componentChannel the component channel
* @param channelManager the channel manager * @param channelManager the channel manager
*/ */
public PoolManager(Channel componentChannel) { public PoolMonitor(Channel componentChannel) {
super(componentChannel, K8sDynamicModel.class, super(componentChannel, K8sDynamicModel.class,
K8sDynamicModels.class); K8sDynamicModels.class);
} }
@ -107,18 +106,13 @@ public class PoolManager extends
// When pool is deleted, save VMs in pending // When pool is deleted, save VMs in pending
if (type == ResponseType.DELETED) { if (type == ResponseType.DELETED) {
try { Optional.ofNullable(pools.get(poolName)).ifPresent(pool -> {
pendingLock.lock(); pool.setDefined(false);
Optional.ofNullable(pools.get(poolName)).ifPresent( if (pool.vms().isEmpty()) {
p -> { pools.remove(poolName);
pending.computeIfAbsent(poolName, k -> Collections }
.synchronizedSet(new HashSet<>())).addAll(p.vms()); poolPipeline.fire(new VmPoolChanged(pool, true));
pools.remove(poolName); });
poolPipeline.fire(new VmPoolChanged(p, true));
});
} finally {
pendingLock.unlock();
}
return; return;
} }
@ -135,75 +129,85 @@ public class PoolManager extends
} }
} }
// Convert to VM pool // Get pool and merge changes
var vmPool = client().getJSON().getGson().fromJson( var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName));
GsonPtr.to(poolModel.data()).to("spec").get(), var newData = client().getJSON().getGson().fromJson(
VmPool.class); GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class);
V1ObjectMeta metadata = response.object.getMetadata(); vmPool.setRetention(newData.retention());
vmPool.setName(metadata.getName()); vmPool.setPermissions(newData.permissions());
vmPool.setDefined(true);
// If modified, merge changes poolPipeline.fire(new VmPoolChanged(vmPool));
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();
}
} }
/** /**
* Track VM definition changes. * Track VM definition changes.
* *
* @param event the event * @param event the event
* @throws ApiException
*/ */
@Handler @Handler
public void onVmDefChanged(VmDefChanged event) { public void onVmDefChanged(VmDefChanged event) throws ApiException {
String vmName = event.vmDefinition().name(); final var vmDef = event.vmDefinition();
final String vmName = vmDef.name();
switch (event.type()) { switch (event.type()) {
case ADDED: case ADDED:
try { vmDef.<List<String>> fromSpec("pools")
pendingLock.lock(); .orElse(Collections.emptyList()).stream().forEach(p -> {
event.vmDefinition().<List<String>> fromSpec("pools") pools.computeIfAbsent(p, k -> new VmPool(p))
.orElse(Collections.emptyList()).stream().forEach(p -> { .vms().add(vmName);
if (pools.containsKey(p)) { poolPipeline.fire(new VmPoolChanged(pools.get(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();
}
break; break;
case DELETED: case DELETED:
try { pools.values().stream().forEach(p -> {
pendingLock.lock(); if (p.vms().remove(vmName)) {
pools.values().stream().forEach(p -> { poolPipeline.fire(new VmPoolChanged(p));
if (p.vms().remove(vmName)) { }
poolPipeline.fire(new VmPoolChanged(p)); });
} return;
});
// Should not be necessary, but just in case
pending.values().stream().forEach(s -> s.remove(vmName));
} finally {
pendingLock.unlock();
}
break;
default: default:
break; 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.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.DataPath;
import org.jdrupes.vmoperator.util.GsonPtr;
import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor; import org.yaml.snakeyaml.constructor.SafeConstructor;
@ -179,13 +180,32 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
var pvcDef = Dynamics.newFromYaml( var pvcDef = Dynamics.newFromYaml(
new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
// Do apply changes // Apply changes
var pvcStub var pvcStub
= K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); = 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(); PatchOptions opts = new PatchOptions();
opts.setForce(true);
opts.setFieldManager("kubernetes-java-kubectl-apply"); 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) new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
.isEmpty()) { .isEmpty()) {
logger.warning( logger.warning(

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,20 +18,22 @@
package org.jdrupes.vmoperator.manager; 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.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.Watch;
import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.ListOptions;
import java.io.IOException; import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; 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.K8s;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicStub; 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.VmDefinitionModel;
import org.jdrupes.vmoperator.common.VmDefinitionModels; import org.jdrupes.vmoperator.common.VmDefinitionModels;
import org.jdrupes.vmoperator.common.VmDefinitionStub; 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.APP_NAME;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.manager.events.AssignVm;
import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.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.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; 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.Channel;
import org.jgrapes.core.Event; import org.jgrapes.core.Event;
import org.jgrapes.core.annotation.Handler;
/** /**
* Watches for changes of VM definitions. * Watches for changes of VM definitions.
@ -119,11 +127,6 @@ public class VmMonitor extends
V1ObjectMeta metadata = response.object.getMetadata(); V1ObjectMeta metadata = response.object.getMetadata();
VmChannel channel = channelManager.channelGet(metadata.getName()); 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 // Get full definition and associate with channel as backup
var vmModel = response.object; var vmModel = response.object;
if (vmModel.data() == null) { if (vmModel.data() == null) {
@ -151,17 +154,16 @@ public class VmMonitor extends
// Create and fire changed event. Remove channel from channel // Create and fire changed event. Remove channel from channel
// manager on completion. // manager on completion.
channel.pipeline() VmDefChanged chgEvt
.fire(Event.onCompletion( = new VmDefChanged(ResponseType.valueOf(response.type),
new VmDefChanged(ResponseType.valueOf(response.type), channel.setGeneration(response.object.getMetadata()
channel.setGeneration(response.object.getMetadata() .getGeneration()),
.getGeneration()), vmDef);
vmDef), if (ResponseType.valueOf(response.type) == ResponseType.DELETED) {
e -> { chgEvt = Event.onCompletion(chgEvt,
if (e.type() == ResponseType.DELETED) { e -> channelManager.remove(e.vmDefinition().name()));
channelManager.remove(e.vmDefinition().name()); }
} channel.pipeline().fire(chgEvt, channel);
}), channel);
} }
private VmDefinitionModel getModel(K8sClient client, private VmDefinitionModel getModel(K8sClient client,
@ -190,16 +192,7 @@ public class VmMonitor extends
// VM definition status changes before the pod terminates. // VM definition status changes before the pod terminates.
// This results in pod information being shown for a stopped // This results in pod information being shown for a stopped
// VM which is irritating. So check condition first. // VM which is irritating. So check condition first.
@SuppressWarnings("PMD.LambdaCanBeMethodReference") if (!vmDef.conditionStatus("Running").orElse(false)) {
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) {
return; return;
} }
var podSearch = new ListOptions(); var podSearch = new ListOptions();
@ -227,4 +220,131 @@ public class VmMonitor extends
() -> "Cannot access node information: " + e.getMessage()); () -> "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.JsonObject;
import com.google.gson.JsonPrimitive; import com.google.gson.JsonPrimitive;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -62,7 +63,8 @@ public class GsonPtr {
* @param selectors the selectors * @param selectors the selectors
* @return the Gson pointer * @return the Gson pointer
*/ */
@SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace",
"PMD.AvoidDuplicateLiterals" })
public GsonPtr to(Object... selectors) { public GsonPtr to(Object... selectors) {
JsonElement element = position; JsonElement element = position;
for (Object sel : selectors) { for (Object sel : selectors) {
@ -91,6 +93,42 @@ public class GsonPtr {
return new GsonPtr(element); 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. * Returns {@link JsonElement} that the pointer points to.
* *
@ -109,7 +147,7 @@ public class GsonPtr {
* @return the result * @return the result
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) @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())) { if (cls.isAssignableFrom(position.getClass())) {
return cls.cast(position); return cls.cast(position);
} }
@ -128,7 +166,7 @@ public class GsonPtr {
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" })
public <T extends JsonElement> Optional<T> public <T extends JsonElement> Optional<T>
get(Class<T> cls, Object... selectors) { getAs(Class<T> cls, Object... selectors) {
JsonElement element = position; JsonElement element = position;
for (Object sel : selectors) { for (Object sel : selectors) {
if (element instanceof JsonObject obj if (element instanceof JsonObject obj
@ -163,7 +201,7 @@ public class GsonPtr {
* @return the as string * @return the as string
*/ */
public Optional<String> getAsString(Object... selectors) { public Optional<String> getAsString(Object... selectors) {
return get(JsonPrimitive.class, selectors) return getAs(JsonPrimitive.class, selectors)
.map(JsonPrimitive::getAsString); .map(JsonPrimitive::getAsString);
} }
@ -174,7 +212,7 @@ public class GsonPtr {
* @return the as string * @return the as string
*/ */
public Optional<Integer> getAsInt(Object... selectors) { public Optional<Integer> getAsInt(Object... selectors) {
return get(JsonPrimitive.class, selectors) return getAs(JsonPrimitive.class, selectors)
.map(JsonPrimitive::getAsInt); .map(JsonPrimitive::getAsInt);
} }
@ -185,7 +223,7 @@ public class GsonPtr {
* @return the as string * @return the as string
*/ */
public Optional<BigInteger> getAsBigInteger(Object... selectors) { public Optional<BigInteger> getAsBigInteger(Object... selectors) {
return get(JsonPrimitive.class, selectors) return getAs(JsonPrimitive.class, selectors)
.map(JsonPrimitive::getAsBigInteger); .map(JsonPrimitive::getAsBigInteger);
} }
@ -196,7 +234,7 @@ public class GsonPtr {
* @return the as string * @return the as string
*/ */
public Optional<Long> getAsLong(Object... selectors) { public Optional<Long> getAsLong(Object... selectors) {
return get(JsonPrimitive.class, selectors) return getAs(JsonPrimitive.class, selectors)
.map(JsonPrimitive::getAsLong); .map(JsonPrimitive::getAsLong);
} }
@ -207,7 +245,7 @@ public class GsonPtr {
* @return the boolean * @return the boolean
*/ */
public Optional<Boolean> getAsBoolean(Object... selectors) { public Optional<Boolean> getAsBoolean(Object... selectors) {
return get(JsonPrimitive.class, selectors) return getAs(JsonPrimitive.class, selectors)
.map(JsonPrimitive::getAsBoolean); .map(JsonPrimitive::getAsBoolean);
} }
@ -222,7 +260,7 @@ public class GsonPtr {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T extends JsonElement> List<T> getAsListOf(Class<T> cls, public <T extends JsonElement> List<T> getAsListOf(Class<T> cls,
Object... selectors) { 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()); .orElse(Collections.emptyList());
} }
@ -336,4 +374,22 @@ public class GsonPtr {
return this; 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"> data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">
<form :id="formId" ref="formDom" onsubmit="return false;"> <form :id="formId" ref="formDom" onsubmit="return false;">
<section> <section>
<span>{{ localize("Select VM") }}</span> <fieldset>
<p> <legend>{{ localize("Select VM or pool") }}</legend>
<label> <ul>
<span>{{ localize("VM") }}</span> <li>
<select v-model="vmNameInput"> <label>
<#list vmNames as name> <input v-model="resource" type="radio" value="vm"
<option value="${name}">${name}</option> <#if vmNames?size == 0>:disabled="true"</#if>label>
</#list> <span>{{ localize("VM") }}</span>
</select> <select v-model="vmNameInput" :disabled="resource !== 'vm'">
</label> <#list vmNames as name>
</p> <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> </section>
</form> </form>
</div> </div>

View file

@ -5,3 +5,5 @@ okayLabel = Apply and Close
confirmResetTitle = Confirm reset confirmResetTitle = Confirm reset
confirmResetMsg = Resetting the VM may cause loss of data. \ confirmResetMsg = Resetting the VM may cause loss of data. \
Please confirm to continue. 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 conletName = VM-Zugriff
okayLabel = Anwenden und Schließen okayLabel = Anwenden und Schließen
Select\ VM = VM auswählen Select\ VM\ or\ pool = VM oder Pool auswählen
Start\ VM = VM starten Start\ VM = VM starten
Stop\ VM = VM anhalten Stop\ VM = VM anhalten
@ -11,3 +11,7 @@ Open\ console = Konsole anzeigen
confirmResetTitle = Zurücksetzen bestätigen confirmResetTitle = Zurücksetzen bestätigen
confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \
Bitte bestätigen um fortzufahren. 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 * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * 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.K8sObserver;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Permission; 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.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.ModifyVm;
import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.manager.events.VmPoolChanged;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
import org.jgrapes.core.Components; import org.jgrapes.core.Components;
import org.jgrapes.core.Event; import org.jgrapes.core.Event;
import org.jgrapes.core.EventPipeline;
import org.jgrapes.core.Manager; import org.jgrapes.core.Manager;
import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.annotation.Handler;
import org.jgrapes.core.events.Start;
import org.jgrapes.http.Session; import org.jgrapes.http.Session;
import org.jgrapes.util.events.ConfigurationUpdate; import org.jgrapes.util.events.ConfigurationUpdate;
import org.jgrapes.util.events.KeyValueStoreQuery; 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.ConsolePrepared;
import org.jgrapes.webconsole.base.events.ConsoleReady; import org.jgrapes.webconsole.base.events.ConsoleReady;
import org.jgrapes.webconsole.base.events.DeleteConlet; 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.NotifyConletModel;
import org.jgrapes.webconsole.base.events.NotifyConletView; import org.jgrapes.webconsole.base.events.NotifyConletView;
import org.jgrapes.webconsole.base.events.OpenModalDialog; import org.jgrapes.webconsole.base.events.OpenModalDialog;
@ -106,10 +114,12 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
* *
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports",
"PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods" }) "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods",
public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> { "PMD.CyclomaticComplexity" })
public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
private static final String VM_NAME_PROPERTY = "vmName"; private static final String VM_NAME_PROPERTY = "vmName";
private static final String POOL_NAME_PROPERTY = "poolName";
private static final String RENDERED private static final String RENDERED
= VmAccess.class.getName() + ".rendered"; = VmAccess.class.getName() + ".rendered";
private static final String PENDING private static final String PENDING
@ -118,8 +128,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
RenderMode.Preview, RenderMode.Edit); RenderMode.Preview, RenderMode.Edit);
private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet( private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet(
RenderMode.Preview, RenderMode.StickyPreview); RenderMode.Preview, RenderMode.StickyPreview);
private final ChannelTracker<String, VmChannel, private EventPipeline appPipeline;
VmDefinition> channelTracker = new ChannelTracker<>();
private static ObjectMapper objectMapper private static ObjectMapper objectMapper
= new ObjectMapper().registerModule(new JavaTimeModule()); = new ObjectMapper().registerModule(new JavaTimeModule());
private Class<?> preferredIpVersion = Inet4Address.class; private Class<?> preferredIpVersion = Inet4Address.class;
@ -144,6 +153,16 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
super(componentChannel); super(componentChannel);
} }
/**
* On start.
*
* @param event the event
*/
@Handler
public void onStart(Start event) {
appPipeline = event.processedBy().get();
}
/** /**
* Configure the component. * Configure the component.
* *
@ -247,36 +266,74 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
* @throws InterruptedException the interrupted exception * @throws InterruptedException the interrupted exception
*/ */
@Handler @Handler
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
public void onConsoleConfigured(ConsoleConfigured event, public void onConsoleConfigured(ConsoleConfigured event,
ConsoleConnection connection) throws InterruptedException, ConsoleConnection connection) throws InterruptedException,
IOException { IOException {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
final var rendered = (Set<String>) connection.session().get(RENDERED); final var rendered
= (Set<ResourceModel>) connection.session().get(RENDERED);
connection.session().remove(RENDERED); connection.session().remove(RENDERED);
if (!syncPreviews(connection.session())) { if (!syncPreviews(connection.session())) {
return; return;
} }
addMissingConlets(event, connection, rendered);
}
boolean foundMissing = false; @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops",
for (var vmName : accessibleVms(connection)) { "PMD.AvoidDuplicateLiterals" })
if (rendered.contains(vmName)) { private void addMissingConlets(ConsoleConfigured event,
continue; ConsoleConnection connection, final Set<ResourceModel> rendered)
} throws InterruptedException {
if (!foundMissing) { var session = connection.session();
// Suspending to allow rendering of conlets to be noticed
var failSafe = Components.schedule(t -> event.resumeHandling(), // Evaluate missing VMs
Duration.ofSeconds(1)); var missingVms = appPipeline.fire(new GetVms().accessibleFor(
event.suspendHandling(failSafe::cancel); WebConsoleUtils.userFromSession(session)
connection.setAssociated(PENDING, event); .map(ConsoleUser::getName).orElse(null),
foundMissing = true; 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(), fire(new AddConletRequest(event.event().event().renderSupport(),
VmAccess.class.getName(), VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview))
RenderMode.asSet(RenderMode.Preview))
.addProperty(VM_NAME_PROPERTY, vmName), .addProperty(VM_NAME_PROPERTY, vmName),
connection); 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 @Override
protected Optional<ViewerModel> createNewState(AddConletRequest event, protected Optional<ResourceModel> createNewState(AddConletRequest event,
ConsoleConnection connection, String conletId) throws Exception { ConsoleConnection connection, String conletId) throws Exception {
var model = new ViewerModel(conletId); var model = new ResourceModel(conletId);
model.vmName = (String) event.properties().get(VM_NAME_PROPERTY); var poolName = (String) event.properties().get(POOL_NAME_PROPERTY);
if (model.vmName != null) { if (poolName != null) {
model.setGenerated(true); 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); String jsonState = objectMapper.writeValueAsString(model);
connection.respond(new KeyValueStoreUpdate().update( connection.respond(new KeyValueStoreUpdate().update(
@ -314,9 +375,9 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
} }
@Override @Override
protected Optional<ViewerModel> createStateRepresentation(Event<?> event, protected Optional<ResourceModel> createStateRepresentation(Event<?> event,
ConsoleConnection connection, String conletId) throws Exception { ConsoleConnection connection, String conletId) throws Exception {
var model = new ViewerModel(conletId); var model = new ResourceModel(conletId);
String jsonState = objectMapper.writeValueAsString(model); String jsonState = objectMapper.writeValueAsString(model);
connection.respond(new KeyValueStoreUpdate().update( connection.respond(new KeyValueStoreUpdate().update(
storagePath(connection.session(), model.getConletId()), jsonState)); storagePath(connection.session(), model.getConletId()), jsonState));
@ -325,7 +386,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
@Override @Override
@SuppressWarnings("PMD.EmptyCatchBlock") @SuppressWarnings("PMD.EmptyCatchBlock")
protected Optional<ViewerModel> recreateState(Event<?> event, protected Optional<ResourceModel> recreateState(Event<?> event,
ConsoleConnection channel, String conletId) throws Exception { ConsoleConnection channel, String conletId) throws Exception {
KeyValueStoreQuery query = new KeyValueStoreQuery( KeyValueStoreQuery query = new KeyValueStoreQuery(
storagePath(channel.session(), conletId), channel); storagePath(channel.session(), conletId), channel);
@ -334,8 +395,8 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
if (!query.results().isEmpty()) { if (!query.results().isEmpty()) {
var json = query.results().get(0).values().stream().findFirst() var json = query.results().get(0).values().stream().findFirst()
.get(); .get();
ViewerModel model ResourceModel model
= objectMapper.readValue(json, ViewerModel.class); = objectMapper.readValue(json, ResourceModel.class);
return Optional.of(model); return Optional.of(model);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
@ -347,58 +408,37 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
} }
@Override @Override
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "unchecked" }) @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" })
protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
ConsoleConnection channel, String conletId, ViewerModel model) ConsoleConnection channel, String conletId, ResourceModel model)
throws Exception { throws Exception {
if (event.renderAs().contains(RenderMode.Preview)) {
return renderPreview(event, channel, conletId, model);
}
// Render edit
ResourceBundle resourceBundle = resourceBundle(channel.locale()); ResourceBundle resourceBundle = resourceBundle(channel.locale());
Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class); 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)) { if (event.renderAs().contains(RenderMode.Edit)) {
Template tpl = freemarkerConfig() var session = channel.session();
.getTemplate("VmAccess-edit.ftl.html"); 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); 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, channel.respond(new OpenModalDialog(type(), conletId,
processTemplate(event, tpl, fmModel)) processTemplate(event, tpl, fmModel))
.addOption("cancelable", true) .addOption("cancelable", true)
@ -408,10 +448,83 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
return renderedAs; return renderedAs;
} }
private List<String> accessibleVms(ConsoleConnection channel) { @SuppressWarnings("unchecked")
return channelTracker.associated().stream() private Set<RenderMode> renderPreview(RenderConletRequestBase<?> event,
.filter(d -> !permissions(d, channel.session()).isEmpty()) ConsoleConnection channel, String conletId, ResourceModel model)
.map(d -> d.getMetadata().getName()).sorted().toList(); 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) { private Set<Permission> permissions(VmDefinition vmDef, Session session) {
@ -422,39 +535,83 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
return vmDef.permissionsFor(user, roles); return vmDef.permissionsFor(user, roles);
} }
private void updateConfig(ConsoleConnection channel, ViewerModel model) { private Set<Permission> permissions(VmPool pool, Session session) {
channel.respond(new NotifyConletView(type(), var user = WebConsoleUtils.userFromSession(session)
model.getConletId(), "updateConfig", model.vmName())); .map(ConsoleUser::getName).orElse(null);
updateVmDef(channel, model); var roles = WebConsoleUtils.rolesFromSession(session)
.stream().map(ConsoleRole::getName).toList();
return pool.permissionsFor(user, roles);
} }
private void updateVmDef(ConsoleConnection channel, ViewerModel model) { private Set<Permission> permissions(ResourceModel model, Session session,
if (Strings.isNullOrEmpty(model.vmName())) { VmPool pool, VmDefinition vmDef) throws InterruptedException {
return; 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 { try {
var vmDef = item.associated(); data = Map.of("metadata",
var data = Map.of("metadata",
Map.of("namespace", vmDef.namespace(), Map.of("namespace", vmDef.namespace(),
"name", vmDef.name()), "name", vmDef.name()),
"spec", vmDef.spec(), "spec", vmDef.spec(),
"status", vmDef.getStatus(), "status", vmDef.getStatus());
"userPermissions",
permissions(vmDef, channel.session()).stream()
.map(Permission::toString).toList());
channel.respond(new NotifyConletView(type(),
model.getConletId(), "updateVmDefinition", data));
} catch (JsonSyntaxException e) { } catch (JsonSyntaxException e) {
logger.log(Level.SEVERE, e, logger.log(Level.SEVERE, e,
() -> "Failed to serialize VM definition"); () -> "Failed to serialize VM definition");
return;
} }
}); }
channel.respond(new NotifyConletView(type(),
model.getConletId(), "updateVmDefinition", data));
} }
@Override @Override
protected void doConletDeleted(ConletDeleted event, protected void doConletDeleted(ConletDeleted event,
ConsoleConnection channel, String conletId, ViewerModel conletState) ConsoleConnection channel, String conletId,
ResourceModel conletState)
throws Exception { throws Exception {
if (event.renderModes().isEmpty()) { if (event.renderModes().isEmpty()) {
channel.respond(new KeyValueStoreUpdate().delete( 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 event the event
* @param channel the channel * @param channel the channel
* @throws IOException * @throws IOException
* @throws InterruptedException
*/ */
@Handler(namedChannels = "manager") @Handler(namedChannels = "manager")
@SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals",
"PMD.ConfusingArgumentToVarargsMethod" }) "PMD.ConfusingArgumentToVarargsMethod" })
public void onVmDefChanged(VmDefChanged event, VmChannel channel) public void onVmDefChanged(VmDefChanged event, VmChannel channel)
throws IOException { throws IOException, InterruptedException {
var vmDef = event.vmDefinition(); var vmDef = event.vmDefinition();
var vmName = vmDef.name();
if (event.type() == K8sObserver.ResponseType.DELETED) { // Update known conlets
channelTracker.remove(vmName);
} else {
channelTracker.put(vmName, channel, vmDef);
}
for (var entry : conletIdsByConsoleConnection().entrySet()) { for (var entry : conletIdsByConsoleConnection().entrySet()) {
var connection = entry.getKey(); var connection = entry.getKey();
for (var conletId : entry.getValue()) { for (var conletId : entry.getValue()) {
var model = stateFromSession(connection.session(), conletId); var model = stateFromSession(connection.session(), conletId);
if (model.isEmpty() if (model.isEmpty()
|| !Objects.areEqual(model.get().vmName(), vmName)) { || Strings.isNullOrEmpty(model.get().name())) {
continue; continue;
} }
if (event.type() == K8sObserver.ResponseType.DELETED) { if (model.get().mode() == ResourceModel.Mode.VM) {
connection.respond( // Check if this VM is used by conlet
new DeleteConlet(conletId, Collections.emptySet())); 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 { } 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", * On vm pool changed.
"PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount", *
* @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" }) "PMD.AvoidLiteralsInIfCondition" })
@Override
protected void doUpdateConletState(NotifyConletModel event, protected void doUpdateConletState(NotifyConletModel event,
ConsoleConnection channel, ViewerModel model) ConsoleConnection channel, ResourceModel model) throws Exception {
throws Exception {
event.stop(); event.stop();
if ("selectedVm".equals(event.method())) { if ("selectedResource".equals(event.method())) {
selectVm(event, channel, model); selectResource(event, channel, model);
return; return;
} }
// Handle command for selected VM Optional<VmData> vmData = getVmData(model, channel);
var both = Optional.ofNullable(model.vmName()) if (vmData.isEmpty()) {
.flatMap(vm -> channelTracker.value(vm)); if (model.mode() == ResourceModel.Mode.VM) {
if (both.isEmpty()) { return;
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 vmName = vmDef.metadata().getName();
var perms = permissions(vmDef, channel.session()); var perms = permissions(model, channel.session(), null, vmDef);
var resourceBundle = resourceBundle(channel.locale()); var resourceBundle = resourceBundle(channel.locale());
switch (event.method()) { switch (event.method()) {
case "start": case "start":
if (perms.contains(Permission.START)) { if (perms.contains(VmDefinition.Permission.START)) {
fire(new ModifyVm(vmName, "state", "Running", vmChannel)); fire(new ModifyVm(vmName, "state", "Running", vmChannel));
} }
break; break;
case "stop": case "stop":
if (perms.contains(Permission.STOP)) { if (perms.contains(VmDefinition.Permission.STOP)) {
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
} }
break; break;
case "reset": case "reset":
if (perms.contains(Permission.RESET)) { if (perms.contains(VmDefinition.Permission.RESET)) {
confirmReset(event, channel, model, resourceBundle); confirmReset(event, channel, model, resourceBundle);
} }
break; break;
case "resetConfirmed": case "resetConfirmed":
if (perms.contains(Permission.RESET)) { if (perms.contains(VmDefinition.Permission.RESET)) {
fire(new ResetVm(vmName), vmChannel); fire(new ResetVm(vmName), vmChannel);
} }
break; break;
case "openConsole": case "openConsole":
if (perms.contains(Permission.ACCESS_CONSOLE)) { var user = WebConsoleUtils.userFromSession(channel.session())
var user = WebConsoleUtils.userFromSession(channel.session()) .map(ConsoleUser::getName).orElse("");
.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 var pwQuery
= Event.onCompletion(new GetDisplayPassword(vmDef, user), = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> openConsole(vmName, channel, model, e -> openConsole(vmDef, channel, model,
e.password().orElse(null))); e.password().orElse(null)));
fire(pwQuery, vmChannel); fire(pwQuery, vmChannel);
} }
@ -561,30 +804,41 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
} }
} }
private void selectVm(NotifyConletModel event, ConsoleConnection channel, @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
ViewerModel model) throws JsonProcessingException { "PMD.UseLocaleWithCaseConversions" })
model.setVmName(event.param(0)); private void selectResource(NotifyConletModel event,
String jsonState = objectMapper.writeValueAsString(model); ConsoleConnection channel, ResourceModel model)
channel.respond(new KeyValueStoreUpdate().update(storagePath( throws JsonProcessingException, InterruptedException {
channel.session(), model.getConletId()), jsonState)); try {
updateConfig(channel, model); 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, private void openConsole(VmDefinition vmDef, ConsoleConnection connection,
ViewerModel model, String password) { ResourceModel model, String password) {
var vmDef = channelTracker.associated(vmName).orElse(null);
if (vmDef == null) { if (vmDef == null) {
return; return;
} }
var addr = displayIp(vmDef); var addr = displayIp(vmDef);
if (addr.isEmpty()) { if (addr.isEmpty()) {
logger.severe(() -> "Failed to find display IP for " + vmName); logger
.severe(() -> "Failed to find display IP for " + vmDef.name());
return; return;
} }
var port = vmDef.<Number> fromVm("display", "spice", "port") var port = vmDef.<Number> fromVm("display", "spice", "port")
.map(Number::longValue); .map(Number::longValue);
if (port.isEmpty()) { if (port.isEmpty()) {
logger.severe(() -> "No port defined for display of " + vmName); logger
.severe(() -> "No port defined for display of " + vmDef.name());
return; return;
} }
StringBuffer data = new StringBuffer(100) StringBuffer data = new StringBuffer(100)
@ -642,7 +896,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
} }
private void confirmReset(NotifyConletModel event, private void confirmReset(NotifyConletModel event,
ConsoleConnection channel, ViewerModel model, ConsoleConnection channel, ResourceModel model,
ResourceBundle resourceBundle) throws TemplateNotFoundException, ResourceBundle resourceBundle) throws TemplateNotFoundException,
MalformedTemplateNameException, ParseException, IOException { MalformedTemplateNameException, ParseException, IOException {
Template tpl = freemarkerConfig() Template tpl = freemarkerConfig()
@ -662,59 +916,119 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ViewerModel> {
} }
/** /**
* The Class VmsModel. * The Class AccessModel.
*/ */
@SuppressWarnings("PMD.DataClass") @SuppressWarnings("PMD.DataClass")
public static class ViewerModel extends ConletBaseModel { public static class ResourceModel extends ConletBaseModel {
private String vmName;
private boolean generated;
/** /**
* 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 * @param conletId the conlet id
*/ */
public ViewerModel(@JsonProperty("conletId") String conletId) { public ResourceModel(@JsonProperty("conletId") String conletId) {
super(conletId); super(conletId);
} }
/** /**
* Gets the vm name. * Returns the mode.
* *
* @return the vmName * @return the resourceType
*/ */
@JsonGetter("vmName") @JsonGetter("mode")
public String vmName() { public Mode mode() {
return vmName; 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) { public void setMode(Mode mode) {
this.vmName = vmName; this.mode = mode;
} }
/** /**
* Checks if is generated. * Gets the resource name.
* *
* @return the generated * @return the string
*/ */
public boolean isGenerated() { @JsonGetter("name")
return generated; 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) { public void setName(String name) {
this.generated = generated; 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 */ /* eslint-disable @typescript-eslint/no-explicit-any */
vmName: string; vmName: string;
vmDefinition: any; vmDefinition: any;
poolName: string | null;
permissions: string[];
} }
const localize = (key: string) => { const localize = (key: string) => {
@ -62,24 +64,33 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
const previewApi: Api = reactive({ const previewApi: Api = reactive({
vmName: "", vmName: "",
vmDefinition: {} vmDefinition: {},
poolName: null,
permissions: []
}); });
const poolName = computed(() => previewApi.poolName);
const vmName = computed(() => previewApi.vmDefinition.name);
const configured = computed(() => previewApi.vmDefinition.spec); const configured = computed(() => previewApi.vmDefinition.spec);
const startable = computed(() => previewApi.vmDefinition.spec && const busy = computed(() => previewApi.vmDefinition.spec
previewApi.vmDefinition.spec.vm.state !== 'Running' && (previewApi.vmDefinition.spec.vm.state === 'Running'
&& !previewApi.vmDefinition.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 && const stoppable = computed(() => previewApi.vmDefinition.spec &&
previewApi.vmDefinition.spec.vm.state !== 'Stopped' previewApi.vmDefinition.spec.vm.state !== 'Stopped'
&& previewApi.vmDefinition.running); && previewApi.vmDefinition.running);
const running = computed(() => previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running);
const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const inUse = computed(() => previewApi.vmDefinition.usedBy != '');
const permissions = computed(() => previewApi.vmDefinition.spec const permissions = computed(() => previewApi.permissions);
? previewApi.vmDefinition.userPermissions : []);
watch(() => previewApi.vmName, (name: string) => { watch(previewApi, (api: Api) => {
if (name !== "") { JGConsole.instance.updateConletTitle(conletId,
JGConsole.instance.updateConletTitle(conletId, name); api.poolName || api.vmDefinition.name || "");
}
}); });
provideApi(previewDom, previewApi); provideApi(previewDom, previewApi);
@ -88,16 +99,16 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
JGConsole.notifyConletModel(conletId, action); JGConsole.notifyConletModel(conletId, action);
}; };
return { localize, resourceBase, vmAction, configured, return { localize, resourceBase, vmAction, poolName, vmName,
startable, stoppable, running, inUse, permissions }; configured, busy, startable, stoppable, running, inUse,
permissions };
}, },
template: ` template: `
<table> <table>
<tbody> <tbody>
<tr> <tr>
<td rowspan="2" style="position: relative"><span <td rowspan="2" style="position: relative"><span
style="position: absolute;" style="position: absolute;" :class="{ busy: busy }"
:class="{ busy: configured && !startable && !stoppable }"
><img role=button :aria-disabled="!running ><img role=button :aria-disabled="!running
|| !permissions.includes('accessConsole')" || !permissions.includes('accessConsole')"
v-on:click="vmAction('openConsole')" v-on:click="vmAction('openConsole')"
@ -107,9 +118,12 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
:title="localize('Open console')"></span><span :title="localize('Open console')"></span><span
style="visibility: hidden;"><img style="visibility: hidden;"><img
:src="resourceBase + 'computer.svg'"></span></td> :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"> <td class="jdrupes-vmoperator-vmaccess-preview-action-list">
<span role="button" <span role="button" :aria-disabled="!startable"
:aria-disabled="!startable || !permissions.includes('start')"
tabindex="0" class="fa fa-play" :title="localize('Start VM')" tabindex="0" class="fa fa-play" :title="localize('Start VM')"
v-on:click="vmAction('start')"></span> v-on:click="vmAction('start')"></span>
<span role="button" <span role="button"
@ -127,9 +141,6 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
</span> </span>
</td> </td>
</tr> </tr>
<tr>
<td></td>
</tr>
</tbody> </tbody>
</table>` </table>`
}); });
@ -139,36 +150,49 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
}; };
JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", 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); const conlet = JGConsole.findConletPreview(conletId);
if (!conlet) { if (!conlet) {
return; return;
} }
const api = getApi<Api>(conlet.element().querySelector( const api = getApi<Api>(conlet.element().querySelector(
":scope .jdrupes-vmoperator-vmaccess-preview"))!; ":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", 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); const conlet = JGConsole.findConletPreview(conletId);
if (!conlet) { if (!conlet) {
return; return;
} }
const api = getApi<Api>(conlet.element().querySelector( const api = getApi<Api>(conlet.element().querySelector(
":scope .jdrupes-vmoperator-vmaccess-preview"))!; ":scope .jdrupes-vmoperator-vmaccess-preview"))!;
// Add some short-cuts for rendering if (vmDefinition) {
vmDefinition.name = vmDefinition.metadata.name; // Add some short-cuts for rendering
vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.name = vmDefinition.metadata.name;
vmDefinition.currentRam = Number(vmDefinition.status.ram); vmDefinition.currentCpus = vmDefinition.status.cpus;
vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; vmDefinition.currentRam = Number(vmDefinition.status.ram);
for (const condition of vmDefinition.status.conditions) { vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
if (condition.type === "Running") { for (const condition of vmDefinition.status.conditions) {
vmDefinition.running = condition.status === "True"; if (condition.type === "Running") {
vmDefinition.runningConditionSince vmDefinition.running = condition.status === "True";
= new Date(condition.lastTransitionTime); vmDefinition.runningConditionSince
break; = new Date(condition.lastTransitionTime);
break;
}
} }
} else {
vmDefinition = {};
} }
api.vmDefinition = vmDefinition; api.vmDefinition = vmDefinition;
}); });
@ -203,19 +227,36 @@ window.orgJDrupesVmOperatorVmAccess.initEdit = (dialogDom: HTMLElement,
l10nBundles, JGWC.lang()!, key); l10nBundles, JGWC.lang()!, key);
}; };
const resource = ref<string>("vm");
const vmNameInput = ref<string>(""); 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( const conletId = (<HTMLElement>dialogDom.closest(
"[data-conlet-id]")!).dataset["conletId"]!; "[data-conlet-id]")!).dataset["conletId"]!;
const conlet = JGConsole.findConletPreview(conletId); const conlet = JGConsole.findConletPreview(conletId);
if (conlet) { if (conlet) {
const api = getApi<Api>(conlet.element().querySelector( const api = getApi<Api>(conlet.element().querySelector(
":scope .jdrupes-vmoperator-vmaccess-preview"))!; ":scope .jdrupes-vmoperator-vmaccess-preview"))!;
if (api.poolName) {
resource.value = "pool";
}
vmNameInput.value = api.vmName; 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); app.use(JgwcPlugin);
@ -229,8 +270,9 @@ window.orgJDrupesVmOperatorVmAccess.applyEdit =
} }
const conletId = (<HTMLElement>dialogDom.closest("[data-conlet-id]")!) const conletId = (<HTMLElement>dialogDom.closest("[data-conlet-id]")!)
.dataset["conletId"]!; .dataset["conletId"]!;
const vmName = getApi<ref<string>>(dialogDom!)!.value; const editApi = getApi<ref<string>>(dialogDom!)!;
JGConsole.notifyConletModel(conletId, "selectedVm", vmName); JGConsole.notifyConletModel(conletId, "selectedResource", editApi.resource(),
editApi.name());
} }
window.orgJDrupesVmOperatorVmAccess.confirmReset = window.orgJDrupesVmOperatorVmAccess.confirmReset =

View file

@ -49,7 +49,12 @@
.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-preview { .jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-preview {
table {
border-spacing: 0;
}
img { img {
display: block;
height: 3em; height: 3em;
padding: 0.25rem; padding: 0.25rem;
@ -77,6 +82,11 @@
} }
.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-edit { .jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-edit {
fieldset ul li {
margin-top: 0.5em;
}
select { select {
width: 15em; width: 15em;
} }

View file

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

View file

@ -2,15 +2,17 @@ conletName = VM Management
VMsSummary = VMs (running/total) VMsSummary = VMs (running/total)
since = Since assignedTo = Assigned to
currentCpus = Current CPUs currentCpus = Current CPUs
currentRam = Current RAM currentRam = Current RAM
maximumCpus = Maximum CPUs maximumCpus = Maximum CPUs
maximumRam = Maximum RAM maximumRam = Maximum RAM
notInUse = Currently closed
nodeName = Node nodeName = Node
requestedCpus = Requested CPUs requestedCpus = Requested CPUs
requestedRam = Requested RAM requestedRam = Requested RAM
running = Running running = Running
since = Since
usedBy = Used by usedBy = Used by
usedFrom = Used from usedFrom = Used from
vmActions = Actions vmActions = Actions

View file

@ -6,15 +6,17 @@ Period = Zeitraum
Last\ hour = Letzte Stunde Last\ hour = Letzte Stunde
Last\ day = Letzter Tag Last\ day = Letzter Tag
running = Gestartet assignedTo = Zugewiesen an
since = Seit
currentCpus = Aktuelle CPUs currentCpus = Aktuelle CPUs
currentRam = Akuelles RAM currentRam = Akuelles RAM
maximumCpus = Maximale CPUs maximumCpus = Maximale CPUs
maximumRam = Maximales RAM maximumRam = Maximales RAM
nodeName = Knoten nodeName = Knoten
notInUse = Derzeit geschlossen
requestedCpus = Angeforderte CPUs requestedCpus = Angeforderte CPUs
requestedRam = Angefordertes RAM requestedRam = Angefordertes RAM
running = Gestartet
since = Seit
usedBy = Benutzt durch usedBy = Benutzt durch
usedFrom = Benutzt von usedFrom = Benutzt von
vmActions = Aktionen vmActions = Aktionen

View file

@ -29,9 +29,7 @@ import java.math.BigDecimal;
import java.math.BigInteger; import java.math.BigInteger;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Collections;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@ -328,14 +326,9 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
.add(vmDef.<String> fromStatus("ram") .add(vmDef.<String> fromStatus("ram")
.map(r -> Quantity.fromString(r).getNumber().toBigInteger()) .map(r -> Quantity.fromString(r).getNumber().toBigInteger())
.orElse(BigInteger.ZERO)); .orElse(BigInteger.ZERO));
summary.runningVms if (vmDef.conditionStatus("Running").orElse(false)) {
+= vmDef.<List<Map<String, Object>>> fromStatus("conditions") summary.runningVms += 1;
.orElse(Collections.emptyList()).stream() }
.filter(cond -> DataPath.get(cond, "type")
.map(t -> "Running".equals(t)).orElse(false)
&& DataPath.get(cond, "status")
.map(s -> "True".equals(s)).orElse(false))
.count();
} }
cachedSummary = summary; cachedSummary = summary;
return summary; return summary;

View file

@ -75,17 +75,17 @@ window.orgJDrupesVmOperatorVmMgmt.initPreview = (previewDom: HTMLElement,
chart = new CpuRamChart(canvas, chartData); chart = new CpuRamChart(canvas, chartData);
}) })
watch(chartDateUpdate, (_) => { watch(chartDateUpdate, (_: never) => {
chart?.update(); chart?.update();
}) })
watch(JGWC.langRef(), (_) => { watch(JGWC.langRef(), (_: never) => {
chart?.localizeChart(); chart?.localizeChart();
}) })
const period: Ref<string> = ref<string>("day"); const period: Ref<string> = ref<string>("day");
watch(period, (_) => { watch(period, (_: never) => {
const hours = (period.value === "day") ? 24 : 1; const hours = (period.value === "day") ? 24 : 1;
chart?.setPeriod(hours * 3600 * 1000); chart?.setPeriod(hours * 3600 * 1000);
}); });
@ -112,8 +112,8 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement,
["currentCpus", "currentCpus"], ["currentCpus", "currentCpus"],
["currentRam", "currentRam"], ["currentRam", "currentRam"],
["nodeName", "nodeName"], ["nodeName", "nodeName"],
["usedFrom", "usedFrom"], ["usedBy", "usedBy"],
["usedBy", "usedBy"] ["assignedTo", "assignedTo"]
], { ], {
sortKey: "name", sortKey: "name",
sortOrder: "up" sortOrder: "up"
@ -183,6 +183,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt",
vmDefinition.currentRam = Number(vmDefinition.status.ram); vmDefinition.currentRam = Number(vmDefinition.status.ram);
vmDefinition.usedFrom = vmDefinition.status.consoleClient || ""; vmDefinition.usedFrom = vmDefinition.status.consoleClient || "";
vmDefinition.usedBy = vmDefinition.status.consoleUser || ""; vmDefinition.usedBy = vmDefinition.status.consoleUser || "";
vmDefinition.assignedTo = vmDefinition.status.assignment?.user || "";
for (const condition of vmDefinition.status.conditions) { for (const condition of vmDefinition.status.conditions) {
if (condition.type === "Running") { if (condition.type === "Running") {
vmDefinition.running = condition.status === "True"; vmDefinition.running = condition.status === "True";

View file

@ -72,12 +72,18 @@
} }
} }
} }
.console-conection-closed {
color: var(--disabled);
}
} }
td.details { td.details {
padding-left: 1em; padding-left: 0;
table { table {
display: inline;
td:nth-child(2) { td:nth-child(2) {
min-width: 7em; min-width: 7em;