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_PASSWORD_EXPIRY;
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.VmDefChanged;
import org.jgrapes.core.Channel;
@ -72,7 +72,7 @@ public class DisplaySecretMonitor
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
private int passwordValidity = 10;
private final List<PendingGet> pendingGets
private final List<PendingGet> pendingPrepares
= Collections.synchronizedList(new LinkedList<>());
private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
@ -178,49 +178,59 @@ public class DisplaySecretMonitor
*/
@Handler
@SuppressWarnings("PMD.StringInstantiation")
public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel)
public void onPrepareConsole(PrepareConsole event, VmChannel channel)
throws ApiException {
// Update console user in status
var vmStub = VmDefinitionStub.get(client(),
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
vmStub.updateStatus(from -> {
var optVmDef = vmStub.updateStatus(from -> {
JsonObject status = from.statusJson();
status.addProperty("consoleUser", event.user());
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
ListOptions options = new ListOptions();
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
+ "app.kubernetes.io/instance="
+ event.vmDefinition().metadata().getName());
var stubs = K8sV1SecretStub.list(client(),
event.vmDefinition().namespace(), options);
+ "app.kubernetes.io/instance=" + vmDef.name());
var stubs = K8sV1SecretStub.list(client(), vmDef.namespace(), options);
if (stubs.isEmpty()) {
// No secret means no password for this VM wanted
event.setResult(null);
return;
}
var stub = stubs.iterator().next();
// Check validity
var model = stub.model().get();
var secret = stub.model().get();
@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);
if (model.getData().get(DATA_DISPLAY_PASSWORD) != null
if (secret.getData().get(DATA_DISPLAY_PASSWORD) != null
&& stillValid(expiry)) {
// Fixed secret, don't touch
event.setResult(
new String(model.getData().get(DATA_DISPLAY_PASSWORD)));
new String(secret.getData().get(DATA_DISPLAY_PASSWORD)));
return;
}
updatePassword(stub, event);
}
@SuppressWarnings("PMD.StringInstantiation")
private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event)
private void updatePassword(K8sV1SecretStub stub, PrepareConsole event)
throws ApiException {
SecureRandom random = null;
try {
@ -242,9 +252,9 @@ public class DisplaySecretMonitor
var pending = new PendingGet(event,
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
new CompletionLock(event, 1500));
pendingGets.add(pending);
pendingPrepares.add(pending);
Event.onCompletion(event, e -> {
pendingGets.remove(pending);
pendingPrepares.remove(pending);
});
// Update, will (eventually) trigger confirmation
@ -273,9 +283,9 @@ public class DisplaySecretMonitor
@Handler
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
public void onVmDefChanged(VmDefChanged event, Channel channel) {
synchronized (pendingGets) {
synchronized (pendingPrepares) {
String vmName = event.vmDefinition().name();
for (var pending : pendingGets) {
for (var pending : pendingPrepares) {
if (pending.event.vmDefinition().name().equals(vmName)
&& event.vmDefinition().displayPasswordSerial()
.map(s -> s >= pending.expectedSerial).orElse(false)) {
@ -293,7 +303,7 @@ public class DisplaySecretMonitor
*/
@SuppressWarnings("PMD.DataClass")
private static class PendingGet {
public final GetDisplayPassword event;
public final PrepareConsole event;
public final long expectedSerial;
public final CompletionLock lock;
@ -303,7 +313,7 @@ public class DisplaySecretMonitor
* @param event the event
* @param expectedSerial the expected serial
*/
public PendingGet(GetDisplayPassword event, long expectedSerial,
public PendingGet(PrepareConsole event, long expectedSerial,
CompletionLock lock) {
super();
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.VmPool;
import org.jdrupes.vmoperator.manager.events.AssignVm;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.GetPools;
import org.jdrupes.vmoperator.manager.events.GetVms;
import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -808,18 +808,23 @@ public class VmAccess extends FreeMarkerConlet<VmAccess.ResourceModel> {
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> {
vmDef.extra()
.map(xtra -> xtra.connectionFile(e.password().orElse(null),
preferredIpVersion, deleteConnectionFile))
.ifPresent(
cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
});
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user,
model.mode() == ResourceModel.Mode.POOL),
e -> gotPassword(channel, model, vmDef, e));
fire(pwQuery, vmChannel);
}
private void gotPassword(ConsoleConnection channel, ResourceModel model,
VmDefinition vmDef, PrepareConsole event) {
if (!event.passwordAvailable()) {
return;
}
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
preferredIpVersion, deleteConnectionFile))
.ifPresent(cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
}
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
"PMD.UseLocaleWithCaseConversions" })
private void selectResource(NotifyConletModel event,

View file

@ -73,7 +73,9 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
const configured = computed(() => previewApi.vmDefinition.spec);
const busy = computed(() => previewApi.vmDefinition.spec
&& (previewApi.vmDefinition.spec.vm.state === 'Running'
&& !previewApi.vmDefinition.running
&& (previewApi.poolName
? !previewApi.vmDefinition.booted
: !previewApi.vmDefinition.running)
|| previewApi.vmDefinition.spec.vm.state === 'Stopped'
&& previewApi.vmDefinition.running));
const startable = computed(() => previewApi.vmDefinition.spec
@ -85,6 +87,7 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
previewApi.vmDefinition.spec.vm.state !== 'Stopped'
&& previewApi.vmDefinition.running);
const running = computed(() => previewApi.vmDefinition.running);
const booted = computed(() => previewApi.vmDefinition.booted);
const inUse = computed(() => previewApi.vmDefinition.usedBy != '');
const permissions = computed(() => previewApi.permissions);
const osicon = computed(() => {
@ -120,8 +123,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
};
return { localize, resourceBase, vmAction, poolName, vmName,
configured, busy, startable, stoppable, running, inUse,
permissions, osicon };
configured, busy, startable, stoppable, running, booted,
inUse, permissions, osicon };
},
template: `
<table>
@ -129,7 +132,8 @@ window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement,
<tr>
<td rowspan="2" style="position: relative"><span
style="position: absolute;" :class="{ busy: busy }"
><img role=button :aria-disabled="!running
><img role=button :aria-disabled="(poolName
? !booted : !running)
|| !permissions.includes('accessConsole')"
v-on:click="vmAction('openConsole')"
:src="resourceBase + (running
@ -206,14 +210,17 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess",
vmDefinition.currentCpus = vmDefinition.status.cpus;
vmDefinition.currentRam = Number(vmDefinition.status.ram);
vmDefinition.usedBy = vmDefinition.status.consoleClient || "";
for (const condition of vmDefinition.status.conditions) {
vmDefinition.status.conditions.forEach((condition: any) => {
if (condition.type === "Running") {
vmDefinition.running = condition.status === "True";
vmDefinition.runningConditionSince
= new Date(condition.lastTransitionTime);
break;
} else if (condition.type === "Booted") {
vmDefinition.booted = condition.status === "True";
vmDefinition.bootedConditionSince
= new Date(condition.lastTransitionTime);
}
}
})
} else {
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.VmExtraData;
import org.jdrupes.vmoperator.manager.events.ChannelTracker;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.ModifyVm;
import org.jdrupes.vmoperator.manager.events.PrepareConsole;
import org.jdrupes.vmoperator.manager.events.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
@ -483,17 +483,22 @@ public class VmMgmt extends FreeMarkerConlet<VmMgmt.VmsModel> {
Map.of("autoClose", 5_000, "type", "Warning")));
return;
}
var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef, user),
e -> {
vmDef.extra().map(xtra -> xtra.connectionFile(
e.password().orElse(null), preferredIpVersion,
deleteConnectionFile)).ifPresent(
cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
});
var pwQuery = Event.onCompletion(new PrepareConsole(vmDef, user),
e -> gotPassword(channel, model, vmDef, e));
fire(pwQuery, vmChannel);
}
private void gotPassword(ConsoleConnection channel, VmsModel model,
VmDefinition vmDef, PrepareConsole event) {
if (!event.passwordAvailable()) {
return;
}
vmDef.extra().map(xtra -> xtra.connectionFile(event.password(),
preferredIpVersion, deleteConnectionFile)).ifPresent(
cf -> channel.respond(new NotifyConletView(type(),
model.getConletId(), "openConsole", cf)));
}
@Override
protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
String conletId) throws Exception {