Merge branch 'feature/auto-login' into testing

This commit is contained in:
Michael Lipp 2025-03-05 13:33:48 +01:00
commit ef3d680421
30 changed files with 460 additions and 286 deletions

38
.markdownlint.yaml Normal file
View file

@ -0,0 +1,38 @@
# See https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml
# Default state for all rules
default: true
# MD007/ul-indent : Unordered list indentation :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md007.md
MD007:
# Spaces for indent
indent: 2
# Whether to indent the first level of the list
start_indented: true
# Spaces for first level indent (when start_indented is set)
start_indent: 2
# MD025/single-title/single-h1 : Multiple top-level headings in the same document :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md025.md
MD025:
# Heading level
level: 1
# RegExp for matching title in front matter (disable)
front_matter_title: ""
# MD036/no-emphasis-as-heading : Emphasis used instead of a heading :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md036.md
MD036: false
# MD043/required-headings : Required heading structure :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md043.md
MD043:
# List of headings
headings: [
"# Head",
"## Item",
"### Detail"
]
# Match case of headings
match_case: false

View file

@ -25,6 +25,12 @@ spec:
type: string 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])?)?)$' 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" default: "PT1h"
loginOnAssignment:
description: >-
If set to true, the user will be automatically logged in
to the VM's console when the VM is assigned to him.
type: boolean
default: false
permissions: permissions:
type: array type: array
description: >- description: >-

View file

@ -1534,6 +1534,24 @@ spec:
lastTransitionTime: "1970-01-01T00:00:00Z" lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation reason: Creation
message: "Creation of CR" message: "Creation of CR"
- type: Booted
status: "False"
observedGeneration: 1
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
- type: VmopAgentConnected
status: "False"
observedGeneration: 1
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
- type: UserLoggedIn
status: "False"
observedGeneration: 1
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
- type: ConsoleConnected - type: ConsoleConnected
status: "False" status: "False"
observedGeneration: 1 observedGeneration: 1

View file

@ -5,6 +5,7 @@ metadata:
name: test-vms name: test-vms
spec: spec:
retention: "PT1m" retention: "PT1m"
loginOnAssignment: true
permissions: permissions:
- user: admin - user: admin
may: may:

View file

@ -8,12 +8,11 @@ metadata:
spec: spec:
image: image:
# repository: docker-registry.lan.mnl.de
# path: vmoperator/org.jdrupes.vmoperator.runner.qemu-arch
# pullPolicy: Always # pullPolicy: Always
# repository: ghcr.io # repository: ghcr.io
# path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine # path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine
# version: "3.0.0" # version: "3.0.0"
# source: docker-registry.lan.mnl.de/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:feature-auto-login
source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing
pullPolicy: Always pullPolicy: Always

View file

