Delay console opening for pool VMs.

This commit is contained in:
Michael Lipp 2025-02-19 21:04:08 +01:00
parent c582763fbf
commit 5ad052ffe4
6 changed files with 191 additions and 119 deletions

View file

@ -1,74 +0,0 @@
/*
* VM-Operator
* Copyright (C) 2024 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.manager.events;
import java.util.Optional;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jgrapes.core.Event;
/**
* Gets the current display secret and optionally updates it.
*/
@SuppressWarnings("PMD.DataClass")
public class GetDisplayPassword extends Event<String> {
private final VmDefinition vmDef;
private final String user;
/**
* Instantiates a new request for the display secret.
*
* @param vmDef the vm name
* @param user the requesting user
*/
public GetDisplayPassword(VmDefinition vmDef, String user) {
this.vmDef = vmDef;
this.user = user;
}
/**
* Gets the vm definition.
*
* @return the vm definition
*/
public VmDefinition vmDefinition() {
return vmDef;
}
/**
* Return the id of the user who has requested the password.
*
* @return the string
*/
public String user() {
return user;
}
/**
* Return the password. May only be called when the event is completed.
*
* @return the optional
*/
public Optional<String> password() {
if (!isDone()) {
throw new IllegalStateException("Event is not done.");
}
return currentResults().stream().findFirst();
}
}

View file

@ -0,0 +1,119 @@
/*
* VM-Operator
* Copyright (C) 2024 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jdrupes.vmoperator.manager.events;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jgrapes.core.Event;
/**
* Gets the current display secret and optionally updates it.
*/
@SuppressWarnings("PMD.DataClass")
public class PrepareConsole extends Event<String> {
private final VmDefinition vmDef;
private final String user;
private final boolean loginUser;
/**
* Instantiates a new request for the display secret.
* After handling the event, a result of `null` means that
* no password is needed. No result means that the console
* is not accessible.
*
* @param vmDef the vm name
* @param user the requesting user
* @param loginUser login the user
*/
public PrepareConsole(VmDefinition vmDef, String user,
boolean loginUser) {
this.vmDef = vmDef;
this.user = user;
this.loginUser = loginUser;
}
/**
* Instantiates a new request for the display secret.
* After handling the event, a result of `null` means that
* no password is needed. No result means that the console
* is not accessible.
*
* @param vmDef the vm name
* @param user the requesting user
*/
public PrepareConsole(VmDefinition vmDef, String user) {
this(vmDef, user, false);
}
/**
* Gets the vm definition.
*
* @return the vm definition
*/
public VmDefinition vmDefinition() {
return vmDef;
}
/**
* Return the id of the user who has requested the password.
*
* @return the string
*/
public String user() {
return user;
}
/**
* Checks if the user should be logged in before allowing access.
*
* @return the loginUser
*/
public boolean loginUser() {
return loginUser;
}
/**
* Returns `true` if a password is available. May only be called
* when the event is completed. Note that the password returned
* by {@link #password()} may be `null`, indicating that no password
* is needed.
*
* @return true, if successful
*/
public boolean passwordAvailable() {
if (!isDone()) {
throw new IllegalStateException("Event is not done.");
}
return !currentResults().isEmpty();
}
/**
* Return the password. May only be called when the event has been
* completed with a valid result (see {@link #passwordAvailable()}).
*
* @return the password. A value of `null` means that no password
* is required.
*/
public String password() {
if (!isDone() || currentResults().isEmpty()) {
throw new IllegalStateException("Event is not done.");
}
return currentResults().get(0);
}
}

View file

