Add console access to VM management.

This commit is contained in:
Michael Lipp 2025-01-29 17:33:16 +01:00
parent 5cd4edcec1
commit af41c78c07
14 changed files with 561 additions and 124 deletions

View file

@ -75,6 +75,7 @@
admin:
- "*"
operator:
- org.jdrupes.vmoperator.vmmgmt.VmMgmt
- org.jdrupes.vmoperator.vmaccess.VmAccess
user:
- org.jdrupes.vmoperator.vmaccess.VmAccess

View file

@ -1,6 +1,6 @@
/*
* VM-Operator
* Copyright (C) 2024 Michael N. Lipp
* Copyright (C) 2025 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@ -22,11 +22,15 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.kubernetes.client.openapi.models.V1Condition;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.util.Strings;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -34,6 +38,8 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.jdrupes.vmoperator.util.DataPath;
@ -43,7 +49,11 @@ import org.jdrupes.vmoperator.util.DataPath;
@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" })
public class VmDefinition {
private static ObjectMapper objectMapper
@SuppressWarnings("PMD.FieldNamingConventions")
private static final Logger logger
= Logger.getLogger(VmDefinition.class.getName());
@SuppressWarnings("PMD.FieldNamingConventions")
private static final ObjectMapper objectMapper
= new ObjectMapper().registerModule(new JavaTimeModule());
private String kind;
@ -427,6 +437,8 @@ public class VmDefinition {
/**
* Collect all permissions for the given user with the given roles.
* If permission "takeConsole" is granted, the result will also
* contain "accessConsole" to simplify checks.
*
* @param user the user
* @param roles the roles
@ -434,7 +446,7 @@ public class VmDefinition {
*/
public Set<Permission> permissionsFor(String user,
Collection<String> roles) {
return this.<List<Map<String, Object>>> fromSpec("permissions")
var result = this.<List<Map<String, Object>>> fromSpec("permissions")
.orElse(Collections.emptyList()).stream()
.filter(p -> DataPath.get(p, "user").map(u -> u.equals(user))
.orElse(false)
@ -443,7 +455,29 @@ public class VmDefinition {
.orElse(Collections.emptyList()).stream())
.flatMap(Function.identity())
.map(Permission::parse).map(Set::stream)
.flatMap(Function.identity()).collect(Collectors.toSet());
.flatMap(Function.identity())
.collect(Collectors.toCollection(HashSet::new));
// Take console implies access console, simplify checks
if (result.contains(Permission.TAKE_CONSOLE)) {
result.add(Permission.ACCESS_CONSOLE);
}
return result;
}
/**
* 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.
*
* @param user the user
* @param permissions the permissions
* @return true, if successful
*/
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);
}
/**
@ -456,6 +490,78 @@ public class VmDefinition {
.map(Number::longValue);
}
/**
* Create a connection file.
*
* @param password the password
* @param preferredIpVersion the preferred IP version
* @param deleteConnectionFile the delete connection file
* @return the string
*/
public String connectionFile(String password,
Class<?> preferredIpVersion, boolean deleteConnectionFile) {
var addr = displayIp(preferredIpVersion);
if (addr.isEmpty()) {
logger.severe(() -> "Failed to find display IP for " + name());
return null;
}
var port = this.<Number> fromVm("display", "spice", "port")
.map(Number::longValue);
if (port.isEmpty()) {
logger.severe(() -> "No port defined for display of " + name());
return null;
}
StringBuffer data = new StringBuffer(100)
.append("[virt-viewer]\ntype=spice\nhost=")
.append(addr.get().getHostAddress()).append("\nport=")
.append(port.get().toString())
.append('\n');
if (password != null) {
data.append("password=").append(password).append('\n');
}
this.<String> fromVm("display", "spice", "proxyUrl")
.ifPresent(u -> {
if (!Strings.isNullOrEmpty(u)) {
data.append("proxy=").append(u).append('\n');
}
});
if (deleteConnectionFile) {
data.append("delete-this-file=1\n");
}
return data.toString();
}
private Optional<InetAddress> displayIp(Class<?> preferredIpVersion) {
Optional<String> server = fromVm("display", "spice", "server");
if (server.isPresent()) {
var srv = server.get();
try {
var addr = InetAddress.getByName(srv);
logger.fine(() -> "Using IP address from CRD for "
+ getMetadata().getName() + ": " + addr);
return Optional.of(addr);
} catch (UnknownHostException e) {
logger.log(Level.SEVERE, e, () -> "Invalid server address "
+ srv + ": " + e.getMessage());
return Optional.empty();
}
}
var addrs = Optional.<List<String>> ofNullable(
extra("nodeAddresses")).orElse(Collections.emptyList()).stream()
.map(a -> {
try {
return InetAddress.getByName(a);
} catch (UnknownHostException e) {
logger.warning(() -> "Invalid IP address: " + a);
return null;
}
}).filter(a -> a != null).toList();
logger.fine(() -> "Known IP addresses for " + name() + ": " + addrs);
return addrs.stream()
.filter(a -> preferredIpVersion.isAssignableFrom(a.getClass()))
.findFirst().or(() -> addrs.stream().findFirst());
}
/**
* Hash code.
*

View file

@ -30,6 +30,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.Optional;
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;
@ -125,7 +126,12 @@ public class VmMonitor extends
protected void handleChange(K8sClient client,
Watch.Response<VmDefinitionModel> response) {
V1ObjectMeta metadata = response.object.getMetadata();
VmChannel channel = channelManager.channelGet(metadata.getName());
AtomicBoolean toBeAdded = new AtomicBoolean(false);
VmChannel channel = channelManager.channel(metadata.getName())
.orElseGet(() -> {
toBeAdded.set(true);
return channelManager.createChannel(metadata.getName());
});
// Get full definition and associate with channel as backup
var vmModel = response.object;
@ -151,6 +157,9 @@ public class VmMonitor extends
+ response.object.getMetadata());
return;
}
if (toBeAdded.get()) {
channelManager.put(vmDef.name(), channel);
}
// Create and fire changed event. Remove channel from channel
// manager on completion.

View file

@ -32,10 +32,7 @@ import io.kubernetes.client.util.Strings;
import java.io.IOException;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.Duration;
import java.util.Base64;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
@ -779,24 +776,8 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
}
break;
case "openConsole":
var user = WebConsoleUtils.userFromSession(channel.session())
.map(ConsoleUser::getName).orElse("");
if (vmDef.conditionStatus("ConsoleConnected").orElse(false)
&& vmDef.consoleUser().map(cu -> !cu.equals(user)
&& !perms.contains(VmDefinition.Permission.TAKE_CONSOLE))
.orElse(false)) {
channel.respond(new DisplayNotification(
resourceBundle.getString("consoleTakenNotification"),
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)
|| perms.contains(VmDefinition.Permission.TAKE_CONSOLE)) {
var pwQuery
= Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> openConsole(vmDef, channel, model,
e.password().orElse(null)));
fire(pwQuery, vmChannel);
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
openConsole(channel, model, vmChannel, vmDef, perms);
}
break;
default:// ignore
@ -804,6 +785,30 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
}
}
private void openConsole(ConsoleConnection channel, ResourceModel model,
VmChannel vmChannel, VmDefinition vmDef, Set<Permission> perms) {
var resourceBundle = resourceBundle(channel.locale());
var user = WebConsoleUtils.userFromSession(channel.session())
.map(ConsoleUser::getName).orElse("");
if (!vmDef.consoleAccessible(user, perms)) {
channel.respond(new DisplayNotification(
resourceBundle.getString("consoleTakenNotification"),
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> {
var data = vmDef.connectionFile(e.password().orElse(null),
preferredIpVersion, deleteConnectionFile);
if (data == null) {
return;
}
channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", data));
});
fire(pwQuery, vmChannel);
}
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
"PMD.UseLocaleWithCaseConversions" })
private void selectResource(NotifyConletModel event,
@ -823,78 +828,6 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
}
}
private void openConsole(VmDefinition vmDef, ConsoleConnection connection,
ResourceModel model, String password) {
if (vmDef == null) {
return;
}
var addr = displayIp(vmDef);
if (addr.isEmpty()) {
logger
.severe(() -> "Failed to find display IP for " + vmDef.name());
return;
}
var port = vmDef.<Number> fromVm("display", "spice", "port")
.map(Number::longValue);
if (port.isEmpty()) {
logger
.severe(() -> "No port defined for display of " + vmDef.name());
return;
}
StringBuffer data = new StringBuffer(100)
.append("[virt-viewer]\ntype=spice\nhost=")
.append(addr.get().getHostAddress()).append("\nport=")
.append(port.get().toString())
.append('\n');
if (password != null) {
data.append("password=").append(password).append('\n');
}
vmDef.<String> fromVm("display", "spice", "proxyUrl")
.ifPresent(u -> {
if (!Strings.isNullOrEmpty(u)) {
data.append("proxy=").append(u).append('\n');
}
});
if (deleteConnectionFile) {
data.append("delete-this-file=1\n");
}
connection.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", "application/x-virt-viewer",
Base64.getEncoder().encodeToString(data.toString().getBytes())));
}
private Optional<InetAddress> displayIp(VmDefinition vmDef) {
Optional<String> server = vmDef.fromVm("display", "spice", "server");
if (server.isPresent()) {
var srv = server.get();
try {
var addr = InetAddress.getByName(srv);
logger.fine(() -> "Using IP address from CRD for "
+ vmDef.getMetadata().getName() + ": " + addr);
return Optional.of(addr);
} catch (UnknownHostException e) {
logger.log(Level.SEVERE, e, () -> "Invalid server address "
+ srv + ": " + e.getMessage());
return Optional.empty();
}
}
var addrs = Optional.<List<String>> ofNullable(vmDef
.extra("nodeAddresses")).orElse(Collections.emptyList()).stream()
.map(a -> {
try {
return InetAddress.getByName(a);
} catch (UnknownHostException e) {
logger.warning(() -> "Invalid IP address: " + a);
return null;
}
}).filter(a -> a != null).toList();
logger.fine(() -> "Known IP addresses for "
+ vmDef.name() + ": " + addrs);
return addrs.stream()
.filter(a -> preferredIpVersion.isAssignableFrom(a.getClass()))
.findFirst().or(() -> addrs.stream().findFirst());
}
private void confirmReset(NotifyConletModel event,
ConsoleConnection channel, ResourceModel model,
ResourceBundle resourceBundle) throws TemplateNotFoundException,

View file

@ -198,7 +198,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
});
JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
"openConsole", function(_conletId: string, mimeType: string, data: string) {
"openConsole", function(_conletId: string, data: string) {
let target = document.getElementById(
"org.jdrupes.vmoperator.vmaccess.VmAccess.target");
if (!target) {
@ -208,7 +208,8 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
target.setAttribute("style", "display: none;");
document.querySelector("body")!.append(target);
}
const url = "data:" + mimeType + ";base64," + data;
const url = "data:application/x-virt-viewer;base64,"
+ window.btoa(data);
window.open(url, target.id);
});

View file

@ -1,6 +1,7 @@
<div class="jdrupes-vmoperator-vmmgmt jdrupes-vmoperator-vmmgmt-view"
data-jgwc-on-load="orgJDrupesVmOperatorVmMgmt.initView"
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps">
data-jgwc-on-unload="JGConsole.jgwc.unmountVueApps"
data-conlet-resource-base="${conletResource('')}">
<div class="jdrupes-vmoperator-vmmgmt-view-search">
<form>
<label class="form__label--horizontal">
@ -58,17 +59,26 @@
</td>
<td class="jdrupes-vmoperator-vmmgmt-view-action-list">
<span role="button"
v-if="entry.spec.vm.state != 'Running' && !entry['running']"
v-if="entry.spec.vm.state != 'Running' && !entry['running']
&& 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']"
v-if="entry.spec.vm.state != 'Stopped' && entry['running']
&& 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>
<img role="button" :src="resourceBase + (!entry['running']
? 'computer-off.svg' : (entry.usedFrom
? 'computer-in-use.svg' : 'computer.svg'))"
:title="localize('Open console')"
:aria-disabled="!entry['running']
|| !(entry.permissions.includes('accessConsole'))"
v-on:click="vmAction(entry.name, 'openConsole')">
</td>
</tr>
<tr :id="scopedId(rowIndex)" v-if="$aash.isDisclosed(scopedId(rowIndex))"

View file

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800"
height="533.33331"
viewBox="0 0 24 15.999999"
version="1.1"
id="svg1"
sodipodi:docname="computer-in-use.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<linearGradient
id="swatch3"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="0"
id="stop3" />
</linearGradient>
</defs>
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.90509668"
inkscape:cx="345.81941"
inkscape:cy="376.2029"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="35"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
id="rect1"
style="fill-opacity:0;stroke:#000000;stroke-width:1.97262;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;paint-order:fill markers stroke"
d="m 4.7729709,13.006705 -1.7691517,0 V 0.98808897 H 20.99618 V 13.006705 l -1.639132,0"
sodipodi:nodetypes="cccccc" />
<path
id="rect2"
style="opacity:1;stroke-width:0.00145614;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;paint-order:fill markers stroke"
d="m 0,13.998258 h 5.4336202 v 2.001741 H 0 Z"
sodipodi:nodetypes="ccccc" />
<path
id="rect3"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.05373;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
d="m 5.6839082,10.94394 c 0.2963594,-0.907428 2.9522319,-1.683971 2.767387,-1.6392261 0,0 1.5028596,1.6181771 3.6459428,1.6129171 2.018383,-0.005 3.362681,-1.6125503 3.362681,-1.6125503 -0.171441,-0.061235 2.778887,0.7741493 2.977303,1.6787203 0.393054,1.791919 0.25928,4.489072 0.25928,4.489072 l -13.3818748,0.001 c 0,0 -0.181061,-2.844856 0.369281,-4.529957 z"
sodipodi:nodetypes="sssssccs" />
<ellipse
style="fill:none;stroke:#000000;stroke-width:1.02152;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
id="path3"
cx="11.964992"
cy="6.3769712"
rx="3.2413731"
ry="3.225764" />
<path
id="rect2-2"
style="stroke-width:0.00145614;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;paint-order:fill markers stroke"
d="M 18.56638,13.998258 H 24 v 2.001741 h -5.43362 z"
sodipodi:nodetypes="ccccc" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800"
height="533.33331"
viewBox="0 0 24 15.999999"
version="1.1"
id="svg1"
sodipodi:docname="computer-off.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<linearGradient
id="swatch3"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="0"
id="stop3" />
</linearGradient>
</defs>
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.3435029"
inkscape:cx="377.74389"
inkscape:cy="227.01849"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
id="rect1"
style="fill-opacity:1;stroke:#000000;stroke-width:1.97262;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;paint-order:fill markers stroke;fill:#545454"
d="M 3.0038192,0.98808897 H 20.99618 V 13.006705 H 3.0038192 Z" />
<rect
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.00306926;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
id="rect2"
width="23.995173"
height="2.0017407"
x="0.0039473679"
y="13.998839" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800"
height="533.33331"
viewBox="0 0 24 15.999999"
version="1.1"
id="svg1"
sodipodi:docname="computer.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<linearGradient
id="swatch3"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="0"
id="stop3" />
</linearGradient>
</defs>
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.3435029"
inkscape:cx="377.74389"
inkscape:cy="227.01849"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
id="rect1"
style="fill-opacity:0;stroke:#000000;stroke-width:1.97262;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;paint-order:fill markers stroke"
d="M 3.0038192,0.98808897 H 20.99618 V 13.006705 H 3.0038192 Z" />
<rect
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.00306926;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
id="rect2"
width="23.995173"
height="2.0017407"
x="0.0039473679"
y="13.998839" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -17,3 +17,8 @@ usedBy = Used by
usedFrom = Used from
vmActions = Actions
vmname = Name
confirmResetTitle = Confirm reset
confirmResetMsg = Resetting the VM may cause loss of data. \
Please confirm to continue.
consoleTakenNotification = Console access is locked by another user.

View file

@ -24,6 +24,12 @@ vmname = Name
Value\ is\ above\ maximum = Wert ist zu groß
Illegal\ format = Ungültiges Format
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.
Open\ console = Konsole anzeigen
Start\ VM = VM Starten
Stop\ VM = VM Anhalten

View file

@ -27,15 +27,22 @@ import io.kubernetes.client.custom.Quantity.Format;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.Set;
import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.manager.events.ChannelTracker;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -44,13 +51,17 @@ import org.jgrapes.core.Channel;
import org.jgrapes.core.Event;
import org.jgrapes.core.Manager;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.util.events.ConfigurationUpdate;
import org.jgrapes.webconsole.base.Conlet.RenderMode;
import org.jgrapes.webconsole.base.ConletBaseModel;
import org.jgrapes.webconsole.base.ConsoleConnection;
import org.jgrapes.webconsole.base.events.AddConletRequest;
import org.jgrapes.webconsole.base.ConsoleRole;
import org.jgrapes.webconsole.base.ConsoleUser;
import org.jgrapes.webconsole.base.WebConsoleUtils;
import org.jgrapes.webconsole.base.events.AddConletType;
import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
import org.jgrapes.webconsole.base.events.ConsoleReady;
import org.jgrapes.webconsole.base.events.DisplayNotification;
import org.jgrapes.webconsole.base.events.NotifyConletModel;
import org.jgrapes.webconsole.base.events.NotifyConletView;
import org.jgrapes.webconsole.base.events.RenderConlet;
@ -61,10 +72,12 @@ import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
/**
* The Class {@link VmMgmt}.
*/
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
"PMD.CouplingBetweenObjects" })
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.CouplingBetweenObjects",
"PMD.ExcessiveImports" })
public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
private Class<?> preferredIpVersion = Inet4Address.class;
private boolean deleteConnectionFile = true;
private static final Set<RenderMode> MODES = RenderMode.asSet(
RenderMode.Preview, RenderMode.View);
private final ChannelTracker<String, VmChannel,
@ -91,6 +104,44 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update());
}
/**
* Configure the component.
*
* @param event the event
*/
@SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
@Handler
public void onConfigurationUpdate(ConfigurationUpdate event) {
event.structured("/Manager/GuiHttpServer"
+ "/ConsoleWeblet/WebConsole/ComponentCollector/VmAccess")
.ifPresent(c -> {
try {
var dispRes = (Map<String, Object>) c
.getOrDefault("displayResource",
Collections.emptyMap());
switch ((String) dispRes.getOrDefault("preferredIpVersion",
"")) {
case "ipv6":
preferredIpVersion = Inet6Address.class;
break;
case "ipv4":
default:
preferredIpVersion = Inet4Address.class;
break;
}
// Delete connection file
deleteConnectionFile
= Optional.ofNullable(c.get("deleteConnectionFile"))
.filter(v -> v instanceof String)
.map(v -> (String) v)
.map(Boolean::parseBoolean).orElse(true);
} catch (ClassCastException e) {
logger.config("Malformed configuration: " + e.getMessage());
}
});
}
/**
* On {@link ConsoleReady}, fire the {@link AddConletType}.
*
@ -117,7 +168,7 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
}
@Override
protected Optional<VmsModel> createNewState(AddConletRequest event,
protected Optional<VmsModel> createStateRepresentation(Event<?> event,
ConsoleConnection connection, String conletId) throws Exception {
return Optional.of(new VmsModel(conletId));
}
@ -160,17 +211,25 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
}
if (sendVmInfos) {
for (var item : channelTracker.values()) {
channel.respond(new NotifyConletView(type(),
conletId, "updateVm",
simplifiedVmDefinition(item.associated())));
updateVm(channel, conletId, item.associated());
}
}
return renderedAs;
}
private void updateVm(ConsoleConnection channel, String conletId,
VmDefinition vmDef) {
var user = WebConsoleUtils.userFromSession(channel.session())
.map(ConsoleUser::getName).orElse(null);
var roles = WebConsoleUtils.rolesFromSession(channel.session())
.stream().map(ConsoleRole::getName).toList();
channel.respond(new NotifyConletView(type(), conletId, "updateVm",
simplifiedVmDefinition(vmDef, user, roles)));
}
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef) {
private Map<String, Object> simplifiedVmDefinition(VmDefinition vmDef,
String user, List<String> roles) {
// Convert RAM sizes to unitless numbers
var spec = DataPath.deepCopy(vmDef.spec());
var vmSpec = DataPath.<Map<String, Object>> get(spec, "vm").get();
@ -191,7 +250,9 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
"name", vmDef.name()),
"spec", spec,
"status", status,
"nodeName", vmDef.extra("nodeName"));
"nodeName", vmDef.extra("nodeName"),
"permissions", vmDef.permissionsFor(user, roles).stream()
.map(VmDefinition.Permission::toString).toList());
}
/**
@ -221,8 +282,7 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
channelTracker.put(vmName, channel, vmDef);
for (var entry : conletIdsByConsoleConnection().entrySet()) {
for (String conletId : entry.getValue()) {
entry.getKey().respond(new NotifyConletView(type(),
conletId, "updateVm", simplifiedVmDefinition(vmDef)));
updateVm(entry.getKey(), conletId, vmDef);
}
}
}
@ -337,22 +397,35 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
@Override
@SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor")
protected void doUpdateConletState(NotifyConletModel event,
ConsoleConnection channel, VmsModel conletState)
throws Exception {
ConsoleConnection channel, VmsModel model) throws Exception {
event.stop();
String vmName = event.param(0);
var vmChannel = channelTracker.channel(vmName).orElse(null);
if (vmChannel == null) {
var value = channelTracker.value(vmName);
var vmChannel = value.map(v -> v.channel()).orElse(null);
var vmDef = value.map(v -> v.associated()).orElse(null);
if (vmDef == null) {
return;
}
var user = WebConsoleUtils.userFromSession(channel.session())
.map(ConsoleUser::getName).orElse("");
var roles = WebConsoleUtils.rolesFromSession(channel.session())
.stream().map(ConsoleRole::getName).toList();
var perms = vmDef.permissionsFor(user, roles);
switch (event.method()) {
case "start":
fire(new ModifyVm(vmName, "state", "Running", vmChannel));
if (perms.contains(VmDefinition.Permission.START)) {
fire(new ModifyVm(vmName, "state", "Running", vmChannel));
}
break;
case "stop":
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
if (perms.contains(VmDefinition.Permission.STOP)) {
fire(new ModifyVm(vmName, "state", "Stopped", vmChannel));
}
break;
case "openConsole":
if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) {
openConsole(channel, model, vmChannel, vmDef, user, perms);
}
break;
case "cpus":
fire(new ModifyVm(vmName, "currentCpus",
@ -370,6 +443,29 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
}
}
private void openConsole(ConsoleConnection channel, VmsModel model,
VmChannel vmChannel, VmDefinition vmDef, String user,
Set<Permission> perms) {
ResourceBundle resourceBundle = resourceBundle(channel.locale());
if (!vmDef.consoleAccessible(user, perms)) {
channel.respond(new DisplayNotification(
resourceBundle.getString("consoleTakenNotification"),
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> {
var data = vmDef.connectionFile(e.password().orElse(null),
preferredIpVersion, deleteConnectionFile);
if (data == null) {
return;
}
channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", data));
});
fire(pwQuery, vmChannel);
}
@Override
protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
String conletId) throws Exception {

View file

@ -104,6 +104,7 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement,
setup(_props: object) {
const conletId: string
= (<HTMLElement>viewDom.parentNode!).dataset["conletId"]!;
const resourceBase = (<HTMLElement>viewDom).dataset.conletResourceBase;
const controller = reactive(new JGConsole.TableController([
["name", "vmname"],
@ -162,9 +163,9 @@ window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement,
}
return {
controller, vmInfos, filteredData, detailsByName, localize,
shortDateTime, formatMemory, vmAction, cic, parseMemory,
maximumCpus,
controller, vmInfos, filteredData, detailsByName,
resourceBase, localize, shortDateTime, formatMemory,
vmAction, cic, parseMemory, maximumCpus,
scopedId: (id: string) => { return idScope.scopedId(id); }
};
}
@ -219,3 +220,20 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt",
Object.assign(vmSummary, summary);
});
JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt",
"openConsole", function(_conletId: string, data: string) {
let target = document.getElementById(
"org.jdrupes.vmoperator.vmmgt.VmMgmt.target");
if (!target) {
target = document.createElement("iframe");
target.id = "org.jdrupes.vmoperator.vmmgt.VmMgmt.target";
target.setAttribute("name", target.id);
target.setAttribute("style", "display: none;");
document.querySelector("body")!.append(target);
}
const url = "data:application/x-virt-viewer;base64,"
+ window.btoa(data);
window.open(url, target.id);
});

View file

@ -103,6 +103,10 @@
.jdrupes-vmoperator-vmmgmt-view-action-list {
white-space: nowrap;
& > * + * {
margin-left: 0.5em;
}
[role=button] {
padding: 0.25rem;
@ -110,4 +114,14 @@
box-shadow: var(--darkening);
}
}
img {
display: inline;
height: 1.5em;
vertical-align: top;
&[aria-disabled=''], &[aria-disabled='true'] {
opacity: 0.4;
}
}
}