@ -74,6 +74,37 @@ public class Constants {
/** The Constant ASSIGNMENT. */ /** The Constant ASSIGNMENT. */
public static final String ASSIGNMENT = "assignment"; public static final String ASSIGNMENT = "assignment";
/**
* Conditions used in Status.
*/
public static class Condition {
/** The Constant COND_RUNNING. */
public static final String RUNNING = "Running";
/** The Constant COND_BOOTED. */
public static final String BOOTED = "Booted";
/** The Constant COND_VMOP_AGENT. */
public static final String VMOP_AGENT = "VmopAgentConnected";
/** The Constant COND_USER_LOGGED_IN. */
public static final String USER_LOGGED_IN = "UserLoggedIn";
/** The Constant COND_CONSOLE. */
public static final String CONSOLE_CONNECTED = "ConsoleConnected";
/**
* Reasons used in conditions.
*/
public static class Reason {
/** The Constant NOT_REQUESTED. */
public static final String NOT_REQUESTED = "NotRequested";
/** The Constant USER_LOGGED_IN. */
public static final String LOGGED_IN = "LoggedIn";
}
}
} }
/** /**

View file

@ -39,6 +39,8 @@ import java.util.function.Function;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.Constants.Status.Condition;
import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason;
import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.DataPath;
/** /**
@ -141,6 +143,16 @@ public class VmDefinition extends K8sDynamicModel {
} }
} }
/**
* The assignment information.
*
* @param pool the pool
* @param user the user
* @param lastUsed the last used
*/
public record Assignment(String pool, String user, Instant lastUsed) {
}
/** /**
* Instantiates a new vm definition. * Instantiates a new vm definition.
* *
@ -215,31 +227,15 @@ public class VmDefinition extends K8sDynamicModel {
} }
/** /**
* The pool that the VM was taken from. * The assignment information.
* *
* @return the optional * @return the optional
*/ */
public Optional<String> assignedFrom() { public Optional<Assignment> assignment() {
return fromStatus(Status.ASSIGNMENT, "pool"); return this.<Map<String, Object>> fromStatus(Status.ASSIGNMENT)
} .filter(m -> !m.isEmpty()).map(a -> new Assignment(
a.get("pool").toString(), a.get("user").toString(),
/** Instant.parse(a.get("lastUsed").toString())));
* The user that the VM was assigned to.
*
* @return the optional
*/
public Optional<String> assignedTo() {
return fromStatus(Status.ASSIGNMENT, "user");
}
/**
* Last usage of assigned VM.
*
* @return the optional
*/
public Optional<Instant> assignmentLastUsed() {
return this.<String> fromStatus(Status.ASSIGNMENT, "lastUsed")
.map(Instant::parse);
} }
/** /**
@ -369,18 +365,47 @@ public class VmDefinition extends K8sDynamicModel {
} }
/** /**
* Check if the console is accessible. Returns true if the console is * Check if the console is accessible. Always returns `true` if
* currently unused, used by the given user or if the permissions * the VM is running and the permissions allow taking over the
* allow taking over the console. * console. Else, returns `true` if
*
* * the permissions allow access to the console and
*
* * the VM is running and
*
* * the console is currently unused or used by the given user and
*
* * if user login is requested, the given user is logged in.
* *
* @param user the user * @param user the user
* @param permissions the permissions * @param permissions the permissions
* @return true, if successful * @return true, if successful
*/ */
@SuppressWarnings("PMD.SimplifyBooleanReturns")
public boolean consoleAccessible(String user, Set<Permission> permissions) { public boolean consoleAccessible(String user, Set<Permission> permissions) {
return !conditionStatus("ConsoleConnected").orElse(true) // Basic checks
|| consoleUser().map(cu -> cu.equals(user)).orElse(true) if (!conditionStatus(Condition.RUNNING).orElse(false)) {
|| permissions.contains(VmDefinition.Permission.TAKE_CONSOLE); return false;
}
if (permissions.contains(Permission.TAKE_CONSOLE)) {
return true;
}
if (!permissions.contains(Permission.ACCESS_CONSOLE)) {
return false;
}
// If the console is in use by another user, deny access
if (conditionStatus(Condition.CONSOLE_CONNECTED).orElse(false)
&& !consoleUser().map(cu -> cu.equals(user)).orElse(false)) {
return false;
}
// If no login is requested, allow access, else check if user matches
if (condition(Condition.USER_LOGGED_IN).map(V1Condition::getReason)
.map(r -> Reason.NOT_REQUESTED.equals(r)).orElse(false)) {
return true;
}
return user.equals(status().get(Status.LOGGED_IN_USER));
} }
/** /**

View file

@ -27,6 +27,7 @@ import java.util.List;
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.Assignment;
import org.jdrupes.vmoperator.common.VmDefinition.Grant; import org.jdrupes.vmoperator.common.VmDefinition.Grant;
import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.DataPath;
@ -37,8 +38,9 @@ import org.jdrupes.vmoperator.util.DataPath;
@SuppressWarnings({ "PMD.DataClass" }) @SuppressWarnings({ "PMD.DataClass" })
public class VmPool { public class VmPool {
private String name; private final String name;
private String retention; private String retention;
private boolean loginOnAssignment;
private boolean defined; 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
@ -53,6 +55,19 @@ public class VmPool {
this.name = name; this.name = name;
} }
/**
* Fill the properties of a provisionally created pool from
* the definition.
*
* @param definition the definition
*/
public void defineFrom(VmPool definition) {
retention = definition.retention();
permissions = definition.permissions();
loginOnAssignment = definition.loginOnAssignment();
defined = true;
}
/** /**
* Returns the name. * Returns the name.
* *
@ -63,12 +78,12 @@ public class VmPool {
} }
/** /**
* Sets the name. * Checks if is login on assignment.
* *
* @param name the name to set * @return the loginOnAssignment
*/ */
public void setName(String name) { public boolean loginOnAssignment() {
this.name = name; return loginOnAssignment;
} }
/** /**
@ -81,12 +96,10 @@ public class VmPool {
} }
/** /**
* Sets if is. * Marks the pool as undefined.
*
* @param defined the defined to set
*/ */
public void setDefined(boolean defined) { public void setUndefined() {
this.defined = defined; defined = false;
} }
/** /**
@ -98,15 +111,6 @@ public class VmPool {
return 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. * Permissions granted for a VM from the pool.
* *
@ -116,15 +120,6 @@ public class VmPool {
return permissions; return permissions;
} }
/**
* Sets the permissions.
*
* @param permissions the permissions to set
*/
public void setPermissions(List<Grant> permissions) {
this.permissions = permissions;
}
/** /**
* Returns the VM names. * Returns the VM names.
* *
@ -171,13 +166,12 @@ public class VmPool {
} }
// If not assigned, it's usable // If not assigned, it's usable
if (vmDef.assignedTo().isEmpty()) { if (vmDef.assignment().isEmpty()) {
return true; return true;
} }
// Check if it is to be retained // Check if it is to be retained
if (vmDef.assignmentLastUsed() if (vmDef.assignment().map(Assignment::lastUsed).map(this::retainUntil)
.map(this::retainUntil)
.map(ru -> Instant.now().isBefore(ru)).orElse(false)) { .map(ru -> Instant.now().isBefore(ru)).orElse(false)) {
return false; return false;
} }

View file

@ -25,46 +25,29 @@ import org.jgrapes.core.Event;
* Gets the current display secret and optionally updates it. * Gets the current display secret and optionally updates it.
*/ */
@SuppressWarnings("PMD.DataClass") @SuppressWarnings("PMD.DataClass")
public class PrepareConsole extends Event<String> { public class GetDisplaySecret extends Event<String> {
private final VmDefinition vmDef; private final VmDefinition vmDef;
private final String user; private final String user;
private final boolean loginUser;
/** /**
* Instantiates a new request for the display secret. * Instantiates a new request for the display secret.
* After handling the event, a result of `null` means that * After handling the event, a result of `null` means that
* no password is needed. No result means that the console * no secret is needed. No result means that the console
* is not accessible. * is not accessible.
* *
* @param vmDef the vm name * @param vmDef the vm name
* @param user the requesting user * @param user the requesting user
* @param loginUser login the user
*/ */
public PrepareConsole(VmDefinition vmDef, String user, public GetDisplaySecret(VmDefinition vmDef, String user) {
boolean loginUser) {
this.vmDef = vmDef; this.vmDef = vmDef;
this.user = user; this.user = user;
this.loginUser = loginUser;
} }
/** /**
* Instantiates a new request for the display secret. * Gets the VM definition.
* After handling the event, a result of `null` means that
* no password is needed. No result means that the console
* is not accessible.
* *
* @param vmDef the vm name * @return the VM definition
* @param user the requesting user
*/
public PrepareConsole(VmDefinition vmDef, String user) {
this(vmDef, user, false);
}
/**
* Gets the vm definition.
*
* @return the vm definition
*/ */
public VmDefinition vmDefinition() { public VmDefinition vmDefinition() {
return vmDef; return vmDef;
@ -79,24 +62,15 @@ public class PrepareConsole extends Event<String> {
return user; return user;
} }
/**
* Checks if the user should be logged in before allowing access.
*
* @return the loginUser
*/
public boolean loginUser() {
return loginUser;
}
/** /**
* Returns `true` if a password is available. May only be called * Returns `true` if a password is available. May only be called
* when the event is completed. Note that the password returned * when the event is completed. Note that the password returned
* by {@link #password()} may be `null`, indicating that no password * by {@link #secret()} may be `null`, indicating that no password
* is needed. * is needed.
* *
* @return true, if successful * @return true, if successful
*/ */
public boolean passwordAvailable() { public boolean secretAvailable() {
if (!isDone()) { if (!isDone()) {
throw new IllegalStateException("Event is not done."); throw new IllegalStateException("Event is not done.");
} }
@ -104,13 +78,13 @@ public class PrepareConsole extends Event<String> {
} }
/** /**
* Return the password. May only be called when the event has been * Return the secret. May only be called when the event has been
* completed with a valid result (see {@link #passwordAvailable()}). * completed with a valid result (see {@link #secretAvailable()}).
* *
* @return the password. A value of `null` means that no password * @return the password. A value of `null` means that no password
* is required. * is required.
*/ */
public String password() { public String secret() {
if (!isDone() || currentResults().isEmpty()) { if (!isDone() || currentResults().isEmpty()) {
throw new IllegalStateException("Event is not done."); throw new IllegalStateException("Event is not done.");
} }

View file

@ -18,6 +18,7 @@
package org.jdrupes.vmoperator.manager.events; package org.jdrupes.vmoperator.manager.events;
import org.jdrupes.vmoperator.common.VmPool;
import org.jgrapes.core.Event; import org.jgrapes.core.Event;
/** /**
@ -26,31 +27,31 @@ import org.jgrapes.core.Event;
@SuppressWarnings("PMD.DataClass") @SuppressWarnings("PMD.DataClass")
public class UpdateAssignment extends Event<Boolean> { public class UpdateAssignment extends Event<Boolean> {
private final String usedPool; private final VmPool fromPool;
private final String toUser; private final String toUser;
/** /**
* Instantiates a new event. * Instantiates a new event.
* *
* @param usedPool the used pool * @param fromPool the pool from which the VM was assigned
* @param toUser the to user * @param toUser the to user
*/ */
public UpdateAssignment(String usedPool, String toUser) { public UpdateAssignment(VmPool fromPool, String toUser) {
this.usedPool = usedPool; this.fromPool = fromPool;
this.toUser = toUser; this.toUser = toUser;
} }
/** /**
* Gets the pool to assign from. * Gets the pool from which the VM was assigned.
* *
* @return the pool * @return the pool
*/ */
public String usedPool() { public VmPool fromPool() {
return usedPool; return fromPool;
} }
/** /**
* Gets the user to assign to. * Gets the user to whom the VM was assigned.
* *
* @return the to user * @return the to user
*/ */

View file

@ -201,8 +201,8 @@ data:
<#if spec.vm.display.outputs?? > <#if spec.vm.display.outputs?? >
outputs: ${ spec.vm.display.outputs?c } outputs: ${ spec.vm.display.outputs?c }
</#if> </#if>
<#if spec.vm.display.loggedInUser?? > <#if loginRequestedFor?? >
loggedInUser: "${ spec.vm.display.loggedInUser }" loggedInUser: "${ loginRequestedFor }"
</#if> </#if>
<#if spec.vm.display.spice??> <#if spec.vm.display.spice??>
spice: spice:

View file

@ -232,7 +232,7 @@ public class Controller extends Component {
if (vmStub.updateStatus(vmDef, from -> { if (vmStub.updateStatus(vmDef, from -> {
JsonObject status = from.statusJson(); JsonObject status = from.statusJson();
var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT);
assignment.set("pool", event.usedPool()); assignment.set("pool", event.fromPool().name());
assignment.set("user", event.toUser()); assignment.set("user", event.toUser());
assignment.set("lastUsed", Instant.now().toString()); assignment.set("lastUsed", Instant.now().toString());
return status; return status;

View file

@ -43,7 +43,7 @@ import org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitionStub;
import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
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;
@ -71,7 +71,7 @@ public class DisplaySecretReconciler extends Component {
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
private int passwordValidity = 10; private int passwordValidity = 10;
private final List<PendingPrepare> pendingPrepares private final List<PendingRequest> pendingPrepares
= Collections.synchronizedList(new LinkedList<>()); = Collections.synchronizedList(new LinkedList<>());
/** /**
@ -184,21 +184,23 @@ public class DisplaySecretReconciler extends Component {
*/ */
@Handler @Handler
@SuppressWarnings("PMD.StringInstantiation") @SuppressWarnings("PMD.StringInstantiation")
public void onPrepareConsole(PrepareConsole event, VmChannel channel) public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel)
throws ApiException { throws ApiException {
// Update console user in status // Get VM definition and check if running
var vmDef = updateConsoleUser(event, channel); var vmStub = VmDefinitionStub.get(channel.client(),
if (vmDef == null) { new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
var vmDef = vmStub.model().orElse(null);
if (vmDef == null || !vmDef.conditionStatus("Running").orElse(false)) {
return; return;
} }
// Check if access is possible // Update console user in status
if (event.loginUser() vmDef = vmStub.updateStatus(from -> {
? !vmDef.<String> fromStatus(Status.LOGGED_IN_USER) JsonObject status = from.statusJson();
.map(u -> u.equals(event.user())).orElse(false) status.addProperty(Status.CONSOLE_USER, event.user());
: !vmDef.conditionStatus("Running").orElse(false)) { return status;
return; }).get();
}
// Get secret and update password in secret // Get secret and update password in secret
var stub = getSecretStub(event, channel, vmDef); var stub = getSecretStub(event, channel, vmDef);
@ -212,7 +214,7 @@ public class DisplaySecretReconciler extends Component {
// Register wait for confirmation (by VM status change, // Register wait for confirmation (by VM status change,
// after secret update) // after secret update)
var pending = new PendingPrepare(event, var pending = new PendingRequest(event,
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
new CompletionLock(event, 1500)); new CompletionLock(event, 1500));
pendingPrepares.add(pending); pendingPrepares.add(pending);
@ -224,19 +226,7 @@ public class DisplaySecretReconciler extends Component {
stub.update(secret).getObject(); stub.update(secret).getObject();
} }
private VmDefinition updateConsoleUser(PrepareConsole event, private K8sV1SecretStub getSecretStub(GetDisplaySecret event,
VmChannel channel) throws ApiException {
var vmStub = VmDefinitionStub.get(channel.client(),
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
return vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.addProperty(Status.CONSOLE_USER, event.user());
return status;
}).orElse(null);
}
private K8sV1SecretStub getSecretStub(PrepareConsole event,
VmChannel channel, VmDefinition vmDef) throws ApiException { VmChannel channel, VmDefinition vmDef) throws ApiException {
// Look for secret // Look for secret
ListOptions options = new ListOptions(); ListOptions options = new ListOptions();
@ -253,7 +243,7 @@ public class DisplaySecretReconciler extends Component {
return stubs.iterator().next(); return stubs.iterator().next();
} }
private boolean updatePassword(V1Secret secret, PrepareConsole event) { private boolean updatePassword(V1Secret secret, GetDisplaySecret event) {
var expiry = Optional.ofNullable(secret.getData() var expiry = Optional.ofNullable(secret.getData()
.get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null); .get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null);
if (secret.getData().get(DisplaySecret.PASSWORD) != null if (secret.getData().get(DisplaySecret.PASSWORD) != null
@ -323,8 +313,8 @@ public class DisplaySecretReconciler extends Component {
* The Class PendingGet. * The Class PendingGet.
*/ */
@SuppressWarnings("PMD.DataClass") @SuppressWarnings("PMD.DataClass")
private static class PendingPrepare { private static class PendingRequest {
public final PrepareConsole event; public final GetDisplaySecret event;
public final long expectedSerial; public final long expectedSerial;
public final CompletionLock lock; public final CompletionLock lock;
@ -334,7 +324,7 @@ public class DisplaySecretReconciler extends Component {
* @param event the event * @param event the event
* @param expectedSerial the expected serial * @param expectedSerial the expected serial
*/ */
public PendingPrepare(PrepareConsole event, long expectedSerial, public PendingRequest(GetDisplaySecret event, long expectedSerial,
CompletionLock lock) { CompletionLock lock) {
super(); super();
this.event = event; this.event = event;

View file

@ -36,6 +36,7 @@ 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.VmDefinition.Assignment;
import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitionStub;
import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.common.VmPool;
import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.GetPools;
@ -105,7 +106,7 @@ public class PoolMonitor 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) {
Optional.ofNullable(pools.get(poolName)).ifPresent(pool -> { Optional.ofNullable(pools.get(poolName)).ifPresent(pool -> {
pool.setDefined(false); pool.setUndefined();
if (pool.vms().isEmpty()) { if (pool.vms().isEmpty()) {
pools.remove(poolName); pools.remove(poolName);
} }
@ -129,11 +130,8 @@ public class PoolMonitor extends
// Get pool and merge changes // Get pool and merge changes
var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName)); var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName));
var newData = client().getJSON().getGson().fromJson( vmPool.defineFrom(client().getJSON().getGson().fromJson(
GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class); GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class));
vmPool.setRetention(newData.retention());
vmPool.setPermissions(newData.permissions());
vmPool.setDefined(true);
poolPipeline.fire(new VmPoolChanged(vmPool)); poolPipeline.fire(new VmPoolChanged(vmPool));
} }
@ -168,7 +166,7 @@ public class PoolMonitor extends
} }
// Sync last usage to console state change if user matches // Sync last usage to console state change if user matches
if (vmDef.assignedTo() if (vmDef.assignment().map(Assignment::user)
.map(at -> at.equals(vmDef.consoleUser().orElse(null))) .map(at -> at.equals(vmDef.consoleUser().orElse(null)))
.orElse(true)) { .orElse(true)) {
return; return;
@ -177,8 +175,8 @@ public class PoolMonitor extends
var ccChange = vmDef.condition("ConsoleConnected") var ccChange = vmDef.condition("ConsoleConnected")
.map(cc -> cc.getLastTransitionTime().toInstant()); .map(cc -> cc.getLastTransitionTime().toInstant());
if (ccChange if (ccChange
.map(tt -> vmDef.assignmentLastUsed().map(alu -> alu.isAfter(tt)) .map(tt -> vmDef.assignment().map(Assignment::lastUsed)
.orElse(true)) .map(alu -> alu.isAfter(tt)).orElse(true))
.orElse(true)) { .orElse(true)) {
return; return;
} }

View file

@ -45,6 +45,7 @@ import java.util.HashMap;
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.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.Convertions;
@ -52,6 +53,9 @@ import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
import org.jdrupes.vmoperator.common.VmPool;
import org.jdrupes.vmoperator.manager.events.GetPools;
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;
@ -212,11 +216,6 @@ public class Reconciler extends Component {
@SuppressWarnings("PMD.ConfusingTernary") @SuppressWarnings("PMD.ConfusingTernary")
public void onVmDefChanged(VmDefChanged event, VmChannel channel) public void onVmDefChanged(VmDefChanged event, VmChannel channel)
throws ApiException, TemplateException, IOException { throws ApiException, TemplateException, IOException {
// We're only interested in "spec" changes.
if (!event.specChanged()) {
return;
}
// Ownership relationships takes care of deletions // Ownership relationships takes care of deletions
if (event.type() == K8sObserver.ResponseType.DELETED) { if (event.type() == K8sObserver.ResponseType.DELETED) {
logger.fine( logger.fine(
@ -228,6 +227,11 @@ public class Reconciler extends Component {
Map<String, Object> model Map<String, Object> model
= prepareModel(channel.client(), event.vmDefinition()); = prepareModel(channel.client(), event.vmDefinition());
var configMap = cmReconciler.reconcile(model, channel); var configMap = cmReconciler.reconcile(model, channel);
// The remaining reconcilers depend only on changes of the spec part.
if (!event.specChanged()) {
return;
}
model.put("cm", configMap); model.put("cm", configMap);
dsReconciler.reconcile(event, model, channel); dsReconciler.reconcile(event, model, channel);
// Manage (eventual) removal of stateful set. // Manage (eventual) removal of stateful set.
@ -266,24 +270,10 @@ public class Reconciler extends Component {
Optional.ofNullable(Reconciler.class.getPackage() Optional.ofNullable(Reconciler.class.getPackage()
.getImplementationVersion()).orElse("(Unknown)")); .getImplementationVersion()).orElse("(Unknown)"));
model.put("cr", vmDef); model.put("cr", vmDef);
// Freemarker's static models don't handle nested classes.
model.put("constants", constantsMap(Constants.class));
model.put("reconciler", config); model.put("reconciler", config);
model.put("constants", constantsMap(Constants.class));
// Check if we have a display secret addLoginRequestedFor(model, vmDef);
ListOptions options = new ListOptions(); addDisplaySecret(client, model, vmDef);
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
+ "app.kubernetes.io/instance=" + vmDef.name());
var dsStub = K8sV1SecretStub
.list(client, vmDef.namespace(), options)
.stream()
.findFirst();
if (dsStub.isPresent()) {
dsStub.get().model().ifPresent(m -> {
model.put("displaySecret", m.getMetadata().getName());
});
}
// Methods // Methods
model.put("parseQuantity", parseQuantityModel); model.put("parseQuantity", parseQuantityModel);
@ -294,6 +284,13 @@ public class Reconciler extends Component {
return model; return model;
} }
/**
* Creates a map with constants. Needed because freemarker doesn't support
* nested classes with its static models.
*
* @param clazz the clazz
* @return the map
*/
@SuppressWarnings("PMD.EmptyCatchBlock") @SuppressWarnings("PMD.EmptyCatchBlock")
private Map<String, Object> constantsMap(Class<?> clazz) { private Map<String, Object> constantsMap(Class<?> clazz) {
@SuppressWarnings("PMD.UseConcurrentHashMap") @SuppressWarnings("PMD.UseConcurrentHashMap")
@ -318,6 +315,38 @@ public class Reconciler extends Component {
return result; return result;
} }
private void addLoginRequestedFor(Map<String, Object> model,
VmDefinition vmDef) {
vmDef.assignment().filter(a -> {
try {
return newEventPipeline()
.fire(new GetPools().withName(a.pool())).get()
.stream().findFirst().map(VmPool::loginOnAssignment)
.orElse(false);
} catch (InterruptedException e) {
logger.log(Level.WARNING, e, e::getMessage);
}
return false;
}).map(Assignment::user)
.or(() -> vmDef.fromSpec("vm", "display", "loggedInUser"))
.ifPresent(u -> model.put("loginRequestedFor", u));
}
private void addDisplaySecret(K8sClient client, Map<String, Object> model,
VmDefinition vmDef) throws ApiException {
ListOptions options = new ListOptions();
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
+ "app.kubernetes.io/instance=" + vmDef.name());
var dsStub = K8sV1SecretStub
.list(client, vmDef.namespace(), options).stream().findFirst();
if (dsStub.isPresent()) {
dsStub.get().model().ifPresent(m -> {
model.put("displaySecret", m.getMetadata().getName());
});
}
}
private final TemplateMethodModelEx parseQuantityModel private final TemplateMethodModelEx parseQuantityModel
= new TemplateMethodModelEx() { = new TemplateMethodModelEx() {
@Override @Override

View file

@ -40,6 +40,7 @@ import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub;
import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1PodStub;
import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitionStub;
import org.jdrupes.vmoperator.common.VmDefinitions; import org.jdrupes.vmoperator.common.VmDefinitions;
import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.common.VmExtraData;
@ -234,10 +235,10 @@ public class VmMonitor extends
|| !c.vmDefinition().permissionsFor(event.user().orElse(null), || !c.vmDefinition().permissionsFor(event.user().orElse(null),
event.roles()).isEmpty()) event.roles()).isEmpty())
.filter(c -> event.fromPool().isEmpty() .filter(c -> event.fromPool().isEmpty()
|| c.vmDefinition().assignedFrom() || c.vmDefinition().assignment().map(Assignment::pool)
.map(p -> p.equals(event.fromPool().get())).orElse(false)) .map(p -> p.equals(event.fromPool().get())).orElse(false))
.filter(c -> event.toUser().isEmpty() .filter(c -> event.toUser().isEmpty()
|| c.vmDefinition().assignedTo() || c.vmDefinition().assignment().map(Assignment::user)
.map(u -> u.equals(event.toUser().get())).orElse(false)) .map(u -> u.equals(event.toUser().get())).orElse(false))
.map(c -> new VmData(c.vmDefinition(), c)) .map(c -> new VmData(c.vmDefinition(), c))
.toList()); .toList());
@ -257,9 +258,9 @@ public class VmMonitor extends
while (true) { while (true) {
// Search for existing assignment. // Search for existing assignment.
var vmQuery = channelManager.channels().stream() var vmQuery = channelManager.channels().stream()
.filter(c -> c.vmDefinition().assignedFrom() .filter(c -> c.vmDefinition().assignment().map(Assignment::pool)
.map(p -> p.equals(event.fromPool())).orElse(false)) .map(p -> p.equals(event.fromPool())).orElse(false))
.filter(c -> c.vmDefinition().assignedTo() .filter(c -> c.vmDefinition().assignment().map(Assignment::user)
.map(u -> u.equals(event.toUser())).orElse(false)) .map(u -> u.equals(event.toUser())).orElse(false))
.findFirst(); .findFirst();
if (vmQuery.isPresent()) { if (vmQuery.isPresent()) {
@ -280,7 +281,8 @@ public class VmMonitor extends
vmQuery = channelManager.channels().stream() vmQuery = channelManager.channels().stream()
.filter(c -> vmPool.isAssignable(c.vmDefinition())) .filter(c -> vmPool.isAssignable(c.vmDefinition()))
.sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition()
.assignmentLastUsed().orElse(Instant.ofEpochSecond(0))) .assignment().map(Assignment::lastUsed)
.orElse(Instant.ofEpochSecond(0)))
.thenComparing(preferRunning)) .thenComparing(preferRunning))
.findFirst(); .findFirst();
@ -293,7 +295,7 @@ public class VmMonitor extends
var chosenVm = vmQuery.get(); var chosenVm = vmQuery.get();
var vmPipeline = chosenVm.pipeline(); var vmPipeline = chosenVm.pipeline();
if (Optional.ofNullable(vmPipeline.fire(new UpdateAssignment( if (Optional.ofNullable(vmPipeline.fire(new UpdateAssignment(
vmPool.name(), event.toUser()), chosenVm).get()) vmPool, event.toUser()), chosenVm).get())
.orElse(false)) { .orElse(false)) {
var vmDef = chosenVm.vmDefinition(); var vmDef = chosenVm.vmDefinition();
event.setResult(new VmData(vmDef, chosenVm)); event.setResult(new VmData(vmDef, chosenVm));

View file

@ -35,6 +35,8 @@ import java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.Constants.Status.Condition;
import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason;
import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitionStub;
@ -72,6 +74,7 @@ public class StatusUpdater extends VmDefUpdater {
private boolean guestShutdownStops; private boolean guestShutdownStops;
private boolean shutdownByGuest; private boolean shutdownByGuest;
private VmDefinitionStub vmStub; private VmDefinitionStub vmStub;
private String loggedInUser;
/** /**
* Instantiates a new status updater. * Instantiates a new status updater.
@ -143,6 +146,7 @@ public class StatusUpdater extends VmDefUpdater {
public void onConfigureQemu(ConfigureQemu event) public void onConfigureQemu(ConfigureQemu event)
throws ApiException { throws ApiException {
guestShutdownStops = event.configuration().guestShutdownStops; guestShutdownStops = event.configuration().guestShutdownStops;
loggedInUser = event.configuration().vm.display.loggedInUser;
// Remainder applies only if we have a connection to k8s. // Remainder applies only if we have a connection to k8s.
if (vmStub == null) { if (vmStub == null) {
@ -169,10 +173,12 @@ public class StatusUpdater extends VmDefUpdater {
status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1); status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1);
} }
status.getAsJsonArray("conditions").asList().stream() status.getAsJsonArray("conditions").asList().stream()
.map(cond -> (JsonObject) cond).filter(cond -> "Running" .map(cond -> (JsonObject) cond)
.filter(cond -> Condition.RUNNING
.equals(cond.get("type").getAsString())) .equals(cond.get("type").getAsString()))
.forEach(cond -> cond.addProperty("observedGeneration", .forEach(cond -> cond.addProperty("observedGeneration",
from.getMetadata().getGeneration())); from.getMetadata().getGeneration()));
updateUserLoggedIn(from);
return status; return status;
}, vmDef); }, vmDef);
} }
@ -194,9 +200,9 @@ public class StatusUpdater extends VmDefUpdater {
} }
vmStub.updateStatus(from -> { vmStub.updateStatus(from -> {
boolean running = event.runState().vmRunning(); boolean running = event.runState().vmRunning();
updateCondition(vmDef, "Running", running, event.reason(), updateCondition(vmDef, Condition.RUNNING, running, event.reason(),
event.message()); event.message());
JsonObject status = updateCondition(vmDef, "Booted", JsonObject status = updateCondition(vmDef, Condition.BOOTED,
event.runState() == RunState.BOOTED, event.reason(), event.runState() == RunState.BOOTED, event.reason(),
event.message()); event.message());
if (event.runState() == RunState.STARTING) { if (event.runState() == RunState.STARTING) {
@ -212,10 +218,13 @@ public class StatusUpdater extends VmDefUpdater {
if (!running) { if (!running) {
// In case console connection was still present // In case console connection was still present
status.addProperty(Status.CONSOLE_CLIENT, ""); status.addProperty(Status.CONSOLE_CLIENT, "");
updateCondition(from, "ConsoleConnected", false, "VmStopped", updateCondition(from, Condition.CONSOLE_CONNECTED, false,
"VmStopped",
"The VM is not running"); "The VM is not running");
// In case we had an irregular shutdown // In case we had an irregular shutdown
updateCondition(from, Condition.USER_LOGGED_IN, false,
"VmStopped", "The VM is not running");
status.remove(Status.OSINFO); status.remove(Status.OSINFO);
updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped",
"The VM is not running"); "The VM is not running");
@ -245,6 +254,26 @@ public class StatusUpdater extends VmDefUpdater {
K8s.createEvent(apiClient, vmDef, evt); K8s.createEvent(apiClient, vmDef, evt);
} }
private void updateUserLoggedIn(VmDefinition from) {
if (loggedInUser == null) {
updateCondition(from, Condition.USER_LOGGED_IN, false,
Reason.NOT_REQUESTED, "No user to be logged in");
return;
}
if (!from.conditionStatus(Condition.VMOP_AGENT).orElse(false)) {
updateCondition(from, Condition.USER_LOGGED_IN, false,
"VmopAgentDisconnected", "Waiting for VMOP agent to connect");
return;
}
if (!from.fromStatus(Status.LOGGED_IN_USER).map(loggedInUser::equals)
.orElse(false)) {
updateCondition(from, Condition.USER_LOGGED_IN, false,
"Processing", "Waiting for user to be logged in");
}
updateCondition(from, Condition.USER_LOGGED_IN, true,
Reason.LOGGED_IN, "User is logged in");
}
/** /**
* On ballon change. * On ballon change.
* *
@ -348,8 +377,10 @@ public class StatusUpdater extends VmDefUpdater {
return; return;
} }
vmStub.updateStatus(from -> { vmStub.updateStatus(from -> {
return updateCondition(vmDef, "VmopAgentConnected", var status = updateCondition(vmDef, "VmopAgentConnected",
true, "VmopAgentStarted", "The VM operator agent is running"); true, "VmopAgentStarted", "The VM operator agent is running");
updateUserLoggedIn(from);
return status;
}, vmDef); }, vmDef);
} }
@ -365,6 +396,7 @@ public class StatusUpdater extends VmDefUpdater {
JsonObject status = from.statusJson(); JsonObject status = from.statusJson();
status.addProperty(Status.LOGGED_IN_USER, status.addProperty(Status.LOGGED_IN_USER,
event.triggering().user()); event.triggering().user());
updateUserLoggedIn(from);
return status; return status;
}); });
} }
@ -380,6 +412,7 @@ public class StatusUpdater extends VmDefUpdater {
vmStub.updateStatus(from -> { vmStub.updateStatus(from -> {
JsonObject status = from.statusJson(); JsonObject status = from.statusJson();
status.remove(Status.LOGGED_IN_USER); status.remove(Status.LOGGED_IN_USER);
updateUserLoggedIn(from);
return status; return status;
}); });
} }

View file

@ -129,9 +129,12 @@ public class VmDefUpdater extends Component {
var current = status.getAsJsonArray("conditions").asList().stream() var current = status.getAsJsonArray("conditions").asList().stream()
.map(cond -> (JsonObject) cond) .map(cond -> (JsonObject) cond)
.filter(cond -> type.equals(cond.get("type").getAsString())) .filter(cond -> type.equals(cond.get("type").getAsString()))
.findFirst() .findFirst();
.map(cond -> "True".equals(cond.get("status").getAsString())); if (current.isPresent()
if (current.isPresent() && current.get() == state) { && current.map(c -> c.get("status").getAsString())
.map("True"::equals).map(s -> s == state).orElse(false)
&& current.map(c -> c.get("reason").getAsString())
.map(reason::equals).orElse(false)) {
return status; return status;
} }

View file

@ -327,6 +327,18 @@ public class GsonPtr {
return set(selector, new JsonPrimitive(value)); return set(selector, new JsonPrimitive(value));
} }
/**
* Short for `set(selector, new JsonPrimitive(value))`.
*
* @param selector the selector
* @param value the value
* @return the gson ptr
* @see #set(Object, JsonElement)
*/
public GsonPtr set(Object selector, Boolean value) {
return set(selector, new JsonPrimitive(value));
}
/** /**
* Same as {@link #set(Object, JsonElement)}, but sets the value * Same as {@link #set(Object, JsonElement)}, but sets the value
* only if it doesn't exist yet, else returns the existing value. * only if it doesn't exist yet, else returns the existing value.

View file

@ -5,5 +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. consoleInaccessibleNotification = Console is not ready or in use.
poolEmptyNotification = No VM available. Please consult your administrator. poolEmptyNotification = No VM available. Please consult your administrator.

View file

@ -11,7 +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. consoleInaccessibleNotification = Die Konsole ist nicht bereit oder belegt.
poolEmptyNotification = Keine VM verfügbar. Wenden Sie sich bitte an den \ poolEmptyNotification = Keine VM verfügbar. Wenden Sie sich bitte an den \
Systemadministrator. Systemadministrator.

View file

@ -46,14 +46,15 @@ import java.util.stream.Collectors;
import org.bouncycastle.util.Objects; 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.Assignment;
import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.common.VmPool;
import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.AssignVm;
import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.GetPools;
import org.jdrupes.vmoperator.manager.events.GetVms; import org.jdrupes.vmoperator.manager.events.GetVms;
import org.jdrupes.vmoperator.manager.events.GetVms.VmData; 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.PrepareConsole;
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;
@ -265,7 +266,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
public void onConsoleConfigured(ConsoleConfigured event, public void onConsoleConfigured(ConsoleConfigured event,
ConsoleConnection connection) throws InterruptedException, ConsoleConnection connection) throws InterruptedException,
IOException { IOException {
@SuppressWarnings("unchecked") @SuppressWarnings({ "unchecked", "PMD.PrematureDeclaration" })
final var rendered final var rendered
= (Set<ResourceModel>) connection.session().get(RENDERED); = (Set<ResourceModel>) connection.session().get(RENDERED);
connection.session().remove(RENDERED); connection.session().remove(RENDERED);
@ -523,6 +524,13 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
.assignedTo(user)).get().stream().findFirst(); .assignedTo(user)).get().stream().findFirst();
} }
/**
* Returns the permissions from the VM definition.
*
* @param vmDef the VM definition
* @param session the session
* @return the sets the
*/
private Set<Permission> permissions(VmDefinition vmDef, Session session) { private Set<Permission> permissions(VmDefinition vmDef, Session session) {
var user = WebConsoleUtils.userFromSession(session) var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null); .map(ConsoleUser::getName).orElse(null);
@ -531,6 +539,13 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
return vmDef.permissionsFor(user, roles); return vmDef.permissionsFor(user, roles);
} }
/**
* Returns the permissions from the pool.
*
* @param pool the pool
* @param session the session
* @return the sets the
*/
private Set<Permission> permissions(VmPool pool, Session session) { private Set<Permission> permissions(VmPool pool, Session session) {
var user = WebConsoleUtils.userFromSession(session) var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null); .map(ConsoleUser::getName).orElse(null);
@ -539,23 +554,33 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
return pool.permissionsFor(user, roles); return pool.permissionsFor(user, roles);
} }
private Set<Permission> permissions(ResourceModel model, Session session, /**
VmPool pool, VmDefinition vmDef) throws InterruptedException { * Returns the permissions from the VM definition or the pool depending
* on the state of the model.
*
* @param session the session
* @param model the model
* @param vmDef the vm def
* @return the sets the
* @throws InterruptedException the interrupted exception
*/
private Set<Permission> permissions(Session session, ResourceModel model,
VmDefinition vmDef) throws InterruptedException {
var user = WebConsoleUtils.userFromSession(session) var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null); .map(ConsoleUser::getName).orElse(null);
var roles = WebConsoleUtils.rolesFromSession(session) var roles = WebConsoleUtils.rolesFromSession(session)
.stream().map(ConsoleRole::getName).toList(); .stream().map(ConsoleRole::getName).toList();
if (model.mode() == ResourceModel.Mode.POOL) { if (model.mode() == ResourceModel.Mode.POOL) {
if (pool == null) { // Use permissions from pool
pool = appPipeline.fire(new GetPools() var pool = appPipeline.fire(new GetPools().withName(model.name()))
.withName(model.name())).get().stream().findFirst() .get().stream().findFirst().orElse(null);
.orElse(null);
}
if (pool == null) { if (pool == null) {
return Collections.emptySet(); return Collections.emptySet();
} }
return pool.permissionsFor(user, roles); return pool.permissionsFor(user, roles);
} }
// Use permissions from VM
if (vmDef == null) { if (vmDef == null) {
vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name()) vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name())
.assignedTo(user)).get().stream().map(VmData::definition) .assignedTo(user)).get().stream().map(VmData::definition)
@ -577,7 +602,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
VmDefinition vmDef) throws InterruptedException { VmDefinition vmDef) throws InterruptedException {
channel.respond(new NotifyConletView(type(), channel.respond(new NotifyConletView(type(),
model.getConletId(), "updateConfig", model.mode(), model.name(), model.getConletId(), "updateConfig", model.mode(), model.name(),
permissions(model, channel.session(), null, vmDef).stream() permissions(channel.session(), model, vmDef).stream()
.map(VmDefinition.Permission::toString).toList())); .map(VmDefinition.Permission::toString).toList()));
} }
@ -588,12 +613,17 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
model.setAssignedVm(null); model.setAssignedVm(null);
} else { } else {
model.setAssignedVm(vmDef.name()); model.setAssignedVm(vmDef.name());
var session = channel.session();
var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null);
var perms = permissions(session, model, vmDef);
try { try {
data = Map.of("metadata", data = Map.of(
Map.of("namespace", vmDef.namespace(), "metadata", Map.of("namespace", vmDef.namespace(),
"name", vmDef.name()), "name", vmDef.name()),
"spec", vmDef.spec(), "spec", vmDef.spec(),
"status", vmDef.status()); "status", vmDef.status(),
"consoleAccessible", vmDef.consoleAccessible(user, perms));
} 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");
@ -634,6 +664,8 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
// Update known conlets // Update known conlets
for (var entry : conletIdsByConsoleConnection().entrySet()) { for (var entry : conletIdsByConsoleConnection().entrySet()) {
var connection = entry.getKey(); var connection = entry.getKey();
var user = WebConsoleUtils.userFromSession(connection.session())
.map(ConsoleUser::getName).orElse(null);
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()
@ -654,13 +686,11 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
} else { } else {
// Check if VM is used by pool conlet or to be assigned to // Check if VM is used by pool conlet or to be assigned to
// it // it
var user var toBeUsedByConlet = vmDef.assignment()
= WebConsoleUtils.userFromSession(connection.session()) .map(Assignment::pool)
.map(ConsoleUser::getName).orElse(null);
var toBeUsedByConlet = vmDef.assignedFrom()
.map(p -> p.equals(model.get().name())).orElse(false) .map(p -> p.equals(model.get().name())).orElse(false)
&& vmDef.assignedTo().map(u -> u.equals(user)) && vmDef.assignment().map(Assignment::user)
.orElse(false); .map(u -> u.equals(user)).orElse(false);
if (!Objects.areEqual(model.get().assignedVm(), if (!Objects.areEqual(model.get().assignedVm(),
vmDef.name()) && !toBeUsedByConlet) { vmDef.name()) && !toBeUsedByConlet) {
continue; continue;
@ -750,7 +780,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
var vmChannel = vmData.get().channel(); var vmChannel = vmData.get().channel();
var vmDef = vmData.get().definition(); var vmDef = vmData.get().definition();
var vmName = vmDef.metadata().getName(); var vmName = vmDef.metadata().getName();
var perms = permissions(model, channel.session(), null, vmDef); var perms = permissions(channel.session(), model, vmDef);
var resourceBundle = resourceBundle(channel.locale()); var resourceBundle = resourceBundle(channel.locale());
switch (event.method()) { switch (event.method()) {
case "start": case "start":
@ -774,9 +804,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
} }
break; break;
case "openConsole": case "openConsole":
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
openConsole(channel, model, vmChannel, vmDef, perms); openConsole(channel, model, vmChannel, vmDef, perms);
}
break; break;
default:// ignore default:// ignore
break; break;
@ -804,22 +832,21 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
.map(ConsoleUser::getName).orElse(""); .map(ConsoleUser::getName).orElse("");
if (!vmDef.consoleAccessible(user, perms)) { if (!vmDef.consoleAccessible(user, perms)) {
channel.respond(new DisplayNotification( channel.respond(new DisplayNotification(
resourceBundle.getString("consoleTakenNotification"), resourceBundle.getString("consoleInaccessibleNotification"),
Map.of("autoClose", 5_000, "type", "Warning"))); Map.of("autoClose", 5_000, "type", "Warning")));
return; return;
} }
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user, var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user),
model.mode() == ResourceModel.Mode.POOL),
e -> gotPassword(channel, model, vmDef, e)); e -> gotPassword(channel, model, vmDef, e));
fire(pwQuery, vmChannel); fire(pwQuery, vmChannel);
} }
private void gotPassword(ConsoleConnection channel, ResourceModel model, private void gotPassword(ConsoleConnection channel, ResourceModel model,
VmDefinition vmDef, PrepareConsole event) { VmDefinition vmDef, GetDisplaySecret event) {
if (!event.passwordAvailable()) { if (!event.secretAvailable()) {
return; return;
} }
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), vmDef.extra().map(xtra -> xtra.connectionFile(event.secret(),
preferredIpVersion, deleteConnectionFile)) preferredIpVersion, deleteConnectionFile))
.ifPresent(cf -> channel.respond(new NotifyConletView(type(), .ifPresent(cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf))); model.getConletId(), "openConsole", cf)));

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2024 Michael N. Lipp * Copyright (C) 2024,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
@ -71,11 +71,10 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
const poolName = computed(() => previewApi.poolName); const poolName = computed(() => previewApi.poolName);
const vmName = computed(() => previewApi.vmDefinition.name); const vmName = computed(() => previewApi.vmDefinition.name);
const configured = computed(() => previewApi.vmDefinition.spec); const configured = computed(() => previewApi.vmDefinition.spec);
const accessible = computed(() => previewApi.vmDefinition.consoleAccessible);
const busy = computed(() => previewApi.vmDefinition.spec const busy = computed(() => previewApi.vmDefinition.spec
&& (previewApi.vmDefinition.spec.vm.state === 'Running' && (previewApi.vmDefinition.spec.vm.state === 'Running'
&& (previewApi.poolName && (!previewApi.vmDefinition.consoleAccessible)
? !previewApi.vmDefinition.vmopAgent
: !previewApi.vmDefinition.running)
|| previewApi.vmDefinition.spec.vm.state === 'Stopped' || previewApi.vmDefinition.spec.vm.state === 'Stopped'
&& previewApi.vmDefinition.running)); && previewApi.vmDefinition.running));
const startable = computed(() => previewApi.vmDefinition.spec const startable = computed(() => previewApi.vmDefinition.spec
@ -87,7 +86,6 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
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 vmopAgent = computed(() => previewApi.vmDefinition.vmopAgent);
const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const inUse = computed(() => previewApi.vmDefinition.usedBy != '');
const permissions = computed(() => previewApi.permissions); const permissions = computed(() => previewApi.permissions);
const osicon = computed(() => { const osicon = computed(() => {
@ -123,7 +121,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
}; };
return { localize, resourceBase, vmAction, poolName, vmName, return { localize, resourceBase, vmAction, poolName, vmName,
configured, busy, startable, stoppable, running, vmopAgent, configured, accessible, busy, startable, stoppable, running,
inUse, permissions, osicon }; inUse, permissions, osicon };
}, },
template: ` template: `
@ -132,9 +130,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
<tr> <tr>
<td rowspan="2" style="position: relative"><span <td rowspan="2" style="position: relative"><span
style="position: absolute;" :class="{ busy: busy }" style="position: absolute;" :class="{ busy: busy }"
><img role=button :aria-disabled="(poolName ><img role=button :aria-disabled="!accessible"
? !vmopAgent : !running)
|| !permissions.includes('accessConsole')"
v-on:click="vmAction('openConsole')" v-on:click="vmAction('openConsole')"
:src="resourceBase + (running :src="resourceBase + (running
? (inUse ? 'computer-in-use.svg' : 'computer.svg') ? (inUse ? 'computer-in-use.svg' : 'computer.svg')
@ -210,15 +206,12 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentCpus = vmDefinition.status.cpus;
vmDefinition.currentRam = Number(vmDefinition.status.ram); vmDefinition.currentRam = Number(vmDefinition.status.ram);
vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
// safety fallbacks
vmDefinition.status.conditions.forEach((condition: any) => { vmDefinition.status.conditions.forEach((condition: any) => {
if (condition.type === "Running") { if (condition.type === "Running") {
vmDefinition.running = condition.status === "True"; vmDefinition.running = condition.status === "True";
vmDefinition.runningConditionSince vmDefinition.runningConditionSince
= new Date(condition.lastTransitionTime); = new Date(condition.lastTransitionTime);
} else if (condition.type === "VmopAgentConnected") {
vmDefinition.vmopAgent = condition.status === "True";
vmDefinition.vmopAgentConditionSince
= new Date(condition.lastTransitionTime);
} }
}) })
} else { } else {

View file

@ -24,6 +24,7 @@
span[role="button"].svg-icon { span[role="button"].svg-icon {
display: inline-block; display: inline-block;
line-height: 1; line-height: 1;
/* Align with forkawesome */ /* Align with forkawesome */
font-size: 14px; font-size: 14px;
fill: var(--primary); fill: var(--primary);

View file

@ -60,21 +60,21 @@
<td class="jdrupes-vmoperator-vmmgmt-view-action-list"> <td class="jdrupes-vmoperator-vmmgmt-view-action-list">
<span role="button" <span role="button"
v-if="entry.spec.vm.state != 'Running' && !entry['running'] v-if="entry.spec.vm.state != 'Running' && !entry['running']
&& entry.permissions.includes('start')" && entry.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(entry.name, 'start')"></span> v-on:click="vmAction(entry.name, 'start')"></span>
<span role="button" v-else class="fa fa-play" <span role="button" v-else class="fa fa-play"
aria-disabled="true" :title="localize('Start VM')"></span> aria-disabled="true" :title="localize('Start VM')"></span>
<span role="button" <span role="button"
v-if="entry.spec.vm.state != 'Stopped' && entry['running'] v-if="entry.spec.vm.state != 'Stopped' && entry['running']
&& entry.permissions.includes('stop')" && entry.permissions.includes('STOP')"
tabindex="0" class="fa fa-stop" :title="localize('Stop VM')" tabindex="0" class="fa fa-stop" :title="localize('Stop VM')"
v-on:click="vmAction(entry.name, 'stop')"></span> v-on:click="vmAction(entry.name, 'stop')"></span>
<span role="button" v-else class="fa fa-stop" <span role="button" v-else class="fa fa-stop"
aria-disabled="true" :title="localize('Stop VM')"></span> aria-disabled="true" :title="localize('Stop VM')"></span>
<span role="button" <span role="button"
:aria-disabled="!entry['running'] :aria-disabled="!entry['running']
|| !entry.permissions.includes('reset')" || !entry.permissions.includes('RESET')"
tabindex="0" class="svg-icon" :title="localize('Reset VM')" tabindex="0" class="svg-icon" :title="localize('Reset VM')"
v-on:click="vmAction(entry.name, 'reset')"> v-on:click="vmAction(entry.name, 'reset')">
<svg viewBox="0 0 1541.33 1535.5083"> <svg viewBox="0 0 1541.33 1535.5083">
@ -86,8 +86,7 @@
? 'computer-off.svg' : (entry.usedFrom ? 'computer-off.svg' : (entry.usedFrom
? 'computer-in-use.svg' : 'computer.svg'))" ? 'computer-in-use.svg' : 'computer.svg'))"
:title="localize('Open console')" :title="localize('Open console')"
:aria-disabled="!entry['running'] :aria-disabled="!entry.consoleAccessible"
|| !(entry.permissions.includes('accessConsole'))"
v-on:click="vmAction(entry.name, 'openConsole')"> v-on:click="vmAction(entry.name, 'openConsole')">
</td> </td>
</tr> </tr>

View file

@ -44,8 +44,8 @@ import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.common.VmExtraData;
import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.ChannelTracker;
import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
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;
@ -249,14 +249,15 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
.toBigInteger()); .toBigInteger());
// Build result // Build result
var perms = vmDef.permissionsFor(user, roles);
return Map.of("metadata", return Map.of("metadata",
Map.of("namespace", vmDef.namespace(), Map.of("namespace", vmDef.namespace(),
"name", vmDef.name()), "name", vmDef.name()),
"spec", spec, "spec", spec,
"status", status, "status", status,
"nodeName", vmDef.extra().map(VmExtraData::nodeName).orElse(""), "nodeName", vmDef.extra().map(VmExtraData::nodeName).orElse(""),
"permissions", vmDef.permissionsFor(user, roles).stream() "consoleAccessible", vmDef.consoleAccessible(user, perms),
.map(VmDefinition.Permission::toString).toList()); "permissions", perms);
} }
/** /**
@ -438,9 +439,7 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
} }
break; break;
case "openConsole": case "openConsole":
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
openConsole(channel, model, vmChannel, vmDef, user, perms); openConsole(channel, model, vmChannel, vmDef, user, perms);
}
break; break;
case "cpus": case "cpus":
fire(new ModifyVm(vmName, "currentCpus", fire(new ModifyVm(vmName, "currentCpus",
@ -484,17 +483,17 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
Map.of("autoClose", 5_000, "type", "Warning"))); Map.of("autoClose", 5_000, "type", "Warning")));
return; return;
} }
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user), var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user),
e -> gotPassword(channel, model, vmDef, e)); e -> gotPassword(channel, model, vmDef, e));
fire(pwQuery, vmChannel); fire(pwQuery, vmChannel);
} }
private void gotPassword(ConsoleConnection channel, VmsModel model, private void gotPassword(ConsoleConnection channel, VmsModel model,
VmDefinition vmDef, PrepareConsole event) { VmDefinition vmDef, GetDisplaySecret event) {
if (!event.passwordAvailable()) { if (!event.secretAvailable()) {
return; return;
} }
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), vmDef.extra().map(xtra -> xtra.connectionFile(event.secret(),
preferredIpVersion, deleteConnectionFile)).ifPresent( preferredIpVersion, deleteConnectionFile)).ifPresent(
cf -> channel.respond(new NotifyConletView(type(), cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf))); model.getConletId(), "openConsole", cf)));

View file

@ -118,6 +118,7 @@
span[role="button"].svg-icon { span[role="button"].svg-icon {
display: inline-block; display: inline-block;
line-height: 1; line-height: 1;
/* Align with forkawesome */ /* Align with forkawesome */
font-size: 14px; font-size: 14px;
fill: var(--primary); fill: var(--primary);

View file

@ -10,8 +10,8 @@ layout: vm-operator
## To version 4.0.0 ## To version 4.0.0
* The VmViewer conlet has been renamed to VmAccess. This affects the * The VmViewer conlet has been renamed to VmAccess. This affects the
[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration [configuration](https://jdrupes.org/vm-operator/user-gui.html).
information using the old path Configuration information using the old path
`/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer` `/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer`
is still accepted for backward compatibility until the next major version, is still accepted for backward compatibility until the next major version,
but should be updated. but should be updated.