@ -50,7 +50,7 @@ import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
import org.jdrupes.vmoperator.manager.events.ChannelDictionary; import org.jdrupes.vmoperator.manager.events.ChannelDictionary;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
@ -72,7 +72,7 @@ public class DisplaySecretMonitor
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> { extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
private int passwordValidity = 10; private int passwordValidity = 10;
private final List<PendingGet> pendingGets private final List<PendingGet> pendingPrepares
= Collections.synchronizedList(new LinkedList<>()); = Collections.synchronizedList(new LinkedList<>());
private final ChannelDictionary<String, VmChannel, ?> channelDictionary; private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
@ -178,49 +178,59 @@ public class DisplaySecretMonitor
*/ */
@Handler @Handler
@SuppressWarnings("PMD.StringInstantiation") @SuppressWarnings("PMD.StringInstantiation")
public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) public void onPrepareConsole(PrepareConsole event, VmChannel channel)
throws ApiException { throws ApiException {
// Update console user in status // Update console user in status
var vmStub = VmDefinitionStub.get(client(), var vmStub = VmDefinitionStub.get(client(),
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name()); event.vmDefinition().namespace(), event.vmDefinition().name());
vmStub.updateStatus(from -> { var optVmDef = vmStub.updateStatus(from -> {
JsonObject status = from.statusJson(); JsonObject status = from.statusJson();
status.addProperty("consoleUser", event.user()); status.addProperty("consoleUser", event.user());
return status; return status;
}); });
if (optVmDef.isEmpty()) {
return;
}
var vmDef = optVmDef.get();
// Check if access is possible
if (event.loginUser()
? !vmDef.conditionStatus("Booted").orElse(false)
: !vmDef.conditionStatus("Running").orElse(false)) {
return;
}
// Look for secret // Look for secret
ListOptions options = new ListOptions(); ListOptions options = new ListOptions();
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
+ "app.kubernetes.io/instance=" + "app.kubernetes.io/instance=" + vmDef.name());
+ event.vmDefinition().metadata().getName()); var stubs = K8sV1SecretStub.list(client(), vmDef.namespace(), options);
var stubs = K8sV1SecretStub.list(client(),
event.vmDefinition().namespace(), options);
if (stubs.isEmpty()) { if (stubs.isEmpty()) {
// No secret means no password for this VM wanted // No secret means no password for this VM wanted
event.setResult(null);
return; return;
} }
var stub = stubs.iterator().next(); var stub = stubs.iterator().next();
// Check validity // Check validity
var model = stub.model().get(); var secret = stub.model().get();
@SuppressWarnings("PMD.StringInstantiation") @SuppressWarnings("PMD.StringInstantiation")
var expiry = Optional.ofNullable(model.getData() var expiry = Optional.ofNullable(secret.getData()
.get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null); .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null);
if (model.getData().get(DATA_DISPLAY_PASSWORD) != null if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null
&& stillValid(expiry)) { && stillValid(expiry)) {
// Fixed secret, don't touch // Fixed secret, don't touch
event.setResult( event.setResult(
new String(model.getData().get(DATA_DISPLAY_PASSWORD))); new String(secret.getData().get(DATA_DISPLAY_PASSWORD)));
return; return;
} }
updatePassword(stub, event); updatePassword(stub, event);
} }
@SuppressWarnings("PMD.StringInstantiation") @SuppressWarnings("PMD.StringInstantiation")
private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event) private void updatePassword(K8sV1SecretStub stub, PrepareConsole event)
throws ApiException { throws ApiException {
SecureRandom random = null; SecureRandom random = null;
try { try {
@ -242,9 +252,9 @@ public class DisplaySecretMonitor
var pending = new PendingGet(event, var pending = new PendingGet(event,
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
new CompletionLock(event, 1500)); new CompletionLock(event, 1500));
pendingGets.add(pending); pendingPrepares.add(pending);
Event.onCompletion(event, e -> { Event.onCompletion(event, e -> {
pendingGets.remove(pending); pendingPrepares.remove(pending);
}); });
// Update, will (eventually) trigger confirmation // Update, will (eventually) trigger confirmation
@ -273,9 +283,9 @@ public class DisplaySecretMonitor
@Handler @Handler
@SuppressWarnings("PMD.AvoidSynchronizedStatement") @SuppressWarnings("PMD.AvoidSynchronizedStatement")
public void onVmDefChanged(VmDefChanged event, Channel channel) { public void onVmDefChanged(VmDefChanged event, Channel channel) {
synchronized (pendingGets) { synchronized (pendingPrepares) {
String vmName = event.vmDefinition().name(); String vmName = event.vmDefinition().name();
for (var pending : pendingGets) { for (var pending : pendingPrepares) {
if (pending.event.vmDefinition().name().equals(vmName) if (pending.event.vmDefinition().name().equals(vmName)
&& event.vmDefinition().displayPasswordSerial() && event.vmDefinition().displayPasswordSerial()
.map(s -> s >= pending.expectedSerial).orElse(false)) { .map(s -> s >= pending.expectedSerial).orElse(false)) {
@ -293,7 +303,7 @@ public class DisplaySecretMonitor
*/ */
@SuppressWarnings("PMD.DataClass") @SuppressWarnings("PMD.DataClass")
private static class PendingGet { private static class PendingGet {
public final GetDisplayPassword event; public final PrepareConsole event;
public final long expectedSerial; public final long expectedSerial;
public final CompletionLock lock; public final CompletionLock lock;
@ -303,7 +313,7 @@ public class DisplaySecretMonitor
* @param event the event * @param event the event
* @param expectedSerial the expected serial * @param expectedSerial the expected serial
*/ */
public PendingGet(GetDisplayPassword event, long expectedSerial, public PendingGet(PrepareConsole event, long expectedSerial,
CompletionLock lock) { CompletionLock lock) {
super(); super();
this.event = event; this.event = event;

View file

@ -49,11 +49,11 @@ import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.common.VmPool; import org.jdrupes.vmoperator.common.VmPool;
import org.jdrupes.vmoperator.manager.events.AssignVm; import org.jdrupes.vmoperator.manager.events.AssignVm;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.GetPools; import org.jdrupes.vmoperator.manager.events.GetPools;
import org.jdrupes.vmoperator.manager.events.GetVms; import org.jdrupes.vmoperator.manager.events.GetVms;
import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -808,18 +808,23 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
Map.of("autoClose", 5_000, "type", "Warning"))); Map.of("autoClose", 5_000, "type", "Warning")));
return; return;
} }
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user,
e -> { model.mode() == ResourceModel.Mode.POOL),
vmDef.extra() e -> gotPassword(channel, model, vmDef, e));
.map(xtra -> xtra.connectionFile(e.password().orElse(null),
preferredIpVersion, deleteConnectionFile))
.ifPresent(
cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
});
fire(pwQuery, vmChannel); fire(pwQuery, vmChannel);
} }
private void gotPassword(ConsoleConnection channel, ResourceModel model,
VmDefinition vmDef, PrepareConsole event) {
if (!event.passwordAvailable()) {
return;
}
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
preferredIpVersion, deleteConnectionFile))
.ifPresent(cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
}
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
"PMD.UseLocaleWithCaseConversions" }) "PMD.UseLocaleWithCaseConversions" })
private void selectResource(NotifyConletModel event, private void selectResource(NotifyConletModel event,

View file

@ -73,7 +73,9 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
const configured = computed(() => previewApi.vmDefinition.spec); const configured = computed(() => previewApi.vmDefinition.spec);
const busy = computed(() => previewApi.vmDefinition.spec const busy = computed(() => previewApi.vmDefinition.spec
&& (previewApi.vmDefinition.spec.vm.state === 'Running' && (previewApi.vmDefinition.spec.vm.state === 'Running'
&& !previewApi.vmDefinition.running && (previewApi.poolName
? !previewApi.vmDefinition.booted
: !previewApi.vmDefinition.running)
|| previewApi.vmDefinition.spec.vm.state === 'Stopped' || previewApi.vmDefinition.spec.vm.state === 'Stopped'
&& previewApi.vmDefinition.running)); && previewApi.vmDefinition.running));
const startable = computed(() => previewApi.vmDefinition.spec const startable = computed(() => previewApi.vmDefinition.spec
@ -85,6 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
previewApi.vmDefinition.spec.vm.state !== 'Stopped' previewApi.vmDefinition.spec.vm.state !== 'Stopped'
&& previewApi.vmDefinition.running); && previewApi.vmDefinition.running);
const running = computed(() => previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running);
const booted = computed(() => previewApi.vmDefinition.booted);
const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const inUse = computed(() => previewApi.vmDefinition.usedBy != '');
const permissions = computed(() => previewApi.permissions); const permissions = computed(() => previewApi.permissions);
const osicon = computed(() => { const osicon = computed(() => {
@ -120,8 +123,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
}; };
return { localize, resourceBase, vmAction, poolName, vmName, return { localize, resourceBase, vmAction, poolName, vmName,
configured, busy, startable, stoppable, running, inUse, configured, busy, startable, stoppable, running, booted,
permissions, osicon }; inUse, permissions, osicon };
}, },
template: ` template: `
<table> <table>
@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
<tr> <tr>
<td rowspan="2" style="position: relative"><span <td rowspan="2" style="position: relative"><span
style="position: absolute;" :class="{ busy: busy }" style="position: absolute;" :class="{ busy: busy }"
><img role=button :aria-disabled="!running ><img role=button :aria-disabled="(poolName
? !booted : !running)
|| !permissions.includes('accessConsole')" || !permissions.includes('accessConsole')"
v-on:click="vmAction('openConsole')" v-on:click="vmAction('openConsole')"
:src="resourceBase + (running :src="resourceBase + (running
@ -206,14 +210,17 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentCpus = vmDefinition.status.cpus;
vmDefinition.currentRam = Number(vmDefinition.status.ram); vmDefinition.currentRam = Number(vmDefinition.status.ram);
vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
for (const condition of vmDefinition.status.conditions) { vmDefinition.status.conditions.forEach((condition: any) => {
if (condition.type === "Running") { if (condition.type === "Running") {
vmDefinition.running = condition.status === "True"; vmDefinition.running = condition.status === "True";
vmDefinition.runningConditionSince vmDefinition.runningConditionSince
= new Date(condition.lastTransitionTime); = new Date(condition.lastTransitionTime);
break; } else if (condition.type === "Booted") {
vmDefinition.booted = condition.status === "True";
vmDefinition.bootedConditionSince
= new Date(condition.lastTransitionTime);
} }
} })
} else { } else {
vmDefinition = {}; vmDefinition = {};
} }

View file

@ -43,8 +43,8 @@ import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Permission; import org.jdrupes.vmoperator.common.VmDefinition.Permission;
import org.jdrupes.vmoperator.common.VmExtraData; import org.jdrupes.vmoperator.common.VmExtraData;
import org.jdrupes.vmoperator.manager.events.ChannelTracker; import org.jdrupes.vmoperator.manager.events.ChannelTracker;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.ModifyVm; import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -483,17 +483,22 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
Map.of("autoClose", 5_000, "type", "Warning"))); Map.of("autoClose", 5_000, "type", "Warning")));
return; return;
} }
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user), var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user),
e -> { e -> gotPassword(channel, model, vmDef, e));
vmDef.extra().map(xtra -> xtra.connectionFile(
e.password().orElse(null), preferredIpVersion,
deleteConnectionFile)).ifPresent(
cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
});
fire(pwQuery, vmChannel); fire(pwQuery, vmChannel);
} }
private void gotPassword(ConsoleConnection channel, VmsModel model,
VmDefinition vmDef, PrepareConsole event) {
if (!event.passwordAvailable()) {
return;
}
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
preferredIpVersion, deleteConnectionFile)).ifPresent(
cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
}
@Override @Override
protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
String conletId) throws Exception { String conletId) throws Exception {