Move automatic login request to CRD.

Also reorganizes constants.
This commit is contained in:
Michael Lipp 2025-03-01 11:02:52 +01:00
parent 3152ff842b
commit 5366e24092
22 changed files with 259 additions and 206 deletions

View file

@ -1430,6 +1430,12 @@ spec:
outputs:
type: integer
default: 1
loggedInUser:
description: >-
The name of a user that should be automatically
logged in on the display. Note that this requires
support from an agent in the guest OS.
type: string
spice:
type: object
properties:
@ -1485,6 +1491,11 @@ spec:
connection.
type: string
default: ""
loggedInUser:
description: >-
The name of a user that is currently logged in by the
VM operator agent.
type: string
displayPasswordSerial:
description: >-
Counts changes of the display password. Set to -1

View file

@ -126,8 +126,18 @@ attemptLogout() {
# 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"
}

View file

@ -27,31 +27,47 @@ public class Constants {
/** The Constant APP_NAME. */
public static final String APP_NAME = "vm-runner";
/** The Constant VM_OP_NAME. */
public static final String VM_OP_NAME = "vm-operator";
/**
* Constants related to the CRD.
*/
@SuppressWarnings("PMD.ShortClassName")
public static class Crd {
/** The Constant VM_OP_GROUP. */
public static final String VM_OP_GROUP = "vmoperator.jdrupes.org";
/** The Constant NAME. */
public static final String NAME = "vm-operator";
/** The Constant VM_OP_KIND_VM. */
public static final String VM_OP_KIND_VM = "VirtualMachine";
/** The Constant GROUP. */
public static final String GROUP = "vmoperator.jdrupes.org";
/** The Constant VM_OP_KIND_VM_POOL. */
public static final String VM_OP_KIND_VM_POOL = "VmPool";
/** The Constant KIND_VM. */
public static final String KIND_VM = "VirtualMachine";
/** The Constant COMP_DISPLAY_SECRETS. */
public static final String COMP_DISPLAY_SECRET = "display-secret";
/** The Constant KIND_VM_POOL. */
public static final String KIND_VM_POOL = "VmPool";
}
/** The Constant DATA_DISPLAY_PASSWORD. */
public static final String DATA_DISPLAY_PASSWORD = "display-password";
/**
* Constants for the display secret.
*/
public static class DisplaySecret {
/** The Constant DATA_PASSWORD_EXPIRY. */
public static final String DATA_PASSWORD_EXPIRY = "password-expiry";
/** The Constant NAME. */
public static final String NAME = "display-secret";
/** The Constant DATA_DISPLAY_USER. */
public static final String DATA_DISPLAY_USER = "display-user";
/** The Constant DISPLAY_PASSWORD. */
public static final String DISPLAY_PASSWORD = "display-password";
/** The Constant DATA_DISPLAY_LOGIN. */
public static final String DATA_DISPLAY_LOGIN = "login-user";
/** The Constant PASSWORD_EXPIRY. */
public static final String PASSWORD_EXPIRY = "password-expiry";
}
/**
* Constants for status fields.
*/
public static class Status {
/** The Constant LOGGED_IN_USER. */
public static final String LOGGED_IN_USER = "loggedInUser";
}
}

View file

