Merge branch 'feature/auto-login'

This commit is contained in:
Michael Lipp 2025-03-05 13:37:09 +01:00
commit 0e57a4e862
30 changed files with 462 additions and 291 deletions

38
.markdownlint.yaml Normal file
View file

@ -0,0 +1,38 @@
# See https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml
# Default state for all rules
default: true
# MD007/ul-indent : Unordered list indentation :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md007.md
MD007:
# Spaces for indent
indent: 2
# Whether to indent the first level of the list
start_indented: true
# Spaces for first level indent (when start_indented is set)
start_indent: 2
# MD025/single-title/single-h1 : Multiple top-level headings in the same document :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md025.md
MD025:
# Heading level
level: 1
# RegExp for matching title in front matter (disable)
front_matter_title: ""
# MD036/no-emphasis-as-heading : Emphasis used instead of a heading :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md036.md
MD036: false
# MD043/required-headings : Required heading structure :
# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md043.md
MD043:
# List of headings
headings: [
"# Head",
"## Item",
"### Detail"
]
# Match case of headings
match_case: false

View file

@ -25,6 +25,12 @@ spec:
type: string
pattern: '^(?:\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3])(?:|:?[0-5]\d))|P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+[Hh])?(?:\d+[Mm])?(?:\d+(?:\.\d{1,9})?[Ss])?)?)$'
default: "PT1h"
loginOnAssignment:
description: >-
If set to true, the user will be automatically logged in
to the VM's console when the VM is assigned to him.
type: boolean
default: false
permissions:
type: array
description: >-

View file

@ -1534,6 +1534,24 @@ spec:
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
- type: Booted
status: "False"
observedGeneration: 1
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
- type: VmopAgentConnected
status: "False"
observedGeneration: 1
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
- type: UserLoggedIn
status: "False"
observedGeneration: 1
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
- type: ConsoleConnected
status: "False"
observedGeneration: 1

View file

@ -5,6 +5,7 @@ metadata:
name: test-vms
spec:
retention: "PT1m"
loginOnAssignment: true
permissions:
- user: admin
may:

View file

@ -8,13 +8,9 @@ metadata:
spec:
image:
# repository: docker-registry.lan.mnl.de
# path: vmoperator/org.jdrupes.vmoperator.runner.qemu-arch
# pullPolicy: Always
# repository: ghcr.io
# path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine
# version: "3.0.0"
source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing
repository: ghcr.io
path: mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine
version: latest
pullPolicy: Always
permissions:

View file

