From c7b65ca581aa5eb8f6513ac05f4f4815d6ad1796 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 14:13:34 +0100 Subject: [PATCH 01/11] Report console connection events. --- deploy/crds/vms-crd.yaml | 11 +++ .../vmoperator/runner/qemu/StatusUpdater.java | 92 +++++++++++++++++++ .../runner/qemu/events/MonitorEvent.java | 14 ++- .../qemu/events/SpiceConnectedEvent.java | 37 ++++++++ .../qemu/events/SpiceDisconnectedEvent.java | 37 ++++++++ .../runner/qemu/events/SpiceEvent.java | 46 ++++++++++ 6 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index cda817c..492deae 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -1457,6 +1457,11 @@ spec: Amount of memory in use. type: string default: "0" + consoleClient: + description: >- + The hostname of the currently connected client. + type: string + default: "" displayPasswordSerial: description: >- Counts changes of the display password. Set to -1 @@ -1473,6 +1478,12 @@ spec: lastTransitionTime: "1970-01-01T00:00:00Z" reason: Creation message: "Creation of CR" + - type: ConsoleConnected + status: "False" + observedGeneration: 1 + lastTransitionTime: "1970-01-01T00:00:00Z" + reason: Creation + message: "Creation of CR" type: array items: type: object 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 412681f..bac272e 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 @@ -30,10 +30,13 @@ 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; @@ -50,6 +53,8 @@ import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; 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; @@ -363,4 +368,91 @@ public class StatusUpdater extends Component { 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/events/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java index ba04a26..2cc0f33 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 @@ -34,7 +34,8 @@ public class MonitorEvent extends Event { * The kind of monitor event. */ public enum Kind { - READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN + READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, + SPICE_CONNECTED, SPICE_DISCONNECTED } private final Kind kind; @@ -49,8 +50,7 @@ public class MonitorEvent extends Event { @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") public static Optional from(JsonNode response) { try { - var kind = MonitorEvent.Kind - .valueOf(response.get("event").asText()); + var kind = Kind.valueOf(response.get("event").asText()); switch (kind) { case POWERDOWN: return Optional.of(new PowerdownEvent(kind, null)); @@ -63,6 +63,14 @@ public class MonitorEvent extends Event { case SHUTDOWN: return Optional .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); + case SPICE_CONNECTED: + return Optional + .of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_DISCONNECTED: + 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/SpiceConnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java new file mode 100644 index 0000000..c133307 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java @@ -0,0 +1,37 @@ +/* + * 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 SpiceConnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceConnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java new file mode 100644 index 0000000..cfcb489 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java @@ -0,0 +1,37 @@ +/* + * 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 SpiceDisconnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice disconnected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceDisconnectedEvent(Kind kind, JsonNode data) { + super(kind, 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 new file mode 100644 index 0000000..6706f0c --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.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 SpiceEvent extends MonitorEvent { + + /** + * Instantiates a new tray moved. + * + * @param kind the kind + * @param data the data + */ + public SpiceEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the client's host. + * + * @return the client's host address + */ + public String clientHost() { + return data().get("client").get("host").asText(); + } +} From 12408143a7491489ed547ca23af5715b56bc548f Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 14:15:17 +0100 Subject: [PATCH 02/11] Fix warning. --- .../src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java | 1 + 1 file changed, 1 insertion(+) 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 bac272e..f6814b3 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 @@ -280,6 +280,7 @@ public class StatusUpdater extends Component { 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()) From e5fd45ebcba7120d2f68ff5e0a2539af69a71132 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 14:41:45 +0100 Subject: [PATCH 03/11] Show console client. --- .../resources/org/jdrupes/vmoperator/vmconlet/l10n.properties | 1 + .../org/jdrupes/vmoperator/vmconlet/l10n_de.properties | 1 + .../jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) 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 880369b..41bf670 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 @@ -11,5 +11,6 @@ nodeName = Node requestedCpus = Requested CPUs requestedRam = Requested RAM running = Running +usedBy = Used by 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 7e1d95e..819db03 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,6 +15,7 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM +usedBy = 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 44d6471..8daf3a9 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 @@ -111,7 +111,8 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["runningConditionSince", "since"], ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], - ["nodeName", "nodeName"] + ["nodeName", "nodeName"], + ["usedBy", "usedBy"] ], { sortKey: "name", sortOrder: "up" @@ -179,6 +180,7 @@ 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 || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; From 090d504b7757d3e2194fcf3c1ac69df23669efa1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Sun, 10 Nov 2024 17:10:08 +0100 Subject: [PATCH 04/11] Add in-use visualization. --- .../vmoperator/vmviewer/computer-in-use.svg | 86 +++++++++++++++++++ .../vmviewer/browser/VmViewer-functions.ts | 7 +- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg 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 new file mode 100644 index 0000000..90339c1 --- /dev/null +++ b/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-in-use.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts index a14e83c..42c7d10 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts +++ b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-functions.ts @@ -72,6 +72,7 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, previewApi.vmDefinition.spec.vm.state !== 'Stopped' && previewApi.vmDefinition.running); const running = computed(() => previewApi.vmDefinition.running); + const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); const permissions = computed(() => previewApi.vmDefinition.spec ? previewApi.vmDefinition.userPermissions : []); @@ -88,7 +89,7 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, }; return { localize, resourceBase, vmAction, configured, - startable, stoppable, running, permissions }; + startable, stoppable, running, inUse, permissions }; }, template: ` @@ -101,7 +102,8 @@ window.orgJDrupesVmOperatorVmViewer.initPreview = (previewDom: HTMLElement, || !permissions.includes('accessConsole')" v-on:click="vmAction('openConsole')" :src="resourceBase + (running - ? 'computer.svg' : 'computer-off.svg')" + ? (inUse ? 'computer-in-use.svg' : 'computer.svg') + : 'computer-off.svg')" :title="localize('Open console')"> @@ -159,6 +161,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmviewer.VmViewer", vmDefinition.name = vmDefinition.metadata.name; vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; From 97732073078bec55dcb2f3490a4cd4c1b34cb101 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 13 Nov 2024 11:14:53 +0100 Subject: [PATCH 05/11] Better name. --- .../resources/org/jdrupes/vmoperator/vmconlet/l10n.properties | 2 +- .../org/jdrupes/vmoperator/vmconlet/l10n_de.properties | 2 +- .../jdrupes/vmoperator/vmconlet/browser/VmConlet-functions.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) 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 41bf670..f4165c4 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 @@ -11,6 +11,6 @@ nodeName = Node 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 819db03..29239ed 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,7 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM -usedBy = Benutzt von +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 8daf3a9..b171569 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,7 +112,7 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], ["nodeName", "nodeName"], - ["usedBy", "usedBy"] + ["usedFrom", "usedFrom"] ], { sortKey: "name", sortOrder: "up" @@ -180,7 +180,7 @@ 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 || ""; for (const condition of vmDefinition.status.conditions) { if (condition.type === "Running") { vmDefinition.running = condition.status === "True"; From 4d447717c2a40a9e933bf95ab60921fa72e88366 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Wed, 13 Nov 2024 23:45:54 +0100 Subject: [PATCH 06/11] Improve tracking. --- .../runner/qemu/ConsoleTracker.java | 159 +++++++++++++++ .../vmoperator/runner/qemu/StatusUpdater.java | 183 ++---------------- .../vmoperator/runner/qemu/VmDefUpdater.java | 141 ++++++++++++++ .../runner/qemu/events/MonitorEvent.java | 15 +- .../runner/qemu/events/SpiceEvent.java | 9 + .../qemu/events/SpiceInitializedEvent.java | 46 +++++ 6 files changed, 375 insertions(+), 178 deletions(-) create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java create mode 100644 org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java 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 0000000..7d54235 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -0,0 +1,159 @@ +/* + * 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 final K8sClient apiClient; + 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(apiClient, from, status, "ConsoleConnected", + true, "Connection from " + event.clientHost(), null); + 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(apiClient, from, status, "ConsoleConnected", + false, event.clientHost() + " has disconnected", null); + 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 f6814b3..ca5d46a 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,13 @@ import io.kubernetes.client.openapi.ApiException; 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,28 +44,21 @@ import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; 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; @@ -98,6 +82,7 @@ public class StatusUpdater extends Component { () -> "Cannot access events API, terminating."); fire(new Exit(1)); } + attach(new ConsoleTracker(componentChannel)); } /** @@ -114,43 +99,6 @@ public class StatusUpdater extends Component { } } - /** - * 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 +186,9 @@ public class StatusUpdater extends Component { } 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(apiClient, 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 +197,13 @@ public class StatusUpdater extends Component { status.addProperty("ram", "0"); status.addProperty("cpus", 0); } + + // In case console connection was still present + if (!running) { + status.addProperty("consoleClient", ""); + updateCondition(apiClient, from, status, "ConsoleConnected", + false, "VM has stopped", null); + } return status; }); @@ -278,29 +229,6 @@ public class StatusUpdater extends Component { 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 +297,4 @@ public class StatusUpdater extends Component { 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 0000000..893fc61 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java @@ -0,0 +1,141 @@ +/* + * 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.openapi.ApiClient; +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.VmDefinitionModel; +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; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public VmDefUpdater(Channel componentChannel) { + super(componentChannel); + } + + /** + * 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(ApiClient apiClient, 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 2cc0f33..df981c8 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 class MonitorEvent extends Event { 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 6706f0c..4ce27e2 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 class SpiceEvent extends MonitorEvent { 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 0000000..7bb84b7 --- /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(); + } +} From f1d973502ddf819638e3e543638acd2e2a9fa3af Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 11:58:51 +0100 Subject: [PATCH 07/11] Fix transparency. --- .../vmoperator/vmviewer/computer-in-use.svg | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 90339c1..00e4cc0 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" /> + From 811164f7b9c34c9e26fb19211b67cc48e69e6503 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 11:59:14 +0100 Subject: [PATCH 08/11] Move api client to base class. --- .../runner/qemu/ConsoleTracker.java | 5 ++--- .../vmoperator/runner/qemu/StatusUpdater.java | 19 ++++------------- .../vmoperator/runner/qemu/VmDefUpdater.java | 21 +++++++++++++++---- 3 files changed, 23 insertions(+), 22 deletions(-) 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 index 7d54235..95b748c 100644 --- 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 @@ -44,7 +44,6 @@ import org.jgrapes.core.events.Start; @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class ConsoleTracker extends VmDefUpdater { - private final K8sClient apiClient; private VmDefinitionStub vmStub; private String mainChannelClientHost; private long mainChannelClientPort; @@ -109,7 +108,7 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", event.clientHost()); - updateCondition(apiClient, from, status, "ConsoleConnected", + updateCondition(from, status, "ConsoleConnected", true, "Connection from " + event.clientHost(), null); return status; }); @@ -144,7 +143,7 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", ""); - updateCondition(apiClient, from, status, "ConsoleConnected", + updateCondition(from, status, "ConsoleConnected", false, event.clientHost() + " has disconnected", null); return status; }); 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 ca5d46a..f663476 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 @@ -33,7 +33,6 @@ 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.VmDefinitionModel; import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; @@ -59,7 +58,6 @@ public class StatusUpdater extends VmDefUpdater { private static final Set RUNNING_STATES = Set.of(RunState.RUNNING, RunState.TERMINATING); - private K8sClient apiClient; private long observedGeneration; private boolean guestShutdownStops; private boolean shutdownByGuest; @@ -73,15 +71,6 @@ public class StatusUpdater extends VmDefUpdater { @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)); } @@ -187,8 +176,8 @@ public class StatusUpdater extends VmDefUpdater { vmStub.updateStatus(vmDef, from -> { JsonObject status = from.status(); boolean running = RUNNING_STATES.contains(event.runState()); - updateCondition(apiClient, vmDef, vmDef.status(), "Running", - running, event.reason(), event.message()); + 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")); @@ -201,8 +190,8 @@ public class StatusUpdater extends VmDefUpdater { // In case console connection was still present if (!running) { status.addProperty("consoleClient", ""); - updateCondition(apiClient, from, status, "ConsoleConnected", - false, "VM has stopped", null); + updateCondition(from, status, "ConsoleConnected", false, + "VM has stopped", null); } return status; }); 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 index 893fc61..1c260c7 100644 --- 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 @@ -19,7 +19,6 @@ package org.jdrupes.vmoperator.runner.qemu; import com.google.gson.JsonObject; -import io.kubernetes.client.openapi.ApiClient; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -31,7 +30,9 @@ 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; @@ -46,15 +47,28 @@ 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)); + } + } } /** @@ -104,9 +118,8 @@ public class VmDefUpdater extends Component { * @param state the new state * @param reason the reason for the change */ - protected void updateCondition(ApiClient apiClient, VmDefinitionModel from, - JsonObject status, String type, boolean state, String reason, - String message) { + 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) From 4ea568ea17ec338897b192ae0b3cd5c22a9cded1 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 12:40:45 +0100 Subject: [PATCH 09/11] Automatically repeat status update in case of conflict. --- .../vmoperator/common/K8sGenericStub.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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 09516a0..0689a97 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 class K8sGenericStub 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 class K8sGenericStub updateStatus(O object, Function status) throws ApiException { - return K8s.optional(api.updateStatus(object, status)); + return updateStatus(object, status, 16); } /** From 0ba8d922ef486854da01603410952506a7755635 Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 18:46:19 +0100 Subject: [PATCH 10/11] Add used by information. --- deploy/crds/vms-crd.yaml | 6 ++++++ .../manager/events/GetDisplayPassword.java | 16 ++++++++++++++-- .../manager/DisplaySecretMonitor.java | 17 ++++++++++++++++- .../jdrupes/vmoperator/vmconlet/l10n.properties | 1 + .../vmoperator/vmconlet/l10n_de.properties | 1 + .../vmconlet/browser/VmConlet-functions.ts | 4 +++- .../jdrupes/vmoperator/vmviewer/VmViewer.java | 9 ++++++--- 7 files changed, 47 insertions(+), 7 deletions(-) diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index f1bbaf2..93c70cc 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.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 3322f1a..f6fa555 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 @@ import org.jgrapes.core.Event; 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 class GetDisplayPassword extends Event { 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 69d4058..2f480a3 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.Optional; 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 class DisplaySecretMonitor + "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.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties index f4165c4..4ab3a3f 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 @@ -11,6 +11,7 @@ nodeName = Node 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 29239ed..15a8b68 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,6 +15,7 @@ maximumRam = Maximales RAM nodeName = Knoten requestedCpus = Angeforderte CPUs requestedRam = Angefordertes RAM +usedBy = Benutzt durch usedFrom = Benutzt von vmActions = Aktionen vmname = Name 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 b171569..cfda2de 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,7 +112,8 @@ window.orgJDrupesVmOperatorVmConlet.initView = (viewDom: HTMLElement, ["currentCpus", "currentCpus"], ["currentRam", "currentRam"], ["nodeName", "nodeName"], - ["usedFrom", "usedFrom"] + ["usedFrom", "usedFrom"], + ["usedBy", "usedBy"] ], { sortKey: "name", sortOrder: "up" @@ -181,6 +182,7 @@ JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmconlet.VmConlet", vmDefinition.currentCpus = vmDefinition.status.cpus; vmDefinition.currentRam = Number(vmDefinition.status.ram); 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/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java b/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewer.java index a21c420..f87b341 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 @@ public class VmViewer extends FreeMarkerConlet { 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; From 69507b540cb4c753616b43872e8ef6c73aded85e Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Thu, 14 Nov 2024 20:17:33 +0100 Subject: [PATCH 11/11] Improve messages. --- .../jdrupes/vmoperator/runner/qemu/ConsoleTracker.java | 8 ++++---- .../org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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 index 95b748c..f2309df 100644 --- 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 @@ -108,8 +108,8 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", event.clientHost()); - updateCondition(from, status, "ConsoleConnected", - true, "Connection from " + event.clientHost(), null); + updateCondition(from, status, "ConsoleConnected", true, "Connected", + "Connection from " + event.clientHost()); return status; }); @@ -143,8 +143,8 @@ public class ConsoleTracker extends VmDefUpdater { vmStub.updateStatus(from -> { JsonObject status = from.status(); status.addProperty("consoleClient", ""); - updateCondition(from, status, "ConsoleConnected", - false, event.clientHost() + " has disconnected", null); + updateCondition(from, status, "ConsoleConnected", false, + "Disconnected", event.clientHost() + " has disconnected"); return status; }); 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 f663476..0b18df0 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 @@ -191,7 +191,7 @@ public class StatusUpdater extends VmDefUpdater { if (!running) { status.addProperty("consoleClient", ""); updateCondition(from, status, "ConsoleConnected", false, - "VM has stopped", null); + "VmStopped", "The VM has been shut down"); } return status; });