diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index f1bbaf25c..93c70ccc6 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1467,6 +1467,12 @@ spec: The hostname of the currently connected client. type: string default: "" + consoleUser: + description: >- + The id of the user who has last requested a console + connection. + type: string + default: "" displayPasswordSerial: description: >- Counts changes of the display password. Set to -1 diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java index 09516a04b..0689a97a5 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java @@ -193,7 +193,33 @@ public Optional model() throws ApiException { } /** - * Updates the object's status. + * Updates the object's status, retrying for the given number of times + * if the update fails due to a conflict. + * + * @param object the current state of the object (passed to `status`) + * @param status function that returns the new status + * @param retries the retries + * @return the updated model or empty if not successful + * @throws ApiException the api exception + */ + @SuppressWarnings("PMD.AssignmentInOperand") + public Optional updateStatus(O object, + Function status, int retries) throws ApiException { + while (true) { + try { + return K8s.optional(api.updateStatus(object, status)); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } + } + } + } + + /** + * Updates the object's status, retrying up to 16 times if there + * is a conflict. * * @param object the current state of the object (passed to `status`) * @param status function that returns the new status @@ -202,7 +228,7 @@ public Optional model() throws ApiException { */ public Optional updateStatus(O object, Function status) throws ApiException { - return K8s.optional(api.updateStatus(object, status)); + return updateStatus(object, status, 16); } /** diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java index 3322f1a4b..f6fa555cb 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java @@ -29,14 +29,17 @@ public class GetDisplayPassword extends Event { private final VmDefinition vmDef; + private final String user; /** - * Instantiates a new returns the display secret. + * Instantiates a new request for the display secret. * * @param vmDef the vm name + * @param user the requesting user */ - public GetDisplayPassword(VmDefinition vmDef) { + public GetDisplayPassword(VmDefinition vmDef, String user) { this.vmDef = vmDef; + this.user = user; } /** @@ -48,6 +51,15 @@ 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. * diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java index 69d40586e..2f480a32d 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -18,6 +18,8 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1Secret; @@ -37,10 +39,13 @@ import java.util.Scanner; import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinitionStub; 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; @@ -181,12 +186,22 @@ public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) + "app.kubernetes.io/instance=" + event.vmDefinition().metadata().getName()); var stubs = K8sV1SecretStub.list(client(), - event.vmDefinition().metadata().getNamespace(), options); + event.vmDefinition().namespace(), options); if (stubs.isEmpty()) { return; } var stub = stubs.iterator().next(); + // Valid request, 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 -> { + JsonObject status = from.status(); + status.addProperty("consoleUser", event.user()); + return status; + }); + // Check validity var model = stub.model().get(); @SuppressWarnings("PMD.StringInstantiation") diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java new file mode 100644 index 000000000..f2309df95 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -0,0 +1,158 @@ +/* + * 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 . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.EventsV1Event; +import java.io.IOException; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceInitializedEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; + +/** + * A (sub)component that updates the console status in the CR status. + * Created as child of {@link StatusUpdater}. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class ConsoleTracker extends VmDefUpdater { + + private VmDefinitionStub vmStub; + private String mainChannelClientHost; + private long mainChannelClientPort; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public ConsoleTracker(Channel componentChannel) { + super(componentChannel); + apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration + .getDefaultApiClient(); + } + + /** + * Handle the start event. + * + * @param event the event + * @throws IOException + * @throws ApiException + */ + @Handler + public void onStart(Start event) { + if (namespace == null) { + return; + } + try { + vmStub = VmDefinitionStub.get(apiClient, + new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + namespace, vmName); + } catch (ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access VM object, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + /** + * On spice connected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidDuplicateLiterals" }) + public void onSpiceInitialized(SpiceInitializedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process connections using main channel. + if (event.channelType() != 1) { + return; + } + mainChannelClientHost = event.clientHost(); + mainChannelClientPort = event.clientPort(); + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", event.clientHost()); + updateCondition(from, status, "ConsoleConnected", true, "Connected", + "Connection from " + event.clientHost()); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Connection from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + /** + * On spice disconnected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + public void onSpiceDisconnected(SpiceDisconnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process disconnects from main channel. + if (!event.clientHost().equals(mainChannelClientHost) + || event.clientPort() != mainChannelClientPort) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.status(); + status.addProperty("consoleClient", ""); + updateCondition(from, status, "ConsoleConnected", false, + "Disconnected", event.clientHost() + " has disconnected"); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Disconnected from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java index f6814b326..0b18df094 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -27,22 +27,12 @@ import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.logging.Level; -import java.util.stream.Collectors; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; @@ -53,29 +43,21 @@ import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; -import org.jdrupes.vmoperator.runner.qemu.events.SpiceConnectedEvent; -import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.InitialConfiguration; /** * Updates the CR status. */ @SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class StatusUpdater extends Component { +public class StatusUpdater extends VmDefUpdater { private static final Set RUNNING_STATES = Set.of(RunState.RUNNING, RunState.TERMINATING); - private String namespace; - private String vmName; - private K8sClient apiClient; private long observedGeneration; private boolean guestShutdownStops; private boolean shutdownByGuest; @@ -89,15 +71,7 @@ public class StatusUpdater extends Component { @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public StatusUpdater(Channel componentChannel) { super(componentChannel); - try { - apiClient = new K8sClient(); - io.kubernetes.client.openapi.Configuration - .setDefaultApiClient(apiClient); - } catch (IOException e) { - logger.log(Level.SEVERE, e, - () -> "Cannot access events API, terminating."); - fire(new Exit(1)); - } + attach(new ConsoleTracker(componentChannel)); } /** @@ -114,43 +88,6 @@ public void onHandlingError(HandlingError event) { } } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @SuppressWarnings("unchecked") - public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured("/Runner").ifPresent(c -> { - if (event instanceof InitialConfiguration) { - namespace = (String) c.get("namespace"); - updateNamespace(); - vmName = Optional.ofNullable((Map) c.get("vm")) - .map(vm -> vm.get("name")).orElse(null); - } - }); - } - - private void updateNamespace() { - if (namespace == null) { - var path = Path - .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); - if (Files.isReadable(path)) { - try { - namespace = Files.lines(path).findFirst().orElse(null); - } catch (IOException e) { - logger.log(Level.WARNING, e, - () -> "Cannot read namespace."); - } - } - } - if (namespace == null) { - logger.warning(() -> "Namespace is unknown, some functions" - + " won't be available."); - } - } - /** * Handle the start event. * @@ -238,13 +175,9 @@ public void onRunnerStateChanged(RunnerStateChange event) } vmStub.updateStatus(vmDef, from -> { JsonObject status = from.status(); - status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .forEach(cond -> { - if ("Running".equals(cond.get("type").getAsString())) { - updateRunningCondition(event, from, cond); - } - }); + boolean running = RUNNING_STATES.contains(event.runState()); + updateCondition(vmDef, vmDef.status(), "Running", running, + event.reason(), event.message()); if (event.runState() == RunState.STARTING) { status.addProperty("ram", GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); @@ -253,6 +186,13 @@ public void onRunnerStateChanged(RunnerStateChange event) status.addProperty("ram", "0"); status.addProperty("cpus", 0); } + + // In case console connection was still present + if (!running) { + status.addProperty("consoleClient", ""); + updateCondition(from, status, "ConsoleConnected", false, + "VmStopped", "The VM has been shut down"); + } return status; }); @@ -278,29 +218,6 @@ public void onRunnerStateChanged(RunnerStateChange event) K8s.createEvent(apiClient, vmDef, evt); } - private void updateRunningCondition(RunnerStateChange event, - K8sDynamicModel from, JsonObject cond) { - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - boolean reportedRunning - = "True".equals(cond.get("status").getAsString()); - if (RUNNING_STATES.contains(event.runState()) - && !reportedRunning) { - cond.addProperty("status", "True"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); - } - if (!RUNNING_STATES.contains(event.runState()) - && reportedRunning) { - cond.addProperty("status", "False"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); - } - cond.addProperty("reason", event.reason()); - cond.addProperty("message", event.message()); - cond.addProperty("observedGeneration", - from.getMetadata().getGeneration()); - } - /** * On ballon change. * @@ -369,91 +286,4 @@ public void onDisplayPasswordChanged(DisplayPasswordChanged event) public void onShutdown(ShutdownEvent event) throws ApiException { shutdownByGuest = event.byGuest(); } - - /** - * On spice connected. - * - * @param event the event - * @throws ApiException the api exception - */ - @Handler - public void onSpiceConnected(SpiceConnectedEvent event) - throws ApiException { - if (vmStub == null) { - return; - } - vmStub.updateStatus(from -> { - JsonObject status = from.status(); - status.addProperty("consoleClient", event.clientHost()); - updateConsoleConnectedCondition(from, status, true); - return status; - }); - - // Log event - var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) - .action("ConsoleConnectionUpdate") - .reason("Connection from " + event.clientHost()); - K8s.createEvent(apiClient, vmStub.model().get(), evt); - } - - /** - * On spice disconnected. - * - * @param event the event - * @throws ApiException the api exception - */ - @Handler - public void onSpiceDisconnected(SpiceDisconnectedEvent event) - throws ApiException { - if (vmStub == null) { - return; - } - vmStub.updateStatus(from -> { - JsonObject status = from.status(); - status.addProperty("consoleClient", ""); - updateConsoleConnectedCondition(from, status, false); - return status; - }); - - // Log event - var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) - .action("ConsoleConnectionUpdate") - .reason("Disconnected from " + event.clientHost()); - K8s.createEvent(apiClient, vmStub.model().get(), evt); - } - - private void updateConsoleConnectedCondition(VmDefinitionModel from, - JsonObject status, boolean connected) { - // Optimize, as we can get this several times - var current = status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .filter(cond -> "ConsoleConnected" - .equals(cond.get("type").getAsString())) - .findFirst() - .map(cond -> "True".equals(cond.get("status").getAsString())); - if (current.isPresent() && current.get() == connected) { - return; - } - - // Do update - final var condition = Map.of("type", "ConsoleConnected", - "status", connected ? "True" : "False", - "observedGeneration", from.getMetadata().getGeneration(), - "reason", connected ? "Connected" : "Disconnected", - "lastTransitionTime", Instant.now().toString()); - List toReplace = new ArrayList<>(List.of(condition)); - List newConds - = status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .map(cond -> "ConsoleConnected" - .equals(cond.get("type").getAsString()) - ? toReplace.remove(0) - : cond) - .collect(Collectors.toCollection(() -> new ArrayList<>())); - newConds.addAll(toReplace); - status.add("conditions", - apiClient.getJSON().getGson().toJsonTree(newConds)); - } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java new file mode 100644 index 000000000..1c260c712 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java @@ -0,0 +1,154 @@ +/* + * 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 . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.InitialConfiguration; + +/** + * Updates the CR status. + */ +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class VmDefUpdater extends Component { + + protected String namespace; + protected String vmName; + protected K8sClient apiClient; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + * @throws IOException + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public VmDefUpdater(Channel componentChannel) { + super(componentChannel); + if (apiClient == null) { + try { + apiClient = new K8sClient(); + io.kubernetes.client.openapi.Configuration + .setDefaultApiClient(apiClient); + } catch (IOException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access events API, terminating."); + fire(new Exit(1)); + } + } + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + @SuppressWarnings("unchecked") + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured("/Runner").ifPresent(c -> { + if (event instanceof InitialConfiguration) { + namespace = (String) c.get("namespace"); + updateNamespace(); + vmName = Optional.ofNullable((Map) c.get("vm")) + .map(vm -> vm.get("name")).orElse(null); + } + }); + } + + private void updateNamespace() { + if (namespace == null) { + var path = Path + .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); + if (Files.isReadable(path)) { + try { + namespace = Files.lines(path).findFirst().orElse(null); + } catch (IOException e) { + logger.log(Level.WARNING, e, + () -> "Cannot read namespace."); + } + } + } + if (namespace == null) { + logger.warning(() -> "Namespace is unknown, some functions" + + " won't be available."); + } + } + + /** + * Update condition. + * + * @param apiClient the api client + * @param from the vM definition + * @param status the current status + * @param type the condition type + * @param state the new state + * @param reason the reason for the change + */ + protected void updateCondition(VmDefinitionModel from, JsonObject status, + String type, boolean state, String reason, String message) { + // Optimize, as we can get this several times + 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) { + return; + } + + // Do update + final var condition = new HashMap<>(Map.of("type", type, + "status", state ? "True" : "False", + "observedGeneration", from.getMetadata().getGeneration(), + "reason", reason, + "lastTransitionTime", Instant.now().toString())); + if (message != null) { + condition.put("message", message); + } + List toReplace = new ArrayList<>(List.of(condition)); + List newConds + = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .map(cond -> type.equals(cond.get("type").getAsString()) + ? toReplace.remove(0) + : cond) + .collect(Collectors.toCollection(() -> new ArrayList<>())); + newConds.addAll(toReplace); + status.add("conditions", + apiClient.getJSON().getGson().toJsonTree(newConds)); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java index 2cc0f337a..df981c88f 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java @@ -35,7 +35,7 @@ public class MonitorEvent extends Event { */ public enum Kind { READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, - SPICE_CONNECTED, SPICE_DISCONNECTED + SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED } private final Kind kind; @@ -64,13 +64,14 @@ public static Optional from(JsonNode response) { return Optional .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); case SPICE_CONNECTED: - return Optional - .of(new SpiceConnectedEvent(kind, - response.get(EVENT_DATA))); + return Optional.of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_INITIALIZED: + return Optional.of(new SpiceInitializedEvent(kind, + response.get(EVENT_DATA))); case SPICE_DISCONNECTED: - return Optional - .of(new SpiceDisconnectedEvent(kind, - response.get(EVENT_DATA))); + return Optional.of(new SpiceDisconnectedEvent(kind, + response.get(EVENT_DATA))); default: return Optional .of(new MonitorEvent(kind, response.get(EVENT_DATA))); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java index 6706f0c74..4ce27e2e7 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java @@ -43,4 +43,13 @@ public SpiceEvent(Kind kind, JsonNode data) { public String clientHost() { return data().get("client").get("host").asText(); } + + /** + * Returns the client's port. + * + * @return the client's port number + */ + public long clientPort() { + return data().get("client").get("port").asLong(); + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java new file mode 100644 index 000000000..7bb84b742 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java @@ -0,0 +1,46 @@ +/* + * VM-Operator + * Copyright (C) 2023 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 . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceInitializedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceInitializedEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the channel type. + * + * @return the channel type + */ + public int channelType() { + return data().get("client").get("channel-type").asInt(); + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties index 41bf670c7..4ab3a3f19 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties @@ -12,5 +12,6 @@ requestedCpus = Requested CPUs requestedRam = Requested RAM running = Running usedBy = Used by +usedFrom = Used from vmActions = Actions vmname = Name diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties index 819db0380..15a8b682d 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties +++ b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties @@ -15,7 +15,8 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM -usedBy = Benutzt von +usedBy = Benutzt durch +usedFrom = Benutzt von vmActions = Aktionen vmname = Name Value\ is\ above\ maximum = Wert ist zu groß diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts index 8daf3a9e3..cfda2de2c 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts +++ b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts @@ -112,6 +112,7 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], ["nodeName", "nodeName"], + ["usedFrom", "usedFrom"], ["usedBy", "usedBy"] ], { sortKey: "name", @@ -180,7 +181,8 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet", vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); - vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; + vmDefinition.usedFrom = vmDefinition.status.consoleClient || ""; + vmDefinition.usedBy = vmDefinition.status.consoleUser || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg index 90339c1ef..00e4cc0f2 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg +++ b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg @@ -51,9 +51,9 @@ inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" - inkscape:zoom="1.28" - inkscape:cx="326.5625" - inkscape:cy="548.04688" + inkscape:zoom="0.90509668" + inkscape:cx="345.81941" + inkscape:cy="376.2029" inkscape:window-width="1920" inkscape:window-height="1008" inkscape:window-x="0" @@ -63,18 +63,17 @@ - + + 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" /> + diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index a21c4203c..f87b341f0 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java @@ -527,9 +527,12 @@ protected void doUpdateConletState(NotifyConletModel event, break; case "openConsole": if (perms.contains(Permission.ACCESS_CONSOLE)) { - var pwQuery = Event.onCompletion(new GetDisplayPassword(vmDef), - e -> openConsole(vmName, channel, model, - e.password().orElse(null))); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + var pwQuery + = Event.onCompletion(new GetDisplayPassword(vmDef, user), + e -> openConsole(vmName, channel, model, + e.password().orElse(null))); fire(pwQuery, vmChannel); } break;