@ -74,6 +74,37 @@ public class Constants {
/** The Constant ASSIGNMENT. */
public static final String ASSIGNMENT = "assignment";
/**
* Conditions used in Status.
*/
public static class Condition {
/** The Constant COND_RUNNING. */
public static final String RUNNING = "Running";
/** The Constant COND_BOOTED. */
public static final String BOOTED = "Booted";
/** The Constant COND_VMOP_AGENT. */
public static final String VMOP_AGENT = "VmopAgentConnected";
/** The Constant COND_USER_LOGGED_IN. */
public static final String USER_LOGGED_IN = "UserLoggedIn";
/** The Constant COND_CONSOLE. */
public static final String CONSOLE_CONNECTED = "ConsoleConnected";
/**
* Reasons used in conditions.
*/
public static class Reason {
/** The Constant NOT_REQUESTED. */
public static final String NOT_REQUESTED = "NotRequested";
/** The Constant USER_LOGGED_IN. */
public static final String LOGGED_IN = "LoggedIn";
}
}
}
/**

View file

@ -39,6 +39,8 @@ 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.common.Constants.Status.Condition;
import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason;
import org.jdrupes.vmoperator.util.DataPath;
/**
@ -141,6 +143,16 @@ public class VmDefinition extends K8sDynamicModel {
}
}
/**
* The assignment information.
*
* @param pool the pool
* @param user the user
* @param lastUsed the last used
*/
public record Assignment(String pool, String user, Instant lastUsed) {
}
/**
* Instantiates a new vm definition.
*
@ -215,31 +227,15 @@ public class VmDefinition extends K8sDynamicModel {
}
/**
* The pool that the VM was taken from.
* The assignment information.
*
* @return the optional
*/
public Optional<String> assignedFrom() {
return fromStatus(Status.ASSIGNMENT, "pool");
}
/**
* The user that the VM was assigned to.
*
* @return the optional
*/
public Optional<String> assignedTo() {
return fromStatus(Status.ASSIGNMENT, "user");
}
/**
* Last usage of assigned VM.
*
* @return the optional
*/
public Optional<Instant> assignmentLastUsed() {
return this.<String> fromStatus(Status.ASSIGNMENT, "lastUsed")
.map(Instant::parse);
public Optional<Assignment> assignment() {
return this.<Map<String, Object>> fromStatus(Status.ASSIGNMENT)
.filter(m -> !m.isEmpty()).map(a -> new Assignment(
a.get("pool").toString(), a.get("user").toString(),
Instant.parse(a.get("lastUsed").toString())));
}
/**
@ -369,18 +365,47 @@ public class VmDefinition extends K8sDynamicModel {
}
/**
* Check if the console is accessible. Returns true if the console is
* currently unused, used by the given user or if the permissions
* allow taking over the console.
* Check if the console is accessible. Always returns `true` if
* the VM is running and the permissions allow taking over the
* console. Else, returns `true` if
*
* * the permissions allow access to the console and
*
* * the VM is running and
*
* * the console is currently unused or used by the given user and
*
* * if user login is requested, the given user is logged in.
*
* @param user the user
* @param permissions the permissions
* @return true, if successful
*/
@SuppressWarnings("PMD.SimplifyBooleanReturns")
public boolean consoleAccessible(String user, Set<Permission> permissions) {
return !conditionStatus("ConsoleConnected").orElse(true)
|| consoleUser().map(cu -> cu.equals(user)).orElse(true)
|| permissions.contains(VmDefinition.Permission.TAKE_CONSOLE);
// Basic checks
if (!conditionStatus(Condition.RUNNING).orElse(false)) {
return false;
}
if (permissions.contains(Permission.TAKE_CONSOLE)) {
return true;
}
if (!permissions.contains(Permission.ACCESS_CONSOLE)) {
return false;
}
// If the console is in use by another user, deny access
if (conditionStatus(Condition.CONSOLE_CONNECTED).orElse(false)
&& !consoleUser().map(cu -> cu.equals(user)).orElse(false)) {
return false;
}
// If no login is requested, allow access, else check if user matches
if (condition(Condition.USER_LOGGED_IN).map(V1Condition::getReason)
.map(r -> Reason.NOT_REQUESTED.equals(r)).orElse(false)) {
return true;
}
return user.equals(status().get(Status.LOGGED_IN_USER));
}
/**

View file

@ -27,6 +27,7 @@ import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
import org.jdrupes.vmoperator.common.VmDefinition.Grant;
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.util.DataPath;
@ -37,8 +38,9 @@ import org.jdrupes.vmoperator.util.DataPath;
@SuppressWarnings({ "PMD.DataClass" })
public class VmPool {
private String name;
private final String name;
private String retention;
private boolean loginOnAssignment;
private boolean defined;
private List<Grant> permissions = Collections.emptyList();
private final Set<String> vms
@ -53,6 +55,19 @@ public class VmPool {
this.name = name;
}
/**
* Fill the properties of a provisionally created pool from
* the definition.
*
* @param definition the definition
*/
public void defineFrom(VmPool definition) {
retention = definition.retention();
permissions = definition.permissions();
loginOnAssignment = definition.loginOnAssignment();
defined = true;
}
/**
* Returns the name.
*
@ -63,12 +78,12 @@ public class VmPool {
}
/**
* Sets the name.
* Checks if is login on assignment.
*
* @param name the name to set
* @return the loginOnAssignment
*/
public void setName(String name) {
this.name = name;
public boolean loginOnAssignment() {
return loginOnAssignment;
}
/**
@ -81,12 +96,10 @@ public class VmPool {
}
/**
* Sets if is.
*
* @param defined the defined to set
* Marks the pool as undefined.
*/
public void setDefined(boolean defined) {
this.defined = defined;
public void setUndefined() {
defined = false;
}
/**
@ -98,15 +111,6 @@ public class VmPool {
return retention;
}
/**
* Sets the retention.
*
* @param retention the retention to set
*/
public void setRetention(String retention) {
this.retention = retention;
}
/**
* Permissions granted for a VM from the pool.
*
@ -116,15 +120,6 @@ public class VmPool {
return permissions;
}
/**
* Sets the permissions.
*
* @param permissions the permissions to set
*/
public void setPermissions(List<Grant> permissions) {
this.permissions = permissions;
}
/**
* Returns the VM names.
*
@ -171,13 +166,12 @@ public class VmPool {
}
// If not assigned, it's usable
if (vmDef.assignedTo().isEmpty()) {
if (vmDef.assignment().isEmpty()) {
return true;
}
// Check if it is to be retained
if (vmDef.assignmentLastUsed()
.map(this::retainUntil)
if (vmDef.assignment().map(Assignment::lastUsed).map(this::retainUntil)
.map(ru -> Instant.now().isBefore(ru)).orElse(false)) {
return false;
}

View file

@ -25,46 +25,29 @@ import org.jgrapes.core.Event;
* Gets the current display secret and optionally updates it.
*/
@SuppressWarnings("PMD.DataClass")
public class PrepareConsole extends Event<String> {
public class GetDisplaySecret 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
* no secret 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);
public GetDisplaySecret(VmDefinition vmDef, String user) {
this.vmDef = vmDef;
this.user = user;
}
/**
* Gets the vm definition.
* Gets the VM definition.
*
* @return the vm definition
* @return the VM definition
*/
public VmDefinition vmDefinition() {
return vmDef;
@ -79,24 +62,15 @@ public class PrepareConsole extends Event<String> {
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
* by {@link #secret()} may be `null`, indicating that no password
* is needed.
*
* @return true, if successful
*/
public boolean passwordAvailable() {
public boolean secretAvailable() {
if (!isDone()) {
throw new IllegalStateException("Event is not done.");
}
@ -104,13 +78,13 @@ public class PrepareConsole extends Event<String> {
}
/**
* Return the password. May only be called when the event has been
* completed with a valid result (see {@link #passwordAvailable()}).
* Return the secret. May only be called when the event has been
* completed with a valid result (see {@link #secretAvailable()}).
*
* @return the password. A value of `null` means that no password
* is required.
*/
public String password() {
public String secret() {
if (!isDone() || currentResults().isEmpty()) {
throw new IllegalStateException("Event is not done.");
}

View file

@ -18,6 +18,7 @@
package org.jdrupes.vmoperator.manager.events;
import org.jdrupes.vmoperator.common.VmPool;
import org.jgrapes.core.Event;
/**
@ -26,31 +27,31 @@ import org.jgrapes.core.Event;
@SuppressWarnings("PMD.DataClass")
public class UpdateAssignment extends Event<Boolean> {
private final String usedPool;
private final VmPool fromPool;
private final String toUser;
/**
* Instantiates a new event.
*
* @param usedPool the used pool
* @param fromPool the pool from which the VM was assigned
* @param toUser the to user
*/
public UpdateAssignment(String usedPool, String toUser) {
this.usedPool = usedPool;
public UpdateAssignment(VmPool fromPool, String toUser) {
this.fromPool = fromPool;
this.toUser = toUser;
}
/**
* Gets the pool to assign from.
* Gets the pool from which the VM was assigned.
*
* @return the pool
*/
public String usedPool() {
return usedPool;
public VmPool fromPool() {
return fromPool;
}
/**
* Gets the user to assign to.
* Gets the user to whom the VM was assigned.
*
* @return the to user
*/

View file

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

View file

@ -232,7 +232,7 @@ public class Controller extends Component {
if (vmStub.updateStatus(vmDef, from -> {
JsonObject status = from.statusJson();
var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT);
assignment.set("pool", event.usedPool());
assignment.set("pool", event.fromPool().name());
assignment.set("user", event.toUser());
assignment.set("lastUsed", Instant.now().toString());
return status;

View file

@ -43,7 +43,7 @@ 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 org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.DataPath;
@ -71,7 +71,7 @@ public class DisplaySecretReconciler extends Component {
protected final Logger logger = Logger.getLogger(getClass().getName());
private int passwordValidity = 10;
private final List<PendingPrepare> pendingPrepares
private final List<PendingRequest> pendingPrepares
= Collections.synchronizedList(new LinkedList<>());
/**
@ -184,21 +184,23 @@ public class DisplaySecretReconciler extends Component {
*/
@Handler
@SuppressWarnings("PMD.StringInstantiation")
public void onPrepareConsole(PrepareConsole event, VmChannel channel)
public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel)
throws ApiException {
// Update console user in status
var vmDef = updateConsoleUser(event, channel);
if (vmDef == null) {
// Get VM definition and check if running
var vmStub = VmDefinitionStub.get(channel.client(),
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
var vmDef = vmStub.model().orElse(null);
if (vmDef == null || !vmDef.conditionStatus("Running").orElse(false)) {
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;
}
// Update console user in status
vmDef = vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.addProperty(Status.CONSOLE_USER, event.user());
return status;
}).get();
// Get secret and update password in secret
var stub = getSecretStub(event, channel, vmDef);
@ -212,7 +214,7 @@ public class DisplaySecretReconciler extends Component {
// Register wait for confirmation (by VM status change,
// after secret update)
var pending = new PendingPrepare(event,
var pending = new PendingRequest(event,
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
new CompletionLock(event, 1500));
pendingPrepares.add(pending);
@ -224,19 +226,7 @@ public class DisplaySecretReconciler extends Component {
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,
private K8sV1SecretStub getSecretStub(GetDisplaySecret event,
VmChannel channel, VmDefinition vmDef) throws ApiException {
// Look for secret
ListOptions options = new ListOptions();
@ -253,7 +243,7 @@ public class DisplaySecretReconciler extends Component {
return stubs.iterator().next();
}
private boolean updatePassword(V1Secret secret, PrepareConsole event) {
private boolean updatePassword(V1Secret secret, GetDisplaySecret event) {
var expiry = Optional.ofNullable(secret.getData()
.get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null);
if (secret.getData().get(DisplaySecret.PASSWORD) != null
@ -323,8 +313,8 @@ public class DisplaySecretReconciler extends Component {
* The Class PendingGet.
*/
@SuppressWarnings("PMD.DataClass")
private static class PendingPrepare {
public final PrepareConsole event;
private static class PendingRequest {
public final GetDisplaySecret event;
public final long expectedSerial;
public final CompletionLock lock;
@ -334,7 +324,7 @@ public class DisplaySecretReconciler extends Component {
* @param event the event
* @param expectedSerial the expected serial
*/
public PendingPrepare(PrepareConsole event, long expectedSerial,
public PendingRequest(GetDisplaySecret event, long expectedSerial,
CompletionLock lock) {
super();
this.event = event;

View file

@ -36,6 +36,7 @@ import org.jdrupes.vmoperator.common.K8sDynamicModel;
import org.jdrupes.vmoperator.common.K8sDynamicModels;
import org.jdrupes.vmoperator.common.K8sDynamicStub;
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
import org.jdrupes.vmoperator.common.VmPool;
import org.jdrupes.vmoperator.manager.events.GetPools;
@ -105,7 +106,7 @@ public class PoolMonitor extends
// When pool is deleted, save VMs in pending
if (type == ResponseType.DELETED) {
Optional.ofNullable(pools.get(poolName)).ifPresent(pool -> {
pool.setDefined(false);
pool.setUndefined();
if (pool.vms().isEmpty()) {
pools.remove(poolName);
}
@ -129,11 +130,8 @@ public class PoolMonitor extends
// Get pool and merge changes
var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName));
var newData = client().getJSON().getGson().fromJson(
GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class);
vmPool.setRetention(newData.retention());
vmPool.setPermissions(newData.permissions());
vmPool.setDefined(true);
vmPool.defineFrom(client().getJSON().getGson().fromJson(
GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class));
poolPipeline.fire(new VmPoolChanged(vmPool));
}
@ -168,7 +166,7 @@ public class PoolMonitor extends
}
// Sync last usage to console state change if user matches
if (vmDef.assignedTo()
if (vmDef.assignment().map(Assignment::user)
.map(at -> at.equals(vmDef.consoleUser().orElse(null)))
.orElse(true)) {
return;
@ -177,8 +175,8 @@ public class PoolMonitor extends
var ccChange = vmDef.condition("ConsoleConnected")
.map(cc -> cc.getLastTransitionTime().toInstant());
if (ccChange
.map(tt -> vmDef.assignmentLastUsed().map(alu -> alu.isAfter(tt))
.orElse(true))
.map(tt -> vmDef.assignment().map(Assignment::lastUsed)
.map(alu -> alu.isAfter(tt)).orElse(true))
.orElse(true)) {
return;
}

View file

@ -45,6 +45,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
import org.jdrupes.vmoperator.common.Convertions;
@ -52,6 +53,9 @@ 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 org.jdrupes.vmoperator.common.VmDefinition.Assignment;
import org.jdrupes.vmoperator.common.VmPool;
import org.jdrupes.vmoperator.manager.events.GetPools;
import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -212,11 +216,6 @@ public class Reconciler extends Component {
@SuppressWarnings("PMD.ConfusingTernary")
public void onVmDefChanged(VmDefChanged event, VmChannel channel)
throws ApiException, TemplateException, IOException {
// We're only interested in "spec" changes.
if (!event.specChanged()) {
return;
}
// Ownership relationships takes care of deletions
if (event.type() == K8sObserver.ResponseType.DELETED) {
logger.fine(
@ -228,6 +227,11 @@ public class Reconciler extends Component {
Map<String, Object> model
= prepareModel(channel.client(), event.vmDefinition());
var configMap = cmReconciler.reconcile(model, channel);
// The remaining reconcilers depend only on changes of the spec part.
if (!event.specChanged()) {
return;
}
model.put("cm", configMap);
dsReconciler.reconcile(event, model, channel);
// Manage (eventual) removal of stateful set.
@ -266,24 +270,10 @@ public class Reconciler extends Component {
Optional.ofNullable(Reconciler.class.getPackage()
.getImplementationVersion()).orElse("(Unknown)"));
model.put("cr", vmDef);
// 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=" + DisplaySecret.NAME + ","
+ "app.kubernetes.io/instance=" + vmDef.name());
var dsStub = K8sV1SecretStub
.list(client, vmDef.namespace(), options)
.stream()
.findFirst();
if (dsStub.isPresent()) {
dsStub.get().model().ifPresent(m -> {
model.put("displaySecret", m.getMetadata().getName());
});
}
model.put("constants", constantsMap(Constants.class));
addLoginRequestedFor(model, vmDef);
addDisplaySecret(client, model, vmDef);
// Methods
model.put("parseQuantity", parseQuantityModel);
@ -294,6 +284,13 @@ public class Reconciler extends Component {
return model;
}
/**
* Creates a map with constants. Needed because freemarker doesn't support
* nested classes with its static models.
*
* @param clazz the clazz
* @return the map
*/
@SuppressWarnings("PMD.EmptyCatchBlock")
private Map<String, Object> constantsMap(Class<?> clazz) {
@SuppressWarnings("PMD.UseConcurrentHashMap")
@ -318,6 +315,38 @@ public class Reconciler extends Component {
return result;
}
private void addLoginRequestedFor(Map<String, Object> model,
VmDefinition vmDef) {
vmDef.assignment().filter(a -> {
try {
return newEventPipeline()
.fire(new GetPools().withName(a.pool())).get()
.stream().findFirst().map(VmPool::loginOnAssignment)
.orElse(false);
} catch (InterruptedException e) {
logger.log(Level.WARNING, e, e::getMessage);
}
return false;
}).map(Assignment::user)
.or(() -> vmDef.fromSpec("vm", "display", "loggedInUser"))
.ifPresent(u -> model.put("loginRequestedFor", u));
}
private void addDisplaySecret(K8sClient client, Map<String, Object> model,
VmDefinition vmDef) throws ApiException {
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 dsStub = K8sV1SecretStub
.list(client, vmDef.namespace(), options).stream().findFirst();
if (dsStub.isPresent()) {
dsStub.get().model().ifPresent(m -> {
model.put("displaySecret", m.getMetadata().getName());
});
}
}
private final TemplateMethodModelEx parseQuantityModel
= new TemplateMethodModelEx() {
@Override

View file

@ -40,6 +40,7 @@ import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub;
import org.jdrupes.vmoperator.common.K8sV1PodStub;
import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
import org.jdrupes.vmoperator.common.VmDefinitions;
import org.jdrupes.vmoperator.common.VmExtraData;
@ -234,10 +235,10 @@ public class VmMonitor extends
|| !c.vmDefinition().permissionsFor(event.user().orElse(null),
event.roles()).isEmpty())
.filter(c -> event.fromPool().isEmpty()
|| c.vmDefinition().assignedFrom()
|| c.vmDefinition().assignment().map(Assignment::pool)
.map(p -> p.equals(event.fromPool().get())).orElse(false))
.filter(c -> event.toUser().isEmpty()
|| c.vmDefinition().assignedTo()
|| c.vmDefinition().assignment().map(Assignment::user)
.map(u -> u.equals(event.toUser().get())).orElse(false))
.map(c -> new VmData(c.vmDefinition(), c))
.toList());
@ -257,9 +258,9 @@ public class VmMonitor extends
while (true) {
// Search for existing assignment.
var vmQuery = channelManager.channels().stream()
.filter(c -> c.vmDefinition().assignedFrom()
.filter(c -> c.vmDefinition().assignment().map(Assignment::pool)
.map(p -> p.equals(event.fromPool())).orElse(false))
.filter(c -> c.vmDefinition().assignedTo()
.filter(c -> c.vmDefinition().assignment().map(Assignment::user)
.map(u -> u.equals(event.toUser())).orElse(false))
.findFirst();
if (vmQuery.isPresent()) {
@ -280,7 +281,8 @@ public class VmMonitor extends
vmQuery = channelManager.channels().stream()
.filter(c -> vmPool.isAssignable(c.vmDefinition()))
.sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition()
.assignmentLastUsed().orElse(Instant.ofEpochSecond(0)))
.assignment().map(Assignment::lastUsed)
.orElse(Instant.ofEpochSecond(0)))
.thenComparing(preferRunning))
.findFirst();
@ -293,7 +295,7 @@ public class VmMonitor extends
var chosenVm = vmQuery.get();
var vmPipeline = chosenVm.pipeline();
if (Optional.ofNullable(vmPipeline.fire(new UpdateAssignment(
vmPool.name(), event.toUser()), chosenVm).get())
vmPool, event.toUser()), chosenVm).get())
.orElse(false)) {
var vmDef = chosenVm.vmDefinition();
event.setResult(new VmData(vmDef, chosenVm));

View file

@ -35,6 +35,8 @@ import java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.Constants.Status.Condition;
import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason;
import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
@ -72,6 +74,7 @@ public class StatusUpdater extends VmDefUpdater {
private boolean guestShutdownStops;
private boolean shutdownByGuest;
private VmDefinitionStub vmStub;
private String loggedInUser;
/**
* Instantiates a new status updater.
@ -143,6 +146,7 @@ public class StatusUpdater extends VmDefUpdater {
public void onConfigureQemu(ConfigureQemu event)
throws ApiException {
guestShutdownStops = event.configuration().guestShutdownStops;
loggedInUser = event.configuration().vm.display.loggedInUser;
// Remainder applies only if we have a connection to k8s.
if (vmStub == null) {
@ -169,10 +173,12 @@ public class StatusUpdater extends VmDefUpdater {
status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1);
}
status.getAsJsonArray("conditions").asList().stream()
.map(cond -> (JsonObject) cond).filter(cond -> "Running"
.map(cond -> (JsonObject) cond)
.filter(cond -> Condition.RUNNING
.equals(cond.get("type").getAsString()))
.forEach(cond -> cond.addProperty("observedGeneration",
from.getMetadata().getGeneration()));
updateUserLoggedIn(from);
return status;
}, vmDef);
}
@ -194,9 +200,9 @@ public class StatusUpdater extends VmDefUpdater {
}
vmStub.updateStatus(from -> {
boolean running = event.runState().vmRunning();
updateCondition(vmDef, "Running", running, event.reason(),
updateCondition(vmDef, Condition.RUNNING, running, event.reason(),
event.message());
JsonObject status = updateCondition(vmDef, "Booted",
JsonObject status = updateCondition(vmDef, Condition.BOOTED,
event.runState() == RunState.BOOTED, event.reason(),
event.message());
if (event.runState() == RunState.STARTING) {
@ -212,10 +218,13 @@ public class StatusUpdater extends VmDefUpdater {
if (!running) {
// In case console connection was still present
status.addProperty(Status.CONSOLE_CLIENT, "");
updateCondition(from, "ConsoleConnected", false, "VmStopped",
updateCondition(from, Condition.CONSOLE_CONNECTED, false,
"VmStopped",
"The VM is not running");
// In case we had an irregular shutdown
updateCondition(from, Condition.USER_LOGGED_IN, false,
"VmStopped", "The VM is not running");
status.remove(Status.OSINFO);
updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped",
"The VM is not running");
@ -245,6 +254,26 @@ public class StatusUpdater extends VmDefUpdater {
K8s.createEvent(apiClient, vmDef, evt);
}
private void updateUserLoggedIn(VmDefinition from) {
if (loggedInUser == null) {
updateCondition(from, Condition.USER_LOGGED_IN, false,
Reason.NOT_REQUESTED, "No user to be logged in");
return;
}
if (!from.conditionStatus(Condition.VMOP_AGENT).orElse(false)) {
updateCondition(from, Condition.USER_LOGGED_IN, false,
"VmopAgentDisconnected", "Waiting for VMOP agent to connect");
return;
}
if (!from.fromStatus(Status.LOGGED_IN_USER).map(loggedInUser::equals)
.orElse(false)) {
updateCondition(from, Condition.USER_LOGGED_IN, false,
"Processing", "Waiting for user to be logged in");
}
updateCondition(from, Condition.USER_LOGGED_IN, true,
Reason.LOGGED_IN, "User is logged in");
}
/**
* On ballon change.
*
@ -348,8 +377,10 @@ public class StatusUpdater extends VmDefUpdater {
return;
}
vmStub.updateStatus(from -> {
return updateCondition(vmDef, "VmopAgentConnected",
var status = updateCondition(vmDef, "VmopAgentConnected",
true, "VmopAgentStarted", "The VM operator agent is running");
updateUserLoggedIn(from);
return status;
}, vmDef);
}
@ -365,6 +396,7 @@ public class StatusUpdater extends VmDefUpdater {
JsonObject status = from.statusJson();
status.addProperty(Status.LOGGED_IN_USER,
event.triggering().user());
updateUserLoggedIn(from);
return status;
});
}
@ -380,6 +412,7 @@ public class StatusUpdater extends VmDefUpdater {
vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.remove(Status.LOGGED_IN_USER);
updateUserLoggedIn(from);
return status;
});
}

View file

@ -129,9 +129,12 @@ public class VmDefUpdater extends Component {
var current = status.getAsJsonArray("conditions").asList().stream()
.map(cond -> (JsonObject) cond)
.filter(cond -> type.equals(cond.get("type").getAsString()))
.findFirst()
.map(cond -> "True".equals(cond.get("status").getAsString()));
if (current.isPresent() && current.get() == state) {
.findFirst();
if (current.isPresent()
&& current.map(c -> c.get("status").getAsString())
.map("True"::equals).map(s -> s == state).orElse(false)
&& current.map(c -> c.get("reason").getAsString())
.map(reason::equals).orElse(false)) {
return status;
}

View file

@ -327,6 +327,18 @@ public class GsonPtr {
return set(selector, new JsonPrimitive(value));
}
/**
* Short for `set(selector, new JsonPrimitive(value))`.
*
* @param selector the selector
* @param value the value
* @return the gson ptr
* @see #set(Object, JsonElement)
*/
public GsonPtr set(Object selector, Boolean value) {
return set(selector, new JsonPrimitive(value));
}
/**
* Same as {@link #set(Object, JsonElement)}, but sets the value
* only if it doesn't exist yet, else returns the existing value.

View file

@ -4,4 +4,4 @@
<rulesets>
<ruleset name="Custom Rules" ref="VM-Operator/ruleset.xml" refcontext="workspace" />
</rulesets>
</eclipse-pmd>
</eclipse-pmd>

View file

@ -5,5 +5,5 @@ okayLabel = Apply and Close
confirmResetTitle = Confirm reset
confirmResetMsg = Resetting the VM may cause loss of data. \
Please confirm to continue.
consoleTakenNotification = Console access is locked by another user.
consoleInaccessibleNotification = Console is not ready or in use.
poolEmptyNotification = No VM available. Please consult your administrator.

View file

@ -11,7 +11,7 @@ Open\ console = Konsole anzeigen
confirmResetTitle = Zurücksetzen bestätigen
confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \
Bitte bestätigen um fortzufahren.
consoleTakenNotification = Die Konsole wird von einem anderen Benutzer verwendet.
consoleInaccessibleNotification = Die Konsole ist nicht bereit oder belegt.
poolEmptyNotification = Keine VM verfügbar. Wenden Sie sich bitte an den \
Systemadministrator.

View file

@ -46,14 +46,15 @@ import java.util.stream.Collectors;
import org.bouncycastle.util.Objects;
import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
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.GetDisplaySecret;
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;
@ -265,7 +266,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
public void onConsoleConfigured(ConsoleConfigured event,
ConsoleConnection connection) throws InterruptedException,
IOException {
@SuppressWarnings("unchecked")
@SuppressWarnings({ "unchecked", "PMD.PrematureDeclaration" })
final var rendered
= (Set<ResourceModel>) connection.session().get(RENDERED);
connection.session().remove(RENDERED);
@ -523,6 +524,13 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
.assignedTo(user)).get().stream().findFirst();
}
/**
* Returns the permissions from the VM definition.
*
* @param vmDef the VM definition
* @param session the session
* @return the sets the
*/
private Set<Permission> permissions(VmDefinition vmDef, Session session) {
var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null);
@ -531,6 +539,13 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
return vmDef.permissionsFor(user, roles);
}
/**
* Returns the permissions from the pool.
*
* @param pool the pool
* @param session the session
* @return the sets the
*/
private Set<Permission> permissions(VmPool pool, Session session) {
var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null);
@ -539,23 +554,33 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
return pool.permissionsFor(user, roles);
}
private Set<Permission> permissions(ResourceModel model, Session session,
VmPool pool, VmDefinition vmDef) throws InterruptedException {
/**
* Returns the permissions from the VM definition or the pool depending
* on the state of the model.
*
* @param session the session
* @param model the model
* @param vmDef the vm def
* @return the sets the
* @throws InterruptedException the interrupted exception
*/
private Set<Permission> permissions(Session session, ResourceModel model,
VmDefinition vmDef) throws InterruptedException {
var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null);
var roles = WebConsoleUtils.rolesFromSession(session)
.stream().map(ConsoleRole::getName).toList();
if (model.mode() == ResourceModel.Mode.POOL) {
if (pool == null) {
pool = appPipeline.fire(new GetPools()
.withName(model.name())).get().stream().findFirst()
.orElse(null);
}
// Use permissions from pool
var pool = appPipeline.fire(new GetPools().withName(model.name()))
.get().stream().findFirst().orElse(null);
if (pool == null) {
return Collections.emptySet();
}
return pool.permissionsFor(user, roles);
}
// Use permissions from VM
if (vmDef == null) {
vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name())
.assignedTo(user)).get().stream().map(VmData::definition)
@ -577,7 +602,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
VmDefinition vmDef) throws InterruptedException {
channel.respond(new NotifyConletView(type(),
model.getConletId(), "updateConfig", model.mode(), model.name(),
permissions(model, channel.session(), null, vmDef).stream()
permissions(channel.session(), model, vmDef).stream()
.map(VmDefinition.Permission::toString).toList()));
}
@ -588,12 +613,17 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
model.setAssignedVm(null);
} else {
model.setAssignedVm(vmDef.name());
var session = channel.session();
var user = WebConsoleUtils.userFromSession(session)
.map(ConsoleUser::getName).orElse(null);
var perms = permissions(session, model, vmDef);
try {
data = Map.of("metadata",
Map.of("namespace", vmDef.namespace(),
data = Map.of(
"metadata", Map.of("namespace", vmDef.namespace(),
"name", vmDef.name()),
"spec", vmDef.spec(),
"status", vmDef.status());
"status", vmDef.status(),
"consoleAccessible", vmDef.consoleAccessible(user, perms));
} catch (JsonSyntaxException e) {
logger.log(Level.SEVERE, e,
() -> "Failed to serialize VM definition");
@ -634,6 +664,8 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
// Update known conlets
for (var entry : conletIdsByConsoleConnection().entrySet()) {
var connection = entry.getKey();
var user = WebConsoleUtils.userFromSession(connection.session())
.map(ConsoleUser::getName).orElse(null);
for (var conletId : entry.getValue()) {
var model = stateFromSession(connection.session(), conletId);
if (model.isEmpty()
@ -654,13 +686,11 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
} else {
// Check if VM is used by pool conlet or to be assigned to
// it
var user
= WebConsoleUtils.userFromSession(connection.session())
.map(ConsoleUser::getName).orElse(null);
var toBeUsedByConlet = vmDef.assignedFrom()
var toBeUsedByConlet = vmDef.assignment()
.map(Assignment::pool)
.map(p -> p.equals(model.get().name())).orElse(false)
&& vmDef.assignedTo().map(u -> u.equals(user))
.orElse(false);
&& vmDef.assignment().map(Assignment::user)
.map(u -> u.equals(user)).orElse(false);
if (!Objects.areEqual(model.get().assignedVm(),
vmDef.name()) && !toBeUsedByConlet) {
continue;
@ -750,7 +780,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
var vmChannel = vmData.get().channel();
var vmDef = vmData.get().definition();
var vmName = vmDef.metadata().getName();
var perms = permissions(model, channel.session(), null, vmDef);
var perms = permissions(channel.session(), model, vmDef);
var resourceBundle = resourceBundle(channel.locale());
switch (event.method()) {
case "start":
@ -774,9 +804,7 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
}
break;
case "openConsole":
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
openConsole(channel, model, vmChannel, vmDef, perms);
}
openConsole(channel, model, vmChannel, vmDef, perms);
break;
default:// ignore
break;
@ -804,22 +832,21 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
.map(ConsoleUser::getName).orElse("");
if (!vmDef.consoleAccessible(user, perms)) {
channel.respond(new DisplayNotification(
resourceBundle.getString("consoleTakenNotification"),
resourceBundle.getString("consoleInaccessibleNotification"),
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user,
model.mode() == ResourceModel.Mode.POOL),
var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user),
e -> gotPassword(channel, model, vmDef, e));
fire(pwQuery, vmChannel);
}
private void gotPassword(ConsoleConnection channel, ResourceModel model,
VmDefinition vmDef, PrepareConsole event) {
if (!event.passwordAvailable()) {
VmDefinition vmDef, GetDisplaySecret event) {
if (!event.secretAvailable()) {
return;
}
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
vmDef.extra().map(xtra -> xtra.connectionFile(event.secret(),
preferredIpVersion, deleteConnectionFile))
.ifPresent(cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));

View file

@ -1,6 +1,6 @@
/*
* VM-Operator
* Copyright (C) 2024 Michael N. Lipp
* Copyright (C) 2024,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
@ -71,11 +71,10 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
const poolName = computed(() => previewApi.poolName);
const vmName = computed(() => previewApi.vmDefinition.name);
const configured = computed(() => previewApi.vmDefinition.spec);
const accessible = computed(() => previewApi.vmDefinition.consoleAccessible);
const busy = computed(() => previewApi.vmDefinition.spec
&& (previewApi.vmDefinition.spec.vm.state === 'Running'
&& (previewApi.poolName
? !previewApi.vmDefinition.vmopAgent
: !previewApi.vmDefinition.running)
&& (!previewApi.vmDefinition.consoleAccessible)
|| previewApi.vmDefinition.spec.vm.state === 'Stopped'
&& previewApi.vmDefinition.running));
const startable = computed(() => previewApi.vmDefinition.spec
@ -87,7 +86,6 @@ 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(() => {
@ -123,7 +121,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
};
return { localize, resourceBase, vmAction, poolName, vmName,
configured, busy, startable, stoppable, running, vmopAgent,
configured, accessible, busy, startable, stoppable, running,
inUse, permissions, osicon };
},
template: `
@ -132,9 +130,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
<tr>
<td rowspan="2" style="position: relative"><span
style="position: absolute;" :class="{ busy: busy }"
><img role=button :aria-disabled="(poolName
? !vmopAgent : !running)
|| !permissions.includes('accessConsole')"
><img role=button :aria-disabled="!accessible"
v-on:click="vmAction('openConsole')"
:src="resourceBase + (running
? (inUse ? 'computer-in-use.svg' : 'computer.svg')
@ -210,15 +206,12 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
vmDefinition.currentCpus = vmDefinition.status.cpus;
vmDefinition.currentRam = Number(vmDefinition.status.ram);
vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
// safety fallbacks
vmDefinition.status.conditions.forEach((condition: any) => {
if (condition.type === "Running") {
vmDefinition.running = condition.status === "True";
vmDefinition.runningConditionSince
= new Date(condition.lastTransitionTime);
} else if (condition.type === "VmopAgentConnected") {
vmDefinition.vmopAgent = condition.status === "True";
vmDefinition.vmopAgentConditionSince
= new Date(condition.lastTransitionTime);
}
})
} else {

View file

@ -24,6 +24,7 @@
span[role="button"].svg-icon {
display: inline-block;
line-height: 1;
/* Align with forkawesome */
font-size: 14px;
fill: var(--primary);

View file

@ -60,21 +60,21 @@
<td class="jdrupes-vmoperator-vmmgmt-view-action-list">
<span role="button"
v-if="entry.spec.vm.state != 'Running' && !entry['running']
&& entry.permissions.includes('start')"
&& entry.permissions.includes('START')"
tabindex="0" class="fa fa-play" :title="localize('Start VM')"
v-on:click="vmAction(entry.name, 'start')"></span>
<span role="button" v-else class="fa fa-play"
aria-disabled="true" :title="localize('Start VM')"></span>
<span role="button"
v-if="entry.spec.vm.state != 'Stopped' && entry['running']
&& entry.permissions.includes('stop')"
&& entry.permissions.includes('STOP')"
tabindex="0" class="fa fa-stop" :title="localize('Stop VM')"
v-on:click="vmAction(entry.name, 'stop')"></span>
<span role="button" v-else class="fa fa-stop"
aria-disabled="true" :title="localize('Stop VM')"></span>
<span role="button"
:aria-disabled="!entry['running']
|| !entry.permissions.includes('reset')"
|| !entry.permissions.includes('RESET')"
tabindex="0" class="svg-icon" :title="localize('Reset VM')"
v-on:click="vmAction(entry.name, 'reset')">
<svg viewBox="0 0 1541.33 1535.5083">
@ -86,8 +86,7 @@
? 'computer-off.svg' : (entry.usedFrom
? 'computer-in-use.svg' : 'computer.svg'))"
:title="localize('Open console')"
:aria-disabled="!entry['running']
|| !(entry.permissions.includes('accessConsole'))"
:aria-disabled="!entry.consoleAccessible"
v-on:click="vmAction(entry.name, 'openConsole')">
</td>
</tr>

View file

@ -44,8 +44,8 @@ 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.GetDisplaySecret;
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;
@ -249,14 +249,15 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
.toBigInteger());
// Build result
var perms = vmDef.permissionsFor(user, roles);
return Map.of("metadata",
Map.of("namespace", vmDef.namespace(),
"name", vmDef.name()),
"spec", spec,
"status", status,
"nodeName", vmDef.extra().map(VmExtraData::nodeName).orElse(""),
"permissions", vmDef.permissionsFor(user, roles).stream()
.map(VmDefinition.Permission::toString).toList());
"consoleAccessible", vmDef.consoleAccessible(user, perms),
"permissions", perms);
}
/**
@ -438,9 +439,7 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
}
break;
case "openConsole":
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
openConsole(channel, model, vmChannel, vmDef, user, perms);
}
openConsole(channel, model, vmChannel, vmDef, user, perms);
break;
case "cpus":
fire(new ModifyVm(vmName, "currentCpus",
@ -484,17 +483,17 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user),
var pwQuery = Event.onCompletion(new GetDisplaySecret(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()) {
VmDefinition vmDef, GetDisplaySecret event) {
if (!event.secretAvailable()) {
return;
}
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
vmDef.extra().map(xtra -> xtra.connectionFile(event.secret(),
preferredIpVersion, deleteConnectionFile)).ifPresent(
cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));

View file

@ -118,6 +118,7 @@
span[role="button"].svg-icon {
display: inline-block;
line-height: 1;
/* Align with forkawesome */
font-size: 14px;
fill: var(--primary);

View file

@ -19,19 +19,19 @@ must support POSIX file access control lists (ACLs).
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
```
* 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
@ -44,18 +44,18 @@ development purposes and not for production.
The following should actually be configured for any VM.
* Prevent suspend/hibernate, because it will lock the VM.
* Prevent suspend/hibernate, because it will lock the VM.
```console
# systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
```
```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
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

View file

@ -9,31 +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 until the next major version,
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 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 latter behavior also applies to the VmConlet conlet which has been
renamed to VmMgmt.
* 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).
* 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