diff --git a/build.gradle b/build.gradle index df173d8..8a7b571 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { } plugins { - id 'org.ajoberstar.grgit' version '5.2.0' apply false + id 'org.ajoberstar.grgit' version '5.2.0' id 'org.ajoberstar.git-publish' version '4.2.0' apply false id 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false id 'org.jdrupes.vmoperator.versioning-conventions' @@ -28,7 +28,9 @@ task stage { tc -> tc.findByName("build") }.flatten() } - if (JavaVersion.current() == JavaVersion.VERSION_21) { + def gitBranch = grgit.branch.current.name.replace('/', '-') + if (JavaVersion.current() == JavaVersion.VERSION_21 + && gitBranch == "main") { // Publish JavaDoc dependsOn gitPublishPush } diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 749b896..2a14f0c 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1430,6 +1430,12 @@ spec: outputs: type: integer default: 1 + loggedInUser: + description: >- + The name of a user that should be automatically + logged in on the display. Note that this requires + support from an agent in the guest OS. + type: string spice: type: object properties: @@ -1485,6 +1491,11 @@ spec: connection. type: string default: "" + loggedInUser: + description: >- + The name of a user that is currently logged in by the + VM operator agent. + type: string displayPasswordSerial: description: >- Counts changes of the display password. Set to -1 diff --git a/dev-example/gen-pool-vm-crds.sh b/dev-example/gen-pool-vm-crds similarity index 100% rename from dev-example/gen-pool-vm-crds.sh rename to dev-example/gen-pool-vm-crds diff --git a/dev-example/pool-action.sh b/dev-example/pool-action similarity index 100% rename from dev-example/pool-action.sh rename to dev-example/pool-action diff --git a/dev-example/vmop-agent/99-vmop-agent.rules b/dev-example/vmop-agent/99-vmop-agent.rules new file mode 100644 index 0000000..4a18472 --- /dev/null +++ b/dev-example/vmop-agent/99-vmop-agent.rules @@ -0,0 +1,2 @@ +SUBSYSTEM=="virtio-ports", ATTR{name}=="org.jdrupes.vmop_agent.0", \ + TAG+="systemd" ENV{SYSTEMD_WANTS}="vmop-agent.service" diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent new file mode 100755 index 0000000..bb2fb86 --- /dev/null +++ b/dev-example/vmop-agent/vmop-agent @@ -0,0 +1,159 @@ +#!/usr/bin/bash + +while [ "$#" -gt 0 ]; do + case "$1" in + --path) shift; ttyPath="$1";; + --path=*) IFS='=' read -r option value <<< "$1"; ttyPath="$value";; + esac + shift +done + +ttyPath="${ttyPath:-/dev/virtio-ports/org.jdrupes.vmop_agent.0}" + +if [ ! -w "$ttyPath" ]; then + echo >&2 "Device $ttyPath not writable" + exit 1 +fi + +# Create fd for the tty in variable con +if ! exec {con}<>"$ttyPath"; then + echo >&2 "Cannot open device $ttyPath" + exit 1 +fi + +# Temporary file for logging error messages, clear tty and signal ready +temperr=$(mktemp) +clear >/dev/tty1 +echo >&${con} "220 Hello" + +# This script uses the (shared) home directory as "dictonary" for +# synchronizing the username and the uid between hosts. +# +# Every user has a directory with his username. The directory is +# owned by root to prevent changes of access rights by the user. +# The uid and gid of the directory are equal. Thus the name of the +# directory and the id from the group ownership also provide the +# association between the username and the uid. + +# Add the user with name $1 to the host's "user database". This +# may not be invoked concurrently. +createUser() { + local missing=$1 + local uid + local userHome="/home/$missing" + local createOpts="" + + # Retrieve or create the uid for the username + if [ -d "$userHome" ]; then + # If a home directory exists, use the id from the group ownership as uid + uid=$(ls -ldn "$userHome" | head -n 1 | awk '{print $4}') + createOpts="--no-create-home" + else + # Else get the maximum of all ids from the group ownership +1 + uid=$(ls -ln "/home" | tail -n +2 | awk '{print $4}' | sort | tail -1) + uid=$(( $uid + 1 )) + if [ $uid -lt 1100 ]; then + uid=1100 + fi + createOpts="--create-home" + fi + groupadd -g $uid $missing + useradd $missing -u $uid -g $uid $createOpts +} + +# Login the user, i.e. create a desktopn for the user. +doLogin() { + user=$1 + if [ "$user" = "root" ]; then + echo >&${con} "504 Won't log in root" + return + fi + + # Check if this user is already logged in on tty1 + curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty1") | .user') + if [ "$curUser" = "$user" ]; then + echo >&${con} "201 User already logged in" + return + fi + + # Terminate a running desktop (fail safe) + attemptLogout + + # Check if username is known on this host. If not, create user + uid=$(id -u ${user} 2>/dev/null) + if [ $? != 0 ]; then + ( flock 200 + createUser ${user} + ) 200>/home/.gen-uid-lock + + # This should now work, else something went wrong + uid=$(id -u ${user} 2>/dev/null) + if [ $? != 0 ]; then + echo >&${con} "451 Cannot determine uid" + return + fi + fi + + # Start the desktop for the user + systemd-run 2>$temperr \ + --unit vmop-user-desktop --uid=$uid --gid=$uid \ + --working-directory="/home/$user" -p TTYPath=/dev/tty1 \ + -p PAMName=login -p StandardInput=tty -p StandardOutput=journal \ + -p Conflicts="gdm.service getty@tty1.service" \ + -E XDG_RUNTIME_DIR="/run/user/$uid" \ + -p ExecStartPre="/usr/bin/chvt 1" \ + dbus-run-session -- gnome-shell --display-server --wayland + if [ $? -eq 0 ]; then + echo >&${con} "201 User logged in successfully" + else + echo >&${con} "451 $(tr '\n' ' ' <${temperr})" + fi +} + +# Attempt to log out a user currently using tty1. This is an intermediate +# operation that can be invoked from other operations +attemptLogout() { + systemctl status vmop-user-desktop > /dev/null 2>&1 + if [ $? = 0 ]; then + systemctl stop vmop-user-desktop + fi + loginctl -j | jq -r '.[] | select(.tty=="tty1") | .session' \ + | while read sid; do + loginctl kill-session $sid + done + echo >&${con} "102 Desktop stopped" +} + +# Log out any user currently using tty1. This is invoked when executing +# the logout command and therefore sends back a 2xx return code. +# Also try to restart gdm, if it is not running. +doLogout() { + attemptLogout + systemctl status gdm >/dev/null 2>&1 + if [ $? != 0 ]; then + systemctl restart gdm 2>$temperr + if [ $? -eq 0 ]; then + echo >&${con} "102 gdm restarted" + else + echo >&${con} "102 Restarting gdm failed: $(tr '\n' ' ' <${temperr})" + fi + fi + echo >&${con} "202 User logged out" +} + +while read line <&${con}; do + case $line in + "login "*) IFS=' ' read -ra args <<< "$line"; doLogin ${args[1]};; + "logout") doLogout;; + esac +done + +onExit() { + attemptLogout + if [ -n "$temperr" ]; then + rm -f $temperr + fi + echo >&${con} "240 Quit" +} + +trap onExit EXIT diff --git a/dev-example/vmop-agent/vmop-agent.service b/dev-example/vmop-agent/vmop-agent.service new file mode 100644 index 0000000..11c64f2 --- /dev/null +++ b/dev-example/vmop-agent/vmop-agent.service @@ -0,0 +1,15 @@ +[Unit] +Description=VM-Operator (Guest) Agent +BindsTo=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device +After=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device multi-user.target +IgnoreOnIsolate=True + +[Service] +UMask=0077 +#EnvironmentFile=/etc/sysconfig/vmop-agent +ExecStart=/usr/local/libexec/vmop-agent +Restart=always +RestartSec=0 + +[Install] +WantedBy=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 5837264..71c8cf3 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.common; +// TODO: Auto-generated Javadoc /** * Some constants. */ @@ -27,18 +28,67 @@ public class Constants { /** The Constant APP_NAME. */ public static final String APP_NAME = "vm-runner"; - /** The Constant COMP_DISPLAY_SECRETS. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; - /** The Constant VM_OP_NAME. */ public static final String VM_OP_NAME = "vm-operator"; - /** The Constant VM_OP_GROUP. */ - public static final String VM_OP_GROUP = "vmoperator.jdrupes.org"; + /** + * Constants related to the CRD. + */ + @SuppressWarnings("PMD.ShortClassName") + public static class Crd { + /** The Constant GROUP. */ + public static final String GROUP = "vmoperator.jdrupes.org"; - /** The Constant VM_OP_KIND_VM. */ - public static final String VM_OP_KIND_VM = "VirtualMachine"; + /** The Constant KIND_VM. */ + public static final String KIND_VM = "VirtualMachine"; - /** The Constant VM_OP_KIND_VM_POOL. */ - public static final String VM_OP_KIND_VM_POOL = "VmPool"; + /** The Constant KIND_VM_POOL. */ + public static final String KIND_VM_POOL = "VmPool"; + } + + /** + * Status related constants. + */ + public static class Status { + /** The Constant CPUS. */ + public static final String CPUS = "cpus"; + + /** The Constant RAM. */ + public static final String RAM = "ram"; + + /** The Constant OSINFO. */ + public static final String OSINFO = "osinfo"; + + /** The Constant DISPLAY_PASSWORD_SERIAL. */ + public static final String DISPLAY_PASSWORD_SERIAL + = "displayPasswordSerial"; + + /** The Constant LOGGED_IN_USER. */ + public static final String LOGGED_IN_USER = "loggedInUser"; + + /** The Constant CONSOLE_CLIENT. */ + public static final String CONSOLE_CLIENT = "consoleClient"; + + /** The Constant CONSOLE_USER. */ + public static final String CONSOLE_USER = "consoleUser"; + + /** The Constant ASSIGNMENT. */ + public static final String ASSIGNMENT = "assignment"; + } + + /** + * DisplaySecret related constants. + */ + public static class DisplaySecret { + + /** The Constant NAME. */ + public static final String NAME = "display-secret"; + + /** The Constant PASSWORD. */ + public static final String PASSWORD = "display-password"; + + /** The Constant EXPIRY. */ + public static final String EXPIRY = "password-expiry"; + + } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java index 688f43f..b8f1992 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java @@ -193,54 +193,94 @@ public class K8sGenericStub updateStatus(O object, Function status) + public Optional updateStatus(O object, Function updater) throws ApiException { - return K8s.optional(api.updateStatus(object, status)); + return K8s.optional(api.updateStatus(object, updater)); + } + + /** + * Updates the status of the given object. In case of conflict, + * get the current version of the object and tries again. Retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param current the current state of the object, used for the first + * attempt to update + * @param retries the retries in case of conflict + * @return the updated model or empty if the object was not found + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" }) + public Optional updateStatus(Function updater, O current, + int retries) throws ApiException { + while (true) { + try { + if (current == null) { + current = api.get(namespace, name) + .throwsApiException().getObject(); + } + return updateStatus(current, updater); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } + // Get current version for new attempt + current = null; + } + } } /** * Gets the object and updates the status. In case of conflict, retries * up to `retries` times. * - * @param status the status + * @param updater the function updating the status * @param retries the retries in case of conflict * @return the updated model or empty if the object was not found * @throws ApiException the api exception */ @SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" }) - public Optional updateStatus(Function status, int retries) + public Optional updateStatus(Function updater, int retries) throws ApiException { - try { - return updateStatus(api.get(namespace, name).throwsApiException() - .getObject(), status); - } catch (ApiException e) { - if (HttpURLConnection.HTTP_CONFLICT != e.getCode() - || retries-- <= 0) { - throw e; - } - } - return Optional.empty(); + return updateStatus(updater, null, retries); } /** - * Updates the status. + * Updates the status of the given object. In case of conflict, + * get the current version of the object and tries again. Retries + * up to `retries` times. * - * @param status the status + * @param updater the function updating the status + * @param current the current * @return the kubernetes api response * the updated model or empty if not successful * @throws ApiException the api exception */ - public Optional updateStatus(Function status) + public Optional updateStatus(Function updater, O current) throws ApiException { - return updateStatus(status, 16); + return updateStatus(updater, current, 16); + } + + /** + * Updates the status. In case of conflict, retries up to 16 times. + * + * @param updater the function updating the status + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater) + throws ApiException { + return updateStatus(updater, null); } /** diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java index ec79b80..a42d2d4 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -38,6 +38,7 @@ import java.util.Set; import java.util.function.Function; import java.util.logging.Logger; import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.util.DataPath; /** @@ -219,7 +220,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional assignedFrom() { - return fromStatus("assignment", "pool"); + return fromStatus(Status.ASSIGNMENT, "pool"); } /** @@ -228,7 +229,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional assignedTo() { - return fromStatus("assignment", "user"); + return fromStatus(Status.ASSIGNMENT, "user"); } /** @@ -237,7 +238,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional assignmentLastUsed() { - return this. fromStatus("assignment", "lastUsed") + return this. fromStatus(Status.ASSIGNMENT, "lastUsed") .map(Instant::parse); } @@ -286,7 +287,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional consoleUser() { - return this. fromStatus("consoleUser"); + return this. fromStatus(Status.CONSOLE_USER); } /** @@ -388,7 +389,7 @@ public class VmDefinition extends K8sDynamicModel { * @return the optional */ public Optional displayPasswordSerial() { - return this. fromStatus("displayPasswordSerial") + return this. fromStatus(Status.DISPLAY_PASSWORD_SERIAL) .map(Number::longValue); } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java deleted file mode 100644 index f6fa555..0000000 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2024 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager.events; - -import java.util.Optional; -import org.jdrupes.vmoperator.common.VmDefinition; -import org.jgrapes.core.Event; - -/** - * Gets the current display secret and optionally updates it. - */ -@SuppressWarnings("PMD.DataClass") -public class GetDisplayPassword extends Event { - - private final VmDefinition vmDef; - private final String user; - - /** - * Instantiates a new request for the display secret. - * - * @param vmDef the vm name - * @param user the requesting user - */ - public GetDisplayPassword(VmDefinition vmDef, String user) { - this.vmDef = vmDef; - this.user = user; - } - - /** - * Gets the vm definition. - * - * @return the vm definition - */ - public VmDefinition vmDefinition() { - return vmDef; - } - - /** - * Return the id of the user who has requested the password. - * - * @return the string - */ - public String user() { - return user; - } - - /** - * Return the password. May only be called when the event is completed. - * - * @return the optional - */ - public Optional password() { - if (!isDone()) { - throw new IllegalStateException("Event is not done."); - } - return currentResults().stream().findFirst(); - } -} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java new file mode 100644 index 0000000..ad8f9ce --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PrepareConsole.java @@ -0,0 +1,119 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the current display secret and optionally updates it. + */ +@SuppressWarnings("PMD.DataClass") +public class PrepareConsole extends Event { + + private final VmDefinition vmDef; + private final String user; + private final boolean loginUser; + + /** + * Instantiates a new request for the display secret. + * 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 + * @param user the requesting user + * @param loginUser login the user + */ + public PrepareConsole(VmDefinition vmDef, String user, + boolean loginUser) { + this.vmDef = vmDef; + this.user = user; + this.loginUser = loginUser; + } + + /** + * Instantiates a new request for the display secret. + * 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 + * @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() { + return vmDef; + } + + /** + * Return the id of the user who has requested the password. + * + * @return the string + */ + public String 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 + * when the event is completed. Note that the password returned + * by {@link #password()} may be `null`, indicating that no password + * is needed. + * + * @return true, if successful + */ + public boolean passwordAvailable() { + if (!isDone()) { + throw new IllegalStateException("Event is not done."); + } + return !currentResults().isEmpty(); + } + + /** + * Return the password. May only be called when the event has been + * completed with a valid result (see {@link #passwordAvailable()}). + * + * @return the password. A value of `null` means that no password + * is required. + */ + public String password() { + if (!isDone() || currentResults().isEmpty()) { + throw new IllegalStateException("Event is not done."); + } + return currentResults().get(0); + } +} diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml index f5aabc5..2fbeb94 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml @@ -11,7 +11,7 @@ metadata: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } - kind: ${ constants.VM_OP_KIND_VM } + kind: ${ constants.Crd.KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } controller: false @@ -201,6 +201,9 @@ data: <#if spec.vm.display.outputs?? > outputs: ${ spec.vm.display.outputs?c } + <#if spec.vm.display.loggedInUser?? > + loggedInUser: "${ spec.vm.display.loggedInUser }" + <#if spec.vm.display.spice??> spice: port: ${ spec.vm.display.spice.port?c } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml index c25d7f4..b7215a5 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -11,7 +11,7 @@ metadata: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } - kind: ${ constants.VM_OP_KIND_VM } + kind: ${ constants.Crd.KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } controller: false diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml index 917d790..f000c70 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml @@ -15,7 +15,7 @@ metadata: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - apiVersion: ${ cr.apiVersion() } - kind: ${ constants.VM_OP_KIND_VM } + kind: ${ constants.Crd.KIND_VM } name: ${ cr.name() } uid: ${ cr.metadata().getUid() } blockOwnerDeletion: true diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java index 7de839b..c5c8528 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java @@ -24,15 +24,6 @@ package org.jdrupes.vmoperator.manager; @SuppressWarnings("PMD.DataClass") public class Constants extends org.jdrupes.vmoperator.common.Constants { - /** The Constant COMP_DISPLAY_SECRET. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; - - /** The Constant DATA_DISPLAY_PASSWORD. */ - public static final String DATA_DISPLAY_PASSWORD = "display-password"; - - /** The Constant DATA_PASSWORD_EXPIRY. */ - public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; - /** The Constant STATE_RUNNING. */ public static final String STATE_RUNNING = "Running"; diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 80ff0f7..b61b26a 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -29,8 +29,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.logging.Level; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -194,7 +194,7 @@ public class Controller extends Component { private void patchVmDef(K8sClient client, String name, String path, Object value) throws ApiException, IOException { var vmStub = K8sDynamicStub.get(client, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, name); // Patch running @@ -227,11 +227,11 @@ public class Controller extends Component { try { var vmDef = channel.vmDefinition(); var vmStub = VmDefinitionStub.get(channel.client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), vmDef.namespace(), vmDef.name()); if (vmStub.updateStatus(vmDef, from -> { JsonObject status = from.statusJson(); - var assignment = GsonPtr.to(status).to("assignment"); + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); assignment.set("pool", event.usedPool()); assignment.set("user", event.toUser()); assignment.set("lastUsed", Instant.now().toString()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index a0809e9..a254c0e 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2024 Michael N. Lipp + * Copyright (C) 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,8 +18,6 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonObject; -import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1Secret; @@ -28,52 +26,26 @@ import io.kubernetes.client.util.Watch.Response; import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.time.Instant; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Scanner; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -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.Constants.DisplaySecret; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub; -import org.jdrupes.vmoperator.common.VmDefinitionStub; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import org.jdrupes.vmoperator.manager.events.ChannelDictionary; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jgrapes.core.Channel; -import org.jgrapes.core.CompletionLock; -import org.jgrapes.core.Event; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jose4j.base64url.Base64; /** - * Watches for changes of display secrets. The component supports the - * following configuration properties: - * - * * `passwordValidity`: the validity of the random password in seconds. - * Used to calculate the password expiry time in the generated secret. + * Watches for changes of display secrets. Updates an artifical attribute + * of the pod running the VM in response to force an update of the files + * in the pod that reflect the information from the secret. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) public class DisplaySecretMonitor extends AbstractMonitor { - private int passwordValidity = 10; - private final List pendingGets - = Collections.synchronizedList(new LinkedList<>()); private final ChannelDictionary channelDictionary; /** @@ -89,31 +61,10 @@ public class DisplaySecretMonitor context(K8sV1SecretStub.CONTEXT); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); options(options); } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @Override - public void onConfigurationUpdate(ConfigurationUpdate event) { - super.onConfigurationUpdate(event); - event.structured(componentPath()).ifPresent(c -> { - try { - if (c.containsKey("passwordValidity")) { - passwordValidity = Integer - .parseInt((String) c.get("passwordValidity")); - } - } catch (ClassCastException e) { - logger.config("Malformed configuration: " + e.getMessage()); - } - }); - } - @Override protected void prepareMonitoring() throws IOException, ApiException { client(new K8sClient()); @@ -168,147 +119,4 @@ public class DisplaySecretMonitor + "\"}]"), patchOpts); } - - /** - * On get display secrets. - * - * @param event the event - * @param channel the channel - * @throws ApiException the api exception - */ - @Handler - @SuppressWarnings("PMD.StringInstantiation") - public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) - throws ApiException { - // Update console user in status - var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), - event.vmDefinition().namespace(), event.vmDefinition().name()); - vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.addProperty("consoleUser", event.user()); - return status; - }); - - // Look for secret - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" - + event.vmDefinition().metadata().getName()); - var stubs = K8sV1SecretStub.list(client(), - event.vmDefinition().namespace(), options); - if (stubs.isEmpty()) { - // No secret means no password for this VM wanted - return; - } - var stub = stubs.iterator().next(); - - // Check validity - var model = stub.model().get(); - @SuppressWarnings("PMD.StringInstantiation") - var expiry = Optional.ofNullable(model.getData() - .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); - if (model.getData().get(DATA_DISPLAY_PASSWORD) != null - && stillValid(expiry)) { - // Fixed secret, don't touch - event.setResult( - new String(model.getData().get(DATA_DISPLAY_PASSWORD))); - return; - } - updatePassword(stub, event); - } - - @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event) - throws ApiException { - SecureRandom random = null; - try { - random = SecureRandom.getInstanceStrong(); - } catch (NoSuchAlgorithmException e) { // NOPMD - // "Every implementation of the Java platform is required - // to support at least one strong SecureRandom implementation." - } - byte[] bytes = new byte[16]; - random.nextBytes(bytes); - var password = Base64.encode(bytes); - var model = stub.model().get(); - model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, - Long.toString(Instant.now().getEpochSecond() + passwordValidity))); - event.setResult(password); - - // Prepare wait for confirmation (by VM status change) - var pending = new PendingGet(event, - event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, - new CompletionLock(event, 1500)); - pendingGets.add(pending); - Event.onCompletion(event, e -> { - pendingGets.remove(pending); - }); - - // Update, will (eventually) trigger confirmation - stub.update(model).getObject(); - } - - private boolean stillValid(String expiry) { - if (expiry == null || "never".equals(expiry)) { - return true; - } - @SuppressWarnings({ "PMD.CloseResource", "resource" }) - var scanner = new Scanner(expiry); - if (!scanner.hasNextLong()) { - return false; - } - long expTime = scanner.nextLong(); - return expTime > Instant.now().getEpochSecond() + passwordValidity; - } - - /** - * On vm def changed. - * - * @param event the event - * @param channel the channel - */ - @Handler - @SuppressWarnings("PMD.AvoidSynchronizedStatement") - public void onVmDefChanged(VmDefChanged event, Channel channel) { - synchronized (pendingGets) { - String vmName = event.vmDefinition().name(); - for (var pending : pendingGets) { - if (pending.event.vmDefinition().name().equals(vmName) - && event.vmDefinition().displayPasswordSerial() - .map(s -> s >= pending.expectedSerial).orElse(false)) { - pending.lock.remove(); - // pending will be removed from pendingGest by - // waiting thread, see updatePassword - continue; - } - } - } - } - - /** - * The Class PendingGet. - */ - @SuppressWarnings("PMD.DataClass") - private static class PendingGet { - public final GetDisplayPassword event; - public final long expectedSerial; - public final CompletionLock lock; - - /** - * Instantiates a new pending get. - * - * @param event the event - * @param expectedSerial the expected serial - */ - public PendingGet(GetDisplayPassword event, long expectedSerial, - CompletionLock lock) { - super(); - this.event = event; - this.expectedSerial = expectedSerial; - this.lock = lock; - } - } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index dcae3a3..7c5c3ad 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,7 +18,9 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; import freemarker.template.TemplateException; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1Secret; @@ -26,25 +28,91 @@ import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Scanner; import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sV1SecretStub; -import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.util.DataPath; +import org.jgrapes.core.Channel; +import org.jgrapes.core.CompletionLock; +import org.jgrapes.core.Component; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; import org.jose4j.base64url.Base64; /** - * Delegee for reconciling the display secret + * The properties of the display secret do not only depend on the + * VM definition, but also on events that occur during runtime. + * The reconciler for the display secret is therefore a separate + * component. + * + * The reconciler supports the following configuration properties: + * + * * `passwordValidity`: the validity of the random password in seconds. + * Used to calculate the password expiry time in the generated secret. */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) -/* default */ class DisplaySecretReconciler { +public class DisplaySecretReconciler extends Component { protected final Logger logger = Logger.getLogger(getClass().getName()); + private int passwordValidity = 10; + private final List pendingPrepares + = Collections.synchronizedList(new LinkedList<>()); + + /** + * Instantiates a new display secret reconciler. + * + * @param componentChannel the component channel + */ + public DisplaySecretReconciler(Channel componentChannel) { + super(componentChannel); + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()) + // for backward compatibility + .or(() -> { + var oldConfig = event + .structured("/Manager/Controller/DisplaySecretMonitor"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "path '/Manager/Controller/DisplaySecretMonitor' " + + "for `passwordValidity`, please update " + + "the configuration."); + } + return oldConfig; + }).ifPresent(c -> { + try { + if (c.containsKey("passwordValidity")) { + passwordValidity = Integer + .parseInt((String) c.get("passwordValidity")); + } + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } /** * Reconcile. If the configuration prevents generating a secret @@ -73,7 +141,7 @@ import org.jose4j.base64url.Base64; var vmDef = event.vmDefinition(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), options); @@ -84,9 +152,9 @@ import org.jose4j.base64url.Base64; // Create secret var secret = new V1Secret(); secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) - .name(vmDef.name() + "-" + COMP_DISPLAY_SECRET) + .name(vmDef.name() + "-" + DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/name", APP_NAME) - .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) + .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); secret.setType("Opaque"); SecureRandom random = null; @@ -99,9 +167,179 @@ import org.jose4j.base64url.Base64; byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, "now")); + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, "now")); K8sV1SecretStub.create(channel.client(), secret); } + /** + * Prepares access to the console for the user from the event. + * Generates a new password and sends it to the runner. + * Requests the VM (via the runner) to login the user if specified + * in the event. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings("PMD.StringInstantiation") + public void onPrepareConsole(PrepareConsole event, VmChannel channel) + throws ApiException { + // Update console user in status + var vmDef = updateConsoleUser(event, channel); + if (vmDef == null) { + return; + } + + // Check if access is possible + if (event.loginUser() + ? !vmDef. fromStatus(Status.LOGGED_IN_USER) + .map(u -> u.equals(event.user())).orElse(false) + : !vmDef.conditionStatus("Running").orElse(false)) { + return; + } + + // Get secret and update password in secret + var stub = getSecretStub(event, channel, vmDef); + if (stub == null) { + return; + } + var secret = stub.model().get(); + if (!updatePassword(secret, event)) { + return; + } + + // Register wait for confirmation (by VM status change, + // after secret update) + var pending = new PendingPrepare(event, + event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, + new CompletionLock(event, 1500)); + pendingPrepares.add(pending); + Event.onCompletion(event, e -> { + pendingPrepares.remove(pending); + }); + + // Update, will (eventually) trigger confirmation + stub.update(secret).getObject(); + } + + private VmDefinition updateConsoleUser(PrepareConsole 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 { + // Look for secret + 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 stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (stubs.isEmpty()) { + // No secret means no password for this VM wanted + event.setResult(null); + return null; + } + return stubs.iterator().next(); + } + + private boolean updatePassword(V1Secret secret, PrepareConsole event) { + var expiry = Optional.ofNullable(secret.getData() + .get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DisplaySecret.PASSWORD) != null + && stillValid(expiry)) { + // Fixed secret, don't touch + event.setResult( + new String(secret.getData().get(DisplaySecret.PASSWORD))); + return false; + } + + // Generate password and set expiry + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + return true; + } + + private boolean stillValid(String expiry) { + if (expiry == null || "never".equals(expiry)) { + return true; + } + @SuppressWarnings({ "PMD.CloseResource", "resource" }) + var scanner = new Scanner(expiry); + if (!scanner.hasNextLong()) { + return false; + } + long expTime = scanner.nextLong(); + return expTime > Instant.now().getEpochSecond() + passwordValidity; + } + + /** + * On vm def changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onVmDefChanged(VmDefChanged event, Channel channel) { + synchronized (pendingPrepares) { + String vmName = event.vmDefinition().name(); + for (var pending : pendingPrepares) { + if (pending.event.vmDefinition().name().equals(vmName) + && event.vmDefinition().displayPasswordSerial() + .map(s -> s >= pending.expectedSerial).orElse(false)) { + pending.lock.remove(); + // pending will be removed from pendingGest by + // waiting thread, see updatePassword + continue; + } + } + } + } + + /** + * The Class PendingGet. + */ + @SuppressWarnings("PMD.DataClass") + private static class PendingPrepare { + public final PrepareConsole event; + public final long expectedSerial; + public final CompletionLock lock; + + /** + * Instantiates a new pending get. + * + * @param event the event + * @param expectedSerial the expected serial + */ + public PendingPrepare(PrepareConsole event, long expectedSerial, + CompletionLock lock) { + super(); + this.event = event; + this.expectedSerial = expectedSerial; + this.lock = lock; + } + } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java index 25fb10b..1ea15e1 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -28,8 +28,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicModel; @@ -38,7 +38,6 @@ import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmPool; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL; import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmPoolChanged; @@ -88,7 +87,7 @@ public class PoolMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM_POOL); + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM_POOL); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; @@ -184,12 +183,12 @@ public class PoolMonitor extends return; } var vmStub = VmDefinitionStub.get(client(), - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), vmDef.namespace(), vmDef.name()); vmStub.updateStatus(from -> { // TODO JsonObject status = from.statusJson(); - var assignment = GsonPtr.to(status).to("assignment"); + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); assignment.set("lastUsed", ccChange.get().toString()); return status; }); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 7dbb410..e5bfaec 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -22,12 +22,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; -import freemarker.template.DefaultObjectWrapperBuilder; import freemarker.template.SimpleNumber; import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; -import freemarker.template.TemplateHashModel; import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; @@ -37,21 +35,23 @@ import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.options.ListOptions; import java.io.IOException; +import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.VmDefinition; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -138,6 +138,8 @@ import org.jgrapes.util.events.ConfigurationUpdate; * properties to be used by the runners managed by the controller. * This property is a string that holds the content of * a logging.properties file. + * + * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler */ @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.AvoidDuplicateLiterals" }) @@ -163,6 +165,7 @@ public class Reconciler extends Component { * * @param componentChannel the component channel */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Reconciler(Channel componentChannel) { super(componentChannel); @@ -177,7 +180,7 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - dsReconciler = new DisplaySecretReconciler(); + dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); stsReconciler = new StatefulSetReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig); @@ -263,17 +266,14 @@ public class Reconciler extends Component { Optional.ofNullable(Reconciler.class.getPackage() .getImplementationVersion()).orElse("(Unknown)")); model.put("cr", vmDef); - model.put("constants", - (TemplateHashModel) new DefaultObjectWrapperBuilder( - Configuration.VERSION_2_3_32) - .build().getStaticModels() - .get(Constants.class.getName())); + // Freemarker's static models don't handle nested classes. + model.put("constants", constantsMap(Constants.class)); model.put("reconciler", config); // Check if we have a display secret ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/instance=" + vmDef.name()); var dsStub = K8sV1SecretStub .list(client, vmDef.namespace(), options) @@ -294,6 +294,30 @@ public class Reconciler extends Component { return model; } + @SuppressWarnings("PMD.EmptyCatchBlock") + private Map constantsMap(Class clazz) { + @SuppressWarnings("PMD.UseConcurrentHashMap") + Map result = new HashMap<>(); + Arrays.stream(clazz.getFields()).filter(f -> { + var modifiers = f.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers) + && f.getType() == String.class; + }).forEach(f -> { + try { + result.put(f.getName(), f.get(null)); + } catch (IllegalArgumentException | IllegalAccessException e) { + // Should not happen, ignore + } + }); + Arrays.stream(clazz.getClasses()).filter(c -> { + var modifiers = c.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers); + }).forEach(c -> { + result.put(c.getSimpleName(), constantsMap(c)); + }); + return result; + } + private final TemplateMethodModelEx parseQuantityModel = new TemplateMethodModelEx() { @Override diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java index 5c1ae77..4f8ac77 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -31,8 +31,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.stream.Collectors; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import org.jdrupes.vmoperator.common.Constants.Crd; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sDynamicStub; @@ -87,7 +86,7 @@ public class VmMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM); + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index 4f5d7a3..03db0d2 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -12,10 +12,10 @@ import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.jdrupes.vmoperator.common.Constants; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; @@ -60,7 +60,7 @@ class BasicTests { waitForManager(); // Context for working with our CR - var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM); + var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM); assertTrue(apiRes.isPresent()); vmsContext = apiRes.get(); @@ -70,7 +70,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); for (var secret : secrets) { secret.delete(); @@ -138,12 +138,11 @@ class BasicTests { List.of("name"), VM_NAME, List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS); checkProps(config.getMetadata(), toCheck); @@ -189,7 +188,7 @@ class BasicTests { ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); Collection secrets = null; for (int i = 0; i < 10; i++) { secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts); @@ -219,8 +218,7 @@ class BasicTests { checkProps(pvc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME)); + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Mi"))); @@ -240,8 +238,7 @@ class BasicTests { checkProps(pvc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("annotations", "use_as"), "system-disk")); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), @@ -262,8 +259,7 @@ class BasicTests { checkProps(pvc.getMetadata(), Map.of( List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME)); + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); checkProps(pvc.getSpec(), Map.of( List.of("resources", "requests", "storage"), Quantity.fromString("1Gi"))); @@ -290,13 +286,12 @@ class BasicTests { List.of("labels", "app.kubernetes.io/name"), APP_NAME, List.of("labels", "app.kubernetes.io/instance"), VM_NAME, List.of("labels", "app.kubernetes.io/component"), APP_NAME, - List.of("labels", "app.kubernetes.io/managed-by"), - Constants.VM_OP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("ownerReferences", 0, "apiVersion"), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), - List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM, + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "uid"), EXISTS)); checkProps(pod.getSpec(), Map.of( diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java new file mode 100644 index 0000000..40db84a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java @@ -0,0 +1,86 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.nio.file.Path; +import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * A component that handles the communication with an agent + * running in the VM. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public abstract class AgentConnector extends QemuConnector { + + protected String channelId; + + /** + * Instantiates a new agent connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public AgentConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param channelId the channel id + * @param socketPath the socket path + */ + /* default */ void configure(String channelId, Path socketPath) { + super.configure(socketPath); + this.channelId = channelId; + logger.fine(() -> getClass().getSimpleName() + " configured with" + + " channelId=" + channelId); + } + + /** + * When the virtual serial port with the configured channel id has + * been opened call {@link #agentConnected()}. + * + * @param event the event + */ + @Handler + public void onVserportChanged(VserportChangeEvent event) { + if (event.id().equals(channelId) && event.isOpen()) { + agentConnected(); + } + } + + /** + * Called when the agent in the VM opens the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentConnected() { + // Default is to do nothing. + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index 086f085..50635b5 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -39,7 +39,7 @@ import org.jdrupes.vmoperator.util.FsdUtils; /** * The configuration information from the configuration file. */ -@SuppressWarnings("PMD.ExcessivePublicCount") +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyFields" }) public class Configuration implements Dto { private static final String CI_INSTANCE_ID = "instance-id"; @@ -67,9 +67,6 @@ public class Configuration implements Dto { /** The monitor socket. */ public Path monitorSocket; - /** The guest agent socket socket. */ - public Path guestAgentSocket; - /** The firmware rom. */ public Path firmwareRom; @@ -251,6 +248,9 @@ public class Configuration implements Dto { /** The number of outputs. */ public int outputs = 1; + /** The logged in user. */ + public String loggedInUser; + /** The spice. */ public Spice spice; } @@ -344,7 +344,6 @@ public class Configuration implements Dto { runtimeDir.toFile().mkdir(); swtpmSocket = runtimeDir.resolve("swtpm-sock"); monitorSocket = runtimeDir.resolve("monitor.sock"); - guestAgentSocket = runtimeDir.resolve("org.qemu.guest_agent.0"); } if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) { logger.severe(() -> String.format( diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java index b91b5df..ddfc702 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -25,8 +25,8 @@ import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -74,7 +74,7 @@ public class ConsoleTracker extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, vmName); } catch (ApiException e) { logger.log(Level.SEVERE, e, @@ -106,16 +106,15 @@ public class ConsoleTracker extends VmDefUpdater { mainChannelClientHost = event.clientHost(); mainChannelClientPort = event.clientPort(); vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.addProperty("consoleClient", event.clientHost()); - updateCondition(from, status, "ConsoleConnected", true, "Connected", - "Connection from " + event.clientHost()); + JsonObject status = updateCondition(from, "ConsoleConnected", true, + "Connected", "Connection from " + event.clientHost()); + status.addProperty(Status.CONSOLE_CLIENT, event.clientHost()); return status; }); // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("ConsoleConnectionUpdate") .reason("Connection from " + event.clientHost()); K8s.createEvent(apiClient, vmStub.model().get(), evt); @@ -141,16 +140,15 @@ public class ConsoleTracker extends VmDefUpdater { return; } vmStub.updateStatus(from -> { - JsonObject status = from.statusJson(); - status.addProperty("consoleClient", ""); - updateCondition(from, status, "ConsoleConnected", false, + JsonObject status = updateCondition(from, "ConsoleConnected", false, "Disconnected", event.clientHost() + " has disconnected"); + status.addProperty(Status.CONSOLE_CLIENT, ""); return status; }); // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("ConsoleConnectionUpdate") .reason("Disconnected from " + event.clientHost()); K8s.createEvent(apiClient, vmStub.model().get(), evt); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java index 1f9833c..d301aac 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -22,14 +22,20 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; +import java.util.Optional; import java.util.logging.Level; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; +import org.jgrapes.core.Event; import org.jgrapes.core.annotation.Handler; import org.jgrapes.util.events.FileChanged; import org.jgrapes.util.events.WatchFile; @@ -40,11 +46,11 @@ import org.jgrapes.util.events.WatchFile; @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class DisplayController extends Component { - public static final String DISPLAY_PASSWORD_FILE = "display-password"; - public static final String PASSWORD_EXPIRY_FILE = "password-expiry"; private String currentPassword; private String protocol; private final Path configDir; + private boolean vmopAgentConnected; + private String loggedInUser; /** * Instantiates a new Display controller. @@ -57,7 +63,7 @@ public class DisplayController extends Component { public DisplayController(Channel componentChannel, Path configDir) { super(componentChannel); this.configDir = configDir; - fire(new WatchFile(configDir.resolve(DISPLAY_PASSWORD_FILE))); + fire(new WatchFile(configDir.resolve(DisplaySecret.PASSWORD))); } /** @@ -72,7 +78,32 @@ public class DisplayController extends Component { } protocol = event.configuration().vm.display.spice != null ? "spice" : null; - updatePassword(); + loggedInUser = event.configuration().vm.display.loggedInUser; + configureLogin(); + if (event.runState() == RunState.STARTING) { + configurePassword(); + } + } + + /** + * On vmop agent connected. + * + * @param event the event + */ + @Handler + public void onVmopAgentConnected(VmopAgentConnected event) { + vmopAgentConnected = true; + configureLogin(); + } + + private void configureLogin() { + if (!vmopAgentConnected) { + return; + } + Event evt = loggedInUser != null + ? new VmopAgentLogIn(loggedInUser) + : new VmopAgentLogOut(); + fire(evt); } /** @@ -83,13 +114,12 @@ public class DisplayController extends Component { @Handler @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { - if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) { - updatePassword(); + if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { + configurePassword(); } } - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") - private void updatePassword() { + private void configurePassword() { if (protocol == null) { return; } @@ -99,47 +129,41 @@ public class DisplayController extends Component { } private boolean setDisplayPassword() { - String password; - Path dpPath = configDir.resolve(DISPLAY_PASSWORD_FILE); - if (dpPath.toFile().canRead()) { - logger.finer(() -> "Found display password"); - try { - password = Files.readString(dpPath); - } catch (IOException e) { - logger.log(Level.WARNING, e, () -> "Cannot read display" - + " password: " + e.getMessage()); - return false; + return readFromFile(DisplaySecret.PASSWORD).map(password -> { + if (Objects.equals(this.currentPassword, password)) { + return true; } - } else { - logger.finer(() -> "No display password"); - return false; - } - - if (Objects.equals(this.currentPassword, password)) { + this.currentPassword = password; + logger.fine(() -> "Updating display password"); + fire(new MonitorCommand( + new QmpSetDisplayPassword(protocol, password))); return true; - } - this.currentPassword = password; - logger.fine(() -> "Updating display password"); - fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password))); - return true; + }).orElse(false); } private void setPasswordExpiry() { - Path pePath = configDir.resolve(PASSWORD_EXPIRY_FILE); - if (!pePath.toFile().canRead()) { - return; - } - logger.finer(() -> "Found expiry time"); - String expiry; - try { - expiry = Files.readString(pePath); - } catch (IOException e) { - logger.log(Level.WARNING, e, () -> "Cannot read expiry" - + " time: " + e.getMessage()); - return; - } - logger.fine(() -> "Updating expiry time"); - fire(new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); + readFromFile(DisplaySecret.EXPIRY).ifPresent(expiry -> { + logger.fine(() -> "Updating expiry time to " + expiry); + fire( + new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); + }); } + private Optional readFromFile(String dataItem) { + Path path = configDir.resolve(dataItem); + String label = dataItem.replace('-', ' '); + if (path.toFile().canRead()) { + logger.finer(() -> "Found " + label); + try { + return Optional.ofNullable(Files.readString(path)); + } catch (IOException e) { + logger.log(Level.WARNING, e, () -> "Cannot read " + label + ": " + + e.getMessage()); + return Optional.empty(); + } + } else { + logger.finer(() -> "No " + label); + return Optional.empty(); + } + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java index f3928f5..880ca58 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -19,14 +19,8 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.LinkedList; import java.util.Queue; import java.util.logging.Level; @@ -34,38 +28,17 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; -import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; -import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; -import org.jgrapes.core.events.Stop; -import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; -import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; -import org.jgrapes.util.events.ConfigurationUpdate; /** - * A component that handles the communication over the guest agent - * socket. + * A component that handles the communication with the guest agent. * * If the log level for this class is set to fine, the messages * exchanged on the monitor socket are logged. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class GuestAgentClient extends Component { +public class GuestAgentClient extends AgentConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; - private SocketIOChannel gaChannel; private final Queue executing = new LinkedList<>(); /** @@ -74,126 +47,36 @@ public class GuestAgentClient extends Component { * @param componentChannel the component channel * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", - "PMD.ConstructorCallsOverridableMethod" }) public GuestAgentClient(Channel componentChannel) throws IOException { super(componentChannel); } /** - * As the initial configuration of this component depends on the - * configuration of the {@link Runner}, it doesn't have a handler - * for the {@link ConfigurationUpdate} event. The values are - * forwarded from the {@link Runner} instead. - * - * @param socketPath the socket path - * @param powerdownTimeout + * When the agent has connected, request the OS information. */ - /* default */ void configure(Path socketPath) { - this.socketPath = socketPath; + @Override + protected void agentConnected() { + fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); } /** - * Handle the start event. + * Process agent input. * - * @param event the event + * @param line the line * @throws IOException Signals that an I/O exception has occurred. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - } - - /** - * When the virtual serial port "channel0" has been opened, - * establish the connection by opening the socket. - * - * @param event the event - */ - @Handler - public void onVserportChanged(VserportChangeEvent event) { - if ("channel0".equals(event.id()) && event.isOpen()) { - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(GuestAgentClient.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @SuppressWarnings("resource") - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(GuestAgentClient.class).ifPresent(qm -> { - gaChannel = channel; - channel.setAssociated(GuestAgentClient.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processGuestAgentInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(GuestAgentClient.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(GuestAgentClient.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processGuestAgentInput(String line) - throws IOException { + @Override + protected void processInput(String line) throws IOException { logger.fine(() -> "guest agent(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("return") || response.has("error")) { QmpCommand executed = executing.poll(); - logger.fine( - () -> String.format("(Previous \"guest agent(in)\" is " - + "result from executing %s)", executed)); + logger.fine(() -> String.format("(Previous \"guest agent(in)\"" + + " is result from executing %s)", executed)); if (executed instanceof QmpGuestGetOsinfo) { - rep.fire(new OsinfoEvent(response.get("return"))); + var osInfo = new OsinfoEvent(response.get("return")); + rep().fire(osInfo); } } } catch (JsonProcessingException e) { @@ -201,29 +84,19 @@ public class GuestAgentClient extends Component { } } - /** - * On closed. - * - * @param event the event - */ - @Handler - @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", - "PMD.AvoidDuplicateLiterals" }) - public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(QemuMonitor.class).ifPresent(qm -> { - gaChannel = null; - }); - } - /** * On guest agent command. * * @param event the event + * @throws IOException */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidSynchronizedStatement" }) - public void onGuestAgentCommand(GuestAgentCommand event) { + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onGuestAgentCommand(GuestAgentCommand event) + throws IOException { + if (qemuChannel() == null) { + return; + } var command = event.command(); logger.fine(() -> "guest agent(out): " + command.toString()); String asText; @@ -235,15 +108,10 @@ public class GuestAgentClient extends Component { return; } synchronized (executing) { - gaChannel.associated(Writer.class).ifPresent(writer -> { - try { - executing.add(command); - writer.append(asText).append('\n').flush(); - } catch (IOException e) { - // Cannot happen, but... - logger.log(Level.WARNING, e, e::getMessage); - } - }); + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java new file mode 100644 index 0000000..2e94c14 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -0,0 +1,250 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.Writer; +import java.lang.reflect.UndeclaredThrowableException; +import java.net.UnixDomainSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ConnectError; +import org.jgrapes.io.events.Input; +import org.jgrapes.io.events.OpenSocketConnection; +import org.jgrapes.io.util.ByteBufferWriter; +import org.jgrapes.io.util.LineCollector; +import org.jgrapes.net.SocketIOChannel; +import org.jgrapes.net.events.ClientConnected; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * A component that handles the communication with QEMU over a socket. + * + * Derived classes should log the messages exchanged on the socket + * if the log level is set to fine. + */ +public abstract class QemuConnector extends Component { + + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + + private EventPipeline rep; + private Path socketPath; + private SocketIOChannel qemuChannel; + + /** + * Instantiates a new QEMU connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public QemuConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param socketPath the socket path + */ + /* default */ void configure(Path socketPath) { + this.socketPath = socketPath; + logger.fine(() -> getClass().getSimpleName() + + " configured with socketPath=" + socketPath); + } + + /** + * Note the runner's event processor and delete the socket. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onStart(Start event) throws IOException { + rep = event.associated(EventPipeline.class).get(); + if (socketPath == null) { + return; + } + Files.deleteIfExists(socketPath); + fire(new WatchFile(socketPath)); + } + + /** + * Return the runner's event pipeline. + * + * @return the event pipeline + */ + protected EventPipeline rep() { + return rep; + } + + /** + * Watch for the creation of the swtpm socket and start the + * qemu process if it has been created. + * + * @param event the event + */ + @Handler + public void onFileChanged(FileChanged event) { + if (event.change() == FileChanged.Kind.CREATED + && event.path().equals(socketPath)) { + // qemu running, open socket + fire(new OpenSocketConnection( + UnixDomainSocketAddress.of(socketPath)) + .setAssociated(getClass(), this)); + } + } + + /** + * Check if this is from opening the agent socket and if true, + * save the socket in the context and associate the channel with + * the context. + * + * @param event the event + * @param channel the channel + */ + @SuppressWarnings("resource") + @Handler + public void onClientConnected(ClientConnected event, + SocketIOChannel channel) { + event.openEvent().associated(getClass()).ifPresent(qm -> { + qemuChannel = channel; + channel.setAssociated(getClass(), this); + channel.setAssociated(Writer.class, new ByteBufferWriter( + channel).nativeCharset()); + channel.setAssociated(LineCollector.class, + new LineCollector() + .consumer(line -> { + try { + processInput(line); + } catch (IOException e) { + throw new UndeclaredThrowableException(e); + } + })); + socketConnected(); + }); + } + + /** + * Return the QEMU channel if the connection has been established. + * + * @return the socket IO channel + */ + protected Optional qemuChannel() { + return Optional.ofNullable(qemuChannel); + } + + /** + * Return the {@link Writer} for the connection if the connection + * has been established. + * + * @return the optional + */ + protected Optional writer() { + return qemuChannel().flatMap(c -> c.associated(Writer.class)); + } + + /** + * Send the given command to QEMU. A newline is appended to the + * command automatically. + * + * @param command the command + * @return true, if successful + * @throws IOException Signals that an I/O exception has occurred. + */ + protected boolean sendCommand(String command) throws IOException { + if (writer().isEmpty()) { + return false; + } + writer().get().append(command).append('\n').flush(); + return true; + } + + /** + * Called when the connector has been connected to the socket. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void socketConnected() { + // Default is to do nothing. + } + + /** + * Called when a connection attempt fails. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onConnectError(ConnectError event, SocketIOChannel channel) { + event.event().associated(getClass()).ifPresent(qm -> { + rep.fire(new Stop()); + }); + } + + /** + * Handle data from the socket connection. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onInput(Input event, SocketIOChannel channel) { + if (channel.associated(getClass()).isEmpty()) { + return; + } + channel.associated(LineCollector.class).ifPresent(collector -> { + collector.feed(event); + }); + } + + /** + * Process agent input. + * + * @param line the line + * @throws IOException Signals that an I/O exception has occurred. + */ + protected abstract void processInput(String line) throws IOException; + + /** + * On closed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onClosed(Closed event, SocketIOChannel channel) { + channel.associated(getClass()).ifPresent(qm -> { + qemuChannel = null; + }); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 7cac734..1de8f60 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java @@ -19,13 +19,8 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; @@ -42,24 +37,13 @@ import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult; import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; import org.jgrapes.core.Components; import org.jgrapes.core.Components.Timer; -import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; import org.jgrapes.core.events.Stop; import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.FileChanged; -import org.jgrapes.util.events.WatchFile; /** * A component that handles the communication over the Qemu monitor @@ -69,14 +53,9 @@ import org.jgrapes.util.events.WatchFile; * exchanged on the monitor socket are logged. */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class QemuMonitor extends Component { +public class QemuMonitor extends QemuConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; private int powerdownTimeout; - private SocketIOChannel monitorChannel; private final Queue executing = new LinkedList<>(); private Instant powerdownStartedAt; private Stop suspendedStop; @@ -84,7 +63,7 @@ public class QemuMonitor extends Component { private boolean powerdownConfirmed; /** - * Instantiates a new qemu monitor. + * Instantiates a new QEMU monitor. * * @param componentChannel the component channel * @param configDir the config dir @@ -111,109 +90,26 @@ public class QemuMonitor extends Component { * @param powerdownTimeout */ /* default */ void configure(Path socketPath, int powerdownTimeout) { - this.socketPath = socketPath; + super.configure(socketPath); this.powerdownTimeout = powerdownTimeout; } /** - * Handle the start event. - * - * @param event the event - * @throws IOException Signals that an I/O exception has occurred. + * When the socket is connected, send the capabilities command. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - fire(new WatchFile(socketPath)); + @Override + protected void socketConnected() { + fire(new MonitorCommand(new QmpCapabilities())); } - /** - * Watch for the creation of the swtpm socket and start the - * qemu process if it has been created. - * - * @param event the event - */ - @Handler - public void onFileChanged(FileChanged event) { - if (event.change() == FileChanged.Kind.CREATED - && event.path().equals(socketPath)) { - // qemu running, open socket - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(QemuMonitor.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @SuppressWarnings("resource") - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = channel; - channel.setAssociated(QemuMonitor.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processMonitorInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new MonitorCommand(new QmpCapabilities())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(QemuMonitor.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(QemuMonitor.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processMonitorInput(String line) + @Override + protected void processInput(String line) throws IOException { logger.fine(() -> "monitor(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("QMP")) { - rep.fire(new MonitorReady()); + rep().fire(new MonitorReady()); return; } if (response.has("return") || response.has("error")) { @@ -221,11 +117,11 @@ public class QemuMonitor extends Component { logger.fine( () -> String.format("(Previous \"monitor(in)\" is result " + "from executing %s)", executed)); - rep.fire(MonitorResult.from(executed, response)); + rep().fire(MonitorResult.from(executed, response)); return; } if (response.has("event")) { - MonitorEvent.from(response).ifPresent(rep::fire); + MonitorEvent.from(response).ifPresent(rep()::fire); } } catch (JsonProcessingException e) { throw new IOException(e); @@ -241,8 +137,8 @@ public class QemuMonitor extends Component { @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", "PMD.AvoidDuplicateLiterals" }) public void onClosed(Closed event, SocketIOChannel channel) { + super.onClosed(event, channel); channel.associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = null; synchronized (this) { if (powerdownTimer != null) { powerdownTimer.cancel(); @@ -259,11 +155,12 @@ public class QemuMonitor extends Component { * On monitor command. * * @param event the event + * @throws IOException */ @Handler @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.AvoidSynchronizedStatement" }) - public void onExecQmpCommand(MonitorCommand event) { + public void onExecQmpCommand(MonitorCommand event) throws IOException { var command = event.command(); logger.fine(() -> "monitor(out): " + command.toString()); String asText; @@ -275,15 +172,10 @@ public class QemuMonitor extends Component { return; } synchronized (executing) { - monitorChannel.associated(Writer.class).ifPresent(writer -> { - try { - executing.add(command); - writer.append(asText).append('\n').flush(); - } catch (IOException e) { - // Cannot happen, but... - logger.log(Level.WARNING, e, e::getMessage); - } - }); + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } } } @@ -295,7 +187,7 @@ public class QemuMonitor extends Component { @Handler(priority = 100) @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onStop(Stop event) { - if (monitorChannel != null) { + if (qemuChannel() != null) { // We have a connection to Qemu, attempt ACPI shutdown. event.suspendHandling(); suspendedStop = event; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index b258e1a..a01618d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -56,6 +56,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; @@ -220,6 +221,7 @@ public class Runner extends Component { private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; private final GuestAgentClient guestAgentClient; + private final VmopAgentClient vmopAgentClient; private Integer resetCounter; private RunState state = RunState.INITIALIZING; @@ -278,6 +280,7 @@ public class Runner extends Component { attach(new SocketConnector(channel())); attach(qemuMonitor = new QemuMonitor(channel(), configDir)); attach(guestAgentClient = new GuestAgentClient(channel())); + attach(vmopAgentClient = new VmopAgentClient(channel())); attach(new StatusUpdater(channel())); attach(new YamlConfigurationStore(channel(), configFile, false)); fire(new WatchFile(configFile.toPath())); @@ -309,8 +312,7 @@ public class Runner extends Component { // Add some values from other sources to configuration newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); - Path dsPath - = configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE); + Path dsPath = configDir.resolve(DisplaySecret.PASSWORD); newConf.hasDisplayPassword = dsPath.toFile().canRead(); // Special actions for initial configuration (startup) @@ -352,7 +354,8 @@ public class Runner extends Component { // Forward some values to child components qemuMonitor.configure(config.monitorSocket, config.vm.powerdownTimeout); - guestAgentClient.configure(config.guestAgentSocket); + configureAgentClient(guestAgentClient, "guest-agent-socket"); + configureAgentClient(vmopAgentClient, "vmop-agent-socket"); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); @@ -477,6 +480,36 @@ public class Runner extends Component { } } + @SuppressWarnings("PMD.CognitiveComplexity") + private void configureAgentClient(AgentConnector client, String chardev) { + String id = null; + Path path = null; + for (var arg : qemuDefinition.command) { + if (arg.startsWith("virtserialport,") + && arg.contains("chardev=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("id=")) { + id = prop.substring(3); + } + } + } + if (arg.startsWith("socket,") + && arg.contains("id=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("path=")) { + path = Path.of(prop.substring(5)); + } + } + } + } + if (id == null || path == null) { + logger.warning(() -> "Definition of chardev " + chardev + + " missing in runner template."); + return; + } + client.configure(id, path); + } + /** * Handle the started event. * diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java index f9644c8..36a63c1 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -33,8 +33,8 @@ import java.io.IOException; import java.math.BigDecimal; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinitionStub; @@ -47,6 +47,9 @@ import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.annotation.Handler; @@ -109,10 +112,17 @@ public class StatusUpdater extends VmDefUpdater { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, vmName); - vmStub.model().ifPresent(model -> { - observedGeneration = model.getMetadata().getGeneration(); + var vmDef = vmStub.model().orElse(null); + if (vmDef == null) { + return; + } + observedGeneration = vmDef.getMetadata().getGeneration(); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.LOGGED_IN_USER); + return status; }); } catch (ApiException e) { logger.log(Level.SEVERE, e, @@ -143,18 +153,20 @@ public class StatusUpdater extends VmDefUpdater { // by a new version of the CR. So we update only if we have // a new version of the CR. There's one exception: the display // password is configured by a file, not by the CR. - var vmDef = vmStub.model(); - if (vmDef.isPresent() - && vmDef.get().metadata().getGeneration() == observedGeneration - && (event.configuration().hasDisplayPassword - || vmDef.get().statusJson().getAsJsonPrimitive( - "displayPasswordSerial").getAsInt() == -1)) { + var vmDef = vmStub.model().orElse(null); + if (vmDef == null) { return; } - vmStub.updateStatus(vmDef.get(), from -> { + if (vmDef.metadata().getGeneration() == observedGeneration + && (event.configuration().hasDisplayPassword + || vmDef.statusJson().getAsJsonPrimitive( + Status.DISPLAY_PASSWORD_SERIAL).getAsInt() == -1)) { + return; + } + vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { - status.addProperty("displayPasswordSerial", -1); + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1); } status.getAsJsonArray("conditions").asList().stream() .map(cond -> (JsonObject) cond).filter(cond -> "Running" @@ -162,7 +174,7 @@ public class StatusUpdater extends VmDefUpdater { .forEach(cond -> cond.addProperty("observedGeneration", from.getMetadata().getGeneration())); return status; - }); + }, vmDef); } /** @@ -172,43 +184,44 @@ public class StatusUpdater extends VmDefUpdater { * @throws ApiException */ @Handler - @SuppressWarnings({ "PMD.AssignmentInOperand", - "PMD.AvoidLiteralsInIfCondition" }) + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AssignmentInOperand", "PMD.AvoidDuplicateLiterals" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { VmDefinition vmDef; if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } - vmStub.updateStatus(vmDef, from -> { - JsonObject status = from.statusJson(); + vmStub.updateStatus(from -> { boolean running = event.runState().vmRunning(); - updateCondition(vmDef, vmDef.statusJson(), "Running", running, - event.reason(), event.message()); - updateCondition(vmDef, vmDef.statusJson(), "Booted", + updateCondition(vmDef, "Running", running, event.reason(), + event.message()); + JsonObject status = updateCondition(vmDef, "Booted", event.runState() == RunState.BOOTED, event.reason(), event.message()); if (event.runState() == RunState.STARTING) { - status.addProperty("ram", GsonPtr.to(from.data()) + status.addProperty(Status.RAM, GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); - status.addProperty("cpus", 1); + status.addProperty(Status.CPUS, 1); + } else if (event.runState() == RunState.STOPPED) { + status.addProperty(Status.RAM, "0"); + status.addProperty(Status.CPUS, 0); + status.remove(Status.LOGGED_IN_USER); + } + + if (!running) { + // In case console connection was still present + status.addProperty(Status.CONSOLE_CLIENT, ""); + updateCondition(from, "ConsoleConnected", false, "VmStopped", + "The VM is not running"); // In case we had an irregular shutdown - status.remove("osinfo"); - } else if (event.runState() == RunState.STOPPED) { - status.addProperty("ram", "0"); - status.addProperty("cpus", 0); - status.remove("osinfo"); - } - - // In case console connection was still present - if (!running) { - status.addProperty("consoleClient", ""); - updateCondition(from, status, "ConsoleConnected", false, - "VmStopped", "The VM has been shut down"); + status.remove(Status.OSINFO); + updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", + "The VM is not running"); } return status; - }); + }, vmDef); // Maybe stop VM if (event.runState() == RunState.TERMINATING && !event.failed() @@ -226,7 +239,7 @@ public class StatusUpdater extends VmDefUpdater { // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("StatusUpdate").reason(event.reason()) .note(event.message()); K8s.createEvent(apiClient, vmDef, evt); @@ -245,7 +258,7 @@ public class StatusUpdater extends VmDefUpdater { } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("ram", + status.addProperty(Status.RAM, new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) .toSuffixedString()); return status; @@ -265,7 +278,7 @@ public class StatusUpdater extends VmDefUpdater { } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("cpus", event.usedCpus().size()); + status.addProperty(Status.CPUS, event.usedCpus().size()); return status; }); } @@ -284,8 +297,8 @@ public class StatusUpdater extends VmDefUpdater { } vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.addProperty("displayPasswordSerial", - status.get("displayPasswordSerial").getAsLong() + 1); + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, + status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1); return status; }); } @@ -314,12 +327,60 @@ public class StatusUpdater extends VmDefUpdater { } var asGson = gson.toJsonTree( objectMapper.convertValue(event.osinfo(), Object.class)); - vmStub.updateStatus(from -> { JsonObject status = from.statusJson(); - status.add("osinfo", asGson); + status.add(Status.OSINFO, asGson); return status; }); } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentConnected(VmopAgentConnected event) + throws ApiException { + VmDefinition vmDef; + if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { + return; + } + vmStub.updateStatus(from -> { + return updateCondition(vmDef, "VmopAgentConnected", + true, "VmopAgentStarted", "The VM operator agent is running"); + }, vmDef); + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.LOGGED_IN_USER, + event.triggering().user()); + return status; + }); + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentLoggedOut(VmopAgentLoggedOut event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.LOGGED_IN_USER); + return status; + }); + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java index f04b478..50017c1 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java @@ -31,6 +31,7 @@ import java.util.Optional; import java.util.logging.Level; import java.util.stream.Collectors; import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sGenericStub; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jgrapes.core.Channel; @@ -109,17 +110,21 @@ public class VmDefUpdater extends Component { } /** - * Update condition. + * Update condition. The `from` VM definition is used to determine the + * observed generation and the current status. This method is intended + * to be called in the function passed to + * {@link K8sGenericStub#updateStatus}. * * @param from the VM definition - * @param status the current status * @param type the condition type * @param state the new state * @param reason the reason for the change * @param message the message + * @return the updated status */ - protected void updateCondition(VmDefinition from, JsonObject status, - String type, boolean state, String reason, String message) { + protected JsonObject updateCondition(VmDefinition from, String type, + boolean state, String reason, String message) { + JsonObject status = from.statusJson(); // Optimize, as we can get this several times var current = status.getAsJsonArray("conditions").asList().stream() .map(cond -> (JsonObject) cond) @@ -127,7 +132,7 @@ public class VmDefUpdater extends Component { .findFirst() .map(cond -> "True".equals(cond.get("status").getAsString())); if (current.isPresent() && current.get() == state) { - return; + return status; } // Do update @@ -150,5 +155,6 @@ public class VmDefUpdater extends Component { newConds.addAll(toReplace); status.add("conditions", apiClient.getJSON().getGson().toJsonTree(newConds)); + return status; } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java new file mode 100644 index 0000000..f50d397 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -0,0 +1,132 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.util.Deque; +import java.util.concurrent.ConcurrentLinkedDeque; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; + +/** + * A component that handles the communication over the vmop agent + * socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public class VmopAgentClient extends AgentConnector { + + private final Deque> executing = new ConcurrentLinkedDeque<>(); + + /** + * Instantiates a new VM operator agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public VmopAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * On vmop agent login. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogIn(VmopAgentLogIn event) throws IOException { + logger.fine(() -> "vmop agent(out): login " + event.user()); + if (writer().isPresent()) { + executing.add(event); + sendCommand("login " + event.user()); + } + } + + /** + * On vmop agent logout. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogout(VmopAgentLogOut event) throws IOException { + logger.fine(() -> "vmop agent(out): logout"); + if (writer().isPresent()) { + executing.add(event); + sendCommand("logout"); + } + } + + @Override + @SuppressWarnings({ "PMD.UnnecessaryReturn", + "PMD.AvoidLiteralsInIfCondition" }) + protected void processInput(String line) throws IOException { + logger.fine(() -> "vmop agent(in): " + line); + + // Check validity + if (line.isEmpty() || !Character.isDigit(line.charAt(0))) { + logger.warning(() -> "Illegal response: " + line); + return; + } + + // Check positive responses + if (line.startsWith("220 ")) { + rep().fire(new VmopAgentConnected()); + return; + } + if (line.startsWith("201 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogIn login) { + rep().fire(new VmopAgentLoggedIn(login)); + } else { + logger.severe(() -> "Response " + line + + " does not match executing command " + cmd); + } + return; + } + if (line.startsWith("202 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogOut logout) { + rep().fire(new VmopAgentLoggedOut(logout)); + } else { + logger.severe(() -> "Response " + line + + "does not match executing command " + cmd); + } + return; + } + + // Ignore unhandled continuations + if (line.charAt(0) == '1') { + return; + } + + // Error + logger.warning(() -> "Error response: " + line); + executing.pop(); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java new file mode 100644 index 0000000..dc13569 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class VmopAgentConnected extends Event { +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java new file mode 100644 index 0000000..96db884 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java @@ -0,0 +1,45 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Sends the login command to the VM operator agent. + */ +public class VmopAgentLogIn extends Event { + + private final String user; + + /** + * Instantiates a new vmop agent logout. + */ + public VmopAgentLogIn(String user) { + this.user = user; + } + + /** + * Returns the user. + * + * @return the user + */ + public String user() { + return user; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java new file mode 100644 index 0000000..1502200 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Sends the logout command to the VM operator agent. + */ +public class VmopAgentLogOut extends Event { +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java new file mode 100644 index 0000000..f59ed71 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedIn extends Event { + + private final VmopAgentLogIn triggering; + + /** + * Instantiates a new vmop agent logged in. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedIn(VmopAgentLogIn triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogIn triggering() { + return triggering; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java new file mode 100644 index 0000000..5f60e00 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedOut extends Event { + + private final VmopAgentLogOut triggering; + + /** + * Instantiates a new vmop agent logged out. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedOut(VmopAgentLogOut triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogOut triggering() { + return triggering; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index e2610ba..c5c0252 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -122,11 +122,16 @@ # Best explanation found: # https://fedoraproject.org/wiki/Features/VirtioSerial - [ "-device", "virtio-serial-pci,id=virtio-serial0" ] - # - Guest agent serial connection. MUST have id "channel0"! + # - Guest agent serial connection. - [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\ chardev=guest-agent-socket" ] - [ "-chardev","socket,id=guest-agent-socket,\ path=${ runtimeDir }/org.qemu.guest_agent.0,server=on,wait=off" ] + # - VM operator agent serial connection. + - [ "-device", "virtserialport,id=channel1,name=org.jdrupes.vmop_agent.0,\ + chardev=vmop-agent-socket" ] + - [ "-chardev","socket,id=vmop-agent-socket,\ + path=${ runtimeDir }/org.jdrupes.vmop_agent.0,server=on,wait=off" ] # * USB Hub and devices (more in SPICE configuration below) # https://qemu-project.gitlab.io/qemu/system/devices/usb.html # https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt index f67978f..ac24b16 100644 --- a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt @@ -9,7 +9,11 @@ archlinux.svg: debian.svg: Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg License : LGPL - + +fedora.svg: + Source: https://commons.wikimedia.org/wiki/File:Fedora_icon_(2021).svg + License: Public Domain + tux.svg: Source: https://commons.wikimedia.org/wiki/File:Tux.svghttps://commons.wikimedia.org/wiki/File:Tux.svg License: Creative Commons CC0 1.0 Universal Public Domain Dedication. Creative Commons CC0 1.0 Universal Public Domain Dedication. diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg index 7871791..e227311 100644 Binary files a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg and b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg differ diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java index e283504..3b28d1c 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -49,11 +49,11 @@ import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.manager.events.AssignVm; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.GetVms; import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PrepareConsole; import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -808,18 +808,23 @@ public class VmAccess extends FreeMarkerConlet { Map.of("autoClose", 5_000, "type", "Warning"))); return; } - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> { - vmDef.extra() - .map(xtra -> xtra.connectionFile(e.password().orElse(null), - preferredIpVersion, deleteConnectionFile)) - .ifPresent( - cf -> channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", cf))); - }); + var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user, + model.mode() == ResourceModel.Mode.POOL), + e -> gotPassword(channel, model, vmDef, e)); fire(pwQuery, vmChannel); } + private void gotPassword(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef, PrepareConsole event) { + if (!event.passwordAvailable()) { + return; + } + vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), + preferredIpVersion, deleteConnectionFile)) + .ifPresent(cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.UseLocaleWithCaseConversions" }) private void selectResource(NotifyConletModel event, diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts index ec21fb5..f0ef3c8 100644 --- a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -73,7 +73,9 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, const configured = computed(() => previewApi.vmDefinition.spec); const busy = computed(() => previewApi.vmDefinition.spec && (previewApi.vmDefinition.spec.vm.state === 'Running' - && !previewApi.vmDefinition.running + && (previewApi.poolName + ? !previewApi.vmDefinition.vmopAgent + : !previewApi.vmDefinition.running) || previewApi.vmDefinition.spec.vm.state === 'Stopped' && previewApi.vmDefinition.running)); const startable = computed(() => previewApi.vmDefinition.spec @@ -85,6 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); + const vmopAgent = computed(() => previewApi.vmDefinition.vmopAgent); const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.permissions); const osicon = computed(() => { @@ -120,8 +123,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, }; return { localize, resourceBase, vmAction, poolName, vmName, - configured, busy, startable, stoppable, running, inUse, - permissions, osicon }; + configured, busy, startable, stoppable, running, vmopAgent, + inUse, permissions, osicon }; }, template: ` @@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
{ if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; vmDefinition.runningConditionSince = new Date(condition.lastTransitionTime); - break; + } else if (condition.type === "VmopAgentConnected") { + vmDefinition.vmopAgent = condition.status === "True"; + vmDefinition.vmopAgentConditionSince + = new Date(condition.lastTransitionTime); } - } + }) } else { vmDefinition = {}; } diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java index 4cc63fa..6d3891d 100644 --- a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -38,13 +38,14 @@ import java.util.Map; import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.manager.events.ChannelTracker; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; 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.VmChannel; import org.jdrupes.vmoperator.manager.events.VmDefChanged; @@ -243,8 +244,8 @@ public class VmMgmt extends FreeMarkerConlet { DataPath. get(vmSpec, "currentRam").orElse("0")).getNumber() .toBigInteger()); var status = DataPath.deepCopy(vmDef.status()); - status.put("ram", Quantity.fromString( - DataPath. get(status, "ram").orElse("0")).getNumber() + status.put(Status.RAM, Quantity.fromString( + DataPath. get(status, Status.RAM).orElse("0")).getNumber() .toBigInteger()); // Build result @@ -383,10 +384,10 @@ public class VmMgmt extends FreeMarkerConlet { Summary summary = new Summary(); for (var vmDef : channelTracker.associated()) { summary.totalVms += 1; - summary.usedCpus += vmDef. fromStatus("cpus") + summary.usedCpus += vmDef. fromStatus(Status.CPUS) .map(Number::intValue).orElse(0); summary.usedRam = summary.usedRam - .add(vmDef. fromStatus("ram") + .add(vmDef. fromStatus(Status.RAM) .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) .orElse(BigInteger.ZERO)); if (vmDef.conditionStatus("Running").orElse(false)) { @@ -483,17 +484,22 @@ public class VmMgmt extends FreeMarkerConlet { Map.of("autoClose", 5_000, "type", "Warning"))); return; } - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), - e -> { - vmDef.extra().map(xtra -> xtra.connectionFile( - e.password().orElse(null), preferredIpVersion, - deleteConnectionFile)).ifPresent( - cf -> channel.respond(new NotifyConletView(type(), - model.getConletId(), "openConsole", cf))); - }); + var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user), + e -> gotPassword(channel, model, vmDef, e)); fire(pwQuery, vmChannel); } + private void gotPassword(ConsoleConnection channel, VmsModel model, + VmDefinition vmDef, PrepareConsole event) { + if (!event.passwordAvailable()) { + return; + } + vmDef.extra().map(xtra -> xtra.connectionFile(event.password(), + preferredIpVersion, deleteConnectionFile)).ifPresent( + cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + @Override protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, String conletId) throws Exception { diff --git a/webpages/vm-operator/pools.md b/webpages/vm-operator/pools.md new file mode 100644 index 0000000..a42293e --- /dev/null +++ b/webpages/vm-operator/pools.md @@ -0,0 +1,73 @@ +--- +title: "VM-Operator: VM pools — assigning VMs to users dynamically" +layout: vm-operator +--- + +# VM Pools + +*Since 4.0.0* + +## Prepare the VM + +### Shared file system + +Mount a shared file system as home file system on all VMs in the pool. +If you want to use the sample script for logging in a user, the filesystem +must support POSIX file access control lists (ACLs). + +### Restrict access + +The VMs should only be accessible via a desktop started by the VM-Operator. + + * Disable the display manager. + + ```console + # systemctl disable gdm + # systemctl stop gdm + ``` + + * Disable `getty` on tty1. + + ```console + # systemctl mask getty@tty1 + # systemctl stop getty@tty1 + ``` + +You can, of course, disable `getty` completely. If you do this, make sure +that you can still access your master VM through `ssh`, else you have +locked yourself out. + +Strictly speaking, it is not necessary to disable these services, because +the sample script includes a `Conflicts=` directive in the systemd service +that starts the desktop for the user. However, this is mainly intended for +development purposes and not for production. + +The following should actually be configured for any VM. + + * Prevent suspend/hibernate, because it will lock the VM. + + ```console + # systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target + ``` + +### Install the VM-Operator agent + +The VM-Operator agent runs as a systemd service. Sample configuration +files can be found +[here](https://github.com/mnlipp/VM-Operator/tree/main/dev-example/vmop-agent). +Copy + + * `99-vmop-agent.rules` to `/usr/local/lib/udev/rules.d/99-vmop-agent.rules`, + * `vmop-agent` to `/usr/local/libexec/vmop-agent` and + * `vmop-agent.service` to `/usr/local/lib/systemd/system/vmop-agent.service`. + +Note that some of the target directories do not exist by default and have to +be created first. Don't forget to run `restorecon` on systems with SELinux. + +Enable everything: + +```console +# udevadm control --reload-rules +# systemctl enable vmop-agent +# udevadm trigger + ``` diff --git a/webpages/vm-operator/upgrading.md b/webpages/vm-operator/upgrading.md index b987298..6fdbc44 100644 --- a/webpages/vm-operator/upgrading.md +++ b/webpages/vm-operator/upgrading.md @@ -9,16 +9,31 @@ layout: vm-operator ## To version 4.0.0 -The VmViewer conlet has been renamed to VmAccess. This affects the -[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration information using the old path -"/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer" -is still accepted for backward compatibility, but should be updated. + * The VmViewer conlet has been renamed to VmAccess. This affects the + [configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration + information using the old path + `/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer` + is still accepted for backward compatibility until the next major version, + but should be updated. -The change of name also causes conlets added to the overview page by -users to "disappear" from the GUI. They have to be re-added. + The change of name also causes conlets added to the overview page by + users to "disappear" from the GUI. They have to be re-added. -The latter behavior also applies to the VmConlet conlet which has been -renamed to VmMgmt. + The latter behavior also applies to the VmConlet conlet which has been + renamed to VmMgmt. + + * The configuration property `passwordValidity` has been moved from component + `/Manager/Controller/DisplaySecretMonitor` to + `/Manager/Controller/Reconciler/DisplaySecretReconciler`. The old path is + still accepted for backward compatibility until the next major version, + but should be updated. + + * The standard [template](./runner.html#stand-alone-configuration) used + to generate the QEMU command has been updated. Unless you have enabled + automatic updates of the template in the VM definition, you have to + update the template manually. If you're using your own template, you + have to add a virtual serial port (see the git history of the standard + template for the required addition). ## To version 3.4.0 diff --git a/webpages/vm-operator/user-gui.md b/webpages/vm-operator/user-gui.md index 0d16113..828eb98 100644 --- a/webpages/vm-operator/user-gui.md +++ b/webpages/vm-operator/user-gui.md @@ -131,16 +131,20 @@ of 16 (strong) random bytes (128 random bits). It is valid for 10 seconds only. This may be challenging on a slower computer or if users may not enable automatic open for connection files in the browser. The validity can therefore be adjusted in the -configuration. +configuration.[^oldPath] ```yaml "/Manager": "/Controller": - "/DisplaySecretMonitor": - # Validity of generated password in seconds - passwordValidity: 10 + "/Reconciler": + "/DisplaySecretReconciler": + # Validity of generated password in seconds + passwordValidity: 10 ``` +[^oldPath]: Before version 4.0, the path for `passwordValidity` was + `/Manager/Controller/DisplaySecretMonitor`. + Taking into account that the controller generates a display secret automatically by default, this approach to securing console access should be sufficient in all cases. (Any feedback