Merge branch 'feature/auto-login'
This commit is contained in:
commit
5c7a9f6e5f
48 changed files with 1863 additions and 816 deletions
|
|
@ -5,7 +5,7 @@ buildscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
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 'org.ajoberstar.git-publish' version '4.2.0' apply false
|
||||||
id 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false
|
id 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false
|
||||||
id 'org.jdrupes.vmoperator.versioning-conventions'
|
id 'org.jdrupes.vmoperator.versioning-conventions'
|
||||||
|
|
@ -28,7 +28,9 @@ task stage {
|
||||||
tc -> tc.findByName("build") }.flatten()
|
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
|
// Publish JavaDoc
|
||||||
dependsOn gitPublishPush
|
dependsOn gitPublishPush
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1430,6 +1430,12 @@ spec:
|
||||||
outputs:
|
outputs:
|
||||||
type: integer
|
type: integer
|
||||||
default: 1
|
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:
|
spice:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -1485,6 +1491,11 @@ spec:
|
||||||
connection.
|
connection.
|
||||||
type: string
|
type: string
|
||||||
default: ""
|
default: ""
|
||||||
|
loggedInUser:
|
||||||
|
description: >-
|
||||||
|
The name of a user that is currently logged in by the
|
||||||
|
VM operator agent.
|
||||||
|
type: string
|
||||||
displayPasswordSerial:
|
displayPasswordSerial:
|
||||||
description: >-
|
description: >-
|
||||||
Counts changes of the display password. Set to -1
|
Counts changes of the display password. Set to -1
|
||||||
|
|
|
||||||
2
dev-example/vmop-agent/99-vmop-agent.rules
Normal file
2
dev-example/vmop-agent/99-vmop-agent.rules
Normal 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
159
dev-example/vmop-agent/vmop-agent
Executable 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
|
||||||
15
dev-example/vmop-agent/vmop-agent.service
Normal file
15
dev-example/vmop-agent/vmop-agent.service
Normal 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
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
package org.jdrupes.vmoperator.common;
|
package org.jdrupes.vmoperator.common;
|
||||||
|
|
||||||
|
// TODO: Auto-generated Javadoc
|
||||||
/**
|
/**
|
||||||
* Some constants.
|
* Some constants.
|
||||||
*/
|
*/
|
||||||
|
|
@ -27,18 +28,67 @@ public class Constants {
|
||||||
/** The Constant APP_NAME. */
|
/** The Constant APP_NAME. */
|
||||||
public static final String APP_NAME = "vm-runner";
|
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. */
|
/** The Constant VM_OP_NAME. */
|
||||||
public static final String VM_OP_NAME = "vm-operator";
|
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. */
|
/** The Constant KIND_VM. */
|
||||||
public static final String VM_OP_KIND_VM = "VirtualMachine";
|
public static final String KIND_VM = "VirtualMachine";
|
||||||
|
|
||||||
/** The Constant VM_OP_KIND_VM_POOL. */
|
/** The Constant KIND_VM_POOL. */
|
||||||
public static final String VM_OP_KIND_VM_POOL = "VmPool";
|
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";
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
* @return the updated model or empty if the object was not found
|
||||||
* @throws ApiException the api exception
|
* @throws ApiException the api exception
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("PMD.AssignmentInOperand")
|
@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 {
|
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
|
* Gets the object and updates the status. In case of conflict, retries
|
||||||
* up to `retries` times.
|
* up to `retries` times.
|
||||||
*
|
*
|
||||||
* @param status the status
|
* @param updater the function updating the status
|
||||||
* @param retries the retries in case of conflict
|
* @param retries the retries in case of conflict
|
||||||
* @return the updated model or empty if the object was not found
|
* @return the updated model or empty if the object was not found
|
||||||
* @throws ApiException the api exception
|
* @throws ApiException the api exception
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" })
|
@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 {
|
throws ApiException {
|
||||||
try {
|
return updateStatus(updater, null, retries);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* @return the kubernetes api response
|
||||||
* the updated model or empty if not successful
|
* the updated model or empty if not successful
|
||||||
* @throws ApiException the api exception
|
* @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 {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import java.util.Set;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||||
import org.jdrupes.vmoperator.util.DataPath;
|
import org.jdrupes.vmoperator.util.DataPath;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -219,7 +220,7 @@ public class VmDefinition extends K8sDynamicModel {
|
||||||
* @return the optional
|
* @return the optional
|
||||||
*/
|
*/
|
||||||
public Optional<String> assignedFrom() {
|
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
|
* @return the optional
|
||||||
*/
|
*/
|
||||||
public Optional<String> assignedTo() {
|
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
|
* @return the optional
|
||||||
*/
|
*/
|
||||||
public Optional<Instant> assignmentLastUsed() {
|
public Optional<Instant> assignmentLastUsed() {
|
||||||
return this.<String> fromStatus("assignment", "lastUsed")
|
return this.<String> fromStatus(Status.ASSIGNMENT, "lastUsed")
|
||||||
.map(Instant::parse);
|
.map(Instant::parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,7 +287,7 @@ public class VmDefinition extends K8sDynamicModel {
|
||||||
* @return the optional
|
* @return the optional
|
||||||
*/
|
*/
|
||||||
public Optional<String> consoleUser() {
|
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
|
* @return the optional
|
||||||
*/
|
*/
|
||||||
public Optional<Long> displayPasswordSerial() {
|
public Optional<Long> displayPasswordSerial() {
|
||||||
return this.<Number> fromStatus("displayPasswordSerial")
|
return this.<Number> fromStatus(Status.DISPLAY_PASSWORD_SERIAL)
|
||||||
.map(Number::longValue);
|
.map(Number::longValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ metadata:
|
||||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||||
ownerReferences:
|
ownerReferences:
|
||||||
- apiVersion: ${ cr.apiVersion() }
|
- apiVersion: ${ cr.apiVersion() }
|
||||||
kind: ${ constants.VM_OP_KIND_VM }
|
kind: ${ constants.Crd.KIND_VM }
|
||||||
name: ${ cr.name() }
|
name: ${ cr.name() }
|
||||||
uid: ${ cr.metadata().getUid() }
|
uid: ${ cr.metadata().getUid() }
|
||||||
controller: false
|
controller: false
|
||||||
|
|
@ -201,6 +201,9 @@ data:
|
||||||
<#if spec.vm.display.outputs?? >
|
<#if spec.vm.display.outputs?? >
|
||||||
outputs: ${ spec.vm.display.outputs?c }
|
outputs: ${ spec.vm.display.outputs?c }
|
||||||
</#if>
|
</#if>
|
||||||
|
<#if spec.vm.display.loggedInUser?? >
|
||||||
|
loggedInUser: "${ spec.vm.display.loggedInUser }"
|
||||||
|
</#if>
|
||||||
<#if spec.vm.display.spice??>
|
<#if spec.vm.display.spice??>
|
||||||
spice:
|
spice:
|
||||||
port: ${ spec.vm.display.spice.port?c }
|
port: ${ spec.vm.display.spice.port?c }
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ metadata:
|
||||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||||
ownerReferences:
|
ownerReferences:
|
||||||
- apiVersion: ${ cr.apiVersion() }
|
- apiVersion: ${ cr.apiVersion() }
|
||||||
kind: ${ constants.VM_OP_KIND_VM }
|
kind: ${ constants.Crd.KIND_VM }
|
||||||
name: ${ cr.name() }
|
name: ${ cr.name() }
|
||||||
uid: ${ cr.metadata().getUid() }
|
uid: ${ cr.metadata().getUid() }
|
||||||
controller: false
|
controller: false
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ metadata:
|
||||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||||
ownerReferences:
|
ownerReferences:
|
||||||
- apiVersion: ${ cr.apiVersion() }
|
- apiVersion: ${ cr.apiVersion() }
|
||||||
kind: ${ constants.VM_OP_KIND_VM }
|
kind: ${ constants.Crd.KIND_VM }
|
||||||
name: ${ cr.name() }
|
name: ${ cr.name() }
|
||||||
uid: ${ cr.metadata().getUid() }
|
uid: ${ cr.metadata().getUid() }
|
||||||
blockOwnerDeletion: true
|
blockOwnerDeletion: true
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,6 @@ package org.jdrupes.vmoperator.manager;
|
||||||
@SuppressWarnings("PMD.DataClass")
|
@SuppressWarnings("PMD.DataClass")
|
||||||
public class Constants extends org.jdrupes.vmoperator.common.Constants {
|
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. */
|
/** The Constant STATE_RUNNING. */
|
||||||
public static final String STATE_RUNNING = "Running";
|
public static final String STATE_RUNNING = "Running";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||||
|
|
@ -194,7 +194,7 @@ public class Controller extends Component {
|
||||||
private void patchVmDef(K8sClient client, String name, String path,
|
private void patchVmDef(K8sClient client, String name, String path,
|
||||||
Object value) throws ApiException, IOException {
|
Object value) throws ApiException, IOException {
|
||||||
var vmStub = K8sDynamicStub.get(client,
|
var vmStub = K8sDynamicStub.get(client,
|
||||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace,
|
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace,
|
||||||
name);
|
name);
|
||||||
|
|
||||||
// Patch running
|
// Patch running
|
||||||
|
|
@ -227,11 +227,11 @@ public class Controller extends Component {
|
||||||
try {
|
try {
|
||||||
var vmDef = channel.vmDefinition();
|
var vmDef = channel.vmDefinition();
|
||||||
var vmStub = VmDefinitionStub.get(channel.client(),
|
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());
|
vmDef.namespace(), vmDef.name());
|
||||||
if (vmStub.updateStatus(vmDef, from -> {
|
if (vmStub.updateStatus(vmDef, from -> {
|
||||||
JsonObject status = from.statusJson();
|
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("pool", event.usedPool());
|
||||||
assignment.set("user", event.toUser());
|
assignment.set("user", event.toUser());
|
||||||
assignment.set("lastUsed", Instant.now().toString());
|
assignment.set("lastUsed", Instant.now().toString());
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* VM-Operator
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
|
@ -18,8 +18,6 @@
|
||||||
|
|
||||||
package org.jdrupes.vmoperator.manager;
|
package org.jdrupes.vmoperator.manager;
|
||||||
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import io.kubernetes.client.apimachinery.GroupVersionKind;
|
|
||||||
import io.kubernetes.client.custom.V1Patch;
|
import io.kubernetes.client.custom.V1Patch;
|
||||||
import io.kubernetes.client.openapi.ApiException;
|
import io.kubernetes.client.openapi.ApiException;
|
||||||
import io.kubernetes.client.openapi.models.V1Secret;
|
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.ListOptions;
|
||||||
import io.kubernetes.client.util.generic.options.PatchOptions;
|
import io.kubernetes.client.util.generic.options.PatchOptions;
|
||||||
import java.io.IOException;
|
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 java.util.logging.Level;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
|
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
import org.jdrupes.vmoperator.common.K8sV1PodStub;
|
import org.jdrupes.vmoperator.common.K8sV1PodStub;
|
||||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
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.ChannelDictionary;
|
||||||
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
|
|
||||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
|
||||||
import org.jgrapes.core.Channel;
|
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
|
* Watches for changes of display secrets. Updates an artifical attribute
|
||||||
* following configuration properties:
|
* 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.
|
||||||
* * `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" })
|
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
|
||||||
public class DisplaySecretMonitor
|
public class DisplaySecretMonitor
|
||||||
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
|
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
|
||||||
|
|
||||||
private int passwordValidity = 10;
|
|
||||||
private final List<PendingGet> pendingGets
|
|
||||||
= Collections.synchronizedList(new LinkedList<>());
|
|
||||||
private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
|
private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -89,31 +61,10 @@ public class DisplaySecretMonitor
|
||||||
context(K8sV1SecretStub.CONTEXT);
|
context(K8sV1SecretStub.CONTEXT);
|
||||||
ListOptions options = new ListOptions();
|
ListOptions options = new ListOptions();
|
||||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
|
+ "app.kubernetes.io/component=" + DisplaySecret.NAME);
|
||||||
options(options);
|
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
|
@Override
|
||||||
protected void prepareMonitoring() throws IOException, ApiException {
|
protected void prepareMonitoring() throws IOException, ApiException {
|
||||||
client(new K8sClient());
|
client(new K8sClient());
|
||||||
|
|
@ -168,147 +119,4 @@ public class DisplaySecretMonitor
|
||||||
+ "\"}]"),
|
+ "\"}]"),
|
||||||
patchOpts);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* VM-Operator
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
|
@ -18,7 +18,9 @@
|
||||||
|
|
||||||
package org.jdrupes.vmoperator.manager;
|
package org.jdrupes.vmoperator.manager;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
import freemarker.template.TemplateException;
|
import freemarker.template.TemplateException;
|
||||||
|
import io.kubernetes.client.apimachinery.GroupVersionKind;
|
||||||
import io.kubernetes.client.openapi.ApiException;
|
import io.kubernetes.client.openapi.ApiException;
|
||||||
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
||||||
import io.kubernetes.client.openapi.models.V1Secret;
|
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.io.IOException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
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.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Scanner;
|
||||||
import java.util.logging.Logger;
|
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 org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||||
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
|
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
|
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
|
||||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
|
|
||||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||||
import org.jdrupes.vmoperator.util.DataPath;
|
import org.jdrupes.vmoperator.util.DataPath;
|
||||||
|
import org.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;
|
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" })
|
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
|
||||||
/* default */ class DisplaySecretReconciler {
|
public class DisplaySecretReconciler extends Component {
|
||||||
|
|
||||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||||
|
private int passwordValidity = 10;
|
||||||
|
private 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
|
* Reconcile. If the configuration prevents generating a secret
|
||||||
|
|
@ -73,7 +141,7 @@ import org.jose4j.base64url.Base64;
|
||||||
var vmDef = event.vmDefinition();
|
var vmDef = event.vmDefinition();
|
||||||
ListOptions options = new ListOptions();
|
ListOptions options = new ListOptions();
|
||||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
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());
|
+ "app.kubernetes.io/instance=" + vmDef.name());
|
||||||
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
|
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
|
||||||
options);
|
options);
|
||||||
|
|
@ -84,9 +152,9 @@ import org.jose4j.base64url.Base64;
|
||||||
// Create secret
|
// Create secret
|
||||||
var secret = new V1Secret();
|
var secret = new V1Secret();
|
||||||
secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace())
|
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/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()));
|
.putLabelsItem("app.kubernetes.io/instance", vmDef.name()));
|
||||||
secret.setType("Opaque");
|
secret.setType("Opaque");
|
||||||
SecureRandom random = null;
|
SecureRandom random = null;
|
||||||
|
|
@ -99,9 +167,179 @@ import org.jose4j.base64url.Base64;
|
||||||
byte[] bytes = new byte[16];
|
byte[] bytes = new byte[16];
|
||||||
random.nextBytes(bytes);
|
random.nextBytes(bytes);
|
||||||
var password = Base64.encode(bytes);
|
var password = Base64.encode(bytes);
|
||||||
secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
|
secret.setStringData(Map.of(DisplaySecret.PASSWORD, password,
|
||||||
DATA_PASSWORD_EXPIRY, "now"));
|
DisplaySecret.EXPIRY, "now"));
|
||||||
K8sV1SecretStub.create(channel.client(), secret);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||||
import org.jdrupes.vmoperator.common.K8s;
|
import org.jdrupes.vmoperator.common.K8s;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
import org.jdrupes.vmoperator.common.K8sDynamicModel;
|
import org.jdrupes.vmoperator.common.K8sDynamicModel;
|
||||||
|
|
@ -38,7 +38,6 @@ import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
||||||
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
|
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||||
import org.jdrupes.vmoperator.common.VmPool;
|
import org.jdrupes.vmoperator.common.VmPool;
|
||||||
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL;
|
|
||||||
import org.jdrupes.vmoperator.manager.events.GetPools;
|
import org.jdrupes.vmoperator.manager.events.GetPools;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmPoolChanged;
|
import org.jdrupes.vmoperator.manager.events.VmPoolChanged;
|
||||||
|
|
@ -88,7 +87,7 @@ public class PoolMonitor extends
|
||||||
client(new K8sClient());
|
client(new K8sClient());
|
||||||
|
|
||||||
// Get all our API versions
|
// 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()) {
|
if (ctx.isEmpty()) {
|
||||||
logger.severe(() -> "Cannot get CRD context.");
|
logger.severe(() -> "Cannot get CRD context.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -184,12 +183,12 @@ public class PoolMonitor extends
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var vmStub = VmDefinitionStub.get(client(),
|
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());
|
vmDef.namespace(), vmDef.name());
|
||||||
vmStub.updateStatus(from -> {
|
vmStub.updateStatus(from -> {
|
||||||
// TODO
|
// TODO
|
||||||
JsonObject status = from.statusJson();
|
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());
|
assignment.set("lastUsed", ccChange.get().toString());
|
||||||
return status;
|
return status;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import freemarker.template.AdapterTemplateModel;
|
import freemarker.template.AdapterTemplateModel;
|
||||||
import freemarker.template.Configuration;
|
import freemarker.template.Configuration;
|
||||||
import freemarker.template.DefaultObjectWrapperBuilder;
|
|
||||||
import freemarker.template.SimpleNumber;
|
import freemarker.template.SimpleNumber;
|
||||||
import freemarker.template.SimpleScalar;
|
import freemarker.template.SimpleScalar;
|
||||||
import freemarker.template.TemplateException;
|
import freemarker.template.TemplateException;
|
||||||
import freemarker.template.TemplateExceptionHandler;
|
import freemarker.template.TemplateExceptionHandler;
|
||||||
import freemarker.template.TemplateHashModel;
|
|
||||||
import freemarker.template.TemplateMethodModelEx;
|
import freemarker.template.TemplateMethodModelEx;
|
||||||
import freemarker.template.TemplateModel;
|
import freemarker.template.TemplateModel;
|
||||||
import freemarker.template.TemplateModelException;
|
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.openapi.models.V1ObjectMeta;
|
||||||
import io.kubernetes.client.util.generic.options.ListOptions;
|
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||||
|
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||||
import org.jdrupes.vmoperator.common.Convertions;
|
import org.jdrupes.vmoperator.common.Convertions;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
import org.jdrupes.vmoperator.common.K8sObserver;
|
import org.jdrupes.vmoperator.common.K8sObserver;
|
||||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
|
||||||
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||||
|
|
@ -138,6 +138,8 @@ import org.jgrapes.util.events.ConfigurationUpdate;
|
||||||
* properties to be used by the runners managed by the controller.
|
* properties to be used by the runners managed by the controller.
|
||||||
* This property is a string that holds the content of
|
* This property is a string that holds the content of
|
||||||
* a logging.properties file.
|
* a logging.properties file.
|
||||||
|
*
|
||||||
|
* @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
|
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
|
||||||
"PMD.AvoidDuplicateLiterals" })
|
"PMD.AvoidDuplicateLiterals" })
|
||||||
|
|
@ -163,6 +165,7 @@ public class Reconciler extends Component {
|
||||||
*
|
*
|
||||||
* @param componentChannel the component channel
|
* @param componentChannel the component channel
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
|
||||||
public Reconciler(Channel componentChannel) {
|
public Reconciler(Channel componentChannel) {
|
||||||
super(componentChannel);
|
super(componentChannel);
|
||||||
|
|
||||||
|
|
@ -177,7 +180,7 @@ public class Reconciler extends Component {
|
||||||
fmConfig.setClassForTemplateLoading(Reconciler.class, "");
|
fmConfig.setClassForTemplateLoading(Reconciler.class, "");
|
||||||
|
|
||||||
cmReconciler = new ConfigMapReconciler(fmConfig);
|
cmReconciler = new ConfigMapReconciler(fmConfig);
|
||||||
dsReconciler = new DisplaySecretReconciler();
|
dsReconciler = attach(new DisplaySecretReconciler(componentChannel));
|
||||||
stsReconciler = new StatefulSetReconciler(fmConfig);
|
stsReconciler = new StatefulSetReconciler(fmConfig);
|
||||||
pvcReconciler = new PvcReconciler(fmConfig);
|
pvcReconciler = new PvcReconciler(fmConfig);
|
||||||
podReconciler = new PodReconciler(fmConfig);
|
podReconciler = new PodReconciler(fmConfig);
|
||||||
|
|
@ -263,17 +266,14 @@ public class Reconciler extends Component {
|
||||||
Optional.ofNullable(Reconciler.class.getPackage()
|
Optional.ofNullable(Reconciler.class.getPackage()
|
||||||
.getImplementationVersion()).orElse("(Unknown)"));
|
.getImplementationVersion()).orElse("(Unknown)"));
|
||||||
model.put("cr", vmDef);
|
model.put("cr", vmDef);
|
||||||
model.put("constants",
|
// Freemarker's static models don't handle nested classes.
|
||||||
(TemplateHashModel) new DefaultObjectWrapperBuilder(
|
model.put("constants", constantsMap(Constants.class));
|
||||||
Configuration.VERSION_2_3_32)
|
|
||||||
.build().getStaticModels()
|
|
||||||
.get(Constants.class.getName()));
|
|
||||||
model.put("reconciler", config);
|
model.put("reconciler", config);
|
||||||
|
|
||||||
// Check if we have a display secret
|
// Check if we have a display secret
|
||||||
ListOptions options = new ListOptions();
|
ListOptions options = new ListOptions();
|
||||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
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());
|
+ "app.kubernetes.io/instance=" + vmDef.name());
|
||||||
var dsStub = K8sV1SecretStub
|
var dsStub = K8sV1SecretStub
|
||||||
.list(client, vmDef.namespace(), options)
|
.list(client, vmDef.namespace(), options)
|
||||||
|
|
@ -294,6 +294,30 @@ public class Reconciler extends Component {
|
||||||
return model;
|
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
|
private final TemplateMethodModelEx parseQuantityModel
|
||||||
= new TemplateMethodModelEx() {
|
= new TemplateMethodModelEx() {
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,7 @@ import java.util.Set;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
|
||||||
import org.jdrupes.vmoperator.common.K8s;
|
import org.jdrupes.vmoperator.common.K8s;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
||||||
|
|
@ -87,7 +86,7 @@ public class VmMonitor extends
|
||||||
client(new K8sClient());
|
client(new K8sClient());
|
||||||
|
|
||||||
// Get all our API versions
|
// 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()) {
|
if (ctx.isEmpty()) {
|
||||||
logger.severe(() -> "Cannot get CRD context.");
|
logger.severe(() -> "Cannot get CRD context.");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ import java.util.Collection;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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.APP_NAME;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET;
|
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
|
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
|
||||||
import org.jdrupes.vmoperator.common.K8s;
|
import org.jdrupes.vmoperator.common.K8s;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
|
|
@ -60,7 +60,7 @@ class BasicTests {
|
||||||
waitForManager();
|
waitForManager();
|
||||||
|
|
||||||
// Context for working with our CR
|
// 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());
|
assertTrue(apiRes.isPresent());
|
||||||
vmsContext = apiRes.get();
|
vmsContext = apiRes.get();
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ class BasicTests {
|
||||||
ListOptions listOpts = new ListOptions();
|
ListOptions listOpts = new ListOptions();
|
||||||
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||||
+ "app.kubernetes.io/instance=" + VM_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);
|
var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts);
|
||||||
for (var secret : secrets) {
|
for (var secret : secrets) {
|
||||||
secret.delete();
|
secret.delete();
|
||||||
|
|
@ -138,12 +138,11 @@ class BasicTests {
|
||||||
List.of("name"), VM_NAME,
|
List.of("name"), VM_NAME,
|
||||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
|
||||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME,
|
||||||
Constants.VM_OP_NAME,
|
|
||||||
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
|
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
|
||||||
List.of("ownerReferences", 0, "apiVersion"),
|
List.of("ownerReferences", 0, "apiVersion"),
|
||||||
vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0),
|
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, "name"), VM_NAME,
|
||||||
List.of("ownerReferences", 0, "uid"), EXISTS);
|
List.of("ownerReferences", 0, "uid"), EXISTS);
|
||||||
checkProps(config.getMetadata(), toCheck);
|
checkProps(config.getMetadata(), toCheck);
|
||||||
|
|
@ -189,7 +188,7 @@ class BasicTests {
|
||||||
ListOptions listOpts = new ListOptions();
|
ListOptions listOpts = new ListOptions();
|
||||||
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||||
+ "app.kubernetes.io/instance=" + VM_NAME + ","
|
+ "app.kubernetes.io/instance=" + VM_NAME + ","
|
||||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
|
+ "app.kubernetes.io/component=" + DisplaySecret.NAME);
|
||||||
Collection<K8sV1SecretStub> secrets = null;
|
Collection<K8sV1SecretStub> secrets = null;
|
||||||
for (int i = 0; i < 10; i++) {
|
for (int i = 0; i < 10; i++) {
|
||||||
secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts);
|
secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts);
|
||||||
|
|
@ -219,8 +218,7 @@ class BasicTests {
|
||||||
checkProps(pvc.getMetadata(), Map.of(
|
checkProps(pvc.getMetadata(), Map.of(
|
||||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
|
||||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME));
|
||||||
Constants.VM_OP_NAME));
|
|
||||||
checkProps(pvc.getSpec(), Map.of(
|
checkProps(pvc.getSpec(), Map.of(
|
||||||
List.of("resources", "requests", "storage"),
|
List.of("resources", "requests", "storage"),
|
||||||
Quantity.fromString("1Mi")));
|
Quantity.fromString("1Mi")));
|
||||||
|
|
@ -240,8 +238,7 @@ class BasicTests {
|
||||||
checkProps(pvc.getMetadata(), Map.of(
|
checkProps(pvc.getMetadata(), Map.of(
|
||||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
|
||||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME,
|
||||||
Constants.VM_OP_NAME,
|
|
||||||
List.of("annotations", "use_as"), "system-disk"));
|
List.of("annotations", "use_as"), "system-disk"));
|
||||||
checkProps(pvc.getSpec(), Map.of(
|
checkProps(pvc.getSpec(), Map.of(
|
||||||
List.of("resources", "requests", "storage"),
|
List.of("resources", "requests", "storage"),
|
||||||
|
|
@ -262,8 +259,7 @@ class BasicTests {
|
||||||
checkProps(pvc.getMetadata(), Map.of(
|
checkProps(pvc.getMetadata(), Map.of(
|
||||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
|
||||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME));
|
||||||
Constants.VM_OP_NAME));
|
|
||||||
checkProps(pvc.getSpec(), Map.of(
|
checkProps(pvc.getSpec(), Map.of(
|
||||||
List.of("resources", "requests", "storage"),
|
List.of("resources", "requests", "storage"),
|
||||||
Quantity.fromString("1Gi")));
|
Quantity.fromString("1Gi")));
|
||||||
|
|
@ -290,13 +286,12 @@ class BasicTests {
|
||||||
List.of("labels", "app.kubernetes.io/name"), APP_NAME,
|
List.of("labels", "app.kubernetes.io/name"), APP_NAME,
|
||||||
List.of("labels", "app.kubernetes.io/instance"), VM_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/component"), APP_NAME,
|
||||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME,
|
||||||
Constants.VM_OP_NAME,
|
|
||||||
List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS,
|
List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS,
|
||||||
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
|
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
|
||||||
List.of("ownerReferences", 0, "apiVersion"),
|
List.of("ownerReferences", 0, "apiVersion"),
|
||||||
vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0),
|
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, "name"), VM_NAME,
|
||||||
List.of("ownerReferences", 0, "uid"), EXISTS));
|
List.of("ownerReferences", 0, "uid"), EXISTS));
|
||||||
checkProps(pod.getSpec(), Map.of(
|
checkProps(pod.getSpec(), Map.of(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,7 +39,7 @@ import org.jdrupes.vmoperator.util.FsdUtils;
|
||||||
/**
|
/**
|
||||||
* The configuration information from the configuration file.
|
* The configuration information from the configuration file.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("PMD.ExcessivePublicCount")
|
@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyFields" })
|
||||||
public class Configuration implements Dto {
|
public class Configuration implements Dto {
|
||||||
private static final String CI_INSTANCE_ID = "instance-id";
|
private static final String CI_INSTANCE_ID = "instance-id";
|
||||||
|
|
||||||
|
|
@ -67,9 +67,6 @@ public class Configuration implements Dto {
|
||||||
/** The monitor socket. */
|
/** The monitor socket. */
|
||||||
public Path monitorSocket;
|
public Path monitorSocket;
|
||||||
|
|
||||||
/** The guest agent socket socket. */
|
|
||||||
public Path guestAgentSocket;
|
|
||||||
|
|
||||||
/** The firmware rom. */
|
/** The firmware rom. */
|
||||||
public Path firmwareRom;
|
public Path firmwareRom;
|
||||||
|
|
||||||
|
|
@ -251,6 +248,9 @@ public class Configuration implements Dto {
|
||||||
/** The number of outputs. */
|
/** The number of outputs. */
|
||||||
public int outputs = 1;
|
public int outputs = 1;
|
||||||
|
|
||||||
|
/** The logged in user. */
|
||||||
|
public String loggedInUser;
|
||||||
|
|
||||||
/** The spice. */
|
/** The spice. */
|
||||||
public Spice spice;
|
public Spice spice;
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +344,6 @@ public class Configuration implements Dto {
|
||||||
runtimeDir.toFile().mkdir();
|
runtimeDir.toFile().mkdir();
|
||||||
swtpmSocket = runtimeDir.resolve("swtpm-sock");
|
swtpmSocket = runtimeDir.resolve("swtpm-sock");
|
||||||
monitorSocket = runtimeDir.resolve("monitor.sock");
|
monitorSocket = runtimeDir.resolve("monitor.sock");
|
||||||
guestAgentSocket = runtimeDir.resolve("org.qemu.guest_agent.0");
|
|
||||||
}
|
}
|
||||||
if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) {
|
if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) {
|
||||||
logger.severe(() -> String.format(
|
logger.severe(() -> String.format(
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import io.kubernetes.client.openapi.models.EventsV1Event;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||||
import org.jdrupes.vmoperator.common.K8s;
|
import org.jdrupes.vmoperator.common.K8s;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||||
|
|
@ -74,7 +74,7 @@ public class ConsoleTracker extends VmDefUpdater {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
vmStub = VmDefinitionStub.get(apiClient,
|
vmStub = VmDefinitionStub.get(apiClient,
|
||||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
|
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||||
namespace, vmName);
|
namespace, vmName);
|
||||||
} catch (ApiException e) {
|
} catch (ApiException e) {
|
||||||
logger.log(Level.SEVERE, e,
|
logger.log(Level.SEVERE, e,
|
||||||
|
|
@ -106,16 +106,15 @@ public class ConsoleTracker extends VmDefUpdater {
|
||||||
mainChannelClientHost = event.clientHost();
|
mainChannelClientHost = event.clientHost();
|
||||||
mainChannelClientPort = event.clientPort();
|
mainChannelClientPort = event.clientPort();
|
||||||
vmStub.updateStatus(from -> {
|
vmStub.updateStatus(from -> {
|
||||||
JsonObject status = from.statusJson();
|
JsonObject status = updateCondition(from, "ConsoleConnected", true,
|
||||||
status.addProperty("consoleClient", event.clientHost());
|
"Connected", "Connection from " + event.clientHost());
|
||||||
updateCondition(from, status, "ConsoleConnected", true, "Connected",
|
status.addProperty(Status.CONSOLE_CLIENT, event.clientHost());
|
||||||
"Connection from " + event.clientHost());
|
|
||||||
return status;
|
return status;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log event
|
// Log event
|
||||||
var evt = new EventsV1Event()
|
var evt = new EventsV1Event()
|
||||||
.reportingController(VM_OP_GROUP + "/" + APP_NAME)
|
.reportingController(Crd.GROUP + "/" + APP_NAME)
|
||||||
.action("ConsoleConnectionUpdate")
|
.action("ConsoleConnectionUpdate")
|
||||||
.reason("Connection from " + event.clientHost());
|
.reason("Connection from " + event.clientHost());
|
||||||
K8s.createEvent(apiClient, vmStub.model().get(), evt);
|
K8s.createEvent(apiClient, vmStub.model().get(), evt);
|
||||||
|
|
@ -141,16 +140,15 @@ public class ConsoleTracker extends VmDefUpdater {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
vmStub.updateStatus(from -> {
|
vmStub.updateStatus(from -> {
|
||||||
JsonObject status = from.statusJson();
|
JsonObject status = updateCondition(from, "ConsoleConnected", false,
|
||||||
status.addProperty("consoleClient", "");
|
|
||||||
updateCondition(from, status, "ConsoleConnected", false,
|
|
||||||
"Disconnected", event.clientHost() + " has disconnected");
|
"Disconnected", event.clientHost() + " has disconnected");
|
||||||
|
status.addProperty(Status.CONSOLE_CLIENT, "");
|
||||||
return status;
|
return status;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log event
|
// Log event
|
||||||
var evt = new EventsV1Event()
|
var evt = new EventsV1Event()
|
||||||
.reportingController(VM_OP_GROUP + "/" + APP_NAME)
|
.reportingController(Crd.GROUP + "/" + APP_NAME)
|
||||||
.action("ConsoleConnectionUpdate")
|
.action("ConsoleConnectionUpdate")
|
||||||
.reason("Disconnected from " + event.clientHost());
|
.reason("Disconnected from " + event.clientHost());
|
||||||
K8s.createEvent(apiClient, vmStub.model().get(), evt);
|
K8s.createEvent(apiClient, vmStub.model().get(), evt);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* VM-Operator
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
|
@ -22,14 +22,20 @@ import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.logging.Level;
|
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.QmpSetDisplayPassword;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry;
|
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
|
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
|
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
|
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.Channel;
|
||||||
import org.jgrapes.core.Component;
|
import org.jgrapes.core.Component;
|
||||||
|
import org.jgrapes.core.Event;
|
||||||
import org.jgrapes.core.annotation.Handler;
|
import org.jgrapes.core.annotation.Handler;
|
||||||
import org.jgrapes.util.events.FileChanged;
|
import org.jgrapes.util.events.FileChanged;
|
||||||
import org.jgrapes.util.events.WatchFile;
|
import org.jgrapes.util.events.WatchFile;
|
||||||
|
|
@ -40,11 +46,11 @@ import org.jgrapes.util.events.WatchFile;
|
||||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||||
public class DisplayController extends Component {
|
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 currentPassword;
|
||||||
private String protocol;
|
private String protocol;
|
||||||
private final Path configDir;
|
private final Path configDir;
|
||||||
|
private boolean vmopAgentConnected;
|
||||||
|
private String loggedInUser;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiates a new Display controller.
|
* Instantiates a new Display controller.
|
||||||
|
|
@ -57,7 +63,7 @@ public class DisplayController extends Component {
|
||||||
public DisplayController(Channel componentChannel, Path configDir) {
|
public DisplayController(Channel componentChannel, Path configDir) {
|
||||||
super(componentChannel);
|
super(componentChannel);
|
||||||
this.configDir = configDir;
|
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
|
protocol
|
||||||
= event.configuration().vm.display.spice != null ? "spice" : null;
|
= 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
|
@Handler
|
||||||
@SuppressWarnings("PMD.EmptyCatchBlock")
|
@SuppressWarnings("PMD.EmptyCatchBlock")
|
||||||
public void onFileChanged(FileChanged event) {
|
public void onFileChanged(FileChanged event) {
|
||||||
if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) {
|
if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) {
|
||||||
updatePassword();
|
configurePassword();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
private void configurePassword() {
|
||||||
private void updatePassword() {
|
|
||||||
if (protocol == null) {
|
if (protocol == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -99,47 +129,41 @@ public class DisplayController extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean setDisplayPassword() {
|
private boolean setDisplayPassword() {
|
||||||
String password;
|
return readFromFile(DisplaySecret.PASSWORD).map(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;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.finer(() -> "No display password");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Objects.equals(this.currentPassword, password)) {
|
if (Objects.equals(this.currentPassword, password)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.currentPassword = password;
|
this.currentPassword = password;
|
||||||
logger.fine(() -> "Updating display password");
|
logger.fine(() -> "Updating display password");
|
||||||
fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password)));
|
fire(new MonitorCommand(
|
||||||
|
new QmpSetDisplayPassword(protocol, password)));
|
||||||
return true;
|
return true;
|
||||||
|
}).orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setPasswordExpiry() {
|
private void setPasswordExpiry() {
|
||||||
Path pePath = configDir.resolve(PASSWORD_EXPIRY_FILE);
|
readFromFile(DisplaySecret.EXPIRY).ifPresent(expiry -> {
|
||||||
if (!pePath.toFile().canRead()) {
|
logger.fine(() -> "Updating expiry time to " + expiry);
|
||||||
return;
|
fire(
|
||||||
}
|
new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry)));
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,8 @@
|
||||||
package org.jdrupes.vmoperator.runner.qemu;
|
package org.jdrupes.vmoperator.runner.qemu;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import java.io.IOException;
|
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.LinkedList;
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
import java.util.logging.Level;
|
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.commands.QmpGuestGetOsinfo;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand;
|
import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent;
|
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.Channel;
|
||||||
import org.jgrapes.core.Component;
|
|
||||||
import org.jgrapes.core.EventPipeline;
|
|
||||||
import org.jgrapes.core.annotation.Handler;
|
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
|
* A component that handles the communication with the guest agent.
|
||||||
* socket.
|
|
||||||
*
|
*
|
||||||
* If the log level for this class is set to fine, the messages
|
* If the log level for this class is set to fine, the messages
|
||||||
* exchanged on the monitor socket are logged.
|
* exchanged on the monitor socket are logged.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
public class GuestAgentClient extends AgentConnector {
|
||||||
public class GuestAgentClient extends Component {
|
|
||||||
|
|
||||||
private static ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
private EventPipeline rep;
|
|
||||||
private Path socketPath;
|
|
||||||
private SocketIOChannel gaChannel;
|
|
||||||
private final Queue<QmpCommand> executing = new LinkedList<>();
|
private final Queue<QmpCommand> executing = new LinkedList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,126 +47,36 @@ public class GuestAgentClient extends Component {
|
||||||
* @param componentChannel the component channel
|
* @param componentChannel the component channel
|
||||||
* @throws IOException Signals that an I/O exception has occurred.
|
* @throws IOException Signals that an I/O exception has occurred.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings({ "PMD.AssignmentToNonFinalStatic",
|
|
||||||
"PMD.ConstructorCallsOverridableMethod" })
|
|
||||||
public GuestAgentClient(Channel componentChannel) throws IOException {
|
public GuestAgentClient(Channel componentChannel) throws IOException {
|
||||||
super(componentChannel);
|
super(componentChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* As the initial configuration of this component depends on the
|
* When the agent has connected, request the OS information.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
/* default */ void configure(Path socketPath) {
|
@Override
|
||||||
this.socketPath = socketPath;
|
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.
|
* @throws IOException Signals that an I/O exception has occurred.
|
||||||
*/
|
*/
|
||||||
@Handler
|
@Override
|
||||||
public void onStart(Start event) throws IOException {
|
protected void processInput(String line) 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 {
|
|
||||||
logger.fine(() -> "guest agent(in): " + line);
|
logger.fine(() -> "guest agent(in): " + line);
|
||||||
try {
|
try {
|
||||||
var response = mapper.readValue(line, ObjectNode.class);
|
var response = mapper.readValue(line, ObjectNode.class);
|
||||||
if (response.has("return") || response.has("error")) {
|
if (response.has("return") || response.has("error")) {
|
||||||
QmpCommand executed = executing.poll();
|
QmpCommand executed = executing.poll();
|
||||||
logger.fine(
|
logger.fine(() -> String.format("(Previous \"guest agent(in)\""
|
||||||
() -> String.format("(Previous \"guest agent(in)\" is "
|
+ " is result from executing %s)", executed));
|
||||||
+ "result from executing %s)", executed));
|
|
||||||
if (executed instanceof QmpGuestGetOsinfo) {
|
if (executed instanceof QmpGuestGetOsinfo) {
|
||||||
rep.fire(new OsinfoEvent(response.get("return")));
|
var osInfo = new OsinfoEvent(response.get("return"));
|
||||||
|
rep().fire(osInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (JsonProcessingException e) {
|
} 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.
|
* On guest agent command.
|
||||||
*
|
*
|
||||||
* @param event the event
|
* @param event the event
|
||||||
|
* @throws IOException
|
||||||
*/
|
*/
|
||||||
@Handler
|
@Handler
|
||||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||||
"PMD.AvoidSynchronizedStatement" })
|
public void onGuestAgentCommand(GuestAgentCommand event)
|
||||||
public void onGuestAgentCommand(GuestAgentCommand event) {
|
throws IOException {
|
||||||
|
if (qemuChannel() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var command = event.command();
|
var command = event.command();
|
||||||
logger.fine(() -> "guest agent(out): " + command.toString());
|
logger.fine(() -> "guest agent(out): " + command.toString());
|
||||||
String asText;
|
String asText;
|
||||||
|
|
@ -235,15 +108,10 @@ public class GuestAgentClient extends Component {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
synchronized (executing) {
|
synchronized (executing) {
|
||||||
gaChannel.associated(Writer.class).ifPresent(writer -> {
|
if (writer().isPresent()) {
|
||||||
try {
|
|
||||||
executing.add(command);
|
executing.add(command);
|
||||||
writer.append(asText).append('\n').flush();
|
sendCommand(asText);
|
||||||
} catch (IOException e) {
|
|
||||||
// Cannot happen, but...
|
|
||||||
logger.log(Level.WARNING, e, e::getMessage);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,13 +19,8 @@
|
||||||
package org.jdrupes.vmoperator.runner.qemu;
|
package org.jdrupes.vmoperator.runner.qemu;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import java.io.IOException;
|
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.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
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.MonitorResult;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent;
|
import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent;
|
||||||
import org.jgrapes.core.Channel;
|
import org.jgrapes.core.Channel;
|
||||||
import org.jgrapes.core.Component;
|
|
||||||
import org.jgrapes.core.Components;
|
import org.jgrapes.core.Components;
|
||||||
import org.jgrapes.core.Components.Timer;
|
import org.jgrapes.core.Components.Timer;
|
||||||
import org.jgrapes.core.EventPipeline;
|
|
||||||
import org.jgrapes.core.annotation.Handler;
|
import org.jgrapes.core.annotation.Handler;
|
||||||
import org.jgrapes.core.events.Start;
|
|
||||||
import org.jgrapes.core.events.Stop;
|
import org.jgrapes.core.events.Stop;
|
||||||
import org.jgrapes.io.events.Closed;
|
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.SocketIOChannel;
|
||||||
import org.jgrapes.net.events.ClientConnected;
|
|
||||||
import org.jgrapes.util.events.ConfigurationUpdate;
|
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
|
* 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.
|
* exchanged on the monitor socket are logged.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
@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 int powerdownTimeout;
|
||||||
private SocketIOChannel monitorChannel;
|
|
||||||
private final Queue<QmpCommand> executing = new LinkedList<>();
|
private final Queue<QmpCommand> executing = new LinkedList<>();
|
||||||
private Instant powerdownStartedAt;
|
private Instant powerdownStartedAt;
|
||||||
private Stop suspendedStop;
|
private Stop suspendedStop;
|
||||||
|
|
@ -84,7 +63,7 @@ public class QemuMonitor extends Component {
|
||||||
private boolean powerdownConfirmed;
|
private boolean powerdownConfirmed;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiates a new qemu monitor.
|
* Instantiates a new QEMU monitor.
|
||||||
*
|
*
|
||||||
* @param componentChannel the component channel
|
* @param componentChannel the component channel
|
||||||
* @param configDir the config dir
|
* @param configDir the config dir
|
||||||
|
|
@ -111,109 +90,26 @@ public class QemuMonitor extends Component {
|
||||||
* @param powerdownTimeout
|
* @param powerdownTimeout
|
||||||
*/
|
*/
|
||||||
/* default */ void configure(Path socketPath, int powerdownTimeout) {
|
/* default */ void configure(Path socketPath, int powerdownTimeout) {
|
||||||
this.socketPath = socketPath;
|
super.configure(socketPath);
|
||||||
this.powerdownTimeout = powerdownTimeout;
|
this.powerdownTimeout = powerdownTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the start event.
|
* When the socket is connected, send the capabilities command.
|
||||||
*
|
|
||||||
* @param event the event
|
|
||||||
* @throws IOException Signals that an I/O exception has occurred.
|
|
||||||
*/
|
*/
|
||||||
@Handler
|
@Override
|
||||||
public void onStart(Start event) throws IOException {
|
protected void socketConnected() {
|
||||||
rep = event.associated(EventPipeline.class).get();
|
|
||||||
if (socketPath == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Files.deleteIfExists(socketPath);
|
|
||||||
fire(new WatchFile(socketPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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()));
|
fire(new MonitorCommand(new QmpCapabilities()));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Override
|
||||||
* Called when a connection attempt fails.
|
protected void processInput(String line)
|
||||||
*
|
|
||||||
* @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)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
logger.fine(() -> "monitor(in): " + line);
|
logger.fine(() -> "monitor(in): " + line);
|
||||||
try {
|
try {
|
||||||
var response = mapper.readValue(line, ObjectNode.class);
|
var response = mapper.readValue(line, ObjectNode.class);
|
||||||
if (response.has("QMP")) {
|
if (response.has("QMP")) {
|
||||||
rep.fire(new MonitorReady());
|
rep().fire(new MonitorReady());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.has("return") || response.has("error")) {
|
if (response.has("return") || response.has("error")) {
|
||||||
|
|
@ -221,11 +117,11 @@ public class QemuMonitor extends Component {
|
||||||
logger.fine(
|
logger.fine(
|
||||||
() -> String.format("(Previous \"monitor(in)\" is result "
|
() -> String.format("(Previous \"monitor(in)\" is result "
|
||||||
+ "from executing %s)", executed));
|
+ "from executing %s)", executed));
|
||||||
rep.fire(MonitorResult.from(executed, response));
|
rep().fire(MonitorResult.from(executed, response));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.has("event")) {
|
if (response.has("event")) {
|
||||||
MonitorEvent.from(response).ifPresent(rep::fire);
|
MonitorEvent.from(response).ifPresent(rep()::fire);
|
||||||
}
|
}
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
throw new IOException(e);
|
throw new IOException(e);
|
||||||
|
|
@ -241,8 +137,8 @@ public class QemuMonitor extends Component {
|
||||||
@SuppressWarnings({ "PMD.AvoidSynchronizedStatement",
|
@SuppressWarnings({ "PMD.AvoidSynchronizedStatement",
|
||||||
"PMD.AvoidDuplicateLiterals" })
|
"PMD.AvoidDuplicateLiterals" })
|
||||||
public void onClosed(Closed<?> event, SocketIOChannel channel) {
|
public void onClosed(Closed<?> event, SocketIOChannel channel) {
|
||||||
|
super.onClosed(event, channel);
|
||||||
channel.associated(QemuMonitor.class).ifPresent(qm -> {
|
channel.associated(QemuMonitor.class).ifPresent(qm -> {
|
||||||
monitorChannel = null;
|
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (powerdownTimer != null) {
|
if (powerdownTimer != null) {
|
||||||
powerdownTimer.cancel();
|
powerdownTimer.cancel();
|
||||||
|
|
@ -259,11 +155,12 @@ public class QemuMonitor extends Component {
|
||||||
* On monitor command.
|
* On monitor command.
|
||||||
*
|
*
|
||||||
* @param event the event
|
* @param event the event
|
||||||
|
* @throws IOException
|
||||||
*/
|
*/
|
||||||
@Handler
|
@Handler
|
||||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||||
"PMD.AvoidSynchronizedStatement" })
|
"PMD.AvoidSynchronizedStatement" })
|
||||||
public void onExecQmpCommand(MonitorCommand event) {
|
public void onExecQmpCommand(MonitorCommand event) throws IOException {
|
||||||
var command = event.command();
|
var command = event.command();
|
||||||
logger.fine(() -> "monitor(out): " + command.toString());
|
logger.fine(() -> "monitor(out): " + command.toString());
|
||||||
String asText;
|
String asText;
|
||||||
|
|
@ -275,15 +172,10 @@ public class QemuMonitor extends Component {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
synchronized (executing) {
|
synchronized (executing) {
|
||||||
monitorChannel.associated(Writer.class).ifPresent(writer -> {
|
if (writer().isPresent()) {
|
||||||
try {
|
|
||||||
executing.add(command);
|
executing.add(command);
|
||||||
writer.append(asText).append('\n').flush();
|
sendCommand(asText);
|
||||||
} catch (IOException e) {
|
|
||||||
// Cannot happen, but...
|
|
||||||
logger.log(Level.WARNING, e, e::getMessage);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,7 +187,7 @@ public class QemuMonitor extends Component {
|
||||||
@Handler(priority = 100)
|
@Handler(priority = 100)
|
||||||
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||||
public void onStop(Stop event) {
|
public void onStop(Stop event) {
|
||||||
if (monitorChannel != null) {
|
if (qemuChannel() != null) {
|
||||||
// We have a connection to Qemu, attempt ACPI shutdown.
|
// We have a connection to Qemu, attempt ACPI shutdown.
|
||||||
event.suspendHandling();
|
event.suspendHandling();
|
||||||
suspendedStop = event;
|
suspendedStop = event;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import org.apache.commons.cli.DefaultParser;
|
||||||
import org.apache.commons.cli.Option;
|
import org.apache.commons.cli.Option;
|
||||||
import org.apache.commons.cli.Options;
|
import org.apache.commons.cli.Options;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||||
|
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont;
|
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset;
|
import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
|
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
|
||||||
|
|
@ -220,6 +221,7 @@ public class Runner extends Component {
|
||||||
private CommandDefinition qemuDefinition;
|
private CommandDefinition qemuDefinition;
|
||||||
private final QemuMonitor qemuMonitor;
|
private final QemuMonitor qemuMonitor;
|
||||||
private final GuestAgentClient guestAgentClient;
|
private final GuestAgentClient guestAgentClient;
|
||||||
|
private final VmopAgentClient vmopAgentClient;
|
||||||
private Integer resetCounter;
|
private Integer resetCounter;
|
||||||
private RunState state = RunState.INITIALIZING;
|
private RunState state = RunState.INITIALIZING;
|
||||||
|
|
||||||
|
|
@ -278,6 +280,7 @@ public class Runner extends Component {
|
||||||
attach(new SocketConnector(channel()));
|
attach(new SocketConnector(channel()));
|
||||||
attach(qemuMonitor = new QemuMonitor(channel(), configDir));
|
attach(qemuMonitor = new QemuMonitor(channel(), configDir));
|
||||||
attach(guestAgentClient = new GuestAgentClient(channel()));
|
attach(guestAgentClient = new GuestAgentClient(channel()));
|
||||||
|
attach(vmopAgentClient = new VmopAgentClient(channel()));
|
||||||
attach(new StatusUpdater(channel()));
|
attach(new StatusUpdater(channel()));
|
||||||
attach(new YamlConfigurationStore(channel(), configFile, false));
|
attach(new YamlConfigurationStore(channel(), configFile, false));
|
||||||
fire(new WatchFile(configFile.toPath()));
|
fire(new WatchFile(configFile.toPath()));
|
||||||
|
|
@ -309,8 +312,7 @@ public class Runner extends Component {
|
||||||
|
|
||||||
// Add some values from other sources to configuration
|
// Add some values from other sources to configuration
|
||||||
newConf.asOf = Instant.ofEpochSecond(configFile.lastModified());
|
newConf.asOf = Instant.ofEpochSecond(configFile.lastModified());
|
||||||
Path dsPath
|
Path dsPath = configDir.resolve(DisplaySecret.PASSWORD);
|
||||||
= configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE);
|
|
||||||
newConf.hasDisplayPassword = dsPath.toFile().canRead();
|
newConf.hasDisplayPassword = dsPath.toFile().canRead();
|
||||||
|
|
||||||
// Special actions for initial configuration (startup)
|
// Special actions for initial configuration (startup)
|
||||||
|
|
@ -352,7 +354,8 @@ public class Runner extends Component {
|
||||||
// Forward some values to child components
|
// Forward some values to child components
|
||||||
qemuMonitor.configure(config.monitorSocket,
|
qemuMonitor.configure(config.monitorSocket,
|
||||||
config.vm.powerdownTimeout);
|
config.vm.powerdownTimeout);
|
||||||
guestAgentClient.configure(config.guestAgentSocket);
|
configureAgentClient(guestAgentClient, "guest-agent-socket");
|
||||||
|
configureAgentClient(vmopAgentClient, "vmop-agent-socket");
|
||||||
} catch (IllegalArgumentException | IOException | TemplateException e) {
|
} catch (IllegalArgumentException | IOException | TemplateException e) {
|
||||||
logger.log(Level.SEVERE, e, () -> "Invalid configuration: "
|
logger.log(Level.SEVERE, e, () -> "Invalid configuration: "
|
||||||
+ e.getMessage());
|
+ 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.
|
* Handle the started event.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||||
import org.jdrupes.vmoperator.common.K8s;
|
import org.jdrupes.vmoperator.common.K8s;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||||
|
|
@ -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;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
|
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
|
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.jdrupes.vmoperator.util.GsonPtr;
|
||||||
import org.jgrapes.core.Channel;
|
import org.jgrapes.core.Channel;
|
||||||
import org.jgrapes.core.annotation.Handler;
|
import org.jgrapes.core.annotation.Handler;
|
||||||
|
|
@ -109,10 +112,17 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
vmStub = VmDefinitionStub.get(apiClient,
|
vmStub = VmDefinitionStub.get(apiClient,
|
||||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
|
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||||
namespace, vmName);
|
namespace, vmName);
|
||||||
vmStub.model().ifPresent(model -> {
|
var vmDef = vmStub.model().orElse(null);
|
||||||
observedGeneration = model.getMetadata().getGeneration();
|
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) {
|
} catch (ApiException e) {
|
||||||
logger.log(Level.SEVERE, 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
|
// 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
|
// a new version of the CR. There's one exception: the display
|
||||||
// password is configured by a file, not by the CR.
|
// password is configured by a file, not by the CR.
|
||||||
var vmDef = vmStub.model();
|
var vmDef = vmStub.model().orElse(null);
|
||||||
if (vmDef.isPresent()
|
if (vmDef == null) {
|
||||||
&& vmDef.get().metadata().getGeneration() == observedGeneration
|
|
||||||
&& (event.configuration().hasDisplayPassword
|
|
||||||
|| vmDef.get().statusJson().getAsJsonPrimitive(
|
|
||||||
"displayPasswordSerial").getAsInt() == -1)) {
|
|
||||||
return;
|
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();
|
JsonObject status = from.statusJson();
|
||||||
if (!event.configuration().hasDisplayPassword) {
|
if (!event.configuration().hasDisplayPassword) {
|
||||||
status.addProperty("displayPasswordSerial", -1);
|
status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1);
|
||||||
}
|
}
|
||||||
status.getAsJsonArray("conditions").asList().stream()
|
status.getAsJsonArray("conditions").asList().stream()
|
||||||
.map(cond -> (JsonObject) cond).filter(cond -> "Running"
|
.map(cond -> (JsonObject) cond).filter(cond -> "Running"
|
||||||
|
|
@ -162,7 +174,7 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
.forEach(cond -> cond.addProperty("observedGeneration",
|
.forEach(cond -> cond.addProperty("observedGeneration",
|
||||||
from.getMetadata().getGeneration()));
|
from.getMetadata().getGeneration()));
|
||||||
return status;
|
return status;
|
||||||
});
|
}, vmDef);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -172,43 +184,44 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
* @throws ApiException
|
* @throws ApiException
|
||||||
*/
|
*/
|
||||||
@Handler
|
@Handler
|
||||||
@SuppressWarnings({ "PMD.AssignmentInOperand",
|
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||||
"PMD.AvoidLiteralsInIfCondition" })
|
"PMD.AssignmentInOperand", "PMD.AvoidDuplicateLiterals" })
|
||||||
public void onRunnerStateChanged(RunnerStateChange event)
|
public void onRunnerStateChanged(RunnerStateChange event)
|
||||||
throws ApiException {
|
throws ApiException {
|
||||||
VmDefinition vmDef;
|
VmDefinition vmDef;
|
||||||
if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
|
if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
vmStub.updateStatus(vmDef, from -> {
|
vmStub.updateStatus(from -> {
|
||||||
JsonObject status = from.statusJson();
|
|
||||||
boolean running = event.runState().vmRunning();
|
boolean running = event.runState().vmRunning();
|
||||||
updateCondition(vmDef, vmDef.statusJson(), "Running", running,
|
updateCondition(vmDef, "Running", running, event.reason(),
|
||||||
event.reason(), event.message());
|
event.message());
|
||||||
updateCondition(vmDef, vmDef.statusJson(), "Booted",
|
JsonObject status = updateCondition(vmDef, "Booted",
|
||||||
event.runState() == RunState.BOOTED, event.reason(),
|
event.runState() == RunState.BOOTED, event.reason(),
|
||||||
event.message());
|
event.message());
|
||||||
if (event.runState() == RunState.STARTING) {
|
if (event.runState() == RunState.STARTING) {
|
||||||
status.addProperty("ram", GsonPtr.to(from.data())
|
status.addProperty(Status.RAM, GsonPtr.to(from.data())
|
||||||
.getAsString("spec", "vm", "maximumRam").orElse("0"));
|
.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
|
// In case we had an irregular shutdown
|
||||||
status.remove("osinfo");
|
status.remove(Status.OSINFO);
|
||||||
} else if (event.runState() == RunState.STOPPED) {
|
updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped",
|
||||||
status.addProperty("ram", "0");
|
"The VM is not running");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
});
|
}, vmDef);
|
||||||
|
|
||||||
// Maybe stop VM
|
// Maybe stop VM
|
||||||
if (event.runState() == RunState.TERMINATING && !event.failed()
|
if (event.runState() == RunState.TERMINATING && !event.failed()
|
||||||
|
|
@ -226,7 +239,7 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
|
|
||||||
// Log event
|
// Log event
|
||||||
var evt = new EventsV1Event()
|
var evt = new EventsV1Event()
|
||||||
.reportingController(VM_OP_GROUP + "/" + APP_NAME)
|
.reportingController(Crd.GROUP + "/" + APP_NAME)
|
||||||
.action("StatusUpdate").reason(event.reason())
|
.action("StatusUpdate").reason(event.reason())
|
||||||
.note(event.message());
|
.note(event.message());
|
||||||
K8s.createEvent(apiClient, vmDef, evt);
|
K8s.createEvent(apiClient, vmDef, evt);
|
||||||
|
|
@ -245,7 +258,7 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
}
|
}
|
||||||
vmStub.updateStatus(from -> {
|
vmStub.updateStatus(from -> {
|
||||||
JsonObject status = from.statusJson();
|
JsonObject status = from.statusJson();
|
||||||
status.addProperty("ram",
|
status.addProperty(Status.RAM,
|
||||||
new Quantity(new BigDecimal(event.size()), Format.BINARY_SI)
|
new Quantity(new BigDecimal(event.size()), Format.BINARY_SI)
|
||||||
.toSuffixedString());
|
.toSuffixedString());
|
||||||
return status;
|
return status;
|
||||||
|
|
@ -265,7 +278,7 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
}
|
}
|
||||||
vmStub.updateStatus(from -> {
|
vmStub.updateStatus(from -> {
|
||||||
JsonObject status = from.statusJson();
|
JsonObject status = from.statusJson();
|
||||||
status.addProperty("cpus", event.usedCpus().size());
|
status.addProperty(Status.CPUS, event.usedCpus().size());
|
||||||
return status;
|
return status;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -284,8 +297,8 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
}
|
}
|
||||||
vmStub.updateStatus(from -> {
|
vmStub.updateStatus(from -> {
|
||||||
JsonObject status = from.statusJson();
|
JsonObject status = from.statusJson();
|
||||||
status.addProperty("displayPasswordSerial",
|
status.addProperty(Status.DISPLAY_PASSWORD_SERIAL,
|
||||||
status.get("displayPasswordSerial").getAsLong() + 1);
|
status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1);
|
||||||
return status;
|
return status;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -314,12 +327,60 @@ public class StatusUpdater extends VmDefUpdater {
|
||||||
}
|
}
|
||||||
var asGson = gson.toJsonTree(
|
var asGson = gson.toJsonTree(
|
||||||
objectMapper.convertValue(event.osinfo(), Object.class));
|
objectMapper.convertValue(event.osinfo(), Object.class));
|
||||||
|
|
||||||
vmStub.updateStatus(from -> {
|
vmStub.updateStatus(from -> {
|
||||||
JsonObject status = from.statusJson();
|
JsonObject status = from.statusJson();
|
||||||
status.add("osinfo", asGson);
|
status.add(Status.OSINFO, asGson);
|
||||||
return status;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import java.util.Optional;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import org.jdrupes.vmoperator.common.K8sClient;
|
import org.jdrupes.vmoperator.common.K8sClient;
|
||||||
|
import org.jdrupes.vmoperator.common.K8sGenericStub;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||||
import org.jdrupes.vmoperator.runner.qemu.events.Exit;
|
import org.jdrupes.vmoperator.runner.qemu.events.Exit;
|
||||||
import org.jgrapes.core.Channel;
|
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 from the VM definition
|
||||||
* @param status the current status
|
|
||||||
* @param type the condition type
|
* @param type the condition type
|
||||||
* @param state the new state
|
* @param state the new state
|
||||||
* @param reason the reason for the change
|
* @param reason the reason for the change
|
||||||
* @param message the message
|
* @param message the message
|
||||||
|
* @return the updated status
|
||||||
*/
|
*/
|
||||||
protected void updateCondition(VmDefinition from, JsonObject status,
|
protected JsonObject updateCondition(VmDefinition from, String type,
|
||||||
String type, boolean state, String reason, String message) {
|
boolean state, String reason, String message) {
|
||||||
|
JsonObject status = from.statusJson();
|
||||||
// Optimize, as we can get this several times
|
// Optimize, as we can get this several times
|
||||||
var current = status.getAsJsonArray("conditions").asList().stream()
|
var current = status.getAsJsonArray("conditions").asList().stream()
|
||||||
.map(cond -> (JsonObject) cond)
|
.map(cond -> (JsonObject) cond)
|
||||||
|
|
@ -127,7 +132,7 @@ public class VmDefUpdater extends Component {
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.map(cond -> "True".equals(cond.get("status").getAsString()));
|
.map(cond -> "True".equals(cond.get("status").getAsString()));
|
||||||
if (current.isPresent() && current.get() == state) {
|
if (current.isPresent() && current.get() == state) {
|
||||||
return;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do update
|
// Do update
|
||||||
|
|
@ -150,5 +155,6 @@ public class VmDefUpdater extends Component {
|
||||||
newConds.addAll(toReplace);
|
newConds.addAll(toReplace);
|
||||||
status.add("conditions",
|
status.add("conditions",
|
||||||
apiClient.getJSON().getGson().toJsonTree(newConds));
|
apiClient.getJSON().getGson().toJsonTree(newConds));
|
||||||
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -122,11 +122,16 @@
|
||||||
# Best explanation found:
|
# Best explanation found:
|
||||||
# https://fedoraproject.org/wiki/Features/VirtioSerial
|
# https://fedoraproject.org/wiki/Features/VirtioSerial
|
||||||
- [ "-device", "virtio-serial-pci,id=virtio-serial0" ]
|
- [ "-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,\
|
- [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\
|
||||||
chardev=guest-agent-socket" ]
|
chardev=guest-agent-socket" ]
|
||||||
- [ "-chardev","socket,id=guest-agent-socket,\
|
- [ "-chardev","socket,id=guest-agent-socket,\
|
||||||
path=${ runtimeDir }/org.qemu.guest_agent.0,server=on,wait=off" ]
|
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)
|
# * USB Hub and devices (more in SPICE configuration below)
|
||||||
# https://qemu-project.gitlab.io/qemu/system/devices/usb.html
|
# https://qemu-project.gitlab.io/qemu/system/devices/usb.html
|
||||||
# https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c
|
# https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ debian.svg:
|
||||||
Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg
|
Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg
|
||||||
License : LGPL
|
License : LGPL
|
||||||
|
|
||||||
|
fedora.svg:
|
||||||
|
Source: https://commons.wikimedia.org/wiki/File:Fedora_icon_(2021).svg
|
||||||
|
License: Public Domain
|
||||||
|
|
||||||
tux.svg:
|
tux.svg:
|
||||||
Source: https://commons.wikimedia.org/wiki/File:Tux.svghttps://commons.wikimedia.org/wiki/File: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.
|
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 |
|
|
@ -49,11 +49,11 @@ import org.jdrupes.vmoperator.common.VmDefinition;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
|
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
|
||||||
import org.jdrupes.vmoperator.common.VmPool;
|
import org.jdrupes.vmoperator.common.VmPool;
|
||||||
import org.jdrupes.vmoperator.manager.events.AssignVm;
|
import org.jdrupes.vmoperator.manager.events.AssignVm;
|
||||||
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
|
|
||||||
import org.jdrupes.vmoperator.manager.events.GetPools;
|
import org.jdrupes.vmoperator.manager.events.GetPools;
|
||||||
import org.jdrupes.vmoperator.manager.events.GetVms;
|
import org.jdrupes.vmoperator.manager.events.GetVms;
|
||||||
import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
|
import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
|
||||||
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
||||||
|
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
|
||||||
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||||
|
|
@ -808,18 +808,23 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
|
||||||
Map.of("autoClose", 5_000, "type", "Warning")));
|
Map.of("autoClose", 5_000, "type", "Warning")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
|
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user,
|
||||||
e -> {
|
model.mode() == ResourceModel.Mode.POOL),
|
||||||
vmDef.extra()
|
e -> gotPassword(channel, model, vmDef, e));
|
||||||
.map(xtra -> xtra.connectionFile(e.password().orElse(null),
|
|
||||||
preferredIpVersion, deleteConnectionFile))
|
|
||||||
.ifPresent(
|
|
||||||
cf -> channel.respond(new NotifyConletView(type(),
|
|
||||||
model.getConletId(), "openConsole", cf)));
|
|
||||||
});
|
|
||||||
fire(pwQuery, vmChannel);
|
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",
|
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||||
"PMD.UseLocaleWithCaseConversions" })
|
"PMD.UseLocaleWithCaseConversions" })
|
||||||
private void selectResource(NotifyConletModel event,
|
private void selectResource(NotifyConletModel event,
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,9 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
||||||
const configured = computed(() => previewApi.vmDefinition.spec);
|
const configured = computed(() => previewApi.vmDefinition.spec);
|
||||||
const busy = computed(() => previewApi.vmDefinition.spec
|
const busy = computed(() => previewApi.vmDefinition.spec
|
||||||
&& (previewApi.vmDefinition.spec.vm.state === 'Running'
|
&& (previewApi.vmDefinition.spec.vm.state === 'Running'
|
||||||
&& !previewApi.vmDefinition.running
|
&& (previewApi.poolName
|
||||||
|
? !previewApi.vmDefinition.vmopAgent
|
||||||
|
: !previewApi.vmDefinition.running)
|
||||||
|| previewApi.vmDefinition.spec.vm.state === 'Stopped'
|
|| previewApi.vmDefinition.spec.vm.state === 'Stopped'
|
||||||
&& previewApi.vmDefinition.running));
|
&& previewApi.vmDefinition.running));
|
||||||
const startable = computed(() => previewApi.vmDefinition.spec
|
const startable = computed(() => previewApi.vmDefinition.spec
|
||||||
|
|
@ -85,6 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
||||||
previewApi.vmDefinition.spec.vm.state !== 'Stopped'
|
previewApi.vmDefinition.spec.vm.state !== 'Stopped'
|
||||||
&& previewApi.vmDefinition.running);
|
&& previewApi.vmDefinition.running);
|
||||||
const running = computed(() => previewApi.vmDefinition.running);
|
const running = computed(() => previewApi.vmDefinition.running);
|
||||||
|
const vmopAgent = computed(() => previewApi.vmDefinition.vmopAgent);
|
||||||
const inUse = computed(() => previewApi.vmDefinition.usedBy != '');
|
const inUse = computed(() => previewApi.vmDefinition.usedBy != '');
|
||||||
const permissions = computed(() => previewApi.permissions);
|
const permissions = computed(() => previewApi.permissions);
|
||||||
const osicon = computed(() => {
|
const osicon = computed(() => {
|
||||||
|
|
@ -120,8 +123,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { localize, resourceBase, vmAction, poolName, vmName,
|
return { localize, resourceBase, vmAction, poolName, vmName,
|
||||||
configured, busy, startable, stoppable, running, inUse,
|
configured, busy, startable, stoppable, running, vmopAgent,
|
||||||
permissions, osicon };
|
inUse, permissions, osicon };
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="2" style="position: relative"><span
|
<td rowspan="2" style="position: relative"><span
|
||||||
style="position: absolute;" :class="{ busy: busy }"
|
style="position: absolute;" :class="{ busy: busy }"
|
||||||
><img role=button :aria-disabled="!running
|
><img role=button :aria-disabled="(poolName
|
||||||
|
? !vmopAgent : !running)
|
||||||
|| !permissions.includes('accessConsole')"
|
|| !permissions.includes('accessConsole')"
|
||||||
v-on:click="vmAction('openConsole')"
|
v-on:click="vmAction('openConsole')"
|
||||||
:src="resourceBase + (running
|
:src="resourceBase + (running
|
||||||
|
|
@ -206,14 +210,17 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
|
||||||
vmDefinition.currentCpus = vmDefinition.status.cpus;
|
vmDefinition.currentCpus = vmDefinition.status.cpus;
|
||||||
vmDefinition.currentRam = Number(vmDefinition.status.ram);
|
vmDefinition.currentRam = Number(vmDefinition.status.ram);
|
||||||
vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
|
vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
|
||||||
for (const condition of vmDefinition.status.conditions) {
|
vmDefinition.status.conditions.forEach((condition: any) => {
|
||||||
if (condition.type === "Running") {
|
if (condition.type === "Running") {
|
||||||
vmDefinition.running = condition.status === "True";
|
vmDefinition.running = condition.status === "True";
|
||||||
vmDefinition.runningConditionSince
|
vmDefinition.runningConditionSince
|
||||||
= new Date(condition.lastTransitionTime);
|
= new Date(condition.lastTransitionTime);
|
||||||
break;
|
} else if (condition.type === "VmopAgentConnected") {
|
||||||
}
|
vmDefinition.vmopAgent = condition.status === "True";
|
||||||
|
vmDefinition.vmopAgentConditionSince
|
||||||
|
= new Date(condition.lastTransitionTime);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
vmDefinition = {};
|
vmDefinition = {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,14 @@ import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.ResourceBundle;
|
import java.util.ResourceBundle;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||||
import org.jdrupes.vmoperator.common.K8sObserver;
|
import org.jdrupes.vmoperator.common.K8sObserver;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||||
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
|
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
|
||||||
import org.jdrupes.vmoperator.common.VmExtraData;
|
import org.jdrupes.vmoperator.common.VmExtraData;
|
||||||
import org.jdrupes.vmoperator.manager.events.ChannelTracker;
|
import org.jdrupes.vmoperator.manager.events.ChannelTracker;
|
||||||
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
|
|
||||||
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
||||||
|
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
|
||||||
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||||
|
|
@ -243,8 +244,8 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
|
||||||
DataPath.<String> get(vmSpec, "currentRam").orElse("0")).getNumber()
|
DataPath.<String> get(vmSpec, "currentRam").orElse("0")).getNumber()
|
||||||
.toBigInteger());
|
.toBigInteger());
|
||||||
var status = DataPath.deepCopy(vmDef.status());
|
var status = DataPath.deepCopy(vmDef.status());
|
||||||
status.put("ram", Quantity.fromString(
|
status.put(Status.RAM, Quantity.fromString(
|
||||||
DataPath.<String> get(status, "ram").orElse("0")).getNumber()
|
DataPath.<String> get(status, Status.RAM).orElse("0")).getNumber()
|
||||||
.toBigInteger());
|
.toBigInteger());
|
||||||
|
|
||||||
// Build result
|
// Build result
|
||||||
|
|
@ -383,10 +384,10 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
|
||||||
Summary summary = new Summary();
|
Summary summary = new Summary();
|
||||||
for (var vmDef : channelTracker.associated()) {
|
for (var vmDef : channelTracker.associated()) {
|
||||||
summary.totalVms += 1;
|
summary.totalVms += 1;
|
||||||
summary.usedCpus += vmDef.<Number> fromStatus("cpus")
|
summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS)
|
||||||
.map(Number::intValue).orElse(0);
|
.map(Number::intValue).orElse(0);
|
||||||
summary.usedRam = summary.usedRam
|
summary.usedRam = summary.usedRam
|
||||||
.add(vmDef.<String> fromStatus("ram")
|
.add(vmDef.<String> fromStatus(Status.RAM)
|
||||||
.map(r -> Quantity.fromString(r).getNumber().toBigInteger())
|
.map(r -> Quantity.fromString(r).getNumber().toBigInteger())
|
||||||
.orElse(BigInteger.ZERO));
|
.orElse(BigInteger.ZERO));
|
||||||
if (vmDef.conditionStatus("Running").orElse(false)) {
|
if (vmDef.conditionStatus("Running").orElse(false)) {
|
||||||
|
|
@ -483,15 +484,20 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
|
||||||
Map.of("autoClose", 5_000, "type", "Warning")));
|
Map.of("autoClose", 5_000, "type", "Warning")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
|
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user),
|
||||||
e -> {
|
e -> gotPassword(channel, model, vmDef, e));
|
||||||
vmDef.extra().map(xtra -> xtra.connectionFile(
|
fire(pwQuery, vmChannel);
|
||||||
e.password().orElse(null), preferredIpVersion,
|
}
|
||||||
deleteConnectionFile)).ifPresent(
|
|
||||||
|
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(),
|
cf -> channel.respond(new NotifyConletView(type(),
|
||||||
model.getConletId(), "openConsole", cf)));
|
model.getConletId(), "openConsole", cf)));
|
||||||
});
|
|
||||||
fire(pwQuery, vmChannel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
73
webpages/vm-operator/pools.md
Normal file
73
webpages/vm-operator/pools.md
Normal 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
|
||||||
|
```
|
||||||
|
|
@ -9,16 +9,31 @@ layout: vm-operator
|
||||||
|
|
||||||
## To version 4.0.0
|
## To version 4.0.0
|
||||||
|
|
||||||
The VmViewer conlet has been renamed to VmAccess. This affects the
|
* The VmViewer conlet has been renamed to VmAccess. This affects the
|
||||||
[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration information using the old path
|
[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration
|
||||||
"/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer"
|
information using the old path
|
||||||
is still accepted for backward compatibility, but should be updated.
|
`/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
|
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.
|
users to "disappear" from the GUI. They have to be re-added.
|
||||||
|
|
||||||
The latter behavior also applies to the VmConlet conlet which has been
|
The latter behavior also applies to the VmConlet conlet which has been
|
||||||
renamed to VmMgmt.
|
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
|
## To version 3.4.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
10 seconds only. This may be challenging on a slower computer
|
||||||
or if users may not enable automatic open for connection files
|
or if users may not enable automatic open for connection files
|
||||||
in the browser. The validity can therefore be adjusted in the
|
in the browser. The validity can therefore be adjusted in the
|
||||||
configuration.
|
configuration.[^oldPath]
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
"/Manager":
|
"/Manager":
|
||||||
"/Controller":
|
"/Controller":
|
||||||
"/DisplaySecretMonitor":
|
"/Reconciler":
|
||||||
|
"/DisplaySecretReconciler":
|
||||||
# Validity of generated password in seconds
|
# Validity of generated password in seconds
|
||||||
passwordValidity: 10
|
passwordValidity: 10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
[^oldPath]: Before version 4.0, the path for `passwordValidity` was
|
||||||
|
`/Manager/Controller/DisplaySecretMonitor`.
|
||||||
|
|
||||||
Taking into account that the controller generates a display
|
Taking into account that the controller generates a display
|
||||||
secret automatically by default, this approach to securing
|
secret automatically by default, this approach to securing
|
||||||
console access should be sufficient in all cases. (Any feedback
|
console access should be sufficient in all cases. (Any feedback
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue