Merge branch 'feature/auto-login'

This commit is contained in:
Michael Lipp 2025-03-03 09:21:05 +01:00
commit 5c7a9f6e5f
48 changed files with 1863 additions and 816 deletions

View file

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

View file

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

View file

@ -0,0 +1,2 @@
SUBSYSTEM=="virtio-ports", ATTR{name}=="org.jdrupes.vmop_agent.0", \
TAG+="systemd" ENV{SYSTEMD_WANTS}="vmop-agent.service"

159
dev-example/vmop-agent/vmop-agent Executable file
View file

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

View file

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

View file

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

View file

@ -193,54 +193,94 @@ public class K8sGenericStub<O extends KubernetesObject,
}
/**
* Updates the object's status.
* Updates the object's status. Does not retry in case of conflict.
*
* @param object the current state of the object (passed to `status`)
* @param status function that returns the new status
* @param updater function that returns the new status
* @return the updated model or empty if the object was not found
* @throws ApiException the api exception
*/
@SuppressWarnings("PMD.AssignmentInOperand")
public Optional<O> updateStatus(O object, Function<O, Object> status)
public Optional<O> updateStatus(O object, Function<O, Object> 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<O> updateStatus(Function<O, Object> 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<O> updateStatus(Function<O, Object> status, int retries)
public Optional<O> updateStatus(Function<O, Object> 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<O> updateStatus(Function<O, Object> status)
public Optional<O> updateStatus(Function<O, Object> 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<O> updateStatus(Function<O, Object> updater)
throws ApiException {
return updateStatus(updater, null);
}
/**

View file

@ -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<String> assignedFrom() {
return fromStatus("assignment", "pool");
return fromStatus(Status.ASSIGNMENT, "pool");
}
/**
@ -228,7 +229,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the optional
*/
public Optional<String> assignedTo() {
return fromStatus("assignment", "user");
return fromStatus(Status.ASSIGNMENT, "user");
}
/**
@ -237,7 +238,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the optional
*/
public Optional<Instant> assignmentLastUsed() {
return this.<String> fromStatus("assignment", "lastUsed")
return this.<String> fromStatus(Status.ASSIGNMENT, "lastUsed")
.map(Instant::parse);
}
@ -286,7 +287,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the optional
*/
public Optional<String> consoleUser() {
return this.<String> fromStatus("consoleUser");
return this.<String> fromStatus(Status.CONSOLE_USER);
}
/**
@ -388,7 +389,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the optional
*/
public Optional<Long> displayPasswordSerial() {
return this.<Number> fromStatus("displayPasswordSerial")
return this.<Number> fromStatus(Status.DISPLAY_PASSWORD_SERIAL)
.map(Number::longValue);
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> {
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<String> password() {
if (!isDone()) {
throw new IllegalStateException("Event is not done.");
}
return currentResults().stream().findFirst();
}
}

View file

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

View file

@ -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>
<#if spec.vm.display.loggedInUser?? >
loggedInUser: "${ spec.vm.display.loggedInUser }"
</#if>
<#if spec.vm.display.spice??>
spice:
port: ${ spec.vm.display.spice.port?c }

View file

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

View file

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

View file

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

View file

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

View file

@ -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<V1Secret, V1SecretList, VmChannel> {
private int passwordValidity = 10;
private final List<PendingGet> pendingGets
= Collections.synchronizedList(new LinkedList<>());
private final ChannelDictionary<String, VmChannel, ?> 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;
}
}
}

View file

@ -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<PendingPrepare> 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.<String> 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;
}
}
}

View file

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

View file

@ -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<String, Object> constantsMap(Class<?> clazz) {
@SuppressWarnings("PMD.UseConcurrentHashMap")
Map<String, Object> 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<SocketIOChannel> qemuChannel() {
return Optional.ofNullable(qemuChannel);
}
/**
* Return the {@link Writer} for the connection if the connection
* has been established.
*
* @return the optional
*/
protected Optional<Writer> 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;
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Event<?>> 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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.runner.qemu.events;
import org.jgrapes.core.Event;
/**
* Signals information about the guest OS.
*/
public class VmopAgentConnected extends Event<Void> {
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

View file

@ -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<VmAccess.ResourceModel> {
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,

View file

@ -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: `
<table>
@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
<tr>
<td rowspan="2" style="position: relative"><span
style="position: absolute;" :class="{ busy: busy }"
><img role=button :aria-disabled="!running
><img role=button :aria-disabled="(poolName
? !vmopAgent : !running)
|| !permissions.includes('accessConsole')"
v-on:click="vmAction('openConsole')"
:src="resourceBase + (running
@ -206,14 +210,17 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
vmDefinition.currentCpus = vmDefinition.status.cpus;
vmDefinition.currentRam = Number(vmDefinition.status.ram);
vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
for (const condition of vmDefinition.status.conditions) {
vmDefinition.status.conditions.forEach((condition: any) => {
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 = {};
}

View file

@ -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<VmMgmt.VmsModel> {
DataPath.<String> get(vmSpec, "currentRam").orElse("0")).getNumber()
.toBigInteger());
var status = DataPath.deepCopy(vmDef.status());
status.put("ram", Quantity.fromString(
DataPath.<String> get(status, "ram").orElse("0")).getNumber()
status.put(Status.RAM, Quantity.fromString(
DataPath.<String> get(status, Status.RAM).orElse("0")).getNumber()
.toBigInteger());
// Build result
@ -383,10 +384,10 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
Summary summary = new Summary();
for (var vmDef : channelTracker.associated()) {
summary.totalVms += 1;
summary.usedCpus += vmDef.<Number> fromStatus("cpus")
summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS)
.map(Number::intValue).orElse(0);
summary.usedRam = summary.usedRam
.add(vmDef.<String> fromStatus("ram")
.add(vmDef.<String> 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<VmMgmt.VmsModel> {
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 {

View file

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

View file

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

View file

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