@ -193,7 +193,7 @@ public class K8sGenericStub<O extends KubernetesObject,
}
/**
* Updates the object's status.
* Updates the object's status. This method will not retry.
*
* @param object the current state of the object (passed to `status`)
* @param status function that returns the new status
@ -231,7 +231,7 @@ public class K8sGenericStub<O extends KubernetesObject,
}
/**
* Updates the status.
* Updates the status. In case of conflict, retries up to 16 times.
*
* @param status the status
* @return the kubernetes api response

View file

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

View file

@ -33,9 +33,9 @@ import java.io.IOException;
import java.io.StringWriter;
import java.util.Map;
import java.util.logging.Logger;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.K8s;
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.util.DataPath;
import org.jdrupes.vmoperator.util.GsonPtr;
@ -121,7 +121,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
DynamicKubernetesObject newCm) {
ListOptions listOpts = new ListOptions();
listOpts.setLabelSelector(
"app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
"app.kubernetes.io/managed-by=" + Crd.NAME + ","
+ "app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/instance=" + newCm.getMetadata()
.getLabels().get("app.kubernetes.io/instance"));

View file

@ -29,8 +29,7 @@ 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.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicStub;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
@ -194,7 +193,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,7 +226,7 @@ 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();

View file

@ -28,11 +28,11 @@ import io.kubernetes.client.util.generic.options.PatchOptions;
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_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sV1PodStub;
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
import org.jdrupes.vmoperator.manager.events.ChannelDictionary;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jgrapes.core.Channel;
@ -61,7 +61,7 @@ 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);
}
@ -95,7 +95,7 @@ public class DisplaySecretMonitor
// Force update for pod
ListOptions listOpts = new ListOptions();
listOpts.setLabelSelector(
"app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
"app.kubernetes.io/managed-by=" + Crd.NAME + ","
+ "app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/instance=" + change.object.getMetadata()
.getLabels().get("app.kubernetes.io/instance"));

View file

@ -26,7 +26,6 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1Secret;
import io.kubernetes.client.util.generic.options.ListOptions;
import java.io.IOException;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
@ -34,20 +33,16 @@ import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Scanner;
import java.util.logging.Logger;
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 org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_LOGIN;
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_USER;
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -146,7 +141,7 @@ public class DisplaySecretReconciler extends Component {
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);
@ -157,9 +152,9 @@ public class DisplaySecretReconciler extends Component {
// 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;
@ -172,8 +167,8 @@ public class DisplaySecretReconciler extends Component {
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.DISPLAY_PASSWORD, password,
DisplaySecret.PASSWORD_EXPIRY, "now"));
K8sV1SecretStub.create(channel.client(), secret);
}
@ -192,49 +187,31 @@ public class DisplaySecretReconciler extends Component {
public void onPrepareConsole(PrepareConsole event, VmChannel channel)
throws ApiException {
// Update console user in status
var vmStub = VmDefinitionStub.get(channel.client(),
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
var optVmDef = vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.addProperty("consoleUser", event.user());
return status;
});
if (optVmDef.isEmpty()) {
var vmDef = updateConsoleUser(event, channel);
if (vmDef == null) {
return;
}
var vmDef = optVmDef.get();
// Check if access is possible
if (event.loginUser()
? !vmDef.conditionStatus("Booted").orElse(false)
? !vmDef.<String> fromStatus(Status.LOGGED_IN_USER)
.map(u -> u.equals(event.user())).orElse(false)
: !vmDef.conditionStatus("Running").orElse(false)) {
return;
}
// 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=" + 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);
// Get secret and update password in secret
var stub = getSecretStub(event, channel, vmDef);
if (stub == null) {
return;
}
var stub = stubs.iterator().next();
// Get secret and update
var secret = stub.model().get();
var updPw = updatePassword(secret, event);
var updUsr = updateUser(secret, event);
if (!updPw && !updUsr) {
if (!updatePassword(secret, event)) {
return;
}
// Register wait for confirmation (by VM status change)
// 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));
@ -247,30 +224,45 @@ public class DisplaySecretReconciler extends Component {
stub.update(secret).getObject();
}
private boolean updateUser(V1Secret secret, PrepareConsole event) {
var curUser = DataPath.<byte[]> get(secret, "data", DATA_DISPLAY_USER)
.map(b -> new String(b, UTF_8)).orElse(null);
var curLogin = DataPath.<byte[]> get(secret, "data", DATA_DISPLAY_LOGIN)
.map(b -> new String(b, UTF_8)).map(Boolean::parseBoolean)
.orElse(null);
if (Objects.equals(curUser, event.user()) && Objects.equals(
curLogin, event.loginUser())) {
return false;
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("consoleUser", 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;
}
secret.getData().put(DATA_DISPLAY_USER, event.user().getBytes(UTF_8));
secret.getData().put(DATA_DISPLAY_LOGIN,
Boolean.toString(event.loginUser()).getBytes(UTF_8));
return true;
return stubs.iterator().next();
}
private boolean updatePassword(V1Secret secret, PrepareConsole event) {
var expiry = Optional.ofNullable(secret.getData()
.get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null);
if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null
.get(DisplaySecret.PASSWORD_EXPIRY)).map(b -> new String(b))
.orElse(null);
if (secret.getData().get(DisplaySecret.DISPLAY_PASSWORD) != null
&& stillValid(expiry)) {
// Fixed secret, don't touch
event.setResult(
new String(secret.getData().get(DATA_DISPLAY_PASSWORD)));
new String(
secret.getData().get(DisplaySecret.DISPLAY_PASSWORD)));
return false;
}
@ -285,8 +277,8 @@ public class DisplaySecretReconciler extends Component {
byte[] bytes = new byte[16];
random.nextBytes(bytes);
var password = Base64.encode(bytes);
secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
DATA_PASSWORD_EXPIRY,
secret.setStringData(Map.of(DisplaySecret.DISPLAY_PASSWORD, password,
DisplaySecret.PASSWORD_EXPIRY,
Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
event.setResult(password);
return true;

View file

@ -40,7 +40,7 @@ import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.manager.events.Exit;
import org.jdrupes.vmoperator.util.FsdUtils;
import org.jgrapes.core.Channel;
@ -108,7 +108,7 @@ public class Manager extends Component {
// Configuration store with file in /etc/opt (default)
File cfgFile = new File(cmdLine.getOptionValue('c',
"/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml"));
"/etc/opt/" + Crd.NAME.replace("-", "") + "/config.yaml"));
logger.config(() -> "Using configuration from: " + cfgFile.getPath());
// Don't rely on night config to produce a good exception
// for this simple case
@ -271,7 +271,7 @@ public class Manager extends Component {
try {
// Get logging properties from file and put them in effect
InputStream props;
var path = FsdUtils.findConfigFile(VM_OP_NAME.replace("-", ""),
var path = FsdUtils.findConfigFile(Crd.NAME.replace("-", ""),
"logging.properties");
if (path.isPresent()) {
props = Files.newInputStream(path.get());

View file

@ -28,8 +28,7 @@ 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.K8s;
import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicModel;
@ -38,7 +37,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 +86,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,7 +182,7 @@ 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

View file

@ -36,7 +36,7 @@ import java.util.Set;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.K8sV1PvcStub;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -83,7 +83,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
// Existing disks
ListOptions listOpts = new ListOptions();
listOpts.setLabelSelector(
"app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
"app.kubernetes.io/managed-by=" + Crd.NAME + ","
+ "app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/instance=" + vmDef.name());
var knownDisks = K8sV1PvcStub.list(channel.client(),

View file

@ -46,12 +46,12 @@ 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;
@ -276,7 +276,7 @@ public class Reconciler extends Component {
// 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)

View file

@ -31,8 +31,7 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.stream.Collectors;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicStub;
@ -46,7 +45,6 @@ import org.jdrupes.vmoperator.common.VmDefinitions;
import org.jdrupes.vmoperator.common.VmExtraData;
import org.jdrupes.vmoperator.common.VmPool;
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.manager.events.AssignVm;
import org.jdrupes.vmoperator.manager.events.ChannelManager;
import org.jdrupes.vmoperator.manager.events.GetPools;
@ -87,7 +85,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;
@ -105,7 +103,7 @@ public class VmMonitor extends
.stream().map(stub -> stub.name()).collect(Collectors.toSet());
ListOptions opts = new ListOptions();
opts.setLabelSelector(
"app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
"app.kubernetes.io/managed-by=" + Crd.NAME + ","
+ "app.kubernetes.io/name=" + APP_NAME);
for (var context : Set.of(K8sV1StatefulSetStub.CONTEXT,
K8sV1ConfigMapStub.CONTEXT)) {

View file

@ -13,10 +13,8 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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 static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicStub;
@ -60,7 +58,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 +68,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();
@ -100,7 +98,7 @@ class BasicTests {
private static void deletePvcs() throws ApiException {
ListOptions listOpts = new ListOptions();
listOpts.setLabelSelector(
"app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
"app.kubernetes.io/managed-by=" + Crd.NAME + ","
+ "app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/instance=" + VM_NAME);
var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts);
@ -139,11 +137,11 @@ class BasicTests {
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,
Crd.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 +187,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);
@ -220,7 +218,7 @@ class BasicTests {
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));
Crd.NAME));
checkProps(pvc.getSpec(), Map.of(
List.of("resources", "requests", "storage"),
Quantity.fromString("1Mi")));
@ -241,7 +239,7 @@ class BasicTests {
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,
Crd.NAME,
List.of("annotations", "use_as"), "system-disk"));
checkProps(pvc.getSpec(), Map.of(
List.of("resources", "requests", "storage"),
@ -263,7 +261,7 @@ class BasicTests {
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));
Crd.NAME));
checkProps(pvc.getSpec(), Map.of(
List.of("resources", "requests", "storage"),
Quantity.fromString("1Gi")));
@ -291,12 +289,12 @@ class BasicTests {
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,
Crd.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(
@ -319,7 +317,7 @@ class BasicTests {
checkProps(svc.getMetadata(), Map.of(
List.of("labels", "app.kubernetes.io/name"), APP_NAME,
List.of("labels", "app.kubernetes.io/instance"), VM_NAME,
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME,
List.of("labels", "app.kubernetes.io/managed-by"), Crd.NAME,
List.of("labels", "label1"), "label1",
List.of("labels", "label2"), "replaced",
List.of("labels", "label3"), "added",

View file

@ -19,8 +19,8 @@
handlers=java.util.logging.ConsoleHandler
org.jgrapes.level=FINE
org.jgrapes.core.handlerTracking.level=FINER
#org.jgrapes.level=FINE
#org.jgrapes.core.handlerTracking.level=FINER
org.jdrupes.vmoperator.runner.qemu.level=FINE

View file

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

View file

@ -25,8 +25,7 @@ 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.K8s;
import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
@ -74,7 +73,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,
@ -115,7 +114,7 @@ public class ConsoleTracker extends VmDefUpdater {
// 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);
@ -150,7 +149,7 @@ public class ConsoleTracker extends VmDefUpdater {
// Log event
var evt = new EventsV1Event()
.reportingController(VM_OP_GROUP + "/" + APP_NAME)
.reportingController(Crd.GROUP + "/" + APP_NAME)
.action("ConsoleConnectionUpdate")
.reason("Disconnected from " + event.clientHost());
K8s.createEvent(apiClient, vmStub.model().get(), evt);

View file

@ -1,6 +1,6 @@
/*
* VM-Operator
* Copyright (C) 2023 Michael N. Lipp
* Copyright (C) 2023,2025 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@ -24,10 +24,7 @@ import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_LOGIN;
import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD;
import static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_USER;
import static org.jdrupes.vmoperator.common.Constants.DATA_PASSWORD_EXPIRY;
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;
@ -35,9 +32,10 @@ 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.VmopAgentLoggedIn;
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;
@ -52,6 +50,7 @@ public class DisplayController extends Component {
private String protocol;
private final Path configDir;
private boolean vmopAgentConnected;
private String loggedInUser;
/**
* Instantiates a new Display controller.
@ -64,17 +63,7 @@ public class DisplayController extends Component {
public DisplayController(Channel componentChannel, Path configDir) {
super(componentChannel);
this.configDir = configDir;
fire(new WatchFile(configDir.resolve(DATA_DISPLAY_PASSWORD)));
}
/**
* On vmop agent connected.
*
* @param event the event
*/
@Handler
public void onVmopAgentConnected(VmopAgentConnected event) {
vmopAgentConnected = true;
fire(new WatchFile(configDir.resolve(DisplaySecret.DISPLAY_PASSWORD)));
}
/**
@ -89,7 +78,32 @@ public class DisplayController extends Component {
}
protocol
= event.configuration().vm.display.spice != null ? "spice" : null;
configureAccess(false);
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);
}
/**
@ -100,46 +114,10 @@ public class DisplayController extends Component {
@Handler
@SuppressWarnings("PMD.EmptyCatchBlock")
public void onFileChanged(FileChanged event) {
if (event.path().equals(configDir.resolve(DATA_DISPLAY_PASSWORD))) {
configureAccess(true);
}
}
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
private void configureAccess(boolean passwordChange) {
var userLoginConfigured = readFromFile(DATA_DISPLAY_LOGIN)
.map(Boolean::parseBoolean).orElse(false);
if (!userLoginConfigured) {
if (event.path()
.equals(configDir.resolve(DisplaySecret.DISPLAY_PASSWORD))) {
configurePassword();
return;
}
// With user login configured, we have to make sure that the
// user is logged in before we set the password and thus allow
// access to the display.
if (!vmopAgentConnected) {
if (passwordChange) {
logger.warning(() -> "Request for user login before "
+ "VM operator agent has connected");
}
return;
}
var user = readFromFile(DATA_DISPLAY_USER);
if (user.isEmpty()) {
logger.warning(() -> "Login requested, but no user configured");
}
fire(new VmopAgentLogIn(user.get()).setAssociated(this, user.get()));
}
/**
* On vmop agent logged in.
*
* @param event the event
*/
@Handler
public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) {
configurePassword();
}
private void configurePassword() {
@ -152,7 +130,7 @@ public class DisplayController extends Component {
}
private boolean setDisplayPassword() {
return readFromFile(DATA_DISPLAY_PASSWORD).map(password -> {
return readFromFile(DisplaySecret.DISPLAY_PASSWORD).map(password -> {
if (Objects.equals(this.currentPassword, password)) {
return true;
}
@ -165,7 +143,7 @@ public class DisplayController extends Component {
}
private void setPasswordExpiry() {
readFromFile(DATA_PASSWORD_EXPIRY).ifPresent(expiry -> {
readFromFile(DisplaySecret.PASSWORD_EXPIRY).ifPresent(expiry -> {
logger.fine(() -> "Updating expiry time to " + expiry);
fire(
new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry)));

View file

@ -56,7 +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 static org.jdrupes.vmoperator.common.Constants.DATA_DISPLAY_PASSWORD;
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;
@ -312,7 +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(DATA_DISPLAY_PASSWORD);
Path dsPath = configDir.resolve(DisplaySecret.DISPLAY_PASSWORD);
newConf.hasDisplayPassword = dsPath.toFile().canRead();
// Special actions for initial configuration (startup)

View file

@ -33,8 +33,8 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
@ -48,6 +48,8 @@ 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;
@ -110,11 +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.updateStatus(from -> {
JsonObject status = from.statusJson();
status.remove(Status.LOGGED_IN_USER);
return status;
}).orElse(null);
if (vmDef == null) {
return;
}
observedGeneration = vmDef.getMetadata().getGeneration();
} catch (ApiException e) {
logger.log(Level.SEVERE, e,
() -> "Cannot access VM object, terminating.");
@ -152,7 +160,7 @@ public class StatusUpdater extends VmDefUpdater {
"displayPasswordSerial").getAsInt() == -1)) {
return;
}
vmStub.updateStatus(vmDef.get(), from -> {
vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
if (!event.configuration().hasDisplayPassword) {
status.addProperty("displayPasswordSerial", -1);
@ -173,15 +181,15 @@ 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 -> {
vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
boolean running = event.runState().vmRunning();
updateCondition(vmDef, vmDef.statusJson(), "Running", running,
@ -196,6 +204,7 @@ public class StatusUpdater extends VmDefUpdater {
} else if (event.runState() == RunState.STOPPED) {
status.addProperty("ram", "0");
status.addProperty("cpus", 0);
status.remove(Status.LOGGED_IN_USER);
}
if (!running) {
@ -228,7 +237,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);
@ -344,4 +353,35 @@ public class StatusUpdater extends VmDefUpdater {
return status;
});
}
/**
* @param event the event
* @throws ApiException
*/
@Handler
@SuppressWarnings("PMD.AssignmentInOperand")
public void onVmopAgentLoggedIn(VmopAgentLoggedIn event)
throws ApiException {
vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.addProperty(Status.LOGGED_IN_USER,
event.triggering().user());
return status;
});
}
/**
* @param event the event
* @throws ApiException
*/
@Handler
@SuppressWarnings("PMD.AssignmentInOperand")
public void onVmopAgentLoggedOut(VmopAgentLoggedOut event)
throws ApiException {
vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.remove(Status.LOGGED_IN_USER);
return status;
});
}
}

View file

@ -12,11 +12,12 @@ layout: vm-operator
### 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 only possibility to access the VMs should be via a desktop started by
the VM-Operator.
The VMs should only be accessible via a desktop started by the VM-Operator.
* Disable the display manager.
@ -32,9 +33,16 @@ the VM-Operator.
# 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.
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.