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 {
|
||||
id 'org.ajoberstar.grgit' version '5.2.0' apply false
|
||||
id 'org.ajoberstar.grgit' version '5.2.0'
|
||||
id 'org.ajoberstar.git-publish' version '4.2.0' apply false
|
||||
id 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false
|
||||
id 'org.jdrupes.vmoperator.versioning-conventions'
|
||||
|
|
@ -28,7 +28,9 @@ task stage {
|
|||
tc -> tc.findByName("build") }.flatten()
|
||||
}
|
||||
|
||||
if (JavaVersion.current() == JavaVersion.VERSION_21) {
|
||||
def gitBranch = grgit.branch.current.name.replace('/', '-')
|
||||
if (JavaVersion.current() == JavaVersion.VERSION_21
|
||||
&& gitBranch == "main") {
|
||||
// Publish JavaDoc
|
||||
dependsOn gitPublishPush
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1430,6 +1430,12 @@ spec:
|
|||
outputs:
|
||||
type: integer
|
||||
default: 1
|
||||
loggedInUser:
|
||||
description: >-
|
||||
The name of a user that should be automatically
|
||||
logged in on the display. Note that this requires
|
||||
support from an agent in the guest OS.
|
||||
type: string
|
||||
spice:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -1485,6 +1491,11 @@ spec:
|
|||
connection.
|
||||
type: string
|
||||
default: ""
|
||||
loggedInUser:
|
||||
description: >-
|
||||
The name of a user that is currently logged in by the
|
||||
VM operator agent.
|
||||
type: string
|
||||
displayPasswordSerial:
|
||||
description: >-
|
||||
Counts changes of the display password. Set to -1
|
||||
|
|
|
|||
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;
|
||||
|
||||
// TODO: Auto-generated Javadoc
|
||||
/**
|
||||
* Some constants.
|
||||
*/
|
||||
|
|
@ -27,18 +28,67 @@ public class Constants {
|
|||
/** The Constant APP_NAME. */
|
||||
public static final String APP_NAME = "vm-runner";
|
||||
|
||||
/** The Constant COMP_DISPLAY_SECRETS. */
|
||||
public static final String COMP_DISPLAY_SECRET = "display-secret";
|
||||
|
||||
/** The Constant VM_OP_NAME. */
|
||||
public static final String VM_OP_NAME = "vm-operator";
|
||||
|
||||
/** The Constant VM_OP_GROUP. */
|
||||
public static final String VM_OP_GROUP = "vmoperator.jdrupes.org";
|
||||
/**
|
||||
* Constants related to the CRD.
|
||||
*/
|
||||
@SuppressWarnings("PMD.ShortClassName")
|
||||
public static class Crd {
|
||||
/** The Constant GROUP. */
|
||||
public static final String GROUP = "vmoperator.jdrupes.org";
|
||||
|
||||
/** The Constant VM_OP_KIND_VM. */
|
||||
public static final String VM_OP_KIND_VM = "VirtualMachine";
|
||||
/** The Constant KIND_VM. */
|
||||
public static final String KIND_VM = "VirtualMachine";
|
||||
|
||||
/** The Constant VM_OP_KIND_VM_POOL. */
|
||||
public static final String VM_OP_KIND_VM_POOL = "VmPool";
|
||||
/** The Constant KIND_VM_POOL. */
|
||||
public static final String KIND_VM_POOL = "VmPool";
|
||||
}
|
||||
|
||||
/**
|
||||
* Status related constants.
|
||||
*/
|
||||
public static class Status {
|
||||
/** The Constant CPUS. */
|
||||
public static final String CPUS = "cpus";
|
||||
|
||||
/** The Constant RAM. */
|
||||
public static final String RAM = "ram";
|
||||
|
||||
/** The Constant OSINFO. */
|
||||
public static final String OSINFO = "osinfo";
|
||||
|
||||
/** The Constant DISPLAY_PASSWORD_SERIAL. */
|
||||
public static final String DISPLAY_PASSWORD_SERIAL
|
||||
= "displayPasswordSerial";
|
||||
|
||||
/** The Constant LOGGED_IN_USER. */
|
||||
public static final String LOGGED_IN_USER = "loggedInUser";
|
||||
|
||||
/** The Constant CONSOLE_CLIENT. */
|
||||
public static final String CONSOLE_CLIENT = "consoleClient";
|
||||
|
||||
/** The Constant CONSOLE_USER. */
|
||||
public static final String CONSOLE_USER = "consoleUser";
|
||||
|
||||
/** The Constant ASSIGNMENT. */
|
||||
public static final String ASSIGNMENT = "assignment";
|
||||
}
|
||||
|
||||
/**
|
||||
* DisplaySecret related constants.
|
||||
*/
|
||||
public static class DisplaySecret {
|
||||
|
||||
/** The Constant NAME. */
|
||||
public static final String NAME = "display-secret";
|
||||
|
||||
/** The Constant PASSWORD. */
|
||||
public static final String PASSWORD = "display-password";
|
||||
|
||||
/** The Constant EXPIRY. */
|
||||
public static final String EXPIRY = "password-expiry";
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,54 +193,94 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates the object's status.
|
||||
* Updates the object's status. Does not retry in case of conflict.
|
||||
*
|
||||
* @param object the current state of the object (passed to `status`)
|
||||
* @param status function that returns the new status
|
||||
* @param updater function that returns the new status
|
||||
* @return the updated model or empty if the object was not found
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings("PMD.AssignmentInOperand")
|
||||
public Optional<O> updateStatus(O object, Function<O, Object> status)
|
||||
public Optional<O> updateStatus(O object, Function<O, Object> updater)
|
||||
throws ApiException {
|
||||
return K8s.optional(api.updateStatus(object, status));
|
||||
return K8s.optional(api.updateStatus(object, updater));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of the given object. In case of conflict,
|
||||
* get the current version of the object and tries again. Retries
|
||||
* up to `retries` times.
|
||||
*
|
||||
* @param updater the function updating the status
|
||||
* @param current the current state of the object, used for the first
|
||||
* attempt to update
|
||||
* @param retries the retries in case of conflict
|
||||
* @return the updated model or empty if the object was not found
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" })
|
||||
public Optional<O> updateStatus(Function<O, Object> updater, O current,
|
||||
int retries) throws ApiException {
|
||||
while (true) {
|
||||
try {
|
||||
if (current == null) {
|
||||
current = api.get(namespace, name)
|
||||
.throwsApiException().getObject();
|
||||
}
|
||||
return updateStatus(current, updater);
|
||||
} catch (ApiException e) {
|
||||
if (HttpURLConnection.HTTP_CONFLICT != e.getCode()
|
||||
|| retries-- <= 0) {
|
||||
throw e;
|
||||
}
|
||||
// Get current version for new attempt
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the object and updates the status. In case of conflict, retries
|
||||
* up to `retries` times.
|
||||
*
|
||||
* @param status the status
|
||||
* @param updater the function updating the status
|
||||
* @param retries the retries in case of conflict
|
||||
* @return the updated model or empty if the object was not found
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.UnusedAssignment" })
|
||||
public Optional<O> updateStatus(Function<O, Object> status, int retries)
|
||||
public Optional<O> updateStatus(Function<O, Object> updater, int retries)
|
||||
throws ApiException {
|
||||
try {
|
||||
return updateStatus(api.get(namespace, name).throwsApiException()
|
||||
.getObject(), status);
|
||||
} catch (ApiException e) {
|
||||
if (HttpURLConnection.HTTP_CONFLICT != e.getCode()
|
||||
|| retries-- <= 0) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
return updateStatus(updater, null, retries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status.
|
||||
* Updates the status of the given object. In case of conflict,
|
||||
* get the current version of the object and tries again. Retries
|
||||
* up to `retries` times.
|
||||
*
|
||||
* @param status the status
|
||||
* @param updater the function updating the status
|
||||
* @param current the current
|
||||
* @return the kubernetes api response
|
||||
* the updated model or empty if not successful
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
public Optional<O> updateStatus(Function<O, Object> status)
|
||||
public Optional<O> updateStatus(Function<O, Object> updater, O current)
|
||||
throws ApiException {
|
||||
return updateStatus(status, 16);
|
||||
return updateStatus(updater, current, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status. In case of conflict, retries up to 16 times.
|
||||
*
|
||||
* @param updater the function updating the status
|
||||
* @return the kubernetes api response
|
||||
* the updated model or empty if not successful
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
public Optional<O> updateStatus(Function<O, Object> updater)
|
||||
throws ApiException {
|
||||
return updateStatus(updater, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import java.util.Set;
|
|||
import java.util.function.Function;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.util.DataPath;
|
||||
|
||||
/**
|
||||
|
|
@ -219,7 +220,7 @@ public class VmDefinition extends K8sDynamicModel {
|
|||
* @return the optional
|
||||
*/
|
||||
public Optional<String> assignedFrom() {
|
||||
return fromStatus("assignment", "pool");
|
||||
return fromStatus(Status.ASSIGNMENT, "pool");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -228,7 +229,7 @@ public class VmDefinition extends K8sDynamicModel {
|
|||
* @return the optional
|
||||
*/
|
||||
public Optional<String> assignedTo() {
|
||||
return fromStatus("assignment", "user");
|
||||
return fromStatus(Status.ASSIGNMENT, "user");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -237,7 +238,7 @@ public class VmDefinition extends K8sDynamicModel {
|
|||
* @return the optional
|
||||
*/
|
||||
public Optional<Instant> assignmentLastUsed() {
|
||||
return this.<String> fromStatus("assignment", "lastUsed")
|
||||
return this.<String> fromStatus(Status.ASSIGNMENT, "lastUsed")
|
||||
.map(Instant::parse);
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +287,7 @@ public class VmDefinition extends K8sDynamicModel {
|
|||
* @return the optional
|
||||
*/
|
||||
public Optional<String> consoleUser() {
|
||||
return this.<String> fromStatus("consoleUser");
|
||||
return this.<String> fromStatus(Status.CONSOLE_USER);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -388,7 +389,7 @@ public class VmDefinition extends K8sDynamicModel {
|
|||
* @return the optional
|
||||
*/
|
||||
public Optional<Long> displayPasswordSerial() {
|
||||
return this.<Number> fromStatus("displayPasswordSerial")
|
||||
return this.<Number> fromStatus(Status.DISPLAY_PASSWORD_SERIAL)
|
||||
.map(Number::longValue);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
ownerReferences:
|
||||
- apiVersion: ${ cr.apiVersion() }
|
||||
kind: ${ constants.VM_OP_KIND_VM }
|
||||
kind: ${ constants.Crd.KIND_VM }
|
||||
name: ${ cr.name() }
|
||||
uid: ${ cr.metadata().getUid() }
|
||||
controller: false
|
||||
|
|
@ -201,6 +201,9 @@ data:
|
|||
<#if spec.vm.display.outputs?? >
|
||||
outputs: ${ spec.vm.display.outputs?c }
|
||||
</#if>
|
||||
<#if spec.vm.display.loggedInUser?? >
|
||||
loggedInUser: "${ spec.vm.display.loggedInUser }"
|
||||
</#if>
|
||||
<#if spec.vm.display.spice??>
|
||||
spice:
|
||||
port: ${ spec.vm.display.spice.port?c }
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ metadata:
|
|||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||
ownerReferences:
|
||||
- apiVersion: ${ cr.apiVersion() }
|
||||
kind: ${ constants.VM_OP_KIND_VM }
|
||||
kind: ${ constants.Crd.KIND_VM }
|
||||
name: ${ cr.name() }
|
||||
uid: ${ cr.metadata().getUid() }
|
||||
controller: false
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ metadata:
|
|||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||
ownerReferences:
|
||||
- apiVersion: ${ cr.apiVersion() }
|
||||
kind: ${ constants.VM_OP_KIND_VM }
|
||||
kind: ${ constants.Crd.KIND_VM }
|
||||
name: ${ cr.name() }
|
||||
uid: ${ cr.metadata().getUid() }
|
||||
blockOwnerDeletion: true
|
||||
|
|
|
|||
|
|
@ -24,15 +24,6 @@ package org.jdrupes.vmoperator.manager;
|
|||
@SuppressWarnings("PMD.DataClass")
|
||||
public class Constants extends org.jdrupes.vmoperator.common.Constants {
|
||||
|
||||
/** The Constant COMP_DISPLAY_SECRET. */
|
||||
public static final String COMP_DISPLAY_SECRET = "display-secret";
|
||||
|
||||
/** The Constant DATA_DISPLAY_PASSWORD. */
|
||||
public static final String DATA_DISPLAY_PASSWORD = "display-password";
|
||||
|
||||
/** The Constant DATA_PASSWORD_EXPIRY. */
|
||||
public static final String DATA_PASSWORD_EXPIRY = "password-expiry";
|
||||
|
||||
/** The Constant STATE_RUNNING. */
|
||||
public static final String STATE_RUNNING = "Running";
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ import java.nio.file.Files;
|
|||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.logging.Level;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
|
|
@ -194,7 +194,7 @@ public class Controller extends Component {
|
|||
private void patchVmDef(K8sClient client, String name, String path,
|
||||
Object value) throws ApiException, IOException {
|
||||
var vmStub = K8sDynamicStub.get(client,
|
||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace,
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace,
|
||||
name);
|
||||
|
||||
// Patch running
|
||||
|
|
@ -227,11 +227,11 @@ public class Controller extends Component {
|
|||
try {
|
||||
var vmDef = channel.vmDefinition();
|
||||
var vmStub = VmDefinitionStub.get(channel.client(),
|
||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||
vmDef.namespace(), vmDef.name());
|
||||
if (vmStub.updateStatus(vmDef, from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
var assignment = GsonPtr.to(status).to("assignment");
|
||||
var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT);
|
||||
assignment.set("pool", event.usedPool());
|
||||
assignment.set("user", event.toUser());
|
||||
assignment.set("lastUsed", Instant.now().toString());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2024 Michael N. Lipp
|
||||
* Copyright (C) 2025 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
|
|
@ -18,8 +18,6 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import io.kubernetes.client.apimachinery.GroupVersionKind;
|
||||
import io.kubernetes.client.custom.V1Patch;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.openapi.models.V1Secret;
|
||||
|
|
@ -28,52 +26,26 @@ import io.kubernetes.client.util.Watch.Response;
|
|||
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||
import io.kubernetes.client.util.generic.options.PatchOptions;
|
||||
import java.io.IOException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Scanner;
|
||||
import java.util.logging.Level;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sV1PodStub;
|
||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
|
||||
import org.jdrupes.vmoperator.manager.events.ChannelDictionary;
|
||||
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.CompletionLock;
|
||||
import org.jgrapes.core.Event;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.util.events.ConfigurationUpdate;
|
||||
import org.jose4j.base64url.Base64;
|
||||
|
||||
/**
|
||||
* Watches for changes of display secrets. The component supports the
|
||||
* following configuration properties:
|
||||
*
|
||||
* * `passwordValidity`: the validity of the random password in seconds.
|
||||
* Used to calculate the password expiry time in the generated secret.
|
||||
* Watches for changes of display secrets. Updates an artifical attribute
|
||||
* of the pod running the VM in response to force an update of the files
|
||||
* in the pod that reflect the information from the secret.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
|
||||
public class DisplaySecretMonitor
|
||||
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
|
||||
|
||||
private int passwordValidity = 10;
|
||||
private final List<PendingGet> pendingGets
|
||||
= Collections.synchronizedList(new LinkedList<>());
|
||||
private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
|
||||
|
||||
/**
|
||||
|
|
@ -89,31 +61,10 @@ public class DisplaySecretMonitor
|
|||
context(K8sV1SecretStub.CONTEXT);
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME);
|
||||
options(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* On configuration update.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
@Override
|
||||
public void onConfigurationUpdate(ConfigurationUpdate event) {
|
||||
super.onConfigurationUpdate(event);
|
||||
event.structured(componentPath()).ifPresent(c -> {
|
||||
try {
|
||||
if (c.containsKey("passwordValidity")) {
|
||||
passwordValidity = Integer
|
||||
.parseInt((String) c.get("passwordValidity"));
|
||||
}
|
||||
} catch (ClassCastException e) {
|
||||
logger.config("Malformed configuration: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareMonitoring() throws IOException, ApiException {
|
||||
client(new K8sClient());
|
||||
|
|
@ -168,147 +119,4 @@ public class DisplaySecretMonitor
|
|||
+ "\"}]"),
|
||||
patchOpts);
|
||||
}
|
||||
|
||||
/**
|
||||
* On get display secrets.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.StringInstantiation")
|
||||
public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel)
|
||||
throws ApiException {
|
||||
// Update console user in status
|
||||
var vmStub = VmDefinitionStub.get(client(),
|
||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
|
||||
event.vmDefinition().namespace(), event.vmDefinition().name());
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty("consoleUser", event.user());
|
||||
return status;
|
||||
});
|
||||
|
||||
// Look for secret
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
|
||||
+ "app.kubernetes.io/instance="
|
||||
+ event.vmDefinition().metadata().getName());
|
||||
var stubs = K8sV1SecretStub.list(client(),
|
||||
event.vmDefinition().namespace(), options);
|
||||
if (stubs.isEmpty()) {
|
||||
// No secret means no password for this VM wanted
|
||||
return;
|
||||
}
|
||||
var stub = stubs.iterator().next();
|
||||
|
||||
// Check validity
|
||||
var model = stub.model().get();
|
||||
@SuppressWarnings("PMD.StringInstantiation")
|
||||
var expiry = Optional.ofNullable(model.getData()
|
||||
.get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null);
|
||||
if (model.getData().get(DATA_DISPLAY_PASSWORD) != null
|
||||
&& stillValid(expiry)) {
|
||||
// Fixed secret, don't touch
|
||||
event.setResult(
|
||||
new String(model.getData().get(DATA_DISPLAY_PASSWORD)));
|
||||
return;
|
||||
}
|
||||
updatePassword(stub, event);
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.StringInstantiation")
|
||||
private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event)
|
||||
throws ApiException {
|
||||
SecureRandom random = null;
|
||||
try {
|
||||
random = SecureRandom.getInstanceStrong();
|
||||
} catch (NoSuchAlgorithmException e) { // NOPMD
|
||||
// "Every implementation of the Java platform is required
|
||||
// to support at least one strong SecureRandom implementation."
|
||||
}
|
||||
byte[] bytes = new byte[16];
|
||||
random.nextBytes(bytes);
|
||||
var password = Base64.encode(bytes);
|
||||
var model = stub.model().get();
|
||||
model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
|
||||
DATA_PASSWORD_EXPIRY,
|
||||
Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
|
||||
event.setResult(password);
|
||||
|
||||
// Prepare wait for confirmation (by VM status change)
|
||||
var pending = new PendingGet(event,
|
||||
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
|
||||
new CompletionLock(event, 1500));
|
||||
pendingGets.add(pending);
|
||||
Event.onCompletion(event, e -> {
|
||||
pendingGets.remove(pending);
|
||||
});
|
||||
|
||||
// Update, will (eventually) trigger confirmation
|
||||
stub.update(model).getObject();
|
||||
}
|
||||
|
||||
private boolean stillValid(String expiry) {
|
||||
if (expiry == null || "never".equals(expiry)) {
|
||||
return true;
|
||||
}
|
||||
@SuppressWarnings({ "PMD.CloseResource", "resource" })
|
||||
var scanner = new Scanner(expiry);
|
||||
if (!scanner.hasNextLong()) {
|
||||
return false;
|
||||
}
|
||||
long expTime = scanner.nextLong();
|
||||
return expTime > Instant.now().getEpochSecond() + passwordValidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* On vm def changed.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||
public void onVmDefChanged(VmDefChanged event, Channel channel) {
|
||||
synchronized (pendingGets) {
|
||||
String vmName = event.vmDefinition().name();
|
||||
for (var pending : pendingGets) {
|
||||
if (pending.event.vmDefinition().name().equals(vmName)
|
||||
&& event.vmDefinition().displayPasswordSerial()
|
||||
.map(s -> s >= pending.expectedSerial).orElse(false)) {
|
||||
pending.lock.remove();
|
||||
// pending will be removed from pendingGest by
|
||||
// waiting thread, see updatePassword
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Class PendingGet.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
private static class PendingGet {
|
||||
public final GetDisplayPassword event;
|
||||
public final long expectedSerial;
|
||||
public final CompletionLock lock;
|
||||
|
||||
/**
|
||||
* Instantiates a new pending get.
|
||||
*
|
||||
* @param event the event
|
||||
* @param expectedSerial the expected serial
|
||||
*/
|
||||
public PendingGet(GetDisplayPassword event, long expectedSerial,
|
||||
CompletionLock lock) {
|
||||
super();
|
||||
this.event = event;
|
||||
this.expectedSerial = expectedSerial;
|
||||
this.lock = lock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2023 Michael N. Lipp
|
||||
* Copyright (C) 2025 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
|
|
@ -18,7 +18,9 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import freemarker.template.TemplateException;
|
||||
import io.kubernetes.client.apimachinery.GroupVersionKind;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
||||
import io.kubernetes.client.openapi.models.V1Secret;
|
||||
|
|
@ -26,25 +28,91 @@ import io.kubernetes.client.util.generic.options.ListOptions;
|
|||
import java.io.IOException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Scanner;
|
||||
import java.util.logging.Logger;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.util.DataPath;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.CompletionLock;
|
||||
import org.jgrapes.core.Component;
|
||||
import org.jgrapes.core.Event;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.util.events.ConfigurationUpdate;
|
||||
import org.jose4j.base64url.Base64;
|
||||
|
||||
/**
|
||||
* Delegee for reconciling the display secret
|
||||
* The properties of the display secret do not only depend on the
|
||||
* VM definition, but also on events that occur during runtime.
|
||||
* The reconciler for the display secret is therefore a separate
|
||||
* component.
|
||||
*
|
||||
* The reconciler supports the following configuration properties:
|
||||
*
|
||||
* * `passwordValidity`: the validity of the random password in seconds.
|
||||
* Used to calculate the password expiry time in the generated secret.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
|
||||
/* default */ class DisplaySecretReconciler {
|
||||
public class DisplaySecretReconciler extends Component {
|
||||
|
||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||
private int passwordValidity = 10;
|
||||
private final List<PendingPrepare> pendingPrepares
|
||||
= Collections.synchronizedList(new LinkedList<>());
|
||||
|
||||
/**
|
||||
* Instantiates a new display secret reconciler.
|
||||
*
|
||||
* @param componentChannel the component channel
|
||||
*/
|
||||
public DisplaySecretReconciler(Channel componentChannel) {
|
||||
super(componentChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* On configuration update.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
public void onConfigurationUpdate(ConfigurationUpdate event) {
|
||||
event.structured(componentPath())
|
||||
// for backward compatibility
|
||||
.or(() -> {
|
||||
var oldConfig = event
|
||||
.structured("/Manager/Controller/DisplaySecretMonitor");
|
||||
if (oldConfig.isPresent()) {
|
||||
logger.warning(() -> "Using configuration with old "
|
||||
+ "path '/Manager/Controller/DisplaySecretMonitor' "
|
||||
+ "for `passwordValidity`, please update "
|
||||
+ "the configuration.");
|
||||
}
|
||||
return oldConfig;
|
||||
}).ifPresent(c -> {
|
||||
try {
|
||||
if (c.containsKey("passwordValidity")) {
|
||||
passwordValidity = Integer
|
||||
.parseInt((String) c.get("passwordValidity"));
|
||||
}
|
||||
} catch (ClassCastException e) {
|
||||
logger.config("Malformed configuration: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile. If the configuration prevents generating a secret
|
||||
|
|
@ -73,7 +141,7 @@ import org.jose4j.base64url.Base64;
|
|||
var vmDef = event.vmDefinition();
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + vmDef.name());
|
||||
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
|
||||
options);
|
||||
|
|
@ -84,9 +152,9 @@ import org.jose4j.base64url.Base64;
|
|||
// Create secret
|
||||
var secret = new V1Secret();
|
||||
secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace())
|
||||
.name(vmDef.name() + "-" + COMP_DISPLAY_SECRET)
|
||||
.name(vmDef.name() + "-" + DisplaySecret.NAME)
|
||||
.putLabelsItem("app.kubernetes.io/name", APP_NAME)
|
||||
.putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET)
|
||||
.putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME)
|
||||
.putLabelsItem("app.kubernetes.io/instance", vmDef.name()));
|
||||
secret.setType("Opaque");
|
||||
SecureRandom random = null;
|
||||
|
|
@ -99,9 +167,179 @@ import org.jose4j.base64url.Base64;
|
|||
byte[] bytes = new byte[16];
|
||||
random.nextBytes(bytes);
|
||||
var password = Base64.encode(bytes);
|
||||
secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
|
||||
DATA_PASSWORD_EXPIRY, "now"));
|
||||
secret.setStringData(Map.of(DisplaySecret.PASSWORD, password,
|
||||
DisplaySecret.EXPIRY, "now"));
|
||||
K8sV1SecretStub.create(channel.client(), secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares access to the console for the user from the event.
|
||||
* Generates a new password and sends it to the runner.
|
||||
* Requests the VM (via the runner) to login the user if specified
|
||||
* in the event.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.StringInstantiation")
|
||||
public void onPrepareConsole(PrepareConsole event, VmChannel channel)
|
||||
throws ApiException {
|
||||
// Update console user in status
|
||||
var vmDef = updateConsoleUser(event, channel);
|
||||
if (vmDef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if access is possible
|
||||
if (event.loginUser()
|
||||
? !vmDef.<String> fromStatus(Status.LOGGED_IN_USER)
|
||||
.map(u -> u.equals(event.user())).orElse(false)
|
||||
: !vmDef.conditionStatus("Running").orElse(false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get secret and update password in secret
|
||||
var stub = getSecretStub(event, channel, vmDef);
|
||||
if (stub == null) {
|
||||
return;
|
||||
}
|
||||
var secret = stub.model().get();
|
||||
if (!updatePassword(secret, event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register wait for confirmation (by VM status change,
|
||||
// after secret update)
|
||||
var pending = new PendingPrepare(event,
|
||||
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
|
||||
new CompletionLock(event, 1500));
|
||||
pendingPrepares.add(pending);
|
||||
Event.onCompletion(event, e -> {
|
||||
pendingPrepares.remove(pending);
|
||||
});
|
||||
|
||||
// Update, will (eventually) trigger confirmation
|
||||
stub.update(secret).getObject();
|
||||
}
|
||||
|
||||
private VmDefinition updateConsoleUser(PrepareConsole event,
|
||||
VmChannel channel) throws ApiException {
|
||||
var vmStub = VmDefinitionStub.get(channel.client(),
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||
event.vmDefinition().namespace(), event.vmDefinition().name());
|
||||
return vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty(Status.CONSOLE_USER, event.user());
|
||||
return status;
|
||||
}).orElse(null);
|
||||
}
|
||||
|
||||
private K8sV1SecretStub getSecretStub(PrepareConsole event,
|
||||
VmChannel channel, VmDefinition vmDef) throws ApiException {
|
||||
// Look for secret
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + vmDef.name());
|
||||
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
|
||||
options);
|
||||
if (stubs.isEmpty()) {
|
||||
// No secret means no password for this VM wanted
|
||||
event.setResult(null);
|
||||
return null;
|
||||
}
|
||||
return stubs.iterator().next();
|
||||
}
|
||||
|
||||
private boolean updatePassword(V1Secret secret, PrepareConsole event) {
|
||||
var expiry = Optional.ofNullable(secret.getData()
|
||||
.get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null);
|
||||
if (secret.getData().get(DisplaySecret.PASSWORD) != null
|
||||
&& stillValid(expiry)) {
|
||||
// Fixed secret, don't touch
|
||||
event.setResult(
|
||||
new String(secret.getData().get(DisplaySecret.PASSWORD)));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate password and set expiry
|
||||
SecureRandom random = null;
|
||||
try {
|
||||
random = SecureRandom.getInstanceStrong();
|
||||
} catch (NoSuchAlgorithmException e) { // NOPMD
|
||||
// "Every implementation of the Java platform is required
|
||||
// to support at least one strong SecureRandom implementation."
|
||||
}
|
||||
byte[] bytes = new byte[16];
|
||||
random.nextBytes(bytes);
|
||||
var password = Base64.encode(bytes);
|
||||
secret.setStringData(Map.of(DisplaySecret.PASSWORD, password,
|
||||
DisplaySecret.EXPIRY,
|
||||
Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
|
||||
event.setResult(password);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean stillValid(String expiry) {
|
||||
if (expiry == null || "never".equals(expiry)) {
|
||||
return true;
|
||||
}
|
||||
@SuppressWarnings({ "PMD.CloseResource", "resource" })
|
||||
var scanner = new Scanner(expiry);
|
||||
if (!scanner.hasNextLong()) {
|
||||
return false;
|
||||
}
|
||||
long expTime = scanner.nextLong();
|
||||
return expTime > Instant.now().getEpochSecond() + passwordValidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* On vm def changed.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||
public void onVmDefChanged(VmDefChanged event, Channel channel) {
|
||||
synchronized (pendingPrepares) {
|
||||
String vmName = event.vmDefinition().name();
|
||||
for (var pending : pendingPrepares) {
|
||||
if (pending.event.vmDefinition().name().equals(vmName)
|
||||
&& event.vmDefinition().displayPasswordSerial()
|
||||
.map(s -> s >= pending.expectedSerial).orElse(false)) {
|
||||
pending.lock.remove();
|
||||
// pending will be removed from pendingGest by
|
||||
// waiting thread, see updatePassword
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Class PendingGet.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
private static class PendingPrepare {
|
||||
public final PrepareConsole event;
|
||||
public final long expectedSerial;
|
||||
public final CompletionLock lock;
|
||||
|
||||
/**
|
||||
* Instantiates a new pending get.
|
||||
*
|
||||
* @param event the event
|
||||
* @param expectedSerial the expected serial
|
||||
*/
|
||||
public PendingPrepare(PrepareConsole event, long expectedSerial,
|
||||
CompletionLock lock) {
|
||||
super();
|
||||
this.event = event;
|
||||
this.expectedSerial = expectedSerial;
|
||||
this.lock = lock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sDynamicModel;
|
||||
|
|
@ -38,7 +38,6 @@ import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
|||
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
import org.jdrupes.vmoperator.common.VmPool;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM_POOL;
|
||||
import org.jdrupes.vmoperator.manager.events.GetPools;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.manager.events.VmPoolChanged;
|
||||
|
|
@ -88,7 +87,7 @@ public class PoolMonitor extends
|
|||
client(new K8sClient());
|
||||
|
||||
// Get all our API versions
|
||||
var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM_POOL);
|
||||
var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM_POOL);
|
||||
if (ctx.isEmpty()) {
|
||||
logger.severe(() -> "Cannot get CRD context.");
|
||||
return;
|
||||
|
|
@ -184,12 +183,12 @@ public class PoolMonitor extends
|
|||
return;
|
||||
}
|
||||
var vmStub = VmDefinitionStub.get(client(),
|
||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||
vmDef.namespace(), vmDef.name());
|
||||
vmStub.updateStatus(from -> {
|
||||
// TODO
|
||||
JsonObject status = from.statusJson();
|
||||
var assignment = GsonPtr.to(status).to("assignment");
|
||||
var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT);
|
||||
assignment.set("lastUsed", ccChange.get().toString());
|
||||
return status;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,12 +22,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
|||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import freemarker.template.AdapterTemplateModel;
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.DefaultObjectWrapperBuilder;
|
||||
import freemarker.template.SimpleNumber;
|
||||
import freemarker.template.SimpleScalar;
|
||||
import freemarker.template.TemplateException;
|
||||
import freemarker.template.TemplateExceptionHandler;
|
||||
import freemarker.template.TemplateHashModel;
|
||||
import freemarker.template.TemplateMethodModelEx;
|
||||
import freemarker.template.TemplateModel;
|
||||
import freemarker.template.TemplateModelException;
|
||||
|
|
@ -37,21 +35,23 @@ import io.kubernetes.client.openapi.ApiException;
|
|||
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
||||
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import org.jdrupes.vmoperator.common.Convertions;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sObserver;
|
||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
||||
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
|
|
@ -138,6 +138,8 @@ import org.jgrapes.util.events.ConfigurationUpdate;
|
|||
* properties to be used by the runners managed by the controller.
|
||||
* This property is a string that holds the content of
|
||||
* a logging.properties file.
|
||||
*
|
||||
* @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
|
||||
"PMD.AvoidDuplicateLiterals" })
|
||||
|
|
@ -163,6 +165,7 @@ public class Reconciler extends Component {
|
|||
*
|
||||
* @param componentChannel the component channel
|
||||
*/
|
||||
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
|
||||
public Reconciler(Channel componentChannel) {
|
||||
super(componentChannel);
|
||||
|
||||
|
|
@ -177,7 +180,7 @@ public class Reconciler extends Component {
|
|||
fmConfig.setClassForTemplateLoading(Reconciler.class, "");
|
||||
|
||||
cmReconciler = new ConfigMapReconciler(fmConfig);
|
||||
dsReconciler = new DisplaySecretReconciler();
|
||||
dsReconciler = attach(new DisplaySecretReconciler(componentChannel));
|
||||
stsReconciler = new StatefulSetReconciler(fmConfig);
|
||||
pvcReconciler = new PvcReconciler(fmConfig);
|
||||
podReconciler = new PodReconciler(fmConfig);
|
||||
|
|
@ -263,17 +266,14 @@ public class Reconciler extends Component {
|
|||
Optional.ofNullable(Reconciler.class.getPackage()
|
||||
.getImplementationVersion()).orElse("(Unknown)"));
|
||||
model.put("cr", vmDef);
|
||||
model.put("constants",
|
||||
(TemplateHashModel) new DefaultObjectWrapperBuilder(
|
||||
Configuration.VERSION_2_3_32)
|
||||
.build().getStaticModels()
|
||||
.get(Constants.class.getName()));
|
||||
// Freemarker's static models don't handle nested classes.
|
||||
model.put("constants", constantsMap(Constants.class));
|
||||
model.put("reconciler", config);
|
||||
|
||||
// Check if we have a display secret
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + vmDef.name());
|
||||
var dsStub = K8sV1SecretStub
|
||||
.list(client, vmDef.namespace(), options)
|
||||
|
|
@ -294,6 +294,30 @@ public class Reconciler extends Component {
|
|||
return model;
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.EmptyCatchBlock")
|
||||
private Map<String, Object> constantsMap(Class<?> clazz) {
|
||||
@SuppressWarnings("PMD.UseConcurrentHashMap")
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Arrays.stream(clazz.getFields()).filter(f -> {
|
||||
var modifiers = f.getModifiers();
|
||||
return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers)
|
||||
&& f.getType() == String.class;
|
||||
}).forEach(f -> {
|
||||
try {
|
||||
result.put(f.getName(), f.get(null));
|
||||
} catch (IllegalArgumentException | IllegalAccessException e) {
|
||||
// Should not happen, ignore
|
||||
}
|
||||
});
|
||||
Arrays.stream(clazz.getClasses()).filter(c -> {
|
||||
var modifiers = c.getModifiers();
|
||||
return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers);
|
||||
}).forEach(c -> {
|
||||
result.put(c.getSimpleName(), constantsMap(c));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private final TemplateMethodModelEx parseQuantityModel
|
||||
= new TemplateMethodModelEx() {
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ import java.util.Set;
|
|||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Level;
|
||||
import java.util.stream.Collectors;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
||||
|
|
@ -87,7 +86,7 @@ public class VmMonitor extends
|
|||
client(new K8sClient());
|
||||
|
||||
// Get all our API versions
|
||||
var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM);
|
||||
var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM);
|
||||
if (ctx.isEmpty()) {
|
||||
logger.severe(() -> "Cannot get CRD context.");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import java.util.Collection;
|
|||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.jdrupes.vmoperator.common.Constants;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
|
|
@ -60,7 +60,7 @@ class BasicTests {
|
|||
waitForManager();
|
||||
|
||||
// Context for working with our CR
|
||||
var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM);
|
||||
var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM);
|
||||
assertTrue(apiRes.isPresent());
|
||||
vmsContext = apiRes.get();
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ class BasicTests {
|
|||
ListOptions listOpts = new ListOptions();
|
||||
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + VM_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME);
|
||||
var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts);
|
||||
for (var secret : secrets) {
|
||||
secret.delete();
|
||||
|
|
@ -138,12 +138,11 @@ class BasicTests {
|
|||
List.of("name"), VM_NAME,
|
||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/instance"), VM_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
||||
Constants.VM_OP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME,
|
||||
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
|
||||
List.of("ownerReferences", 0, "apiVersion"),
|
||||
vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0),
|
||||
List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM,
|
||||
List.of("ownerReferences", 0, "kind"), Crd.KIND_VM,
|
||||
List.of("ownerReferences", 0, "name"), VM_NAME,
|
||||
List.of("ownerReferences", 0, "uid"), EXISTS);
|
||||
checkProps(config.getMetadata(), toCheck);
|
||||
|
|
@ -189,7 +188,7 @@ class BasicTests {
|
|||
ListOptions listOpts = new ListOptions();
|
||||
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + VM_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME);
|
||||
Collection<K8sV1SecretStub> secrets = null;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts);
|
||||
|
|
@ -219,8 +218,7 @@ class BasicTests {
|
|||
checkProps(pvc.getMetadata(), Map.of(
|
||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/instance"), VM_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
||||
Constants.VM_OP_NAME));
|
||||
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME));
|
||||
checkProps(pvc.getSpec(), Map.of(
|
||||
List.of("resources", "requests", "storage"),
|
||||
Quantity.fromString("1Mi")));
|
||||
|
|
@ -240,8 +238,7 @@ class BasicTests {
|
|||
checkProps(pvc.getMetadata(), Map.of(
|
||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/instance"), VM_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
||||
Constants.VM_OP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME,
|
||||
List.of("annotations", "use_as"), "system-disk"));
|
||||
checkProps(pvc.getSpec(), Map.of(
|
||||
List.of("resources", "requests", "storage"),
|
||||
|
|
@ -262,8 +259,7 @@ class BasicTests {
|
|||
checkProps(pvc.getMetadata(), Map.of(
|
||||
List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/instance"), VM_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
||||
Constants.VM_OP_NAME));
|
||||
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME));
|
||||
checkProps(pvc.getSpec(), Map.of(
|
||||
List.of("resources", "requests", "storage"),
|
||||
Quantity.fromString("1Gi")));
|
||||
|
|
@ -290,13 +286,12 @@ class BasicTests {
|
|||
List.of("labels", "app.kubernetes.io/name"), APP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/instance"), VM_NAME,
|
||||
List.of("labels", "app.kubernetes.io/component"), APP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"),
|
||||
Constants.VM_OP_NAME,
|
||||
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME,
|
||||
List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS,
|
||||
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
|
||||
List.of("ownerReferences", 0, "apiVersion"),
|
||||
vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0),
|
||||
List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM,
|
||||
List.of("ownerReferences", 0, "kind"), Crd.KIND_VM,
|
||||
List.of("ownerReferences", 0, "name"), VM_NAME,
|
||||
List.of("ownerReferences", 0, "uid"), EXISTS));
|
||||
checkProps(pod.getSpec(), Map.of(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
@SuppressWarnings("PMD.ExcessivePublicCount")
|
||||
@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyFields" })
|
||||
public class Configuration implements Dto {
|
||||
private static final String CI_INSTANCE_ID = "instance-id";
|
||||
|
||||
|
|
@ -67,9 +67,6 @@ public class Configuration implements Dto {
|
|||
/** The monitor socket. */
|
||||
public Path monitorSocket;
|
||||
|
||||
/** The guest agent socket socket. */
|
||||
public Path guestAgentSocket;
|
||||
|
||||
/** The firmware rom. */
|
||||
public Path firmwareRom;
|
||||
|
||||
|
|
@ -251,6 +248,9 @@ public class Configuration implements Dto {
|
|||
/** The number of outputs. */
|
||||
public int outputs = 1;
|
||||
|
||||
/** The logged in user. */
|
||||
public String loggedInUser;
|
||||
|
||||
/** The spice. */
|
||||
public Spice spice;
|
||||
}
|
||||
|
|
@ -344,7 +344,6 @@ public class Configuration implements Dto {
|
|||
runtimeDir.toFile().mkdir();
|
||||
swtpmSocket = runtimeDir.resolve("swtpm-sock");
|
||||
monitorSocket = runtimeDir.resolve("monitor.sock");
|
||||
guestAgentSocket = runtimeDir.resolve("org.qemu.guest_agent.0");
|
||||
}
|
||||
if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) {
|
||||
logger.severe(() -> String.format(
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import io.kubernetes.client.openapi.models.EventsV1Event;
|
|||
import java.io.IOException;
|
||||
import java.util.logging.Level;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
|
|
@ -74,7 +74,7 @@ public class ConsoleTracker extends VmDefUpdater {
|
|||
}
|
||||
try {
|
||||
vmStub = VmDefinitionStub.get(apiClient,
|
||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||
namespace, vmName);
|
||||
} catch (ApiException e) {
|
||||
logger.log(Level.SEVERE, e,
|
||||
|
|
@ -106,16 +106,15 @@ public class ConsoleTracker extends VmDefUpdater {
|
|||
mainChannelClientHost = event.clientHost();
|
||||
mainChannelClientPort = event.clientPort();
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty("consoleClient", event.clientHost());
|
||||
updateCondition(from, status, "ConsoleConnected", true, "Connected",
|
||||
"Connection from " + event.clientHost());
|
||||
JsonObject status = updateCondition(from, "ConsoleConnected", true,
|
||||
"Connected", "Connection from " + event.clientHost());
|
||||
status.addProperty(Status.CONSOLE_CLIENT, event.clientHost());
|
||||
return status;
|
||||
});
|
||||
|
||||
// Log event
|
||||
var evt = new EventsV1Event()
|
||||
.reportingController(VM_OP_GROUP + "/" + APP_NAME)
|
||||
.reportingController(Crd.GROUP + "/" + APP_NAME)
|
||||
.action("ConsoleConnectionUpdate")
|
||||
.reason("Connection from " + event.clientHost());
|
||||
K8s.createEvent(apiClient, vmStub.model().get(), evt);
|
||||
|
|
@ -141,16 +140,15 @@ public class ConsoleTracker extends VmDefUpdater {
|
|||
return;
|
||||
}
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty("consoleClient", "");
|
||||
updateCondition(from, status, "ConsoleConnected", false,
|
||||
JsonObject status = updateCondition(from, "ConsoleConnected", false,
|
||||
"Disconnected", event.clientHost() + " has disconnected");
|
||||
status.addProperty(Status.CONSOLE_CLIENT, "");
|
||||
return status;
|
||||
});
|
||||
|
||||
// Log event
|
||||
var evt = new EventsV1Event()
|
||||
.reportingController(VM_OP_GROUP + "/" + APP_NAME)
|
||||
.reportingController(Crd.GROUP + "/" + APP_NAME)
|
||||
.action("ConsoleConnectionUpdate")
|
||||
.reason("Disconnected from " + event.clientHost());
|
||||
K8s.createEvent(apiClient, vmStub.model().get(), evt);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2023 Michael N. Lipp
|
||||
* Copyright (C) 2023,2025 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
|
|
@ -22,14 +22,20 @@ import java.io.IOException;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword;
|
||||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Component;
|
||||
import org.jgrapes.core.Event;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.util.events.FileChanged;
|
||||
import org.jgrapes.util.events.WatchFile;
|
||||
|
|
@ -40,11 +46,11 @@ import org.jgrapes.util.events.WatchFile;
|
|||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class DisplayController extends Component {
|
||||
|
||||
public static final String DISPLAY_PASSWORD_FILE = "display-password";
|
||||
public static final String PASSWORD_EXPIRY_FILE = "password-expiry";
|
||||
private String currentPassword;
|
||||
private String protocol;
|
||||
private final Path configDir;
|
||||
private boolean vmopAgentConnected;
|
||||
private String loggedInUser;
|
||||
|
||||
/**
|
||||
* Instantiates a new Display controller.
|
||||
|
|
@ -57,7 +63,7 @@ public class DisplayController extends Component {
|
|||
public DisplayController(Channel componentChannel, Path configDir) {
|
||||
super(componentChannel);
|
||||
this.configDir = configDir;
|
||||
fire(new WatchFile(configDir.resolve(DISPLAY_PASSWORD_FILE)));
|
||||
fire(new WatchFile(configDir.resolve(DisplaySecret.PASSWORD)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,7 +78,32 @@ public class DisplayController extends Component {
|
|||
}
|
||||
protocol
|
||||
= event.configuration().vm.display.spice != null ? "spice" : null;
|
||||
updatePassword();
|
||||
loggedInUser = event.configuration().vm.display.loggedInUser;
|
||||
configureLogin();
|
||||
if (event.runState() == RunState.STARTING) {
|
||||
configurePassword();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On vmop agent connected.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
public void onVmopAgentConnected(VmopAgentConnected event) {
|
||||
vmopAgentConnected = true;
|
||||
configureLogin();
|
||||
}
|
||||
|
||||
private void configureLogin() {
|
||||
if (!vmopAgentConnected) {
|
||||
return;
|
||||
}
|
||||
Event<?> evt = loggedInUser != null
|
||||
? new VmopAgentLogIn(loggedInUser)
|
||||
: new VmopAgentLogOut();
|
||||
fire(evt);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,13 +114,12 @@ public class DisplayController extends Component {
|
|||
@Handler
|
||||
@SuppressWarnings("PMD.EmptyCatchBlock")
|
||||
public void onFileChanged(FileChanged event) {
|
||||
if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) {
|
||||
updatePassword();
|
||||
if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) {
|
||||
configurePassword();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
private void updatePassword() {
|
||||
private void configurePassword() {
|
||||
if (protocol == null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -99,47 +129,41 @@ public class DisplayController extends Component {
|
|||
}
|
||||
|
||||
private boolean setDisplayPassword() {
|
||||
String password;
|
||||
Path dpPath = configDir.resolve(DISPLAY_PASSWORD_FILE);
|
||||
if (dpPath.toFile().canRead()) {
|
||||
logger.finer(() -> "Found display password");
|
||||
try {
|
||||
password = Files.readString(dpPath);
|
||||
} catch (IOException e) {
|
||||
logger.log(Level.WARNING, e, () -> "Cannot read display"
|
||||
+ " password: " + e.getMessage());
|
||||
return false;
|
||||
return readFromFile(DisplaySecret.PASSWORD).map(password -> {
|
||||
if (Objects.equals(this.currentPassword, password)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
logger.finer(() -> "No display password");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Objects.equals(this.currentPassword, password)) {
|
||||
this.currentPassword = password;
|
||||
logger.fine(() -> "Updating display password");
|
||||
fire(new MonitorCommand(
|
||||
new QmpSetDisplayPassword(protocol, password)));
|
||||
return true;
|
||||
}
|
||||
this.currentPassword = password;
|
||||
logger.fine(() -> "Updating display password");
|
||||
fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password)));
|
||||
return true;
|
||||
}).orElse(false);
|
||||
}
|
||||
|
||||
private void setPasswordExpiry() {
|
||||
Path pePath = configDir.resolve(PASSWORD_EXPIRY_FILE);
|
||||
if (!pePath.toFile().canRead()) {
|
||||
return;
|
||||
}
|
||||
logger.finer(() -> "Found expiry time");
|
||||
String expiry;
|
||||
try {
|
||||
expiry = Files.readString(pePath);
|
||||
} catch (IOException e) {
|
||||
logger.log(Level.WARNING, e, () -> "Cannot read expiry"
|
||||
+ " time: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
logger.fine(() -> "Updating expiry time");
|
||||
fire(new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry)));
|
||||
readFromFile(DisplaySecret.EXPIRY).ifPresent(expiry -> {
|
||||
logger.fine(() -> "Updating expiry time to " + expiry);
|
||||
fire(
|
||||
new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry)));
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<String> readFromFile(String dataItem) {
|
||||
Path path = configDir.resolve(dataItem);
|
||||
String label = dataItem.replace('-', ' ');
|
||||
if (path.toFile().canRead()) {
|
||||
logger.finer(() -> "Found " + label);
|
||||
try {
|
||||
return Optional.ofNullable(Files.readString(path));
|
||||
} catch (IOException e) {
|
||||
logger.log(Level.WARNING, e, () -> "Cannot read " + label + ": "
|
||||
+ e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
} else {
|
||||
logger.finer(() -> "No " + label);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,8 @@
|
|||
package org.jdrupes.vmoperator.runner.qemu;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.lang.reflect.UndeclaredThrowableException;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Queue;
|
||||
import java.util.logging.Level;
|
||||
|
|
@ -34,38 +28,17 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand;
|
|||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Component;
|
||||
import org.jgrapes.core.EventPipeline;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.core.events.Start;
|
||||
import org.jgrapes.core.events.Stop;
|
||||
import org.jgrapes.io.events.Closed;
|
||||
import org.jgrapes.io.events.ConnectError;
|
||||
import org.jgrapes.io.events.Input;
|
||||
import org.jgrapes.io.events.OpenSocketConnection;
|
||||
import org.jgrapes.io.util.ByteBufferWriter;
|
||||
import org.jgrapes.io.util.LineCollector;
|
||||
import org.jgrapes.net.SocketIOChannel;
|
||||
import org.jgrapes.net.events.ClientConnected;
|
||||
import org.jgrapes.util.events.ConfigurationUpdate;
|
||||
|
||||
/**
|
||||
* A component that handles the communication over the guest agent
|
||||
* socket.
|
||||
* A component that handles the communication with the guest agent.
|
||||
*
|
||||
* If the log level for this class is set to fine, the messages
|
||||
* exchanged on the monitor socket are logged.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class GuestAgentClient extends Component {
|
||||
public class GuestAgentClient extends AgentConnector {
|
||||
|
||||
private static ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private EventPipeline rep;
|
||||
private Path socketPath;
|
||||
private SocketIOChannel gaChannel;
|
||||
private final Queue<QmpCommand> executing = new LinkedList<>();
|
||||
|
||||
/**
|
||||
|
|
@ -74,126 +47,36 @@ public class GuestAgentClient extends Component {
|
|||
* @param componentChannel the component channel
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AssignmentToNonFinalStatic",
|
||||
"PMD.ConstructorCallsOverridableMethod" })
|
||||
public GuestAgentClient(Channel componentChannel) throws IOException {
|
||||
super(componentChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* As the initial configuration of this component depends on the
|
||||
* configuration of the {@link Runner}, it doesn't have a handler
|
||||
* for the {@link ConfigurationUpdate} event. The values are
|
||||
* forwarded from the {@link Runner} instead.
|
||||
*
|
||||
* @param socketPath the socket path
|
||||
* @param powerdownTimeout
|
||||
* When the agent has connected, request the OS information.
|
||||
*/
|
||||
/* default */ void configure(Path socketPath) {
|
||||
this.socketPath = socketPath;
|
||||
@Override
|
||||
protected void agentConnected() {
|
||||
fire(new GuestAgentCommand(new QmpGuestGetOsinfo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the start event.
|
||||
* Process agent input.
|
||||
*
|
||||
* @param event the event
|
||||
* @param line the line
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
*/
|
||||
@Handler
|
||||
public void onStart(Start event) throws IOException {
|
||||
rep = event.associated(EventPipeline.class).get();
|
||||
if (socketPath == null) {
|
||||
return;
|
||||
}
|
||||
Files.deleteIfExists(socketPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the virtual serial port "channel0" has been opened,
|
||||
* establish the connection by opening the socket.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
public void onVserportChanged(VserportChangeEvent event) {
|
||||
if ("channel0".equals(event.id()) && event.isOpen()) {
|
||||
fire(new OpenSocketConnection(
|
||||
UnixDomainSocketAddress.of(socketPath))
|
||||
.setAssociated(GuestAgentClient.class, this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is from opening the monitor socket and if true,
|
||||
* save the socket in the context and associate the channel with
|
||||
* the context. Then send the initial message to the socket.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@SuppressWarnings("resource")
|
||||
@Handler
|
||||
public void onClientConnected(ClientConnected event,
|
||||
SocketIOChannel channel) {
|
||||
event.openEvent().associated(GuestAgentClient.class).ifPresent(qm -> {
|
||||
gaChannel = channel;
|
||||
channel.setAssociated(GuestAgentClient.class, this);
|
||||
channel.setAssociated(Writer.class, new ByteBufferWriter(
|
||||
channel).nativeCharset());
|
||||
channel.setAssociated(LineCollector.class,
|
||||
new LineCollector()
|
||||
.consumer(line -> {
|
||||
try {
|
||||
processGuestAgentInput(line);
|
||||
} catch (IOException e) {
|
||||
throw new UndeclaredThrowableException(e);
|
||||
}
|
||||
}));
|
||||
fire(new GuestAgentCommand(new QmpGuestGetOsinfo()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a connection attempt fails.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
public void onConnectError(ConnectError event, SocketIOChannel channel) {
|
||||
event.event().associated(GuestAgentClient.class).ifPresent(qm -> {
|
||||
rep.fire(new Stop());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle data from qemu monitor connection.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
public void onInput(Input<?> event, SocketIOChannel channel) {
|
||||
if (channel.associated(GuestAgentClient.class).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
channel.associated(LineCollector.class).ifPresent(collector -> {
|
||||
collector.feed(event);
|
||||
});
|
||||
}
|
||||
|
||||
private void processGuestAgentInput(String line)
|
||||
throws IOException {
|
||||
@Override
|
||||
protected void processInput(String line) throws IOException {
|
||||
logger.fine(() -> "guest agent(in): " + line);
|
||||
try {
|
||||
var response = mapper.readValue(line, ObjectNode.class);
|
||||
if (response.has("return") || response.has("error")) {
|
||||
QmpCommand executed = executing.poll();
|
||||
logger.fine(
|
||||
() -> String.format("(Previous \"guest agent(in)\" is "
|
||||
+ "result from executing %s)", executed));
|
||||
logger.fine(() -> String.format("(Previous \"guest agent(in)\""
|
||||
+ " is result from executing %s)", executed));
|
||||
if (executed instanceof QmpGuestGetOsinfo) {
|
||||
rep.fire(new OsinfoEvent(response.get("return")));
|
||||
var osInfo = new OsinfoEvent(response.get("return"));
|
||||
rep().fire(osInfo);
|
||||
}
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
|
|
@ -201,29 +84,19 @@ public class GuestAgentClient extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On closed.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings({ "PMD.AvoidSynchronizedStatement",
|
||||
"PMD.AvoidDuplicateLiterals" })
|
||||
public void onClosed(Closed<?> event, SocketIOChannel channel) {
|
||||
channel.associated(QemuMonitor.class).ifPresent(qm -> {
|
||||
gaChannel = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* On guest agent command.
|
||||
*
|
||||
* @param event the event
|
||||
* @throws IOException
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.AvoidSynchronizedStatement" })
|
||||
public void onGuestAgentCommand(GuestAgentCommand event) {
|
||||
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||
public void onGuestAgentCommand(GuestAgentCommand event)
|
||||
throws IOException {
|
||||
if (qemuChannel() == null) {
|
||||
return;
|
||||
}
|
||||
var command = event.command();
|
||||
logger.fine(() -> "guest agent(out): " + command.toString());
|
||||
String asText;
|
||||
|
|
@ -235,15 +108,10 @@ public class GuestAgentClient extends Component {
|
|||
return;
|
||||
}
|
||||
synchronized (executing) {
|
||||
gaChannel.associated(Writer.class).ifPresent(writer -> {
|
||||
try {
|
||||
executing.add(command);
|
||||
writer.append(asText).append('\n').flush();
|
||||
} catch (IOException e) {
|
||||
// Cannot happen, but...
|
||||
logger.log(Level.WARNING, e, e::getMessage);
|
||||
}
|
||||
});
|
||||
if (writer().isPresent()) {
|
||||
executing.add(command);
|
||||
sendCommand(asText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.lang.reflect.UndeclaredThrowableException;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
|
@ -42,24 +37,13 @@ import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady;
|
|||
import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Component;
|
||||
import org.jgrapes.core.Components;
|
||||
import org.jgrapes.core.Components.Timer;
|
||||
import org.jgrapes.core.EventPipeline;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.core.events.Start;
|
||||
import org.jgrapes.core.events.Stop;
|
||||
import org.jgrapes.io.events.Closed;
|
||||
import org.jgrapes.io.events.ConnectError;
|
||||
import org.jgrapes.io.events.Input;
|
||||
import org.jgrapes.io.events.OpenSocketConnection;
|
||||
import org.jgrapes.io.util.ByteBufferWriter;
|
||||
import org.jgrapes.io.util.LineCollector;
|
||||
import org.jgrapes.net.SocketIOChannel;
|
||||
import org.jgrapes.net.events.ClientConnected;
|
||||
import org.jgrapes.util.events.ConfigurationUpdate;
|
||||
import org.jgrapes.util.events.FileChanged;
|
||||
import org.jgrapes.util.events.WatchFile;
|
||||
|
||||
/**
|
||||
* A component that handles the communication over the Qemu monitor
|
||||
|
|
@ -69,14 +53,9 @@ import org.jgrapes.util.events.WatchFile;
|
|||
* exchanged on the monitor socket are logged.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class QemuMonitor extends Component {
|
||||
public class QemuMonitor extends QemuConnector {
|
||||
|
||||
private static ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private EventPipeline rep;
|
||||
private Path socketPath;
|
||||
private int powerdownTimeout;
|
||||
private SocketIOChannel monitorChannel;
|
||||
private final Queue<QmpCommand> executing = new LinkedList<>();
|
||||
private Instant powerdownStartedAt;
|
||||
private Stop suspendedStop;
|
||||
|
|
@ -84,7 +63,7 @@ public class QemuMonitor extends Component {
|
|||
private boolean powerdownConfirmed;
|
||||
|
||||
/**
|
||||
* Instantiates a new qemu monitor.
|
||||
* Instantiates a new QEMU monitor.
|
||||
*
|
||||
* @param componentChannel the component channel
|
||||
* @param configDir the config dir
|
||||
|
|
@ -111,109 +90,26 @@ public class QemuMonitor extends Component {
|
|||
* @param powerdownTimeout
|
||||
*/
|
||||
/* default */ void configure(Path socketPath, int powerdownTimeout) {
|
||||
this.socketPath = socketPath;
|
||||
super.configure(socketPath);
|
||||
this.powerdownTimeout = powerdownTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the start event.
|
||||
*
|
||||
* @param event the event
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* When the socket is connected, send the capabilities command.
|
||||
*/
|
||||
@Handler
|
||||
public void onStart(Start event) throws IOException {
|
||||
rep = event.associated(EventPipeline.class).get();
|
||||
if (socketPath == null) {
|
||||
return;
|
||||
}
|
||||
Files.deleteIfExists(socketPath);
|
||||
fire(new WatchFile(socketPath));
|
||||
@Override
|
||||
protected void socketConnected() {
|
||||
fire(new MonitorCommand(new QmpCapabilities()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for the creation of the swtpm socket and start the
|
||||
* qemu process if it has been created.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
public void onFileChanged(FileChanged event) {
|
||||
if (event.change() == FileChanged.Kind.CREATED
|
||||
&& event.path().equals(socketPath)) {
|
||||
// qemu running, open socket
|
||||
fire(new OpenSocketConnection(
|
||||
UnixDomainSocketAddress.of(socketPath))
|
||||
.setAssociated(QemuMonitor.class, this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is from opening the monitor socket and if true,
|
||||
* save the socket in the context and associate the channel with
|
||||
* the context. Then send the initial message to the socket.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@SuppressWarnings("resource")
|
||||
@Handler
|
||||
public void onClientConnected(ClientConnected event,
|
||||
SocketIOChannel channel) {
|
||||
event.openEvent().associated(QemuMonitor.class).ifPresent(qm -> {
|
||||
monitorChannel = channel;
|
||||
channel.setAssociated(QemuMonitor.class, this);
|
||||
channel.setAssociated(Writer.class, new ByteBufferWriter(
|
||||
channel).nativeCharset());
|
||||
channel.setAssociated(LineCollector.class,
|
||||
new LineCollector()
|
||||
.consumer(line -> {
|
||||
try {
|
||||
processMonitorInput(line);
|
||||
} catch (IOException e) {
|
||||
throw new UndeclaredThrowableException(e);
|
||||
}
|
||||
}));
|
||||
fire(new MonitorCommand(new QmpCapabilities()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a connection attempt fails.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
public void onConnectError(ConnectError event, SocketIOChannel channel) {
|
||||
event.event().associated(QemuMonitor.class).ifPresent(qm -> {
|
||||
rep.fire(new Stop());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle data from qemu monitor connection.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
public void onInput(Input<?> event, SocketIOChannel channel) {
|
||||
if (channel.associated(QemuMonitor.class).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
channel.associated(LineCollector.class).ifPresent(collector -> {
|
||||
collector.feed(event);
|
||||
});
|
||||
}
|
||||
|
||||
private void processMonitorInput(String line)
|
||||
@Override
|
||||
protected void processInput(String line)
|
||||
throws IOException {
|
||||
logger.fine(() -> "monitor(in): " + line);
|
||||
try {
|
||||
var response = mapper.readValue(line, ObjectNode.class);
|
||||
if (response.has("QMP")) {
|
||||
rep.fire(new MonitorReady());
|
||||
rep().fire(new MonitorReady());
|
||||
return;
|
||||
}
|
||||
if (response.has("return") || response.has("error")) {
|
||||
|
|
@ -221,11 +117,11 @@ public class QemuMonitor extends Component {
|
|||
logger.fine(
|
||||
() -> String.format("(Previous \"monitor(in)\" is result "
|
||||
+ "from executing %s)", executed));
|
||||
rep.fire(MonitorResult.from(executed, response));
|
||||
rep().fire(MonitorResult.from(executed, response));
|
||||
return;
|
||||
}
|
||||
if (response.has("event")) {
|
||||
MonitorEvent.from(response).ifPresent(rep::fire);
|
||||
MonitorEvent.from(response).ifPresent(rep()::fire);
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException(e);
|
||||
|
|
@ -241,8 +137,8 @@ public class QemuMonitor extends Component {
|
|||
@SuppressWarnings({ "PMD.AvoidSynchronizedStatement",
|
||||
"PMD.AvoidDuplicateLiterals" })
|
||||
public void onClosed(Closed<?> event, SocketIOChannel channel) {
|
||||
super.onClosed(event, channel);
|
||||
channel.associated(QemuMonitor.class).ifPresent(qm -> {
|
||||
monitorChannel = null;
|
||||
synchronized (this) {
|
||||
if (powerdownTimer != null) {
|
||||
powerdownTimer.cancel();
|
||||
|
|
@ -259,11 +155,12 @@ public class QemuMonitor extends Component {
|
|||
* On monitor command.
|
||||
*
|
||||
* @param event the event
|
||||
* @throws IOException
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.AvoidSynchronizedStatement" })
|
||||
public void onExecQmpCommand(MonitorCommand event) {
|
||||
public void onExecQmpCommand(MonitorCommand event) throws IOException {
|
||||
var command = event.command();
|
||||
logger.fine(() -> "monitor(out): " + command.toString());
|
||||
String asText;
|
||||
|
|
@ -275,15 +172,10 @@ public class QemuMonitor extends Component {
|
|||
return;
|
||||
}
|
||||
synchronized (executing) {
|
||||
monitorChannel.associated(Writer.class).ifPresent(writer -> {
|
||||
try {
|
||||
executing.add(command);
|
||||
writer.append(asText).append('\n').flush();
|
||||
} catch (IOException e) {
|
||||
// Cannot happen, but...
|
||||
logger.log(Level.WARNING, e, e::getMessage);
|
||||
}
|
||||
});
|
||||
if (writer().isPresent()) {
|
||||
executing.add(command);
|
||||
sendCommand(asText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +187,7 @@ public class QemuMonitor extends Component {
|
|||
@Handler(priority = 100)
|
||||
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||
public void onStop(Stop event) {
|
||||
if (monitorChannel != null) {
|
||||
if (qemuChannel() != null) {
|
||||
// We have a connection to Qemu, attempt ACPI shutdown.
|
||||
event.suspendHandling();
|
||||
suspendedStop = event;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import org.apache.commons.cli.DefaultParser;
|
|||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.Options;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont;
|
||||
import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
|
||||
|
|
@ -220,6 +221,7 @@ public class Runner extends Component {
|
|||
private CommandDefinition qemuDefinition;
|
||||
private final QemuMonitor qemuMonitor;
|
||||
private final GuestAgentClient guestAgentClient;
|
||||
private final VmopAgentClient vmopAgentClient;
|
||||
private Integer resetCounter;
|
||||
private RunState state = RunState.INITIALIZING;
|
||||
|
||||
|
|
@ -278,6 +280,7 @@ public class Runner extends Component {
|
|||
attach(new SocketConnector(channel()));
|
||||
attach(qemuMonitor = new QemuMonitor(channel(), configDir));
|
||||
attach(guestAgentClient = new GuestAgentClient(channel()));
|
||||
attach(vmopAgentClient = new VmopAgentClient(channel()));
|
||||
attach(new StatusUpdater(channel()));
|
||||
attach(new YamlConfigurationStore(channel(), configFile, false));
|
||||
fire(new WatchFile(configFile.toPath()));
|
||||
|
|
@ -309,8 +312,7 @@ public class Runner extends Component {
|
|||
|
||||
// Add some values from other sources to configuration
|
||||
newConf.asOf = Instant.ofEpochSecond(configFile.lastModified());
|
||||
Path dsPath
|
||||
= configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE);
|
||||
Path dsPath = configDir.resolve(DisplaySecret.PASSWORD);
|
||||
newConf.hasDisplayPassword = dsPath.toFile().canRead();
|
||||
|
||||
// Special actions for initial configuration (startup)
|
||||
|
|
@ -352,7 +354,8 @@ public class Runner extends Component {
|
|||
// Forward some values to child components
|
||||
qemuMonitor.configure(config.monitorSocket,
|
||||
config.vm.powerdownTimeout);
|
||||
guestAgentClient.configure(config.guestAgentSocket);
|
||||
configureAgentClient(guestAgentClient, "guest-agent-socket");
|
||||
configureAgentClient(vmopAgentClient, "vmop-agent-socket");
|
||||
} catch (IllegalArgumentException | IOException | TemplateException e) {
|
||||
logger.log(Level.SEVERE, e, () -> "Invalid configuration: "
|
||||
+ e.getMessage());
|
||||
|
|
@ -477,6 +480,36 @@ public class Runner extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.CognitiveComplexity")
|
||||
private void configureAgentClient(AgentConnector client, String chardev) {
|
||||
String id = null;
|
||||
Path path = null;
|
||||
for (var arg : qemuDefinition.command) {
|
||||
if (arg.startsWith("virtserialport,")
|
||||
&& arg.contains("chardev=" + chardev)) {
|
||||
for (var prop : arg.split(",")) {
|
||||
if (prop.startsWith("id=")) {
|
||||
id = prop.substring(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (arg.startsWith("socket,")
|
||||
&& arg.contains("id=" + chardev)) {
|
||||
for (var prop : arg.split(",")) {
|
||||
if (prop.startsWith("path=")) {
|
||||
path = Path.of(prop.substring(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (id == null || path == null) {
|
||||
logger.warning(() -> "Definition of chardev " + chardev
|
||||
+ " missing in runner template.");
|
||||
return;
|
||||
}
|
||||
client.configure(id, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the started event.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ import java.io.IOException;
|
|||
import java.math.BigDecimal;
|
||||
import java.util.logging.Level;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
|
|
@ -47,6 +47,9 @@ import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent;
|
|||
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
|
|
@ -109,10 +112,17 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
}
|
||||
try {
|
||||
vmStub = VmDefinitionStub.get(apiClient,
|
||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||
namespace, vmName);
|
||||
vmStub.model().ifPresent(model -> {
|
||||
observedGeneration = model.getMetadata().getGeneration();
|
||||
var vmDef = vmStub.model().orElse(null);
|
||||
if (vmDef == null) {
|
||||
return;
|
||||
}
|
||||
observedGeneration = vmDef.getMetadata().getGeneration();
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.remove(Status.LOGGED_IN_USER);
|
||||
return status;
|
||||
});
|
||||
} catch (ApiException e) {
|
||||
logger.log(Level.SEVERE, e,
|
||||
|
|
@ -143,18 +153,20 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
// by a new version of the CR. So we update only if we have
|
||||
// a new version of the CR. There's one exception: the display
|
||||
// password is configured by a file, not by the CR.
|
||||
var vmDef = vmStub.model();
|
||||
if (vmDef.isPresent()
|
||||
&& vmDef.get().metadata().getGeneration() == observedGeneration
|
||||
&& (event.configuration().hasDisplayPassword
|
||||
|| vmDef.get().statusJson().getAsJsonPrimitive(
|
||||
"displayPasswordSerial").getAsInt() == -1)) {
|
||||
var vmDef = vmStub.model().orElse(null);
|
||||
if (vmDef == null) {
|
||||
return;
|
||||
}
|
||||
vmStub.updateStatus(vmDef.get(), from -> {
|
||||
if (vmDef.metadata().getGeneration() == observedGeneration
|
||||
&& (event.configuration().hasDisplayPassword
|
||||
|| vmDef.statusJson().getAsJsonPrimitive(
|
||||
Status.DISPLAY_PASSWORD_SERIAL).getAsInt() == -1)) {
|
||||
return;
|
||||
}
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
if (!event.configuration().hasDisplayPassword) {
|
||||
status.addProperty("displayPasswordSerial", -1);
|
||||
status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1);
|
||||
}
|
||||
status.getAsJsonArray("conditions").asList().stream()
|
||||
.map(cond -> (JsonObject) cond).filter(cond -> "Running"
|
||||
|
|
@ -162,7 +174,7 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
.forEach(cond -> cond.addProperty("observedGeneration",
|
||||
from.getMetadata().getGeneration()));
|
||||
return status;
|
||||
});
|
||||
}, vmDef);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -172,43 +184,44 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
* @throws ApiException
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings({ "PMD.AssignmentInOperand",
|
||||
"PMD.AvoidLiteralsInIfCondition" })
|
||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.AssignmentInOperand", "PMD.AvoidDuplicateLiterals" })
|
||||
public void onRunnerStateChanged(RunnerStateChange event)
|
||||
throws ApiException {
|
||||
VmDefinition vmDef;
|
||||
if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
|
||||
return;
|
||||
}
|
||||
vmStub.updateStatus(vmDef, from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
vmStub.updateStatus(from -> {
|
||||
boolean running = event.runState().vmRunning();
|
||||
updateCondition(vmDef, vmDef.statusJson(), "Running", running,
|
||||
event.reason(), event.message());
|
||||
updateCondition(vmDef, vmDef.statusJson(), "Booted",
|
||||
updateCondition(vmDef, "Running", running, event.reason(),
|
||||
event.message());
|
||||
JsonObject status = updateCondition(vmDef, "Booted",
|
||||
event.runState() == RunState.BOOTED, event.reason(),
|
||||
event.message());
|
||||
if (event.runState() == RunState.STARTING) {
|
||||
status.addProperty("ram", GsonPtr.to(from.data())
|
||||
status.addProperty(Status.RAM, GsonPtr.to(from.data())
|
||||
.getAsString("spec", "vm", "maximumRam").orElse("0"));
|
||||
status.addProperty("cpus", 1);
|
||||
status.addProperty(Status.CPUS, 1);
|
||||
} else if (event.runState() == RunState.STOPPED) {
|
||||
status.addProperty(Status.RAM, "0");
|
||||
status.addProperty(Status.CPUS, 0);
|
||||
status.remove(Status.LOGGED_IN_USER);
|
||||
}
|
||||
|
||||
if (!running) {
|
||||
// In case console connection was still present
|
||||
status.addProperty(Status.CONSOLE_CLIENT, "");
|
||||
updateCondition(from, "ConsoleConnected", false, "VmStopped",
|
||||
"The VM is not running");
|
||||
|
||||
// In case we had an irregular shutdown
|
||||
status.remove("osinfo");
|
||||
} else if (event.runState() == RunState.STOPPED) {
|
||||
status.addProperty("ram", "0");
|
||||
status.addProperty("cpus", 0);
|
||||
status.remove("osinfo");
|
||||
}
|
||||
|
||||
// In case console connection was still present
|
||||
if (!running) {
|
||||
status.addProperty("consoleClient", "");
|
||||
updateCondition(from, status, "ConsoleConnected", false,
|
||||
"VmStopped", "The VM has been shut down");
|
||||
status.remove(Status.OSINFO);
|
||||
updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped",
|
||||
"The VM is not running");
|
||||
}
|
||||
return status;
|
||||
});
|
||||
}, vmDef);
|
||||
|
||||
// Maybe stop VM
|
||||
if (event.runState() == RunState.TERMINATING && !event.failed()
|
||||
|
|
@ -226,7 +239,7 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
|
||||
// Log event
|
||||
var evt = new EventsV1Event()
|
||||
.reportingController(VM_OP_GROUP + "/" + APP_NAME)
|
||||
.reportingController(Crd.GROUP + "/" + APP_NAME)
|
||||
.action("StatusUpdate").reason(event.reason())
|
||||
.note(event.message());
|
||||
K8s.createEvent(apiClient, vmDef, evt);
|
||||
|
|
@ -245,7 +258,7 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
}
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty("ram",
|
||||
status.addProperty(Status.RAM,
|
||||
new Quantity(new BigDecimal(event.size()), Format.BINARY_SI)
|
||||
.toSuffixedString());
|
||||
return status;
|
||||
|
|
@ -265,7 +278,7 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
}
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty("cpus", event.usedCpus().size());
|
||||
status.addProperty(Status.CPUS, event.usedCpus().size());
|
||||
return status;
|
||||
});
|
||||
}
|
||||
|
|
@ -284,8 +297,8 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
}
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty("displayPasswordSerial",
|
||||
status.get("displayPasswordSerial").getAsLong() + 1);
|
||||
status.addProperty(Status.DISPLAY_PASSWORD_SERIAL,
|
||||
status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1);
|
||||
return status;
|
||||
});
|
||||
}
|
||||
|
|
@ -314,12 +327,60 @@ public class StatusUpdater extends VmDefUpdater {
|
|||
}
|
||||
var asGson = gson.toJsonTree(
|
||||
objectMapper.convertValue(event.osinfo(), Object.class));
|
||||
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.add("osinfo", asGson);
|
||||
status.add(Status.OSINFO, asGson);
|
||||
return status;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param event the event
|
||||
* @throws ApiException
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AssignmentInOperand")
|
||||
public void onVmopAgentConnected(VmopAgentConnected event)
|
||||
throws ApiException {
|
||||
VmDefinition vmDef;
|
||||
if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) {
|
||||
return;
|
||||
}
|
||||
vmStub.updateStatus(from -> {
|
||||
return updateCondition(vmDef, "VmopAgentConnected",
|
||||
true, "VmopAgentStarted", "The VM operator agent is running");
|
||||
}, vmDef);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param event the event
|
||||
* @throws ApiException
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AssignmentInOperand")
|
||||
public void onVmopAgentLoggedIn(VmopAgentLoggedIn event)
|
||||
throws ApiException {
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty(Status.LOGGED_IN_USER,
|
||||
event.triggering().user());
|
||||
return status;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param event the event
|
||||
* @throws ApiException
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AssignmentInOperand")
|
||||
public void onVmopAgentLoggedOut(VmopAgentLoggedOut event)
|
||||
throws ApiException {
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.remove(Status.LOGGED_IN_USER);
|
||||
return status;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import java.util.Optional;
|
|||
import java.util.logging.Level;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sGenericStub;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.runner.qemu.events.Exit;
|
||||
import org.jgrapes.core.Channel;
|
||||
|
|
@ -109,17 +110,21 @@ public class VmDefUpdater extends Component {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update condition.
|
||||
* Update condition. The `from` VM definition is used to determine the
|
||||
* observed generation and the current status. This method is intended
|
||||
* to be called in the function passed to
|
||||
* {@link K8sGenericStub#updateStatus}.
|
||||
*
|
||||
* @param from the VM definition
|
||||
* @param status the current status
|
||||
* @param type the condition type
|
||||
* @param state the new state
|
||||
* @param reason the reason for the change
|
||||
* @param message the message
|
||||
* @return the updated status
|
||||
*/
|
||||
protected void updateCondition(VmDefinition from, JsonObject status,
|
||||
String type, boolean state, String reason, String message) {
|
||||
protected JsonObject updateCondition(VmDefinition from, String type,
|
||||
boolean state, String reason, String message) {
|
||||
JsonObject status = from.statusJson();
|
||||
// Optimize, as we can get this several times
|
||||
var current = status.getAsJsonArray("conditions").asList().stream()
|
||||
.map(cond -> (JsonObject) cond)
|
||||
|
|
@ -127,7 +132,7 @@ public class VmDefUpdater extends Component {
|
|||
.findFirst()
|
||||
.map(cond -> "True".equals(cond.get("status").getAsString()));
|
||||
if (current.isPresent() && current.get() == state) {
|
||||
return;
|
||||
return status;
|
||||
}
|
||||
|
||||
// Do update
|
||||
|
|
@ -150,5 +155,6 @@ public class VmDefUpdater extends Component {
|
|||
newConds.addAll(toReplace);
|
||||
status.add("conditions",
|
||||
apiClient.getJSON().getGson().toJsonTree(newConds));
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
# https://fedoraproject.org/wiki/Features/VirtioSerial
|
||||
- [ "-device", "virtio-serial-pci,id=virtio-serial0" ]
|
||||
# - Guest agent serial connection. MUST have id "channel0"!
|
||||
# - Guest agent serial connection.
|
||||
- [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\
|
||||
chardev=guest-agent-socket" ]
|
||||
- [ "-chardev","socket,id=guest-agent-socket,\
|
||||
path=${ runtimeDir }/org.qemu.guest_agent.0,server=on,wait=off" ]
|
||||
# - VM operator agent serial connection.
|
||||
- [ "-device", "virtserialport,id=channel1,name=org.jdrupes.vmop_agent.0,\
|
||||
chardev=vmop-agent-socket" ]
|
||||
- [ "-chardev","socket,id=vmop-agent-socket,\
|
||||
path=${ runtimeDir }/org.jdrupes.vmop_agent.0,server=on,wait=off" ]
|
||||
# * USB Hub and devices (more in SPICE configuration below)
|
||||
# https://qemu-project.gitlab.io/qemu/system/devices/usb.html
|
||||
# https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ archlinux.svg:
|
|||
debian.svg:
|
||||
Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg
|
||||
License : LGPL
|
||||
|
||||
|
||||
fedora.svg:
|
||||
Source: https://commons.wikimedia.org/wiki/File:Fedora_icon_(2021).svg
|
||||
License: Public Domain
|
||||
|
||||
tux.svg:
|
||||
Source: https://commons.wikimedia.org/wiki/File:Tux.svghttps://commons.wikimedia.org/wiki/File:Tux.svg
|
||||
License: Creative Commons CC0 1.0 Universal Public Domain Dedication. Creative Commons CC0 1.0 Universal Public Domain Dedication.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 1.6 KiB |
|
|
@ -49,11 +49,11 @@ import org.jdrupes.vmoperator.common.VmDefinition;
|
|||
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
|
||||
import org.jdrupes.vmoperator.common.VmPool;
|
||||
import org.jdrupes.vmoperator.manager.events.AssignVm;
|
||||
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
|
||||
import org.jdrupes.vmoperator.manager.events.GetPools;
|
||||
import org.jdrupes.vmoperator.manager.events.GetVms;
|
||||
import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
|
||||
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
||||
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
|
||||
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
|
|
@ -808,18 +808,23 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
|
|||
Map.of("autoClose", 5_000, "type", "Warning")));
|
||||
return;
|
||||
}
|
||||
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
|
||||
e -> {
|
||||
vmDef.extra()
|
||||
.map(xtra -> xtra.connectionFile(e.password().orElse(null),
|
||||
preferredIpVersion, deleteConnectionFile))
|
||||
.ifPresent(
|
||||
cf -> channel.respond(new NotifyConletView(type(),
|
||||
model.getConletId(), "openConsole", cf)));
|
||||
});
|
||||
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user,
|
||||
model.mode() == ResourceModel.Mode.POOL),
|
||||
e -> gotPassword(channel, model, vmDef, e));
|
||||
fire(pwQuery, vmChannel);
|
||||
}
|
||||
|
||||
private void gotPassword(ConsoleConnection channel, ResourceModel model,
|
||||
VmDefinition vmDef, PrepareConsole event) {
|
||||
if (!event.passwordAvailable()) {
|
||||
return;
|
||||
}
|
||||
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
|
||||
preferredIpVersion, deleteConnectionFile))
|
||||
.ifPresent(cf -> channel.respond(new NotifyConletView(type(),
|
||||
model.getConletId(), "openConsole", cf)));
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.UseLocaleWithCaseConversions" })
|
||||
private void selectResource(NotifyConletModel event,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
|||
const configured = computed(() => previewApi.vmDefinition.spec);
|
||||
const busy = computed(() => previewApi.vmDefinition.spec
|
||||
&& (previewApi.vmDefinition.spec.vm.state === 'Running'
|
||||
&& !previewApi.vmDefinition.running
|
||||
&& (previewApi.poolName
|
||||
? !previewApi.vmDefinition.vmopAgent
|
||||
: !previewApi.vmDefinition.running)
|
||||
|| previewApi.vmDefinition.spec.vm.state === 'Stopped'
|
||||
&& previewApi.vmDefinition.running));
|
||||
const startable = computed(() => previewApi.vmDefinition.spec
|
||||
|
|
@ -85,6 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
|||
previewApi.vmDefinition.spec.vm.state !== 'Stopped'
|
||||
&& previewApi.vmDefinition.running);
|
||||
const running = computed(() => previewApi.vmDefinition.running);
|
||||
const vmopAgent = computed(() => previewApi.vmDefinition.vmopAgent);
|
||||
const inUse = computed(() => previewApi.vmDefinition.usedBy != '');
|
||||
const permissions = computed(() => previewApi.permissions);
|
||||
const osicon = computed(() => {
|
||||
|
|
@ -120,8 +123,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
|||
};
|
||||
|
||||
return { localize, resourceBase, vmAction, poolName, vmName,
|
||||
configured, busy, startable, stoppable, running, inUse,
|
||||
permissions, osicon };
|
||||
configured, busy, startable, stoppable, running, vmopAgent,
|
||||
inUse, permissions, osicon };
|
||||
},
|
||||
template: `
|
||||
<table>
|
||||
|
|
@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
|
|||
<tr>
|
||||
<td rowspan="2" style="position: relative"><span
|
||||
style="position: absolute;" :class="{ busy: busy }"
|
||||
><img role=button :aria-disabled="!running
|
||||
><img role=button :aria-disabled="(poolName
|
||||
? !vmopAgent : !running)
|
||||
|| !permissions.includes('accessConsole')"
|
||||
v-on:click="vmAction('openConsole')"
|
||||
:src="resourceBase + (running
|
||||
|
|
@ -206,14 +210,17 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
|
|||
vmDefinition.currentCpus = vmDefinition.status.cpus;
|
||||
vmDefinition.currentRam = Number(vmDefinition.status.ram);
|
||||
vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
|
||||
for (const condition of vmDefinition.status.conditions) {
|
||||
vmDefinition.status.conditions.forEach((condition: any) => {
|
||||
if (condition.type === "Running") {
|
||||
vmDefinition.running = condition.status === "True";
|
||||
vmDefinition.runningConditionSince
|
||||
= new Date(condition.lastTransitionTime);
|
||||
break;
|
||||
} else if (condition.type === "VmopAgentConnected") {
|
||||
vmDefinition.vmopAgent = condition.status === "True";
|
||||
vmDefinition.vmopAgentConditionSince
|
||||
= new Date(condition.lastTransitionTime);
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
vmDefinition = {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,14 @@ import java.util.Map;
|
|||
import java.util.Optional;
|
||||
import java.util.ResourceBundle;
|
||||
import java.util.Set;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8sObserver;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
|
||||
import org.jdrupes.vmoperator.common.VmExtraData;
|
||||
import org.jdrupes.vmoperator.manager.events.ChannelTracker;
|
||||
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
|
||||
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
||||
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
|
||||
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
|
|
@ -243,8 +244,8 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
|
|||
DataPath.<String> get(vmSpec, "currentRam").orElse("0")).getNumber()
|
||||
.toBigInteger());
|
||||
var status = DataPath.deepCopy(vmDef.status());
|
||||
status.put("ram", Quantity.fromString(
|
||||
DataPath.<String> get(status, "ram").orElse("0")).getNumber()
|
||||
status.put(Status.RAM, Quantity.fromString(
|
||||
DataPath.<String> get(status, Status.RAM).orElse("0")).getNumber()
|
||||
.toBigInteger());
|
||||
|
||||
// Build result
|
||||
|
|
@ -383,10 +384,10 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
|
|||
Summary summary = new Summary();
|
||||
for (var vmDef : channelTracker.associated()) {
|
||||
summary.totalVms += 1;
|
||||
summary.usedCpus += vmDef.<Number> fromStatus("cpus")
|
||||
summary.usedCpus += vmDef.<Number> fromStatus(Status.CPUS)
|
||||
.map(Number::intValue).orElse(0);
|
||||
summary.usedRam = summary.usedRam
|
||||
.add(vmDef.<String> fromStatus("ram")
|
||||
.add(vmDef.<String> fromStatus(Status.RAM)
|
||||
.map(r -> Quantity.fromString(r).getNumber().toBigInteger())
|
||||
.orElse(BigInteger.ZERO));
|
||||
if (vmDef.conditionStatus("Running").orElse(false)) {
|
||||
|
|
@ -483,17 +484,22 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
|
|||
Map.of("autoClose", 5_000, "type", "Warning")));
|
||||
return;
|
||||
}
|
||||
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
|
||||
e -> {
|
||||
vmDef.extra().map(xtra -> xtra.connectionFile(
|
||||
e.password().orElse(null), preferredIpVersion,
|
||||
deleteConnectionFile)).ifPresent(
|
||||
cf -> channel.respond(new NotifyConletView(type(),
|
||||
model.getConletId(), "openConsole", cf)));
|
||||
});
|
||||
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user),
|
||||
e -> gotPassword(channel, model, vmDef, e));
|
||||
fire(pwQuery, vmChannel);
|
||||
}
|
||||
|
||||
private void gotPassword(ConsoleConnection channel, VmsModel model,
|
||||
VmDefinition vmDef, PrepareConsole event) {
|
||||
if (!event.passwordAvailable()) {
|
||||
return;
|
||||
}
|
||||
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
|
||||
preferredIpVersion, deleteConnectionFile)).ifPresent(
|
||||
cf -> channel.respond(new NotifyConletView(type(),
|
||||
model.getConletId(), "openConsole", cf)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
|
||||
String conletId) throws Exception {
|
||||
|
|
|
|||
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
|
||||
|
||||
The VmViewer conlet has been renamed to VmAccess. This affects the
|
||||
[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration information using the old path
|
||||
"/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer"
|
||||
is still accepted for backward compatibility, but should be updated.
|
||||
* The VmViewer conlet has been renamed to VmAccess. This affects the
|
||||
[configuration](https://jdrupes.org/vm-operator/user-gui.html). Configuration
|
||||
information using the old path
|
||||
`/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer`
|
||||
is still accepted for backward compatibility until the next major version,
|
||||
but should be updated.
|
||||
|
||||
The change of name also causes conlets added to the overview page by
|
||||
users to "disappear" from the GUI. They have to be re-added.
|
||||
The change of name also causes conlets added to the overview page by
|
||||
users to "disappear" from the GUI. They have to be re-added.
|
||||
|
||||
The latter behavior also applies to the VmConlet conlet which has been
|
||||
renamed to VmMgmt.
|
||||
The latter behavior also applies to the VmConlet conlet which has been
|
||||
renamed to VmMgmt.
|
||||
|
||||
* The configuration property `passwordValidity` has been moved from component
|
||||
`/Manager/Controller/DisplaySecretMonitor` to
|
||||
`/Manager/Controller/Reconciler/DisplaySecretReconciler`. The old path is
|
||||
still accepted for backward compatibility until the next major version,
|
||||
but should be updated.
|
||||
|
||||
* The standard [template](./runner.html#stand-alone-configuration) used
|
||||
to generate the QEMU command has been updated. Unless you have enabled
|
||||
automatic updates of the template in the VM definition, you have to
|
||||
update the template manually. If you're using your own template, you
|
||||
have to add a virtual serial port (see the git history of the standard
|
||||
template for the required addition).
|
||||
|
||||
## To version 3.4.0
|
||||
|
||||
|
|
|
|||
|
|
@ -131,16 +131,20 @@ of 16 (strong) random bytes (128 random bits). It is valid for
|
|||
10 seconds only. This may be challenging on a slower computer
|
||||
or if users may not enable automatic open for connection files
|
||||
in the browser. The validity can therefore be adjusted in the
|
||||
configuration.
|
||||
configuration.[^oldPath]
|
||||
|
||||
```yaml
|
||||
"/Manager":
|
||||
"/Controller":
|
||||
"/DisplaySecretMonitor":
|
||||
# Validity of generated password in seconds
|
||||
passwordValidity: 10
|
||||
"/Reconciler":
|
||||
"/DisplaySecretReconciler":
|
||||
# Validity of generated password in seconds
|
||||
passwordValidity: 10
|
||||
```
|
||||
|
||||
[^oldPath]: Before version 4.0, the path for `passwordValidity` was
|
||||
`/Manager/Controller/DisplaySecretMonitor`.
|
||||
|
||||
Taking into account that the controller generates a display
|
||||
secret automatically by default, this approach to securing
|
||||
console access should be sufficient in all cases. (Any feedback
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue